From 1b002aafcd34eefb03d85945661a80fdecd3cff3 Mon Sep 17 00:00:00 2001 From: yb__char <68099546+char-yb@users.noreply.github.com> Date: Sun, 4 Aug 2024 06:23:16 +0900 Subject: [PATCH] v0.0.1 (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 프로젝트 세팅 (#2) * chore: issue, pr 템플릿 작성 * chore: CODEOWNERS 작성 * chore: editorconfig 작성 * chore: chore 이슈 템플릿 about 내용 수정 * chore: develop PR on check workflow 작성 (#7) * chore: develop PR on check workflow 작성 * fix: jdk zulu로 변경 * chore: slack webhook Test * chore: slack webhook Test * chore: slack webhook Test * chore: cache-read-only false 옵션 * chore: Spring Actuator 구성 (#8) * chore: domain 구조 아키텍처 구성 (#12) * chore: domain 구조 아키텍처 구성 * chore: global.config 추가 * chore: SwaggerConfig 작성 (#14) * chore: SwaggerConfig 작성 * fix: Api path versioning 제거 및 SwaggerConfig 수정 * fix: 상수 컨벤션 수정 * [WALWAL-104] Spotless 구성 (#19) * chore: pre-commit, spotless 구성 * chore: pre-commit 실행 모드 추가 * fix: spotlessApply * chore: googleJavaFormat aosp * chore: googleJavaFormat aosp * fix: spotlessApply * fix: spotlessApply version test * fix: spotlessApply * [WALWAL-84] jacoco 세팅 추가 (#16) * chore: jacoco 세팅 추가 * chore: config, resources, QDomains 커버리지 제외 및 reports 커스텀 경로 세팅 * feat: BaseTimeEntity 추가 (#22) * [WALWAL-88] 회원 엔티티 구성 (#26) * [WALWAL-108] Querydsl 환경 구성 (#25) * chore: querydsl config 추가 * chore: openfeign Querydsl 의존성 변경 * fix: spotlessApply * fix: spotlessApply * fix: spotlessApply * [WALWAL-114] record -> class 변경 (#30) * fix: record -> class 변경 * fix: profileImageUrl getter 삭제 * feat: Schema Swagger 프로퍼티 * [WALWAL-109] GlobalResponse를 위한 RestControllerAdvice 세팅 (#23) * chore: GlobalResponse를 위한 RestControllerAdvice 세팅 * fix: Response 형식 변경 및 ResponseEntityExceptionHandler 상속 * fix: status상태에 따른 sucess, fail 응답처리 * fix: ApiResponse record형식으로 수정 * chore: develop 브랜치 머지 및 spotless적용 * fix: 인텔리제이 자동import제거설정변경 * fix: 생성자 대신 RequiredArgsConstructor 어노테이션 통일 * [WALWAL-115] 미션 엔티티 구현 (#32) feature: 미션 엔티티 구현 * [WALWAL-106] fixtureMonkey 도입 (#28) * chore: fixtureMonkey 도입 * fix: order items 갯수 제한 수정 * chore: redis 환경 구성 (#37) * [WALWAL-113] SecurityConfig 및 유틸리티 구현 (#35) * chore: security 구성 * fix: CookieCsrfTokenRepository 활성화 * chore: csrf 옵션 임시 삭제 * fix: SwaggerUrlConstants 수정 * [WALWAL-116] 미션 기록 엔티티 구현 (#34) * feature: 미션 기록 엔티티 구현 * fix: Record -> MissionRecord로 클래스명 수정 * fix: mission_title 대신 proxy객체를 통해서 title가져오기 * [WALWAL-110] elastic beanstalk dev 서버 workflow 작성 (#38) * chore: dev workflow test * chore: dev workflow test * chore: dev workflow test * chore: dev workflow test * chore: dev workflow test * chore: dev workflow 1차 작성 * fix: current time 삭제 * fix: job name 수정 * fix: docker compose 및 env 파일 패키징 항목 삭제 * hotfix: Elastic BeanStalk 배포 version Label (#41) * [WALWAL-132] application yml 분리 (#42) * chore: mysql 구성 및 yml 분리 * chore: s3 property 추가 * [WALWAL-135] missionrecord crud 구현 (#46) * feature: 미션 기록 생성 구현 * fix: record response에Title추가 * fix: pathvariable 제거 * feature: 미션 기록 삭제 구현 * fix: ApiResponse적용 * fix: @Tag어노테이션 추가 및 컨트롤러반환값 수정 * refactor: 단일 미션 조회 메서드 분리 * [WALWAL-138]: mission crud 구현 (#45) * feature: mission crud 구현 * fix: review resolve * fix: id 네이밍 수정 및 서비스 응답 클래스 변경 * [WALWAL-142] EB docker-compose 구성 (#50) * chore: docker-compose 테스트 * chore: docker-compose aws json 테스트 * chore: EB deploy envs 삭제 * chore: docker-compose aws json 배포 테스트 * chore: docker-compose aws json 배포 테스트 및 테스트 케이스 수정 * fix: version lavel 수정 * fix: docker compose로 배포 테스트 * fix: docker compose로 배포 테스트 * chore: docker-compose aws json 배포 테스트 * chore: docker-compose aws json v3 배포 테스트 * chore: docker-compose aws json v3 배포 테스트 * fix: docker compose로 배포 테스트 * fix: docker compose로 배포 테스트 * fix: docker compose로 배포 테스트 * fix: docker compose로 배포 테스트 * fix: docker compose로 배포 테스트 * chore: 슬랙 봇 테스트 및 compose port 설정 * chore: docker compose ports 테스트 * chore: docker compose ports 테스트 * chore: nginx test * chore: nginx test * chore: nginx test * chore: Dockerrun.aws.json 테스트 * chore: Dockerrun.aws.json 테스트 * chore: docker compose 테스트 * fix: nginx volumes endpoint * fix: nginx conf workflow 삭제 테스트 * fix: bucket 업로드 삭제 테스트 * fix: version-label 현재 시각 versioning * fix: bucket 업로드 원복 * fix: nginx conf workflow 원복 테스트 * fix: push branch develop으로 변경 * fix: Dockerrun json 삭제 * [WALWAL-81] 애플 로그인 구현 (#47) * feat: RefreshToken 및 DTO 정의 * feat: apple server 통신 * feat: Apple 로그인 및 회원가입 * fix: 내 정보 조회 API 수정 * refactor: 로그인 로직 분리 * feat: 로직 개선 * chore: securityConfig auth 엔드포인트 추가 * fix: conflict 해결 * refactor: Apple 로그인 리팩토링 * refactor: Apple 로그인 리팩토링 및 마케팅 동의 여부 컬럼 추가 * feat: swagger default 유저 및 security JWT Filter 적용 * fix: csrf 이슈 * fix: csrf 이슈 * refactor: Service 로직 코드 분리 * fix: User fixtureMonkey PersonName 수정 * fix: Swagger 수정 * refactor: 미사용 DTO 및 네이밍 수정 * refactor: 네이밍 수정 및 println 삭제 * fix: yml include 추가 * chore: env sample 프로퍼티 추가 * fix: Apple Error Code 정의 * fix: 회원가입 로직 수정 및 토큰 로직 수정 * fix: socialLogin 메서드 orElseGet 수정 * refactor: RefreshToken 생성 로직 중복 * fix: 미사용 메서드 삭제 * fix: Apple PrivateKey 만료 시간 5분 설정 * refactor: apple Private Key 싱글톤 패턴 * refactor: apple Private Key 싱글톤 패턴으로 미사용 메서드 삭제 * refactor: 변수 상수화 * [WALWAL-148] Dev, Prod 환경 분리 (#56) * feat: Environment 환경 분리 * test: dev profile swagger permitAll * fix: fixtureMokey 수정 * refactor: dev 환경 배포 테스트 * refactor: dev 환경 배포 테스트 * fix: fixtureMokey 수정 * test: dev profile swagger permitAll * refactor: dev 환경 배포 테스트 * fix: push branch develop으로 변경 * fix: profile 예외 메세지 * [WALWAL-145] missionrecord calendarview (#52) * [WALWAL-122] 이미지 업로드 기능 (#58) * feat: Image 도메인 및 DTO 정의 * feat: aws 의존성 추가 및 Image Controller, Service 추가 * fix: image 로직 임시 커밋 * [WALWAL-148] Dev, Prod 환경 분리 (#56) * feat: Environment 환경 분리 * test: dev profile swagger permitAll * fix: fixtureMokey 수정 * refactor: dev 환경 배포 테스트 * refactor: dev 환경 배포 테스트 * fix: fixtureMokey 수정 * test: dev profile swagger permitAll * refactor: dev 환경 배포 테스트 * fix: push branch develop으로 변경 * fix: profile 예외 메세지 * chore: s3 Config 추가 * feat: Member 이미지 업로드 기능 * fix: 이미지 업로드 로직 수정 * refactor: @dbscks97 피드백 반영 * refactor: @kwanok 피드백 반영 * fix: MissionCreateRequest test 코드 * [WALWAL-152] register Request Body 불필요한 필드 삭제 (#62) * fix: register Request Body * fix: 마케팅 수신 동의여부 삭제 * [WALWAL-147] 오늘의 미션 API 추가 (#53) * feat: 오늘의 미션 엔드포인트 추가 * fix: 시큐어 랜덤으로 보안패치 * fix: 리뷰 반영, 테스트 이름 수정, jpa 쿼리 수정 * fix: 변수명 수정 missionIds * fix: QueryDSL로 변경 * fix: QueryDSL 리뷰 반영 * fix: Error Code 수정 * [WALWAL-153] 미션 n번째 카운트 기능 추가 (#64) * feat: 수행한 총 미션 기록 수 * fix: 엔드포인트 수정 * fix: 메서드 명 * [WALWAL-150] missionrecord imageupload 기능 구현 (#66) * feature: 미션참여 API구현 * feature: 미션 탭 상태 조회 API 구현 * feature: 완료된 미션이미지제공 API 구현 * feature: 미션 기록 업로드 및 저장, 테스트 코드 작성 * fix: PR 수정사항 반영 * fix: 안쓰는 Response 및 스키마 수정 * refactor: 미션 탭 조회 시 이미지데이터 포함 * fix: 이미지URL 저장로직 수정 * fix: application.yml 수정 * fix: Test프로파일 설정 * refactor: 미션기록 Response에 recordId추가 및 이미지url수정 * [WALWAL-151] 데일리미션 일러스트, 컬러값 추가 (#71) * feature: 데일리미션 일러스트, 컬러값 추가 * fix: MissionControllertest코드 수정 * [WALWAL-155] 카카오 로그인/회원가입 구현 (#68) * feat: 카카오 로그인/회원가입 * fix: println 삭제 * fix: 임시 토큰 여부 판별 (#74) * [WALWAL-163] 배포 환경 메모리 제한 (#77) * [WALWAL-163] 배포 환경 메모리 제한 * fix: memory * [WALWAL-160] 미션 시작 시 동시성 문제 개선 (#75) * [WALWAL-157] 회원탈퇴 API (#82) * feat: 회원탈퇴 API * feat: 회원탈퇴 API redis deleteById * chore: 주석 * chore: 주석 처리 수정 * [WALWAL-154] PROD 운영 서버 워크플로 작성 (#85) * chore: PROD 배포 테스트 * fix: 변수 추가 * fix: minor tags 테스트 * fix: minor tags 테스트 * fix: minor tags 테스트 * fix: workflow version --------- Co-authored-by: Park Yun Chan Co-authored-by: kwanok noh <61671343+kwanok@users.noreply.github.com> --- .editorconfig | 29 +++ .env.example | 35 +++ .github/CODEOWNERS | 1 + .../\342\231\273\357\270\217-refactor.md" | 14 ++ .../\342\232\231\357\270\217-chore.md" | 14 ++ ".github/ISSUE_TEMPLATE/\342\234\205-test.md" | 14 ++ .../ISSUE_TEMPLATE/\342\234\250-feature.md" | 14 ++ .../ISSUE_TEMPLATE/\360\237\220\233-fix.md" | 14 ++ .../\360\237\223\235-documentation.md" | 14 ++ .github/PULL_REQUEST_TEMPLATE.md | 11 + .github/workflows/develop-build-deploy.yml | 90 ++++++++ .../develop-pull-request-on-check.yml | 56 +++++ .github/workflows/production-build-deploy.yml | 124 +++++++++++ .gitignore | 3 +- Dockerfile | 4 + build.gradle | 127 ++++++++++- docker-compose.yaml | 40 ++++ nginx/default.conf | 12 + scripts/pre-commit | 13 ++ .../stonebed/StonebedApplication.java | 8 +- .../domain/auth/api/AuthController.java | 65 ++++++ .../domain/auth/application/AuthService.java | 122 +++++++++++ .../auth/application/JwtTokenService.java | 118 ++++++++++ .../auth/application/apple/AppleClient.java | 191 ++++++++++++++++ .../auth/application/kakao/KakaoClient.java | 38 ++++ .../auth/dao/RefreshTokenRepository.java | 6 + .../domain/auth/domain/OAuthProvider.java | 22 ++ .../domain/auth/domain/RefreshToken.java | 23 ++ .../domain/auth/domain/TokenType.java | 23 ++ .../domain/auth/dto/AccessTokenDto.java | 5 + .../domain/auth/dto/AuthenticationToken.java | 5 + .../domain/auth/dto/RefreshTokenDto.java | 3 + .../auth/dto/request/AppleTokenRequest.java | 15 ++ .../auth/dto/request/RefreshTokenRequest.java | 6 + .../auth/dto/request/SocialLoginRequest.java | 6 + .../dto/response/AppleKeyListResponse.java | 28 +++ .../auth/dto/response/AppleKeyResponse.java | 4 + .../auth/dto/response/AppleTokenResponse.java | 20 ++ .../auth/dto/response/AuthTokenResponse.java | 16 ++ .../auth/dto/response/KakaoAuthResponse.java | 13 ++ .../dto/response/SocialClientResponse.java | 3 + .../auth/dto/response/TokenPairResponse.java | 12 + .../domain/common/BaseTimeEntity.java | 22 ++ .../domain/image/api/ImageController.java | 60 +++++ .../image/application/ImageService.java | 207 ++++++++++++++++++ .../domain/image/dao/ImageRepository.java | 12 + .../stonebed/domain/image/domain/Image.java | 63 ++++++ .../image/domain/ImageFileExtension.java | 27 +++ .../domain/image/domain/ImageType.java | 13 ++ .../MemberProfileImageCreateRequest.java | 10 + ...mberProfileImageUploadCompleteRequest.java | 9 + .../MissionRecordImageCreateRequest.java | 11 + .../MissionRecordImageUploadRequest.java | 10 + .../dto/response/PresignedUrlResponse.java | 9 + .../domain/member/api/MemberController.java | 25 +++ .../member/application/MemberService.java | 47 ++++ .../domain/member/dao/MemberRepository.java | 10 + .../stonebed/domain/member/domain/Member.java | 109 +++++++++ .../domain/member/domain/MemberRole.java | 15 ++ .../domain/member/domain/MemberStatus.java | 14 ++ .../domain/member/domain/OauthInfo.java | 39 ++++ .../domain/member/domain/Profile.java | 30 +++ .../domain/member/domain/RaisePet.java | 14 ++ .../dto/request/CreateMemberRequest.java | 8 + .../domain/mission/api/MissionController.java | 49 +++++ .../mission/application/MissionService.java | 95 ++++++++ .../mission/dao/MissionHistoryRepository.java | 14 ++ .../dao/MissionHistoryRepositoryCustom.java | 8 + .../dao/MissionHistoryRepositoryImpl.java | 25 +++ .../domain/mission/dao/MissionRepository.java | 6 + .../mission/dao/MissionRepositoryCustom.java | 11 + .../mission/dao/MissionRepositoryImpl.java | 31 +++ .../domain/mission/domain/Mission.java | 41 ++++ .../domain/mission/domain/MissionHistory.java | 32 +++ .../dto/request/MissionCreateRequest.java | 9 + .../dto/request/MissionUpdateRequest.java | 9 + .../dto/response/MissionCreateResponse.java | 15 ++ .../dto/response/MissionGetOneResponse.java | 15 ++ .../dto/response/MissionGetTodayResponse.java | 23 ++ .../dto/response/MissionUpdateResponse.java | 15 ++ .../api/MissionRecordController.java | 60 +++++ .../application/MissionRecordService.java | 174 +++++++++++++++ .../dao/MissionRecordRepository.java | 16 ++ .../dao/MissionRecordRepositoryCustom.java | 13 ++ .../dao/MissionRecordRepositoryImpl.java | 50 +++++ .../missionRecord/domain/MissionRecord.java | 61 ++++++ .../domain/MissionRecordStatus.java | 13 ++ .../request/MissionRecordCalendarRequest.java | 9 + .../response/MissionRecordCalendarDto.java | 18 ++ .../MissionRecordCalendarResponse.java | 18 ++ .../dto/response/MissionTabResponse.java | 15 ++ .../annotation/ConditionalOnProfile.java | 15 ++ .../global/annotation/OnProfileCondition.java | 32 +++ .../constants/EnvironmentConstants.java | 27 +++ .../common/constants/SecurityConstants.java | 22 ++ .../common/constants/SwaggerUrlConstants.java | 22 ++ .../global/common/constants/UrlConstants.java | 16 ++ .../global/common/response/ApiResponse.java | 14 ++ .../common/response/ApiResponseAdvice.java | 68 ++++++ .../stonebed/global/config/jpa/JpaConfig.java | 8 + .../config/querydsl/QuerydslConfig.java | 19 ++ .../config/restClient/RestClientConfig.java | 23 ++ .../config/security/WebSecurityConfig.java | 129 +++++++++++ .../global/config/swagger/SwaggerConfig.java | 92 ++++++++ .../stonebed/global/error/ErrorCode.java | 46 ++++ .../stonebed/global/error/ErrorResponse.java | 8 + .../error/exception/CustomException.java | 15 ++ .../exception/GlobalExceptionHandler.java | 39 ++++ .../filter/JwtAuthenticationFilter.java | 125 +++++++++++ .../global/security/PrincipalDetails.java | 51 +++++ .../stonebed/global/util/CookieUtil.java | 50 +++++ .../stonebed/global/util/JwtUtil.java | 169 ++++++++++++++ .../stonebed/global/util/MemberUtil.java | 36 +++ .../stonebed/global/util/SecurityUtil.java | 29 +++ .../global/util/SpringEnvironmentUtil.java | 39 ++++ .../infra/config/redis/RedisConfig.java | 33 +++ .../stonebed/infra/config/s3/S3Config.java | 32 +++ .../infra/properties/AppleProperties.java | 10 + .../infra/properties/JwtProperties.java | 19 ++ .../infra/properties/PropertiesConfig.java | 14 ++ .../infra/properties/RedisProperties.java | 6 + .../infra/properties/S3Properties.java | 7 + .../infra/properties/SwaggerProperties.java | 6 + src/main/resources/application-actuator.yml | 18 ++ src/main/resources/application-cloud.yml | 10 + src/main/resources/application-datasource.yml | 9 + src/main/resources/application-dev.yml | 18 ++ src/main/resources/application-docs.yml | 31 +++ src/main/resources/application-local.yml | 17 ++ src/main/resources/application-prod.yml | 7 + src/main/resources/application-redis.yml | 9 + src/main/resources/application-security.yml | 15 ++ src/main/resources/application.yml | 15 ++ .../stonebed/StonebedApplicationTests.java | 8 +- .../stonebed/TestQuerydslConfig.java | 18 ++ .../domain/member/domain/MemberTest.java | 8 + .../mission/api/MissionControllerTest.java | 108 +++++++++ .../application/MissionServiceTest.java | 187 ++++++++++++++++ .../dao/MissionHistoryRepositoryTest.java | 64 ++++++ .../mission/dao/MissionRepositoryTest.java | 58 +++++ .../MissionRecordServiceTest.java | 174 +++++++++++++++ .../stonebed/fixtureMonkeyTest/Order.java | 43 ++++ .../fixtureMonkeyTest/TestFixtureMonkey.java | 85 +++++++ .../stonebed/fixtureMonkeyTest/User.java | 30 +++ .../util/SpringEnvironmentUtilTest.java | 111 ++++++++++ src/test/resources/application-test.yml | 12 + 146 files changed, 5253 insertions(+), 13 deletions(-) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .github/CODEOWNERS create mode 100644 ".github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" create mode 100644 ".github/ISSUE_TEMPLATE/\342\232\231\357\270\217-chore.md" create mode 100644 ".github/ISSUE_TEMPLATE/\342\234\205-test.md" create mode 100644 ".github/ISSUE_TEMPLATE/\342\234\250-feature.md" create mode 100644 ".github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" create mode 100644 ".github/ISSUE_TEMPLATE/\360\237\223\235-documentation.md" create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/develop-build-deploy.yml create mode 100644 .github/workflows/develop-pull-request-on-check.yml create mode 100644 .github/workflows/production-build-deploy.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yaml create mode 100644 nginx/default.conf create mode 100755 scripts/pre-commit create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/api/AuthController.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/application/AuthService.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/application/JwtTokenService.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/application/apple/AppleClient.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/application/kakao/KakaoClient.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dao/RefreshTokenRepository.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/domain/OAuthProvider.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/domain/RefreshToken.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/domain/TokenType.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/AccessTokenDto.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/AuthenticationToken.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/RefreshTokenDto.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/request/AppleTokenRequest.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/request/RefreshTokenRequest.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/request/SocialLoginRequest.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AppleKeyListResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AppleKeyResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AppleTokenResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AuthTokenResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/response/KakaoAuthResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/response/SocialClientResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/auth/dto/response/TokenPairResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/common/BaseTimeEntity.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/image/api/ImageController.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/image/application/ImageService.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/image/dao/ImageRepository.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/image/domain/Image.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/image/domain/ImageFileExtension.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/image/domain/ImageType.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/image/dto/request/MemberProfileImageCreateRequest.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/image/dto/request/MemberProfileImageUploadCompleteRequest.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/image/dto/request/MissionRecordImageCreateRequest.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/image/dto/request/MissionRecordImageUploadRequest.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/image/dto/response/PresignedUrlResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/member/api/MemberController.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/member/application/MemberService.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/member/dao/MemberRepository.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/member/domain/Member.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/member/domain/MemberRole.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/member/domain/MemberStatus.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/member/domain/OauthInfo.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/member/domain/Profile.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/member/domain/RaisePet.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/member/dto/request/CreateMemberRequest.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/api/MissionController.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/application/MissionService.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepository.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepositoryCustom.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepositoryImpl.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionRepository.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryCustom.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryImpl.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/domain/Mission.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/domain/MissionHistory.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/dto/request/MissionCreateRequest.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/dto/request/MissionUpdateRequest.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionCreateResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionGetOneResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionGetTodayResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionUpdateResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/missionRecord/api/MissionRecordController.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/missionRecord/application/MissionRecordService.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepository.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryCustom.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryImpl.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/missionRecord/domain/MissionRecord.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/missionRecord/domain/MissionRecordStatus.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/request/MissionRecordCalendarRequest.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarDto.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionTabResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/global/annotation/ConditionalOnProfile.java create mode 100644 src/main/java/com/depromeet/stonebed/global/annotation/OnProfileCondition.java create mode 100644 src/main/java/com/depromeet/stonebed/global/common/constants/EnvironmentConstants.java create mode 100644 src/main/java/com/depromeet/stonebed/global/common/constants/SecurityConstants.java create mode 100644 src/main/java/com/depromeet/stonebed/global/common/constants/SwaggerUrlConstants.java create mode 100644 src/main/java/com/depromeet/stonebed/global/common/constants/UrlConstants.java create mode 100644 src/main/java/com/depromeet/stonebed/global/common/response/ApiResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/global/common/response/ApiResponseAdvice.java create mode 100644 src/main/java/com/depromeet/stonebed/global/config/jpa/JpaConfig.java create mode 100644 src/main/java/com/depromeet/stonebed/global/config/querydsl/QuerydslConfig.java create mode 100644 src/main/java/com/depromeet/stonebed/global/config/restClient/RestClientConfig.java create mode 100644 src/main/java/com/depromeet/stonebed/global/config/security/WebSecurityConfig.java create mode 100644 src/main/java/com/depromeet/stonebed/global/config/swagger/SwaggerConfig.java create mode 100644 src/main/java/com/depromeet/stonebed/global/error/ErrorCode.java create mode 100644 src/main/java/com/depromeet/stonebed/global/error/ErrorResponse.java create mode 100644 src/main/java/com/depromeet/stonebed/global/error/exception/CustomException.java create mode 100644 src/main/java/com/depromeet/stonebed/global/error/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/depromeet/stonebed/global/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/depromeet/stonebed/global/security/PrincipalDetails.java create mode 100644 src/main/java/com/depromeet/stonebed/global/util/CookieUtil.java create mode 100644 src/main/java/com/depromeet/stonebed/global/util/JwtUtil.java create mode 100644 src/main/java/com/depromeet/stonebed/global/util/MemberUtil.java create mode 100644 src/main/java/com/depromeet/stonebed/global/util/SecurityUtil.java create mode 100644 src/main/java/com/depromeet/stonebed/global/util/SpringEnvironmentUtil.java create mode 100644 src/main/java/com/depromeet/stonebed/infra/config/redis/RedisConfig.java create mode 100644 src/main/java/com/depromeet/stonebed/infra/config/s3/S3Config.java create mode 100644 src/main/java/com/depromeet/stonebed/infra/properties/AppleProperties.java create mode 100644 src/main/java/com/depromeet/stonebed/infra/properties/JwtProperties.java create mode 100644 src/main/java/com/depromeet/stonebed/infra/properties/PropertiesConfig.java create mode 100644 src/main/java/com/depromeet/stonebed/infra/properties/RedisProperties.java create mode 100644 src/main/java/com/depromeet/stonebed/infra/properties/S3Properties.java create mode 100644 src/main/java/com/depromeet/stonebed/infra/properties/SwaggerProperties.java create mode 100644 src/main/resources/application-actuator.yml create mode 100644 src/main/resources/application-cloud.yml create mode 100644 src/main/resources/application-datasource.yml create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/main/resources/application-docs.yml create mode 100644 src/main/resources/application-local.yml create mode 100644 src/main/resources/application-prod.yml create mode 100644 src/main/resources/application-redis.yml create mode 100644 src/main/resources/application-security.yml create mode 100644 src/test/java/com/depromeet/stonebed/TestQuerydslConfig.java create mode 100644 src/test/java/com/depromeet/stonebed/domain/member/domain/MemberTest.java create mode 100644 src/test/java/com/depromeet/stonebed/domain/mission/api/MissionControllerTest.java create mode 100644 src/test/java/com/depromeet/stonebed/domain/mission/application/MissionServiceTest.java create mode 100644 src/test/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepositoryTest.java create mode 100644 src/test/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryTest.java create mode 100644 src/test/java/com/depromeet/stonebed/domain/missionRecord/MissionRecordServiceTest.java create mode 100644 src/test/java/com/depromeet/stonebed/fixtureMonkeyTest/Order.java create mode 100644 src/test/java/com/depromeet/stonebed/fixtureMonkeyTest/TestFixtureMonkey.java create mode 100644 src/test/java/com/depromeet/stonebed/fixtureMonkeyTest/User.java create mode 100644 src/test/java/com/depromeet/stonebed/global/util/SpringEnvironmentUtilTest.java create mode 100644 src/test/resources/application-test.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..e72c072d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,29 @@ +# top-most EditorConfig file +root = true + +[*] +# [encoding-utf8] +charset = utf-8 + +# [newline-lf] +end_of_line = lf + +# [newline-eof] +insert_final_newline = true + +[*.bat] +end_of_line = crlf + +[*.java] +# [indentation-tab] +indent_style = tab + +# [4-spaces-tab] +indent_size = 4 +tab_width = 4 + +# [no-trailing-spaces] +trim_trailing_whitespace = true + +[line-length-120] +max_line_length = 120 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..9b28585c --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +### DB 설정 정보 ### +MYSQL_HOST={MYSQL 호스트} +MYSQL_PORT={MYSQL 포트, 기본값은 3306} +DB_NAME={DB이름} +MYSQL_PASSWORD={MYSQL 비밀번호} +MYSQL_USERNAME={MYSQL 유저이름} + +### REDIS ### +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +### AWS ### +AWS_ACCESS_KEY={AWS ACCESS KEY} +AWS_SECRET_KEY={AWS SECRET KEY} +S3_IMAGE_BUCKET={AWS S3 IMAGE 버킷} +AWS_REGION={AWS REGION} +S3_ENDPOINT={AWS S3 ENDPOINT} + +### JWT ### +JWT_ACCESS_TOKEN_SECRET={JWT ACCESS TOKEN SECRET_KEY} +JWT_REFRESH_TOKEN_SECRET={JWT REFRESH TOKEN SECRET_KEY} + +# 1일 +JWT_ACCESS_TOKEN_EXPIRATION_TIME=86400 +# 30일 +JWT_REFRESH_TOKEN_EXPIRATION_TIME=2592000 + +### APPLE ### +APPLE_DEV_CLIENT_ID={APPLE 개발 CLIENT ID} +APPLE_DEV_TEAM_ID={APPLE 개발 TEAM ID} +APPLE_PROD_CLIENT_ID={APPLE 운영 CLIENT ID} +APPLE_PROD_TEAM_ID={APPLE 운영 TEAM ID} +APPLE_KEY_ID={APPLE KEY ID} +APPLE_P8={APPLE P8} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..b7cdc9ba --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @char-yb @dbscks97 @kwanok diff --git "a/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" new file mode 100644 index 00000000..40bd151e --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" @@ -0,0 +1,14 @@ +--- +name: "♻️ refactor" +about: 리팩토링 이슈 템플릿 +title: "♻️ " +labels: "♻️ refactor" +assignees: '' + +--- + +## 🔗 작업 링크 +- [작업 내용](링크) + +## 📌 Description +- diff --git "a/.github/ISSUE_TEMPLATE/\342\232\231\357\270\217-chore.md" "b/.github/ISSUE_TEMPLATE/\342\232\231\357\270\217-chore.md" new file mode 100644 index 00000000..6bb13b83 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\232\231\357\270\217-chore.md" @@ -0,0 +1,14 @@ +--- +name: "⚙️ chore" +about: CI/CD 및 환경 구성 이슈 템플릿 +title: "⚙️ " +labels: "⚙️ chore" +assignees: '' + +--- + +## 🔗 작업 링크 +- [작업 내용](링크) + +## 📌 Description +- diff --git "a/.github/ISSUE_TEMPLATE/\342\234\205-test.md" "b/.github/ISSUE_TEMPLATE/\342\234\205-test.md" new file mode 100644 index 00000000..ed9a5f13 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\234\205-test.md" @@ -0,0 +1,14 @@ +--- +name: "✅ test" +about: 테스트 이슈 템플릿 +title: "✅ " +labels: "✅ test" +assignees: '' + +--- + +## 🔗 작업 링크 +- [작업 내용](링크) + +## 📌 Description +- diff --git "a/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" "b/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" new file mode 100644 index 00000000..f6be594e --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" @@ -0,0 +1,14 @@ +--- +name: "✨ feature" +about: 기능 추가 이슈 템플릿 +title: "✨ " +labels: "✨ feature" +assignees: '' + +--- + +## 🔗 작업 링크 +- [작업 내용](링크) + +## 📌 Description +- diff --git "a/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" "b/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" new file mode 100644 index 00000000..48c737e5 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" @@ -0,0 +1,14 @@ +--- +name: "🐛 fix" +about: 버그 및 에러 이슈 템플릿 +title: "🐛 " +labels: "🐛 bug/error" +assignees: '' + +--- + +## 🔗 작업 링크 +- [작업 내용](링크) + +## 📌 Description +- diff --git "a/.github/ISSUE_TEMPLATE/\360\237\223\235-documentation.md" "b/.github/ISSUE_TEMPLATE/\360\237\223\235-documentation.md" new file mode 100644 index 00000000..b7bc6812 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\360\237\223\235-documentation.md" @@ -0,0 +1,14 @@ +--- +name: "📝 documentation" +about: 문서화 이슈 템플릿 +title: "📝 " +labels: "📝 documentation" +assignees: '' + +--- + +## 🔗 작업 링크 +- [작업 내용](링크) + +## 📌 Description +- diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..972f626f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## 🌱 관련 이슈 +- close # + +## 📌 작업 내용 +- + +## 🙏 리뷰 요구사항 +- + +## 📚 레퍼런스 +- diff --git a/.github/workflows/develop-build-deploy.yml b/.github/workflows/develop-build-deploy.yml new file mode 100644 index 00000000..058f8b76 --- /dev/null +++ b/.github/workflows/develop-build-deploy.yml @@ -0,0 +1,90 @@ +name: Develop Build & Deploy + +on: + push: + branches: [ "develop" ] + +env: + DOCKERHUB_IMAGE_NAME: walwal-server + +jobs: + build-deploy: + runs-on: ubuntu-latest + environment: DEV + strategy: + matrix: + java-version: [ 17 ] + distribution: [ 'zulu' ] + steps: + - name: Checkout code + uses: actions/checkout@v3 + + # JDK를 17 버전으로 세팅 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: ${{ matrix.java-version }} + distribution: ${{ matrix.distribution }} + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Build with Gradle + id: gradle + uses: gradle/gradle-build-action@v3 + with: + arguments: | + build + --scan + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + + # Docker 이미지 빌드 및 도커 허브 푸시 + - name: Docker build & push + run: | + docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} + docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKERHUB_IMAGE_NAME }} . + docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKERHUB_IMAGE_NAME }} + + - name: Current Time + uses: gerred/current-time@v1.0.0 + id: current-time + + - name: Replace string + uses: frabert/replace-string-action@v2.1 + id: format-time + with: + pattern: '[:\.]+' + string: "${{ steps.current-time.outputs.time }}" + replace-with: '-' + flags: 'g' + + - name: Prepare deployment package + run: | + zip -r deployment-package.zip docker-compose.yaml nginx/default.conf + + - name: Beanstalk Deploy + uses: einaregilsson/beanstalk-deploy@v22 + with: + aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + existing_bucket_name: "walwal-server-dev-deployment" + application_name: "walwal-dev" + environment_name: "Walwal-dev-env" + version_label: "walwal-dev-${{ steps.format-time.outputs.replaced }}" + region: ap-northeast-2 + deployment_package: deployment-package.zip + wait_for_environment_recovery: 180 + + # Slack 알림 + - name: Send Deploy Result to Slack + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} + SLACK_USERNAME: 왈왈봇 + SLACK_COLOR: ${{ job.status == 'success' && 'good' || 'danger' }} + SLACK_TITLE: "Deploy Summary - Develop" + SLACK_MESSAGE: | + - image tag: `${{ steps.metadata.outputs.tags }}` + - build scan report: ${{ steps.gradle.outputs.build-scan-url }} + - deploy status: ${{ job.status }} + - deploy time: ${{ steps.current-time.outputs.time }} diff --git a/.github/workflows/develop-pull-request-on-check.yml b/.github/workflows/develop-pull-request-on-check.yml new file mode 100644 index 00000000..8f1548b1 --- /dev/null +++ b/.github/workflows/develop-pull-request-on-check.yml @@ -0,0 +1,56 @@ +name: Pull Request on Check +on: + pull_request: + branches: + - develop +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + java-version: [ 17 ] + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Java + uses: actions/setup-java@v3 + with: + java-version: ${{ matrix.java-version }} + distribution: 'zulu' + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Setup Gradle + uses: gradle/gradle-build-action@v3 + with: + arguments: check + cache-read-only: false + + ## slack 알람 + - name: Slack Alarm + uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + author_name: GitHub-Actions CI/CD + fields: repo,message,commit,author,ref,job,took + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # required + if: always() # Pick up events even if the job fails or is canceled. + + style-test: + runs-on: ubuntu-latest + steps: + - name: Git Checkout + uses: actions/checkout@v3.0.2 + - name: JDK install + uses: actions/setup-java@v3 + with: + distribution: zulu + java-version: '17' + cache: 'gradle' + + - name: Checkstyle + run: | + ./gradlew spotlessCheck diff --git a/.github/workflows/production-build-deploy.yml b/.github/workflows/production-build-deploy.yml new file mode 100644 index 00000000..3a301c41 --- /dev/null +++ b/.github/workflows/production-build-deploy.yml @@ -0,0 +1,124 @@ +name: Production Build & Deploy + +on: + push: + tags: + - v*.*.* + +env: + DOCKERHUB_IMAGE_NAME: walwal-server + DOCKERHUB_IMAGE_FULL_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/walwal-server + +jobs: + build-deploy: + runs-on: ubuntu-latest + environment: PROD + strategy: + matrix: + java-version: [ 17 ] + distribution: [ 'temurin' ] + + steps: + # 기본 체크아웃 + - name: Checkout + uses: actions/checkout@v3 + + # JDK를 17 버전으로 세팅 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: ${{ matrix.java-version }} + distribution: ${{ matrix.distribution }} + + # Gradlew 실행 허용 + - name: Run chmod to make gradlew executable + run: chmod +x ./gradlew + + # Gradle 빌드 + - name: Build with Gradle + id: gradle + uses: gradle/gradle-build-action@v2 + with: + arguments: | + build + --scan + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + + # Dockerhub 로그인 + - name: Login to Dockerhub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Docker 메타데이터 추출 + - name: Extract Docker metadata + id: metadata + uses: docker/metadata-action@v5.5.0 + with: + images: ${{ env.DOCKERHUB_IMAGE_FULL_NAME }} + tags: | + type=semver,pattern={{version}} + flavor: | + latest=false + + # 멀티 아키텍처 지원을 위한 QEMU 설정 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + # 도커 확장 빌드를 위한 Buildx 설정 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Docker 이미지 빌드 및 도커허브로 푸시 + - name: Docker Build and Push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/arm64/v8 + push: true + tags: ${{ steps.metadata.outputs.tags }} + + - name: Current Time + uses: gerred/current-time@v1.0.0 + id: current-time + + - name: Replace string + uses: frabert/replace-string-action@v2.1 + id: format-time + with: + pattern: '[:\.]+' + string: "${{ steps.current-time.outputs.time }}" + replace-with: '-' + flags: 'g' + + - name: Prepare deployment package + run: | + zip -r deployment-package.zip docker-compose.yaml nginx/default.conf + + - name: Beanstalk Deploy + uses: einaregilsson/beanstalk-deploy@v22 + with: + aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + existing_bucket_name: "walwal-server-prod-deployment" + application_name: "walwal-prod" + environment_name: "Walwal-prod-env" + version_label: "walwal-prod-${{ steps.format-time.outputs.replaced }}" + region: ap-northeast-2 + deployment_package: deployment-package.zip + wait_for_environment_recovery: 180 + + # Slack 알림 + - name: Send Deploy Result to Slack + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} + SLACK_USERNAME: 왈왈봇 + SLACK_COLOR: ${{ job.status == 'success' && 'good' || 'danger' }} + SLACK_TITLE: "Deploy Summary - Production" + SLACK_MESSAGE: | + - image tag: `${{ steps.metadata.outputs.tags }}` + - build scan report: ${{ steps.gradle.outputs.build-scan-url }} + - deploy status: ${{ job.status }} + - deploy time: ${{ steps.current-time.outputs.time }} diff --git a/.gitignore b/.gitignore index 483ba31b..acb28ecc 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ out/ ### Custom ### *.env *.DS_Store -/src/main/generated \ No newline at end of file +/src/main/generated +.*-database diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..d5b0b605 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM azul/zulu-openjdk:17-latest +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java","-jar","/app.jar"] diff --git a/build.gradle b/build.gradle index 6e2810d4..e4e85a7c 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,8 @@ plugins { id 'java' id 'org.springframework.boot' version '3.2.6' id 'io.spring.dependency-management' version '1.1.5' + id 'com.diffplug.spotless' version '6.21.0' + id 'jacoco' } group = 'com.depromeet' @@ -26,14 +28,133 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + + // Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.h2database:h2' + + // Database + runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Querydsl + implementation "io.github.openfeign.querydsl:querydsl-jpa:6.0" + implementation "io.github.openfeign.querydsl:querydsl-core:6.0" + annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:6.0:jpa" + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-test' + implementation 'org.springframework.security:spring-security-oauth2-client' + + // AWS + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Apple Login + implementation 'org.bouncycastle:bcpkix-jdk18on:1.72' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // Test Lombok + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + // FixtureMonkey + testImplementation("com.navercorp.fixturemonkey:fixture-monkey-starter:1.0.20") } tasks.named('test') { useJUnitPlatform() + jvmArgs '-Xshare:off' // JVM 아규먼트 설정 + finalizedBy jacocoTestReport } + +jacoco { + toolVersion = "0.8.12" +} + +def QDomains = [] + +for (qPattern in '*/QA'..'*/QZ') { + QDomains.add(qPattern + '*') +} + +def jacocoExcludePatterns = [ + '**/*Application*', + '**/*Config/*', + '**/resources/**', + '**/test/**', +] + +def jacocoDir = layout.buildDirectory.dir("reports/") + +jacocoTestReport { + dependsOn test + reports { + html.required.set(true) + xml.required.set(true) + csv.required.set(true) + html.destination jacocoDir.get().file("jacoco/html").asFile + xml.destination jacocoDir.get().file("jacoco/index.xml").asFile + csv.destination jacocoDir.get().file("jacoco/index.csv").asFile + } + + afterEvaluate { + classDirectories.setFrom( + files(classDirectories.files.collect { + fileTree(dir: it, excludes: jacocoExcludePatterns + QDomains) + }) + ) + } + + finalizedBy jacocoTestCoverageVerification +} + +jacocoTestCoverageVerification { + violationRules { + rule { + enabled = true + element = 'CLASS' + excludes = jacocoExcludePatterns + QDomains + } + } +} + +spotless { + java { + target("**/*.java") + importOrder() + // 사용하지 않은 import문 제거 + removeUnusedImports() + // 2개 이상 있거나 이제 앞뒤로 불필요한 공백이 있을 때 제거해주는 옵션 + trimTrailingWhitespace() + // 줄은 공백으로 남아있을 수 있도록 항상 옵션을 걸어준다. + endWithNewline() + // google java format + googleJavaFormat().aosp() + } +} + +// git pre-commit hook task 설정 +tasks.register("addGitPreCommitHook", Copy) { + from 'scripts/pre-commit' + into '.git/hooks' +} + +compileJava.dependsOn addGitPreCommitHook diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..eb39dbfb --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,40 @@ +version: "3.8" + +services: + backend: + image: "olderstonebed/walwal-server:latest" + container_name: "walwal-server" + restart: always + environment: + - TZ=Asia/Seoul + network_mode: host + env_file: + - ~/.env + deploy: + resources: + limits: + memory: 640m + redis: + image: "redis:alpine" + container_name: redis + ports: + - "6379:6379" + environment: + - TZ=Asia/Seoul + network_mode: "host" + deploy: + resources: + limits: + memory: 128m + nginx: + image: "nginx:alpine" + container_name: nginx + environment: + - TZ=Asia/Seoul + network_mode: host + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf + deploy: + resources: + limits: + memory: 128m diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 00000000..c78d5a90 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,12 @@ +server { + listen 80; + server_name walwal.life; + + location / { + proxy_pass http://localhost:8080/; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 00000000..7dee15db --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,13 @@ +#!/bin/sh + +targetFiles=$(git diff --staged --name-only) + +echo "Apply Spotless.." +./gradlew spotlessApply + +# Add files to stage spotless applied +for file in $targetFiles; do + if test -f "$file"; then + git add $file + fi +done diff --git a/src/main/java/com/depromeet/stonebed/StonebedApplication.java b/src/main/java/com/depromeet/stonebed/StonebedApplication.java index 52cdd17e..51f8c2b6 100644 --- a/src/main/java/com/depromeet/stonebed/StonebedApplication.java +++ b/src/main/java/com/depromeet/stonebed/StonebedApplication.java @@ -5,9 +5,7 @@ @SpringBootApplication public class StonebedApplication { - - public static void main(String[] args) { - SpringApplication.run(StonebedApplication.class, args); - } - + public static void main(String[] args) { + SpringApplication.run(StonebedApplication.class, args); + } } diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/api/AuthController.java b/src/main/java/com/depromeet/stonebed/domain/auth/api/AuthController.java new file mode 100644 index 00000000..4044d4b6 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/api/AuthController.java @@ -0,0 +1,65 @@ +package com.depromeet.stonebed.domain.auth.api; + +import com.depromeet.stonebed.domain.auth.application.AuthService; +import com.depromeet.stonebed.domain.auth.domain.OAuthProvider; +import com.depromeet.stonebed.domain.auth.dto.request.RefreshTokenRequest; +import com.depromeet.stonebed.domain.auth.dto.request.SocialLoginRequest; +import com.depromeet.stonebed.domain.auth.dto.response.AuthTokenResponse; +import com.depromeet.stonebed.domain.auth.dto.response.SocialClientResponse; +import com.depromeet.stonebed.domain.member.dto.request.CreateMemberRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "1-1. [인증]", description = "인증 관련 API입니다.") +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "소셜 로그인", description = "소셜 로그인 후 임시 토큰을 발급합니다.") + @PostMapping("/social-login/{provider}") + public AuthTokenResponse socialLogin( + @PathVariable(name = "provider") + @Parameter(example = "apple", description = "OAuth 제공자") + String provider, + @RequestBody @Valid SocialLoginRequest request) { + OAuthProvider oAuthProvider = OAuthProvider.from(provider); + + SocialClientResponse socialClientResponse = + authService.authenticateFromProvider(oAuthProvider, request.token()); + + // 위 결과에서 나온 oauthId와 email로 토큰 발급 + return authService.socialLogin( + oAuthProvider, socialClientResponse.oauthId(), socialClientResponse.email()); + } + + @Operation(summary = "회원가입", description = "회원가입을 진행 후 토큰 발급") + @PostMapping("/register") + public AuthTokenResponse register(@RequestBody @Valid CreateMemberRequest request) { + return authService.registerMember(request); + } + + @Operation(summary = "리프레시 토큰 발급", description = "리프레시 토큰을 이용해 새로운 액세스 토큰을 발급합니다.") + @PostMapping("/reissue") + public AuthTokenResponse reissueTokenPair(@RequestBody @Valid RefreshTokenRequest request) { + return authService.reissueTokenPair(request); + } + + @Operation(summary = "회원탈퇴 기능", description = "회원탈퇴를 진행합니다.") + @PostMapping("/withdraw") + public ResponseEntity withdraw() { + authService.withdraw(); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/application/AuthService.java b/src/main/java/com/depromeet/stonebed/domain/auth/application/AuthService.java new file mode 100644 index 00000000..295c6dde --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/application/AuthService.java @@ -0,0 +1,122 @@ +package com.depromeet.stonebed.domain.auth.application; + +import com.depromeet.stonebed.domain.auth.application.apple.AppleClient; +import com.depromeet.stonebed.domain.auth.application.kakao.KakaoClient; +import com.depromeet.stonebed.domain.auth.domain.OAuthProvider; +import com.depromeet.stonebed.domain.auth.dto.RefreshTokenDto; +import com.depromeet.stonebed.domain.auth.dto.request.RefreshTokenRequest; +import com.depromeet.stonebed.domain.auth.dto.response.AuthTokenResponse; +import com.depromeet.stonebed.domain.auth.dto.response.SocialClientResponse; +import com.depromeet.stonebed.domain.auth.dto.response.TokenPairResponse; +import com.depromeet.stonebed.domain.member.application.MemberService; +import com.depromeet.stonebed.domain.member.domain.Member; +import com.depromeet.stonebed.domain.member.domain.MemberRole; +import com.depromeet.stonebed.domain.member.dto.request.CreateMemberRequest; +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import com.depromeet.stonebed.global.util.MemberUtil; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + private final AppleClient appleClient; + private final KakaoClient kakaoClient; + private final MemberService memberService; + private final JwtTokenService jwtTokenService; + private final MemberUtil memberUtil; + + public SocialClientResponse authenticateFromProvider(OAuthProvider provider, String token) { + /* token + 1. apple의 경우 authorizationCode Value + 2. kakao의 경우 accessToken Value + */ + return switch (provider) { + case APPLE -> appleClient.authenticateFromApple(token); + case KAKAO -> kakaoClient.authenticateFromKakao(token); + }; + } + + public AuthTokenResponse socialLogin( + OAuthProvider oAuthProvider, String oauthId, String email) { + Optional memberOptional = memberService.getMemberByOauthId(oAuthProvider, oauthId); + + return memberOptional + .map( + member -> { + // 사용자 로그인 토큰 생성 + TokenPairResponse tokenPair = + member.getRole() == MemberRole.TEMPORARY + ? getTemporaryLoginResponse(member) + : getLoginResponse(member); + member.updateLastLoginAt(); + return AuthTokenResponse.of( + tokenPair, member.getRole() == MemberRole.TEMPORARY); + }) + .orElseGet( + () -> { + // 회원가입이 안된 경우, 임시 회원가입 진행 + Member newMember = + memberService.socialSignUp(oAuthProvider, oauthId, email); + // 임시 토큰 발행 + TokenPairResponse temporaryTokenPair = + jwtTokenService.generateTemporaryTokenPair(newMember); + newMember.updateLastLoginAt(); + return AuthTokenResponse.of(temporaryTokenPair, true); + }); + } + + // 회원가입 + public AuthTokenResponse registerMember(CreateMemberRequest request) { + Member currentMember = memberUtil.getCurrentMember(); + // 사용자 회원가입 + if (memberUtil.getMemberRole().equals(MemberRole.TEMPORARY.getValue())) { + Member member = memberService.registerMember(currentMember, request); + // 새 토큰 생성 + TokenPairResponse tokenPair = getLoginResponse(member); + return AuthTokenResponse.of(tokenPair, false); + } + throw new CustomException(ErrorCode.ALREADY_EXISTS_MEMBER); + } + + @Transactional(readOnly = true) + public AuthTokenResponse reissueTokenPair(RefreshTokenRequest request) { + // 리프레시 토큰을 이용해 새로운 액세스 토큰 발급 + RefreshTokenDto refreshTokenDto = + jwtTokenService.retrieveRefreshToken(request.refreshToken()); + RefreshTokenDto refreshToken = + jwtTokenService.createRefreshTokenDto(refreshTokenDto.memberId()); + + Member member = memberUtil.getMemberByMemberId(refreshToken.memberId()); + + TokenPairResponse tokenPair = getLoginResponse(member); + return AuthTokenResponse.of(tokenPair, false); + } + + private TokenPairResponse getLoginResponse(Member member) { + return jwtTokenService.generateTokenPair(member.getId(), MemberRole.USER); + } + + private TokenPairResponse getTemporaryLoginResponse(Member member) { + return jwtTokenService.generateTokenPair(member.getId(), MemberRole.TEMPORARY); + } + + public void withdraw( + /** OAuthProvider provider */ + ) { + Member member = memberUtil.getCurrentMember(); + /** + * TODO: 런칭데이 이후 고도화 if (provider.equals(OAuthProvider.APPLE)) { + * appleClient.withdraw(member.getOauthInfo().getOauthId()); } + */ + jwtTokenService.deleteRefreshToken(member.getId()); + member.withdrawal(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/application/JwtTokenService.java b/src/main/java/com/depromeet/stonebed/domain/auth/application/JwtTokenService.java new file mode 100644 index 00000000..a601f018 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/application/JwtTokenService.java @@ -0,0 +1,118 @@ +package com.depromeet.stonebed.domain.auth.application; + +import static com.depromeet.stonebed.global.common.constants.SecurityConstants.*; + +import com.depromeet.stonebed.domain.auth.dao.RefreshTokenRepository; +import com.depromeet.stonebed.domain.auth.domain.RefreshToken; +import com.depromeet.stonebed.domain.auth.dto.AccessTokenDto; +import com.depromeet.stonebed.domain.auth.dto.RefreshTokenDto; +import com.depromeet.stonebed.domain.auth.dto.response.TokenPairResponse; +import com.depromeet.stonebed.domain.member.domain.Member; +import com.depromeet.stonebed.domain.member.domain.MemberRole; +import com.depromeet.stonebed.global.util.JwtUtil; +import io.jsonwebtoken.ExpiredJwtException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class JwtTokenService { + + private final JwtUtil jwtUtil; + private final RefreshTokenRepository refreshTokenRepository; + + public TokenPairResponse generateTokenPair(Long memberId, MemberRole memberRole) { + String accessToken = createAccessToken(memberId, memberRole); + String refreshToken = createRefreshToken(memberId); + return TokenPairResponse.of(accessToken, refreshToken); + } + + public TokenPairResponse generateTemporaryTokenPair(Member temporaryMember) { + String accessToken = createAccessToken(temporaryMember.getId(), MemberRole.TEMPORARY); + String refreshToken = createRefreshToken(temporaryMember.getId()); + return TokenPairResponse.of(accessToken, refreshToken); + } + + private String createAccessToken(Long memberId, MemberRole memberRole) { + return jwtUtil.generateAccessToken(memberId, memberRole); + } + + public AccessTokenDto createAccessTokenDto(Long memberId, MemberRole memberRole) { + return jwtUtil.generateAccessTokenDto(memberId, memberRole); + } + + private String createRefreshToken(Long memberId) { + String token = jwtUtil.generateRefreshToken(memberId); + saveRefreshTokenToRedis(memberId, token, jwtUtil.getRefreshTokenExpirationTime()); + return token; + } + + public RefreshTokenDto createRefreshTokenDto(Long memberId) { + RefreshTokenDto refreshTokenDto = jwtUtil.generateRefreshTokenDto(memberId); + saveRefreshTokenToRedis(memberId, refreshTokenDto.tokenValue(), refreshTokenDto.ttl()); + return refreshTokenDto; + } + + private void saveRefreshTokenToRedis(Long memberId, String refreshTokenDto, Long ttl) { + RefreshToken refreshToken = + RefreshToken.builder().memberId(memberId).token(refreshTokenDto).ttl(ttl).build(); + refreshTokenRepository.save(refreshToken); + } + + public AccessTokenDto retrieveAccessToken(String accessTokenValue) { + try { + return jwtUtil.parseAccessToken(accessTokenValue); + } catch (Exception e) { + return null; + } + } + + public RefreshTokenDto retrieveRefreshToken(String refreshTokenValue) { + RefreshTokenDto refreshTokenDto = parseRefreshToken(refreshTokenValue); + + if (refreshTokenDto == null) { + return null; + } + + // 파싱된 DTO와 일치하는 토큰이 Redis에 저장되어 있는지 확인 + Optional refreshToken = getRefreshTokenFromRedis(refreshTokenDto.memberId()); + + // Redis에 토큰이 존재하고, 쿠키의 토큰과 값이 일치하면 DTO 반환 + if (refreshToken.isPresent()) { + return refreshTokenDto; + } + + // Redis에 토큰이 존재하지 않거나, 쿠키의 토큰과 값이 일치하지 않으면 null 반환 + return null; + } + + private Optional getRefreshTokenFromRedis(Long memberId) { + return refreshTokenRepository.findById(memberId); + } + + private RefreshTokenDto parseRefreshToken(String refreshTokenValue) { + try { + return jwtUtil.parseRefreshToken(refreshTokenValue); + } catch (Exception e) { + return null; + } + } + + public AccessTokenDto reissueAccessTokenIfExpired(String accessTokenValue) { + // AT가 만료된 경우 AT 재발급, 만료되지 않은 경우 null 반환 + try { + jwtUtil.parseAccessToken(accessTokenValue); + return null; + } catch (ExpiredJwtException e) { + Long memberId = Long.parseLong(e.getClaims().getSubject()); + MemberRole memberRole = + MemberRole.valueOf(e.getClaims().get(TOKEN_ROLE_NAME, String.class)); + return createAccessTokenDto(memberId, memberRole); + } + } + + public void deleteRefreshToken(Long memberId) { + refreshTokenRepository.deleteById(memberId); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/application/apple/AppleClient.java b/src/main/java/com/depromeet/stonebed/domain/auth/application/apple/AppleClient.java new file mode 100644 index 00000000..3e6905f4 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/application/apple/AppleClient.java @@ -0,0 +1,191 @@ +package com.depromeet.stonebed.domain.auth.application.apple; + +import static com.depromeet.stonebed.global.common.constants.SecurityConstants.*; + +import com.depromeet.stonebed.domain.auth.dto.request.AppleTokenRequest; +import com.depromeet.stonebed.domain.auth.dto.response.AppleKeyListResponse; +import com.depromeet.stonebed.domain.auth.dto.response.AppleKeyResponse; +import com.depromeet.stonebed.domain.auth.dto.response.AppleTokenResponse; +import com.depromeet.stonebed.domain.auth.dto.response.SocialClientResponse; +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import com.depromeet.stonebed.global.util.SpringEnvironmentUtil; +import com.depromeet.stonebed.infra.properties.AppleProperties; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.JWK; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.security.InvalidParameterException; +import java.security.Key; +import java.security.PrivateKey; +import java.security.Security; +import java.text.ParseException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.Base64; +import java.util.Date; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +@Component +@RequiredArgsConstructor +public class AppleClient { + + private final ObjectMapper objectMapper; + private final RestClient restClient; + private final AppleProperties appleProperties; + private final SpringEnvironmentUtil springEnvironmentUtil; + + private static final int APPLE_TOKEN_EXPIRE_MINUTES = 5; + + /** + * Apple로부터 받은 idToken 검증하고 identifier를 추출합니다. + * + * @param authorizationCode + * @return + */ + public SocialClientResponse authenticateFromApple(String authorizationCode) { + AppleTokenRequest tokenRequest = + AppleTokenRequest.of( + authorizationCode, + springEnvironmentUtil.isProdProfile() + ? appleProperties.prod().clientId() + : appleProperties.dev().clientId(), + generateAppleClientSecret(), + APPLE_GRANT_TYPE); + AppleTokenResponse appleTokenResponse = getAppleToken(tokenRequest); + + AppleKeyResponse[] keys = retrieveAppleKeys(); + try { + String[] tokenParts = appleTokenResponse.idToken().split("\\."); + String headerPart = new String(Base64.getDecoder().decode(tokenParts[0])); + JsonNode headerNode = objectMapper.readTree(headerPart); + String kid = headerNode.get("kid").asText(); + String alg = headerNode.get("alg").asText(); + + AppleKeyResponse matchedKey = + Arrays.stream(keys) + .filter(key -> key.kid().equals(kid) && key.alg().equals(alg)) + .findFirst() + // 일치하는 키가 없음 => 만료된 토큰 or 이상한 토큰 => throw + .orElseThrow(InvalidParameterException::new); + + Claims claims = parseIdentifierFromAppleToken(matchedKey, appleTokenResponse.idToken()); + + String oauthId = claims.get("sub", String.class); + String email = claims.get("email", String.class); + + return new SocialClientResponse(email, oauthId); + } catch (Exception ex) { + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + // apple server에서 받아온 id_token + private AppleTokenResponse getAppleToken(AppleTokenRequest appleTokenRequest) { + // Prepare form data + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", appleTokenRequest.clientId()); + formData.add("client_secret", appleTokenRequest.clientSecret()); + formData.add("code", appleTokenRequest.code()); + formData.add("grant_type", appleTokenRequest.grantType()); + + return restClient + .post() + .uri(APPLE_TOKEN_URL) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .body(formData) + .exchange( + (request, response) -> { + if (!response.getStatusCode().is2xxSuccessful()) { + throw new CustomException(ErrorCode.APPLE_TOKEN_CLIENT_FAILED); + } + return Objects.requireNonNull( + response.bodyTo(AppleTokenResponse.class)); + }); + } + + private String generateAppleClientSecret() { + Date expirationDate = + Date.from( + LocalDateTime.now() + .plusMinutes(APPLE_TOKEN_EXPIRE_MINUTES) + .atZone(ZoneId.systemDefault()) + .toInstant()); + + String teamId = + springEnvironmentUtil.isProdProfile() + ? appleProperties.prod().teamId() + : appleProperties.dev().teamId(); + String clientId = + springEnvironmentUtil.isProdProfile() + ? appleProperties.prod().clientId() + : appleProperties.dev().clientId(); + return Jwts.builder() + .setHeaderParam("kid", appleProperties.keyId()) + .setHeaderParam("alg", "ES256") + // TODO: dev, prod 환경분리 필요 + .setIssuer(teamId.split("\\.")[0]) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(expirationDate) + .setAudience(APPLE_ISSUER) + .setSubject(clientId) + .signWith(getPrivateKey(), SignatureAlgorithm.ES256) + .compact(); + } + + private PrivateKey getPrivateKey() { + Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider()); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); + + try { + byte[] privateKeyBytes = Base64.getDecoder().decode(appleProperties.p8()); + PrivateKeyInfo privateKeyInfo = PrivateKeyInfo.getInstance(privateKeyBytes); + return converter.getPrivateKey(privateKeyInfo); + } catch (Exception e) { + throw new CustomException(ErrorCode.APPLE_PRIVATE_KEY_ENCODING_FAILED); + } + } + + private AppleKeyResponse[] retrieveAppleKeys() { + AppleKeyListResponse keyListResponse = + restClient + .get() + .uri(APPLE_JWK_SET_URL) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_URLENCODED) + .exchange( + (request, response) -> { + if (!response.getStatusCode().is2xxSuccessful()) + throw new CustomException( + ErrorCode.APPLE_TOKEN_CLIENT_FAILED); + return Objects.requireNonNull( + response.bodyTo(AppleKeyListResponse.class)); + }); + + return keyListResponse.keys(); + } + + private Claims parseIdentifierFromAppleToken(AppleKeyResponse matchedKey, String accessToken) + throws JsonProcessingException, ParseException, JOSEException { + Key keyData = + JWK.parse(objectMapper.writeValueAsString(matchedKey)).toRSAKey().toRSAPublicKey(); + Jws parsedClaims = + Jwts.parserBuilder().setSigningKey(keyData).build().parseClaimsJws(accessToken); + + return parsedClaims.getBody(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/application/kakao/KakaoClient.java b/src/main/java/com/depromeet/stonebed/domain/auth/application/kakao/KakaoClient.java new file mode 100644 index 00000000..0734a876 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/application/kakao/KakaoClient.java @@ -0,0 +1,38 @@ +package com.depromeet.stonebed.domain.auth.application.kakao; + +import static com.depromeet.stonebed.global.common.constants.SecurityConstants.*; + +import com.depromeet.stonebed.domain.auth.dto.response.KakaoAuthResponse; +import com.depromeet.stonebed.domain.auth.dto.response.SocialClientResponse; +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +@RequiredArgsConstructor +public class KakaoClient { + private final RestClient restClient; + + public SocialClientResponse authenticateFromKakao(String token) { + KakaoAuthResponse kakaoAuthResponse = + restClient + .get() + .uri(KAKAO_USER_ME_URL) + .header("Authorization", TOKEN_PREFIX + token) + .exchange( + (request, response) -> { + if (!response.getStatusCode().is2xxSuccessful()) { + throw new CustomException( + ErrorCode.APPLE_TOKEN_CLIENT_FAILED); + } + return Objects.requireNonNull( + response.bodyTo(KakaoAuthResponse.class)); + }); + + return new SocialClientResponse( + kakaoAuthResponse.kakaoAccount().email(), kakaoAuthResponse.id().toString()); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dao/RefreshTokenRepository.java b/src/main/java/com/depromeet/stonebed/domain/auth/dao/RefreshTokenRepository.java new file mode 100644 index 00000000..c52fcf34 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dao/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package com.depromeet.stonebed.domain.auth.dao; + +import com.depromeet.stonebed.domain.auth.domain.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository {} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/domain/OAuthProvider.java b/src/main/java/com/depromeet/stonebed/domain/auth/domain/OAuthProvider.java new file mode 100644 index 00000000..90193d0d --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/domain/OAuthProvider.java @@ -0,0 +1,22 @@ +package com.depromeet.stonebed.domain.auth.domain; + +import java.security.InvalidParameterException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum OAuthProvider { + KAKAO("KAKAO"), + APPLE("APPLE"), + ; + private final String value; + + public static OAuthProvider from(String provider) { + return switch (provider.toUpperCase()) { + case "APPLE" -> APPLE; + case "KAKAO" -> KAKAO; + default -> throw new InvalidParameterException(); + }; + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/domain/RefreshToken.java b/src/main/java/com/depromeet/stonebed/domain/auth/domain/RefreshToken.java new file mode 100644 index 00000000..ce5c1b10 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/domain/RefreshToken.java @@ -0,0 +1,23 @@ +package com.depromeet.stonebed.domain.auth.domain; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash(value = "refreshToken") +public class RefreshToken { + + @Id private Long memberId; + private final String token; + @TimeToLive private final long ttl; + + @Builder + public RefreshToken(Long memberId, String token, long ttl) { + this.memberId = memberId; + this.token = token; + this.ttl = ttl; + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/domain/TokenType.java b/src/main/java/com/depromeet/stonebed/domain/auth/domain/TokenType.java new file mode 100644 index 00000000..58ec3977 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/domain/TokenType.java @@ -0,0 +1,23 @@ +package com.depromeet.stonebed.domain.auth.domain; + +import java.security.InvalidParameterException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum TokenType { + ACCESS("access"), + REFRESH("refresh"), + TEMPORARY("temporary"); + private final String value; + + public static TokenType from(String typeKey) { + return switch (typeKey.toUpperCase()) { + case "ACCESS" -> ACCESS; + case "REFRESH" -> REFRESH; + case "TEMPORARY" -> TEMPORARY; + default -> throw new InvalidParameterException(); + }; + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/AccessTokenDto.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/AccessTokenDto.java new file mode 100644 index 00000000..7a9bf04f --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/AccessTokenDto.java @@ -0,0 +1,5 @@ +package com.depromeet.stonebed.domain.auth.dto; + +import com.depromeet.stonebed.domain.member.domain.MemberRole; + +public record AccessTokenDto(Long memberId, MemberRole memberRole, String tokenValue) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/AuthenticationToken.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/AuthenticationToken.java new file mode 100644 index 00000000..43444097 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/AuthenticationToken.java @@ -0,0 +1,5 @@ +package com.depromeet.stonebed.domain.auth.dto; + +import com.depromeet.stonebed.domain.auth.domain.TokenType; + +public record AuthenticationToken(String userId, TokenType tokenType, String provider) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/RefreshTokenDto.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/RefreshTokenDto.java new file mode 100644 index 00000000..cf7d5828 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/RefreshTokenDto.java @@ -0,0 +1,3 @@ +package com.depromeet.stonebed.domain.auth.dto; + +public record RefreshTokenDto(Long memberId, String tokenValue, Long ttl) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/request/AppleTokenRequest.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/request/AppleTokenRequest.java new file mode 100644 index 00000000..3c442521 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/request/AppleTokenRequest.java @@ -0,0 +1,15 @@ +package com.depromeet.stonebed.domain.auth.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AppleTokenRequest( + // 외부 통신 시 snake_case로 요청 및 응답 + @JsonProperty("code") String code, + @JsonProperty("client_id") String clientId, + @JsonProperty("client_secret") String clientSecret, + @JsonProperty("grant_type") String grantType) { + public static AppleTokenRequest of( + String code, String clientId, String clientSecret, String grantType) { + return new AppleTokenRequest(code, clientId, clientSecret, grantType); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/request/RefreshTokenRequest.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/request/RefreshTokenRequest.java new file mode 100644 index 00000000..49ab3150 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/request/RefreshTokenRequest.java @@ -0,0 +1,6 @@ +package com.depromeet.stonebed.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record RefreshTokenRequest( + @Schema(description = "리프레시 토큰", example = "refreshToken") String refreshToken) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/request/SocialLoginRequest.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/request/SocialLoginRequest.java new file mode 100644 index 00000000..eeb767cf --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/request/SocialLoginRequest.java @@ -0,0 +1,6 @@ +package com.depromeet.stonebed.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record SocialLoginRequest( + @Schema(description = "apple auth code", example = "authorization_code") String token) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AppleKeyListResponse.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AppleKeyListResponse.java new file mode 100644 index 00000000..96012071 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AppleKeyListResponse.java @@ -0,0 +1,28 @@ +package com.depromeet.stonebed.domain.auth.dto.response; + +import java.util.Arrays; +import java.util.Optional; + +public record AppleKeyListResponse(AppleKeyResponse[] keys) { + @Override + public boolean equals(Object obj) { + return obj instanceof AppleKeyListResponse applekeylistresponse + && Arrays.equals(applekeylistresponse.keys, keys); + } + + @Override + public int hashCode() { + return Arrays.hashCode(keys); + } + + @Override + public String toString() { + return "AppleKeyListResponse{" + "keys=" + Arrays.toString(keys) + '}'; + } + + public Optional getMatchedKeyBy(String kid, String alg) { + return Arrays.stream(keys) + .filter(key -> key.kid().equals(kid) && key.alg().equals(alg)) + .findFirst(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AppleKeyResponse.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AppleKeyResponse.java new file mode 100644 index 00000000..39e43a98 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AppleKeyResponse.java @@ -0,0 +1,4 @@ +package com.depromeet.stonebed.domain.auth.dto.response; + +public record AppleKeyResponse( + String kty, String kid, String use, String alg, String n, String e) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AppleTokenResponse.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AppleTokenResponse.java new file mode 100644 index 00000000..ef937a1e --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AppleTokenResponse.java @@ -0,0 +1,20 @@ +package com.depromeet.stonebed.domain.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AppleTokenResponse( + // 외부 통신 시 snake_case로 요청 및 응답 + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") Long expiresIn, + @JsonProperty("id_token") String idToken, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("token_type") String tokenType) { + public static AppleTokenResponse of( + String accessToken, + Long expiresIn, + String idToken, + String refreshToken, + String tokenType) { + return new AppleTokenResponse(accessToken, expiresIn, idToken, refreshToken, tokenType); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AuthTokenResponse.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AuthTokenResponse.java new file mode 100644 index 00000000..ba2d9540 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/AuthTokenResponse.java @@ -0,0 +1,16 @@ +package com.depromeet.stonebed.domain.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AuthTokenResponse( + @Schema(description = "엑세스 토큰", example = "accessToken") String accessToken, + @Schema(description = "리프레시 토큰", example = "refreshToken") String refreshToken, + @Schema(description = "임시 토큰 여부", example = "false") boolean isTemporaryToken) { + public static AuthTokenResponse of( + TokenPairResponse tokenPairResponse, boolean isTemporaryToken) { + return new AuthTokenResponse( + tokenPairResponse.accessToken(), + tokenPairResponse.refreshToken(), + isTemporaryToken); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/KakaoAuthResponse.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/KakaoAuthResponse.java new file mode 100644 index 00000000..d2a94ccd --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/KakaoAuthResponse.java @@ -0,0 +1,13 @@ +package com.depromeet.stonebed.domain.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoAuthResponse( + Long id, + @JsonProperty("kakao_account") KakaoAccountResponse kakaoAccount, + @JsonProperty("properties") PropertiesResponse properties) { + public static record KakaoAccountResponse(String email) {} + + public static record PropertiesResponse( + String nickname, String profile_image, String thumbnail_image) {} +} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/SocialClientResponse.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/SocialClientResponse.java new file mode 100644 index 00000000..0179347d --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/SocialClientResponse.java @@ -0,0 +1,3 @@ +package com.depromeet.stonebed.domain.auth.dto.response; + +public record SocialClientResponse(String email, String oauthId) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/TokenPairResponse.java b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/TokenPairResponse.java new file mode 100644 index 00000000..52d1c8be --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/auth/dto/response/TokenPairResponse.java @@ -0,0 +1,12 @@ +package com.depromeet.stonebed.domain.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TokenPairResponse( + @Schema(description = "액세스 토큰", defaultValue = "accessToken") String accessToken, + @Schema(description = "리프레시 토큰", defaultValue = "refreshToken") String refreshToken) { + + public static TokenPairResponse of(String accessToken, String refreshToken) { + return new TokenPairResponse(accessToken, refreshToken); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/common/BaseTimeEntity.java b/src/main/java/com/depromeet/stonebed/domain/common/BaseTimeEntity.java new file mode 100644 index 00000000..e9adae09 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/common/BaseTimeEntity.java @@ -0,0 +1,22 @@ +package com.depromeet.stonebed.domain.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @Column(updatable = false) + @CreatedDate + private LocalDateTime createdAt; + + @Column @LastModifiedDate private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/depromeet/stonebed/domain/image/api/ImageController.java b/src/main/java/com/depromeet/stonebed/domain/image/api/ImageController.java new file mode 100644 index 00000000..9bb48597 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/image/api/ImageController.java @@ -0,0 +1,60 @@ +package com.depromeet.stonebed.domain.image.api; + +import com.depromeet.stonebed.domain.image.application.ImageService; +import com.depromeet.stonebed.domain.image.dto.request.MemberProfileImageCreateRequest; +import com.depromeet.stonebed.domain.image.dto.request.MemberProfileImageUploadCompleteRequest; +import com.depromeet.stonebed.domain.image.dto.request.MissionRecordImageCreateRequest; +import com.depromeet.stonebed.domain.image.dto.request.MissionRecordImageUploadRequest; +import com.depromeet.stonebed.domain.image.dto.response.PresignedUrlResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "3. [이미지]", description = "이미지 관련 API입니다.") +@RestController +@RequestMapping("/images") +@RequiredArgsConstructor +public class ImageController { + + private final ImageService imageService; + + @Operation( + summary = "회원 프로필 이미지 Presigned URL 생성", + description = "회원 프로필 이미지 Presigned URL을 생성합니다.") + @PostMapping("/members/me/upload-url") + public PresignedUrlResponse memberProfilePresignedUrlCreate( + @Valid @RequestBody MemberProfileImageCreateRequest request) { + return imageService.createMemberProfilePresignedUrl(request); + } + + @Operation(summary = "회원 프로필 이미지 업로드 완료", description = "회원 프로필 이미지 업로드 완료 업로드 상태를 변경합니다.") + @PostMapping("/members/me/upload-complete") + public ResponseEntity memberProfileUploaded( + @Valid @RequestBody MemberProfileImageUploadCompleteRequest request) { + imageService.uploadCompleteMemberProfile(request); + return ResponseEntity.ok().build(); + } + + @Operation( + summary = "미션 기록 이미지 Presigned URL 생성", + description = "미션 기록 이미지 Presigned URL을 생성합니다.") + @PostMapping("/mission-record/upload-url") + public PresignedUrlResponse missionRecordPresignedUrlCreate( + @Valid @RequestBody MissionRecordImageCreateRequest request) { + return imageService.createMissionRecordPresignedUrl(request); + } + + @Operation(summary = "미션 기록 이미지 업로드 완료", description = "미션 기록 이미지 업로드 완료 업로드 상태를 변경합니다.") + @PostMapping("/mission-record/upload-complete") + public ResponseEntity missionRecordUploaded( + @Valid @RequestBody MissionRecordImageUploadRequest request) { + imageService.uploadCompleteMissionRecord(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/image/application/ImageService.java b/src/main/java/com/depromeet/stonebed/domain/image/application/ImageService.java new file mode 100644 index 00000000..a3f7c71b --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/image/application/ImageService.java @@ -0,0 +1,207 @@ +package com.depromeet.stonebed.domain.image.application; + +import com.amazonaws.HttpMethod; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.Headers; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import com.depromeet.stonebed.domain.image.dao.ImageRepository; +import com.depromeet.stonebed.domain.image.domain.Image; +import com.depromeet.stonebed.domain.image.domain.ImageFileExtension; +import com.depromeet.stonebed.domain.image.domain.ImageType; +import com.depromeet.stonebed.domain.image.dto.request.MemberProfileImageCreateRequest; +import com.depromeet.stonebed.domain.image.dto.request.MemberProfileImageUploadCompleteRequest; +import com.depromeet.stonebed.domain.image.dto.request.MissionRecordImageCreateRequest; +import com.depromeet.stonebed.domain.image.dto.request.MissionRecordImageUploadRequest; +import com.depromeet.stonebed.domain.image.dto.response.PresignedUrlResponse; +import com.depromeet.stonebed.domain.member.domain.Member; +import com.depromeet.stonebed.domain.member.domain.Profile; +import com.depromeet.stonebed.domain.missionRecord.application.MissionRecordService; +import com.depromeet.stonebed.global.common.constants.UrlConstants; +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import com.depromeet.stonebed.global.util.MemberUtil; +import com.depromeet.stonebed.global.util.SpringEnvironmentUtil; +import com.depromeet.stonebed.infra.properties.S3Properties; +import java.net.URL; +import java.util.Date; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ImageService { + + private final ImageRepository imageRepository; + private final AmazonS3 amazonS3; + private final MemberUtil memberUtil; + private final S3Properties s3Properties; + private final SpringEnvironmentUtil springEnvironmentUtil; + private final MissionRecordService missionRecordService; + + public PresignedUrlResponse createMemberProfilePresignedUrl( + MemberProfileImageCreateRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + validateImageFileExtension(request.imageFileExtension()); + String imageKey = generateUUID(); + String fileName = + createFileName( + ImageType.MEMBER_PROFILE, + currentMember.getId(), + imageKey, + request.imageFileExtension()); + GeneratePresignedUrlRequest presignedUrlRequest = + createPreSignedUrlRequest( + s3Properties.bucket(), + fileName, + request.imageFileExtension().getUploadExtension()); + + URL presignedUrl = amazonS3.generatePresignedUrl(presignedUrlRequest); + imageRepository.save( + Image.createImage( + ImageType.MEMBER_PROFILE, + currentMember.getId(), + imageKey, + request.imageFileExtension())); + return PresignedUrlResponse.from(presignedUrl.toString()); + } + + public void uploadCompleteMemberProfile(MemberProfileImageUploadCompleteRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + validateImageFileExtension(request.imageFileExtension()); + + Image image = + findImage( + ImageType.MEMBER_PROFILE, + currentMember.getId(), + request.imageFileExtension()); + String imageUrl = + createReadImageUrl( + ImageType.MEMBER_PROFILE, + currentMember.getId(), + image.getImageKey(), + request.imageFileExtension()); + currentMember.updateProfile(Profile.createProfile(request.nickname(), imageUrl)); + } + + public PresignedUrlResponse createMissionRecordPresignedUrl( + MissionRecordImageCreateRequest request) { + validateImageFileExtension(request.imageFileExtension()); + String imageKey = generateUUID(); + + Long missionRecordId = request.recordId(); + + String fileName = + createFileName( + ImageType.MISSION_RECORD, + missionRecordId, + imageKey, + request.imageFileExtension()); + GeneratePresignedUrlRequest presignedUrlRequest = + createPreSignedUrlRequest( + s3Properties.bucket(), + fileName, + request.imageFileExtension().getUploadExtension()); + + URL presignedUrl = amazonS3.generatePresignedUrl(presignedUrlRequest); + imageRepository.save( + Image.createImage( + ImageType.MISSION_RECORD, + missionRecordId, + imageKey, + request.imageFileExtension())); + return PresignedUrlResponse.from(presignedUrl.toString()); + } + + public void uploadCompleteMissionRecord(MissionRecordImageUploadRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + validateImageFileExtension(request.imageFileExtension()); + + Image image = + findImage( + ImageType.MISSION_RECORD, + currentMember.getId(), + request.imageFileExtension()); + String imageUrl = + createReadImageUrl( + ImageType.MISSION_RECORD, + currentMember.getId(), + image.getImageKey(), + request.imageFileExtension()); + + missionRecordService.updateMissionRecordWithImage(request.recordId(), imageUrl); + } + + private void validateImageFileExtension(ImageFileExtension imageFileExtension) { + if (imageFileExtension == null) { + throw new CustomException(ErrorCode.IMAGE_FILE_EXTENSION_NOT_FOUND); + } + try { + ImageFileExtension.of(imageFileExtension.getUploadExtension()); + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.INVALID_IMAGE_FILE_EXTENSION); + } + } + + private Image findImage( + ImageType imageType, Long targetId, ImageFileExtension imageFileExtension) { + return imageRepository + .findTopByImageTypeAndTargetIdAndImageFileExtensionOrderByIdDesc( + imageType, targetId, imageFileExtension) + .orElseThrow(() -> new CustomException(ErrorCode.IMAGE_KEY_NOT_FOUND)); + } + + private String generateUUID() { + return UUID.randomUUID().toString(); + } + + private String createFileName( + ImageType imageType, + Long targetId, + String imageKey, + ImageFileExtension imageFileExtension) { + return String.format( + "%s/%s/%d/%s.%s", + springEnvironmentUtil.getCurrentProfile(), + imageType.getValue(), + targetId, + imageKey, + imageFileExtension.getUploadExtension()); + } + + private String createReadImageUrl( + ImageType imageType, + Long targetId, + String imageKey, + ImageFileExtension imageFileExtension) { + return String.format( + "%s/%s/%s/%d/%s.%s", + UrlConstants.IMAGE_DOMAIN_URL.getValue(), + springEnvironmentUtil.getCurrentProfile(), + imageType.getValue(), + targetId, + imageKey, + imageFileExtension.getUploadExtension()); + } + + private GeneratePresignedUrlRequest createPreSignedUrlRequest( + String bucket, String fileName, String fileExtension) { + GeneratePresignedUrlRequest generatePresignedUrlRequest = + new GeneratePresignedUrlRequest(bucket, fileName, HttpMethod.PUT) + .withKey(fileName) + .withContentType("image/" + fileExtension) + .withExpiration(getPreSignedUrlExpiration()); + + generatePresignedUrlRequest.addRequestParameter( + Headers.S3_CANNED_ACL, CannedAccessControlList.PublicRead.toString()); + + return generatePresignedUrlRequest; + } + + private Date getPreSignedUrlExpiration() { + return new Date(System.currentTimeMillis() + 1000 * 60 * 30); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/image/dao/ImageRepository.java b/src/main/java/com/depromeet/stonebed/domain/image/dao/ImageRepository.java new file mode 100644 index 00000000..1aafdd1e --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/image/dao/ImageRepository.java @@ -0,0 +1,12 @@ +package com.depromeet.stonebed.domain.image.dao; + +import com.depromeet.stonebed.domain.image.domain.Image; +import com.depromeet.stonebed.domain.image.domain.ImageFileExtension; +import com.depromeet.stonebed.domain.image.domain.ImageType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImageRepository extends JpaRepository { + Optional findTopByImageTypeAndTargetIdAndImageFileExtensionOrderByIdDesc( + ImageType imageType, Long targetId, ImageFileExtension imageFileExtension); +} diff --git a/src/main/java/com/depromeet/stonebed/domain/image/domain/Image.java b/src/main/java/com/depromeet/stonebed/domain/image/domain/Image.java new file mode 100644 index 00000000..d731f650 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/image/domain/Image.java @@ -0,0 +1,63 @@ +package com.depromeet.stonebed.domain.image.domain; + +import com.depromeet.stonebed.domain.common.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Image extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_id") + private Long id; + + @Enumerated(EnumType.STRING) + private ImageType imageType; + + // targetId 예시: missionRecord와 같이 이미지를 가지는 대상의 id + private Long targetId; + + @Column(length = 36) + private String imageKey; + + @Enumerated(EnumType.STRING) + private ImageFileExtension imageFileExtension; + + @Builder(access = AccessLevel.PRIVATE) + private Image( + Long id, + ImageType imageType, + Long targetId, + String imageKey, + ImageFileExtension imageFileExtension) { + this.id = id; + this.imageType = imageType; + this.targetId = targetId; + this.imageKey = imageKey; + this.imageFileExtension = imageFileExtension; + } + + public static Image createImage( + ImageType imageType, + Long targetId, + String imageKey, + ImageFileExtension imageFileExtension) { + return Image.builder() + .imageType(imageType) + .targetId(targetId) + .imageKey(imageKey) + .imageFileExtension(imageFileExtension) + .build(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/image/domain/ImageFileExtension.java b/src/main/java/com/depromeet/stonebed/domain/image/domain/ImageFileExtension.java new file mode 100644 index 00000000..cdc600e0 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/image/domain/ImageFileExtension.java @@ -0,0 +1,27 @@ +package com.depromeet.stonebed.domain.image.domain; + +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import java.util.Arrays; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ImageFileExtension { + JPEG("jpeg"), + JPG("jpg"), + PNG("png"), + ; + + private final String uploadExtension; + + public static ImageFileExtension of(String uploadExtension) { + return Arrays.stream(values()) + .filter( + imageFileExtension -> + imageFileExtension.uploadExtension.equals(uploadExtension)) + .findFirst() + .orElseThrow(() -> new CustomException(ErrorCode.IMAGE_FILE_EXTENSION_NOT_FOUND)); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/image/domain/ImageType.java b/src/main/java/com/depromeet/stonebed/domain/image/domain/ImageType.java new file mode 100644 index 00000000..34feb8cd --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/image/domain/ImageType.java @@ -0,0 +1,13 @@ +package com.depromeet.stonebed.domain.image.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ImageType { + MISSION_RECORD("mission_record"), + MEMBER_PROFILE("member_profile"), + ; + private final String value; +} diff --git a/src/main/java/com/depromeet/stonebed/domain/image/dto/request/MemberProfileImageCreateRequest.java b/src/main/java/com/depromeet/stonebed/domain/image/dto/request/MemberProfileImageCreateRequest.java new file mode 100644 index 00000000..d32abfeb --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/image/dto/request/MemberProfileImageCreateRequest.java @@ -0,0 +1,10 @@ +package com.depromeet.stonebed.domain.image.dto.request; + +import com.depromeet.stonebed.domain.image.domain.ImageFileExtension; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record MemberProfileImageCreateRequest( + @NotNull(message = "이미지 파일의 확장자는 비워둘 수 없습니다.") + @Schema(description = "이미지 파일의 확장자", defaultValue = "JPEG") + ImageFileExtension imageFileExtension) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/image/dto/request/MemberProfileImageUploadCompleteRequest.java b/src/main/java/com/depromeet/stonebed/domain/image/dto/request/MemberProfileImageUploadCompleteRequest.java new file mode 100644 index 00000000..b9809808 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/image/dto/request/MemberProfileImageUploadCompleteRequest.java @@ -0,0 +1,9 @@ +package com.depromeet.stonebed.domain.image.dto.request; + +import com.depromeet.stonebed.domain.image.domain.ImageFileExtension; +import io.swagger.v3.oas.annotations.media.Schema; + +public record MemberProfileImageUploadCompleteRequest( + @Schema(description = "이미지 파일의 확장자", defaultValue = "JPEG") + ImageFileExtension imageFileExtension, + @Schema(description = "닉네임", defaultValue = "수정닉네임") String nickname) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/image/dto/request/MissionRecordImageCreateRequest.java b/src/main/java/com/depromeet/stonebed/domain/image/dto/request/MissionRecordImageCreateRequest.java new file mode 100644 index 00000000..09dd1e83 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/image/dto/request/MissionRecordImageCreateRequest.java @@ -0,0 +1,11 @@ +package com.depromeet.stonebed.domain.image.dto.request; + +import com.depromeet.stonebed.domain.image.domain.ImageFileExtension; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record MissionRecordImageCreateRequest( + @NotNull(message = "이미지 파일의 확장자는 비워둘 수 없습니다.") + @Schema(description = "이미지 파일의 확장자", defaultValue = "JPEG") + ImageFileExtension imageFileExtension, + @NotNull @Schema(description = "미션 기록 아이디", defaultValue = "1") Long recordId) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/image/dto/request/MissionRecordImageUploadRequest.java b/src/main/java/com/depromeet/stonebed/domain/image/dto/request/MissionRecordImageUploadRequest.java new file mode 100644 index 00000000..6e17ec67 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/image/dto/request/MissionRecordImageUploadRequest.java @@ -0,0 +1,10 @@ +package com.depromeet.stonebed.domain.image.dto.request; + +import com.depromeet.stonebed.domain.image.domain.ImageFileExtension; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record MissionRecordImageUploadRequest( + @Schema(description = "이미지 파일의 확장자", defaultValue = "JPEG") + ImageFileExtension imageFileExtension, + @NotNull @Schema(description = "기록 아이디", defaultValue = "1") Long recordId) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/image/dto/response/PresignedUrlResponse.java b/src/main/java/com/depromeet/stonebed/domain/image/dto/response/PresignedUrlResponse.java new file mode 100644 index 00000000..e79ef155 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/image/dto/response/PresignedUrlResponse.java @@ -0,0 +1,9 @@ +package com.depromeet.stonebed.domain.image.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PresignedUrlResponse(@Schema(description = "Presigned URL") String presignedUrl) { + public static PresignedUrlResponse from(String presignedUrl) { + return new PresignedUrlResponse(presignedUrl); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/member/api/MemberController.java b/src/main/java/com/depromeet/stonebed/domain/member/api/MemberController.java new file mode 100644 index 00000000..217283fd --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/member/api/MemberController.java @@ -0,0 +1,25 @@ +package com.depromeet.stonebed.domain.member.api; + +import com.depromeet.stonebed.domain.member.application.MemberService; +import com.depromeet.stonebed.domain.member.domain.Member; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "1-2. [회원]", description = "회원 관련 API입니다.") +@RestController +@RequestMapping("/members") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + @Operation(summary = "내 정보 조회", description = "내 정보를 조회하는 API입니다.") + @GetMapping("/me") + public Member memberInfo() { + return memberService.findMemberInfo(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/member/application/MemberService.java b/src/main/java/com/depromeet/stonebed/domain/member/application/MemberService.java new file mode 100644 index 00000000..bc0bea1b --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/member/application/MemberService.java @@ -0,0 +1,47 @@ +package com.depromeet.stonebed.domain.member.application; + +import com.depromeet.stonebed.domain.auth.domain.OAuthProvider; +import com.depromeet.stonebed.domain.member.dao.MemberRepository; +import com.depromeet.stonebed.domain.member.domain.Member; +import com.depromeet.stonebed.domain.member.domain.MemberRole; +import com.depromeet.stonebed.domain.member.domain.Profile; +import com.depromeet.stonebed.domain.member.dto.request.CreateMemberRequest; +import com.depromeet.stonebed.global.util.MemberUtil; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MemberService { + + private final MemberRepository memberRepository; + private final MemberUtil memberUtil; + + @Transactional(readOnly = true) + public Member findMemberInfo() { + return memberUtil.getCurrentMember(); + } + + @Transactional(readOnly = true) + public Optional getMemberByOauthId(OAuthProvider oAuthProvider, String oauthId) { + return memberRepository.findByOauthInfoOauthProviderAndOauthInfoOauthId( + oAuthProvider.getValue(), oauthId); + } + + public Member registerMember(Member member, CreateMemberRequest request) { + member.updateProfile( + Profile.createProfile( + request.nickname(), member.getProfile().getProfileImageUrl())); + member.updateRaisePet(request.raisePet()); + member.updateMemberRole(MemberRole.USER); + return member; + } + + public Member socialSignUp(OAuthProvider oAuthProvider, String oauthId, String email) { + Member member = Member.createOAuthMember(oAuthProvider, oauthId, email); + return memberRepository.save(member); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/member/dao/MemberRepository.java b/src/main/java/com/depromeet/stonebed/domain/member/dao/MemberRepository.java new file mode 100644 index 00000000..d8f6fe16 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/member/dao/MemberRepository.java @@ -0,0 +1,10 @@ +package com.depromeet.stonebed.domain.member.dao; + +import com.depromeet.stonebed.domain.member.domain.Member; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + Optional findByOauthInfoOauthProviderAndOauthInfoOauthId( + String oauthProvider, String oauthId); +} diff --git a/src/main/java/com/depromeet/stonebed/domain/member/domain/Member.java b/src/main/java/com/depromeet/stonebed/domain/member/domain/Member.java new file mode 100644 index 00000000..7257eaf8 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/member/domain/Member.java @@ -0,0 +1,109 @@ +package com.depromeet.stonebed.domain.member.domain; + +import com.depromeet.stonebed.domain.auth.domain.OAuthProvider; +import com.depromeet.stonebed.domain.common.BaseTimeEntity; +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @Embedded private Profile profile = Profile.createProfile("", ""); + + @Embedded private OauthInfo oauthInfo; + + @Enumerated(EnumType.STRING) + private MemberStatus status; + + @Enumerated(EnumType.STRING) + private MemberRole role; + + @Enumerated(EnumType.STRING) + private RaisePet raisePet; + + private LocalDateTime lastLoginAt; + + @Builder(access = AccessLevel.PRIVATE) + public Member( + Profile profile, + OauthInfo oauthInfo, + MemberStatus status, + MemberRole role, + RaisePet raisePet) { + this.profile = profile; + this.oauthInfo = oauthInfo; + this.status = status; + this.role = role; + this.raisePet = raisePet; + } + + public static Member createMember( + Profile profile, + OauthInfo oauthInfo, + MemberStatus status, + MemberRole role, + RaisePet raisePet) { + return Member.builder() + .profile(profile) + .oauthInfo(oauthInfo) + .status(status) + .role(role) + .raisePet(raisePet) + .build(); + } + + public static Member createOAuthMember( + OAuthProvider oAuthProvider, String oauthId, String email) { + OauthInfo oauthInfo = OauthInfo.createOauthInfo(oauthId, oAuthProvider.getValue(), email); + + return Member.createMember( + Profile.createProfile("", ""), + oauthInfo, + MemberStatus.NORMAL, + MemberRole.TEMPORARY, + null); + } + + public void updateLastLoginAt() { + this.lastLoginAt = LocalDateTime.now(); + } + + public void updateRaisePet(RaisePet raisePet) { + this.raisePet = raisePet; + } + + public void updateProfile(Profile profile) { + this.profile = profile; + } + + public void updateMemberRole(MemberRole memberRole) { + this.role = memberRole; + } + + public void withdrawal() { + if (this.status == MemberStatus.DELETED) { + throw new CustomException(ErrorCode.MEMBER_ALREADY_DELETED); + } + this.status = MemberStatus.DELETED; + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/member/domain/MemberRole.java b/src/main/java/com/depromeet/stonebed/domain/member/domain/MemberRole.java new file mode 100644 index 00000000..e0c984dd --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/member/domain/MemberRole.java @@ -0,0 +1,15 @@ +package com.depromeet.stonebed.domain.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberRole { + USER("ROLE_USER"), + ADMIN("ROLE_ADMIN"), + TEMPORARY("ROLE_TEMPORARY"), + ; + + private final String value; +} diff --git a/src/main/java/com/depromeet/stonebed/domain/member/domain/MemberStatus.java b/src/main/java/com/depromeet/stonebed/domain/member/domain/MemberStatus.java new file mode 100644 index 00000000..4a8f2078 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/member/domain/MemberStatus.java @@ -0,0 +1,14 @@ +package com.depromeet.stonebed.domain.member.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MemberStatus { + NORMAL("NORMAL"), + DELETED("DELETED"), + FORBIDDEN("FORBIDDEN"); + + private final String value; +} diff --git a/src/main/java/com/depromeet/stonebed/domain/member/domain/OauthInfo.java b/src/main/java/com/depromeet/stonebed/domain/member/domain/OauthInfo.java new file mode 100644 index 00000000..66987d37 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/member/domain/OauthInfo.java @@ -0,0 +1,39 @@ +package com.depromeet.stonebed.domain.member.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OauthInfo { + + @Schema(description = "소셜 ID", example = "123487892") + private String oauthId; + + @Schema(description = "소셜 제공자", example = "APPLE") + private String oauthProvider; + + @Schema(description = "소셜 이메일", example = "test@gmail.com") + private String oauthEmail; + + @Builder(access = AccessLevel.PRIVATE) + private OauthInfo(String oauthId, String oauthProvider, String oauthEmail) { + this.oauthId = oauthId; + this.oauthProvider = oauthProvider; + this.oauthEmail = oauthEmail; + } + + public static OauthInfo createOauthInfo( + String oauthId, String oauthProvider, String oauthEmail) { + return OauthInfo.builder() + .oauthId(oauthId) + .oauthProvider(oauthProvider) + .oauthEmail(oauthEmail) + .build(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/member/domain/Profile.java b/src/main/java/com/depromeet/stonebed/domain/member/domain/Profile.java new file mode 100644 index 00000000..637aaf81 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/member/domain/Profile.java @@ -0,0 +1,30 @@ +package com.depromeet.stonebed.domain.member.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Profile { + + @Schema(description = "닉네임", example = "왈왈멍") + private String nickname; + + @Schema(description = "프로필 이미지 URL", example = "./profile.jpg") + private String profileImageUrl; + + @Builder(access = AccessLevel.PRIVATE) + private Profile(String nickname, String profileImageUrl) { + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + } + + public static Profile createProfile(String nickname, String profileImageUrl) { + return Profile.builder().nickname(nickname).profileImageUrl(profileImageUrl).build(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/member/domain/RaisePet.java b/src/main/java/com/depromeet/stonebed/domain/member/domain/RaisePet.java new file mode 100644 index 00000000..4562912a --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/member/domain/RaisePet.java @@ -0,0 +1,14 @@ +package com.depromeet.stonebed.domain.member.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum RaisePet { + DOG("DOG"), + CAT("CAT"), + ; + + private final String value; +} diff --git a/src/main/java/com/depromeet/stonebed/domain/member/dto/request/CreateMemberRequest.java b/src/main/java/com/depromeet/stonebed/domain/member/dto/request/CreateMemberRequest.java new file mode 100644 index 00000000..6e3244fb --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/member/dto/request/CreateMemberRequest.java @@ -0,0 +1,8 @@ +package com.depromeet.stonebed.domain.member.dto.request; + +import com.depromeet.stonebed.domain.member.domain.RaisePet; +import io.swagger.v3.oas.annotations.media.Schema; + +public record CreateMemberRequest( + @Schema(description = "닉네임", example = "왈왈멍") String nickname, + @Schema(description = "반려동물", example = "DOG") RaisePet raisePet) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/api/MissionController.java b/src/main/java/com/depromeet/stonebed/domain/mission/api/MissionController.java new file mode 100644 index 00000000..46605ebe --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/api/MissionController.java @@ -0,0 +1,49 @@ +package com.depromeet.stonebed.domain.mission.api; + +import com.depromeet.stonebed.domain.mission.application.MissionService; +import com.depromeet.stonebed.domain.mission.dto.request.MissionCreateRequest; +import com.depromeet.stonebed.domain.mission.dto.request.MissionUpdateRequest; +import com.depromeet.stonebed.domain.mission.dto.response.MissionCreateResponse; +import com.depromeet.stonebed.domain.mission.dto.response.MissionGetOneResponse; +import com.depromeet.stonebed.domain.mission.dto.response.MissionGetTodayResponse; +import com.depromeet.stonebed.domain.mission.dto.response.MissionUpdateResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "2. 미션", description = "미션 관련 API입니다.") +@RestController +@RequestMapping("/missions") +@RequiredArgsConstructor +public class MissionController { + private final MissionService missionService; + + @PostMapping + public MissionCreateResponse createMission( + @Valid @RequestBody MissionCreateRequest missionCreateRequest) { + return missionService.createMission(missionCreateRequest); + } + + @GetMapping("/today") + public MissionGetTodayResponse getTodayMission() { + return missionService.getOrCreateTodayMission(); + } + + @GetMapping("/{missionId}") + public MissionGetOneResponse getMission(@PathVariable Long missionId) { + return missionService.getMission(missionId); + } + + @PatchMapping("/{missionId}") + public MissionUpdateResponse updateMission( + @PathVariable Long missionId, + @Valid @RequestBody MissionUpdateRequest missionUpdateRequest) { + return missionService.updateMission(missionId, missionUpdateRequest); + } + + @DeleteMapping("/{missionId}") + public void deleteMission(@PathVariable Long missionId) { + missionService.deleteMission(missionId); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/application/MissionService.java b/src/main/java/com/depromeet/stonebed/domain/mission/application/MissionService.java new file mode 100644 index 00000000..eab53356 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/application/MissionService.java @@ -0,0 +1,95 @@ +package com.depromeet.stonebed.domain.mission.application; + +import com.depromeet.stonebed.domain.mission.dao.MissionHistoryRepository; +import com.depromeet.stonebed.domain.mission.dao.MissionRepository; +import com.depromeet.stonebed.domain.mission.domain.Mission; +import com.depromeet.stonebed.domain.mission.domain.MissionHistory; +import com.depromeet.stonebed.domain.mission.dto.request.MissionCreateRequest; +import com.depromeet.stonebed.domain.mission.dto.request.MissionUpdateRequest; +import com.depromeet.stonebed.domain.mission.dto.response.MissionCreateResponse; +import com.depromeet.stonebed.domain.mission.dto.response.MissionGetOneResponse; +import com.depromeet.stonebed.domain.mission.dto.response.MissionGetTodayResponse; +import com.depromeet.stonebed.domain.mission.dto.response.MissionUpdateResponse; +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import java.security.SecureRandom; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class MissionService { + private final MissionRepository missionRepository; + private final MissionHistoryRepository missionHistoryRepository; + private final SecureRandom secureRandom = new SecureRandom(); + + public MissionCreateResponse createMission(MissionCreateRequest missionCreateRequest) { + Mission mission = Mission.builder().title(missionCreateRequest.title()).build(); + + mission = missionRepository.save(mission); + return MissionCreateResponse.from(mission); + } + + @Transactional(readOnly = true) + public MissionGetOneResponse getMission(Long missionId) { + return missionRepository + .findById(missionId) + .map(MissionGetOneResponse::from) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_NOT_FOUND)); + } + + @Transactional + public MissionGetTodayResponse getOrCreateTodayMission() { + LocalDate today = LocalDate.now(); + LocalDate threeDaysAgo = today.minusDays(3); + + Optional optionalMissionHistory = + missionHistoryRepository.findByAssignedDate(today); + + if (optionalMissionHistory.isPresent()) { + return MissionGetTodayResponse.from(optionalMissionHistory.get().getMission()); + } + + // 최근 3일 이전의 미션들 불러오기 + List recentMissions = missionRepository.findMissionsAssignedBefore(threeDaysAgo); + + // 최근 3일 이내의 미션은 제외하고 불러오기 + List availableMissions = missionRepository.findNotInMissions(recentMissions); + + if (availableMissions.isEmpty()) { + throw new CustomException(ErrorCode.NO_AVAILABLE_TODAY_MISSION); + } + + Mission selectedMission = + availableMissions.get(secureRandom.nextInt(availableMissions.size())); + + MissionHistory newMissionHistory = + MissionHistory.builder().mission(selectedMission).assignedDate(today).build(); + + missionHistoryRepository.save(newMissionHistory); + + return MissionGetTodayResponse.from(selectedMission); + } + + public MissionUpdateResponse updateMission( + Long missionId, MissionUpdateRequest missionUpdateRequest) { + Mission missionToUpdate = + missionRepository + .findById(missionId) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_NOT_FOUND)); + + missionToUpdate.updateTitle(missionUpdateRequest.title()); + missionRepository.save(missionToUpdate); + + return MissionUpdateResponse.from(missionToUpdate); + } + + public void deleteMission(Long missionId) { + missionRepository.deleteById(missionId); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepository.java b/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepository.java new file mode 100644 index 00000000..43294b01 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepository.java @@ -0,0 +1,14 @@ +package com.depromeet.stonebed.domain.mission.dao; + +import com.depromeet.stonebed.domain.mission.domain.MissionHistory; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MissionHistoryRepository + extends JpaRepository, MissionHistoryRepositoryCustom { + Optional findByAssignedDate(LocalDate date); + + List findByAssignedDateBefore(LocalDate date); +} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepositoryCustom.java b/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepositoryCustom.java new file mode 100644 index 00000000..15fd7159 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepositoryCustom.java @@ -0,0 +1,8 @@ +package com.depromeet.stonebed.domain.mission.dao; + +import com.depromeet.stonebed.domain.mission.domain.MissionHistory; +import java.util.Optional; + +public interface MissionHistoryRepositoryCustom { + Optional findLatestOneByMissionId(Long missionId); +} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepositoryImpl.java b/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepositoryImpl.java new file mode 100644 index 00000000..cec36cd7 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.depromeet.stonebed.domain.mission.dao; + +import static com.depromeet.stonebed.domain.mission.domain.QMissionHistory.missionHistory; + +import com.depromeet.stonebed.domain.mission.domain.MissionHistory; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MissionHistoryRepositoryImpl implements MissionHistoryRepositoryCustom { + private final JPAQueryFactory queryFactory; + + @Override + public Optional findLatestOneByMissionId(Long missionId) { + return Optional.ofNullable( + queryFactory + .selectFrom(missionHistory) + .where(missionHistory.mission.id.eq(missionId)) + .orderBy(missionHistory.assignedDate.desc()) + .fetchFirst()); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionRepository.java b/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionRepository.java new file mode 100644 index 00000000..628205b5 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionRepository.java @@ -0,0 +1,6 @@ +package com.depromeet.stonebed.domain.mission.dao; + +import com.depromeet.stonebed.domain.mission.domain.Mission; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MissionRepository extends JpaRepository, MissionRepositoryCustom {} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryCustom.java b/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryCustom.java new file mode 100644 index 00000000..7efefec2 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryCustom.java @@ -0,0 +1,11 @@ +package com.depromeet.stonebed.domain.mission.dao; + +import com.depromeet.stonebed.domain.mission.domain.Mission; +import java.time.LocalDate; +import java.util.List; + +public interface MissionRepositoryCustom { + List findNotInMissions(List missions); + + List findMissionsAssignedBefore(LocalDate assignedDate); +} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryImpl.java b/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryImpl.java new file mode 100644 index 00000000..d2f08d74 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.depromeet.stonebed.domain.mission.dao; + +import static com.depromeet.stonebed.domain.mission.domain.QMission.mission; +import static com.depromeet.stonebed.domain.mission.domain.QMissionHistory.missionHistory; + +import com.depromeet.stonebed.domain.mission.domain.Mission; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MissionRepositoryImpl implements MissionRepositoryCustom { + private final JPAQueryFactory queryFactory; + + @Override + public List findNotInMissions(List missions) { + return queryFactory.selectFrom(mission).where(mission.notIn(missions)).fetch(); + } + + @Override + public List findMissionsAssignedBefore(LocalDate assignedDate) { + return queryFactory + .select(missionHistory.mission) + .from(missionHistory) + .where(missionHistory.assignedDate.before(assignedDate)) + .fetch(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/domain/Mission.java b/src/main/java/com/depromeet/stonebed/domain/mission/domain/Mission.java new file mode 100644 index 00000000..5d262842 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/domain/Mission.java @@ -0,0 +1,41 @@ +package com.depromeet.stonebed.domain.mission.domain; + +import com.depromeet.stonebed.domain.common.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Mission extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "mission_id") + private Long id; + + @NotBlank + @Size(max = 100) + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Column(name = "illustration_url") + private String illustrationUrl; + + @Column(name = "hex_color") + private String hexColor; + + @Builder + public Mission(String title) { + this.title = title; + } + + public void updateTitle(String title) { + this.title = title; + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/domain/MissionHistory.java b/src/main/java/com/depromeet/stonebed/domain/mission/domain/MissionHistory.java new file mode 100644 index 00000000..fe20b3b9 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/domain/MissionHistory.java @@ -0,0 +1,32 @@ +package com.depromeet.stonebed.domain.mission.domain; + +import com.depromeet.stonebed.domain.common.BaseTimeEntity; +import jakarta.persistence.*; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MissionHistory extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "mission_history_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "mission_id", nullable = false) + private Mission mission; + + @Column(name = "assigned_date", nullable = false, unique = true) + private LocalDate assignedDate; + + @Builder + public MissionHistory(Mission mission, LocalDate assignedDate) { + this.mission = mission; + this.assignedDate = assignedDate; + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/dto/request/MissionCreateRequest.java b/src/main/java/com/depromeet/stonebed/domain/mission/dto/request/MissionCreateRequest.java new file mode 100644 index 00000000..4b04ccb5 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/dto/request/MissionCreateRequest.java @@ -0,0 +1,9 @@ +package com.depromeet.stonebed.domain.mission.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record MissionCreateRequest( + @Schema(description = "미션 제목", example = "산책하기") + @NotBlank(message = "Title cannot be blank") + String title) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/dto/request/MissionUpdateRequest.java b/src/main/java/com/depromeet/stonebed/domain/mission/dto/request/MissionUpdateRequest.java new file mode 100644 index 00000000..8ff656b0 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/dto/request/MissionUpdateRequest.java @@ -0,0 +1,9 @@ +package com.depromeet.stonebed.domain.mission.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record MissionUpdateRequest( + @Schema(description = "미션 제목", example = "산책하기") + @NotBlank(message = "Title cannot be blank") + String title) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionCreateResponse.java b/src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionCreateResponse.java new file mode 100644 index 00000000..8f820561 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionCreateResponse.java @@ -0,0 +1,15 @@ +package com.depromeet.stonebed.domain.mission.dto.response; + +import com.depromeet.stonebed.domain.mission.domain.Mission; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record MissionCreateResponse( + @Schema(description = "미션 ID", example = "1") @NotBlank Long id, + @Schema(description = "미션 제목", example = "산책하기") + @NotBlank(message = "Title cannot be blank") + String title) { + public static MissionCreateResponse from(Mission mission) { + return new MissionCreateResponse(mission.getId(), mission.getTitle()); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionGetOneResponse.java b/src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionGetOneResponse.java new file mode 100644 index 00000000..16814ed0 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionGetOneResponse.java @@ -0,0 +1,15 @@ +package com.depromeet.stonebed.domain.mission.dto.response; + +import com.depromeet.stonebed.domain.mission.domain.Mission; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record MissionGetOneResponse( + @Schema(description = "미션 ID", example = "1") @NotBlank Long id, + @Schema(description = "미션 제목", example = "산책하기") + @NotBlank(message = "Title cannot be blank") + String title) { + public static MissionGetOneResponse from(Mission mission) { + return new MissionGetOneResponse(mission.getId(), mission.getTitle()); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionGetTodayResponse.java b/src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionGetTodayResponse.java new file mode 100644 index 00000000..fda07298 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionGetTodayResponse.java @@ -0,0 +1,23 @@ +package com.depromeet.stonebed.domain.mission.dto.response; + +import com.depromeet.stonebed.domain.mission.domain.Mission; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record MissionGetTodayResponse( + @Schema(description = "미션 ID", example = "1") @NotBlank Long id, + @Schema(description = "미션 제목", example = "산책하기") + @NotBlank(message = "Title cannot be blank") + String title, + @Schema(description = "일러스트 이미지 URL", example = "https://example.com/image.jpeg") + String illustrationUrl, + @Schema(description = "Hex 색상 값", example = "#FFFFFF") String hexColor) { + + public static MissionGetTodayResponse from(Mission mission) { + return new MissionGetTodayResponse( + mission.getId(), + mission.getTitle(), + mission.getIllustrationUrl(), + mission.getHexColor()); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionUpdateResponse.java b/src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionUpdateResponse.java new file mode 100644 index 00000000..47d9f280 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/mission/dto/response/MissionUpdateResponse.java @@ -0,0 +1,15 @@ +package com.depromeet.stonebed.domain.mission.dto.response; + +import com.depromeet.stonebed.domain.mission.domain.Mission; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record MissionUpdateResponse( + @Schema(description = "미션 ID", example = "1") @NotBlank Long id, + @Schema(description = "미션 제목", example = "산책하기") + @NotBlank(message = "Title cannot be blank") + String title) { + public static MissionUpdateResponse from(Mission mission) { + return new MissionUpdateResponse(mission.getId(), mission.getTitle()); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/api/MissionRecordController.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/api/MissionRecordController.java new file mode 100644 index 00000000..199b084f --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/api/MissionRecordController.java @@ -0,0 +1,60 @@ +package com.depromeet.stonebed.domain.missionRecord.api; + +import com.depromeet.stonebed.domain.missionRecord.application.MissionRecordService; +import com.depromeet.stonebed.domain.missionRecord.dto.request.MissionRecordCalendarRequest; +import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionRecordCalendarResponse; +import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionTabResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "3. 미션 기록", description = "미션 기록 관련 API입니다.") +@RestController +@RequestMapping("/records") +@RequiredArgsConstructor +public class MissionRecordController { + + private final MissionRecordService missionRecordService; + + @Operation(summary = "미션 탭 상태 조회", description = "미션 탭의 상태를 조회한다.") + @GetMapping("/{missionId}/status") + public MissionTabResponse getMissionTabStatus(@PathVariable("missionId") Long missionId) { + return missionRecordService.getMissionTabStatus(missionId); + } + + @Operation(summary = "미션 참여", description = "미션 참여하기.") + @PostMapping("/{missionId}/start") + public ResponseEntity startMission(@PathVariable("missionId") Long missionId) { + missionRecordService.startMission(missionId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "미션 기록 저장", description = "미션 완료 후 기록을 저장한다.") + @PostMapping + public ResponseEntity saveMission(@PathVariable("missionId") Long missionId) { + missionRecordService.saveMission(missionId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "미션 기록 삭제", description = "미션 기록을 삭제한다.") + @DeleteMapping("/{recordId}") + public void deleteMissionRecord(@PathVariable Long recordId) { + missionRecordService.deleteMissionRecord(recordId); + } + + @Operation(summary = "캘린더 형식의 미션 기록 조회", description = "회원의 미션 기록을 페이징하여 조회한다.") + @PostMapping("/calendar") + public MissionRecordCalendarResponse getMissionRecordsForCalendar( + @Valid @RequestBody MissionRecordCalendarRequest request) { + return missionRecordService.getMissionRecordsForCalendar(request.cursor(), request.limit()); + } + + @Operation(summary = "수행한 총 미션 기록 수", description = "회원이 수행한 총 미션 기록 수를 조회한다.") + @PostMapping("/complete/total") + public Long getTotalMissionRecords() { + return missionRecordService.getTotalMissionRecords(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/application/MissionRecordService.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/application/MissionRecordService.java new file mode 100644 index 00000000..1a06d1e8 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/application/MissionRecordService.java @@ -0,0 +1,174 @@ +package com.depromeet.stonebed.domain.missionRecord.application; + +import com.depromeet.stonebed.domain.image.dao.ImageRepository; +import com.depromeet.stonebed.domain.member.domain.Member; +import com.depromeet.stonebed.domain.mission.dao.MissionHistoryRepository; +import com.depromeet.stonebed.domain.mission.domain.MissionHistory; +import com.depromeet.stonebed.domain.missionRecord.dao.MissionRecordRepository; +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord; +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecordStatus; +import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionRecordCalendarDto; +import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionRecordCalendarResponse; +import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionTabResponse; +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import com.depromeet.stonebed.global.util.MemberUtil; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MissionRecordService { + + private final MissionRecordRepository missionRecordRepository; + private final MissionHistoryRepository missionHistoryRepository; + private final ImageRepository imageRepository; + private final MemberUtil memberUtil; + + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + public void startMission(Long missionId) { + final Member member = memberUtil.getCurrentMember(); + + MissionHistory missionHistory = findMissionHistoryById(missionId); + + MissionRecord missionRecord = + missionRecordRepository + .findByMemberAndMissionHistory(member, missionHistory) + .orElseGet( + () -> + MissionRecord.builder() + .member(member) + .missionHistory(missionHistory) + .status(MissionRecordStatus.IN_PROGRESS) + .build()); + + missionRecordRepository.save(missionRecord); + } + + public void saveMission(Long missionId) { + final Member member = memberUtil.getCurrentMember(); + + MissionHistory missionHistory = findMissionHistoryById(missionId); + + MissionRecord missionRecord = + missionRecordRepository + .findByMemberAndMissionHistory(member, missionHistory) + .orElseGet( + () -> + MissionRecord.builder() + .member(member) + .missionHistory(missionHistory) + .status(MissionRecordStatus.COMPLETED) + .build()); + + missionRecordRepository.save(missionRecord); + } + + public void deleteMissionRecord(Long recordId) { + MissionRecord missionRecord = + missionRecordRepository + .findById(recordId) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_RECORD_NOT_FOUND)); + + missionRecordRepository.delete(missionRecord); + } + + private MissionHistory findMissionHistoryById(Long missionId) { + return missionHistoryRepository + .findLatestOneByMissionId(missionId) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_HISTORY_NOT_FOUNT)); + } + + @Transactional(readOnly = true) + public MissionRecordCalendarResponse getMissionRecordsForCalendar(String cursor, int limit) { + final Member member = memberUtil.getCurrentMember(); + Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.ASC, "createdAt")); + + List records = getMissionRecords(cursor, member, pageable); + + List calendarData = + records.stream() + .map(record -> MissionRecordCalendarDto.from(record, DATE_FORMATTER)) + .toList(); + + String nextCursor = getNextCursor(records); + + return MissionRecordCalendarResponse.from(calendarData, nextCursor); + } + + private List getMissionRecords(String cursor, Member member, Pageable pageable) { + if (cursor == null) { + return missionRecordRepository.findByMemberIdWithPagination(member.getId(), pageable); + } + + try { + LocalDateTime cursorDate = LocalDate.parse(cursor, DATE_FORMATTER).atStartOfDay(); + return missionRecordRepository.findByMemberIdAndCreatedAtFromWithPagination( + member.getId(), cursorDate, pageable); + } catch (DateTimeParseException e) { + throw new CustomException(ErrorCode.INVALID_CURSOR_DATE_FORMAT); + } + } + + private String getNextCursor(List records) { + if (records.isEmpty()) { + return null; + } + + MissionRecord lastRecord = records.get(records.size() - 1); + LocalDate nextCursorDate = lastRecord.getCreatedAt().toLocalDate().plusDays(1); + return nextCursorDate.format(DATE_FORMATTER); + } + + @Transactional(readOnly = true) + public MissionTabResponse getMissionTabStatus(Long missionId) { + final Member member = memberUtil.getCurrentMember(); + + MissionHistory missionHistory = findMissionHistoryById(missionId); + + MissionRecord missionRecord = + missionRecordRepository + .findByMemberAndMissionHistory(member, missionHistory) + .orElse(null); + + if (missionRecord == null) { + return new MissionTabResponse(null, null, MissionRecordStatus.NOT_COMPLETED); + } + + MissionRecordStatus missionRecordStatus = missionRecord.getStatus(); + String imageUrl = + missionRecordStatus == MissionRecordStatus.COMPLETED + ? missionRecord.getImageUrl() + : null; + + return new MissionTabResponse(missionRecord.getId(), imageUrl, missionRecordStatus); + } + + @Transactional + public void updateMissionRecordWithImage(Long recordId, String imageUrl) { + MissionRecord missionRecord = + missionRecordRepository + .findById(recordId) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_RECORD_NOT_FOUND)); + + missionRecord.updateImageUrl(imageUrl); + } + + public Long getTotalMissionRecords() { + final Member member = memberUtil.getCurrentMember(); + return missionRecordRepository.countByMemberIdAndStatus( + member.getId(), MissionRecordStatus.COMPLETED); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepository.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepository.java new file mode 100644 index 00000000..8661cd33 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepository.java @@ -0,0 +1,16 @@ +package com.depromeet.stonebed.domain.missionRecord.dao; + +import com.depromeet.stonebed.domain.member.domain.Member; +import com.depromeet.stonebed.domain.mission.domain.MissionHistory; +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord; +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecordStatus; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MissionRecordRepository + extends JpaRepository, MissionRecordRepositoryCustom { + Optional findByMemberAndMissionHistory( + Member member, MissionHistory missionHistory); + + Long countByMemberIdAndStatus(Long memberId, MissionRecordStatus status); +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryCustom.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryCustom.java new file mode 100644 index 00000000..ee8b2b36 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryCustom.java @@ -0,0 +1,13 @@ +package com.depromeet.stonebed.domain.missionRecord.dao; + +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.domain.Pageable; + +public interface MissionRecordRepositoryCustom { + List findByMemberIdWithPagination(Long memberId, Pageable pageable); + + List findByMemberIdAndCreatedAtFromWithPagination( + Long memberId, LocalDateTime createdAt, Pageable pageable); +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryImpl.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryImpl.java new file mode 100644 index 00000000..eaf3e6aa --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dao/MissionRecordRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.depromeet.stonebed.domain.missionRecord.dao; + +import static com.depromeet.stonebed.domain.missionRecord.domain.QMissionRecord.missionRecord; + +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MissionRecordRepositoryImpl implements MissionRecordRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findByMemberIdWithPagination(Long memberId, Pageable pageable) { + return queryFactory + .selectFrom(missionRecord) + .where(isMemberId(memberId)) + .orderBy(missionRecord.createdAt.asc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + + @Override + public List findByMemberIdAndCreatedAtFromWithPagination( + Long memberId, LocalDateTime createdAt, Pageable pageable) { + return queryFactory + .selectFrom(missionRecord) + .where(missionRecord.member.id.eq(memberId).and(createdAtFrom(createdAt))) + .orderBy(missionRecord.createdAt.asc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + + private BooleanExpression isMemberId(Long memberId) { + return missionRecord.member.id.eq(memberId); + } + + private BooleanExpression createdAtFrom(LocalDateTime createdAt) { + return missionRecord.createdAt.goe(createdAt); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/domain/MissionRecord.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/domain/MissionRecord.java new file mode 100644 index 00000000..f4303abf --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/domain/MissionRecord.java @@ -0,0 +1,61 @@ +package com.depromeet.stonebed.domain.missionRecord.domain; + +import com.depromeet.stonebed.domain.common.BaseTimeEntity; +import com.depromeet.stonebed.domain.member.domain.Member; +import com.depromeet.stonebed.domain.mission.domain.MissionHistory; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "mission_record", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_member_mission_history", + columnNames = {"member_id", "mission_history_id"}) + }) +public class MissionRecord extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "record_id") + private Long id; + + @ManyToOne + @JoinColumn(name = "mission_history_id", nullable = false) + private MissionHistory missionHistory; + + @ManyToOne + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Schema(description = "미션 이미지 URL", example = "./missionRecord.jpg") + @Column(name = "image_url") + private String imageUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private MissionRecordStatus status; + + @Builder + public MissionRecord( + Member member, + MissionHistory missionHistory, + String imageUrl, + MissionRecordStatus status) { + this.member = member; + this.missionHistory = missionHistory; + this.imageUrl = imageUrl; + this.status = status; + } + + public void updateImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/domain/MissionRecordStatus.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/domain/MissionRecordStatus.java new file mode 100644 index 00000000..6fb3d6f1 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/domain/MissionRecordStatus.java @@ -0,0 +1,13 @@ +package com.depromeet.stonebed.domain.missionRecord.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MissionRecordStatus { + NOT_COMPLETED("NOT_COMPLETED"), + IN_PROGRESS("IN_PROGRESS"), + COMPLETED("COMPLETED"); + private final String value; +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/request/MissionRecordCalendarRequest.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/request/MissionRecordCalendarRequest.java new file mode 100644 index 00000000..dd267c42 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/request/MissionRecordCalendarRequest.java @@ -0,0 +1,9 @@ +package com.depromeet.stonebed.domain.missionRecord.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record MissionRecordCalendarRequest( + @Schema(description = "커서 위치", example = "2024-01-01") String cursor, + @NotNull @Min(1) @Schema(description = "페이지 당 항목 수", example = "30") int limit) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarDto.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarDto.java new file mode 100644 index 00000000..75da124a --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarDto.java @@ -0,0 +1,18 @@ +package com.depromeet.stonebed.domain.missionRecord.dto.response; + +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.format.DateTimeFormatter; + +public record MissionRecordCalendarDto( + @Schema(description = "이미지 ID") Long imageId, + @Schema(description = "이미지 URL") String imageUrl, + @Schema(description = "미션 수행 일자") String missionDate) { + + public static MissionRecordCalendarDto from( + MissionRecord missionRecord, DateTimeFormatter formatter) { + String formattedDate = missionRecord.getCreatedAt().format(formatter); + return new MissionRecordCalendarDto( + missionRecord.getId(), missionRecord.getImageUrl(), formattedDate); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarResponse.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarResponse.java new file mode 100644 index 00000000..cc2da93c --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionRecordCalendarResponse.java @@ -0,0 +1,18 @@ +package com.depromeet.stonebed.domain.missionRecord.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record MissionRecordCalendarResponse( + @Schema( + description = "미션 기록 데이터 리스트", + example = + "[{ 'imageId': 1, 'imageUrl': 'http://example.com/image1.jpg', 'missionDate': '2024-01-01' }]") + List list, + @Schema(description = "커서 위치", example = "2024-01-03") String nextCursor) { + + public static MissionRecordCalendarResponse from( + List list, String nextCursor) { + return new MissionRecordCalendarResponse(list, nextCursor); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionTabResponse.java b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionTabResponse.java new file mode 100644 index 00000000..18d3995c --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/missionRecord/dto/response/MissionTabResponse.java @@ -0,0 +1,15 @@ +package com.depromeet.stonebed.domain.missionRecord.dto.response; + +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecordStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +public record MissionTabResponse( + @Schema(description = "미션기록 ID", example = "1") Long recordId, + @Schema(description = "이미지 URL", example = "example.jpeg") String imageUrl, + @Schema(description = "미션 상태", example = "NOT_COMPLETED") MissionRecordStatus status) { + + public static MissionTabResponse from( + Long recordId, String imageUrl, MissionRecordStatus status) { + return new MissionTabResponse(recordId, imageUrl, status); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/annotation/ConditionalOnProfile.java b/src/main/java/com/depromeet/stonebed/global/annotation/ConditionalOnProfile.java new file mode 100644 index 00000000..da3daba9 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/annotation/ConditionalOnProfile.java @@ -0,0 +1,15 @@ +package com.depromeet.stonebed.global.annotation; + +import com.depromeet.stonebed.global.common.constants.EnvironmentConstants; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.context.annotation.Conditional; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Conditional({OnProfileCondition.class}) +public @interface ConditionalOnProfile { + EnvironmentConstants[] value() default {EnvironmentConstants.LOCAL}; +} diff --git a/src/main/java/com/depromeet/stonebed/global/annotation/OnProfileCondition.java b/src/main/java/com/depromeet/stonebed/global/annotation/OnProfileCondition.java new file mode 100644 index 00000000..35d4e5bc --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/annotation/OnProfileCondition.java @@ -0,0 +1,32 @@ +package com.depromeet.stonebed.global.annotation; + +import static java.util.Objects.*; + +import com.depromeet.stonebed.global.common.constants.EnvironmentConstants; +import java.util.Arrays; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +public class OnProfileCondition implements Condition { + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + String[] activeProfiles = context.getEnvironment().getActiveProfiles(); + EnvironmentConstants[] targetProfiles = getTargetProfiles(metadata); + + return Arrays.stream(targetProfiles) + .anyMatch( + targetProfile -> + Arrays.asList(activeProfiles).contains(targetProfile.getValue())); + } + + private EnvironmentConstants[] getTargetProfiles(AnnotatedTypeMetadata metadata) { + return (EnvironmentConstants[]) + requireNonNull( + metadata.getAnnotationAttributes( + ConditionalOnProfile.class.getName()), + "target Profiles가 null입니다.") + .get("value"); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/common/constants/EnvironmentConstants.java b/src/main/java/com/depromeet/stonebed/global/common/constants/EnvironmentConstants.java new file mode 100644 index 00000000..f1747b1d --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/common/constants/EnvironmentConstants.java @@ -0,0 +1,27 @@ +package com.depromeet.stonebed.global.common.constants; + +import static com.depromeet.stonebed.global.common.constants.EnvironmentConstants.Constants.*; + +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +public enum EnvironmentConstants { + PROD(PROD_ENV), + DEV(DEV_ENV), + LOCAL(LOCAL_ENV); + + private final String value; + + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class Constants { + public static final String PROD_ENV = "prod"; + public static final String DEV_ENV = "dev"; + public static final String LOCAL_ENV = "local"; + public static final List PROD_AND_DEV_ENV = List.of(PROD_ENV, DEV_ENV); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/common/constants/SecurityConstants.java b/src/main/java/com/depromeet/stonebed/global/common/constants/SecurityConstants.java new file mode 100644 index 00000000..590e3384 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/common/constants/SecurityConstants.java @@ -0,0 +1,22 @@ +package com.depromeet.stonebed.global.common.constants; + +public final class SecurityConstants { + + // apple + public static final String APPLE_JWK_SET_URL = "https://appleid.apple.com/auth/keys"; + public static final String APPLE_ISSUER = "https://appleid.apple.com"; + public static final String APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token"; + public static final String APPLE_GRANT_TYPE = "authorization_code"; + + // kakao + public static final String KAKAO_USER_ME_URL = "https://kapi.kakao.com/v2/user/me"; + + // security + public static final String TOKEN_ROLE_NAME = "role"; + public static final String TOKEN_PREFIX = "Bearer "; + public static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; + public static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + public static final String APPLICATION_URLENCODED = "application/x-www-form-urlencoded"; + + private SecurityConstants() {} +} diff --git a/src/main/java/com/depromeet/stonebed/global/common/constants/SwaggerUrlConstants.java b/src/main/java/com/depromeet/stonebed/global/common/constants/SwaggerUrlConstants.java new file mode 100644 index 00000000..e39b6a2c --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/common/constants/SwaggerUrlConstants.java @@ -0,0 +1,22 @@ +package com.depromeet.stonebed.global.common.constants; + +import java.util.Arrays; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum SwaggerUrlConstants { + SWAGGER_RESOURCES_URL("/swagger-resources/**"), + SWAGGER_UI_URL("/swagger-ui/**"), + SWAGGER_API_DOCS_URL("/v3/api-docs/**"), + ; + + private final String value; + + public static String[] getSwaggerUrls() { + return Arrays.stream(SwaggerUrlConstants.values()) + .map(SwaggerUrlConstants::getValue) + .toArray(String[]::new); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/common/constants/UrlConstants.java b/src/main/java/com/depromeet/stonebed/global/common/constants/UrlConstants.java new file mode 100644 index 00000000..c7263080 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/common/constants/UrlConstants.java @@ -0,0 +1,16 @@ +package com.depromeet.stonebed.global.common.constants; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UrlConstants { + PROD_SERVER_URL("https://api.walwal.life"), + DEV_SERVER_URL("https://dev-api.walwal.life"), + LOCAL_SERVER_URL("http://localhost:8080"), + IMAGE_DOMAIN_URL("https://image.walwal.life"), + ; + + private final String value; +} diff --git a/src/main/java/com/depromeet/stonebed/global/common/response/ApiResponse.java b/src/main/java/com/depromeet/stonebed/global/common/response/ApiResponse.java new file mode 100644 index 00000000..1a852a9a --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/common/response/ApiResponse.java @@ -0,0 +1,14 @@ +package com.depromeet.stonebed.global.common.response; + +import com.depromeet.stonebed.global.error.ErrorResponse; + +public record ApiResponse(boolean success, int status, Object data) { + + public static ApiResponse success(int status, Object data) { + return new ApiResponse(true, status, data); + } + + public static ApiResponse fail(int status, ErrorResponse errorResponse) { + return new ApiResponse(false, status, errorResponse); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/common/response/ApiResponseAdvice.java b/src/main/java/com/depromeet/stonebed/global/common/response/ApiResponseAdvice.java new file mode 100644 index 00000000..875ab36d --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/common/response/ApiResponseAdvice.java @@ -0,0 +1,68 @@ +package com.depromeet.stonebed.global.common.response; + +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +@RequiredArgsConstructor +@RestControllerAdvice(basePackages = "com.depromeet") +public class ApiResponseAdvice implements ResponseBodyAdvice { + + private final ObjectMapper objectMapper; + + @Override + public boolean supports( + MethodParameter returnType, Class> converterType) { + return true; + } + + @Override + public Object beforeBodyWrite( + Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class> selectedConverterType, + ServerHttpRequest request, + ServerHttpResponse response) { + + HttpServletResponse servletResponse = + ((ServletServerHttpResponse) response).getServletResponse(); + HttpStatus status = HttpStatus.valueOf(servletResponse.getStatus()); + + ApiResponse apiResponse; + if (status.is2xxSuccessful()) { + apiResponse = ApiResponse.success(status.value(), body); + + if (MappingJackson2HttpMessageConverter.class.isAssignableFrom(selectedConverterType)) { + servletResponse.setStatus(apiResponse.status()); + return apiResponse; + } else if (StringHttpMessageConverter.class.isAssignableFrom(selectedConverterType)) { + try { + response.getHeaders().set("Content-Type", "application/json"); + String json = objectMapper.writeValueAsString(apiResponse); + servletResponse.setStatus(apiResponse.status()); + return json; + } catch (JsonProcessingException e) { + throw new CustomException(ErrorCode.JSON_PROCESSING_ERROR); + } + } + } + + // 성공 상태가 아닌 경우 원래 body 반환 + return body; + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/config/jpa/JpaConfig.java b/src/main/java/com/depromeet/stonebed/global/config/jpa/JpaConfig.java new file mode 100644 index 00000000..cd9c4acb --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/config/jpa/JpaConfig.java @@ -0,0 +1,8 @@ +package com.depromeet.stonebed.global.config.jpa; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig {} diff --git a/src/main/java/com/depromeet/stonebed/global/config/querydsl/QuerydslConfig.java b/src/main/java/com/depromeet/stonebed/global/config/querydsl/QuerydslConfig.java new file mode 100644 index 00000000..e8dd4b23 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/config/querydsl/QuerydslConfig.java @@ -0,0 +1,19 @@ +package com.depromeet.stonebed.global.config.querydsl; + +import com.querydsl.jpa.JPQLTemplates; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext public EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/config/restClient/RestClientConfig.java b/src/main/java/com/depromeet/stonebed/global/config/restClient/RestClientConfig.java new file mode 100644 index 00000000..46e02e58 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/config/restClient/RestClientConfig.java @@ -0,0 +1,23 @@ +package com.depromeet.stonebed.global.config.restClient; + +import java.time.Duration; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient() { + RestTemplate restTemplate = + new RestTemplateBuilder() + .setConnectTimeout(Duration.ofSeconds(10)) + .setReadTimeout(Duration.ofSeconds(5)) + .build(); + + return RestClient.create(restTemplate); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/config/security/WebSecurityConfig.java b/src/main/java/com/depromeet/stonebed/global/config/security/WebSecurityConfig.java new file mode 100644 index 00000000..7b1d8e7e --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/config/security/WebSecurityConfig.java @@ -0,0 +1,129 @@ +package com.depromeet.stonebed.global.config.security; + +import static com.depromeet.stonebed.global.common.constants.EnvironmentConstants.*; +import static com.depromeet.stonebed.global.common.constants.SwaggerUrlConstants.*; +import static org.springframework.http.HttpHeaders.*; +import static org.springframework.security.config.Customizer.*; + +import com.depromeet.stonebed.domain.auth.application.JwtTokenService; +import com.depromeet.stonebed.global.annotation.ConditionalOnProfile; +import com.depromeet.stonebed.global.filter.JwtAuthenticationFilter; +import com.depromeet.stonebed.global.util.CookieUtil; +import com.depromeet.stonebed.global.util.SpringEnvironmentUtil; +import com.depromeet.stonebed.infra.properties.SwaggerProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class WebSecurityConfig { + private final JwtTokenService jwtTokenService; + private final CookieUtil cookieUtil; + private final SpringEnvironmentUtil springEnvironmentUtil; + + private final SwaggerProperties swaggerProperties; + + @Bean + public InMemoryUserDetailsManager inMemoryUserDetailsManager() { + UserDetails user = + User.withUsername(swaggerProperties.user()) + .password(passwordEncoder().encode(swaggerProperties.password())) + .roles("SWAGGER") + .build(); + return new InMemoryUserDetailsManager(user); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + defaultFilterChain(http); + http.authorizeHttpRequests( + authorize -> + authorize + .requestMatchers("/walwal-actuator/**") + .permitAll() // Actuator + .requestMatchers("/auth/**") + .permitAll() // Auth endpoints + .anyRequest() + .authenticated()) + .exceptionHandling( + exception -> + exception.authenticationEntryPoint( + (request, response, authException) -> + response.setStatus(401))); + + http.addFilterBefore( + jwtAuthenticationFilter(jwtTokenService, cookieUtil), + UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + @Order(1) + @ConditionalOnProfile({DEV, LOCAL}) + public SecurityFilterChain swaggerFilterChain(HttpSecurity http) throws Exception { + defaultFilterChain(http); + + http.securityMatcher(getSwaggerUrls()).httpBasic(withDefaults()); + + http.authorizeHttpRequests( + springEnvironmentUtil.isDevProfile() + ? authorize -> authorize.anyRequest().authenticated() + : authorize -> authorize.anyRequest().permitAll()); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + private void defaultFilterChain(HttpSecurity http) throws Exception { + http.httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .cors(withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + // TODO: CORS 임시 전체 허용 + configuration.addAllowedOriginPattern("*"); + + configuration.addAllowedHeader("*"); + configuration.addAllowedMethod("*"); + configuration.setAllowCredentials(true); + configuration.addExposedHeader(SET_COOKIE); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter( + JwtTokenService jwtTokenService, CookieUtil cookieUtil) { + return new JwtAuthenticationFilter(jwtTokenService, cookieUtil); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/config/swagger/SwaggerConfig.java b/src/main/java/com/depromeet/stonebed/global/config/swagger/SwaggerConfig.java new file mode 100644 index 00000000..34341e25 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/config/swagger/SwaggerConfig.java @@ -0,0 +1,92 @@ +package com.depromeet.stonebed.global.config.swagger; + +import com.depromeet.stonebed.global.common.constants.UrlConstants; +import com.depromeet.stonebed.global.util.SpringEnvironmentUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.core.jackson.ModelResolver; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class SwaggerConfig { + private static final String PACKAGES_TO_SCAN = "com.depromeet.stonebed"; + private static final String SWAGGER_API_TITLE = "WalWal 프로젝트 API 문서"; + private static final String SWAGGER_API_DESCRIPTION = "WalWal 프로젝트 API 문서입니다."; + + private final SpringEnvironmentUtil springEnvironmentUtil; + + @Value("${api.version}") + private String apiVersion; + + // OpenAPI Bean 설정 + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .servers(swaggerServers()) + .addSecurityItem(securityRequirement()) + .components(authSetting()) + .info( + new Info() + .title(SWAGGER_API_TITLE) + .version(apiVersion) + .description(SWAGGER_API_DESCRIPTION)); + } + + private String getServerUrl() { + return switch (springEnvironmentUtil.getCurrentProfile()) { + case "prod" -> UrlConstants.PROD_SERVER_URL.getValue(); + case "dev" -> UrlConstants.DEV_SERVER_URL.getValue(); + default -> UrlConstants.LOCAL_SERVER_URL.getValue(); + }; + } + + private List swaggerServers() { + Server server = new Server().url(getServerUrl()).description(SWAGGER_API_DESCRIPTION); + return List.of(server); + } + + private Components authSetting() { + return new Components() + .addSecuritySchemes( + "accessToken", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization")); + } + + // GroupedOpenApi Bean 설정 + @Bean + public GroupedOpenApi groupedOpenApi() { + return GroupedOpenApi.builder() + .group("WalWal API") + .packagesToScan(PACKAGES_TO_SCAN) + .build(); + } + + private SecurityRequirement securityRequirement() { + SecurityRequirement securityRequirement = new SecurityRequirement(); + securityRequirement.addList("accessToken"); + return securityRequirement; + } + + @Bean + public ModelResolver modelResolver(ObjectMapper objectMapper) { + // 객체 직렬화 + // swagger에서는 objectMapper를 사용하기에 objectMapper를 사용할 수 있도록 설정 + return new ModelResolver(objectMapper); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/error/ErrorCode.java b/src/main/java/com/depromeet/stonebed/global/error/ErrorCode.java new file mode 100644 index 00000000..5c9d51e6 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/error/ErrorCode.java @@ -0,0 +1,46 @@ +package com.depromeet.stonebed.global.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + SAMPLE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "샘플 에러 메시지 입니다."), + JSON_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "JSON 처리 중 오류가 발생했습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."), + INVALID_CURSOR_DATE_FORMAT(HttpStatus.BAD_REQUEST, "잘못된 커서 날짜 형식입니다."), + + // auth + AUTH_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "시큐리티 인증 정보를 찾을 수 없습니다."), + AUTHORIZATION_FAILED(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다."), + + // apple client + APPLE_KEY_CLIENT_FAILED(HttpStatus.BAD_REQUEST, "애플 키 생성에 실패했습니다."), + APPLE_TOKEN_CLIENT_FAILED(HttpStatus.BAD_REQUEST, "애플 토큰 생성에 실패했습니다."), + APPLE_PRIVATE_KEY_ENCODING_FAILED(HttpStatus.BAD_REQUEST, "애플 개인키 인코딩에 실패했습니다."), + + // kakao client + KAKAO_TOKEN_CLIENT_FAILED(HttpStatus.BAD_REQUEST, "카카오 통신에 실패했습니다."), + + // member + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 회원을 찾을 수 없습니다."), + ALREADY_EXISTS_MEMBER(HttpStatus.CONFLICT, "이미 존재하는 회원입니다."), + + // mission + MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 미션을 찾을 수 없습니다."), + MISSION_HISTORY_NOT_FOUNT(HttpStatus.NOT_FOUND, "해당 일별 미션 정보를 찾을 수 없습니다."), + MISSION_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 미션 기록을 찾을 수 없습니다."), + NO_AVAILABLE_TODAY_MISSION(HttpStatus.INTERNAL_SERVER_ERROR, "할당 가능한 오늘의 미션이 없습니다."), + + // image + IMAGE_KEY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 이미지를 찾을 수 없습니다."), + IMAGE_FILE_EXTENSION_NOT_FOUND(HttpStatus.NOT_FOUND, "이미지 파일 확장자를 찾을 수 없습니다."), + INVALID_IMAGE_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "올바른 이미지 확장자가 아닙니다."), + MEMBER_ALREADY_DELETED(HttpStatus.CONFLICT, "이미 탈퇴한 회원입니다."), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/depromeet/stonebed/global/error/ErrorResponse.java b/src/main/java/com/depromeet/stonebed/global/error/ErrorResponse.java new file mode 100644 index 00000000..163ae82d --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/error/ErrorResponse.java @@ -0,0 +1,8 @@ +package com.depromeet.stonebed.global.error; + +public record ErrorResponse(String errorClassName, String message) { + + public static ErrorResponse of(String errorClassName, String message) { + return new ErrorResponse(errorClassName, message); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/error/exception/CustomException.java b/src/main/java/com/depromeet/stonebed/global/error/exception/CustomException.java new file mode 100644 index 00000000..e2f18cd5 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/error/exception/CustomException.java @@ -0,0 +1,15 @@ +package com.depromeet.stonebed.global.error.exception; + +import com.depromeet.stonebed.global.error.ErrorCode; +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/error/exception/GlobalExceptionHandler.java b/src/main/java/com/depromeet/stonebed/global/error/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..57665f7f --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/error/exception/GlobalExceptionHandler.java @@ -0,0 +1,39 @@ +package com.depromeet.stonebed.global.error.exception; + +import com.depromeet.stonebed.global.common.response.ApiResponse; +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + /** CustomException 예외 처리 */ + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e) { + log.error("CustomException : {}", e.getMessage(), e); + final ErrorCode errorCode = e.getErrorCode(); + final ErrorResponse errorResponse = + ErrorResponse.of(errorCode.name(), errorCode.getMessage()); + final ApiResponse response = + ApiResponse.fail(errorCode.getHttpStatus().value(), errorResponse); + return ResponseEntity.status(errorCode.getHttpStatus()).body(response); + } + + /** 500번대 에러 처리 */ + @ExceptionHandler(Exception.class) + protected ResponseEntity handleException(Exception e) { + log.error("Internal Server Error : {}", e.getMessage(), e); + final ErrorCode internalServerError = ErrorCode.INTERNAL_SERVER_ERROR; + final ErrorResponse errorResponse = + ErrorResponse.of(e.getClass().getSimpleName(), internalServerError.getMessage()); + final ApiResponse response = + ApiResponse.fail(internalServerError.getHttpStatus().value(), errorResponse); + return ResponseEntity.status(internalServerError.getHttpStatus()).body(response); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/filter/JwtAuthenticationFilter.java b/src/main/java/com/depromeet/stonebed/global/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..7320265a --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/filter/JwtAuthenticationFilter.java @@ -0,0 +1,125 @@ +package com.depromeet.stonebed.global.filter; + +import static com.depromeet.stonebed.global.common.constants.SecurityConstants.*; + +import com.depromeet.stonebed.domain.auth.application.JwtTokenService; +import com.depromeet.stonebed.domain.auth.dto.AccessTokenDto; +import com.depromeet.stonebed.domain.auth.dto.RefreshTokenDto; +import com.depromeet.stonebed.domain.member.domain.MemberRole; +import com.depromeet.stonebed.global.security.PrincipalDetails; +import com.depromeet.stonebed.global.util.CookieUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.WebUtils; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenService jwtTokenService; + private final CookieUtil cookieUtil; + + private static String extractAccessTokenFromHeader(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION)) + .filter(header -> header.startsWith(TOKEN_PREFIX)) + .map(header -> header.replace(TOKEN_PREFIX, "")) + .orElse(null); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String accessTokenHeaderValue = extractAccessTokenFromHeader(request); + String accessTokenValue = extractAccessTokenFromCookie(request); + String refreshTokenValue = extractRefreshTokenFromCookie(request); + + // 헤더에 AT가 있으면 우선적으로 검증 + if (accessTokenHeaderValue != null) { + AccessTokenDto accessTokenDto = + jwtTokenService.retrieveAccessToken(accessTokenHeaderValue); + if (accessTokenDto != null) { + setAuthenticationToContext(accessTokenDto.memberId(), accessTokenDto.memberRole()); + filterChain.doFilter(request, response); + return; + } + } + + // 쿠키에서 가져올 때 AT와 RT 중 하나라도 없으면 실패 + if (accessTokenValue == null || refreshTokenValue == null) { + filterChain.doFilter(request, response); + return; + } + + AccessTokenDto accessTokenDto = jwtTokenService.retrieveAccessToken(accessTokenValue); + + // AT가 유효하면 통과 + if (accessTokenDto != null) { + setAuthenticationToContext(accessTokenDto.memberId(), accessTokenDto.memberRole()); + filterChain.doFilter(request, response); + return; + } + + // AT가 만료된 경우 AT 재발급, 만료되지 않은 경우 null 반환 + Optional reissuedAccessToken = + Optional.ofNullable(jwtTokenService.reissueAccessTokenIfExpired(accessTokenValue)); + // RT 유효하면 파싱, 유효하지 않으면 null 반환 + RefreshTokenDto refreshTokenDto = jwtTokenService.retrieveRefreshToken(refreshTokenValue); + + // AT가 만료되었고, RT가 유효하면 AT, RT 재발급 + if (reissuedAccessToken.isPresent() && refreshTokenDto != null) { + AccessTokenDto accessToken = reissuedAccessToken.get(); // 재발급된 AT + RefreshTokenDto refreshToken = + jwtTokenService.createRefreshTokenDto(refreshTokenDto.memberId()); + + // 쿠키에 재발급된 AT, RT 저장 + HttpHeaders httpHeaders = + cookieUtil.generateTokenCookies( + accessToken.tokenValue(), refreshToken.tokenValue()); + response.addHeader( + HttpHeaders.SET_COOKIE, httpHeaders.getFirst(ACCESS_TOKEN_COOKIE_NAME)); + response.addHeader( + HttpHeaders.SET_COOKIE, httpHeaders.getFirst(REFRESH_TOKEN_COOKIE_NAME)); + + setAuthenticationToContext(accessToken.memberId(), accessToken.memberRole()); + } + + // AT, RT가 모두 만료된 경우 실패 + filterChain.doFilter(request, response); + } + + private void setAuthenticationToContext(Long memberId, MemberRole memberRole) { + UserDetails userDetails = new PrincipalDetails(memberId, memberRole); + + Authentication authentication = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private String extractAccessTokenFromCookie(HttpServletRequest request) { + return Optional.ofNullable(WebUtils.getCookie(request, ACCESS_TOKEN_COOKIE_NAME)) + .map(Cookie::getValue) + .orElse(null); + } + + private String extractRefreshTokenFromCookie(HttpServletRequest request) { + return Optional.ofNullable(WebUtils.getCookie(request, REFRESH_TOKEN_COOKIE_NAME)) + .map(Cookie::getValue) + .orElse(null); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/security/PrincipalDetails.java b/src/main/java/com/depromeet/stonebed/global/security/PrincipalDetails.java new file mode 100644 index 00000000..6230eae8 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/security/PrincipalDetails.java @@ -0,0 +1,51 @@ +package com.depromeet.stonebed.global.security; + +import com.depromeet.stonebed.domain.member.domain.MemberRole; +import java.util.Collection; +import java.util.Collections; +import lombok.AllArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@AllArgsConstructor +public class PrincipalDetails implements UserDetails { + + private final Long memberId; + private final MemberRole role; + + @Override + public Collection getAuthorities() { + return Collections.singleton(new SimpleGrantedAuthority(role.getValue())); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return memberId.toString(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/util/CookieUtil.java b/src/main/java/com/depromeet/stonebed/global/util/CookieUtil.java new file mode 100644 index 00000000..2e57cc04 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/util/CookieUtil.java @@ -0,0 +1,50 @@ +package com.depromeet.stonebed.global.util; + +import static com.depromeet.stonebed.global.common.constants.SecurityConstants.*; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.server.Cookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CookieUtil { + + private final SpringEnvironmentUtil springEnvironmentUtil; + + public HttpHeaders generateTokenCookies(String accessToken, String refreshToken) { + + String sameSite = determineSameSitePolicy(); + + ResponseCookie accessTokenCookie = + ResponseCookie.from(ACCESS_TOKEN_COOKIE_NAME, accessToken) + .path("/") + .secure(true) + .sameSite(sameSite) + .httpOnly(true) + .build(); + + ResponseCookie refreshTokenCookie = + ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken) + .path("/") + .secure(true) + .sameSite(sameSite) + .httpOnly(true) + .build(); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); + headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + + return headers; + } + + private String determineSameSitePolicy() { + if (springEnvironmentUtil.isProdProfile()) { + return Cookie.SameSite.STRICT.attributeValue(); + } + return Cookie.SameSite.NONE.attributeValue(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/util/JwtUtil.java b/src/main/java/com/depromeet/stonebed/global/util/JwtUtil.java new file mode 100644 index 00000000..684632f2 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/util/JwtUtil.java @@ -0,0 +1,169 @@ +package com.depromeet.stonebed.global.util; + +import static com.depromeet.stonebed.global.common.constants.SecurityConstants.*; + +import com.depromeet.stonebed.domain.auth.domain.OAuthProvider; +import com.depromeet.stonebed.domain.auth.domain.TokenType; +import com.depromeet.stonebed.domain.auth.dto.AccessTokenDto; +import com.depromeet.stonebed.domain.auth.dto.RefreshTokenDto; +import com.depromeet.stonebed.domain.member.domain.Member; +import com.depromeet.stonebed.domain.member.domain.MemberRole; +import com.depromeet.stonebed.infra.properties.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtUtil { + + private final JwtProperties jwtProperties; + private static final String TOKEN_TYPE_KEY_NAME = "type"; + private static final String USER_ID_KEY_NAME = "memberId"; + private static final String PROVIDER_KEY_NAME = "provider"; + + public String generateAccessToken(Long memberId, MemberRole memberRole) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.accessTokenExpirationMilliTime()); + return buildAccessToken(memberId, memberRole, issuedAt, expiredAt); + } + + public AccessTokenDto generateAccessTokenDto(Long memberId, MemberRole memberRole) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.accessTokenExpirationMilliTime()); + String tokenValue = buildAccessToken(memberId, memberRole, issuedAt, expiredAt); + return new AccessTokenDto(memberId, memberRole, tokenValue); + } + + public String generateRefreshToken(Long memberId) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.refreshTokenExpirationMilliTime()); + return buildRefreshToken(memberId, issuedAt, expiredAt); + } + + public RefreshTokenDto generateRefreshTokenDto(Long memberId) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.refreshTokenExpirationMilliTime()); + String tokenValue = buildRefreshToken(memberId, issuedAt, expiredAt); + return new RefreshTokenDto( + memberId, tokenValue, jwtProperties.refreshTokenExpirationTime()); + } + + public AccessTokenDto parseAccessToken(String token) throws ExpiredJwtException { + // 토큰 파싱하여 성공하면 AccessTokenDto 반환, 실패하면 null 반환 + // 만료된 토큰인 경우에만 ExpiredJwtException 발생 + try { + Jws claims = getClaims(token, getAccessTokenKey()); + + return new AccessTokenDto( + Long.parseLong(claims.getBody().getSubject()), + MemberRole.valueOf(claims.getBody().get("role", String.class)), + token); + } catch (ExpiredJwtException e) { + throw e; + } catch (Exception e) { + return null; + } + } + + public RefreshTokenDto parseRefreshToken(String token) throws ExpiredJwtException { + try { + Jws claims = getClaims(token, getRefreshTokenKey()); + + return new RefreshTokenDto( + Long.parseLong(claims.getBody().getSubject()), + token, + jwtProperties.refreshTokenExpirationTime()); + } catch (ExpiredJwtException e) { + throw e; + } catch (Exception e) { + return null; + } + } + + public long getRefreshTokenExpirationTime() { + return jwtProperties.refreshTokenExpirationTime(); + } + + private Key getRefreshTokenKey() { + return Keys.hmacShaKeyFor(jwtProperties.refreshTokenSecret().getBytes()); + } + + private Key getAccessTokenKey() { + return Keys.hmacShaKeyFor(jwtProperties.accessTokenSecret().getBytes()); + } + + private Jws getClaims(String token, Key key) { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + } + + private Date generateTemporaryTokenExpiration() { + return new Date(Long.MAX_VALUE); + } + + public String generateTemporaryToken(OAuthProvider oAuthProvider, Member member) { + return Jwts.builder() + .setHeader(createTokenHeader(TokenType.TEMPORARY)) + .setSubject(member.getId().toString()) + .setClaims( + Map.of( + USER_ID_KEY_NAME, + member.getOauthInfo().getOauthId(), + PROVIDER_KEY_NAME, + oAuthProvider.getValue(), + TOKEN_ROLE_NAME, + MemberRole.TEMPORARY.name())) + .setExpiration(generateTemporaryTokenExpiration()) + .signWith(getAccessTokenKey()) + .compact(); + } + + private String buildAccessToken( + Long memberId, MemberRole memberRole, Date issuedAt, Date expiredAt) { + return Jwts.builder() + .setHeader( + createTokenHeader( + memberRole == MemberRole.TEMPORARY + ? TokenType.TEMPORARY + : TokenType.ACCESS)) + .setSubject(memberId.toString()) + .claim(TOKEN_ROLE_NAME, memberRole.name()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(getAccessTokenKey()) + .compact(); + } + + private String buildRefreshToken(Long memberId, Date issuedAt, Date expiredAt) { + return Jwts.builder() + .setHeader(createTokenHeader(TokenType.REFRESH)) + .setSubject(memberId.toString()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(getRefreshTokenKey()) + .compact(); + } + + private Map createTokenHeader(TokenType tokenType) { + return Map.of( + "typ", + "JWT", + "alg", + "HS256", + "regDate", + System.currentTimeMillis(), + TOKEN_TYPE_KEY_NAME, + tokenType.getValue()); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/util/MemberUtil.java b/src/main/java/com/depromeet/stonebed/global/util/MemberUtil.java new file mode 100644 index 00000000..191c86ca --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/util/MemberUtil.java @@ -0,0 +1,36 @@ +package com.depromeet.stonebed.global.util; + +import com.depromeet.stonebed.domain.member.dao.MemberRepository; +import com.depromeet.stonebed.domain.member.domain.Member; +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberUtil { + + private final SecurityUtil securityUtil; + private final MemberRepository memberRepository; + + public Member getCurrentMember() { + return memberRepository + .findById(securityUtil.getCurrentMemberId()) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + } + + public Member getMemberByMemberId(Long memberId) { + return memberRepository + .findById(memberId) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + } + + public String getMemberRole() { + return securityUtil.getCurrentMemberRole(); + } + + public String getMemberProvider() { + return getCurrentMember().getOauthInfo().getOauthProvider(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/util/SecurityUtil.java b/src/main/java/com/depromeet/stonebed/global/util/SecurityUtil.java new file mode 100644 index 00000000..89534420 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/util/SecurityUtil.java @@ -0,0 +1,29 @@ +package com.depromeet.stonebed.global.util; + +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class SecurityUtil { + + public Long getCurrentMemberId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + try { + return Long.parseLong(authentication.getName()); + } catch (Exception e) { + throw new CustomException(ErrorCode.AUTH_NOT_FOUND); + } + } + + public String getCurrentMemberRole() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + try { + return authentication.getAuthorities().stream().findFirst().get().getAuthority(); + } catch (Exception e) { + throw new CustomException(ErrorCode.AUTH_NOT_FOUND); + } + } +} diff --git a/src/main/java/com/depromeet/stonebed/global/util/SpringEnvironmentUtil.java b/src/main/java/com/depromeet/stonebed/global/util/SpringEnvironmentUtil.java new file mode 100644 index 00000000..cce3e84d --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/global/util/SpringEnvironmentUtil.java @@ -0,0 +1,39 @@ +package com.depromeet.stonebed.global.util; + +import static com.depromeet.stonebed.global.common.constants.EnvironmentConstants.Constants.*; + +import java.util.Arrays; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SpringEnvironmentUtil { + + private final Environment environment; + + public String getCurrentProfile() { + return getActiveProfiles() + .filter(profile -> profile.equals(PROD_ENV) || profile.equals(DEV_ENV)) + .findFirst() + .orElse(LOCAL_ENV); + } + + public boolean isProdProfile() { + return getActiveProfiles().anyMatch(PROD_ENV::equals); + } + + public boolean isDevProfile() { + return getActiveProfiles().anyMatch(DEV_ENV::equals); + } + + public boolean isProdAndDevProfile() { + return getActiveProfiles().anyMatch(PROD_AND_DEV_ENV::contains); + } + + private Stream getActiveProfiles() { + return Arrays.stream(environment.getActiveProfiles()); + } +} diff --git a/src/main/java/com/depromeet/stonebed/infra/config/redis/RedisConfig.java b/src/main/java/com/depromeet/stonebed/infra/config/redis/RedisConfig.java new file mode 100644 index 00000000..a0e30b53 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/infra/config/redis/RedisConfig.java @@ -0,0 +1,33 @@ +package com.depromeet.stonebed.infra.config.redis; + +import com.depromeet.stonebed.infra.properties.RedisProperties; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; + +@EnableRedisRepositories +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisConfig = + new RedisStandaloneConfiguration(redisProperties.host(), redisProperties.port()); + if (!redisProperties.password().isBlank()) + redisConfig.setPassword(redisProperties.password()); + LettuceClientConfiguration clientConfig = + LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofSeconds(1)) + .shutdownTimeout(Duration.ZERO) + .build(); + return new LettuceConnectionFactory(redisConfig, clientConfig); + } +} diff --git a/src/main/java/com/depromeet/stonebed/infra/config/s3/S3Config.java b/src/main/java/com/depromeet/stonebed/infra/config/s3/S3Config.java new file mode 100644 index 00000000..a4950210 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/infra/config/s3/S3Config.java @@ -0,0 +1,32 @@ +package com.depromeet.stonebed.infra.config.s3; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.depromeet.stonebed.infra.properties.S3Properties; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class S3Config { + private final S3Properties s3Properties; + + @Bean + public AmazonS3 amazonS3() { + AWSCredentials credentials = + new BasicAWSCredentials(s3Properties.accessKey(), s3Properties.secretKey()); + AwsClientBuilder.EndpointConfiguration endpointConfiguration = + new AwsClientBuilder.EndpointConfiguration( + s3Properties.endpoint(), s3Properties.region()); + + return AmazonS3ClientBuilder.standard() + .withEndpointConfiguration(endpointConfiguration) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/infra/properties/AppleProperties.java b/src/main/java/com/depromeet/stonebed/infra/properties/AppleProperties.java new file mode 100644 index 00000000..e9c47314 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/infra/properties/AppleProperties.java @@ -0,0 +1,10 @@ +package com.depromeet.stonebed.infra.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "apple") +public record AppleProperties( + EnvironmentProperties dev, EnvironmentProperties prod, String keyId, String p8) { + + public static record EnvironmentProperties(String clientId, String teamId) {} +} diff --git a/src/main/java/com/depromeet/stonebed/infra/properties/JwtProperties.java b/src/main/java/com/depromeet/stonebed/infra/properties/JwtProperties.java new file mode 100644 index 00000000..b35f2144 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/infra/properties/JwtProperties.java @@ -0,0 +1,19 @@ +package com.depromeet.stonebed.infra.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + String accessTokenSecret, + String refreshTokenSecret, + Long accessTokenExpirationTime, + Long refreshTokenExpirationTime) { + + public Long accessTokenExpirationMilliTime() { + return accessTokenExpirationTime * 1000; + } + + public Long refreshTokenExpirationMilliTime() { + return refreshTokenExpirationTime * 1000; + } +} diff --git a/src/main/java/com/depromeet/stonebed/infra/properties/PropertiesConfig.java b/src/main/java/com/depromeet/stonebed/infra/properties/PropertiesConfig.java new file mode 100644 index 00000000..2687b000 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/infra/properties/PropertiesConfig.java @@ -0,0 +1,14 @@ +package com.depromeet.stonebed.infra.properties; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@EnableConfigurationProperties({ + RedisProperties.class, + S3Properties.class, + JwtProperties.class, + AppleProperties.class, + SwaggerProperties.class +}) +@Configuration +public class PropertiesConfig {} diff --git a/src/main/java/com/depromeet/stonebed/infra/properties/RedisProperties.java b/src/main/java/com/depromeet/stonebed/infra/properties/RedisProperties.java new file mode 100644 index 00000000..dcd73685 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/infra/properties/RedisProperties.java @@ -0,0 +1,6 @@ +package com.depromeet.stonebed.infra.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "spring.data.redis") +public record RedisProperties(String host, int port, String password) {} diff --git a/src/main/java/com/depromeet/stonebed/infra/properties/S3Properties.java b/src/main/java/com/depromeet/stonebed/infra/properties/S3Properties.java new file mode 100644 index 00000000..7c57659a --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/infra/properties/S3Properties.java @@ -0,0 +1,7 @@ +package com.depromeet.stonebed.infra.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "storage") +public record S3Properties( + String accessKey, String secretKey, String region, String bucket, String endpoint) {} diff --git a/src/main/java/com/depromeet/stonebed/infra/properties/SwaggerProperties.java b/src/main/java/com/depromeet/stonebed/infra/properties/SwaggerProperties.java new file mode 100644 index 00000000..fc8461c4 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/infra/properties/SwaggerProperties.java @@ -0,0 +1,6 @@ +package com.depromeet.stonebed.infra.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "swagger") +public record SwaggerProperties(String version, String user, String password) {} diff --git a/src/main/resources/application-actuator.yml b/src/main/resources/application-actuator.yml new file mode 100644 index 00000000..e7ef5b5e --- /dev/null +++ b/src/main/resources/application-actuator.yml @@ -0,0 +1,18 @@ +spring: + config: + activate: + on-profile: "actuator" + +management: + endpoints: + web: + exposure: + include: health + base-path: /walwal-actuator + jmx: + exposure: + exclude: "*" + enabled-by-default: false + endpoint: + health: + enabled: true diff --git a/src/main/resources/application-cloud.yml b/src/main/resources/application-cloud.yml new file mode 100644 index 00000000..d8036b59 --- /dev/null +++ b/src/main/resources/application-cloud.yml @@ -0,0 +1,10 @@ +spring: + config: + activate: + on-profile: "cloud" +storage: + accessKey: ${AWS_ACCESS_KEY:} + secretKey: ${AWS_SECRET_KEY:} + bucket: ${S3_IMAGE_BUCKET:} + region: ${AWS_REGION:} + endpoint: ${S3_ENDPOINT:https://s3.ap-northeast-2.amazonaws.com} diff --git a/src/main/resources/application-datasource.yml b/src/main/resources/application-datasource.yml new file mode 100644 index 00000000..8c4a641a --- /dev/null +++ b/src/main/resources/application-datasource.yml @@ -0,0 +1,9 @@ +spring: + config: + activate: + on-profile: "datasource" + datasource: + url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${DB_NAME}?useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true&tinyInt1isBit=false + driver-class-name: com.mysql.cj.jdbc.Driver + password: ${MYSQL_PASSWORD} + username: ${MYSQL_USERNAME} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 00000000..31e1b979 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,18 @@ +spring: + config: + activate: + on-profile: "dev" + jpa: + hibernate: + ddl-auto: update + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + format_sql: ${FORMAT_SQL:true} + defer-datasource-initialization: true + open-in-view: false + +logging: + level: + org.springframework.orm.jpa: DEBUG + org.springframework.transaction: DEBUG diff --git a/src/main/resources/application-docs.yml b/src/main/resources/application-docs.yml new file mode 100644 index 00000000..6daa40ed --- /dev/null +++ b/src/main/resources/application-docs.yml @@ -0,0 +1,31 @@ +spring: + config: + activate: + on-profile: "docs" + +swagger: + version: ${SWAGGER_VERSION:0.0.1} + user: ${SWAGGER_USER:default} + password: ${SWAGGER_PASSWORD:default} + +springdoc: + swagger-ui: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + enabled: true + groups-order: ASC + syntax-highlight: + theme: none + tags-sorter: alpha + operations-sorter: alpha + disable-swagger-default-url: true + display-request-duration: true + doc-expansion: none + urls-primary-name: WALWAL API DOCS + + cache: + disabled: true + + +api: + version: "0.0.1" diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 00000000..8ffb6c95 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,17 @@ +spring: + config: + activate: + on-profile: "local" + jpa: + hibernate: + ddl-auto: update + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + format_sql: ${FORMAT_SQL:true} + defer-datasource-initialization: true + open-in-view: false +logging: + level: + org.springframework.orm.jpa: DEBUG + org.springframework.transaction: DEBUG diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..5564164c --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,7 @@ +spring: + config: + activate: + on-profile: "prod" + jpa: + hibernate: + ddl-auto: none diff --git a/src/main/resources/application-redis.yml b/src/main/resources/application-redis.yml new file mode 100644 index 00000000..0f5ac38c --- /dev/null +++ b/src/main/resources/application-redis.yml @@ -0,0 +1,9 @@ +spring: + config: + activate: + on-profile: "redis" + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} diff --git a/src/main/resources/application-security.yml b/src/main/resources/application-security.yml new file mode 100644 index 00000000..9a6ae749 --- /dev/null +++ b/src/main/resources/application-security.yml @@ -0,0 +1,15 @@ +jwt: + access-token-secret: ${JWT_ACCESS_TOKEN_SECRET:} + refresh-token-secret: ${JWT_REFRESH_TOKEN_SECRET:} + access-token-expiration-time: ${JWT_ACCESS_TOKEN_EXPIRATION_TIME:7200} + refresh-token-expiration-time: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME:604800} + +apple: + dev: + client-id: ${APPLE_DEV_CLIENT_ID:} + team-id: ${APPLE_DEV_TEAM_ID:} + prod: + client-id: ${APPLE_PROD_CLIENT_ID:} + team-id: ${APPLE_PROD_TEAM_ID:} + key-id: ${APPLE_KEY_ID:} + p8: ${APPLE_P8:} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0e1db5e4..b6dd840a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,19 @@ spring: + profiles: + group: + test: "test" + local: "local, datasource" + dev: "dev, datasource" + prod: "prod, datasource" + include: + - redis + - actuator + - docs + - cloud + - security application: name: stonebed +logging: + level: + com.depromeet.stonebed.*.api.*: debug diff --git a/src/test/java/com/depromeet/stonebed/StonebedApplicationTests.java b/src/test/java/com/depromeet/stonebed/StonebedApplicationTests.java index e08f2e3f..80aab2f6 100644 --- a/src/test/java/com/depromeet/stonebed/StonebedApplicationTests.java +++ b/src/test/java/com/depromeet/stonebed/StonebedApplicationTests.java @@ -2,12 +2,12 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class StonebedApplicationTests { - @Test - void contextLoads() { - } - + @Test + void contextLoads() {} } diff --git a/src/test/java/com/depromeet/stonebed/TestQuerydslConfig.java b/src/test/java/com/depromeet/stonebed/TestQuerydslConfig.java new file mode 100644 index 00000000..19733c17 --- /dev/null +++ b/src/test/java/com/depromeet/stonebed/TestQuerydslConfig.java @@ -0,0 +1,18 @@ +package com.depromeet.stonebed; + +import com.querydsl.jpa.JPQLTemplates; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestQuerydslConfig { + @PersistenceContext public EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager); + } +} diff --git a/src/test/java/com/depromeet/stonebed/domain/member/domain/MemberTest.java b/src/test/java/com/depromeet/stonebed/domain/member/domain/MemberTest.java new file mode 100644 index 00000000..adc3205e --- /dev/null +++ b/src/test/java/com/depromeet/stonebed/domain/member/domain/MemberTest.java @@ -0,0 +1,8 @@ +package com.depromeet.stonebed.domain.member.domain; + +import static org.junit.jupiter.api.Assertions.*; + +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +class MemberTest {} diff --git a/src/test/java/com/depromeet/stonebed/domain/mission/api/MissionControllerTest.java b/src/test/java/com/depromeet/stonebed/domain/mission/api/MissionControllerTest.java new file mode 100644 index 00000000..1c92f26d --- /dev/null +++ b/src/test/java/com/depromeet/stonebed/domain/mission/api/MissionControllerTest.java @@ -0,0 +1,108 @@ +package com.depromeet.stonebed.domain.mission.api; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.depromeet.stonebed.domain.mission.application.MissionService; +import com.depromeet.stonebed.domain.mission.dto.request.MissionCreateRequest; +import com.depromeet.stonebed.domain.mission.dto.request.MissionUpdateRequest; +import com.depromeet.stonebed.domain.mission.dto.response.MissionCreateResponse; +import com.depromeet.stonebed.domain.mission.dto.response.MissionGetOneResponse; +import com.depromeet.stonebed.domain.mission.dto.response.MissionGetTodayResponse; +import com.depromeet.stonebed.domain.mission.dto.response.MissionUpdateResponse; +import com.depromeet.stonebed.global.common.response.ApiResponseAdvice; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +public class MissionControllerTest { + + private MockMvc mockMvc; + + @Mock private MissionService missionService; + + @InjectMocks private MissionController missionController; + + @InjectMocks private ApiResponseAdvice apiResponseAdvice; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + mockMvc = + MockMvcBuilders.standaloneSetup(missionController) + .setControllerAdvice(apiResponseAdvice) + .build(); + } + + @Test + public void 미션_생성_성공() throws Exception { + // Given + MissionCreateResponse missionCreateResponse = new MissionCreateResponse(1L, "Test Mission"); + when(missionService.createMission(any(MissionCreateRequest.class))) + .thenReturn(missionCreateResponse); + + // When & Then + mockMvc.perform( + post("/missions") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Test Mission\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("Test Mission")); + } + + @Test + public void 오늘의_미션_조회_성공() throws Exception { + // Given + MissionGetTodayResponse missionGetTodayResponse = + new MissionGetTodayResponse( + 1L, "Test Mission", "https://example.com/image.png", "#FFFFFF"); + when(missionService.getOrCreateTodayMission()).thenReturn(missionGetTodayResponse); + + // When & Then + mockMvc.perform(get("/missions/today")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("Test Mission")); + } + + @Test + public void 미션_조회_성공() throws Exception { + // Given + MissionGetOneResponse missionGetOneResponse = new MissionGetOneResponse(1L, "Test Mission"); + when(missionService.getMission(1L)).thenReturn(missionGetOneResponse); + + // When & Then + mockMvc.perform(get("/missions/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("Test Mission")); + } + + @Test + public void 미션_수정_성공() throws Exception { + // Given + when(missionService.updateMission(anyLong(), any(MissionUpdateRequest.class))) + .thenReturn(new MissionUpdateResponse(1L, "Updated Mission")); + + // When & Then + mockMvc.perform( + patch("/missions/1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Updated Mission\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("Updated Mission")); + } + + @Test + public void 미션_삭제_성공() throws Exception { + // Given + doNothing().when(missionService).deleteMission(anyLong()); + + // When & Then + mockMvc.perform(delete("/missions/1")).andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/depromeet/stonebed/domain/mission/application/MissionServiceTest.java b/src/test/java/com/depromeet/stonebed/domain/mission/application/MissionServiceTest.java new file mode 100644 index 00000000..e46c93a3 --- /dev/null +++ b/src/test/java/com/depromeet/stonebed/domain/mission/application/MissionServiceTest.java @@ -0,0 +1,187 @@ +package com.depromeet.stonebed.domain.mission.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +import com.depromeet.stonebed.domain.mission.dao.MissionHistoryRepository; +import com.depromeet.stonebed.domain.mission.dao.MissionRepository; +import com.depromeet.stonebed.domain.mission.domain.Mission; +import com.depromeet.stonebed.domain.mission.domain.MissionHistory; +import com.depromeet.stonebed.domain.mission.dto.request.MissionCreateRequest; +import com.depromeet.stonebed.domain.mission.dto.request.MissionUpdateRequest; +import com.depromeet.stonebed.domain.mission.dto.response.MissionCreateResponse; +import com.depromeet.stonebed.domain.mission.dto.response.MissionGetOneResponse; +import com.depromeet.stonebed.domain.mission.dto.response.MissionGetTodayResponse; +import com.depromeet.stonebed.domain.mission.dto.response.MissionUpdateResponse; +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import java.time.LocalDate; +import java.util.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class MissionServiceTest { + + @Mock private MissionRepository missionRepository; + @Mock private MissionHistoryRepository missionHistoryRepository; + + @InjectMocks private MissionService missionService; + + private LocalDate today; + private LocalDate threeDaysAgo; + private Mission mission; + private MissionHistory missionHistory; + + @BeforeEach + public void setUp() { + today = LocalDate.now(); + threeDaysAgo = LocalDate.now().minusDays(3); + mission = Mission.builder().title("Test Mission").build(); + missionHistory = MissionHistory.builder().mission(mission).assignedDate(today).build(); + MockitoAnnotations.openMocks(this); + } + + @Test + public void 미션_생성_성공() { + // Given + when(missionRepository.save(any(Mission.class))).thenReturn(mission); + MissionCreateRequest missionCreateRequest = new MissionCreateRequest("Test Mission"); + + // When + MissionCreateResponse missionCreateResponse = + missionService.createMission(missionCreateRequest); + + // Then + assertThat(missionCreateResponse.title()).isEqualTo("Test Mission"); + verify(missionRepository, times(1)).save(any(Mission.class)); + } + + @Test + public void 미션_단일_조회_성공() { + // Given + when(missionRepository.findById(anyLong())).thenReturn(Optional.of(mission)); + + // When + MissionGetOneResponse missionGetOneResponse = missionService.getMission(1L); + + // Then + assertThat(missionGetOneResponse.title()).isEqualTo("Test Mission"); + verify(missionRepository, times(1)).findById(anyLong()); + } + + @Test + public void 오늘의_미션_조회_성공_히스토리가_이미_존재하는_경우() { + // Given + when(missionHistoryRepository.findByAssignedDate(today)) + .thenReturn(Optional.of(missionHistory)); + + // When + MissionGetTodayResponse result = missionService.getOrCreateTodayMission(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.title()).isEqualTo("Test Mission"); + verify(missionHistoryRepository, times(1)).findByAssignedDate(today); + verify(missionHistoryRepository, times(0)).save(any(MissionHistory.class)); + } + + @Test + public void 오늘의_미션_조회_성공_히스토리가_없는_경우() { + // Given: 초기 설정 + List recentMissions = new ArrayList<>(); + recentMissions.add(Mission.builder().title("1일 전 미션").build()); + recentMissions.add(Mission.builder().title("2일 전 미션").build()); + recentMissions.add(Mission.builder().title("3일 전 미션").build()); + + List availableMissions = new ArrayList<>(); + availableMissions.add(Mission.builder().title("4일 전 미션").build()); + availableMissions.add(Mission.builder().title("5일 전 미션").build()); + + when(missionRepository.findMissionsAssignedBefore(threeDaysAgo)).thenReturn(recentMissions); + + when(missionRepository.findNotInMissions(recentMissions)).thenReturn(availableMissions); + + when(missionHistoryRepository.save(any(MissionHistory.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // When: getOrCreateTodayMission 을 호출하면 + MissionGetTodayResponse result = missionService.getOrCreateTodayMission(); + + // Then: 각 메서드들이 실행됐는지 검증 + assertThat(result).isNotNull(); + assertThat(result.title()).isIn("4일 전 미션", "5일 전 미션"); + } + + @Test + public void 오늘의_미션_조회_실패_할당가능한_미션이_없는_경우() { + // Given: 초기 설정 + List emptyMissionList = new ArrayList<>(); + + when(missionRepository.findMissionsAssignedBefore(today)).thenReturn(emptyMissionList); + + when(missionRepository.findNotInMissions(emptyMissionList)).thenReturn(emptyMissionList); + + when(missionHistoryRepository.save(any(MissionHistory.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // When: getOrCreateTodayMission 을 호출하면 + CustomException exception = + assertThrows(CustomException.class, () -> missionService.getOrCreateTodayMission()); + + // Then: 에러코드 검증 + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.NO_AVAILABLE_TODAY_MISSION); + } + + @Test + public void 미션_조회_미션이_없는_경우() { + // Given + + // When + when(missionRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // Then + assertThrows(CustomException.class, () -> missionService.getMission(1L)); + verify(missionRepository, times(1)).findById(anyLong()); + } + + @Test + public void 미션_수정_성공() { + // Given + when(missionRepository.findById(anyLong())).thenReturn(Optional.of(mission)); + + // When + MissionUpdateResponse missionUpdateResponse = + missionService.updateMission(1L, new MissionUpdateRequest("Updated Mission")); + + // Then + assertThat(missionUpdateResponse.title()).isEqualTo("Updated Mission"); + verify(missionRepository, times(1)).findById(anyLong()); + verify(missionRepository, times(1)).save(any(Mission.class)); + } + + @Test + public void 미션_수정_실패_미션이_없는_경우() { + // Given + when(missionRepository.findById(anyLong())).thenReturn(Optional.empty()); + MissionUpdateRequest updateRequest = new MissionUpdateRequest("Test Mission"); + + // When & Then + assertThrows(CustomException.class, () -> missionService.updateMission(1L, updateRequest)); + } + + @Test + public void 미션_삭제_성공() { + // Given + doNothing().when(missionRepository).deleteById(anyLong()); + + // When + missionService.deleteMission(1L); + + // Then + verify(missionRepository, times(1)).deleteById(anyLong()); + } +} diff --git a/src/test/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepositoryTest.java b/src/test/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepositoryTest.java new file mode 100644 index 00000000..354ef647 --- /dev/null +++ b/src/test/java/com/depromeet/stonebed/domain/mission/dao/MissionHistoryRepositoryTest.java @@ -0,0 +1,64 @@ +package com.depromeet.stonebed.domain.mission.dao; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.depromeet.stonebed.TestQuerydslConfig; +import com.depromeet.stonebed.domain.mission.domain.Mission; +import com.depromeet.stonebed.domain.mission.domain.MissionHistory; +import java.time.LocalDate; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@DataJpaTest +@Import(TestQuerydslConfig.class) +public class MissionHistoryRepositoryTest { + + @Autowired private MissionRepository missionRepository; + @Autowired private MissionHistoryRepository missionHistoryRepository; + + @Test + public void 미션_히스토리_생성_성공() { + // Given: 오늘 날짜를 기준으로 미션 히스토리가 있다 (생성할 예정) + Mission mission = Mission.builder().title("Test Mission").build(); + missionRepository.save(mission); + LocalDate today = LocalDate.now(); + + MissionHistory missionHistory = + MissionHistory.builder().mission(mission).assignedDate(today).build(); + + // When: 미션 히스토리를 저장하면 + MissionHistory savedMissionHistory = missionHistoryRepository.save(missionHistory); + + // Then: 저장된 미션 히스토리가 올바른지 검증한다 + assertThat(savedMissionHistory.getId()).isNotNull(); + assertThat(savedMissionHistory.getMission().getTitle()).isEqualTo("Test Mission"); + assertThat(savedMissionHistory.getAssignedDate()).isEqualTo(today); + } + + @Test + public void 미션_히스토리_특정_날짜_조회_성공() { + // Given: 오늘 날짜를 기준으로 저장된 객체가 있다 + Mission mission = Mission.builder().title("Test Mission").build(); + missionRepository.save(mission); + LocalDate today = LocalDate.now(); + + MissionHistory missionHistory = + MissionHistory.builder().mission(mission).assignedDate(today).build(); + missionHistoryRepository.save(missionHistory); + + // When: 특정 날짜(오늘)의 미션 히스토리를 가져오면 + Optional foundMissionHistory = + missionHistoryRepository.findByAssignedDate(today); + + // Then: 가져온 미션 히스토리가 올바른지 검증한다 + assertThat(foundMissionHistory).isPresent(); + assertThat(foundMissionHistory.get().getMission().getTitle()).isEqualTo("Test Mission"); + assertThat(foundMissionHistory.get().getAssignedDate()).isEqualTo(today); + } +} diff --git a/src/test/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryTest.java b/src/test/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryTest.java new file mode 100644 index 00000000..7f40128e --- /dev/null +++ b/src/test/java/com/depromeet/stonebed/domain/mission/dao/MissionRepositoryTest.java @@ -0,0 +1,58 @@ +package com.depromeet.stonebed.domain.mission.dao; + +import static org.assertj.core.api.Assertions.*; + +import com.depromeet.stonebed.TestQuerydslConfig; +import com.depromeet.stonebed.domain.mission.domain.Mission; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@DataJpaTest +@Import(TestQuerydslConfig.class) +public class MissionRepositoryTest { + + @Autowired private MissionRepository missionRepository; + + @Test + public void 미션_생성_성공() { + // Given + Mission mission = Mission.builder().title("Test Mission").build(); + + // When + Mission savedMission = missionRepository.save(mission); + + // Then + assertThat(savedMission.getId()).isNotNull(); + assertThat(savedMission.getTitle()).isEqualTo("Test Mission"); + } + + @Test + public void 고유번호로_미션_조회_성공() { + // Given + Mission mission = Mission.builder().title("Test Mission").build(); + missionRepository.save(mission); + + // When + Mission foundMission = missionRepository.findById(mission.getId()).orElse(null); + + // Then + assertThat(foundMission).isNotNull(); + assertThat(foundMission.getTitle()).isEqualTo("Test Mission"); + } + + @Test + public void 미션_삭제_성공() { + // Given + Mission mission = Mission.builder().title("Test Mission").build(); + mission = missionRepository.save(mission); + + // When + missionRepository.deleteById(mission.getId()); + Mission deletedMission = missionRepository.findById(mission.getId()).orElse(null); + + // Then + assertThat(deletedMission).isNull(); + } +} diff --git a/src/test/java/com/depromeet/stonebed/domain/missionRecord/MissionRecordServiceTest.java b/src/test/java/com/depromeet/stonebed/domain/missionRecord/MissionRecordServiceTest.java new file mode 100644 index 00000000..8666a1f4 --- /dev/null +++ b/src/test/java/com/depromeet/stonebed/domain/missionRecord/MissionRecordServiceTest.java @@ -0,0 +1,174 @@ +package com.depromeet.stonebed.domain.missionRecord; + +import static org.assertj.core.api.BDDAssertions.then; +import static org.mockito.Mockito.*; + +import com.depromeet.stonebed.domain.member.domain.Member; +import com.depromeet.stonebed.domain.mission.dao.MissionHistoryRepository; +import com.depromeet.stonebed.domain.mission.domain.MissionHistory; +import com.depromeet.stonebed.domain.missionRecord.application.MissionRecordService; +import com.depromeet.stonebed.domain.missionRecord.dao.MissionRecordRepository; +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord; +import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecordStatus; +import com.depromeet.stonebed.domain.missionRecord.dto.response.MissionRecordCalendarResponse; +import com.depromeet.stonebed.global.error.ErrorCode; +import com.depromeet.stonebed.global.error.exception.CustomException; +import com.depromeet.stonebed.global.util.MemberUtil; +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.FieldReflectionArbitraryIntrospector; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@ExtendWith(MockitoExtension.class) +public class MissionRecordServiceTest { + + @InjectMocks private MissionRecordService missionRecordService; + + @Mock private MissionRecordRepository missionRecordRepository; + @Mock private MissionHistoryRepository missionHistoryRepository; + @Mock private MemberUtil memberUtil; + + private FixtureMonkey fixtureMonkey; + + @BeforeEach + void setUp() { + fixtureMonkey = + FixtureMonkey.builder() + .objectIntrospector(FieldReflectionArbitraryIntrospector.INSTANCE) + .defaultNotNull(true) + .build(); + } + + @Test + void 미션기록_성공() { + // given + Long missionId = 1L; + + MissionHistory missionHistory = fixtureMonkey.giveMeOne(MissionHistory.class); + Member member = fixtureMonkey.giveMeOne(Member.class); + MissionRecord missionRecord = + fixtureMonkey + .giveMeBuilder(MissionRecord.class) + .set("missionHistory", missionHistory) + .set("member", member) + .set("status", MissionRecordStatus.COMPLETED) + .sample(); + + when(missionHistoryRepository.findLatestOneByMissionId(missionId)) + .thenReturn(Optional.of(missionHistory)); + when(memberUtil.getCurrentMember()).thenReturn(member); + when(missionRecordRepository.save(any(MissionRecord.class))).thenReturn(missionRecord); + + // when + missionRecordService.saveMission(missionId); + + // then + verify(missionHistoryRepository).findLatestOneByMissionId(missionId); + verify(memberUtil).getCurrentMember(); + verify(missionRecordRepository).save(any(MissionRecord.class)); + } + + @Test + void 미션기록삭제_성공() { + // given + Long recordId = 1L; + MissionRecord missionRecord = fixtureMonkey.giveMeOne(MissionRecord.class); + + when(missionRecordRepository.findById(recordId)).thenReturn(Optional.of(missionRecord)); + + // when + missionRecordService.deleteMissionRecord(recordId); + + // then + verify(missionRecordRepository).findById(recordId); + verify(missionRecordRepository).delete(missionRecord); + } + + @Test + void 미션기록삭제_실패() { + // given + Long recordId = 1L; + + when(missionRecordRepository.findById(recordId)).thenReturn(Optional.empty()); + + // when & then + CustomException exception = + org.junit.jupiter.api.Assertions.assertThrows( + CustomException.class, + () -> missionRecordService.deleteMissionRecord(recordId)); + + then(exception.getErrorCode()).isEqualTo(ErrorCode.MISSION_RECORD_NOT_FOUND); + + verify(missionRecordRepository).findById(recordId); + verify(missionRecordRepository, never()).delete(any(MissionRecord.class)); + } + + @Test + void 캘린더미션기록조회_성공() { + // given + Member member = fixtureMonkey.giveMeOne(Member.class); + List missionRecords = fixtureMonkey.giveMe(MissionRecord.class, 5); + + when(memberUtil.getCurrentMember()).thenReturn(member); + when(missionRecordRepository.findByMemberIdWithPagination(anyLong(), any(Pageable.class))) + .thenReturn(missionRecords); + + String cursor = null; + int limit = 5; + + // when + MissionRecordCalendarResponse response = + missionRecordService.getMissionRecordsForCalendar(cursor, limit); + + // then + then(response).isNotNull(); + then(response.list()).isNotEmpty(); + + verify(memberUtil).getCurrentMember(); + verify(missionRecordRepository) + .findByMemberIdWithPagination( + member.getId(), + PageRequest.of(0, limit, Sort.by(Sort.Direction.ASC, "createdAt"))); + } + + @Test + void 미션참여_성공() { + // given + Long missionId = 1L; + + MissionHistory missionHistory = fixtureMonkey.giveMeOne(MissionHistory.class); + Member member = fixtureMonkey.giveMeOne(Member.class); + MissionRecord missionRecord = + fixtureMonkey + .giveMeBuilder(MissionRecord.class) + .set("missionHistory", missionHistory) + .set("member", member) + .set("status", MissionRecordStatus.IN_PROGRESS) + .sample(); + + when(memberUtil.getCurrentMember()).thenReturn(member); + when(missionHistoryRepository.findLatestOneByMissionId(missionId)) + .thenReturn(Optional.of(missionHistory)); + when(missionRecordRepository.findByMemberAndMissionHistory(member, missionHistory)) + .thenReturn(Optional.of(missionRecord)); + when(missionRecordRepository.save(any(MissionRecord.class))).thenReturn(missionRecord); + + // when + missionRecordService.startMission(missionId); + + // then + verify(memberUtil).getCurrentMember(); + verify(missionHistoryRepository).findLatestOneByMissionId(missionId); + verify(missionRecordRepository).findByMemberAndMissionHistory(member, missionHistory); + verify(missionRecordRepository).save(any(MissionRecord.class)); + } +} diff --git a/src/test/java/com/depromeet/stonebed/fixtureMonkeyTest/Order.java b/src/test/java/com/depromeet/stonebed/fixtureMonkeyTest/Order.java new file mode 100644 index 00000000..247171d9 --- /dev/null +++ b/src/test/java/com/depromeet/stonebed/fixtureMonkeyTest/Order.java @@ -0,0 +1,43 @@ +package com.depromeet.stonebed.fixtureMonkeyTest; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import jakarta.validation.constraints.Size; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +@Data +public class Order { + @NotNull private Long id; + + @NotBlank private String orderNo; + + private OrderType orderType; + + @Size(min = 2, max = 10) + private String productName; + + @Min(1) + @Max(100) + private int quantity; + + @Min(0) + private long price; + + private long totalPrice; + + private List<@NotBlank @Size(max = 10) String> items = new ArrayList<>(); + + @PastOrPresent private Instant orderedAt; + + public enum OrderType { + SMARTSTORE, + BLOG, + CAFE, + } +} diff --git a/src/test/java/com/depromeet/stonebed/fixtureMonkeyTest/TestFixtureMonkey.java b/src/test/java/com/depromeet/stonebed/fixtureMonkeyTest/TestFixtureMonkey.java new file mode 100644 index 00000000..0cc9c5d9 --- /dev/null +++ b/src/test/java/com/depromeet/stonebed/fixtureMonkeyTest/TestFixtureMonkey.java @@ -0,0 +1,85 @@ +package com.depromeet.stonebed.fixtureMonkeyTest; + +import static org.assertj.core.api.BDDAssertions.*; + +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.FieldReflectionArbitraryIntrospector; +import net.jqwik.api.Arbitraries; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class TestFixtureMonkey { + + @Test + void checkPerson() { + // given + FixtureMonkey sut = + FixtureMonkey.builder() + .objectIntrospector(FieldReflectionArbitraryIntrospector.INSTANCE) + .defaultNotNull(true) + .build(); + + // when + User person = + sut.giveMeBuilder(User.class) + .set("personId", Arbitraries.strings().ofMinLength(1).ofMaxLength(16)) + .set("personName", Arbitraries.strings().ofMinLength(1).ofMaxLength(16)) + .set("age", Arbitraries.integers().between(1, 100)) + .set("personNo", Arbitraries.strings().ofMinLength(1).ofMaxLength(16)) + .set( + "addressList", + Arbitraries.strings() + .ofMaxLength(16) + .list() + .ofMinSize(1) + .ofMaxSize(2)) + .sample(); + + // then + then(person.getPersonId()).isNotNull(); + then(person.getPersonNo().length()).isBetween(1, 16); + } + + @Test + void testOrder() { + // given + FixtureMonkey sut = + FixtureMonkey.builder() + .objectIntrospector(FieldReflectionArbitraryIntrospector.INSTANCE) + .defaultNotNull(true) + .build(); + + // when + Order actual = + sut.giveMeBuilder(Order.class) + .set( + "orderNo", + Arbitraries.strings() + .ofMinLength(1) + .ofMaxLength(16) + .map(it -> "orderNo-" + it)) + .set("productName", Arbitraries.strings().ofMinLength(2).ofMaxLength(10)) + .set("price", Arbitraries.longs().between(0, 1000)) + .set("quantity", Arbitraries.integers().between(1, 100)) + .set( + "items", + Arbitraries.strings() + .ofMaxLength(10) + .list() + .ofMinSize(1) + .ofMaxSize(2)) + .sample(); + + // then + then(actual.getId()).isNotNull(); // @NotNull + then(actual.getOrderNo()).isNotBlank(); // @NotBlank + then(actual.getProductName().length()).isBetween(2, 10); // @Size(min = 2, max = 10) + then(actual.getQuantity()).isBetween(1, 100); // Min(1) @Max(100) + then(actual.getPrice()).isGreaterThanOrEqualTo(0); // @Min(0) + then(actual.getItems()).hasSizeLessThan(3); // @Size(max = 3) + then(actual.getItems()).allMatch(it -> it.length() <= 10); // @NotBlank @Size(max = 10) + } +} diff --git a/src/test/java/com/depromeet/stonebed/fixtureMonkeyTest/User.java b/src/test/java/com/depromeet/stonebed/fixtureMonkeyTest/User.java new file mode 100644 index 00000000..069aa5fb --- /dev/null +++ b/src/test/java/com/depromeet/stonebed/fixtureMonkeyTest/User.java @@ -0,0 +1,30 @@ +package com.depromeet.stonebed.fixtureMonkeyTest; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import lombok.Data; + +@Data +public class User { + + @NotNull private String personId; + + @NotBlank private String personName; + + private Gender gender; + + @Size(min = 1, max = 16) + private String personNo; + + @Size(min = 1, max = 16) + private int age; + + private List addressList; + + public enum Gender { + MAN, + WOMAN + } +} diff --git a/src/test/java/com/depromeet/stonebed/global/util/SpringEnvironmentUtilTest.java b/src/test/java/com/depromeet/stonebed/global/util/SpringEnvironmentUtilTest.java new file mode 100644 index 00000000..244ee754 --- /dev/null +++ b/src/test/java/com/depromeet/stonebed/global/util/SpringEnvironmentUtilTest.java @@ -0,0 +1,111 @@ +package com.depromeet.stonebed.global.util; + +import static com.depromeet.stonebed.global.common.constants.EnvironmentConstants.Constants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.Environment; + +@ExtendWith(MockitoExtension.class) +class SpringEnvironmentUtilTest { + private static final String[] PROD_ARRAY = new String[] {PROD_ENV}; + private static final String[] DEV_ARRAY = new String[] {DEV_ENV}; + private static final String[] LOCAL_ARRAY = new String[] {LOCAL_ENV}; + @Mock private Environment environment; + @InjectMocks private SpringEnvironmentUtil springEnvironmentUtil; + + @Test + void 상용_환경이라면_isProdProfile은_true를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(PROD_ARRAY); + + // when + // then + assertTrue(springEnvironmentUtil.isProdProfile()); + } + + @Test + void 상용_환경이_아니라면_isProdProfile은_false를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(DEV_ARRAY); + + // when + // then + assertFalse(springEnvironmentUtil.isProdProfile()); + } + + @Test + void 테스트_환경이라면_isDevProfile은_true를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(DEV_ARRAY); + + // when + // then + assertTrue(springEnvironmentUtil.isDevProfile()); + } + + @Test + void 테스트_환경이_아니라면_isDevProfile은_false를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(LOCAL_ARRAY); + + // when + // then + assertFalse(springEnvironmentUtil.isDevProfile()); + } + + @Test + void 로컬_환경이라면_isProdAndDevProfile은_false를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(LOCAL_ARRAY); + + // when + // then + assertFalse(springEnvironmentUtil.isProdAndDevProfile()); + } + + @Test + void 로컬_환경이_아니라면_isProdAndDevProfile은_true를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(DEV_ARRAY); + + // when + // then + assertTrue(springEnvironmentUtil.isProdAndDevProfile()); + } + + @Test + void 상용_환경이라면_getCurrentProfile는은_prod를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(PROD_ARRAY); + + // when + // then + assertEquals(springEnvironmentUtil.getCurrentProfile(), PROD_ENV); + } + + @Test + void 테스트_환경이라면_getCurrentProfile는은_dev를_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(DEV_ARRAY); + + // when + // then + assertEquals(springEnvironmentUtil.getCurrentProfile(), DEV_ENV); + } + + @Test + void 로컬_환경이라면_getCurrentProfile는은_local을_반환한다() { + // given + given(environment.getActiveProfiles()).willReturn(LOCAL_ARRAY); + + // when + // then + assertEquals(springEnvironmentUtil.getCurrentProfile(), LOCAL_ENV); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..a13f2fde --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,12 @@ +spring: + config: + activate: + on-profile: "test" + + datasource: + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=MYSQL + + jpa: + properties: + hibernate: + default_batch_fetch_size: 1000