├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── 기능-구현-이슈.md │ └── 버그-이슈.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── cd.yml │ ├── ci.yml │ └── d-day-labeler.yml ├── .gitignore ├── .gitmodules ├── README.md ├── appspec.yml ├── build.gradle ├── config └── checkstyle │ └── checkstyle.xml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── scripts └── gh_deploy.sh ├── settings.gradle └── src ├── main ├── java │ └── balancetalk │ │ ├── BalanceTalkApplication.java │ │ ├── authmail │ │ ├── application │ │ │ └── MailService.java │ │ ├── dto │ │ │ └── EmailDto.java │ │ └── presentation │ │ │ └── MailController.java │ │ ├── bookmark │ │ ├── application │ │ │ ├── BookmarkGameService.java │ │ │ └── BookmarkTalkPickService.java │ │ ├── domain │ │ │ ├── BookmarkGenerator.java │ │ │ ├── GameBookmark.java │ │ │ ├── GameBookmarkRepository.java │ │ │ ├── TalkPickBookmark.java │ │ │ └── TalkPickBookmarkRepository.java │ │ └── presentation │ │ │ ├── BookmarkGameController.java │ │ │ └── BookmarkTalkPickController.java │ │ ├── comment │ │ ├── application │ │ │ └── CommentService.java │ │ ├── domain │ │ │ ├── Comment.java │ │ │ └── CommentRepository.java │ │ ├── dto │ │ │ └── CommentDto.java │ │ └── presentation │ │ │ └── CommentController.java │ │ ├── file │ │ ├── application │ │ │ └── FileService.java │ │ ├── domain │ │ │ ├── File.java │ │ │ ├── FileHandler.java │ │ │ ├── FileProcessor.java │ │ │ ├── FileType.java │ │ │ ├── MultipartFiles.java │ │ │ └── repository │ │ │ │ ├── FileRepository.java │ │ │ │ ├── FileRepositoryCustom.java │ │ │ │ └── FileRepositoryImpl.java │ │ ├── dto │ │ │ └── UploadFileResponse.java │ │ └── presentation │ │ │ └── FileController.java │ │ ├── friends │ │ ├── application │ │ │ └── FriendsService.java │ │ ├── domain │ │ │ ├── Friends.java │ │ │ └── FriendsRepository.java │ │ ├── dto │ │ │ └── FriendsDto.java │ │ └── presentation │ │ │ └── FriendsController.java │ │ ├── game │ │ ├── application │ │ │ ├── GameService.java │ │ │ ├── SearchGameService.java │ │ │ └── TempGameService.java │ │ ├── domain │ │ │ ├── Game.java │ │ │ ├── GameOption.java │ │ │ ├── GameReader.java │ │ │ ├── GameSet.java │ │ │ ├── MainTag.java │ │ │ ├── TempGame.java │ │ │ ├── TempGameOption.java │ │ │ ├── TempGameSet.java │ │ │ └── repository │ │ │ │ ├── GameRepository.java │ │ │ │ ├── GameSetRepository.java │ │ │ │ ├── GameSetRepositoryCustom.java │ │ │ │ ├── GameSetRepositoryCustomImpl.java │ │ │ │ ├── MainTagRepository.java │ │ │ │ ├── TempGameRepository.java │ │ │ │ └── TempGameSetRepository.java │ │ ├── dto │ │ │ ├── GameDto.java │ │ │ ├── GameOptionDto.java │ │ │ ├── GameSetDto.java │ │ │ ├── SearchGameResponse.java │ │ │ ├── TempGameDto.java │ │ │ ├── TempGameOptionDto.java │ │ │ └── TempGameSetDto.java │ │ └── presentation │ │ │ ├── GameController.java │ │ │ ├── GameTagController.java │ │ │ ├── SearchGameController.java │ │ │ └── TempGameController.java │ │ ├── global │ │ ├── caffeine │ │ │ ├── CacheConfig.java │ │ │ └── CacheType.java │ │ ├── common │ │ │ └── BaseTimeEntity.java │ │ ├── config │ │ │ ├── AsyncConfig.java │ │ │ ├── MySQLFunctionContributor.java │ │ │ ├── OpenaiConfig.java │ │ │ ├── QuerydslConfig.java │ │ │ ├── SecurityConfig.java │ │ │ ├── SwaggerConfig.java │ │ │ └── WebConfig.java │ │ ├── exception │ │ │ ├── BalanceTalkException.java │ │ │ ├── CustomAsyncUncaughtExceptionHandler.java │ │ │ ├── ErrorCode.java │ │ │ ├── ErrorResponse.java │ │ │ └── GlobalExceptionHandler.java │ │ ├── jwt │ │ │ ├── ApiMemberArgumentResolver.java │ │ │ ├── CustomLogoutHandler.java │ │ │ ├── CustomSuccessHandler.java │ │ │ ├── GuestOrApiMemberArgumentResolver.java │ │ │ ├── JwtAccessDeniedHandler.java │ │ │ ├── JwtAuthenticationEntryPoint.java │ │ │ ├── JwtAuthenticationFilter.java │ │ │ └── JwtTokenProvider.java │ │ ├── notification │ │ │ ├── application │ │ │ │ └── NotificationService.java │ │ │ ├── domain │ │ │ │ ├── Notification.java │ │ │ │ ├── NotificationHistory.java │ │ │ │ ├── NotificationMessage.java │ │ │ │ ├── NotificationRepository.java │ │ │ │ ├── NotificationStandard.java │ │ │ │ └── NotificationTitleCategory.java │ │ │ ├── dto │ │ │ │ └── NotificationDto.java │ │ │ └── presentation │ │ │ │ └── NotificationController.java │ │ ├── oauth2 │ │ │ ├── dto │ │ │ │ ├── CustomOAuth2User.java │ │ │ │ ├── GoogleResponse.java │ │ │ │ ├── KakaoResponse.java │ │ │ │ ├── NaverResponse.java │ │ │ │ ├── Oauth2Dto.java │ │ │ │ └── Oauth2Response.java │ │ │ └── service │ │ │ │ └── CustomOAuth2UserService.java │ │ └── utils │ │ │ ├── AuthPrincipal.java │ │ │ ├── LoggingAspect.java │ │ │ ├── QuerydslUtils.java │ │ │ └── SecurityUtils.java │ │ ├── like │ │ ├── application │ │ │ └── CommentLikeService.java │ │ ├── domain │ │ │ ├── Like.java │ │ │ ├── LikeRepository.java │ │ │ └── LikeType.java │ │ ├── dto │ │ │ └── LikeDto.java │ │ └── presentation │ │ │ └── LikeController.java │ │ ├── member │ │ ├── application │ │ │ ├── MemberService.java │ │ │ ├── MyPageService.java │ │ │ └── MyUserDetailService.java │ │ ├── domain │ │ │ ├── CustomUserDetails.java │ │ │ ├── Member.java │ │ │ ├── MemberRepository.java │ │ │ ├── Role.java │ │ │ └── SignupType.java │ │ ├── dto │ │ │ ├── ApiMember.java │ │ │ ├── GuestOrApiMember.java │ │ │ └── MemberDto.java │ │ └── presentation │ │ │ ├── MemberController.java │ │ │ ├── MyPageController.java │ │ │ └── Oauth2Controller.java │ │ ├── report │ │ ├── application │ │ │ └── ReportCommentService.java │ │ ├── domain │ │ │ ├── Report.java │ │ │ ├── ReportReason.java │ │ │ ├── ReportRepository.java │ │ │ └── ReportType.java │ │ ├── dto │ │ │ └── ReportDto.java │ │ └── presentation │ │ │ └── ReportController.java │ │ ├── talkpick │ │ ├── application │ │ │ ├── SearchTalkPickService.java │ │ │ ├── TalkPickFileService.java │ │ │ ├── TalkPickScheduleService.java │ │ │ ├── TalkPickService.java │ │ │ ├── TalkPickSummaryService.java │ │ │ ├── TempTalkPickService.java │ │ │ └── TodayTalkPickService.java │ │ ├── domain │ │ │ ├── Summary.java │ │ │ ├── SummaryStatus.java │ │ │ ├── TalkPick.java │ │ │ ├── TalkPickImage.java │ │ │ ├── TalkPickReader.java │ │ │ ├── TempTalkPick.java │ │ │ ├── TodayTalkPick.java │ │ │ ├── ViewStatus.java │ │ │ ├── event │ │ │ │ ├── TalkPickCreatedEvent.java │ │ │ │ ├── TalkPickDeletedEvent.java │ │ │ │ ├── TalkPickEventHandler.java │ │ │ │ └── TalkPickUpdatedEvent.java │ │ │ └── repository │ │ │ │ ├── SearchTalkPickRepository.java │ │ │ │ ├── SearchTalkPickRepositoryCustom.java │ │ │ │ ├── SearchTalkPickRepositoryImpl.java │ │ │ │ ├── TalkPickRepository.java │ │ │ │ ├── TalkPickRepositoryCustom.java │ │ │ │ ├── TalkPickRepositoryImpl.java │ │ │ │ ├── TempTalkPickRepository.java │ │ │ │ └── TodayTalkPickRepository.java │ │ ├── dto │ │ │ ├── SearchTalkPickResponse.java │ │ │ ├── SummaryResponse.java │ │ │ ├── TalkPickDto.java │ │ │ ├── TempTalkPickDto.java │ │ │ ├── TodayTalkPickDto.java │ │ │ └── fields │ │ │ │ ├── BaseTalkPickFields.java │ │ │ │ └── ValidatedNotBlankTalkPickFields.java │ │ └── presentation │ │ │ ├── SearchTalkPickController.java │ │ │ ├── TalkPickController.java │ │ │ ├── TempTalkPickController.java │ │ │ └── TodayTalkPickController.java │ │ └── vote │ │ ├── application │ │ ├── VoteGameService.java │ │ └── VoteTalkPickService.java │ │ ├── domain │ │ ├── GameVote.java │ │ ├── TalkPickVote.java │ │ ├── TalkPickVoteRepository.java │ │ ├── VoteOption.java │ │ └── VoteRepository.java │ │ ├── dto │ │ ├── VoteGameDto.java │ │ └── VoteTalkPickDto.java │ │ └── presentation │ │ ├── VoteGameController.java │ │ └── VoteTalkPickController.java └── resources │ ├── META-INF │ └── services │ │ └── org.hibernate.boot.model.FunctionContributor │ └── logs │ ├── log4j2-dev.yml │ ├── log4j2-local.yml │ └── log4j2-prod.yml └── test └── java └── balancetalk ├── BalanceTalkApplicationTests.java ├── bookmark ├── application │ └── TalkPickBookmarkServiceTest.java └── domain │ └── BookmarkTalkPickGeneratorTest.java ├── file └── domain │ └── FileProcessorTest.java ├── member └── application │ └── MemberServiceTest.java ├── talkpick ├── application │ └── TalkPickServiceTest.java └── domain │ └── TalkPickReaderTest.java └── vote ├── application └── VoteTalkPickServiceTest.java └── domain └── VoteTest.java /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | *.java @Hanjaemo @gywns0417 @jschoi-96 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/기능-구현-이슈.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 기능 구현 이슈 3 | about: 기능 구현 관련 이슈입니다. 4 | title: '' 5 | labels: "\U0001F468\U0001F3FB‍\U0001F4BB backend" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 💡 구현 기능 11 | 구현할 기능을 1-2문장으로 요약해주세요. 12 | 13 | ## 🔆 참고 사항 (선택) 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/버그-이슈.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 버그 이슈 3 | about: 버그 관련 이슈입니다. 4 | title: '' 5 | labels: "\U0001F468\U0001F3FB‍\U0001F4BB backend, \U0001F41B bug" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🐞 버그 내용 11 | 어떤 버그인지 간단하게 설명해주세요. 12 | 13 | ## 🚨 버그 발생 과정 14 | 어떤 과정을 통해 버그가 발생했는지 자세하게 알려주세요. 15 | 16 | ## 📸 스크린샷 (선택) 17 | 18 | ## 🔆 참고 사항 (선택) 19 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 💡 작업 내용 2 | - [ ] 작업 내용 3 | 4 | ## 💡 자세한 설명 5 | (가능한 한 자세히 작성해 주시면 도움이 됩니다.) 6 | 7 | ## 📗 참고 자료 (선택) 8 | 9 | ## 📢 리뷰 요구 사항 (선택) 10 | 11 | ## 🚩 후속 작업 (선택) 12 | 13 | ## ✅ 셀프 체크리스트 14 | - [ ] PR 제목을 형식에 맞게 작성했나요? 15 | - [ ] 브랜치 전략에 맞는 브랜치에 PR을 올리고 있나요? 16 | - [ ] 이슈는 close 했나요? 17 | - [ ] Reviewers, Labels, Projects를 등록했나요? 18 | - [ ] 작업 도중 문서 수정이 필요한 경우 잘 수정했나요? 19 | - [ ] 테스트는 잘 통과했나요? 20 | - [ ] 불필요한 코드는 제거했나요? 21 | 22 | closes #이슈번호 23 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout Repository 15 | uses: actions/checkout@v4 16 | with: 17 | token: ${{ secrets.GIT_ACCESS_TOKEN }} 18 | submodules: recursive 19 | - name: Set up JDK 17 20 | uses: actions/setup-java@v4 21 | with: 22 | java-version: '17' 23 | distribution: 'temurin' 24 | - name: Cache Gradle packages 25 | uses: actions/cache@v4 26 | with: 27 | path: ~/.gradle/caches 28 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} 29 | restore-keys: ${{ runner.os }}-gradle 30 | - name: Build 31 | run: ./gradlew build 32 | - name: Make zip file 33 | run: zip -r ./$GITHUB_SHA.zip . 34 | - name: Configure AWS credentials 35 | uses: aws-actions/configure-aws-credentials@v4 36 | with: 37 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} 38 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 39 | aws-region: ${{ secrets.AWS_REGION }} 40 | - name: Upload to S3 41 | run: aws s3 cp --region ap-northeast-2 ./$GITHUB_SHA.zip s3://picko-build/$GITHUB_SHA.zip 42 | - name: Code deploy 43 | run: | 44 | aws deploy create-deployment \ 45 | --deployment-config-name CodeDeployDefault.AllAtOnce \ 46 | --application-name picko-cicd \ 47 | --deployment-group-name picko-server \ 48 | --s3-location bucket=picko-build,bundleType=zip,key=$GITHUB_SHA.zip 49 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout sources 15 | uses: actions/checkout@v4 16 | with: 17 | token: ${{ secrets.GIT_ACCESS_TOKEN }} 18 | submodules: recursive 19 | fetch-depth: 0 20 | - name: Set up JDK 17 21 | uses: actions/setup-java@v4 22 | with: 23 | java-version: '17' 24 | distribution: 'temurin' 25 | - name: Cache SonarCloud packages 26 | uses: actions/cache@v4 27 | with: 28 | path: ~/.sonar/cache 29 | key: ${{ runner.os }}-sonar 30 | restore-keys: ${{ runner.os }}-sonar 31 | - name: Cache Gradle packages 32 | uses: actions/cache@v4 33 | with: 34 | path: ~/.gradle/caches 35 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} 36 | restore-keys: ${{ runner.os }}-gradle 37 | - name: Build and analyze 38 | env: 39 | GIT_ACCESS_TOKEN: ${{ secrets.GIT_ACCESS_TOKEN }} 40 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 41 | run: ./gradlew build sonar --info 42 | -------------------------------------------------------------------------------- /.github/workflows/d-day-labeler.yml: -------------------------------------------------------------------------------- 1 | name: Update D-n Labels 2 | on: 3 | schedule: 4 | - cron: '0 15 * * *' # 매일 밤 12시에 실행 (KST 기준) 5 | jobs: 6 | d-day-labeler: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Update D-n Labels 10 | uses: naver/d-day-labeler@latest 11 | with: 12 | token: ${{ secrets.GIT_ACCESS_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/main/resources/config"] 2 | path = src/main/resources/config 3 | url = git@github.com:CHZZK-Study/config.git 4 | -------------------------------------------------------------------------------- /appspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.0 # 0.0 고정 2 | os: linux # ubuntu 포함 3 | files: 4 | - source: / # 수정 버전의 모든 파일이 인스턴스에 복사된다. 5 | destination: /home/ubuntu/Balance-Talk-Backend 6 | overwrite: yes # 현재 배포 중인 애플리케이션 수정 버전의 파일 버전이 인스턴스에 이미 있는 버전을 대체한다. 7 | file_exists_behavior: OVERWRITE 8 | permissions: 9 | - object: / 10 | pattern: "**" 11 | owner: ubuntu 12 | group: ubuntu 13 | 14 | hooks: 15 | ApplicationStart: 16 | - location: scripts/gh_deploy.sh 17 | timeout: 180 18 | runas: ubuntu 19 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JECT-Study/PICK-O-Server/faa22cdc6dd71cdefc7bdb13d6933c917bff5e81/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /scripts/gh_deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PROJECT_PATH="/home/ubuntu/Balance-Talk-Backend" 3 | DEPLOY_LOG_PATH="$PROJECT_PATH/deploy-logs/deploy.log" 4 | DEPLOY_ERR_LOG_PATH="$PROJECT_PATH/deploy-logs/deploy_err.log" 5 | APPLICATION_LOG_PATH="$PROJECT_PATH/deploy-logs/application.log" 6 | BUILD_PATH="$PROJECT_PATH/build/libs" 7 | JAR_PATH="$BUILD_PATH/*.jar" 8 | BUILD_JAR=$(ls $JAR_PATH) 9 | JAR_NAME=$(basename $BUILD_JAR) 10 | 11 | echo "===== 배포 시작 : $(date +%c) =====" >> $DEPLOY_LOG_PATH 12 | 13 | echo "> build 파일명: $JAR_NAME" >> $DEPLOY_LOG_PATH 14 | echo "> build 파일 복사" >> $DEPLOY_LOG_PATH 15 | cp $BUILD_JAR $BUILD_PATH 16 | 17 | echo "> 애플리케이션 재구동" >> $DEPLOY_LOG_PATH 18 | 19 | CURRENT_PID=$(pgrep -f $JAR_NAME) 20 | echo “현재 구동중인 애플리케이션 PID: $CURRENT_PID” 21 | if [ -z “$CURRENT_PID” ]; then 22 | echo “> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다.” 23 | else 24 | echo “> sudo kill -9 $CURRENT_PID” 25 | sudo kill -9 $CURRENT_PID 26 | sleep 5 27 | fi 28 | 29 | CUR_DTTM=$(date +%Y%m%d%H%M%S) 30 | echo "nohup 백업 : nohup-${CUR_DTTM}.out" 31 | mv $BUILD_PATH/nohup.out $PROJECT_PATH/nohup/nohup-${CUR_DTTM}.out 32 | 33 | DEPLOY_JAR="$BUILD_PATH/$JAR_NAME" 34 | echo "> 새 애플리케이션 배포" >> $DEPLOY_LOG_PATH 35 | 36 | # LOG_DIR 시스템 프로퍼티 추가 37 | sudo nohup java -DLOG_DIR="/home/ubuntu/Balance-Talk-Backend/logs" -Dspring.profiles.active=dev -jar $DEPLOY_JAR >> $APPLICATION_LOG_PATH 2> $DEPLOY_ERR_LOG_PATH & 38 | 39 | 40 | sleep 3 41 | 42 | echo "> 배포 종료 : $(date +%c)" >> $DEPLOY_LOG_PATH 43 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'balance-talk' 2 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/BalanceTalkApplication.java: -------------------------------------------------------------------------------- 1 | package balancetalk; 2 | 3 | import io.swagger.v3.oas.annotations.OpenAPIDefinition; 4 | import io.swagger.v3.oas.annotations.servers.Server; 5 | import org.springframework.boot.SpringApplication; 6 | import org.springframework.boot.autoconfigure.SpringBootApplication; 7 | import org.springframework.cache.annotation.EnableCaching; 8 | import org.springframework.data.jpa.repository.config.EnableJpaAuditing; 9 | import org.springframework.retry.annotation.EnableRetry; 10 | import org.springframework.scheduling.annotation.EnableScheduling; 11 | 12 | @OpenAPIDefinition(servers = {@Server(url = "/", description = "Default Server URL")}) 13 | @EnableJpaAuditing 14 | @EnableCaching 15 | @EnableRetry 16 | @EnableScheduling 17 | @SpringBootApplication 18 | public class BalanceTalkApplication { 19 | 20 | public static void main(String[] args) { 21 | SpringApplication.run(BalanceTalkApplication.class, args); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/authmail/dto/EmailDto.java: -------------------------------------------------------------------------------- 1 | package balancetalk.authmail.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.Email; 5 | import jakarta.validation.constraints.NotBlank; 6 | import jakarta.validation.constraints.NotNull; 7 | import jakarta.validation.constraints.Pattern; 8 | import jakarta.validation.constraints.Size; 9 | import lombok.AllArgsConstructor; 10 | import lombok.Data; 11 | import lombok.NoArgsConstructor; 12 | 13 | @Data 14 | public class EmailDto { 15 | 16 | private EmailDto() { 17 | 18 | } 19 | 20 | @Data 21 | @NoArgsConstructor 22 | @AllArgsConstructor 23 | @Schema(description = "회원가입을 위한 인증번호 발송 요청") 24 | public static class EmailRequest { 25 | 26 | @NotNull 27 | @Size(max = 30) 28 | @Email(regexp = "^[a-zA-Z0-9._%+-]{1,20}@[a-zA-Z0-9.-]{1,10}\\.[a-zA-Z]{2,}$") 29 | @Schema(description = "인증 번호를 받을 이메일 주소", example = "test1234@naver.com") 30 | private String email; 31 | } 32 | 33 | @Data 34 | @AllArgsConstructor 35 | @Schema(description = "이메일 인증번호 검증 발송 요청") 36 | public static class EmailVerification { 37 | 38 | @NotNull 39 | @Size(max = 30) 40 | @Email(regexp = "^[a-zA-Z0-9._%+-]{1,20}@[a-zA-Z0-9.-]{1,10}\\.[a-zA-Z]{2,}$") 41 | @Schema(description = "인증 번호를 검증할 이메일 주소", example = "test1234@naver.com") 42 | private String email; 43 | 44 | @Schema(description = "인증 번호", example = "4f7dfb") 45 | private String verificationCode; 46 | } 47 | 48 | @Data 49 | @AllArgsConstructor 50 | @Schema(description = "비밀번호 재설정 요청") 51 | public static class PasswordResetRequest { 52 | 53 | @NotNull 54 | @Size(max = 30) 55 | @Email(regexp = "^[a-zA-Z0-9._%+-]{1,20}@[a-zA-Z0-9.-]{1,10}\\.[a-zA-Z]{2,}$") 56 | @Schema(description = "인증 번호를 검증할 이메일 주소", example = "test1234@naver.com") 57 | private String email; 58 | 59 | @NotBlank 60 | @Size(min = 10, max = 20) 61 | @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*#?&]{10,20}$") 62 | @Schema(description = "비밀번호", example = "Test1234test!") 63 | private String password; 64 | 65 | @NotBlank 66 | @Size(min = 10, max = 20) 67 | @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*#?&]{10,20}$") 68 | @Schema(description = "비밀번호 확인", example = "Test1234test!") 69 | private String passwordConfirm; 70 | } 71 | } -------------------------------------------------------------------------------- /src/main/java/balancetalk/authmail/presentation/MailController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.authmail.presentation; 2 | 3 | import balancetalk.authmail.dto.EmailDto.EmailRequest; 4 | import balancetalk.authmail.dto.EmailDto.EmailVerification; 5 | import balancetalk.authmail.dto.EmailDto.PasswordResetRequest; 6 | import balancetalk.authmail.application.MailService; 7 | import io.swagger.v3.oas.annotations.Operation; 8 | import io.swagger.v3.oas.annotations.tags.Tag; 9 | import jakarta.validation.Valid; 10 | import lombok.RequiredArgsConstructor; 11 | import org.springframework.web.bind.annotation.*; 12 | 13 | @RestController 14 | @RequiredArgsConstructor 15 | @RequestMapping("/email") 16 | @Tag(name = "email", description = "회원가입 인증 및 비밀번호 재설정 API") 17 | public class MailController { 18 | 19 | private final MailService mailService; 20 | 21 | @PostMapping("/signup/code") 22 | @Operation(summary = "회원 가입 인증번호 발송", description = "회원 가입 시 필요한 인증번호를 발송한다.") 23 | public String sendSignUpCode(@Valid @RequestBody EmailRequest request) { 24 | mailService.sendSignUpCode(request); 25 | return "회원 가입 인증 번호가 발송되었습니다."; 26 | } 27 | 28 | @PostMapping("/reset/code") 29 | @Operation(summary = "비밀번호 재설정 인증번호 발송", description = "비밀번호 재설정 시 필요한 인증번호를 발송한다") 30 | public String sendPasswordReset(@Valid @RequestBody EmailRequest request) { 31 | mailService.sendPasswordReset(request); 32 | return "비밀 번호 재설정 인증 번호가 발송되었습니다."; 33 | } 34 | 35 | @PostMapping("/verify") 36 | @Operation(summary = "인증 번호 검증", description = "해당 이메일 주소로 보낸 인증번호와 입력한 인증번호가 일치하는지 검증한다.") 37 | public String verifyCode(@Valid @RequestBody EmailVerification request) { 38 | mailService.verifyCode(request); 39 | return "인증이 완료 되었습니다."; 40 | } 41 | 42 | @PostMapping("/reset") 43 | @Operation(summary = "비밀번호 초기화", description = "비밀번호와 비밀번호 확인 값이 일치하는 경우 기존 비밀번호를 변경한다.") 44 | public String resetPassword(@Valid @RequestBody PasswordResetRequest request) { 45 | mailService.resetPassword(request); 46 | return "비밀번호가 변경되었습니다."; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/bookmark/domain/BookmarkGenerator.java: -------------------------------------------------------------------------------- 1 | package balancetalk.bookmark.domain; 2 | 3 | import balancetalk.game.domain.GameSet; 4 | import balancetalk.member.domain.Member; 5 | import balancetalk.talkpick.domain.TalkPick; 6 | import org.springframework.stereotype.Component; 7 | 8 | @Component 9 | public class BookmarkGenerator { 10 | 11 | public TalkPickBookmark generate(final TalkPick talkPick, final Member member) { 12 | return TalkPickBookmark.builder() 13 | .member(member) 14 | .talkPick(talkPick) 15 | .active(true) 16 | .build(); 17 | } 18 | 19 | public GameBookmark generate(final GameSet gameSet, final long gameId, final Member member) { 20 | return GameBookmark.builder() 21 | .member(member) 22 | .gameSet(gameSet) 23 | .gameId(gameId) 24 | .active(true) 25 | .isEndGameSet(false) 26 | .build(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/bookmark/domain/GameBookmark.java: -------------------------------------------------------------------------------- 1 | package balancetalk.bookmark.domain; 2 | 3 | import balancetalk.game.domain.GameSet; 4 | import balancetalk.global.common.BaseTimeEntity; 5 | import balancetalk.member.domain.Member; 6 | import jakarta.persistence.Entity; 7 | import jakarta.persistence.FetchType; 8 | import jakarta.persistence.GeneratedValue; 9 | import jakarta.persistence.GenerationType; 10 | import jakarta.persistence.Id; 11 | import jakarta.persistence.JoinColumn; 12 | import jakarta.persistence.ManyToOne; 13 | import jakarta.validation.constraints.NotNull; 14 | import lombok.AccessLevel; 15 | import lombok.AllArgsConstructor; 16 | import lombok.Builder; 17 | import lombok.Getter; 18 | import lombok.NoArgsConstructor; 19 | 20 | @Entity 21 | @Builder 22 | @Getter 23 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 24 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 25 | public class GameBookmark extends BaseTimeEntity { 26 | 27 | @Id 28 | @GeneratedValue(strategy = GenerationType.IDENTITY) 29 | private Long id; 30 | 31 | @ManyToOne(fetch = FetchType.LAZY) 32 | @JoinColumn(name = "member_id") 33 | private Member member; 34 | 35 | @ManyToOne(fetch = FetchType.LAZY) 36 | @JoinColumn(name = "game_set_id") 37 | private GameSet gameSet; 38 | 39 | @NotNull 40 | private Long gameId; 41 | 42 | @NotNull 43 | private Boolean active; 44 | 45 | @NotNull 46 | private Boolean isEndGameSet; 47 | 48 | public boolean matches(GameSet gameSet) { 49 | return isEqualsGameSetId(gameSet); 50 | } 51 | 52 | public boolean matches(GameSet gameSet, long gameId) { 53 | return isEqualsGameSetId(gameSet) && isEqualsGameId(gameId); 54 | } 55 | 56 | private boolean isEqualsGameSetId(GameSet gameSet) { 57 | return this.gameSet.equals(gameSet); 58 | } 59 | 60 | private boolean isEqualsGameId(long gameId) { 61 | return this.gameId.equals(gameId); 62 | } 63 | 64 | public boolean isActive() { 65 | return active; 66 | } 67 | 68 | public void activate() { 69 | this.active = true; 70 | } 71 | 72 | public void deactivate() { 73 | this.active = false; 74 | } 75 | 76 | public void updateGameId(Long gameId) { 77 | this.gameId = gameId; 78 | } 79 | 80 | public void setIsEndGameSet(boolean isEndGameSet) { 81 | this.isEndGameSet = isEndGameSet; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/bookmark/domain/GameBookmarkRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.bookmark.domain; 2 | 3 | import balancetalk.member.domain.Member; 4 | import java.util.Optional; 5 | import org.springframework.data.domain.Page; 6 | import org.springframework.data.domain.Pageable; 7 | import org.springframework.data.jpa.repository.JpaRepository; 8 | import org.springframework.data.jpa.repository.Query; 9 | import org.springframework.data.repository.query.Param; 10 | 11 | public interface GameBookmarkRepository extends JpaRepository { 12 | 13 | @Query("SELECT b FROM GameBookmark b WHERE b.member = :member AND b.active = true ORDER BY b.lastModifiedAt DESC") 14 | Page findActivatedByMemberOrderByDesc(@Param("member") Member member, Pageable pageable); 15 | 16 | Optional findByMemberAndGameSetId(Member member, Long gameSetId); 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/bookmark/domain/TalkPickBookmark.java: -------------------------------------------------------------------------------- 1 | package balancetalk.bookmark.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import balancetalk.member.domain.Member; 5 | import balancetalk.talkpick.domain.TalkPick; 6 | import jakarta.persistence.Entity; 7 | import jakarta.persistence.FetchType; 8 | import jakarta.persistence.GeneratedValue; 9 | import jakarta.persistence.GenerationType; 10 | import jakarta.persistence.Id; 11 | import jakarta.persistence.JoinColumn; 12 | import jakarta.persistence.ManyToOne; 13 | import jakarta.validation.constraints.NotNull; 14 | import lombok.AccessLevel; 15 | import lombok.AllArgsConstructor; 16 | import lombok.Builder; 17 | import lombok.Getter; 18 | import lombok.NoArgsConstructor; 19 | 20 | @Entity 21 | @Builder 22 | @Getter 23 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 24 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 25 | public class TalkPickBookmark extends BaseTimeEntity { 26 | 27 | @Id 28 | @GeneratedValue(strategy = GenerationType.IDENTITY) 29 | private Long id; 30 | 31 | @ManyToOne(fetch = FetchType.LAZY) 32 | @JoinColumn(name = "member_id") 33 | private Member member; 34 | 35 | @ManyToOne(fetch = FetchType.LAZY) 36 | @JoinColumn(name = "talk_pick_id") 37 | private TalkPick talkPick; 38 | 39 | @NotNull 40 | private Boolean active; 41 | 42 | public boolean matches(TalkPick talkPick) { 43 | return this.talkPick.equals(talkPick); 44 | } 45 | 46 | public boolean isActive() { 47 | return active; 48 | } 49 | 50 | public void activate() { 51 | this.active = true; 52 | } 53 | 54 | public void deactivate() { 55 | this.active = false; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/bookmark/domain/TalkPickBookmarkRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.bookmark.domain; 2 | 3 | import balancetalk.member.domain.Member; 4 | import org.springframework.data.domain.Page; 5 | import org.springframework.data.domain.Pageable; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | import org.springframework.data.jpa.repository.Query; 8 | import org.springframework.data.repository.query.Param; 9 | 10 | public interface TalkPickBookmarkRepository extends JpaRepository { 11 | 12 | @Query("SELECT b FROM TalkPickBookmark b WHERE b.member = :member AND b.active = true ORDER BY b.lastModifiedAt DESC") 13 | Page findActivatedByMemberOrderByDesc(@Param("member") Member member, Pageable pageable); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/bookmark/presentation/BookmarkGameController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.bookmark.presentation; 2 | 3 | import balancetalk.bookmark.application.BookmarkGameService; 4 | import balancetalk.global.utils.AuthPrincipal; 5 | import balancetalk.member.dto.ApiMember; 6 | import io.swagger.v3.oas.annotations.Operation; 7 | import io.swagger.v3.oas.annotations.Parameter; 8 | import io.swagger.v3.oas.annotations.tags.Tag; 9 | import lombok.RequiredArgsConstructor; 10 | import lombok.extern.slf4j.Slf4j; 11 | import org.springframework.web.bind.annotation.*; 12 | 13 | @Slf4j 14 | @RestController 15 | @RequiredArgsConstructor 16 | @RequestMapping("/bookmarks/game-sets/{gameSetId}") 17 | @Tag(name = "bookmark", description = "북마크 API") 18 | public class BookmarkGameController { 19 | 20 | private final BookmarkGameService bookmarkGameService; 21 | 22 | @Operation(summary = "밸런스게임 세트 북마크 추가", description = "밸런스게임 세트에 북마크를 추가합니다.") 23 | @PostMapping("/games/{gameId}") 24 | public void bookmarkGameSet(@PathVariable final Long gameSetId, @PathVariable final Long gameId, 25 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 26 | bookmarkGameService.createBookmark(gameSetId, gameId, apiMember); 27 | } 28 | 29 | @Operation(summary = "투표 완료한 밸런스게임 세트 북마크 추가", description = "전체 투표를 완료한 밸런스게임 세트에 북마크를 추가합니다. ") 30 | @PostMapping() 31 | public void bookmarkEndGameSet(@PathVariable final Long gameSetId, 32 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 33 | bookmarkGameService.createEndGameSetBookmark(gameSetId, apiMember); 34 | } 35 | 36 | @Operation(summary = "밸런스게임 세트 북마크 취소", description = "밸런스게임 세트에 북마크를 취소합니다.") 37 | @DeleteMapping 38 | public void deleteBookmarkGame(@PathVariable final Long gameSetId, 39 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 40 | bookmarkGameService.deleteBookmark(gameSetId, apiMember); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/bookmark/presentation/BookmarkTalkPickController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.bookmark.presentation; 2 | 3 | import balancetalk.bookmark.application.BookmarkTalkPickService; 4 | import balancetalk.global.utils.AuthPrincipal; 5 | import balancetalk.member.dto.ApiMember; 6 | import io.swagger.v3.oas.annotations.Operation; 7 | import io.swagger.v3.oas.annotations.Parameter; 8 | import io.swagger.v3.oas.annotations.tags.Tag; 9 | import lombok.RequiredArgsConstructor; 10 | import org.springframework.web.bind.annotation.*; 11 | 12 | @RequiredArgsConstructor 13 | @RestController 14 | @RequestMapping("/bookmarks/talks/{talkPickId}") 15 | @Tag(name = "bookmark", description = "북마크 API") 16 | public class BookmarkTalkPickController { 17 | 18 | private final BookmarkTalkPickService bookmarkTalkPickService; 19 | 20 | @Operation(summary = "톡픽 북마크 추가", description = "북마크에 톡픽을 추가합니다.") 21 | @PostMapping 22 | public void createBookmark(@PathVariable Long talkPickId, 23 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 24 | bookmarkTalkPickService.createBookmark(talkPickId, apiMember); 25 | } 26 | 27 | @Operation(summary = "톡픽 북마크 제거", description = "북마크에서 톡픽을 제거합니다.") 28 | @DeleteMapping 29 | public void deleteBookmarkTalkPick(@PathVariable Long talkPickId, 30 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 31 | bookmarkTalkPickService.deleteBookmark(talkPickId, apiMember); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/comment/domain/CommentRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.comment.domain; 2 | 3 | import balancetalk.like.domain.LikeType; 4 | import org.springframework.data.domain.Page; 5 | import org.springframework.data.domain.Pageable; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | import org.springframework.data.jpa.repository.Query; 8 | import org.springframework.data.repository.query.Param; 9 | 10 | import java.util.List; 11 | 12 | public interface CommentRepository extends JpaRepository { 13 | Page findAllByTalkPickIdAndParentIsNull(Long talkPickId, Pageable pageable); 14 | 15 | List findAllByTalkPickIdAndParentIsNullOrderByCreatedAtDesc(Long talkPickId); 16 | 17 | @Query("SELECT c FROM Comment c WHERE c.parent.id = :parentId " + 18 | "ORDER BY CASE WHEN c.member.id = :currentMemberId THEN 0 ELSE 1 END, c.createdAt ASC") 19 | List findAllRepliesByParentIdOrderByMemberAndCreatedAt(@Param("parentId") Long parentId, 20 | @Param("currentMemberId") Long currentMemberId); 21 | @Query("SELECT c FROM Comment c LEFT JOIN Like l ON c.id = l.resourceId AND l.likeType = :likeType " + 22 | "WHERE c.talkPick.id = :talkPickId AND c.parent IS NULL " + 23 | "GROUP BY c " + 24 | "ORDER BY COUNT(l) DESC, c.createdAt ASC") 25 | List findByTalkPickIdAndParentIsNullOrderByLikesCountDescCreatedAtAsc(@Param("talkPickId") Long talkPickId, 26 | @Param("likeType") LikeType likeType); 27 | 28 | @Query("SELECT c FROM Comment c WHERE c.member.id = :memberId AND c.talkPick IS NOT NULL " + 29 | "AND c.editedAt IN (SELECT MAX(c2.editedAt) FROM Comment c2 WHERE c2.member.id = :memberId GROUP BY c2.talkPick.id) " + 30 | "ORDER BY c.editedAt DESC") 31 | Page findAllLatestCommentsByMemberIdAndOrderByDesc(@Param("memberId") Long memberId, Pageable pageable); 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/file/domain/File.java: -------------------------------------------------------------------------------- 1 | package balancetalk.file.domain; 2 | 3 | import static balancetalk.file.domain.FileType.FRIENDS; 4 | 5 | import balancetalk.global.common.BaseTimeEntity; 6 | import jakarta.persistence.Entity; 7 | import jakarta.persistence.EnumType; 8 | import jakarta.persistence.Enumerated; 9 | import jakarta.persistence.GeneratedValue; 10 | import jakarta.persistence.GenerationType; 11 | import jakarta.persistence.Id; 12 | import jakarta.validation.constraints.NotBlank; 13 | import jakarta.validation.constraints.NotNull; 14 | import jakarta.validation.constraints.Positive; 15 | import jakarta.validation.constraints.Size; 16 | import lombok.AccessLevel; 17 | import lombok.AllArgsConstructor; 18 | import lombok.Builder; 19 | import lombok.Getter; 20 | import lombok.NoArgsConstructor; 21 | 22 | @Entity 23 | @Getter 24 | @Builder 25 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 26 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 27 | public class File extends BaseTimeEntity { 28 | 29 | @Id 30 | @GeneratedValue(strategy = GenerationType.IDENTITY) 31 | private Long id; 32 | 33 | private Long resourceId; 34 | 35 | @Enumerated(value = EnumType.STRING) 36 | @NotNull 37 | private FileType fileType; 38 | 39 | @NotBlank 40 | @Size(max = 50) 41 | private String uploadName; 42 | 43 | @NotBlank 44 | @Size(max = 100) 45 | private String storedName; 46 | 47 | @NotBlank 48 | private String mimeType; 49 | 50 | @NotNull 51 | @Positive 52 | private Long size; 53 | 54 | @NotBlank 55 | private String directoryPath; 56 | 57 | @NotBlank 58 | private String imgUrl; 59 | 60 | public void updateDirectoryPathAndImgUrl(String newDirectoryPath, String s3Endpoint) { 61 | this.directoryPath = newDirectoryPath; 62 | this.imgUrl = String.format("%s%s%s", s3Endpoint, newDirectoryPath, storedName); 63 | } 64 | 65 | public void updateResourceId(Long newResourceId) { 66 | this.resourceId = newResourceId; 67 | } 68 | 69 | public void updateFileType(FileType newFileType) { 70 | this.fileType = newFileType; 71 | } 72 | 73 | public String getS3Key() { 74 | return "%s%s".formatted(directoryPath, storedName); 75 | } 76 | 77 | public boolean isUnmapped() { 78 | return directoryPath.endsWith("temp/") && resourceId == null; 79 | } 80 | 81 | public boolean isUploadedByMember() { 82 | return fileType != FRIENDS; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/file/domain/FileProcessor.java: -------------------------------------------------------------------------------- 1 | package balancetalk.file.domain; 2 | 3 | import java.util.UUID; 4 | import org.springframework.stereotype.Component; 5 | import org.springframework.web.multipart.MultipartFile; 6 | 7 | @Component 8 | public class FileProcessor { 9 | 10 | public File process(MultipartFile multipartFile, FileType fileType) { 11 | String originalName = multipartFile.getOriginalFilename(); 12 | String storedName = createRandomName(originalName); 13 | long size = multipartFile.getSize(); 14 | String mimeType = multipartFile.getContentType(); 15 | return createFile(originalName, storedName, fileType, mimeType, size); 16 | } 17 | 18 | private String createRandomName(String originalName) { 19 | return String.format("%s_%s", UUID.randomUUID(), originalName); 20 | } 21 | 22 | private File createFile(String uploadName, 23 | String storedName, 24 | FileType fileType, 25 | String mimeType, 26 | long size) { 27 | return File.builder() 28 | .fileType(fileType) 29 | .storedName(storedName) 30 | .uploadName(uploadName) 31 | .mimeType(mimeType) 32 | .size(size) 33 | .build(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/file/domain/FileType.java: -------------------------------------------------------------------------------- 1 | package balancetalk.file.domain; 2 | 3 | public enum FileType { 4 | TALK_PICK("talks/", 10), 5 | TEMP_TALK_PICK("temp-talks/", 10), 6 | GAME_OPTION("game-options/", 1), 7 | TEMP_GAME_OPTION("temp-game-options/", 1), 8 | MEMBER("members/", 1), 9 | FRIENDS("friends/", 1); 10 | 11 | private final String uploadDir; 12 | private final int maxCount; 13 | 14 | FileType(String uploadDir, int maxCount) { 15 | this.uploadDir = uploadDir; 16 | this.maxCount = maxCount; 17 | } 18 | 19 | public String getUploadDir() { 20 | return uploadDir; 21 | } 22 | 23 | public int getMaxCount() { 24 | return maxCount; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/file/domain/MultipartFiles.java: -------------------------------------------------------------------------------- 1 | package balancetalk.file.domain; 2 | 3 | import static balancetalk.global.exception.ErrorCode.EXCEEDED_IMAGES_SIZE; 4 | import static balancetalk.global.exception.ErrorCode.MISSING_MIME_TYPE; 5 | import static balancetalk.global.exception.ErrorCode.NOT_ATTACH_IMAGE; 6 | import static balancetalk.global.exception.ErrorCode.NOT_SUPPORTED_MIME_TYPE; 7 | 8 | import balancetalk.global.exception.BalanceTalkException; 9 | import java.util.List; 10 | import org.springframework.web.multipart.MultipartFile; 11 | 12 | public record MultipartFiles(List multipartFiles, FileType fileType) { 13 | 14 | public MultipartFiles { 15 | if (multipartFiles == null || containsEmptyFile(multipartFiles)) { 16 | throw new BalanceTalkException(NOT_ATTACH_IMAGE); 17 | } 18 | if (multipartFiles.size() > fileType.getMaxCount()) { 19 | throw new BalanceTalkException(EXCEEDED_IMAGES_SIZE); 20 | } 21 | if (containsNotImage(multipartFiles)) { 22 | throw new BalanceTalkException(NOT_SUPPORTED_MIME_TYPE); 23 | } 24 | } 25 | 26 | private boolean containsEmptyFile(List multipartFiles) { 27 | return multipartFiles.stream().anyMatch(MultipartFile::isEmpty); 28 | } 29 | 30 | private boolean containsNotImage(List multipartFiles) { 31 | return multipartFiles.stream() 32 | .anyMatch(this::isNotImage); 33 | } 34 | 35 | private boolean isNotImage(MultipartFile multipartFile) { 36 | String contentType = multipartFile.getContentType(); 37 | if (contentType == null) { 38 | throw new BalanceTalkException(MISSING_MIME_TYPE); 39 | } 40 | return !contentType.startsWith("image"); 41 | } 42 | 43 | @Override 44 | public List multipartFiles() { 45 | return List.copyOf(multipartFiles); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/file/domain/repository/FileRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.file.domain.repository; 2 | 3 | import balancetalk.file.domain.File; 4 | import balancetalk.file.domain.FileType; 5 | import java.util.List; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | 8 | public interface FileRepository extends JpaRepository, FileRepositoryCustom { 9 | 10 | void deleteByResourceIdAndFileType(Long tempTalkPickId, FileType fileType); 11 | 12 | void deleteByResourceIdInAndFileType(List ids, FileType fileType); 13 | 14 | boolean existsByResourceIdAndFileType(Long talkPickId, FileType fileType); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/file/domain/repository/FileRepositoryCustom.java: -------------------------------------------------------------------------------- 1 | package balancetalk.file.domain.repository; 2 | 3 | import balancetalk.file.domain.File; 4 | import balancetalk.file.domain.FileType; 5 | import java.util.List; 6 | import java.util.Optional; 7 | 8 | public interface FileRepositoryCustom { 9 | 10 | List findImgUrlsByResourceIdAndFileType(Long resourceId, FileType fileType); 11 | 12 | List findIdsByResourceIdAndFileType(Long resourceId, FileType fileType); 13 | 14 | List findAllByResourceIdAndFileType(Long resourceId, FileType fileType); 15 | 16 | List findAllByResourceIdsAndFileType(List resourceIds, FileType fileType); 17 | 18 | Optional findImgUrlByResourceIdAndFileType(Long resourceId, FileType fileType); 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/file/domain/repository/FileRepositoryImpl.java: -------------------------------------------------------------------------------- 1 | package balancetalk.file.domain.repository; 2 | 3 | import static balancetalk.file.domain.QFile.file; 4 | 5 | import balancetalk.file.domain.File; 6 | import balancetalk.file.domain.FileType; 7 | import com.querydsl.jpa.impl.JPAQueryFactory; 8 | import java.util.List; 9 | import java.util.Optional; 10 | import lombok.RequiredArgsConstructor; 11 | 12 | @RequiredArgsConstructor 13 | public class FileRepositoryImpl implements FileRepositoryCustom { 14 | 15 | private final JPAQueryFactory queryFactory; 16 | 17 | @Override 18 | public List findImgUrlsByResourceIdAndFileType(Long resourceId, FileType fileType) { 19 | List images = queryFactory.selectFrom(file) 20 | .where(file.fileType.eq(fileType), file.resourceId.eq(resourceId)) 21 | .fetch(); 22 | 23 | return images.stream() 24 | .map(File::getImgUrl) 25 | .toList(); 26 | } 27 | 28 | @Override 29 | public List findIdsByResourceIdAndFileType(Long resourceId, FileType fileType) { 30 | return queryFactory.select(file.id) 31 | .from(file) 32 | .where(file.fileType.eq(fileType), file.resourceId.eq(resourceId)) 33 | .fetch(); 34 | } 35 | 36 | @Override 37 | public List findAllByResourceIdAndFileType(Long resourceId, FileType fileType) { 38 | return queryFactory.select(file) 39 | .from(file) 40 | .where(file.fileType.eq(fileType), file.resourceId.eq(resourceId)) 41 | .fetch(); 42 | } 43 | 44 | @Override 45 | public List findAllByResourceIdsAndFileType(List resourceIds, FileType fileType) { 46 | return queryFactory.select(file) 47 | .from(file) 48 | .where(file.fileType.eq(fileType), file.resourceId.in(resourceIds)) 49 | .fetch(); 50 | } 51 | 52 | @Override 53 | public Optional findImgUrlByResourceIdAndFileType(Long resourceId, FileType fileType) { 54 | return Optional.ofNullable(queryFactory.select(file.imgUrl) 55 | .from(file) 56 | .where(file.fileType.eq(fileType), file.resourceId.eq(resourceId)) 57 | .fetchOne()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/file/dto/UploadFileResponse.java: -------------------------------------------------------------------------------- 1 | package balancetalk.file.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import java.util.List; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | 8 | @Schema(description = "이미지 파일 업로드 응답") 9 | @Data 10 | @AllArgsConstructor 11 | public class UploadFileResponse { 12 | 13 | @Schema(description = "업로드한 이미지 URL 목록", 14 | example = "[" 15 | + "\"https://picko-image.s3.ap-northeast-2.amazonaws.com/talk-pick/ad80-a94e083301d2_czz.png\",\n" 16 | + "\"https://picko-image.s3.ap-northeast-2.amazonaws.com/talk-pick/957e6ed4830b_prom.jpeg\"" 17 | + "]") 18 | private List imgUrls; 19 | 20 | @Schema(description = "업로드한 이미지 파일 ID 목록", example = "[121, 255]") 21 | private List fileIds; 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/file/presentation/FileController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.file.presentation; 2 | 3 | import balancetalk.file.application.FileService; 4 | import balancetalk.file.domain.FileType; 5 | import balancetalk.file.domain.MultipartFiles; 6 | import balancetalk.file.dto.UploadFileResponse; 7 | import io.swagger.v3.oas.annotations.Operation; 8 | import io.swagger.v3.oas.annotations.Parameter; 9 | import io.swagger.v3.oas.annotations.tags.Tag; 10 | import java.util.List; 11 | import lombok.RequiredArgsConstructor; 12 | import org.springframework.http.MediaType; 13 | import org.springframework.web.bind.annotation.DeleteMapping; 14 | import org.springframework.web.bind.annotation.PathVariable; 15 | import org.springframework.web.bind.annotation.PostMapping; 16 | import org.springframework.web.bind.annotation.RequestMapping; 17 | import org.springframework.web.bind.annotation.RequestParam; 18 | import org.springframework.web.bind.annotation.RequestPart; 19 | import org.springframework.web.bind.annotation.RestController; 20 | import org.springframework.web.multipart.MultipartFile; 21 | 22 | @RestController 23 | @RequiredArgsConstructor 24 | @RequestMapping("/images") 25 | @Tag(name = "file", description = "파일 API") 26 | public class FileController { 27 | 28 | private final FileService fileService; 29 | 30 | @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) 31 | @Operation(summary = "이미지 파일 업로드", description = "이미지 파일을 업로드한 후 이미지 URL을 반환 받습니다.") 32 | public UploadFileResponse deleteImage(@RequestPart("file") List multipartFiles, 33 | @Parameter(description = "리소스 타입", example = "TALK_PICK") 34 | @RequestParam("type") FileType fileType) { 35 | return fileService.uploadImages(new MultipartFiles(multipartFiles, fileType)); 36 | } 37 | 38 | @DeleteMapping("/{fileId}") 39 | @Operation(summary = "이미지 파일 제거", description = "첨부한 이미지 파일을 제거합니다.") 40 | public void deleteImage(@PathVariable Long fileId) { 41 | fileService.deleteImageById(fileId); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/friends/application/FriendsService.java: -------------------------------------------------------------------------------- 1 | package balancetalk.friends.application; 2 | 3 | import static balancetalk.friends.dto.FriendsDto.FriendsImageResponse; 4 | 5 | import balancetalk.file.domain.File; 6 | import balancetalk.file.domain.FileHandler; 7 | import balancetalk.file.domain.FileType; 8 | import balancetalk.file.domain.repository.FileRepository; 9 | import balancetalk.friends.domain.Friends; 10 | import balancetalk.friends.domain.FriendsRepository; 11 | import balancetalk.friends.dto.FriendsDto.CreateFriendsRequest; 12 | import balancetalk.global.exception.BalanceTalkException; 13 | import balancetalk.global.exception.ErrorCode; 14 | import balancetalk.member.domain.Member; 15 | import balancetalk.member.domain.MemberRepository; 16 | import balancetalk.member.dto.ApiMember; 17 | import java.util.List; 18 | import lombok.RequiredArgsConstructor; 19 | import org.springframework.stereotype.Service; 20 | import org.springframework.transaction.annotation.Transactional; 21 | 22 | @Service 23 | @RequiredArgsConstructor 24 | public class FriendsService { 25 | 26 | private final MemberRepository memberRepository; 27 | private final FriendsRepository friendsRepository; 28 | private final FileRepository fileRepository; 29 | private final FileHandler fileHandler; 30 | 31 | @Transactional 32 | public void createFriends(CreateFriendsRequest request, ApiMember apiMember) { 33 | Member member = apiMember.toMember(memberRepository); 34 | if (member.isRoleUser()) { 35 | throw new BalanceTalkException(ErrorCode.FORBIDDEN_PICK_O_FRIENDS_OPERATION); 36 | } 37 | Friends savedFriends = friendsRepository.save(request.toEntity()); 38 | 39 | File file = fileRepository.findById(request.getImgId()) 40 | .orElseThrow(() -> new BalanceTalkException(ErrorCode.NOT_FOUND_FILE)); 41 | fileHandler.relocateFile(file, savedFriends.getId(), FileType.FRIENDS); 42 | } 43 | 44 | public List findAllFriendsImages() { 45 | return fileRepository.findAllById(getFriendsImgIds()) 46 | .stream() 47 | .map(FriendsImageResponse::from) 48 | .toList(); 49 | } 50 | 51 | private List getFriendsImgIds() { 52 | return friendsRepository.findAll() 53 | .stream() 54 | .map(Friends::getImgId) 55 | .toList(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/friends/domain/Friends.java: -------------------------------------------------------------------------------- 1 | package balancetalk.friends.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import jakarta.persistence.Entity; 5 | import jakarta.persistence.GeneratedValue; 6 | import jakarta.persistence.GenerationType; 7 | import jakarta.persistence.Id; 8 | import jakarta.validation.constraints.NotBlank; 9 | import jakarta.validation.constraints.NotNull; 10 | import lombok.AccessLevel; 11 | import lombok.AllArgsConstructor; 12 | import lombok.Builder; 13 | import lombok.Getter; 14 | import lombok.NoArgsConstructor; 15 | 16 | @Entity 17 | @Builder 18 | @Getter 19 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 20 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 21 | public class Friends extends BaseTimeEntity { 22 | 23 | @Id 24 | @GeneratedValue(strategy = GenerationType.IDENTITY) 25 | private Long id; 26 | 27 | @NotBlank 28 | private String name; 29 | 30 | @NotNull 31 | private Long imgId; 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/friends/domain/FriendsRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.friends.domain; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | public interface FriendsRepository extends JpaRepository { 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/friends/dto/FriendsDto.java: -------------------------------------------------------------------------------- 1 | package balancetalk.friends.dto; 2 | 3 | import balancetalk.file.domain.File; 4 | import balancetalk.friends.domain.Friends; 5 | import io.swagger.v3.oas.annotations.media.Schema; 6 | import jakarta.validation.constraints.NotBlank; 7 | import jakarta.validation.constraints.NotNull; 8 | import lombok.AccessLevel; 9 | import lombok.AllArgsConstructor; 10 | import lombok.Builder; 11 | import lombok.Data; 12 | import lombok.NoArgsConstructor; 13 | 14 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 15 | public class FriendsDto { 16 | 17 | @Schema(description = "PICK-O 프렌즈 캐릭터 생성 요청") 18 | @Data 19 | @AllArgsConstructor 20 | public static class CreateFriendsRequest { 21 | 22 | @Schema(description = "프렌즈 이름", example = "꺼북이") 23 | @NotBlank(message = "프렌즈 이름은 필수입니다.") 24 | private String name; 25 | 26 | @Schema(description = "첨부한 이미지 파일 ID", example = "41") 27 | @NotNull(message = "프렌즈 이미지 ID는 필수입니다.") 28 | private Long imgId; 29 | 30 | public Friends toEntity() { 31 | return Friends.builder() 32 | .name(name) 33 | .imgId(imgId) 34 | .build(); 35 | } 36 | } 37 | 38 | @Schema(description = "PICK-O 프렌즈 이미지 응답") 39 | @Data 40 | @Builder 41 | @AllArgsConstructor 42 | public static class FriendsImageResponse { 43 | 44 | @Schema(description = "프렌즈 이미지 파일 ID", example = "3") 45 | private Long fileId; 46 | 47 | @Schema(description = "프렌즈 이미지 URL", 48 | example = "https://picko-image.amazonaws.com/friends/ad80-a94e08-3301d2_대해파리.png") 49 | private String imgUrl; 50 | 51 | public static FriendsImageResponse from(File file) { 52 | return FriendsImageResponse.builder() 53 | .fileId(file.getId()) 54 | .imgUrl(file.getImgUrl()) 55 | .build(); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/friends/presentation/FriendsController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.friends.presentation; 2 | 3 | import static balancetalk.friends.dto.FriendsDto.FriendsImageResponse; 4 | 5 | import balancetalk.friends.application.FriendsService; 6 | import balancetalk.friends.dto.FriendsDto.CreateFriendsRequest; 7 | import balancetalk.global.utils.AuthPrincipal; 8 | import balancetalk.member.dto.ApiMember; 9 | import io.swagger.v3.oas.annotations.Operation; 10 | import io.swagger.v3.oas.annotations.Parameter; 11 | import io.swagger.v3.oas.annotations.tags.Tag; 12 | import java.util.List; 13 | import lombok.RequiredArgsConstructor; 14 | import org.springframework.web.bind.annotation.GetMapping; 15 | import org.springframework.web.bind.annotation.PostMapping; 16 | import org.springframework.web.bind.annotation.RequestBody; 17 | import org.springframework.web.bind.annotation.RequestMapping; 18 | import org.springframework.web.bind.annotation.RestController; 19 | 20 | @RestController 21 | @RequiredArgsConstructor 22 | @RequestMapping("/friends") 23 | @Tag(name = "friends", description = "PICK-O 프렌즈 API") 24 | public class FriendsController { 25 | 26 | private final FriendsService friendsService; 27 | 28 | @Operation(summary = "PICK-O 프렌즈 캐릭터 생성", description = "PICK-O 프렌즈 캐릭터를 생성합니다.") 29 | @PostMapping 30 | public void createCharacter(@RequestBody final CreateFriendsRequest request, 31 | @Parameter(hidden = true) @AuthPrincipal final ApiMember apiMember) { 32 | friendsService.createFriends(request, apiMember); 33 | } 34 | 35 | @Operation(summary = "PICK-O 프렌즈 이미지 목록 조회", description = "PICK-O 프렌즈 이미지 목록을 조회합니다.") 36 | @GetMapping("/images") 37 | public List findAllFriendsImages() { 38 | return friendsService.findAllFriendsImages(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/domain/GameOption.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import balancetalk.vote.domain.GameVote; 5 | import balancetalk.vote.domain.VoteOption; 6 | import jakarta.persistence.CascadeType; 7 | import jakarta.persistence.Column; 8 | import jakarta.persistence.Entity; 9 | import jakarta.persistence.EnumType; 10 | import jakarta.persistence.Enumerated; 11 | import jakarta.persistence.FetchType; 12 | import jakarta.persistence.GeneratedValue; 13 | import jakarta.persistence.GenerationType; 14 | import jakarta.persistence.Id; 15 | import jakarta.persistence.JoinColumn; 16 | import jakarta.persistence.ManyToOne; 17 | import jakarta.persistence.OneToMany; 18 | import jakarta.validation.constraints.NotBlank; 19 | import jakarta.validation.constraints.PositiveOrZero; 20 | import jakarta.validation.constraints.Size; 21 | import java.util.ArrayList; 22 | import java.util.List; 23 | import lombok.AccessLevel; 24 | import lombok.AllArgsConstructor; 25 | import lombok.Builder; 26 | import lombok.Getter; 27 | import lombok.NoArgsConstructor; 28 | import org.hibernate.annotations.ColumnDefault; 29 | 30 | @Entity 31 | @Getter 32 | @Builder 33 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 34 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 35 | public class GameOption extends BaseTimeEntity { 36 | 37 | @Id 38 | @GeneratedValue(strategy = GenerationType.IDENTITY) 39 | @Column(name = "id") 40 | private Long id; 41 | 42 | @NotBlank 43 | @Size(max = 30) 44 | private String name; 45 | 46 | private Long imgId; 47 | 48 | @Size(max = 50) 49 | private String description; 50 | 51 | @Enumerated(value = EnumType.STRING) 52 | private VoteOption optionType; 53 | 54 | @PositiveOrZero 55 | @ColumnDefault("0") 56 | private long votesCount; 57 | 58 | @ManyToOne(fetch = FetchType.LAZY) 59 | @JoinColumn(name = "game_id") 60 | private Game game; 61 | 62 | @OneToMany(mappedBy = "gameOption", cascade = CascadeType.REMOVE) 63 | private List gameVotes = new ArrayList<>(); 64 | 65 | public void addGame(Game game) { 66 | this.game = game; 67 | } 68 | 69 | public void updateGameOption(GameOption newGameOption) { 70 | this.imgId = newGameOption.getImgId(); 71 | this.name = newGameOption.getName(); 72 | this.description = newGameOption.getDescription(); 73 | } 74 | 75 | public boolean isTypeEqual(VoteOption voteOption) { 76 | return optionType.equals(voteOption); 77 | } 78 | 79 | public void increaseVotesCount() { 80 | this.votesCount++; 81 | } 82 | 83 | public void decreaseVotesCount() { 84 | this.votesCount--; 85 | } 86 | 87 | public boolean hasImage() { 88 | return imgId != null; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/domain/GameReader.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.domain; 2 | 3 | import balancetalk.game.domain.repository.GameRepository; 4 | import balancetalk.game.domain.repository.GameSetRepository; 5 | import balancetalk.global.exception.BalanceTalkException; 6 | import balancetalk.global.exception.ErrorCode; 7 | import lombok.RequiredArgsConstructor; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Component 11 | @RequiredArgsConstructor 12 | public class GameReader { 13 | 14 | private final GameRepository gameRepository; 15 | private final GameSetRepository gameSetRepository; 16 | 17 | public GameSet findGameSetById(Long gameSetId) { 18 | return gameSetRepository.findById(gameSetId) 19 | .orElseThrow(() -> new BalanceTalkException(ErrorCode.NOT_FOUND_BALANCE_GAME_SET)); 20 | } 21 | 22 | public Game findGameById(Long gameId) { 23 | return gameRepository.findById(gameId) 24 | .orElseThrow(() -> new BalanceTalkException(ErrorCode.NOT_FOUND_BALANCE_GAME)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/domain/MainTag.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import jakarta.persistence.Column; 5 | import jakarta.persistence.Entity; 6 | import jakarta.persistence.GeneratedValue; 7 | import jakarta.persistence.GenerationType; 8 | import jakarta.persistence.Id; 9 | import jakarta.persistence.OneToMany; 10 | import jakarta.validation.constraints.NotBlank; 11 | import jakarta.validation.constraints.Size; 12 | import java.util.ArrayList; 13 | import java.util.List; 14 | import lombok.AccessLevel; 15 | import lombok.AllArgsConstructor; 16 | import lombok.Builder; 17 | import lombok.Getter; 18 | import lombok.NoArgsConstructor; 19 | 20 | @Entity 21 | @Builder 22 | @Getter 23 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 24 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 25 | public class MainTag extends BaseTimeEntity { 26 | @Id 27 | @GeneratedValue(strategy = GenerationType.IDENTITY) 28 | @Column(name = "id") 29 | private Long id; 30 | 31 | @NotBlank 32 | @Size(max = 10) 33 | private String name; 34 | 35 | @OneToMany(mappedBy = "mainTag") 36 | private List gameSets = new ArrayList<>(); 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/domain/TempGame.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import jakarta.persistence.CascadeType; 5 | import jakarta.persistence.Column; 6 | import jakarta.persistence.Entity; 7 | import jakarta.persistence.FetchType; 8 | import jakarta.persistence.GeneratedValue; 9 | import jakarta.persistence.GenerationType; 10 | import jakarta.persistence.Id; 11 | import jakarta.persistence.ManyToOne; 12 | import jakarta.persistence.OneToMany; 13 | import jakarta.validation.constraints.NotBlank; 14 | import jakarta.validation.constraints.Size; 15 | import java.util.ArrayList; 16 | import java.util.List; 17 | import java.util.stream.IntStream; 18 | import lombok.AccessLevel; 19 | import lombok.AllArgsConstructor; 20 | import lombok.Builder; 21 | import lombok.Getter; 22 | import lombok.NoArgsConstructor; 23 | 24 | @Entity 25 | @Getter 26 | @Builder 27 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 28 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 29 | public class TempGame extends BaseTimeEntity { 30 | 31 | @Id 32 | @GeneratedValue(strategy = GenerationType.IDENTITY) 33 | @Column(name = "id") 34 | private Long id; 35 | 36 | @ManyToOne(fetch = FetchType.LAZY) 37 | private TempGameSet tempGameSet; 38 | 39 | @OneToMany(mappedBy = "tempGame", cascade = CascadeType.ALL) 40 | private List tempGameOptions = new ArrayList<>(); 41 | 42 | @Size(max = 100) 43 | private String description; 44 | 45 | public void assignTempGameSet(TempGameSet tempGameSet) { 46 | this.tempGameSet = tempGameSet; 47 | } 48 | 49 | public void addTempGameOption(TempGameOption tempGameOption) { 50 | tempGameOption.assignTempGame(this); 51 | tempGameOptions.add(tempGameOption); 52 | } 53 | 54 | public void updateTempGame(TempGame newTempGame) { 55 | this.description = newTempGame.getDescription(); 56 | IntStream.range(0, newTempGame.getTempGameOptions().size()).forEach(i -> { 57 | if (i < this.tempGameOptions.size()) { 58 | TempGameOption currentOption = this.tempGameOptions.get(i); 59 | TempGameOption newOption = newTempGame.getTempGameOptions().get(i); 60 | currentOption.updateTempGameOption(newOption); 61 | } else { 62 | TempGameOption newOption = newTempGame.getTempGameOptions().get(i); 63 | this.addTempGameOption(newOption); 64 | } 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/domain/TempGameOption.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.domain; 2 | 3 | import balancetalk.vote.domain.VoteOption; 4 | import jakarta.persistence.Column; 5 | import jakarta.persistence.Entity; 6 | import jakarta.persistence.EnumType; 7 | import jakarta.persistence.Enumerated; 8 | import jakarta.persistence.FetchType; 9 | import jakarta.persistence.GeneratedValue; 10 | import jakarta.persistence.GenerationType; 11 | import jakarta.persistence.Id; 12 | import jakarta.persistence.JoinColumn; 13 | import jakarta.persistence.ManyToOne; 14 | import jakarta.validation.constraints.Size; 15 | import lombok.AccessLevel; 16 | import lombok.AllArgsConstructor; 17 | import lombok.Builder; 18 | import lombok.Getter; 19 | import lombok.NoArgsConstructor; 20 | 21 | @Entity 22 | @Getter 23 | @Builder 24 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 25 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 26 | public class TempGameOption { 27 | 28 | @Id 29 | @GeneratedValue(strategy = GenerationType.IDENTITY) 30 | @Column(name = "id") 31 | private Long id; 32 | 33 | @Size(max = 30) 34 | private String name; 35 | 36 | @Size(max = 50) 37 | private String description; 38 | 39 | @Enumerated(value = EnumType.STRING) 40 | private VoteOption optionType; 41 | 42 | private Long imgId; 43 | 44 | @ManyToOne(fetch = FetchType.LAZY) 45 | @JoinColumn(name = "temp_game_id") 46 | private TempGame tempGame; 47 | 48 | public void assignTempGame(TempGame tempGame) { 49 | this.tempGame = tempGame; 50 | } 51 | 52 | public void updateTempGameOption(TempGameOption newTempGameOption) { 53 | this.name = newTempGameOption.getName(); 54 | this.description = newTempGameOption.getDescription(); 55 | this.imgId = newTempGameOption.getImgId(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/domain/TempGameSet.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import balancetalk.member.domain.Member; 5 | import jakarta.persistence.CascadeType; 6 | import jakarta.persistence.Column; 7 | import jakarta.persistence.Entity; 8 | import jakarta.persistence.FetchType; 9 | import jakarta.persistence.GeneratedValue; 10 | import jakarta.persistence.GenerationType; 11 | import jakarta.persistence.Id; 12 | import jakarta.persistence.JoinColumn; 13 | import jakarta.persistence.OneToMany; 14 | import jakarta.persistence.OneToOne; 15 | import jakarta.validation.constraints.Size; 16 | import java.util.ArrayList; 17 | import java.util.List; 18 | import java.util.Objects; 19 | import java.util.stream.IntStream; 20 | import lombok.AccessLevel; 21 | import lombok.AllArgsConstructor; 22 | import lombok.Builder; 23 | import lombok.Getter; 24 | import lombok.NoArgsConstructor; 25 | 26 | @Entity 27 | @Getter 28 | @Builder 29 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 30 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 31 | public class TempGameSet extends BaseTimeEntity { 32 | 33 | @Id 34 | @GeneratedValue(strategy = GenerationType.IDENTITY) 35 | @Column(name = "id") 36 | private Long id; 37 | 38 | @OneToMany(mappedBy = "tempGameSet", cascade = CascadeType.ALL) 39 | private List tempGames = new ArrayList<>(); 40 | 41 | @Size(max = 50) 42 | private String title; 43 | 44 | @OneToOne(fetch = FetchType.LAZY) 45 | @JoinColumn(name = "member_id") 46 | private Member member; 47 | 48 | public void addGames(List tempGames) { 49 | this.tempGames = tempGames; 50 | tempGames.forEach(tempGame -> { 51 | tempGame.assignTempGameSet(this); 52 | tempGame.getTempGameOptions().forEach(option -> option.assignTempGame(tempGame)); 53 | }); 54 | } 55 | 56 | public void updateTempGameSet(String title, List newTempGames) { 57 | this.title = title; 58 | IntStream.range(0, this.tempGames.size()).forEach(i -> { 59 | TempGame existingGame = this.tempGames.get(i); 60 | TempGame newGame = newTempGames.get(i); 61 | existingGame.updateTempGame(newGame); 62 | }); 63 | } 64 | 65 | public List getAllFileIds() { 66 | return tempGames.stream() 67 | .flatMap(game -> game.getTempGameOptions().stream()) 68 | .map(TempGameOption::getImgId) 69 | .filter(Objects::nonNull) 70 | .toList(); 71 | } 72 | } -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/domain/repository/GameSetRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.domain.repository; 2 | 3 | import balancetalk.game.domain.GameSet; 4 | import java.util.List; 5 | import org.springframework.data.domain.Page; 6 | import org.springframework.data.domain.Pageable; 7 | import org.springframework.data.jpa.repository.JpaRepository; 8 | import org.springframework.data.jpa.repository.Query; 9 | import org.springframework.data.repository.query.Param; 10 | 11 | public interface GameSetRepository extends JpaRepository, GameSetRepositoryCustom { 12 | 13 | Page findAllByMemberIdOrderByEditedAtDesc(Long memberId, Pageable pageable); 14 | 15 | @Query("SELECT g FROM GameSet g " + 16 | "WHERE g.mainTag.name = :name " + 17 | "ORDER BY g.createdAt DESC") 18 | List findGamesByCreationDate(@Param("name") String mainTag, Pageable pageable); 19 | 20 | @Query("SELECT g FROM GameSet g " + 21 | "WHERE g.mainTag.name = :name " + 22 | "ORDER BY g.views DESC, g.createdAt DESC") 23 | List findGamesByViews(@Param("name") String mainTag, Pageable pageable); 24 | 25 | @Query("SELECT g FROM GameSet g " 26 | + "ORDER BY g.views DESC, " 27 | + "g.createdAt DESC") 28 | List findPopularGames(Pageable pageable); 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/domain/repository/GameSetRepositoryCustom.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.domain.repository; 2 | 3 | public interface GameSetRepositoryCustom { 4 | Long findRandomGameSetId(); 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/domain/repository/GameSetRepositoryCustomImpl.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.domain.repository; 2 | 3 | import static balancetalk.game.domain.QGameSet.gameSet; 4 | 5 | import balancetalk.global.exception.BalanceTalkException; 6 | import balancetalk.global.exception.ErrorCode; 7 | import com.querydsl.core.Tuple; 8 | import com.querydsl.core.types.dsl.Expressions; 9 | import com.querydsl.core.types.dsl.NumberTemplate; 10 | import com.querydsl.jpa.impl.JPAQueryFactory; 11 | import java.util.concurrent.ThreadLocalRandom; 12 | import lombok.RequiredArgsConstructor; 13 | 14 | @RequiredArgsConstructor 15 | public class GameSetRepositoryCustomImpl implements GameSetRepositoryCustom { 16 | 17 | private final JPAQueryFactory queryFactory; 18 | 19 | @Override 20 | public Long findRandomGameSetId() { 21 | // 1. MIN, MAX ID 조회 22 | NumberTemplate maxId = Expressions.numberTemplate(Long.class, 23 | "MAX({0})", gameSet.id); 24 | NumberTemplate minId = Expressions.numberTemplate(Long.class, 25 | "MIN({0})", gameSet.id); 26 | 27 | Tuple minMaxResult = queryFactory 28 | .select(minId, maxId) 29 | .from(gameSet) 30 | .fetchOne(); 31 | 32 | if (minMaxResult == null) { 33 | throw new BalanceTalkException(ErrorCode.NOT_FOUND_BALANCE_GAME); 34 | } 35 | 36 | Long min = minMaxResult.get(minId); 37 | Long max = minMaxResult.get(maxId); 38 | 39 | if (min == null || max == null) { 40 | throw new BalanceTalkException(ErrorCode.NOT_FOUND_BALANCE_GAME); 41 | } 42 | 43 | // 2. 랜덤 ID 생성 44 | long randomId = ThreadLocalRandom.current().nextLong(min, max + 1); 45 | 46 | // 3. randomId 이상의 첫 번째 ID 조회 47 | Long result = queryFactory 48 | .select(gameSet.id) 49 | .from(gameSet) 50 | .where(gameSet.id.goe(randomId)) 51 | .orderBy(gameSet.id.asc()) 52 | .limit(1) 53 | .fetchFirst(); 54 | 55 | if (result == null) { 56 | throw new BalanceTalkException(ErrorCode.NOT_FOUND_BALANCE_GAME); 57 | } 58 | 59 | return result; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/domain/repository/MainTagRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.domain.repository; 2 | 3 | import balancetalk.game.domain.MainTag; 4 | import java.util.Optional; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | 7 | public interface MainTagRepository extends JpaRepository { 8 | 9 | Optional findByName(String name); 10 | 11 | boolean existsByName(String mainTag); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/domain/repository/TempGameRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.domain.repository; 2 | 3 | import balancetalk.game.domain.TempGame; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface TempGameRepository extends JpaRepository { 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/domain/repository/TempGameSetRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.domain.repository; 2 | 3 | import balancetalk.game.domain.TempGameSet; 4 | import balancetalk.member.domain.Member; 5 | import java.util.Optional; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | 8 | public interface TempGameSetRepository extends JpaRepository { 9 | Optional findByMember(Member member); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/dto/GameOptionDto.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.dto; 2 | 3 | import balancetalk.game.domain.GameOption; 4 | import balancetalk.vote.domain.VoteOption; 5 | import com.fasterxml.jackson.annotation.JsonInclude; 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Builder; 9 | import lombok.Data; 10 | 11 | @Data 12 | @Builder 13 | @AllArgsConstructor 14 | @Schema(description = "밸런스 게임 선택지") 15 | public class GameOptionDto { 16 | 17 | private Long id; 18 | 19 | @Schema(description = "선택지 이름", example = "선택지 이름") 20 | private String name; 21 | 22 | @JsonInclude(JsonInclude.Include.NON_NULL) 23 | @Schema(description = "선택지 이미지 파일 ID", example = "12") 24 | private Long fileId; 25 | 26 | @JsonInclude(JsonInclude.Include.NON_NULL) 27 | @Schema(description = "선택지 이미지", 28 | example = "https://pikko-image.s3.ap-northeast-2.amazonaws.com/balance-game/4839036ee7cd_unnamed.png", 29 | accessMode = Schema.AccessMode.READ_ONLY) 30 | private String imgUrl; 31 | 32 | @Schema(description = "선택지 추가설명", example = "선택지 추가 설명") 33 | private String description; 34 | 35 | @Schema(description = "선택지", example = "A") 36 | private VoteOption optionType; 37 | 38 | public static GameOptionDto fromEntity(GameOption gameOption, Long fileId, String imgUrl) { 39 | return GameOptionDto.builder() 40 | .id(gameOption.getId()) 41 | .name(gameOption.getName()) 42 | .fileId(fileId) 43 | .imgUrl(imgUrl) 44 | .description(gameOption.getDescription()) 45 | .optionType(gameOption.getOptionType()) 46 | .build(); 47 | } 48 | 49 | public GameOption toEntity() { 50 | return GameOption.builder() 51 | .name(name) 52 | .imgId(fileId) 53 | .description(description) 54 | .optionType(optionType) 55 | .build(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/dto/SearchGameResponse.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.dto; 2 | 3 | import balancetalk.game.domain.Game; 4 | import com.fasterxml.jackson.annotation.JsonInclude; 5 | import io.swagger.v3.oas.annotations.media.Schema; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | 10 | @Schema(description = "밸런스 게임 검색 응답") 11 | @Data 12 | @AllArgsConstructor 13 | @Builder 14 | @JsonInclude(JsonInclude.Include.NON_NULL) 15 | public class SearchGameResponse { 16 | 17 | @Schema(description = "작성자 id", example = "1") 18 | private Long writerId; 19 | 20 | @Schema(description = "밸런스 게임 ID", example = "1") 21 | private long id; 22 | 23 | @Schema(description = "밸런스 게임 세트 ID", example = "1") 24 | private long gameSetId; 25 | 26 | @Schema(description = "밸런스게임 세트 제목", example = "제목") 27 | private String title; 28 | 29 | @Schema(description = "선택지 A 이미지", example = "https://pikko-image.s3.ap-northeast-2.amazonaws.com/balance-game/067cc56e-21b7-468f-a2c1-4839036ee7cd_unnamed.png") 30 | private String optionAImg; 31 | 32 | @Schema(description = "선택지 B 이미지", example = "https://pikko-image.s3.ap-northeast-2.amazonaws.com/balance-game/1157461e-a685-42fd-837e-7ed490894ca6_unnamed.png") 33 | private String optionBImg; 34 | 35 | @Schema(description = "밸런스 게임 서브 태그", example = "화제의 중심") 36 | private String subTag; 37 | 38 | @Schema(description = "밸런스 게임 메인 태그", example = "인기") 39 | private String mainTag; 40 | 41 | public static SearchGameResponse from(Game game, String imgA, String imgB) { 42 | return SearchGameResponse.builder() 43 | .writerId(game.getWriterId()) 44 | .gameSetId(game.getGameSet().getId()) 45 | .id(game.getId()) 46 | .title(game.getGameSet().getTitle()) 47 | .optionAImg(imgA) 48 | .optionBImg(imgB) 49 | .subTag(game.getGameSet().getSubTag()) 50 | .mainTag(game.getGameSet().getMainTag().getName()) 51 | .build(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/dto/TempGameDto.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.dto; 2 | 3 | import balancetalk.game.domain.TempGame; 4 | import balancetalk.game.domain.TempGameOption; 5 | import io.swagger.v3.oas.annotations.media.Schema; 6 | import java.util.List; 7 | import java.util.Map; 8 | import lombok.AllArgsConstructor; 9 | import lombok.Builder; 10 | import lombok.Data; 11 | 12 | public class TempGameDto { 13 | 14 | @Data 15 | @Builder 16 | @AllArgsConstructor 17 | @Schema(description = "임시 밸런스 게임 저장 요청") 18 | public static class CreateTempGameRequest { 19 | 20 | @Schema(description = "게임 추가 설명", example = "추가 설명") 21 | private String description; 22 | 23 | private List tempGameOptions; 24 | 25 | public TempGame toEntity() { 26 | List options = tempGameOptions.stream() 27 | .map(TempGameOptionDto::toEntity) 28 | .toList(); 29 | 30 | return TempGame.builder() 31 | .description(description) 32 | .tempGameOptions(options) 33 | .build(); 34 | } 35 | } 36 | 37 | @Data 38 | @Builder 39 | @AllArgsConstructor 40 | @Schema(description = "임시 밸런스 게임 응답") 41 | public static class TempGameResponse { 42 | 43 | @Schema(description = "게임 추가 설명", example = "추가 설명") 44 | private String description; 45 | 46 | private List tempGameOptions; 47 | 48 | public static TempGameResponse fromEntity(TempGame tempGame, Map tempGameOptionImgUrls) { 49 | return TempGameResponse.builder() 50 | .description(tempGame.getDescription()) 51 | .tempGameOptions(getTempGameOptionDtos(tempGame, tempGameOptionImgUrls)) 52 | .build(); 53 | } 54 | } 55 | 56 | public static List getTempGameOptionDtos( 57 | TempGame tempGame, 58 | Map tempGameOptionImgUrls 59 | ) { 60 | return tempGame.getTempGameOptions().stream() 61 | .map(option -> { 62 | Long fileId = option.getImgId(); 63 | String imgUrl = tempGameOptionImgUrls.get(option.getId()); 64 | return TempGameOptionDto.fromEntity(option, fileId, imgUrl); 65 | }) 66 | .toList(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/dto/TempGameOptionDto.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.dto; 2 | 3 | import balancetalk.game.domain.TempGameOption; 4 | import balancetalk.vote.domain.VoteOption; 5 | import com.fasterxml.jackson.annotation.JsonInclude; 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Builder; 9 | import lombok.Data; 10 | 11 | 12 | @Data 13 | @Builder 14 | @AllArgsConstructor 15 | @Schema(description = "임시 밸런스 게임 선택지") 16 | public class TempGameOptionDto { 17 | 18 | @Schema(description = "선택지 이름", example = "선택지 이름") 19 | private String name; 20 | 21 | @JsonInclude(JsonInclude.Include.NON_NULL) 22 | @Schema(description = "선택지 이미지 파일 ID", example = "1") 23 | private Long fileId; 24 | 25 | @JsonInclude(JsonInclude.Include.NON_NULL) 26 | @Schema(description = "선택지 이미지", 27 | example = "https://pikko-image.s3.ap-northeast-2.amazonaws.com/balance-game/4839036ee7cd_unnamed.png", 28 | accessMode = Schema.AccessMode.READ_ONLY) 29 | private String imgUrl; 30 | 31 | @Schema(description = "선택지 추가설명", example = "선택지 추가 설명") 32 | private String description; 33 | 34 | @Schema(description = "선택지", example = "A") 35 | private VoteOption optionType; 36 | 37 | public TempGameOption toEntity() { 38 | return TempGameOption.builder() 39 | .name(name) 40 | .imgId(fileId) 41 | .description(description) 42 | .optionType(optionType) 43 | .build(); 44 | } 45 | 46 | public static TempGameOptionDto fromEntity(TempGameOption tempGameOption, Long fileId, String imgUrl) { 47 | return TempGameOptionDto.builder() 48 | .name(tempGameOption.getName()) 49 | .fileId(fileId) 50 | .imgUrl(imgUrl) 51 | .description(tempGameOption.getDescription()) 52 | .optionType(tempGameOption.getOptionType()) 53 | .build(); 54 | } 55 | } -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/dto/TempGameSetDto.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.dto; 2 | 3 | import balancetalk.game.domain.TempGameSet; 4 | import balancetalk.game.dto.TempGameDto.CreateTempGameRequest; 5 | import balancetalk.game.dto.TempGameDto.TempGameResponse; 6 | import balancetalk.member.domain.Member; 7 | import com.fasterxml.jackson.annotation.JsonIgnore; 8 | import io.swagger.v3.oas.annotations.media.Schema; 9 | import jakarta.validation.constraints.NotNull; 10 | import jakarta.validation.constraints.Size; 11 | import java.util.List; 12 | import java.util.Objects; 13 | import lombok.AllArgsConstructor; 14 | import lombok.Builder; 15 | import lombok.Data; 16 | 17 | public class TempGameSetDto { 18 | 19 | @Data 20 | public static class CreateTempGameSetRequest { 21 | 22 | @Schema(description = "밸런스게임 세트 제목", example = "밸런스게임 세트 제목") 23 | @Size(max = 50, message = "제목은 최대 50자까지 입력 가능합니다") 24 | private String title; 25 | 26 | @Schema(description = "최근 임시저장된 밸런스 게임 불러오기 여부", example = "false") 27 | @NotNull(message = "isLoaded 필드는 NULL을 허용하지 않습니다.") 28 | private Boolean isLoaded; 29 | 30 | private List tempGames; 31 | 32 | public TempGameSet toEntity(Member member) { 33 | return TempGameSet.builder() 34 | .title(title) 35 | .member(member) 36 | .build(); 37 | } 38 | 39 | @JsonIgnore 40 | public List getAllFileIds() { 41 | return tempGames.stream() 42 | .flatMap(game -> game.getTempGameOptions().stream()) 43 | .map(TempGameOptionDto::getFileId) 44 | .filter(Objects::nonNull) 45 | .toList(); 46 | } 47 | 48 | @JsonIgnore 49 | public boolean isNewRequest() { 50 | return !isLoaded; 51 | } 52 | } 53 | 54 | @Data 55 | @Builder 56 | @AllArgsConstructor 57 | @Schema(description = "임시 밸런스 게임 세트 조회 응답") 58 | public static class TempGameSetResponse { 59 | 60 | @Schema(description = "밸런스게임 세트 제목", example = "밸런스게임 세트 제목") 61 | private String title; 62 | 63 | @Schema(description = "게임 리스트") 64 | private List tempGames; 65 | 66 | public static TempGameSetResponse fromEntity(TempGameSet tempGameSet, List tempGames) { 67 | return TempGameSetResponse.builder() 68 | .title(tempGameSet.getTitle()) 69 | .tempGames(tempGames) 70 | .build(); 71 | } 72 | 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/presentation/GameTagController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.presentation; 2 | 3 | import static balancetalk.game.dto.GameDto.*; 4 | import balancetalk.game.application.GameService; 5 | import balancetalk.global.utils.AuthPrincipal; 6 | import balancetalk.member.dto.ApiMember; 7 | import io.swagger.v3.oas.annotations.Operation; 8 | import io.swagger.v3.oas.annotations.Parameter; 9 | import io.swagger.v3.oas.annotations.tags.Tag; 10 | import jakarta.validation.Valid; 11 | import lombok.RequiredArgsConstructor; 12 | import lombok.extern.slf4j.Slf4j; 13 | import org.springframework.web.bind.annotation.PostMapping; 14 | import org.springframework.web.bind.annotation.RequestBody; 15 | import org.springframework.web.bind.annotation.RequestMapping; 16 | import org.springframework.web.bind.annotation.RestController; 17 | 18 | @Slf4j 19 | @RestController 20 | @RequiredArgsConstructor 21 | @RequestMapping("/games") 22 | @Tag(name = "game", description = "밸런스 게임 API") 23 | public class GameTagController { 24 | 25 | private final GameService gameService; 26 | 27 | @PostMapping("/main-tags") 28 | @Operation(summary = "새로운 밸런스 메인 태그 생성", description = "새로운 밸런스 게임 메인 태그를 생성합니다.") 29 | public void createGameMainTag(@Valid @RequestBody CreateGameMainTagRequest request, 30 | @Parameter(hidden = true) @AuthPrincipal final ApiMember apiMember) { 31 | gameService.createGameMainTag(request, apiMember); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/presentation/SearchGameController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.presentation; 2 | 3 | import balancetalk.game.application.SearchGameService; 4 | import balancetalk.game.dto.SearchGameResponse; 5 | import io.swagger.v3.oas.annotations.Operation; 6 | import io.swagger.v3.oas.annotations.tags.Tag; 7 | import lombok.RequiredArgsConstructor; 8 | import org.springframework.data.domain.Page; 9 | import org.springframework.data.domain.PageRequest; 10 | import org.springframework.data.domain.Pageable; 11 | import org.springframework.web.bind.annotation.GetMapping; 12 | import org.springframework.web.bind.annotation.RequestParam; 13 | import org.springframework.web.bind.annotation.RestController; 14 | 15 | @RestController 16 | @RequiredArgsConstructor 17 | @Tag(name = "search", description = "밸런스게임 검색 API") 18 | public class SearchGameController { 19 | 20 | private final SearchGameService searchGameService; 21 | 22 | @Operation(summary = "밸런스게임 검색", description = "밸런스게임을 검색합니다. (정렬 기준 : views, createdAt)") 23 | @GetMapping("/search/game-sets") 24 | public Page searchTalkPicks(@RequestParam("query") String query, 25 | @RequestParam(defaultValue = "0") int page, 26 | @RequestParam(defaultValue = "9", required = false) int size, 27 | @RequestParam(defaultValue = "views") String sort) { 28 | 29 | Pageable pageable = PageRequest.of(page, size); 30 | return searchGameService.search(query, pageable, sort); 31 | } 32 | } -------------------------------------------------------------------------------- /src/main/java/balancetalk/game/presentation/TempGameController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.game.presentation; 2 | 3 | import balancetalk.game.application.TempGameService; 4 | import balancetalk.game.dto.TempGameSetDto.CreateTempGameSetRequest; 5 | import balancetalk.game.dto.TempGameSetDto.TempGameSetResponse; 6 | import balancetalk.global.utils.AuthPrincipal; 7 | import balancetalk.member.dto.ApiMember; 8 | import io.swagger.v3.oas.annotations.Operation; 9 | import io.swagger.v3.oas.annotations.Parameter; 10 | import io.swagger.v3.oas.annotations.tags.Tag; 11 | import lombok.RequiredArgsConstructor; 12 | import org.springframework.web.bind.annotation.GetMapping; 13 | import org.springframework.web.bind.annotation.PostMapping; 14 | import org.springframework.web.bind.annotation.RequestBody; 15 | import org.springframework.web.bind.annotation.RequestMapping; 16 | import org.springframework.web.bind.annotation.RestController; 17 | 18 | @RestController 19 | @RequiredArgsConstructor 20 | @RequestMapping("/games/temp") 21 | @Tag(name = "game", description = "밸런스 게임 API") 22 | public class TempGameController { 23 | 24 | private final TempGameService tempGameService; 25 | 26 | @Operation(summary = "밸런스 게임 임시 저장", description = "밸런스 게임을 임시 저장합니다.") 27 | @PostMapping 28 | public void saveTempGameSet(@RequestBody CreateTempGameSetRequest request, 29 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 30 | tempGameService.createTempGame(request, apiMember); 31 | } 32 | 33 | @Operation(summary = "밸런스 게임 임시 저장 불러오기", description = "임시 저장된 밸런스 게임을 불러옵니다.") 34 | @GetMapping 35 | public TempGameSetResponse findTempGameSet(@Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 36 | return tempGameService.findTempGameSet(apiMember); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/caffeine/CacheConfig.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.caffeine; 2 | 3 | import com.github.benmanes.caffeine.cache.Caffeine; 4 | import java.util.Arrays; 5 | import java.util.List; 6 | import java.util.concurrent.TimeUnit; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.cache.CacheManager; 9 | import org.springframework.cache.annotation.EnableCaching; 10 | import org.springframework.cache.caffeine.CaffeineCache; 11 | import org.springframework.cache.support.SimpleCacheManager; 12 | import org.springframework.context.annotation.Bean; 13 | import org.springframework.context.annotation.Configuration; 14 | 15 | @Slf4j 16 | @Configuration 17 | @EnableCaching 18 | public class CacheConfig { 19 | 20 | @Bean 21 | public CacheManager cacheManager() { 22 | SimpleCacheManager cacheManager = new SimpleCacheManager(); 23 | List caches = Arrays.stream(CacheType.values()) 24 | .map(cache -> new CaffeineCache(cache.getCacheName(), Caffeine.newBuilder().recordStats() 25 | .expireAfterWrite(cache.getExpiredAfterWrite(), TimeUnit.SECONDS) 26 | .maximumSize(cache.getMaximumSize()) 27 | .build() 28 | ) 29 | ).toList(); 30 | cacheManager.setCaches(caches); 31 | return cacheManager; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/caffeine/CacheType.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.caffeine; 2 | 3 | import lombok.Getter; 4 | 5 | @Getter 6 | public enum CacheType { 7 | 8 | RefreshToken("refreshToken", 604800000, 10000), 9 | TempCode("tempCode", 1800, 10000); 10 | 11 | private String cacheName; 12 | private int expiredAfterWrite; 13 | private int maximumSize; 14 | 15 | CacheType(String cacheName, int expiredAfterWrite, int maximumSize) { 16 | this.cacheName = cacheName; 17 | this.expiredAfterWrite = expiredAfterWrite; 18 | this.maximumSize = maximumSize; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/common/BaseTimeEntity.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.common; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.EntityListeners; 5 | import jakarta.persistence.MappedSuperclass; 6 | import java.time.LocalDateTime; 7 | import lombok.Getter; 8 | import org.springframework.data.annotation.CreatedDate; 9 | import org.springframework.data.annotation.LastModifiedDate; 10 | import org.springframework.data.jpa.domain.support.AuditingEntityListener; 11 | 12 | @Getter 13 | @MappedSuperclass 14 | @EntityListeners(AuditingEntityListener.class) 15 | public abstract class BaseTimeEntity { 16 | 17 | @CreatedDate 18 | @Column(updatable = false) 19 | private LocalDateTime createdAt; 20 | 21 | @LastModifiedDate 22 | private LocalDateTime lastModifiedAt; 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/config/AsyncConfig.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.config; 2 | 3 | import balancetalk.global.exception.CustomAsyncUncaughtExceptionHandler; 4 | import java.util.concurrent.Executor; 5 | import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.scheduling.annotation.AsyncConfigurer; 9 | import org.springframework.scheduling.annotation.EnableAsync; 10 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 11 | 12 | @EnableAsync 13 | @Configuration 14 | public class AsyncConfig implements AsyncConfigurer { 15 | 16 | @Bean 17 | public Executor fileMappingTaskExecutor() { 18 | ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); 19 | executor.setCorePoolSize(4); 20 | executor.setThreadNamePrefix("FileMappingTask - "); 21 | executor.initialize(); 22 | return executor; 23 | } 24 | 25 | @Bean 26 | public Executor talkPickSummaryTaskExecutor() { 27 | ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); 28 | executor.setCorePoolSize(6); 29 | executor.setThreadNamePrefix("TalkPickSummaryTask - "); 30 | executor.initialize(); 31 | return executor; 32 | } 33 | 34 | @Override 35 | public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { 36 | return new CustomAsyncUncaughtExceptionHandler(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/config/MySQLFunctionContributor.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.config; 2 | 3 | import org.hibernate.boot.model.FunctionContributions; 4 | import org.hibernate.boot.model.FunctionContributor; 5 | import org.hibernate.type.BasicType; 6 | import org.hibernate.type.StandardBasicTypes; 7 | 8 | public class MySQLFunctionContributor implements FunctionContributor { 9 | 10 | @Override 11 | public void contributeFunctions(FunctionContributions functionContributions) { 12 | functionContributions.getFunctionRegistry() 13 | .registerPattern( 14 | "match_talk_pick_in_boolean_mode", 15 | "MATCH(?1, ?2, ?3, ?4, ?5, ?6, ?7) AGAINST(?8 IN BOOLEAN MODE)", 16 | getBooleanBasicType(functionContributions)); 17 | 18 | functionContributions.getFunctionRegistry() 19 | .registerPattern( 20 | "match_talk_pick_in_natural_mode", 21 | "MATCH(?1, ?2, ?3, ?4, ?5, ?6, ?7) AGAINST(?8)", 22 | getBooleanBasicType(functionContributions)); 23 | } 24 | 25 | private BasicType getBooleanBasicType(FunctionContributions functionContributions) { 26 | return functionContributions.getTypeConfiguration() 27 | .getBasicTypeRegistry() 28 | .resolve(StandardBasicTypes.BOOLEAN); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/config/OpenaiConfig.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.config; 2 | 3 | import org.springframework.ai.chat.client.ChatClient; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | @Configuration 8 | public class OpenaiConfig { 9 | 10 | @Bean 11 | public ChatClient chatClient(ChatClient.Builder builder) { 12 | return builder.build(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/config/QuerydslConfig.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.config; 2 | 3 | import com.querydsl.jpa.impl.JPAQueryFactory; 4 | import jakarta.persistence.EntityManager; 5 | import jakarta.persistence.PersistenceContext; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | @Configuration 10 | public class QuerydslConfig { 11 | 12 | @PersistenceContext 13 | private EntityManager entityManager; 14 | 15 | @Bean 16 | public JPAQueryFactory jpaQueryFactory() { 17 | return new JPAQueryFactory(entityManager); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/config/SwaggerConfig.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.config; 2 | 3 | import io.swagger.v3.oas.annotations.OpenAPIDefinition; 4 | import io.swagger.v3.oas.annotations.info.Info; 5 | import io.swagger.v3.oas.models.Components; 6 | import io.swagger.v3.oas.models.OpenAPI; 7 | import io.swagger.v3.oas.models.security.SecurityRequirement; 8 | import io.swagger.v3.oas.models.security.SecurityScheme; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | 12 | @OpenAPIDefinition(info = @Info(title = "Balance Talk API 명세서", version = "v1")) 13 | @Configuration 14 | public class SwaggerConfig { 15 | @Bean 16 | public OpenAPI openAPI() { 17 | 18 | String jwtSchemeName = "AccessToken"; 19 | 20 | SecurityRequirement securityRequirement = new SecurityRequirement() 21 | .addList(jwtSchemeName); 22 | 23 | Components components = new Components() 24 | .addSecuritySchemes(jwtSchemeName, new SecurityScheme() 25 | .name(jwtSchemeName) 26 | .type(SecurityScheme.Type.HTTP) 27 | .scheme("bearer") 28 | .bearerFormat("JWT")); 29 | return new OpenAPI() 30 | .components(new Components()) 31 | .addSecurityItem(securityRequirement) 32 | .components(components); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/config/WebConfig.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.config; 2 | 3 | import balancetalk.global.jwt.ApiMemberArgumentResolver; 4 | import balancetalk.global.jwt.GuestOrApiMemberArgumentResolver; 5 | import balancetalk.global.jwt.JwtTokenProvider; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.web.method.support.HandlerMethodArgumentResolver; 9 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 10 | 11 | import java.util.List; 12 | 13 | @Configuration 14 | @RequiredArgsConstructor 15 | public class WebConfig implements WebMvcConfigurer { 16 | 17 | private final JwtTokenProvider jwtTokenProvider; 18 | 19 | @Override 20 | public void addArgumentResolvers(List resolvers) { 21 | resolvers.add(new GuestOrApiMemberArgumentResolver(jwtTokenProvider)); 22 | resolvers.add(new ApiMemberArgumentResolver(jwtTokenProvider)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/exception/BalanceTalkException.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.exception; 2 | 3 | import lombok.Getter; 4 | 5 | @Getter 6 | public class BalanceTalkException extends RuntimeException { 7 | 8 | private final ErrorCode errorCode; 9 | 10 | public BalanceTalkException(ErrorCode errorCode) { 11 | super(errorCode.getMessage()); 12 | this.errorCode = errorCode; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/exception/CustomAsyncUncaughtExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.exception; 2 | 3 | import java.lang.reflect.Method; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; 6 | 7 | @Slf4j 8 | public class CustomAsyncUncaughtExceptionHandler implements AsyncUncaughtExceptionHandler { 9 | 10 | @Override 11 | public void handleUncaughtException(Throwable ex, Method method, Object... params) { 12 | log.error("exception message - {} {}", ex.getMessage(), ex.getStackTrace()); 13 | log.error("method name - {}", method.getName()); 14 | for (Object param : params) { 15 | log.error("param value - {}", param); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/exception/ErrorResponse.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.exception; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | import org.springframework.http.HttpStatus; 7 | 8 | @Getter 9 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 10 | public class ErrorResponse { 11 | 12 | private HttpStatus httpStatus; 13 | private String message; 14 | 15 | public static ErrorResponse from(HttpStatus httpStatus, String message) { 16 | return new ErrorResponse(httpStatus, message); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/exception/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.exception; 2 | 3 | import jakarta.validation.ConstraintViolation; 4 | import jakarta.validation.ConstraintViolationException; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.http.HttpStatus; 7 | import org.springframework.http.ResponseEntity; 8 | import org.springframework.web.bind.MethodArgumentNotValidException; 9 | import org.springframework.web.bind.annotation.ExceptionHandler; 10 | import org.springframework.web.bind.annotation.RestControllerAdvice; 11 | import org.springframework.web.multipart.MaxUploadSizeExceededException; 12 | 13 | import java.util.Set; 14 | import java.util.stream.Collectors; 15 | 16 | import static balancetalk.global.exception.ErrorCode.FILE_SIZE_EXCEEDED; 17 | import static org.springframework.http.HttpStatus.BAD_REQUEST; 18 | 19 | @Slf4j 20 | @RestControllerAdvice 21 | public class GlobalExceptionHandler { 22 | 23 | @ExceptionHandler(BalanceTalkException.class) 24 | public ResponseEntity handleBalanceTalkException(BalanceTalkException e) { 25 | ErrorResponse response = ErrorResponse.from(e.getErrorCode().getHttpStatus(), e.getMessage()); 26 | log.error("exception message = {}", e.getMessage()); 27 | return ResponseEntity.status(response.getHttpStatus()).body(response); 28 | } 29 | 30 | @ExceptionHandler(MethodArgumentNotValidException.class) 31 | public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { 32 | String message = e.getBindingResult().getFieldError().getDefaultMessage(); 33 | log.error("exception message = {}", message); 34 | return ErrorResponse.from(BAD_REQUEST, message); 35 | } 36 | 37 | @ExceptionHandler(ConstraintViolationException.class) 38 | public ResponseEntity handleConstraintViolationException(ConstraintViolationException e) { 39 | Set> violations = e.getConstraintViolations(); 40 | String message = violations.stream() 41 | .map(ConstraintViolation::getMessage) 42 | .collect(Collectors.joining("\n")); 43 | log.error("exception message = {}", message); 44 | ErrorResponse response = ErrorResponse.from(HttpStatus.BAD_REQUEST, message); 45 | return ResponseEntity.status(response.getHttpStatus()).body(response); 46 | } 47 | 48 | @ExceptionHandler(MaxUploadSizeExceededException.class) 49 | public ErrorResponse handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { 50 | log.error("exception message = {}", e.getMessage()); 51 | return ErrorResponse.from(FILE_SIZE_EXCEEDED.getHttpStatus(), e.getMessage()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/jwt/ApiMemberArgumentResolver.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.jwt; 2 | 3 | import balancetalk.global.exception.BalanceTalkException; 4 | import balancetalk.global.exception.ErrorCode; 5 | import balancetalk.global.utils.AuthPrincipal; 6 | import balancetalk.member.dto.ApiMember; 7 | import jakarta.servlet.http.HttpServletRequest; 8 | import lombok.RequiredArgsConstructor; 9 | import org.springframework.core.MethodParameter; 10 | import org.springframework.stereotype.Component; 11 | import org.springframework.web.bind.support.WebDataBinderFactory; 12 | import org.springframework.web.context.request.NativeWebRequest; 13 | import org.springframework.web.method.support.HandlerMethodArgumentResolver; 14 | import org.springframework.web.method.support.ModelAndViewContainer; 15 | 16 | @Component 17 | @RequiredArgsConstructor 18 | public class ApiMemberArgumentResolver implements HandlerMethodArgumentResolver { 19 | 20 | private final JwtTokenProvider jwtTokenProvider; 21 | 22 | @Override 23 | public boolean supportsParameter(MethodParameter parameter) { 24 | return parameter.getParameterType().equals(ApiMember.class) && parameter.hasParameterAnnotation( 25 | AuthPrincipal.class); 26 | } 27 | 28 | @Override 29 | public ApiMember resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, 30 | NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { 31 | HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); 32 | String accessToken = jwtTokenProvider.resolveToken(request); 33 | 34 | if (accessToken == null) { 35 | throw new BalanceTalkException(ErrorCode.EMPTY_JWT_TOKEN); 36 | } 37 | 38 | String token = jwtTokenProvider.resolveToken(request); 39 | jwtTokenProvider.validateToken(token); 40 | Long memberId = jwtTokenProvider.getMemberId(token); 41 | return new ApiMember(memberId); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/jwt/CustomSuccessHandler.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.jwt; 2 | 3 | import static balancetalk.global.caffeine.CacheType.RefreshToken; 4 | import static balancetalk.global.exception.ErrorCode.CACHE_NOT_FOUND; 5 | 6 | import balancetalk.global.exception.BalanceTalkException; 7 | import balancetalk.global.exception.ErrorCode; 8 | import balancetalk.global.oauth2.dto.CustomOAuth2User; 9 | import balancetalk.member.domain.Member; 10 | import balancetalk.member.domain.MemberRepository; 11 | import jakarta.servlet.ServletException; 12 | import jakarta.servlet.http.HttpServletRequest; 13 | import jakarta.servlet.http.HttpServletResponse; 14 | import java.io.IOException; 15 | import java.util.Optional; 16 | import lombok.RequiredArgsConstructor; 17 | import org.springframework.beans.factory.annotation.Value; 18 | import org.springframework.cache.CacheManager; 19 | import org.springframework.security.core.Authentication; 20 | import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; 21 | import org.springframework.stereotype.Component; 22 | 23 | @Component 24 | @RequiredArgsConstructor 25 | public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { 26 | 27 | @Value("${urls.alreadyRegistered}") 28 | private String alreadyRegisteredUrl; 29 | 30 | private final JwtTokenProvider jwtTokenProvider; 31 | private final MemberRepository memberRepository; 32 | private final CacheManager cacheManager; 33 | 34 | @Override 35 | public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, 36 | Authentication authentication) throws IOException, ServletException { 37 | 38 | // Oauth2User 39 | CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal(); 40 | 41 | String email = customUserDetails.getEmail(); 42 | 43 | Member member = memberRepository.findByEmail(email) 44 | .orElseThrow(() -> new BalanceTalkException(ErrorCode.NOT_FOUND_MEMBER)); 45 | String redirectUrl = customUserDetails.getRedirectUrl(); 46 | 47 | if (redirectUrl.equals(alreadyRegisteredUrl)) { // 이미 가입한 회원인 경우에, 쿠키 생성과 토큰 생성을 하지 않고 리다이렉트 처리 48 | response.sendRedirect(redirectUrl); 49 | return; 50 | } 51 | 52 | String refreshToken = jwtTokenProvider.createRefreshToken(authentication, member.getId()); 53 | 54 | response.addCookie(JwtTokenProvider.createCookie(refreshToken)); 55 | 56 | Optional.ofNullable(cacheManager.getCache(RefreshToken.getCacheName())) 57 | .ifPresentOrElse( 58 | cache -> cache.put(member.getId(), refreshToken), 59 | () -> { 60 | throw new BalanceTalkException(CACHE_NOT_FOUND); 61 | }); 62 | 63 | response.sendRedirect(redirectUrl); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/jwt/GuestOrApiMemberArgumentResolver.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.jwt; 2 | 3 | import balancetalk.global.utils.AuthPrincipal; 4 | import balancetalk.member.dto.GuestOrApiMember; 5 | import jakarta.servlet.http.HttpServletRequest; 6 | import lombok.RequiredArgsConstructor; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.core.MethodParameter; 9 | import org.springframework.stereotype.Component; 10 | import org.springframework.web.bind.support.WebDataBinderFactory; 11 | import org.springframework.web.context.request.NativeWebRequest; 12 | import org.springframework.web.method.support.HandlerMethodArgumentResolver; 13 | import org.springframework.web.method.support.ModelAndViewContainer; 14 | 15 | @Slf4j 16 | @Component 17 | @RequiredArgsConstructor 18 | public class GuestOrApiMemberArgumentResolver implements HandlerMethodArgumentResolver { 19 | 20 | private final JwtTokenProvider jwtTokenProvider; 21 | 22 | @Override 23 | public boolean supportsParameter(MethodParameter parameter) { 24 | return parameter.getParameterType().equals(GuestOrApiMember.class) && parameter.hasParameterAnnotation( 25 | AuthPrincipal.class); 26 | } 27 | 28 | @Override 29 | public GuestOrApiMember resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, 30 | NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { 31 | HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); 32 | String accessToken = jwtTokenProvider.resolveToken(request); 33 | 34 | if (accessToken == null) { // 비회원일 때 35 | return new GuestOrApiMember(-1L); 36 | } 37 | 38 | String token = jwtTokenProvider.resolveToken(request); 39 | jwtTokenProvider.validateToken(token); 40 | Long memberId = jwtTokenProvider.getMemberId(token); 41 | 42 | return new GuestOrApiMember(memberId); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/jwt/JwtAccessDeniedHandler.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.jwt; 2 | 3 | import jakarta.servlet.ServletException; 4 | import jakarta.servlet.http.HttpServletRequest; 5 | import jakarta.servlet.http.HttpServletResponse; 6 | import org.springframework.security.access.AccessDeniedException; 7 | import org.springframework.security.web.access.AccessDeniedHandler; 8 | import org.springframework.stereotype.Component; 9 | 10 | import java.io.IOException; 11 | 12 | @Component 13 | public class JwtAccessDeniedHandler implements AccessDeniedHandler { 14 | @Override 15 | public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { 16 | response.sendError(HttpServletResponse.SC_FORBIDDEN); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/jwt/JwtAuthenticationEntryPoint.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.jwt; 2 | 3 | import balancetalk.global.exception.ErrorCode; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import jakarta.servlet.ServletException; 6 | import jakarta.servlet.http.HttpServletRequest; 7 | import jakarta.servlet.http.HttpServletResponse; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.http.HttpStatus; 10 | import org.springframework.security.core.AuthenticationException; 11 | import org.springframework.security.web.AuthenticationEntryPoint; 12 | import org.springframework.stereotype.Component; 13 | 14 | import java.io.IOException; 15 | import java.util.HashMap; 16 | import java.util.Map; 17 | 18 | @Slf4j 19 | @Component 20 | public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { 21 | @Override 22 | public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { 23 | 24 | response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); 25 | response.setCharacterEncoding("utf-8"); 26 | response.setContentType("application/json"); 27 | 28 | ObjectMapper objectMapper = new ObjectMapper(); 29 | Map jsonMessage = new HashMap<>(); 30 | 31 | String errorMessage = (String) request.getAttribute("exception"); 32 | jsonMessage.put("httpStatus", HttpStatus.UNAUTHORIZED); 33 | jsonMessage.put("message", errorMessage); 34 | String result = objectMapper.writeValueAsString(jsonMessage); 35 | 36 | response.getWriter().write(result); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/jwt/JwtAuthenticationFilter.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.jwt; 2 | 3 | import jakarta.servlet.FilterChain; 4 | import jakarta.servlet.ServletException; 5 | import jakarta.servlet.ServletRequest; 6 | import jakarta.servlet.ServletResponse; 7 | import jakarta.servlet.http.HttpServletRequest; 8 | import lombok.RequiredArgsConstructor; 9 | import lombok.extern.slf4j.Slf4j; 10 | import org.springframework.security.core.Authentication; 11 | import org.springframework.security.core.context.SecurityContextHolder; 12 | import java.io.IOException; 13 | import org.springframework.web.filter.GenericFilterBean; 14 | 15 | @Slf4j 16 | @RequiredArgsConstructor 17 | public class JwtAuthenticationFilter extends GenericFilterBean { 18 | 19 | private final JwtTokenProvider jwtTokenProvider; 20 | 21 | @Override 22 | public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 23 | throws IOException, ServletException { 24 | String token = jwtTokenProvider.resolveToken((HttpServletRequest) request); 25 | 26 | try { 27 | if (token != null && jwtTokenProvider.validateToken(token)) { 28 | Authentication auth = jwtTokenProvider.getAuthenticationByToken(token); 29 | SecurityContextHolder.getContext().setAuthentication(auth); 30 | } 31 | } catch (Exception e) { 32 | log.error("error={}", e.getMessage()); 33 | request.setAttribute("exception", e.getMessage()); 34 | } 35 | chain.doFilter(request, response); 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/notification/domain/Notification.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.notification.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import balancetalk.member.domain.Member; 5 | import jakarta.persistence.*; 6 | import jakarta.validation.constraints.NotBlank; 7 | import lombok.*; 8 | 9 | @Entity 10 | @Builder 11 | @Getter 12 | @RequiredArgsConstructor 13 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 14 | public class Notification extends BaseTimeEntity { 15 | 16 | @Id 17 | @GeneratedValue(strategy = GenerationType.IDENTITY) 18 | private Long id; 19 | 20 | @ManyToOne(fetch = FetchType.LAZY) 21 | @JoinColumn(name = "member_id") 22 | private Member member; 23 | 24 | @NotBlank 25 | private String category; 26 | 27 | @NotBlank 28 | private String resourceTitle; 29 | 30 | @NotBlank 31 | private String message; 32 | 33 | private boolean readStatus; 34 | 35 | public void read() { 36 | this.readStatus = true; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/notification/domain/NotificationHistory.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.notification.domain; 2 | 3 | import static balancetalk.global.exception.ErrorCode.FAIL_PARSE_NOTIFICATION_HISTORY; 4 | import static balancetalk.global.exception.ErrorCode.FAIL_SERIALIZE_NOTIFICATION_HISTORY; 5 | 6 | import balancetalk.global.exception.BalanceTalkException; 7 | import com.fasterxml.jackson.core.type.TypeReference; 8 | import com.fasterxml.jackson.databind.ObjectMapper; 9 | import jakarta.persistence.Column; 10 | import jakarta.persistence.Embeddable; 11 | import java.io.IOException; 12 | import java.util.HashMap; 13 | import java.util.Map; 14 | 15 | @Embeddable 16 | public class NotificationHistory { 17 | 18 | private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); 19 | 20 | @Column(columnDefinition = "TEXT") 21 | private String notificationHistoryJson; 22 | 23 | public Map mappingNotification() { 24 | if (notificationHistoryJson == null) { 25 | return new HashMap<>(); 26 | } 27 | try { 28 | return OBJECT_MAPPER.readValue(notificationHistoryJson, new TypeReference>() {}); 29 | } catch (IOException e) { 30 | throw new BalanceTalkException(FAIL_PARSE_NOTIFICATION_HISTORY); 31 | } 32 | } 33 | 34 | public void setNotificationHistory(Map history) { 35 | try { 36 | this.notificationHistoryJson = OBJECT_MAPPER.writeValueAsString(history); 37 | } catch (IOException e) { 38 | throw new BalanceTalkException(FAIL_SERIALIZE_NOTIFICATION_HISTORY); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/notification/domain/NotificationMessage.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.notification.domain; 2 | 3 | import lombok.Getter; 4 | import lombok.RequiredArgsConstructor; 5 | 6 | @RequiredArgsConstructor 7 | @Getter 8 | public enum NotificationMessage { 9 | FIRST_COMMENT_REPLY("MY 댓글에 답글이 달렸어요!"), 10 | COMMENT_REPLY("내가 작성한 댓글에 %d개의 답글이 달렸어요!"), 11 | COMMENT_REPLY_50("MY 댓글에 답글이 50개! '난리파티 (캐릭터)' 배찌를 얻었어요!"), 12 | COMMENT_REPLY_100("MY 댓글에 답글이 100개! '쿵쓰쿵쓰 쿵쿵따 (캐릭터)' 배찌를 얻었어요!"), 13 | COMMENT_LIKE("작성한 댓글이 하트 %d개를 달성했어요!"), 14 | COMMENT_LIKE_100("작성한 댓글에 하트가 100개! '공감왕 (캐릭터)' 배찌를 얻었어요!"), 15 | COMMENT_LIKE_1000("작성한 댓글에 하트가 1000개! '공감대왕 (캐릭터)' 배찌를 얻었어요!"), 16 | TALK_PICK_BOOKMARK("작성한 톡픽이 저장 %d개를 달성했어요!"), 17 | TALK_PICK_BOOKMARK_100("작성한 톡픽을 100명이나 저장! '맛깔난 글솜씨 (캐릭터)' 배찌를 얻었어요!"), 18 | TALK_PICK_BOOKMARK_1000("작성한 톡픽을 1000명이나 저장! '킹왕짱 미다스의 손 (캐릭터)' 배찌를 얻었어요!"), 19 | TALK_PICK_VOTE("작성한 톡픽에 벌써 %d명이 투표했어요!"), 20 | TALK_PICK_VOTE_100("작성한 톡픽에 투표한 사람이 100명! '따끈한 핫플 (캐릭터)' 배찌를 얻었어요!"), 21 | TALK_PICK_VOTE_1000("작성한 톡픽에 투표한 사람이 1000명! '후끈한 핫플 (캐릭터)' 배찌를 얻었어요!"), 22 | TALK_PICK_COMMENT("MY 톡픽이 댓글 %d개를 달성했어요!"), 23 | TALK_PICK_COMMENT_100("MY 톡픽에 댓글이 100개! '와글와글 (캐릭터)' 배찌를 얻었어요!"), 24 | TALK_PICK_COMMENT_1000("MY 톡픽에 댓글이 1000개! '북적북적 (캐릭터)' 배찌를 얻었어요!"), 25 | TALK_PICK_RATIO_2_1("%s '%s'이 압도적으로 우세해요! '마이웨이 (캐릭터)' 배찌를 얻었어요!'"), 26 | TALK_PICK_RATIO_3_1("%s '%s'이 압승중! '이게 나야 (캐릭터)' 배찌를 얻었어요!'"), 27 | GAME_VOTE("MY 밸런스게임에 벌써 %d명이 투표했어요!"), 28 | GAME_VOTE_100("MY 밸런스게임에 투표한 사람이 100명! '물이 좋은 (캐릭터)' 배찌를 얻었어요!"), 29 | GAME_VOTE_1000("MY 밸런스게임에 투표한 사람이 1000명! '파도에 올라탄 (캐릭터)' 배찌를 얻었어요!"), 30 | GAME_BOOKMARK("MY 밸런스게임이 저장 %d개를 달성했어요!"), 31 | GAME_BOOKMARK_100("MY 밸런스게임을 100명이나 저장! '트렌드 리더 (캐릭터)' 배찌를 얻었어요!"), 32 | GAME_BOOKMARK_1000("MY 밸런스게임을 1000명이나 저장! '이정도면 문화대통령 (캐릭터)' 배찌를 얻었어요!"), 33 | VOTE_RATIO_COUNT_KEY("RATIO_%s_SIZE_&d_"); 34 | 35 | private final String message; 36 | 37 | public String format(Object... args) { 38 | return String.format(message, args); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/notification/domain/NotificationRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.notification.domain; 2 | 3 | import balancetalk.member.domain.Member; 4 | import java.util.List; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | 7 | public interface NotificationRepository extends JpaRepository { 8 | List findAllByMemberAndReadStatusIsFalseOrderByCreatedAtDesc(Member member); 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/notification/domain/NotificationStandard.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.notification.domain; 2 | 3 | import lombok.Getter; 4 | import lombok.RequiredArgsConstructor; 5 | 6 | @RequiredArgsConstructor 7 | @Getter 8 | public enum NotificationStandard { 9 | FIRST_STANDARD_OF_NOTIFICATION(10), 10 | SECOND_STANDARD_OF_NOTIFICATION(50), 11 | THIRD_STANDARD_OF_NOTIFICATION(100), 12 | FOURTH_STANDARD_OF_NOTIFICATION(1000), 13 | FIRST_STANDARD_OF_VOTE_RATIO_2_1_NOTIFICATION(75), 14 | SECOND_STANDARD_OF_VOTE_RATIO_2_1_NOTIFICATION(150), 15 | THIRD_STANDARD_OF_VOTE_RATIO_2_1_NOTIFICATION(300), 16 | FIRST_STANDARD_OF_VOTE_RATIO_3_1_NOTIFICATION(100), 17 | SECOND_STANDARD_OF_VOTE_RATIO_3_1_NOTIFICATION(200), 18 | THIRD_STANDARD_OF_VOTE_RATIO_3_1_NOTIFICATION(300), 19 | FIRST_STANDARD_OF_VOTE_RATIO(2), 20 | SECOND_STANDARD_OF_VOTE_RATIO(3); 21 | 22 | private final int count; 23 | } -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/notification/domain/NotificationTitleCategory.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.notification.domain; 2 | 3 | import balancetalk.game.domain.GameSet; 4 | import lombok.Getter; 5 | import lombok.RequiredArgsConstructor; 6 | 7 | @Getter 8 | @RequiredArgsConstructor 9 | public enum NotificationTitleCategory { 10 | 11 | WRITTEN_TALK_PICK("MY 톡픽"), 12 | OTHERS_TALK_PICK("톡픽"), 13 | WRITTEN_GAME("MY 밸런스게임"), 14 | OTHERS_GAME(null), 15 | MY_PICK("MY PICK"); 16 | 17 | private final String category; 18 | 19 | public String format(Object... args) { 20 | return String.format(getCategory(), args); 21 | } 22 | 23 | public String getCategory(GameSet gameSet) { 24 | if (this == OTHERS_GAME) { 25 | // 동적으로 GameSet의 mainTag를 이용해 값을 설정 26 | return gameSet.getMainTag().getName(); // GameSet 인스턴스를 외부에서 제공받아 사용 27 | } 28 | return category; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/notification/presentation/NotificationController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.notification.presentation; 2 | 3 | import balancetalk.global.notification.application.NotificationService; 4 | import balancetalk.global.utils.AuthPrincipal; 5 | import balancetalk.member.dto.ApiMember; 6 | import io.swagger.v3.oas.annotations.Operation; 7 | import io.swagger.v3.oas.annotations.Parameter; 8 | import io.swagger.v3.oas.annotations.tags.Tag; 9 | import lombok.RequiredArgsConstructor; 10 | import org.springframework.web.bind.annotation.GetMapping; 11 | import org.springframework.web.bind.annotation.PathVariable; 12 | import org.springframework.web.bind.annotation.PostMapping; 13 | import org.springframework.web.bind.annotation.RestController; 14 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 15 | 16 | @RestController 17 | @RequiredArgsConstructor 18 | @Tag(name = "notification", description = "알림 API") 19 | public class NotificationController { 20 | 21 | private final NotificationService notificationService; 22 | 23 | @GetMapping("/notifications") 24 | @Operation(summary = "알림 스트리밍", description = "로그인한 사용자의 알림을 실시간으로 스트리밍합니다.") 25 | public SseEmitter streamNotifications(@Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 26 | return notificationService.createEmitter(apiMember); 27 | } 28 | 29 | @PostMapping("/notifications/{id}/read") 30 | @Operation(summary = "알림 읽음 처리", description = "알림을 읽음 상태로 변경합니다.") 31 | public void markNotificationAsRead(@PathVariable Long id) { 32 | notificationService.markNotificationAsRead(id); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/oauth2/dto/CustomOAuth2User.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.oauth2.dto; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collection; 5 | import java.util.Map; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.security.core.GrantedAuthority; 8 | import org.springframework.security.oauth2.core.user.OAuth2User; 9 | 10 | @RequiredArgsConstructor 11 | public class CustomOAuth2User implements OAuth2User { 12 | 13 | private final Oauth2Dto oauth2Dto; 14 | private final String redirectUrl; 15 | 16 | @Override 17 | public Map getAttributes() { 18 | return Map.of(); 19 | } 20 | 21 | public String getRedirectUrl() { 22 | return redirectUrl; 23 | } 24 | 25 | @Override 26 | public Collection getAuthorities() { 27 | Collection collection = new ArrayList<>(); 28 | 29 | collection.add(new GrantedAuthority() { 30 | @Override 31 | public String getAuthority() { 32 | return String.valueOf(oauth2Dto.getRole()); 33 | } 34 | }); 35 | return collection; 36 | } 37 | 38 | @Override 39 | public String getName() { 40 | return oauth2Dto.getName(); 41 | } 42 | 43 | public String getEmail() { 44 | return oauth2Dto.getEmail(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/oauth2/dto/GoogleResponse.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.oauth2.dto; 2 | 3 | import java.util.Map; 4 | 5 | public class GoogleResponse implements Oauth2Response{ 6 | 7 | private final Map attribute; 8 | 9 | public GoogleResponse(Map attribute) { 10 | this.attribute = attribute; 11 | } 12 | 13 | @Override 14 | public String getProvider() { 15 | return "google"; 16 | } 17 | 18 | @Override 19 | public String getProviderId() { 20 | return attribute.get("sub").toString(); 21 | } 22 | 23 | @Override 24 | public String getEmail() { 25 | return attribute.get("email").toString(); 26 | } 27 | 28 | @Override 29 | public String getName() { 30 | return attribute.get("name").toString(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/oauth2/dto/KakaoResponse.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.oauth2.dto; 2 | 3 | import java.util.Map; 4 | 5 | public class KakaoResponse implements Oauth2Response { 6 | 7 | private final Map attributes; 8 | private final Map kakaoAccount; 9 | private final Map kakaoProfile; 10 | 11 | public KakaoResponse(Map attributes) { 12 | this.attributes = attributes; 13 | this.kakaoAccount = (Map) attributes.get("kakao_account"); 14 | this.kakaoProfile = (Map) kakaoAccount.get("profile"); 15 | } 16 | 17 | @Override 18 | public String getProvider() { 19 | return "kakao"; 20 | } 21 | 22 | @Override 23 | public String getProviderId() { 24 | return attributes.get("id").toString(); 25 | } 26 | 27 | @Override 28 | public String getEmail() { 29 | return kakaoAccount.get("email").toString(); 30 | } 31 | 32 | @Override 33 | public String getName() { 34 | return kakaoProfile.get("nickname").toString(); 35 | } 36 | 37 | 38 | public String getProfileImageUrl() { 39 | Object profileImageUrl = kakaoProfile.get("profile_image_url"); 40 | return profileImageUrl != null ? profileImageUrl.toString() : "No profile image URL provided"; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/oauth2/dto/NaverResponse.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.oauth2.dto; 2 | 3 | import java.util.Map; 4 | 5 | public class NaverResponse implements Oauth2Response{ 6 | 7 | private final Map attribute; 8 | 9 | public NaverResponse(Map attribute) { 10 | this.attribute = (Map) attribute.get("response"); 11 | } 12 | 13 | @Override 14 | public String getProvider() { 15 | return "naver"; 16 | } 17 | 18 | @Override 19 | public String getProviderId() { 20 | return attribute.get("id").toString(); 21 | } 22 | 23 | @Override 24 | public String getEmail() { 25 | return attribute.get("email").toString(); 26 | } 27 | 28 | @Override 29 | public String getName() { 30 | return attribute.get("name").toString(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/oauth2/dto/Oauth2Dto.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.oauth2.dto; 2 | 3 | 4 | 5 | import static balancetalk.member.domain.Role.USER; 6 | import static balancetalk.member.domain.SignupType.SOCIAL; 7 | 8 | import balancetalk.member.domain.Member; 9 | import balancetalk.member.domain.Role; 10 | import balancetalk.member.domain.SignupType; 11 | import lombok.AllArgsConstructor; 12 | import lombok.Builder; 13 | import lombok.Data; 14 | 15 | @Data 16 | @Builder 17 | @AllArgsConstructor 18 | public class Oauth2Dto { 19 | 20 | private String name; 21 | private String email; 22 | private Role role; 23 | private SignupType signupType; 24 | private String password; 25 | 26 | public Member toEntity() { 27 | return Member.builder() 28 | .nickname(name) 29 | .email(email) 30 | .role(USER) 31 | .signupType(SOCIAL) 32 | .password(password) 33 | .build(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/oauth2/dto/Oauth2Response.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.oauth2.dto; 2 | 3 | public interface Oauth2Response { 4 | 5 | //제공자 (Ex. naver, google, ...) 6 | String getProvider(); 7 | 8 | //제공자에서 발급해주는 아이디(번호) 9 | String getProviderId(); 10 | 11 | //이메일 12 | String getEmail(); 13 | 14 | //사용자 실명 (설정한 이름) 15 | String getName(); 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/utils/AuthPrincipal.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.utils; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | @Target(ElementType.PARAMETER) 9 | @Retention(RetentionPolicy.RUNTIME) 10 | public @interface AuthPrincipal { 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/utils/LoggingAspect.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.utils; 2 | 3 | import balancetalk.member.dto.ApiMember; 4 | import balancetalk.member.dto.GuestOrApiMember; 5 | import jakarta.servlet.http.HttpServletRequest; 6 | import jakarta.servlet.http.HttpServletResponse; 7 | import java.util.Arrays; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.aspectj.lang.ProceedingJoinPoint; 10 | import org.aspectj.lang.annotation.Around; 11 | import org.aspectj.lang.annotation.Aspect; 12 | import org.aspectj.lang.annotation.Pointcut; 13 | import org.springframework.stereotype.Component; 14 | import org.springframework.web.context.request.RequestContextHolder; 15 | import org.springframework.web.context.request.ServletRequestAttributes; 16 | 17 | @Slf4j 18 | @Aspect 19 | @Component 20 | public class LoggingAspect { 21 | 22 | @Pointcut("execution(* *..*Controller.*(..))") 23 | public void pointcut() {} // pointcut signature 24 | 25 | @Around("pointcut()") 26 | public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { 27 | HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()) 28 | .getRequest(); 29 | HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getResponse(); 30 | 31 | String userIp = request.getRemoteAddr(); 32 | String method = request.getMethod(); 33 | String requestURI = request.getRequestURI(); 34 | Object[] args = joinPoint.getArgs(); 35 | 36 | Long memberId = Arrays.stream(args) 37 | .filter(arg -> arg instanceof ApiMember || arg instanceof GuestOrApiMember) 38 | .map(arg -> { 39 | if (arg instanceof ApiMember) { 40 | return ((ApiMember) arg).getMemberId(); 41 | } else if (arg instanceof GuestOrApiMember) { 42 | return ((GuestOrApiMember) arg).getMemberId(); 43 | } 44 | return null; // 에러 케이스 45 | }) 46 | .findFirst() 47 | .orElse(-1L); 48 | 49 | int status = response.getStatus(); 50 | if (memberId == -1L) { 51 | log.info("[REQUEST] {} GUEST {} {} args={}", userIp, method, requestURI, args); 52 | } 53 | else { 54 | log.info("[REQUEST] {} memberId={} {} {} args={}", userIp, memberId, method, requestURI, args); 55 | } 56 | 57 | try { 58 | Object proceed = joinPoint.proceed(); 59 | log.info("[RESPONSE] {} {}", status, proceed); 60 | return proceed; 61 | } catch (Exception e) { 62 | log.error("[RESPONSE] {} {} {}", status, e.getMessage(), e.getStackTrace()); 63 | throw e; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/utils/QuerydslUtils.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.utils; 2 | 3 | import balancetalk.global.exception.BalanceTalkException; 4 | import balancetalk.global.exception.ErrorCode; 5 | import com.querydsl.core.types.Order; 6 | import com.querydsl.core.types.OrderSpecifier; 7 | import com.querydsl.core.types.Path; 8 | import com.querydsl.core.types.dsl.Expressions; 9 | import lombok.AccessLevel; 10 | import lombok.AllArgsConstructor; 11 | import org.springframework.data.domain.Sort; 12 | 13 | import java.util.ArrayList; 14 | import java.util.List; 15 | 16 | import static balancetalk.talkpick.domain.QTalkPick.talkPick; 17 | 18 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 19 | public class QuerydslUtils { 20 | 21 | public static OrderSpecifier[] getOrderSpecifiers(Path path, Sort sort) { 22 | if (sort == null || sort.isEmpty()) { 23 | throw new BalanceTalkException(ErrorCode.SORT_REQUIRED); 24 | } 25 | 26 | List> orderSpecifiers = new ArrayList<>(); 27 | for (Sort.Order order : sort) { 28 | Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC; 29 | String property = order.getProperty(); 30 | if (path.equals(talkPick)) { 31 | return orderSpecifiersForTalkPick(orderSpecifiers, direction, property); 32 | } 33 | } 34 | 35 | throw new BalanceTalkException(ErrorCode.FAIL_SORT); 36 | } 37 | 38 | private static OrderSpecifier[] orderSpecifiersForTalkPick(List> orderSpecifiers, Order direction, String property) { 39 | switch (property) { 40 | case "views" -> { 41 | orderSpecifiers.add(getOrderSpecifier(direction, talkPick, property)); 42 | orderSpecifiers.add(getOrderSpecifier(Order.DESC, talkPick, "createdAt")); 43 | } 44 | case "createdAt" -> { 45 | orderSpecifiers.add(getOrderSpecifier(direction, talkPick, property)); 46 | orderSpecifiers.add(getOrderSpecifier(Order.DESC, talkPick, "views")); 47 | } 48 | } 49 | 50 | return orderSpecifiers.toArray(OrderSpecifier[]::new); 51 | } 52 | 53 | private static OrderSpecifier getOrderSpecifier(Order direction, Path path, String fieldName) { 54 | Path fieldPath = Expressions.path(Boolean.class, path, fieldName); 55 | return new OrderSpecifier<>(direction, fieldPath); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/global/utils/SecurityUtils.java: -------------------------------------------------------------------------------- 1 | package balancetalk.global.utils; 2 | 3 | import static balancetalk.global.exception.ErrorCode.NOT_FOUND_MEMBER; 4 | import balancetalk.global.exception.BalanceTalkException; 5 | import balancetalk.member.domain.Member; 6 | import balancetalk.member.domain.MemberRepository; 7 | import lombok.AccessLevel; 8 | import lombok.NoArgsConstructor; 9 | import org.springframework.security.core.Authentication; 10 | import org.springframework.security.core.context.SecurityContextHolder; 11 | 12 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 13 | public class SecurityUtils { 14 | 15 | public static Member getCurrentMember(MemberRepository memberRepository) { 16 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 17 | String email = authentication.getName(); 18 | 19 | return memberRepository.findByEmail(email) 20 | .orElseThrow(() -> new BalanceTalkException(NOT_FOUND_MEMBER)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/like/domain/Like.java: -------------------------------------------------------------------------------- 1 | package balancetalk.like.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import balancetalk.member.domain.Member; 5 | import jakarta.persistence.Entity; 6 | import jakarta.persistence.EnumType; 7 | import jakarta.persistence.Enumerated; 8 | import jakarta.persistence.FetchType; 9 | import jakarta.persistence.GeneratedValue; 10 | import jakarta.persistence.GenerationType; 11 | import jakarta.persistence.Id; 12 | import jakarta.persistence.JoinColumn; 13 | import jakarta.persistence.ManyToOne; 14 | import jakarta.persistence.Table; 15 | import jakarta.validation.constraints.NotNull; 16 | import lombok.AccessLevel; 17 | import lombok.AllArgsConstructor; 18 | import lombok.Builder; 19 | import lombok.Getter; 20 | import lombok.NoArgsConstructor; 21 | 22 | @Entity 23 | @Builder 24 | @Getter 25 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 26 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 27 | @Table(name = "likes") 28 | public class Like extends BaseTimeEntity { 29 | 30 | @Id 31 | @GeneratedValue(strategy = GenerationType.IDENTITY) 32 | private Long id; 33 | 34 | @Enumerated(value = EnumType.STRING) 35 | @NotNull 36 | private LikeType likeType; 37 | 38 | @ManyToOne(fetch = FetchType.LAZY) 39 | @JoinColumn(name = "member_id") 40 | private Member member; 41 | 42 | @NotNull 43 | private Long resourceId; 44 | 45 | @NotNull 46 | private Boolean active = true; 47 | 48 | public void activate() { 49 | this.active = true; 50 | } 51 | 52 | public void deActive() { 53 | this.active = false; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/like/domain/LikeRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.like.domain; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | import org.springframework.data.jpa.repository.Query; 5 | import org.springframework.data.repository.query.Param; 6 | 7 | import java.util.Optional; 8 | 9 | public interface LikeRepository extends JpaRepository { 10 | Optional findByResourceIdAndMemberId(Long commentId, Long MemberId); 11 | 12 | boolean existsByResourceIdAndMemberId(Long commentId, Long MemberId); 13 | 14 | @Query("SELECT COUNT(l) FROM Like l WHERE l.resourceId = :commentId AND l.likeType = :likeType AND l.active = true") 15 | int countByResourceIdAndLikeType(@Param("commentId") Long commentId, @Param("likeType") LikeType likeType); 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/like/domain/LikeType.java: -------------------------------------------------------------------------------- 1 | package balancetalk.like.domain; 2 | 3 | public enum LikeType { 4 | COMMENT 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/like/dto/LikeDto.java: -------------------------------------------------------------------------------- 1 | package balancetalk.like.dto; 2 | 3 | import balancetalk.like.domain.Like; 4 | import balancetalk.like.domain.LikeType; 5 | import balancetalk.member.domain.Member; 6 | import com.fasterxml.jackson.annotation.JsonInclude; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import lombok.AllArgsConstructor; 9 | import lombok.Builder; 10 | import lombok.Data; 11 | 12 | import java.time.LocalDateTime; 13 | 14 | public class LikeDto { 15 | 16 | @Data 17 | @Builder 18 | @AllArgsConstructor 19 | @Schema(description = "좋아요 생성 요청") 20 | public static class CreateLikeRequest { 21 | 22 | @Schema(description = "좋아요 타입", example = "COMMENT") 23 | private LikeType likeType; // TODO : 변경 필 24 | 25 | @Schema(description = "좋아요한 댓글 id", example = "1") 26 | private Long commentId; // TODO : 변경 필 27 | 28 | public static Like toEntity(Long resourceId, Member member) { 29 | return Like.builder() 30 | .likeType(LikeType.COMMENT) 31 | .resourceId(resourceId) 32 | .member(member) 33 | .active(true) 34 | .build(); 35 | } 36 | } 37 | 38 | @Data 39 | @Builder 40 | @AllArgsConstructor 41 | @JsonInclude 42 | @Schema(description = "좋아요 조회 응답 (현재 미사용)") 43 | public static class LikeResponse { 44 | 45 | @Schema(description = "좋아요 id", example = "1") 46 | private Long id; 47 | 48 | @Schema(description = "좋아요 타입", example = "COMMENT") 49 | private LikeType likeType; 50 | 51 | @Schema(description = "좋아요한 멤버 id", example = "1") 52 | private Long memberId; 53 | 54 | @Schema(description = "좋아요한 댓글 id", example = "1") 55 | private Long resourceId; 56 | 57 | @Schema(description = "좋아요 생성 날짜") 58 | private LocalDateTime createdAt; 59 | 60 | @Schema(description = "좋아요 변경 날짜") 61 | private LocalDateTime lastModifiedAt; 62 | 63 | public static LikeResponse fromEntity(Like like) { 64 | return LikeResponse.builder() 65 | .id(like.getId()) 66 | .likeType(like.getLikeType()) 67 | .memberId(like.getMember().getId()) 68 | .resourceId(like.getResourceId()) 69 | .createdAt(like.getCreatedAt()) 70 | .lastModifiedAt(like.getLastModifiedAt()) 71 | .build(); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/like/presentation/LikeController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.like.presentation; 2 | 3 | import balancetalk.global.utils.AuthPrincipal; 4 | import balancetalk.like.application.CommentLikeService; 5 | import balancetalk.member.dto.ApiMember; 6 | import io.swagger.v3.oas.annotations.Operation; 7 | import io.swagger.v3.oas.annotations.Parameter; 8 | import io.swagger.v3.oas.annotations.tags.Tag; 9 | import lombok.RequiredArgsConstructor; 10 | import org.springframework.web.bind.annotation.*; 11 | 12 | @RestController 13 | @RequestMapping("/likes/talks/{talkPickId}/comments/{commentId}") 14 | @RequiredArgsConstructor 15 | @Tag(name = "like", description = "좋아요 API") 16 | public class LikeController { 17 | 18 | private final CommentLikeService commentLikeService; 19 | 20 | @PostMapping 21 | @Operation(summary = "댓글 좋아요", description = "commentId에 해당하는 댓글에 좋아요를 활성화합니다.") 22 | public void likeComment(@PathVariable Long commentId, @PathVariable Long talkPickId, @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 23 | commentLikeService.likeComment(commentId, talkPickId, apiMember); 24 | } 25 | 26 | @DeleteMapping 27 | @Operation(summary = "댓글 좋아요 취소", description = "commentId에 해당하는 댓글의 좋아요를 취소합니다.") 28 | public void unlikeComment(@PathVariable Long commentId, @PathVariable Long talkPickId, @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 29 | commentLikeService.unLikeComment(commentId, talkPickId, apiMember); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/member/application/MyUserDetailService.java: -------------------------------------------------------------------------------- 1 | package balancetalk.member.application; 2 | 3 | import balancetalk.member.domain.CustomUserDetails; 4 | import balancetalk.global.exception.BalanceTalkException; 5 | import balancetalk.global.exception.ErrorCode; 6 | import balancetalk.member.domain.Member; 7 | import balancetalk.member.domain.MemberRepository; 8 | import lombok.RequiredArgsConstructor; 9 | import lombok.extern.slf4j.Slf4j; 10 | import org.springframework.security.core.userdetails.UserDetailsService; 11 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 12 | import org.springframework.stereotype.Service; 13 | 14 | @Slf4j 15 | @Service 16 | @RequiredArgsConstructor 17 | public class MyUserDetailService implements UserDetailsService { 18 | 19 | private final MemberRepository memberRepository; 20 | 21 | @Override 22 | public CustomUserDetails loadUserByUsername(String username) throws UsernameNotFoundException { 23 | Member member = memberRepository.findByEmail(username) 24 | .orElseThrow(() -> new BalanceTalkException(ErrorCode.NOT_FOUND_MEMBER)); 25 | return new CustomUserDetails(member); 26 | } 27 | 28 | public CustomUserDetails loadByMemberId(Long memberId) { 29 | Member member = memberRepository.findById(memberId) 30 | .orElseThrow(() -> new BalanceTalkException(ErrorCode.NOT_FOUND_MEMBER)); 31 | return new CustomUserDetails(member); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/member/domain/CustomUserDetails.java: -------------------------------------------------------------------------------- 1 | package balancetalk.member.domain; 2 | 3 | import java.util.ArrayList; 4 | import org.springframework.security.core.GrantedAuthority; 5 | import java.util.Collection; 6 | import org.springframework.security.core.userdetails.UserDetails; 7 | 8 | public class CustomUserDetails implements UserDetails { 9 | 10 | private final Member member; 11 | 12 | public CustomUserDetails(Member member) { 13 | this.member = member; 14 | } 15 | 16 | @Override 17 | public Collection getAuthorities() { 18 | Collection collection = new ArrayList<>(); 19 | collection.add(new GrantedAuthority() { 20 | @Override 21 | public String getAuthority() { 22 | return String.valueOf(member.getRole()); 23 | } 24 | }); 25 | return collection; 26 | } 27 | 28 | @Override 29 | public String getPassword() { 30 | return member.getPassword(); 31 | } 32 | 33 | @Override 34 | public String getUsername() { 35 | return member.getEmail(); 36 | } 37 | 38 | public Long getMemberId() { 39 | return member.getId(); 40 | } 41 | 42 | @Override 43 | public boolean isAccountNonExpired() { 44 | return true; 45 | } 46 | 47 | @Override 48 | public boolean isAccountNonLocked() { 49 | return true; 50 | } 51 | 52 | @Override 53 | public boolean isCredentialsNonExpired() { 54 | return true; 55 | } 56 | 57 | @Override 58 | public boolean isEnabled() { 59 | return true; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/member/domain/MemberRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.member.domain; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | import java.util.Optional; 5 | 6 | public interface MemberRepository extends JpaRepository { 7 | Optional findByEmail(String username); 8 | boolean existsByNickname(String nickname); 9 | 10 | boolean existsByEmail(String email); 11 | void deleteByEmail(String email); 12 | 13 | // @Query("select m.id from Member m JOIN m.votes v WHERE v.balanceOption.id = :balanceOptionId") 14 | // List findMemberIdsBySelectedOptionId(Long balanceOptionId); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/member/domain/Role.java: -------------------------------------------------------------------------------- 1 | package balancetalk.member.domain; 2 | 3 | public enum Role { 4 | ADMIN, USER 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/member/domain/SignupType.java: -------------------------------------------------------------------------------- 1 | package balancetalk.member.domain; 2 | 3 | public enum SignupType { 4 | SOCIAL, STANDARD 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/member/dto/ApiMember.java: -------------------------------------------------------------------------------- 1 | package balancetalk.member.dto; 2 | 3 | import balancetalk.global.exception.BalanceTalkException; 4 | import balancetalk.global.exception.ErrorCode; 5 | import balancetalk.member.domain.Member; 6 | import balancetalk.member.domain.MemberRepository; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Data; 9 | 10 | @Data 11 | @AllArgsConstructor 12 | public class ApiMember { 13 | 14 | private Long memberId; 15 | 16 | public Member toMember(MemberRepository memberRepository) { 17 | return memberRepository.findById(memberId) 18 | .orElseThrow(() -> new BalanceTalkException(ErrorCode.NOT_FOUND_MEMBER)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/member/dto/GuestOrApiMember.java: -------------------------------------------------------------------------------- 1 | package balancetalk.member.dto; 2 | 3 | import balancetalk.global.exception.BalanceTalkException; 4 | import balancetalk.global.exception.ErrorCode; 5 | import balancetalk.member.domain.Member; 6 | import balancetalk.member.domain.MemberRepository; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Data; 9 | 10 | @Data 11 | @AllArgsConstructor 12 | public class GuestOrApiMember { 13 | 14 | private Long memberId; 15 | 16 | public boolean isGuest() { 17 | return memberId.equals(-1L); 18 | } 19 | 20 | public Member toMember(MemberRepository memberRepository) { 21 | return memberRepository.findById(memberId) 22 | .orElseThrow(() -> new BalanceTalkException(ErrorCode.NOT_FOUND_MEMBER)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/member/presentation/Oauth2Controller.java: -------------------------------------------------------------------------------- 1 | package balancetalk.member.presentation; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.tags.Tag; 5 | import lombok.RequiredArgsConstructor; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.validation.annotation.Validated; 8 | import org.springframework.web.bind.annotation.GetMapping; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.RestController; 11 | import org.springframework.web.servlet.view.RedirectView; 12 | 13 | @Slf4j 14 | @RestController 15 | @RequiredArgsConstructor 16 | @RequestMapping("/oauth2") 17 | @Validated 18 | @Tag(name = "oauth2", description = "소셜 로그인 API") 19 | public class Oauth2Controller { 20 | 21 | @GetMapping("/naver") 22 | @Operation(summary = "소셜 로그인_네이버", description = "네이버 소셜 로그인 URL을 리다이렉트 합니다.") 23 | public RedirectView loginToNaver() { 24 | return new RedirectView("/oauth2/authorization/naver"); 25 | } 26 | 27 | @GetMapping("/kakao") 28 | @Operation(summary = "소셜 로그인_카카오", description = "카카오 소셜 로그인 URL을 리다이렉트 합니다.") 29 | public RedirectView loginToKakao() { 30 | return new RedirectView("/oauth2/authorization/kakao"); 31 | } 32 | 33 | @GetMapping("/google") 34 | @Operation(summary = "소셜 로그인_구글", description = "구글 소셜 로그인 URL을 리다이렉트 합니다.") 35 | public RedirectView loginToGoogle() { 36 | return new RedirectView("/oauth2/authorization/kakao"); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/report/application/ReportCommentService.java: -------------------------------------------------------------------------------- 1 | package balancetalk.report.application; 2 | 3 | import static balancetalk.global.exception.ErrorCode.ALREADY_REPORTED_COMMENT; 4 | import static balancetalk.global.exception.ErrorCode.NOT_FOUND_COMMENT; 5 | import static balancetalk.global.exception.ErrorCode.NOT_FOUND_COMMENT_AT_THAT_TALK_PICK; 6 | import static balancetalk.global.exception.ErrorCode.REPORT_MY_COMMENT; 7 | 8 | import balancetalk.comment.domain.Comment; 9 | import balancetalk.comment.domain.CommentRepository; 10 | import balancetalk.global.exception.BalanceTalkException; 11 | import balancetalk.member.domain.Member; 12 | import balancetalk.member.domain.MemberRepository; 13 | import balancetalk.member.dto.ApiMember; 14 | import balancetalk.report.domain.Report; 15 | import balancetalk.report.domain.ReportRepository; 16 | import balancetalk.report.dto.ReportDto.CreateReportRequest; 17 | import jakarta.validation.Valid; 18 | import lombok.RequiredArgsConstructor; 19 | import org.springframework.stereotype.Service; 20 | import org.springframework.transaction.annotation.Transactional; 21 | 22 | @Service 23 | @Transactional 24 | @RequiredArgsConstructor 25 | public class ReportCommentService { 26 | 27 | private final CommentRepository commentRepository; 28 | private final ReportRepository reportRepository; 29 | private final MemberRepository memberRepository; 30 | 31 | @Transactional 32 | public void createCommentReport(@Valid CreateReportRequest createReportRequest, ApiMember apiMember, 33 | Long resourceId, Long talkPickId) { 34 | 35 | Member reporter = apiMember.toMember(memberRepository); 36 | 37 | // 댓글과 톡픽 검증 38 | Comment comment = validateCommentOnTalkPick(resourceId, talkPickId); 39 | Member reported = comment.getMember(); 40 | 41 | // 본인의 댓글을 신고할 수 없음 예외 처리 42 | if (reporter.equals(reported)) { 43 | throw new BalanceTalkException(REPORT_MY_COMMENT); 44 | } 45 | 46 | // 중복 신고 방지 47 | if (reportRepository.existsByReporterAndReportedAndResourceId(reporter, reported, resourceId)) { 48 | throw new BalanceTalkException(ALREADY_REPORTED_COMMENT); 49 | } 50 | 51 | Report report = createReportRequest.toEntity(reporter, reported, resourceId, comment.getContent()); 52 | 53 | reportRepository.save(report); 54 | } 55 | 56 | private Comment validateCommentOnTalkPick(Long resourceId, Long talkPickId) { 57 | Comment comment = commentRepository.findById(resourceId) 58 | .orElseThrow(() -> new BalanceTalkException(NOT_FOUND_COMMENT)); 59 | 60 | if (!comment.getTalkPick().getId().equals(talkPickId)) { 61 | throw new BalanceTalkException(NOT_FOUND_COMMENT_AT_THAT_TALK_PICK); 62 | } 63 | 64 | return comment; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/report/domain/Report.java: -------------------------------------------------------------------------------- 1 | package balancetalk.report.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import balancetalk.member.domain.Member; 5 | import jakarta.persistence.Entity; 6 | import jakarta.persistence.EnumType; 7 | import jakarta.persistence.Enumerated; 8 | import jakarta.persistence.GeneratedValue; 9 | import jakarta.persistence.GenerationType; 10 | import jakarta.persistence.Id; 11 | import jakarta.persistence.JoinColumn; 12 | import jakarta.persistence.ManyToOne; 13 | import jakarta.validation.constraints.NotBlank; 14 | import jakarta.validation.constraints.NotNull; 15 | import lombok.AccessLevel; 16 | import lombok.AllArgsConstructor; 17 | import lombok.Builder; 18 | import lombok.Getter; 19 | import lombok.NoArgsConstructor; 20 | 21 | @Entity 22 | @Getter 23 | @Builder 24 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 25 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 26 | public class Report extends BaseTimeEntity { 27 | 28 | @Id 29 | @GeneratedValue(strategy = GenerationType.IDENTITY) 30 | private Long id; 31 | 32 | @Enumerated(value = EnumType.STRING) 33 | @NotNull 34 | private ReportType reportType; 35 | 36 | @NotNull 37 | private Long resourceId; 38 | 39 | @Enumerated(value = EnumType.STRING) 40 | @NotNull 41 | private ReportReason reason; 42 | 43 | @NotBlank 44 | private String reportedContent; 45 | 46 | @ManyToOne 47 | @JoinColumn(name = "reporter_id") 48 | private Member reporter; 49 | 50 | @ManyToOne 51 | @JoinColumn(name = "reported_id") 52 | private Member reported; 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/report/domain/ReportReason.java: -------------------------------------------------------------------------------- 1 | package balancetalk.report.domain; 2 | 3 | import balancetalk.global.exception.BalanceTalkException; 4 | import com.fasterxml.jackson.annotation.JsonCreator; 5 | import com.fasterxml.jackson.annotation.JsonValue; 6 | 7 | import static balancetalk.global.exception.ErrorCode.INVALID_REPORT_REASON; 8 | 9 | 10 | public enum ReportReason { 11 | 욕설, 도배_및_스팸, 불건전_및_불법_정보, 차별적_발언, 홍보, 개인정보_노출_및_침해, 기타; 12 | 13 | @JsonCreator 14 | public static ReportReason from(String value) { 15 | for (ReportReason reason : ReportReason.values()) { 16 | if (reason.name().equals(value)) { 17 | return reason; 18 | } 19 | } 20 | throw new BalanceTalkException(INVALID_REPORT_REASON); 21 | } 22 | 23 | @JsonValue 24 | public String toValue() { 25 | return this.name(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/report/domain/ReportRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.report.domain; 2 | 3 | import balancetalk.member.domain.Member; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface ReportRepository extends JpaRepository { 7 | boolean existsByReporterAndReportedAndResourceId(Member reporter, Member reported, Long commentId); 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/report/domain/ReportType.java: -------------------------------------------------------------------------------- 1 | package balancetalk.report.domain; 2 | 3 | import balancetalk.global.exception.BalanceTalkException; 4 | import com.fasterxml.jackson.annotation.JsonCreator; 5 | import com.fasterxml.jackson.annotation.JsonValue; 6 | 7 | import static balancetalk.global.exception.ErrorCode.INVALID_REPORT_TYPE; 8 | 9 | public enum ReportType { 10 | COMMENT; 11 | 12 | @JsonCreator 13 | public static ReportType from(String value) { 14 | for (ReportType type : ReportType.values()) { 15 | if (type.name().equals(value)) { 16 | return type; 17 | } 18 | } 19 | throw new BalanceTalkException(INVALID_REPORT_TYPE); 20 | } 21 | 22 | @JsonValue 23 | public String toValue() { 24 | return this.name(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/report/dto/ReportDto.java: -------------------------------------------------------------------------------- 1 | package balancetalk.report.dto; 2 | 3 | import balancetalk.member.domain.Member; 4 | import balancetalk.report.domain.Report; 5 | import balancetalk.report.domain.ReportReason; 6 | import balancetalk.report.domain.ReportType; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import lombok.AllArgsConstructor; 9 | import lombok.Builder; 10 | import lombok.Data; 11 | 12 | public class ReportDto { 13 | 14 | @Data 15 | @Builder 16 | @AllArgsConstructor 17 | @Schema(description = "신고 생성 요청") 18 | public static class CreateReportRequest { 19 | 20 | @Schema(description = "신고된 컨텐츠 타입", example = "COMMENT") 21 | private ReportType reportType; 22 | 23 | @Schema(description = "신고 사유", example = "욕설, 도배_및_스팸, 불건전_및_불법_정보, 차별적_발언, 홍보, 개인정보_노출_및_침해, 기타 (한글 문자열)") 24 | private ReportReason reason; 25 | 26 | public Report toEntity(Member reporter, Member reported, Long resourceId, String content) { 27 | return Report.builder() 28 | .reportType(reportType) 29 | .resourceId(resourceId) 30 | .reason(reason) 31 | .reporter(reporter) 32 | .reported(reported) 33 | .reportedContent(content) 34 | .build(); 35 | } 36 | } 37 | 38 | // TODO : 추후 어드민 페이지 구현시 Response 작성 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/report/presentation/ReportController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.report.presentation; 2 | 3 | import balancetalk.comment.dto.CommentDto; 4 | import balancetalk.global.utils.AuthPrincipal; 5 | import balancetalk.member.dto.ApiMember; 6 | import balancetalk.report.application.ReportCommentService; 7 | import balancetalk.report.dto.ReportDto; 8 | import io.swagger.v3.oas.annotations.Operation; 9 | import io.swagger.v3.oas.annotations.Parameter; 10 | import io.swagger.v3.oas.annotations.tags.Tag; 11 | import jakarta.validation.Valid; 12 | import lombok.RequiredArgsConstructor; 13 | import org.springframework.web.bind.annotation.PathVariable; 14 | import org.springframework.web.bind.annotation.PostMapping; 15 | import org.springframework.web.bind.annotation.RequestBody; 16 | import org.springframework.web.bind.annotation.RequestMapping; 17 | import org.springframework.web.bind.annotation.RestController; 18 | 19 | import static balancetalk.report.dto.ReportDto.CreateReportRequest; 20 | 21 | @RestController 22 | @RequestMapping("/reports") 23 | @RequiredArgsConstructor 24 | @Tag(name = "report", description = "신고 API") 25 | public class ReportController { 26 | 27 | private final ReportCommentService reportCommentService; 28 | 29 | @PostMapping("/talks/{talkPickId}/comments/{commentId}") 30 | @Operation(summary = "댓글 신고", description = "comment-Id에 해당하는 댓글을 신고한다.") 31 | public void createCommentReport(@PathVariable Long talkPickId, @PathVariable Long commentId, 32 | @Valid @RequestBody CreateReportRequest createCommentRequest, 33 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 34 | 35 | reportCommentService.createCommentReport(createCommentRequest, apiMember, commentId, talkPickId); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/application/SearchTalkPickService.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.application; 2 | 3 | import balancetalk.talkpick.domain.repository.SearchTalkPickRepository; 4 | import balancetalk.talkpick.dto.SearchTalkPickResponse; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.data.domain.Page; 7 | import org.springframework.data.domain.Pageable; 8 | import org.springframework.stereotype.Service; 9 | 10 | @Service 11 | @RequiredArgsConstructor 12 | public class SearchTalkPickService { 13 | 14 | private final SearchTalkPickRepository searchTalkPickRepository; 15 | 16 | public Page searchTalkPicks(final String query, Pageable pageable) { 17 | return searchTalkPickRepository.searchTalkPicks(query, pageable); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/application/TalkPickFileService.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.application; 2 | 3 | import static balancetalk.file.domain.FileType.TALK_PICK; 4 | 5 | import balancetalk.file.domain.File; 6 | import balancetalk.file.domain.FileHandler; 7 | import balancetalk.file.domain.repository.FileRepository; 8 | import java.util.List; 9 | import lombok.RequiredArgsConstructor; 10 | import org.springframework.retry.annotation.Backoff; 11 | import org.springframework.retry.annotation.Retryable; 12 | import org.springframework.scheduling.annotation.Async; 13 | import org.springframework.stereotype.Component; 14 | import org.springframework.transaction.annotation.Transactional; 15 | 16 | @Component 17 | @RequiredArgsConstructor 18 | public class TalkPickFileService { 19 | 20 | private final FileRepository fileRepository; 21 | private final FileHandler fileHandler; 22 | 23 | @Async("fileMappingTaskExecutor") 24 | @Retryable(backoff = @Backoff(delay = 1000)) 25 | @Transactional 26 | public void handleFilesOnTalkPickCreate(List fileIds, Long talkPickId) { 27 | if (fileIds == null || fileIds.isEmpty()) { 28 | return; 29 | } 30 | relocateFiles(fileIds, talkPickId); 31 | } 32 | 33 | private void relocateFiles(List fileIds, Long talkPickId) { 34 | List files = fileRepository.findAllById(fileIds); 35 | fileHandler.relocateFiles(files, talkPickId, TALK_PICK); 36 | } 37 | 38 | @Async 39 | @Retryable(backoff = @Backoff(delay = 1000)) 40 | @Transactional 41 | public void handleFilesOnTalkPickUpdate(List newFileIds, List deleteFileIds, Long talkPickId) { 42 | deleteFiles(deleteFileIds); 43 | newFileIds.removeIf((deleteFileIds::contains)); 44 | relocateFiles(newFileIds, talkPickId); 45 | } 46 | 47 | private void deleteFiles(List deleteFileIds) { 48 | if (deleteFileIds.isEmpty()) { 49 | return; 50 | } 51 | List files = fileRepository.findAllById(deleteFileIds); 52 | fileHandler.deleteFiles(files); 53 | } 54 | 55 | @Async 56 | @Retryable(backoff = @Backoff(delay = 1000)) 57 | @Transactional 58 | public void handleFilesOnTalkPickDelete(Long talkPickId) { 59 | if (notExistsFilesBy(talkPickId)) { 60 | return; 61 | } 62 | List files = fileRepository.findAllByResourceIdAndFileType(talkPickId, TALK_PICK); 63 | fileHandler.deleteFiles(files); 64 | } 65 | 66 | private boolean notExistsFilesBy(Long talkPickId) { 67 | return !fileRepository.existsByResourceIdAndFileType(talkPickId, TALK_PICK); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/application/TalkPickScheduleService.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.application; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.scheduling.annotation.Scheduled; 5 | import org.springframework.stereotype.Service; 6 | 7 | @Service 8 | @RequiredArgsConstructor 9 | public class TalkPickScheduleService { 10 | 11 | private final TalkPickSummaryService talkPickSummaryService; 12 | private final TodayTalkPickService todayTalkPickService; 13 | 14 | // @Scheduled(cron = "0 30 00 * * ?") 15 | public void retryFailedSummaries() { 16 | talkPickSummaryService.summarizeFailedTalkPick(); 17 | } 18 | 19 | @Scheduled(cron = "0 00 00 * * ?") 20 | public void updateTodayTalkPick() { 21 | todayTalkPickService.updateTodayTalkPick(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/application/TodayTalkPickService.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.application; 2 | 3 | import static balancetalk.talkpick.dto.TodayTalkPickDto.TodayTalkPickResponse; 4 | 5 | import balancetalk.talkpick.domain.TalkPick; 6 | import balancetalk.talkpick.domain.TodayTalkPick; 7 | import balancetalk.talkpick.domain.repository.TalkPickRepository; 8 | import balancetalk.talkpick.domain.repository.TodayTalkPickRepository; 9 | import java.time.LocalDate; 10 | import java.util.Collections; 11 | import java.util.List; 12 | import java.util.stream.Collectors; 13 | import lombok.RequiredArgsConstructor; 14 | import org.springframework.beans.factory.annotation.Value; 15 | import org.springframework.stereotype.Service; 16 | import org.springframework.transaction.annotation.Transactional; 17 | 18 | @Service 19 | @RequiredArgsConstructor 20 | public class TodayTalkPickService { 21 | 22 | private final TalkPickRepository talkPickRepository; 23 | private final TodayTalkPickRepository todayTalkPickRepository; 24 | 25 | @Value("${pick-o.candidate-today-talk-pick-count}") 26 | private int candidateTodayTalkPickCount; 27 | 28 | @Value("${pick-o.today-talk-pick-count}") 29 | private int todayTalkPickCount; 30 | 31 | @Transactional 32 | public void updateTodayTalkPick() { 33 | List candidateTodayTalkPicks = getCandidateTodayTalkPicks(); 34 | Collections.shuffle(candidateTodayTalkPicks); 35 | List todayTalkPicks = candidateTodayTalkPicks.subList(0, todayTalkPickCount) 36 | .stream() 37 | .map(TalkPick::toTodayTalkPick) 38 | .toList(); 39 | todayTalkPickRepository.saveAll(todayTalkPicks); 40 | } 41 | 42 | private List getCandidateTodayTalkPicks() { 43 | List yesterdayTalkPicks = getYesterdaySelectedTalkPicks(); 44 | return talkPickRepository.findCandidateTodayTalkPicks(candidateTodayTalkPickCount, yesterdayTalkPicks) 45 | .stream() 46 | .filter(talkPick -> !yesterdayTalkPicks.contains(talkPick)) 47 | .collect(Collectors.toList()); 48 | } 49 | 50 | private List getYesterdaySelectedTalkPicks() { 51 | return todayTalkPickRepository.findByPickDate(LocalDate.now().minusDays(1L)) 52 | .stream() 53 | .map(TodayTalkPick::getTalkPick) 54 | .toList(); 55 | } 56 | 57 | @Transactional(readOnly = true) 58 | public List findTodayTalkPick() { 59 | return todayTalkPickRepository.findByPickDate(LocalDate.now()) 60 | .stream() 61 | .map(todayTalkPick -> TodayTalkPickResponse.from(todayTalkPick.getTalkPick())) 62 | .toList(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/Summary.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.Embeddable; 5 | import jakarta.validation.constraints.Size; 6 | import lombok.Getter; 7 | 8 | @Embeddable 9 | @Getter 10 | public class Summary { 11 | 12 | private static final int MAX_SIZE = 120; 13 | 14 | @Size(max = MAX_SIZE) 15 | @Column(name = "summary_first_line") 16 | String firstLine; 17 | 18 | @Size(max = MAX_SIZE) 19 | @Column(name = "summary_second_line") 20 | String secondLine; 21 | 22 | @Size(max = MAX_SIZE) 23 | @Column(name = "summary_third_line") 24 | String thirdLine; 25 | 26 | public boolean isOverSize() { 27 | return firstLine.length() > MAX_SIZE || secondLine.length() > MAX_SIZE || thirdLine.length() > MAX_SIZE; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/SummaryStatus.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain; 2 | 3 | public enum SummaryStatus { 4 | PENDING, SUCCESS, FAIL, NOT_REQUIRED 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/TalkPickImage.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import jakarta.persistence.Entity; 5 | import jakarta.persistence.FetchType; 6 | import jakarta.persistence.GeneratedValue; 7 | import jakarta.persistence.GenerationType; 8 | import jakarta.persistence.Id; 9 | import jakarta.persistence.JoinColumn; 10 | import jakarta.persistence.ManyToOne; 11 | import jakarta.validation.constraints.NotBlank; 12 | import jakarta.validation.constraints.Size; 13 | import lombok.AccessLevel; 14 | import lombok.NoArgsConstructor; 15 | 16 | @Entity 17 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 18 | public class TalkPickImage extends BaseTimeEntity { 19 | 20 | @Id 21 | @GeneratedValue(strategy = GenerationType.IDENTITY) 22 | private Long id; 23 | 24 | @NotBlank 25 | @Size(max = 2083) 26 | private String url; 27 | 28 | @NotBlank 29 | @Size(max = 50) 30 | private String uploadName; 31 | 32 | @NotBlank 33 | @Size(max = 50) 34 | private String storedName; 35 | 36 | @ManyToOne(fetch = FetchType.LAZY) 37 | @JoinColumn(name = "talk_pick_id") 38 | private TalkPick talkPick; 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/TalkPickReader.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain; 2 | 3 | import balancetalk.global.exception.BalanceTalkException; 4 | import balancetalk.global.exception.ErrorCode; 5 | import balancetalk.talkpick.domain.repository.TalkPickRepository; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.stereotype.Component; 8 | 9 | @Component 10 | @RequiredArgsConstructor 11 | public class TalkPickReader { 12 | 13 | private final TalkPickRepository talkPickRepository; 14 | 15 | public TalkPick readById(Long id) { 16 | return talkPickRepository.findById(id) 17 | .orElseThrow(() -> new BalanceTalkException(ErrorCode.NOT_FOUND_TALK_PICK)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/TempTalkPick.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import balancetalk.member.domain.Member; 5 | import jakarta.persistence.Column; 6 | import jakarta.persistence.Entity; 7 | import jakarta.persistence.GeneratedValue; 8 | import jakarta.persistence.GenerationType; 9 | import jakarta.persistence.Id; 10 | import jakarta.persistence.JoinColumn; 11 | import jakarta.persistence.OneToOne; 12 | import jakarta.validation.constraints.Size; 13 | import lombok.AccessLevel; 14 | import lombok.AllArgsConstructor; 15 | import lombok.Builder; 16 | import lombok.Getter; 17 | import lombok.NoArgsConstructor; 18 | 19 | @Entity 20 | @Getter 21 | @Builder 22 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 23 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 24 | public class TempTalkPick extends BaseTimeEntity { 25 | 26 | @Id 27 | @GeneratedValue(strategy = GenerationType.IDENTITY) 28 | private Long id; 29 | 30 | @OneToOne 31 | @JoinColumn(name = "member_id") 32 | private Member member; 33 | 34 | @Size(max = 50) 35 | private String title; 36 | 37 | @Column(columnDefinition = "LONGTEXT") 38 | private String content; 39 | 40 | @Size(max = 10) 41 | @Column(name = "option_a") 42 | private String optionA; 43 | 44 | @Size(max = 10) 45 | @Column(name = "option_b") 46 | private String optionB; 47 | 48 | private String sourceUrl; 49 | 50 | public Long update(TempTalkPick newTempTalkPick) { 51 | this.title = newTempTalkPick.getTitle(); 52 | this.content = newTempTalkPick.getContent(); 53 | this.optionA = newTempTalkPick.getOptionA(); 54 | this.optionB = newTempTalkPick.getOptionB(); 55 | this.sourceUrl = newTempTalkPick.getSourceUrl(); 56 | return id; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/TodayTalkPick.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import jakarta.persistence.Entity; 5 | import jakarta.persistence.FetchType; 6 | import jakarta.persistence.GeneratedValue; 7 | import jakarta.persistence.GenerationType; 8 | import jakarta.persistence.Id; 9 | import jakarta.persistence.JoinColumn; 10 | import jakarta.persistence.ManyToOne; 11 | import jakarta.validation.constraints.NotNull; 12 | import java.time.LocalDate; 13 | import lombok.AccessLevel; 14 | import lombok.AllArgsConstructor; 15 | import lombok.Builder; 16 | import lombok.Getter; 17 | import lombok.NoArgsConstructor; 18 | 19 | @Entity 20 | @Getter 21 | @Builder 22 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 23 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 24 | public class TodayTalkPick extends BaseTimeEntity { 25 | 26 | @Id 27 | @GeneratedValue(strategy = GenerationType.IDENTITY) 28 | private Long id; 29 | 30 | @ManyToOne(fetch = FetchType.LAZY) 31 | @JoinColumn(name = "talk_pick_id") 32 | private TalkPick talkPick; 33 | 34 | @NotNull 35 | private LocalDate pickDate; 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/ViewStatus.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain; 2 | 3 | public enum ViewStatus { 4 | NORMAL, BLIND 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/event/TalkPickCreatedEvent.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain.event; 2 | 3 | import java.util.List; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Getter 9 | @NoArgsConstructor 10 | @AllArgsConstructor 11 | public class TalkPickCreatedEvent { 12 | 13 | private Long talkPickId; 14 | private List fileIds; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/event/TalkPickDeletedEvent.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain.event; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | import lombok.NoArgsConstructor; 6 | 7 | @Getter 8 | @NoArgsConstructor 9 | @AllArgsConstructor 10 | public class TalkPickDeletedEvent { 11 | 12 | private Long talkPickId; 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/event/TalkPickEventHandler.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain.event; 2 | 3 | import balancetalk.talkpick.application.TalkPickFileService; 4 | import balancetalk.talkpick.application.TalkPickSummaryService; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Component; 7 | import org.springframework.transaction.event.TransactionalEventListener; 8 | 9 | @Component 10 | @RequiredArgsConstructor 11 | public class TalkPickEventHandler { 12 | 13 | private final TalkPickSummaryService talkPickSummaryService; 14 | private final TalkPickFileService talkPickFileService; 15 | 16 | @TransactionalEventListener 17 | public void handleTalkPickCreatedEvent(TalkPickCreatedEvent event) { 18 | talkPickFileService.handleFilesOnTalkPickCreate(event.getFileIds(), event.getTalkPickId()); 19 | talkPickSummaryService.summarizeTalkPick(event.getTalkPickId()); 20 | } 21 | 22 | @TransactionalEventListener 23 | public void handleTalkPickUpdatedEvent(TalkPickUpdatedEvent event) { 24 | talkPickFileService.handleFilesOnTalkPickUpdate( 25 | event.getNewFileIds(), event.getDeleteFileIds(), event.getTalkPickId()); 26 | talkPickSummaryService.summarizeTalkPick(event.getTalkPickId()); 27 | } 28 | 29 | @TransactionalEventListener 30 | public void handleTalkPickDeletedEvent(TalkPickDeletedEvent event) { 31 | talkPickFileService.handleFilesOnTalkPickDelete(event.getTalkPickId()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/event/TalkPickUpdatedEvent.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain.event; 2 | 3 | import java.util.List; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Getter 9 | @NoArgsConstructor 10 | @AllArgsConstructor 11 | public class TalkPickUpdatedEvent { 12 | 13 | private Long talkPickId; 14 | private List newFileIds; 15 | private List deleteFileIds; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/repository/SearchTalkPickRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain.repository; 2 | 3 | import balancetalk.talkpick.domain.TalkPick; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface SearchTalkPickRepository extends JpaRepository, SearchTalkPickRepositoryCustom { 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/repository/SearchTalkPickRepositoryCustom.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain.repository; 2 | 3 | import balancetalk.talkpick.dto.SearchTalkPickResponse; 4 | import org.springframework.data.domain.Page; 5 | import org.springframework.data.domain.Pageable; 6 | 7 | public interface SearchTalkPickRepositoryCustom { 8 | 9 | Page searchTalkPicks(String query, Pageable pageable); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/repository/TalkPickRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain.repository; 2 | 3 | import balancetalk.member.domain.Member; 4 | import balancetalk.talkpick.domain.SummaryStatus; 5 | import balancetalk.talkpick.domain.TalkPick; 6 | import java.util.List; 7 | import org.springframework.data.domain.Page; 8 | import org.springframework.data.domain.Pageable; 9 | import org.springframework.data.jpa.repository.JpaRepository; 10 | 11 | public interface TalkPickRepository extends JpaRepository, TalkPickRepositoryCustom { 12 | 13 | Page findAllByMemberOrderByEditedAtDesc(Member member, Pageable pageable); 14 | 15 | List findAllBySummaryStatus(SummaryStatus summaryStatus); 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/repository/TalkPickRepositoryCustom.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain.repository; 2 | 3 | import static balancetalk.talkpick.dto.TalkPickDto.TalkPickResponse; 4 | 5 | import balancetalk.talkpick.domain.TalkPick; 6 | import java.util.List; 7 | import org.springframework.data.domain.Page; 8 | import org.springframework.data.domain.Pageable; 9 | 10 | public interface TalkPickRepositoryCustom { 11 | 12 | List findCandidateTodayTalkPicks(int topN, List yesterdayTalkPicks); 13 | 14 | Page findPagedTalkPicks(Pageable pageable); 15 | 16 | List findBestTalkPicks(); 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/repository/TalkPickRepositoryImpl.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain.repository; 2 | 3 | import static balancetalk.global.utils.QuerydslUtils.getOrderSpecifiers; 4 | import static balancetalk.talkpick.domain.QTalkPick.talkPick; 5 | import static balancetalk.talkpick.dto.TalkPickDto.TalkPickResponse; 6 | import static balancetalk.vote.domain.QTalkPickVote.talkPickVote; 7 | 8 | import balancetalk.talkpick.domain.TalkPick; 9 | import balancetalk.talkpick.dto.QTalkPickDto_TalkPickResponse; 10 | import com.querydsl.jpa.impl.JPAQuery; 11 | import com.querydsl.jpa.impl.JPAQueryFactory; 12 | import java.util.List; 13 | import lombok.RequiredArgsConstructor; 14 | import org.springframework.data.domain.Page; 15 | import org.springframework.data.domain.Pageable; 16 | import org.springframework.data.support.PageableExecutionUtils; 17 | 18 | @RequiredArgsConstructor 19 | public class TalkPickRepositoryImpl implements TalkPickRepositoryCustom { 20 | 21 | private final JPAQueryFactory queryFactory; 22 | 23 | @Override 24 | public List findCandidateTodayTalkPicks(int topN, List yesterdayTalkPicks) { 25 | return queryFactory 26 | .selectFrom(talkPick) 27 | .leftJoin(talkPick.votes, talkPickVote) 28 | .where(talkPick.notIn(yesterdayTalkPicks)) 29 | .groupBy(talkPick.id) 30 | .orderBy(talkPick.views.desc(), talkPickVote.count().desc(), talkPick.createdAt.desc()) 31 | .limit(topN) 32 | .fetch(); 33 | } 34 | 35 | @Override 36 | public Page findPagedTalkPicks(Pageable pageable) { 37 | List content = queryFactory 38 | .select(new QTalkPickDto_TalkPickResponse( 39 | talkPick.id, talkPick.title, talkPick.member.nickname, 40 | talkPick.createdAt, talkPick.views, talkPick.bookmarks 41 | )) 42 | .from(talkPick) 43 | .orderBy(getOrderSpecifiers(talkPick, pageable.getSort())) 44 | .offset(pageable.getOffset()) 45 | .limit(pageable.getPageSize()) 46 | .fetch(); 47 | 48 | JPAQuery countQuery = queryFactory 49 | .select(talkPick.count()) 50 | .from(talkPick); 51 | 52 | return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); 53 | } 54 | 55 | @Override 56 | public List findBestTalkPicks() { 57 | return queryFactory 58 | .select(new QTalkPickDto_TalkPickResponse( 59 | talkPick.id, talkPick.title, talkPick.member.nickname, 60 | talkPick.createdAt, talkPick.views, talkPick.bookmarks 61 | )) 62 | .from(talkPick) 63 | .orderBy(talkPick.views.desc(), talkPick.createdAt.desc()) 64 | .limit(3) 65 | .fetch(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/repository/TempTalkPickRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain.repository; 2 | 3 | import balancetalk.member.domain.Member; 4 | import balancetalk.talkpick.domain.TempTalkPick; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | 7 | import java.util.Optional; 8 | 9 | public interface TempTalkPickRepository extends JpaRepository { 10 | Optional findByMember(Member member); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/domain/repository/TodayTalkPickRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain.repository; 2 | 3 | import balancetalk.talkpick.domain.TodayTalkPick; 4 | import java.time.LocalDate; 5 | import java.util.List; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | 8 | public interface TodayTalkPickRepository extends JpaRepository { 9 | 10 | List findByPickDate(LocalDate pickDate); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/dto/SearchTalkPickResponse.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.dto; 2 | 3 | import balancetalk.talkpick.domain.TalkPick; 4 | import com.querydsl.core.annotations.QueryProjection; 5 | import io.swagger.v3.oas.annotations.media.Schema; 6 | import java.time.LocalDateTime; 7 | import lombok.Data; 8 | 9 | @Schema(description = "톡픽 검색 응답") 10 | @Data 11 | public class SearchTalkPickResponse { 12 | 13 | @Schema(description = "톡픽 ID", example = "2") 14 | private Long id; 15 | 16 | @Schema(description = "제목", example = "톡픽 제목") 17 | private String title; 18 | 19 | private SummaryResponse summary; 20 | 21 | @Schema(description = "본문 내용", example = "톡픽 본문 내용") 22 | private String content; 23 | 24 | @Schema(description = "선택지 A 이름", example = "선택지 A 이름") 25 | private String optionA; 26 | 27 | @Schema(description = "선택지 B 이름", example = "선택지 B 이름") 28 | private String optionB; 29 | 30 | @Schema(description = "첫 번째 이미지 URL", example = "https://picko-image.s3.ap-northeast-2.amazonaws.com/temp-talk-pick/9b4856fe-b624-4e54-ad80-a94e083301d2_czz.png") 31 | private String firstImgUrl; 32 | 33 | @Schema(description = "작성일", example = "2024-08-04") 34 | private LocalDateTime createdAt; 35 | 36 | @QueryProjection 37 | public SearchTalkPickResponse(TalkPick talkPick, String firstImgUrl) { 38 | this.id = talkPick.getId(); 39 | this.title = talkPick.getTitle(); 40 | this.summary = new SummaryResponse(talkPick.getSummary()); 41 | this.content = talkPick.getContent(); 42 | this.optionA = talkPick.getOptionA(); 43 | this.optionB = talkPick.getOptionB(); 44 | this.createdAt = talkPick.getCreatedAt(); 45 | this.firstImgUrl = firstImgUrl; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/dto/SummaryResponse.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.dto; 2 | 3 | import balancetalk.talkpick.domain.Summary; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import lombok.Data; 6 | 7 | @Schema(description = "톡픽 3줄 요약") 8 | @Data 9 | public class SummaryResponse { 10 | 11 | @Schema(description = "요약 1번째 줄", example = "요약 1번째 줄 내용") 12 | private String summaryFirstLine; 13 | 14 | @Schema(description = "요약 2번째 줄", example = "요약 2번째 줄 내용") 15 | private String summarySecondLine; 16 | 17 | @Schema(description = "요약 3번째 줄", example = "요약 3번째 줄 내용") 18 | private String summaryThirdLine; 19 | 20 | public SummaryResponse(Summary summary) { 21 | if (summary == null) { 22 | return; 23 | } 24 | this.summaryFirstLine = summary.getFirstLine(); 25 | this.summarySecondLine = summary.getSecondLine(); 26 | this.summaryThirdLine = summary.getThirdLine(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/dto/TodayTalkPickDto.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.dto; 2 | 3 | import balancetalk.talkpick.domain.TalkPick; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Data; 8 | 9 | public class TodayTalkPickDto { 10 | 11 | @Schema(description = "오늘의 톡픽 조회 응답") 12 | @Data 13 | @Builder 14 | @AllArgsConstructor 15 | public static class TodayTalkPickResponse { 16 | 17 | @Schema(description = "톡픽 ID", example = "톡픽 ID") 18 | private Long id; 19 | 20 | @Schema(description = "제목", example = "톡픽 제목") 21 | private String title; 22 | 23 | @Schema(description = "선택지 A 이름", example = "선택지 A 이름") 24 | private String optionA; 25 | 26 | @Schema(description = "선택지 B 이름", example = "선택지 B 이름") 27 | private String optionB; 28 | 29 | public static TodayTalkPickResponse from(TalkPick talkPick) { 30 | return TodayTalkPickResponse.builder() 31 | .id(talkPick.getId()) 32 | .title(talkPick.getTitle()) 33 | .optionA(talkPick.getOptionA()) 34 | .optionB(talkPick.getOptionB()) 35 | .build(); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/dto/fields/BaseTalkPickFields.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.dto.fields; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import jakarta.validation.constraints.Size; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Getter; 9 | 10 | @Getter 11 | @Builder 12 | @AllArgsConstructor 13 | public class BaseTalkPickFields { 14 | 15 | @Schema(description = "제목", example = "제목") 16 | @Size(max = 50, message = "제목은 50자 이하여야 합니다.") 17 | private String title; 18 | 19 | @Schema(description = "본문 내용", example = "본문 내용") 20 | @Size(max = 2000, message = "본문은 2,000자 이하여야 합니다.") 21 | private String content; 22 | 23 | @Schema(description = "선택지 A 이름", example = "선택지 A 이름") 24 | @Size(max = 30, message = "선택지 이름은 30자 이하여야 합니다.") 25 | private String optionA; 26 | 27 | @Schema(description = "선택지 B 이름", example = "선택지 B 이름") 28 | @Size(max = 30, message = "선택지 이름은 30자 이하여야 합니다.") 29 | private String optionB; 30 | 31 | @Schema(description = "출처 URL", example = "https://github.com/CHZZK-Study/Balance-Talk-Backend/issues/506") 32 | @JsonInclude(JsonInclude.Include.NON_NULL) 33 | private String sourceUrl; 34 | 35 | public static BaseTalkPickFields from(String title, String content, 36 | String optionA, String optionB, 37 | String sourceUrl) { 38 | return BaseTalkPickFields.builder() 39 | .title(title) 40 | .content(content) 41 | .optionA(optionA) 42 | .optionB(optionB) 43 | .sourceUrl(sourceUrl) 44 | .build(); 45 | } 46 | 47 | public static BaseTalkPickFields from(String title, String content, 48 | String optionA, String optionB) { 49 | return BaseTalkPickFields.builder() 50 | .title(title) 51 | .content(content) 52 | .optionA(optionA) 53 | .optionB(optionB) 54 | .build(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/dto/fields/ValidatedNotBlankTalkPickFields.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.dto.fields; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | 5 | public class ValidatedNotBlankTalkPickFields extends BaseTalkPickFields { 6 | 7 | public ValidatedNotBlankTalkPickFields(String title, String content, 8 | String optionA, String optionB, String sourceUrl) { 9 | super(title, content, optionA, optionB, sourceUrl); 10 | } 11 | 12 | @NotBlank(message = "제목은 공백을 허용하지 않습니다.") 13 | @Override 14 | public String getTitle() { 15 | return super.getTitle(); 16 | } 17 | 18 | @NotBlank(message = "본문 내용은 공백을 허용하지 않습니다.") 19 | @Override 20 | public String getContent() { 21 | return super.getContent(); 22 | } 23 | 24 | @NotBlank(message = "선택지 이름은 공백을 허용하지 않습니다.") 25 | @Override 26 | public String getOptionA() { 27 | return super.getOptionA(); 28 | } 29 | 30 | @NotBlank(message = "선택지 이름은 공백을 허용하지 않습니다.") 31 | @Override 32 | public String getOptionB() { 33 | return super.getOptionB(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/presentation/SearchTalkPickController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.presentation; 2 | 3 | import balancetalk.talkpick.application.SearchTalkPickService; 4 | import balancetalk.talkpick.dto.SearchTalkPickResponse; 5 | import io.swagger.v3.oas.annotations.Operation; 6 | import io.swagger.v3.oas.annotations.tags.Tag; 7 | import lombok.RequiredArgsConstructor; 8 | import org.springframework.data.domain.Page; 9 | import org.springframework.data.domain.Pageable; 10 | import org.springframework.data.web.PageableDefault; 11 | import org.springframework.web.bind.annotation.GetMapping; 12 | import org.springframework.web.bind.annotation.RequestMapping; 13 | import org.springframework.web.bind.annotation.RequestParam; 14 | import org.springframework.web.bind.annotation.RestController; 15 | 16 | import static org.springframework.data.domain.Sort.Direction.DESC; 17 | 18 | @Tag(name = "talk_pick", description = "톡픽 API") 19 | @RestController 20 | @RequestMapping("/talks/search") 21 | @RequiredArgsConstructor 22 | public class SearchTalkPickController { 23 | 24 | private final SearchTalkPickService searchTalkPickService; 25 | 26 | @Operation(summary = "톡픽 검색", description = "키워드를 통해 톡픽을 검색합니다.") 27 | @GetMapping 28 | public Page searchTalkPicks( 29 | @RequestParam final String query, 30 | @PageableDefault(size = 4, sort = "views", direction = DESC) Pageable pageable) { 31 | return searchTalkPickService.searchTalkPicks(query, pageable); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/presentation/TempTalkPickController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.presentation; 2 | 3 | import balancetalk.global.utils.AuthPrincipal; 4 | import balancetalk.member.dto.ApiMember; 5 | import balancetalk.talkpick.application.TempTalkPickService; 6 | import io.swagger.v3.oas.annotations.Operation; 7 | import io.swagger.v3.oas.annotations.Parameter; 8 | import io.swagger.v3.oas.annotations.tags.Tag; 9 | import jakarta.validation.Valid; 10 | import lombok.RequiredArgsConstructor; 11 | import org.springframework.web.bind.annotation.GetMapping; 12 | import org.springframework.web.bind.annotation.PostMapping; 13 | import org.springframework.web.bind.annotation.RequestBody; 14 | import org.springframework.web.bind.annotation.RequestMapping; 15 | import org.springframework.web.bind.annotation.RestController; 16 | 17 | import static balancetalk.talkpick.dto.TempTalkPickDto.FindTempTalkPickResponse; 18 | import static balancetalk.talkpick.dto.TempTalkPickDto.SaveTempTalkPickRequest; 19 | 20 | @RestController 21 | @RequiredArgsConstructor 22 | @RequestMapping("/talks/temp") 23 | @Tag(name = "talk_pick", description = "톡픽 API") 24 | public class TempTalkPickController { 25 | 26 | private final TempTalkPickService tempTalkPickService; 27 | 28 | @Operation(summary = "톡픽 임시 저장", description = "작성중인 톡픽을 임시 저장합니다.") 29 | @PostMapping 30 | public void saveTempTalkPick(@RequestBody @Valid final SaveTempTalkPickRequest request, 31 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 32 | tempTalkPickService.createTempTalkPick(request, apiMember); 33 | } 34 | 35 | @Operation(summary = "임시 저장한 톡픽 조회", description = "임시 저장한 톡픽을 불러옵니다.") 36 | @GetMapping 37 | public FindTempTalkPickResponse findTempTalkPick(@Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 38 | return tempTalkPickService.findTempTalkPick(apiMember); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/talkpick/presentation/TodayTalkPickController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.presentation; 2 | 3 | import static balancetalk.talkpick.dto.TodayTalkPickDto.TodayTalkPickResponse; 4 | 5 | import balancetalk.talkpick.application.TodayTalkPickService; 6 | import io.swagger.v3.oas.annotations.Operation; 7 | import io.swagger.v3.oas.annotations.tags.Tag; 8 | import java.util.List; 9 | import lombok.RequiredArgsConstructor; 10 | import org.springframework.web.bind.annotation.GetMapping; 11 | import org.springframework.web.bind.annotation.RequestMapping; 12 | import org.springframework.web.bind.annotation.RestController; 13 | 14 | @RequiredArgsConstructor 15 | @RestController 16 | @RequestMapping("/talks/today") 17 | @Tag(name = "talk_pick", description = "톡픽 API") 18 | public class TodayTalkPickController { 19 | 20 | private final TodayTalkPickService todayTalkPickService; 21 | 22 | @Operation(summary = "오늘의 톡픽 조회", description = "오늘의 톡픽을 조회합니다.") 23 | @GetMapping 24 | public List findTodayTalkPick() { 25 | return todayTalkPickService.findTodayTalkPick(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/vote/domain/GameVote.java: -------------------------------------------------------------------------------- 1 | package balancetalk.vote.domain; 2 | 3 | import balancetalk.game.domain.GameOption; 4 | import balancetalk.global.common.BaseTimeEntity; 5 | import balancetalk.member.domain.Member; 6 | import jakarta.persistence.Entity; 7 | import jakarta.persistence.FetchType; 8 | import jakarta.persistence.GeneratedValue; 9 | import jakarta.persistence.GenerationType; 10 | import jakarta.persistence.Id; 11 | import jakarta.persistence.JoinColumn; 12 | import jakarta.persistence.ManyToOne; 13 | import lombok.AccessLevel; 14 | import lombok.AllArgsConstructor; 15 | import lombok.Builder; 16 | import lombok.Getter; 17 | import lombok.NoArgsConstructor; 18 | 19 | @Entity 20 | @Getter 21 | @Builder 22 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 23 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 24 | public class GameVote extends BaseTimeEntity { 25 | 26 | @Id 27 | @GeneratedValue(strategy = GenerationType.IDENTITY) 28 | private Long id; 29 | 30 | @ManyToOne(fetch = FetchType.LAZY) 31 | @JoinColumn(name = "member_id") 32 | private Member member; 33 | 34 | @ManyToOne(fetch = FetchType.LAZY) 35 | @JoinColumn(name = "game_option_id") 36 | private GameOption gameOption; 37 | 38 | private boolean isActive; 39 | 40 | public VoteOption getVoteOption() { 41 | return gameOption.getOptionType(); 42 | } 43 | 44 | public boolean matchesGameOption(GameOption gameOption) { 45 | return this.gameOption.equals(gameOption); 46 | } 47 | 48 | 49 | public void updateGameOption(GameOption gameOption) { 50 | this.gameOption = gameOption; 51 | } 52 | 53 | public void updateActive(boolean isActive) { 54 | this.isActive = isActive; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/vote/domain/TalkPickVote.java: -------------------------------------------------------------------------------- 1 | package balancetalk.vote.domain; 2 | 3 | import balancetalk.global.common.BaseTimeEntity; 4 | import balancetalk.member.domain.Member; 5 | import balancetalk.talkpick.domain.TalkPick; 6 | import jakarta.persistence.Entity; 7 | import jakarta.persistence.EnumType; 8 | import jakarta.persistence.Enumerated; 9 | import jakarta.persistence.FetchType; 10 | import jakarta.persistence.GeneratedValue; 11 | import jakarta.persistence.GenerationType; 12 | import jakarta.persistence.Id; 13 | import jakarta.persistence.JoinColumn; 14 | import jakarta.persistence.ManyToOne; 15 | import jakarta.validation.constraints.NotNull; 16 | import lombok.AccessLevel; 17 | import lombok.AllArgsConstructor; 18 | import lombok.Builder; 19 | import lombok.Getter; 20 | import lombok.NoArgsConstructor; 21 | 22 | @Entity 23 | @Getter 24 | @Builder 25 | @AllArgsConstructor(access = AccessLevel.PRIVATE) 26 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 27 | public class TalkPickVote extends BaseTimeEntity { 28 | 29 | @Id 30 | @GeneratedValue(strategy = GenerationType.IDENTITY) 31 | private Long id; 32 | 33 | @ManyToOne(fetch = FetchType.LAZY) 34 | @JoinColumn(name = "member_id") 35 | private Member member; 36 | 37 | @ManyToOne(fetch = FetchType.LAZY) 38 | @JoinColumn(name = "talk_pick_id") 39 | private TalkPick talkPick; 40 | 41 | @Enumerated(value = EnumType.STRING) 42 | @NotNull 43 | private VoteOption voteOption; 44 | 45 | public boolean matchesTalkPick(TalkPick talkPick) { 46 | return this.talkPick.equals(talkPick); 47 | } 48 | 49 | public boolean isVoteOptionEquals(VoteOption voteOption) { 50 | return this.voteOption.equals(voteOption); 51 | } 52 | 53 | public void updateVoteOption(VoteOption newVoteOption) { 54 | this.voteOption = newVoteOption; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/vote/domain/TalkPickVoteRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.vote.domain; 2 | 3 | import org.springframework.data.domain.Page; 4 | import org.springframework.data.domain.Pageable; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.data.jpa.repository.Query; 7 | 8 | import java.util.List; 9 | 10 | public interface TalkPickVoteRepository extends JpaRepository { 11 | 12 | @Query("SELECT v FROM TalkPickVote v WHERE v.member.id = :memberId AND v.talkPick IS NOT NULL ORDER BY v.lastModifiedAt DESC") 13 | Page findAllByMemberIdAndTalkPickDesc(Long memberId, Pageable pageable); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/vote/domain/VoteOption.java: -------------------------------------------------------------------------------- 1 | package balancetalk.vote.domain; 2 | 3 | import balancetalk.global.exception.BalanceTalkException; 4 | import com.fasterxml.jackson.annotation.JsonCreator; 5 | import com.fasterxml.jackson.annotation.JsonValue; 6 | 7 | import static balancetalk.global.exception.ErrorCode.INVALID_VOTE_OPTION; 8 | 9 | public enum VoteOption { 10 | A, B; 11 | 12 | @JsonCreator 13 | public static VoteOption from(String value) { 14 | for (VoteOption option : VoteOption.values()) { 15 | if (option.name().equals(value)) { 16 | return option; 17 | } 18 | } 19 | throw new BalanceTalkException(INVALID_VOTE_OPTION); 20 | } 21 | 22 | @JsonValue 23 | public String toValue() { 24 | return this.name(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/vote/domain/VoteRepository.java: -------------------------------------------------------------------------------- 1 | package balancetalk.vote.domain; 2 | 3 | import balancetalk.game.domain.GameSet; 4 | import java.util.List; 5 | import java.util.Optional; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | import org.springframework.data.jpa.repository.Modifying; 8 | import org.springframework.data.jpa.repository.Query; 9 | import org.springframework.data.repository.query.Param; 10 | 11 | 12 | public interface VoteRepository extends JpaRepository { 13 | 14 | // [1] "내가 투표한 밸런스게임 목록" 전용: 비활성화 포함 (isActive 조건 제거) 15 | GameVote findTopByMemberIdAndGameOptionIdInOrderByCreatedAtDesc( 16 | Long memberId, 17 | List gameOptionIds 18 | ); 19 | 20 | // [2] 활성화된 투표만 조회 (기존 로직 그대로 사용 - 추후 사용 염두) 21 | @Query(""" 22 | SELECT gv 23 | FROM GameVote gv 24 | WHERE gv.member.id = :memberId 25 | AND gv.gameOption.game.id = :gameId 26 | AND gv.isActive = true 27 | """) 28 | Optional findActiveVoteByMemberIdAndGameId(@Param("memberId") Long memberId, 29 | @Param("gameId") Long gameId); 30 | 31 | @Modifying 32 | @Query(""" 33 | UPDATE GameVote gv SET gv.isActive = false 34 | WHERE gv.member.id = :memberId AND gv.gameOption.game.gameSet = :gameSet 35 | """) 36 | void updateVotesAsInactive(@Param("memberId") Long memberId, @Param("gameSet") GameSet gameSet); 37 | 38 | // 특정 사용자가 특정 게임에 대해 투표한 기록 조회 (비활성화된 투표도 포함) 39 | @Query("SELECT gv FROM GameVote gv WHERE gv.member.id = :memberId AND gv.gameOption.game.id = :gameId") 40 | Optional findByMemberIdAndGameId(@Param("memberId") Long memberId, @Param("gameId") Long gameId); 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/vote/dto/VoteGameDto.java: -------------------------------------------------------------------------------- 1 | package balancetalk.vote.dto; 2 | 3 | import balancetalk.game.domain.GameOption; 4 | import balancetalk.member.domain.Member; 5 | import balancetalk.vote.domain.GameVote; 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | 12 | public class VoteGameDto { 13 | 14 | @Data 15 | @AllArgsConstructor 16 | @NoArgsConstructor 17 | @Schema(description = "밸런스 게임 투표 생성 요청") 18 | public static class VoteRequest { 19 | 20 | @Schema(description = "투표할 선택지", example = "A") 21 | private String voteOption; 22 | 23 | public GameVote toEntity(Member member, GameOption gameOption) { 24 | return GameVote.builder() 25 | .member(member) 26 | .gameOption(gameOption) 27 | .isActive(true) 28 | .build(); 29 | } 30 | } 31 | 32 | @Data 33 | @AllArgsConstructor 34 | @Schema(description = "밸런스 게임 투표 결과 응답") 35 | public static class VoteResultResponse { 36 | 37 | @Schema(description = "선택지 B 투표 개수", example = "23") 38 | private int optionACount; 39 | 40 | @Schema(description = "선택지 B 투표 개수", example = "12") 41 | private int optionBCount; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/vote/dto/VoteTalkPickDto.java: -------------------------------------------------------------------------------- 1 | package balancetalk.vote.dto; 2 | 3 | import balancetalk.member.domain.Member; 4 | import balancetalk.talkpick.domain.TalkPick; 5 | import balancetalk.vote.domain.TalkPickVote; 6 | import balancetalk.vote.domain.VoteOption; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import jakarta.validation.constraints.NotNull; 9 | import lombok.AllArgsConstructor; 10 | import lombok.Data; 11 | import lombok.NoArgsConstructor; 12 | 13 | public class VoteTalkPickDto { 14 | 15 | @Data 16 | @AllArgsConstructor 17 | @NoArgsConstructor 18 | @Schema(description = "톡픽 투표 생성 요청") 19 | public static class VoteRequest { 20 | 21 | @Schema(description = "투표할 선택지", example = "A") 22 | @NotNull(message = "선택지 값은 필수입니다.") 23 | private VoteOption voteOption; 24 | 25 | public TalkPickVote toEntity(Member member, TalkPick talkPick) { 26 | return TalkPickVote.builder() 27 | .member(member) 28 | .talkPick(talkPick) 29 | .voteOption(voteOption) 30 | .build(); 31 | } 32 | } 33 | 34 | @Data 35 | @AllArgsConstructor 36 | @Schema(description = "톡픽 투표 결과 응답") 37 | public static class VoteResultResponse { 38 | 39 | @Schema(description = "선택지 B 투표 개수", example = "23") 40 | private int optionACount; 41 | 42 | @Schema(description = "선택지 B 투표 개수", example = "12") 43 | private int optionBCount; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/vote/presentation/VoteGameController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.vote.presentation; 2 | 3 | import balancetalk.global.utils.AuthPrincipal; 4 | import balancetalk.member.dto.ApiMember; 5 | import balancetalk.vote.application.VoteGameService; 6 | import io.swagger.v3.oas.annotations.Operation; 7 | import io.swagger.v3.oas.annotations.Parameter; 8 | import io.swagger.v3.oas.annotations.tags.Tag; 9 | import lombok.RequiredArgsConstructor; 10 | import org.springframework.web.bind.annotation.*; 11 | 12 | import static balancetalk.vote.dto.VoteGameDto.VoteRequest; 13 | 14 | @RestController 15 | @RequestMapping("/votes/games/{gameId}") 16 | @RequiredArgsConstructor 17 | @Tag(name = "vote", description = "투표 API") 18 | public class VoteGameController { 19 | 20 | private final VoteGameService voteGameService; 21 | 22 | @Operation(summary = "밸런스 게임 투표 생성", description = "밸런스 게임에서 원하는 선택지에 투표합니다.") 23 | @PostMapping 24 | public void createVoteGame(@PathVariable Long gameId, @RequestBody VoteRequest request, 25 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 26 | voteGameService.createVote(gameId, request, apiMember); 27 | } 28 | 29 | @Operation(summary = "밸런스 게임 투표 수정", description = "밸런스 게임 투표를 수정합니다.") 30 | @PutMapping 31 | public void updateVoteGame(@PathVariable Long gameId, @RequestBody VoteRequest request, 32 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 33 | voteGameService.updateVote(gameId, request, apiMember); 34 | } 35 | 36 | @Operation(summary = "밸런스 게임 투표 삭제", description = "밸런스 게임 투표를 삭제합니다.") 37 | @DeleteMapping 38 | public void deleteVoteGame(@PathVariable Long gameId, 39 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 40 | voteGameService.deleteVote(gameId, apiMember); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/balancetalk/vote/presentation/VoteTalkPickController.java: -------------------------------------------------------------------------------- 1 | package balancetalk.vote.presentation; 2 | 3 | import static balancetalk.vote.dto.VoteTalkPickDto.VoteRequest; 4 | 5 | import balancetalk.global.utils.AuthPrincipal; 6 | import balancetalk.member.dto.ApiMember; 7 | import balancetalk.vote.application.VoteTalkPickService; 8 | import io.swagger.v3.oas.annotations.Operation; 9 | import io.swagger.v3.oas.annotations.Parameter; 10 | import io.swagger.v3.oas.annotations.tags.Tag; 11 | import jakarta.validation.Valid; 12 | import lombok.RequiredArgsConstructor; 13 | import org.springframework.web.bind.annotation.DeleteMapping; 14 | import org.springframework.web.bind.annotation.PathVariable; 15 | import org.springframework.web.bind.annotation.PostMapping; 16 | import org.springframework.web.bind.annotation.PutMapping; 17 | import org.springframework.web.bind.annotation.RequestBody; 18 | import org.springframework.web.bind.annotation.RequestMapping; 19 | import org.springframework.web.bind.annotation.RestController; 20 | 21 | @RestController 22 | @RequiredArgsConstructor 23 | @RequestMapping("/votes/talks/{talkPickId}") 24 | @Tag(name = "vote", description = "투표 API") 25 | public class VoteTalkPickController { 26 | 27 | private final VoteTalkPickService voteTalkPickService; 28 | 29 | @Operation(summary = "톡픽 투표 생성", description = "톡픽에서 원하는 선택지에 투표합니다.") 30 | @PostMapping 31 | public void createVoteTalkPick(@PathVariable long talkPickId, 32 | @RequestBody @Valid VoteRequest request, 33 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 34 | voteTalkPickService.createVote(talkPickId, request, apiMember); 35 | } 36 | 37 | @Operation(summary = "톡픽 투표 수정", description = "톡픽 투표를 수정합니다.") 38 | @PutMapping 39 | public void updateVoteResultTalkPick(@PathVariable long talkPickId, 40 | @RequestBody @Valid VoteRequest request, 41 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 42 | voteTalkPickService.updateVote(talkPickId, request, apiMember); 43 | } 44 | 45 | @Operation(summary = "톡픽 투표 삭제", description = "톡픽 투표를 삭제합니다.") 46 | @DeleteMapping 47 | public void deleteVoteTalkPick(@PathVariable long talkPickId, 48 | @Parameter(hidden = true) @AuthPrincipal ApiMember apiMember) { 49 | voteTalkPickService.deleteVote(talkPickId, apiMember); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor: -------------------------------------------------------------------------------- 1 | balancetalk.global.config.MySQLFunctionContributor -------------------------------------------------------------------------------- /src/main/resources/logs/log4j2-dev.yml: -------------------------------------------------------------------------------- 1 | Configuration: 2 | name: Logger-dev 3 | status: info 4 | 5 | Appenders: 6 | RollingFile: 7 | name: RollingFile_Appender 8 | fileName: ${sys:LOG_DIR}/logfile.log 9 | filePattern: "${sys:LOG_DIR}/logfile-%d{yyyy-MM-dd}.%i.txt" 10 | PatternLayout: 11 | pattern: "%style{%d{yyyy-MM-dd HH:mm:ss.SSS}{GMT+9}}{cyan} %highlight{[%-5p]}{FATAL=bg_red, 12 | ERROR=red, INFO=green, DEBUG=blue, TRACE=bg_yellow} [%C] %style{[%t]}{yellow}- %m%n" 13 | # immediateFlush: false # async 방식으로 버퍼를 통해 로그 남기기 14 | 15 | Policies: 16 | SizeBasedTriggeringPolicy: 17 | size: "10 MB" 18 | TimeBasedTriggeringPolicy: 19 | Interval: 1 # 하루마다 rollover 발생 20 | modulate: true 21 | 22 | DefaultRollOverStrategy: 23 | max: 10 24 | Delete: 25 | basePath: ${sys:LOG_DIR} 26 | maxDepth: "1" # 디렉토리 깊이 27 | IfLastModified: 28 | age: "P7D" # 파일을 7일동안 보관 29 | 30 | Loggers: 31 | Root: 32 | level: info 33 | AppenderRef: 34 | ref: RollingFile_Appender 35 | Logger: 36 | name: balancetalk 37 | additivity: false 38 | level: info 39 | includeLocation: false 40 | AppenderRef: 41 | ref: RollingFile_Appender 42 | -------------------------------------------------------------------------------- /src/main/resources/logs/log4j2-local.yml: -------------------------------------------------------------------------------- 1 | Configutation: 2 | name: Logger-local 3 | status: debug 4 | 5 | Properties: 6 | Property: 7 | name: log-path 8 | value: "logs" 9 | 10 | Appenders: 11 | Console: 12 | name: Console_Appender 13 | target: SYSTEM_OUT 14 | PatternLayout: 15 | pattern: "%style{%d{yyyy-MM-dd HH:mm:ss.SSS}}{cyan} %highlight{[%-5p]}{FATAL=bg_red, 16 | ERROR=red, INFO=green, DEBUG=blue, TRACE=bg_yellow} [%C] %style{[%t]}{yellow}- %m%n" 17 | 18 | Loggers: 19 | Root: 20 | level: info 21 | AppenderRef: 22 | - ref: Console_Appender 23 | Logger: 24 | - name: com.pick-o 25 | additivity: false 26 | level: debug 27 | AppenderRef: 28 | - ref: Console_Appender 29 | -------------------------------------------------------------------------------- /src/main/resources/logs/log4j2-prod.yml: -------------------------------------------------------------------------------- 1 | Configuration: 2 | name: Logger-prod 3 | status: info 4 | 5 | Appenders: 6 | RollingFile: 7 | name: RollingFile_Appender 8 | fileName: ${sys:LOG_DIR}/logfile.log 9 | filePattern: "${sys:LOG_DIR}/logfile-%d{yyyy-MM-dd}.%i.txt" 10 | PatternLayout: 11 | pattern: "%style{%d{yyyy-MM-dd HH:mm:ss.SSS}{GMT+9}}{cyan} %highlight{[%-5p]}{FATAL=bg_red, 12 | ERROR=red, INFO=green, DEBUG=blue, TRACE=bg_yellow} [%C] %style{[%t]}{yellow}- %m%n" 13 | # immediateFlush: false # async 방식으로 버퍼를 통해 로그 남기기 14 | 15 | Policies: 16 | SizeBasedTriggeringPolicy: 17 | size: "10 MB" 18 | TimeBasedTriggeringPolicy: 19 | Interval: 1 # 하루마다 rollover 발생 20 | modulate: true 21 | 22 | DefaultRollOverStrategy: 23 | max: 10 24 | Delete: 25 | basePath: ${sys:LOG_DIR} 26 | maxDepth: "1" # 디렉토리 깊이 27 | IfLastModified: 28 | age: "P7D" # 파일을 7일동안 보관 29 | 30 | Loggers: 31 | Root: 32 | level: info 33 | AppenderRef: 34 | ref: RollingFile_Appender 35 | Logger: 36 | name: picko-prod 37 | additivity: false 38 | level: info 39 | includeLocation: false 40 | AppenderRef: 41 | ref: RollingFile_Appender -------------------------------------------------------------------------------- /src/test/java/balancetalk/BalanceTalkApplicationTests.java: -------------------------------------------------------------------------------- 1 | package balancetalk; 2 | 3 | import org.springframework.boot.test.context.SpringBootTest; 4 | 5 | @SpringBootTest 6 | class BalanceTalkApplicationTests { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/test/java/balancetalk/bookmark/domain/BookmarkTalkPickGeneratorTest.java: -------------------------------------------------------------------------------- 1 | package balancetalk.bookmark.domain; 2 | 3 | import balancetalk.member.domain.Member; 4 | import balancetalk.talkpick.domain.TalkPick; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.DisplayName; 7 | import org.junit.jupiter.api.Test; 8 | 9 | import static org.assertj.core.api.Assertions.assertThat; 10 | import static org.mockito.Mockito.mock; 11 | 12 | class BookmarkTalkPickGeneratorTest { 13 | 14 | BookmarkGenerator bookmarkGenerator; 15 | Member member; 16 | 17 | @BeforeEach 18 | void setUp() { 19 | bookmarkGenerator = new BookmarkGenerator(); 20 | member = Member.builder() 21 | .id(1L) 22 | .build(); 23 | } 24 | 25 | @Test 26 | @DisplayName("북마크 객체를 성공적으로 생성합니다.") 27 | void generate_Success_ThenReturnBookmark() { 28 | // given 29 | TalkPick talkPick = mock(TalkPick.class); 30 | 31 | // when 32 | TalkPickBookmark bookmark = bookmarkGenerator.generate(talkPick, member); 33 | 34 | // then 35 | assertThat(bookmark.getTalkPick()).isEqualTo(talkPick); 36 | assertThat(bookmark.getMember()).isEqualTo(member); 37 | } 38 | 39 | @Test 40 | void generate_Success_ThenActiveIsTrue() { 41 | // given 42 | TalkPick talkPick = mock(TalkPick.class); 43 | 44 | // when 45 | TalkPickBookmark bookmark = bookmarkGenerator.generate(talkPick, member); 46 | 47 | // then 48 | assertThat(bookmark.getActive()).isTrue(); 49 | } 50 | } -------------------------------------------------------------------------------- /src/test/java/balancetalk/file/domain/FileProcessorTest.java: -------------------------------------------------------------------------------- 1 | package balancetalk.file.domain; 2 | 3 | import static balancetalk.file.domain.FileType.TALK_PICK; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.DisplayName; 8 | import org.junit.jupiter.api.Test; 9 | import org.springframework.mock.web.MockMultipartFile; 10 | import org.springframework.web.multipart.MultipartFile; 11 | 12 | class FileProcessorTest { 13 | 14 | FileProcessor fileProcessor; 15 | 16 | @BeforeEach 17 | void setUp() { 18 | fileProcessor = new FileProcessor(); 19 | } 20 | 21 | @Test 22 | @DisplayName("MultipartFile 타입의 이미지를 전달하면, 같은 속성을 가진 File 엔티티를 반환한다.") 23 | void process_Success_ReturnFileEntityWithSameProperties() { 24 | // given 25 | MultipartFile multipartFile = 26 | new MockMultipartFile("mockFile", "강아지", "image/png", "content".getBytes()); 27 | 28 | // when 29 | File result = fileProcessor.process(multipartFile, TALK_PICK); 30 | 31 | // then 32 | assertThat(result.getFileType()).isEqualTo(TALK_PICK); 33 | assertThat(result.getMimeType()).isEqualTo("image/png"); 34 | assertThat(result.getUploadName()).isEqualTo(multipartFile.getOriginalFilename()); 35 | } 36 | } -------------------------------------------------------------------------------- /src/test/java/balancetalk/talkpick/domain/TalkPickReaderTest.java: -------------------------------------------------------------------------------- 1 | package balancetalk.talkpick.domain; 2 | 3 | import balancetalk.global.exception.BalanceTalkException; 4 | import balancetalk.talkpick.domain.repository.TalkPickRepository; 5 | import org.junit.jupiter.api.DisplayName; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.mockito.InjectMocks; 9 | import org.mockito.Mock; 10 | import org.mockito.junit.jupiter.MockitoExtension; 11 | 12 | import java.util.Optional; 13 | 14 | import static org.assertj.core.api.Assertions.assertThat; 15 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 16 | import static org.mockito.Mockito.any; 17 | import static org.mockito.Mockito.when; 18 | 19 | @ExtendWith(MockitoExtension.class) 20 | class TalkPickReaderTest { 21 | 22 | @InjectMocks 23 | TalkPickReader talkPickReader; 24 | 25 | @Mock 26 | TalkPickRepository talkPickRepository; 27 | 28 | @Test 29 | @DisplayName("ID에 해당하는 톡픽을 가져온다.") 30 | void readTalkPickById_Success() { 31 | // given 32 | TalkPick talkPick = new TalkPick(); 33 | when(talkPickRepository.findById(any())).thenReturn(Optional.of(talkPick)); 34 | 35 | // when 36 | TalkPick result = talkPickReader.readById(1L); 37 | 38 | // then 39 | assertThat(result).isEqualTo(talkPick); 40 | } 41 | 42 | @Test 43 | @DisplayName("존재하지 않는 톡픽의 ID를 통해 톡픽을 조회하려 하면 실패한다.") 44 | void readTalkPickById_Fail_ByNotFoundTalkPick() { 45 | // when, then 46 | assertThatThrownBy(() -> talkPickReader.readById(1L)) 47 | .isInstanceOf(BalanceTalkException.class); 48 | } 49 | } -------------------------------------------------------------------------------- /src/test/java/balancetalk/vote/application/VoteTalkPickServiceTest.java: -------------------------------------------------------------------------------- 1 | package balancetalk.vote.application; 2 | 3 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 4 | import static org.mockito.Mockito.any; 5 | import static org.mockito.Mockito.when; 6 | 7 | import balancetalk.global.exception.BalanceTalkException; 8 | import balancetalk.member.domain.Member; 9 | import balancetalk.member.dto.ApiMember; 10 | import balancetalk.talkpick.domain.TalkPick; 11 | import balancetalk.talkpick.domain.TalkPickReader; 12 | import balancetalk.vote.domain.TalkPickVote; 13 | import java.util.List; 14 | import org.junit.jupiter.api.DisplayName; 15 | import org.junit.jupiter.api.Test; 16 | import org.junit.jupiter.api.extension.ExtendWith; 17 | import org.mockito.InjectMocks; 18 | import org.mockito.Mock; 19 | import org.mockito.junit.jupiter.MockitoExtension; 20 | 21 | @ExtendWith(MockitoExtension.class) 22 | class VoteTalkPickServiceTest { 23 | 24 | @InjectMocks 25 | VoteTalkPickService voteTalkPickService; 26 | 27 | @Mock 28 | TalkPickReader talkPickReader; 29 | 30 | @Mock 31 | ApiMember apiMember; 32 | 33 | @Test 34 | @DisplayName("회원이 이미 투표한 톡픽일 경우 투표 생성은 실패한다.") 35 | void createVote_Fail_ByAlreadyVote() { 36 | // given 37 | TalkPick talkPick = TalkPick.builder().id(1L).build(); 38 | TalkPickVote vote = TalkPickVote.builder().talkPick(talkPick).build(); 39 | Member member = Member.builder().talkPickVotes(List.of(vote)).build(); 40 | 41 | when(talkPickReader.readById(any())).thenReturn(talkPick); 42 | when(apiMember.toMember(any())).thenReturn(member); 43 | 44 | // when, then 45 | assertThatThrownBy(() -> voteTalkPickService.createVote(1L, any(), apiMember)) 46 | .isInstanceOf(BalanceTalkException.class); 47 | } 48 | 49 | @Test 50 | @DisplayName("투표한 이력이 없을 경우 투표 수정은 실패한다.") 51 | void updateVote_Fail_ByNotFoundVote() { 52 | // given 53 | TalkPick talkPick = TalkPick.builder().id(1L).build(); 54 | Member member = Member.builder().talkPickVotes(List.of()).build(); 55 | 56 | when(talkPickReader.readById(any())).thenReturn(talkPick); 57 | when(apiMember.toMember(any())).thenReturn(member); 58 | 59 | // when, then 60 | assertThatThrownBy(() -> voteTalkPickService.updateVote(1L, any(), apiMember)) 61 | .isInstanceOf(BalanceTalkException.class); 62 | } 63 | 64 | @Test 65 | @DisplayName("투표한 이력이 없을 경우 투표 삭제는 실패한다.") 66 | void deleteVote_Fail_ByNotFoundVote() { 67 | // given 68 | TalkPick talkPick = TalkPick.builder().id(1L).build(); 69 | Member member = Member.builder().talkPickVotes(List.of()).build(); 70 | 71 | when(talkPickReader.readById(any())).thenReturn(talkPick); 72 | when(apiMember.toMember(any())).thenReturn(member); 73 | 74 | // when, then 75 | assertThatThrownBy(() -> voteTalkPickService.deleteVote(1L, apiMember)) 76 | .isInstanceOf(BalanceTalkException.class); 77 | } 78 | } -------------------------------------------------------------------------------- /src/test/java/balancetalk/vote/domain/VoteTest.java: -------------------------------------------------------------------------------- 1 | package balancetalk.vote.domain; 2 | 3 | import balancetalk.talkpick.domain.TalkPick; 4 | import org.junit.jupiter.api.DisplayName; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import static balancetalk.vote.domain.VoteOption.*; 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | 10 | class VoteTest { 11 | 12 | @Test 13 | @DisplayName("투표 대상 톡픽이 전달 톡픽과 일치하면 성공한다.") 14 | void matchesTalkPick_True() { 15 | // given 16 | TalkPick talkPick = TalkPick.builder().build(); 17 | TalkPickVote vote = TalkPickVote.builder().talkPick(talkPick).build(); 18 | 19 | // when 20 | boolean result = vote.matchesTalkPick(talkPick); 21 | 22 | // then 23 | assertThat(result).isTrue(); 24 | } 25 | 26 | @Test 27 | @DisplayName("투표 선택지를 성공적으로 수정한다.") 28 | void updateVoteOption_Success() { 29 | // given 30 | TalkPickVote vote = TalkPickVote.builder().voteOption(A).build(); 31 | 32 | // when 33 | vote.updateVoteOption(B); 34 | 35 | // then 36 | assertThat(vote.getVoteOption()).isEqualTo(B); 37 | } 38 | 39 | @Test 40 | @DisplayName("투표 선택지가 전달된 선택지와 일치하면 성공한다.") 41 | void isVoteOptionEquals_True() { 42 | // given 43 | TalkPickVote vote = TalkPickVote.builder().voteOption(A).build(); 44 | 45 | // when 46 | boolean result = vote.isVoteOptionEquals(A); 47 | 48 | // then 49 | assertThat(result).isTrue(); 50 | } 51 | } --------------------------------------------------------------------------------