From 67aa516d66b0d6c997677864c056f0093e3fa4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EA=B2=B8?= <67851124+Mingyum-Kim@users.noreply.github.com> Date: Sun, 17 Sep 2023 18:04:09 +0900 Subject: [PATCH] merge dev to staging server (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 상위 목표 엔티티 구현 (#5) * chore: Lombok, JPA 의존성 추가 * feat: plan 엔티티 내 유효성 검사 구현 * feat: PlanRepository 생성 * test: plan 엔티티 테스트 작성 - 상위 목표 제목 길이 validation 테스트 - 상위 목표 시작, 달성 날짜 validation 테스트 - 상위 목표 제목 길이 Figma 참고해서 15자 제한 * feat: 상위 목표 기간 LocalDate 타입으로 변경 (#3) 기간을 설정할때 연월일 까지만 데이터를 받기에 시,분까지 데이터를 받는 LocalDateTime은 불필요하다고 판단했음. * [Feature/#2] 소셜 로그인 구현 (#4) * feat: BaseEntity 작성 global/entity 폴더에 created_at, updated_at 필드를 넣는 BaseEntity 클래스를 작성하였습니다. * feat: User Entity 생성 (#2) DB에 매핑될 User 객체를 생성하였습니다. Kakao, Apple 두 개의 소셜 타입을 구분할 SocialType 필드를 enum Type으로 추가하였습니다. * chore: Swagger, Logging 클래스 global 폴더로 이동 * feat: OAuthController 생성 및 콜백 메서드 생성 1. 클라이언트가 AccessToken을 전달할 때 접근하는 'POST /auth' 콜백 주소를 연결할 메서드를 생성하였습니다. 2. 컨트롤러 메서드의 request, response dto를 record 클래스로 정의하였습니다. * feat: login 로직을 구현한 OAuthService 생성 1. 팩토리 메서드 패턴을 적용하여 OAuthClient 인터페이스를 provider에 따라 구현체를 매핑하도록 설정하였습니다. 2. OAuthClient에서 소셜 사용자의 정보를 불러오고 User 객체로 변환하였습니다. 3. User가 기 가입한 회원이라면 바로 반환, 그렇지 않다면 DB에 등록 후 반환하기 위해 UserService에서 로직을 구현하였습니다. 4. 마지막으로 회원에 대한 JWT 를 발급하여 반환하였습니다. * build: syntax error 수정 * build: syntax error 수정 * build: Junit Test 관련 권한 추가 * feat: WebClient를 이용해 카카오 인증서버에서 사용자 추출 (#2) KakaoClient 구현체에서 WebClient 라이브러리를 사용해 kapi.kakao.com 서버에 접근하여 사용자 정보 (KakaoUserInfo)를 추출하는 코드입니다. * feat: JwtProvider 클래스 생성 1. createToken 메서드 2. validateToken 메서드 * chore: .gitignore 수정 * feat: 애플 로그인 구현 * refactor: OAuthHandler에서 Provider별 클래스 분리 구현 * test: OAuthServiceTest 로그인 성공 테스트 생성(#2) * feat: KakaoClient BASE_URL과 URI 분리 * style: dto 폴더구조 request, response 나눠서 변경 * refactor: SocialId만 이용해서 사용자 조회하도록 변경 * refactor: BaseEntity에 자동으로 날짜를 주입하도록 설정 --------- Co-authored-by: MingyeomKim <67851124+MingyeomKim@users.noreply.github.com> * [Feature/#7] 설정 파일 submodule로 관리 (#8) * build: db 정보 암호화를 위한 submodules 설정(#7) * build: github Secrets 설정 수정 * fix: build.yml의 들여쓰기 오류 수정 * feat: 빌드 시 submodule 설정 파일 불러올 수 있도록 build.gradle 수정 * build: deploy.yml ACTION_TOKEN 추가 * build: build.gradle 라이브러리 중복 제거 * "build: build.gradle에 active profile 설정 추가 (#7) " * feat: dev 환경 데이터베이스 설정 추가 * build: dev 환경에서 빌드되도록 수정 * fix: build.yml의 test 명령어 수정 * fix: build.yml의 Publish unit test r구문 수정 * fix: build.yml test 파일 경로 수정" * "build: test 시 h2 database에서 작동하도록 application-tets.yml 추가 * chore: .gitignore 수정 * build: test profile을 test로 변경 * test: config 설정 수정 * build: build.yml의 Publish Unit Test Results에 토큰 추가 * build: test-results files 경로 수정 * build: github actions permissions를 write 권한으로 변경 * build: build.yml에 issues: write 권한 추가 * refactor: User 명칭을 Member로 변경 --------- Co-authored-by: MingyeomKim <67851124+MingyeomKim@users.noreply.github.com> * [Feature/#6] 공통 응답 추가 및 상위 목표 단건 조회 기능 구현 (#9) * feat: 상위 목표 d-day 기능 구현, 테스트 작성 (#6) - 상위 목표 종료 일자 필수 입력으로 확정 후, d-day 계산 로직 plan 엔티티내 생성 후 테스트 - planService에 상위 목표 단건 조회 로직 추가 * feat: 공통 응답 스펙 추가 * feat: 상위 목표 삭제 기능 추가 - Service에 삭제 기능 추가, 차후 테스트 필요 - 상위 목표 생성 Controller DTO 추가 * chore: application.yml 수정 * test: CI 테스트 * refactor: controller내 pathVariable 제거 * chore: build.yml을 테스트 환경에서 동작하도록 수정 * refactor: 소셜 로그인 시 input을 userId로 맞추어 수정 (#11) (#12) Co-authored-by: MingyeomKim <67851124+MingyeomKim@users.noreply.github.com> * [Feature/#13] TokenProvider 클래스 생성 및 Refresh Token 반환 (#14) * feat: Refresh token을 생성하도록 TokenProvider 클래스 추가 * test: TokenProvider 단위 테스트 생성 * test: TokenProviderTest 오류 수정 --------- Co-authored-by: MingyeomKim <67851124+MingyeomKim@users.noreply.github.com> * [Feature/#10] 상위 목표 리스트 조회 기능 구현 (#15) * feat: 상위 목표 d-day 기능 구현, 테스트 작성 (#6) - 상위 목표 종료 일자 필수 입력으로 확정 후, d-day 계산 로직 plan 엔티티내 생성 후 테스트 - planService에 상위 목표 단건 조회 로직 추가 * chore: git 캐시 초기화 * feat: 상위 목표 생성, 수정 기능 구현 (#10) * refactor: 상위 목표 도메인 명을 Plan에서 Goal로 변경 - Goal이라는 단어가 목표라는 의미를 더 잘 표현하는 것 같아서 변경하게 되었습니다 * refactor: 공통 응답 스펙명 CustomResponse로 변경 - Swagger에 있는 ApiResponse와 네이밍이 같아서 패키지 명 전체를 명시해야 함. 따라서 customResponse로 명칭 변경함 * feat: 공통 예외 스펙 추가 - 공통 예외 스펙과 exceptionHandler 추가 * chore: QueryDSL 세팅 * feat: 상위 목표 페이징 조회 기능 개발 - 커서 기반 페이지네이션을 사용 - Service 통합 테스트 후 Controller 개발 예정 * test: 상위 목표 페이징 조회 기능 테스트 - 테스트 데이터간 독립성을 위해 databaseCleaner 추가 * test: 페이징 기능 응답 스펙 변경 - 기존의 Slice의 반환 형식이 no offset 기반 페이징에서는 불필요하다고 판단되어 필요한 데이터만 파싱 처리함 * refactor: 상위 목표 통계 기능 삭제 및 테스트 수정 * feat: 상위 목표 상태별 개수 조회 기능 추가 - 기능 추가 및 테스트 진행 완료 * feat: 상위 목표 리스트에 dDay 컬럼 추가 - Swagger 어노테이션 수정 * feat: ErrorCode에 상위 목표 예외 추가 * test: GoalStatus 변경 * refactor: CustomResponse 오타 수정 * refactor: Swagger ApiResponse 제거 ApiResponse로 응답값을 제대로 표현할 수 없다고 판단되어 우선은 배제하기로 결정 * refactor: GlobalExceptionHandler 수정 * refactor: 클래스 내 빈공간 제거 * refactor: Tag와 Operation 재배치 * [Feature/#17] refresh token 발급 시 Redis에 저장하도록 설정 (#18) * feat: Redis 저장소 추가 및 세팅 (#17) * docs: @Parameter 어노테이션으로 OAuthController 파라미터 설명 추가 * refactor: 소문자 provider를 대문자로 변경하는 함수 작성 * feat: OAuthController에 issue 메서드 추가 (#17) * refactor: socialId를 uid로, socialType을 provider로 명칭 변경 socialId가 사실상 사용자 식별 필드로 사용된다. 그러나 문자열인 socialId를 PK로 두는 것은 한계가 있기 때문에 uid로 명칭을 변경한다. 또한, Provider와 SocialType 명칭도 겹치기 때문에 provider라는 단어로 통일한다 * feat: RefreshToken, RefreshTokenRepository 작성 (#17) * feat: 액세스 토큰 만료 시 재발급 요청하는 reissue 서비스 코드 작성 (#17) * feat: 로그인 시 refresh token을 redis에 저장하는 기능 추가 (#17) * refactor: Auth 비즈니스 로직 Exception 처리 * feat: Redis 설정 수정 * refactor: login 호출 시 잘못된 provider가 입력되는 경우 예외처리 * test: reissue, login fail 테스트 케이스 추가 (#17) * refactor: CustomResponse를 사용하여 Controller 반환 * feat: @Valid 어노테이션 추가 * ci: 배포 시 테스트되지 않도록 deploy.yml 수정 * build: build.yml에 Redis 설정 추가 * build: redis port 입력 방식 수정 * refactor: 풀 리퀘스트 리뷰 반영 --------- Co-authored-by: MingyeomKim <67851124+MingyeomKim@users.noreply.github.com> * [Feature/#16] 하위 목표 기능 구현 (#20) * feat: 상위 목표 d-day 기능 구현, 테스트 작성 (#6) - 상위 목표 종료 일자 필수 입력으로 확정 후, d-day 계산 로직 plan 엔티티내 생성 후 테스트 - planService에 상위 목표 단건 조회 로직 추가 * feat: detail_goal 엔티티 설계 (#16) * feat: detailGoal 엔티티 설계 (#16) * feat: detailGoal 전체 조회 기능 구현 (#16) * feat: detailGoal 전체 조회 Controller 구현 * feat: detailGoal 저장 기능 구현 (#16) * refactor: 에러 코드 수정 - Goal 에러 코드 수정 - Detail Goal 에러 코드 추가 * feat: detail goal 상세 조회 응답값 변경 (#16) - 요일 정보를 EnumSet으로 관리하는걸 생각했으나 엔티티에는 적용이 되지 않아 List로 관리하기로 결정 * refactor: detail goal 요청 시 요일 정보 string으로 변경 * feat: detail goal 추가 시 goal 내부 카운트 증가 로직 추가 (#16) * feat: detail goal 수정, 삭제 기능 추가 (#16) * feat: detail goal 달성, 달성 해제 기능 구현 (#16) * test: detail goal 서비스 테스트 작성 (#16) * refactor: application-prod.yml 임시 파일 삭제 * feat: detail goal, goal 조회 쿼리에 isDeleted 판별 조건 추가 (#16) 기본적으로 soft delete를 사용하기 때문에 isDeleted에 대한 판별 조건을 포함해야 한다 * feat: goal 삭제 시 연관 detail goal 삭제 로직 추가 (#16) * feat: 전체 detail goal과 달성 detail goal 계산 및 조회 로직 추가 (#16) * [Feature/#21] 보관함 복구 기능, 회고 개수 조회 기능 구현 (#22) * feat: 상위 목표 d-day 기능 구현, 테스트 작성 (#6) - 상위 목표 종료 일자 필수 입력으로 확정 후, d-day 계산 로직 plan 엔티티내 생성 후 테스트 - planService에 상위 목표 단건 조회 로직 추가 * 간격 수정 * feat: 상위 목표 복구 기능 구현 (#21) * refactor: transactional 어노테이션 추가 * feat: LocalTime 요청 응답 값 오전/오후 반영하도록 변경 (#21) * feat: 상위 목표 제약 조건 수정 (#21) - 현재 보다 이전 날짜를 시작일로 선택 불가능하다는 조건이 없기에 조건 제거 - 보관함에서 복구 시 시작, 종료 날짜를 초기화 예정이기에 nullable을 true로 변경 * feat: 상위 목표 복구 기능 수정 (#21) - 복구 시 새로운 데이터로 초기화해야 복구가 완료 되도록 설정 * feat: 회고 작성 가능한 완료 목표 개수 조회 (#21) * feat: goal 생성시, goalstatus 생성자 주입으로 변경 - goalstatus 값을 변경해서 테스트 해야 할 경우들이 있다고 판단되어, 테스트 코드 편의성과 changeGoalStatus라는 setter 메서드를 제거하기 위해 작업을 진행함 * test: 테스트 작성 완료 * refactor: GoalController내 위치 변경 * application.yml 제거 * refactor: goalController 공통 URI 통일 * [Feature/#23] 하위 목표 모두 성공시 보상 제공 및 팝업 기능 구현 (#25) * feat: 상위 목표 d-day 기능 구현, 테스트 작성 (#6) - 상위 목표 종료 일자 필수 입력으로 확정 후, d-day 계산 로직 plan 엔티티내 생성 후 테스트 - planService에 상위 목표 단건 조회 로직 추가 * 간격 수정 * feat: 보상 제공 기능 구현 (#23) * feat: 상위 목표 달성 시 팝업 기능 구현 (#23) - 랜덤으로 보석 지급 기능 구현 - 상위 목표 리스트 조회 시 보석 enum도 조회 추가 - 보석 지급과 함께 complete 상태의 상위 목표 개수 조회 * test: 상위 목표 성공 시 기능 테스트 작성 (#23) * refactor: 상위 목표 개수 반환값 및 주석 수정 * [Feature/#24] 채움함 내 상위 목표의 종료 기간 경과 시 보관함으로 이동하는 스케쥴러 구현 (#26) * feat: 상위 목표 d-day 기능 구현, 테스트 작성 (#6) - 상위 목표 종료 일자 필수 입력으로 확정 후, d-day 계산 로직 plan 엔티티내 생성 후 테스트 - planService에 상위 목표 단건 조회 로직 추가 * 간격 수정 * feat: 0시 0분에 호출되는 스케쥴러 구현 (#24) * .gitignore 초기화 * test: 스케쥴러 테스트 작성 * test: 스케쥴러 테스트 작성 (#24) * feat: cron 수정 (#24) * Cicd test (#27) * feat: 상위 목표 d-day 기능 구현, 테스트 작성 (#6) - 상위 목표 종료 일자 필수 입력으로 확정 후, d-day 계산 로직 plan 엔티티내 생성 후 테스트 - planService에 상위 목표 단건 조회 로직 추가 * 간격 수정 * 서브 모듈 수정 * ci: redis container 설정 추가 * ci: SuperCharge의 Redis Server를 사용하도록 수정 * ci : build시 submodule을 가져오도록 설정 * ci: deploy 대상을 dev 브랜치로만 변경 * ci: deploy.yml의 submodule를 recursive로 설정 * ci: submodule 업데이트 * ci: dev profile로 빌드하도록 수정 및 submodule update 구문 추가 * [Feature/#19] 로그아웃, 회원 탈퇴 구현 및 Custom Filter 생성 후 Spring Security 적용 (#29) * feat: 로그아웃 기능 추가 (#19) * feat: Redis에 블랙 리스트 기능 추가 (#19) * refactor: BlackList 도메인에서 status 제외 * feat: 회원 탈퇴 기능 구현 (#19) * feat: reissue 시 refresh token도 새로 발급하도록 수정 * feat: Security Config 생성 및 OAuthController URI 통일 * feat: JwtFilter 구현 및 TokenProvider의 getAuthentication 작성 * feat: 인증, 인가 로직 실패 시 Exception Handler 작성 * feat: Q클래스 생성 * chore: SecurityConfig 작성 * feat: SecurityConfig 설정 추가 * test: TokenProviderTest의 멤버 호출 구문 Mocking * ci: build.yml, deploy.yml에서 submodule 자동 업데이트하도록 수정 * refactor: 토큰 유효성 검사 중복 제거 * feat: 회원 탈퇴 시 블랙 리스트에 추가 * feat: jwtFilter에서 블랙리스트 조회하는 로직 추가 * refactor: OAuthController의 함수 명칭을 일관되게 변경 * build: 라이브러리 꼬임 해결 * ci: java 실행 시 sudo 권한 추가 * docs: swagger cors에러 수정 * chore: 모든 URL 접근 가능하도록 설정 * refactor: jwtFilter에서 예외 던지지 않도록 수정 * chore: 모든 경로 접근 허용 * chore: 모든 경로 접근 허용 * refactor: JwtFilter에서 모든 Exception을 제외 * refactor: CORS 에러 해결 * chore: swagger에 authorization 인증 받을 수 있도록 수정 * chore: SwaggerConfig 수정 * build: run_new_was.sh 스크립트 들여쓰기 수정 * chore: run_new_was.sh가 동작 확인을 위한 로그 출력 * build: run_new_was.sh 에서 구동할 포트를 죽이는 명령어 수정 * feat: 회원 탈퇴 컨트롤러 URL추가 * [Feature/#31] 유저 로그인 시 fcm token을 저장하는 로직 구현 (#32) * feat: 로그인 시 fcm token을 함께 전달받도록 수정 * feat: Fcm Token 저장 및 조회 로직 추가 * [Feature/#28] 알림 요일과 시간에 따라 FCM 알림을 전송하는 기능 구현 (#35) * feat: 상위 목표 d-day 기능 구현, 테스트 작성 (#6) - 상위 목표 종료 일자 필수 입력으로 확정 후, d-day 계산 로직 plan 엔티티내 생성 후 테스트 - planService에 상위 목표 단건 조회 로직 추가 * 간격 수정 * 최신 사항 머지 * git cache 초기화 * feat: 푸시 알람 스케쥴러 구현 (#28) - 매일 30분 간격으로 작동하는 스케쥴러 구현 - FCM 서비스를 호출하는 event 구현 * feat: 요일, 시간별 알림 전송 스케쥴러 구현 (#28) * test: detailGoalQueryRepository 테스트 추가 (#28) * refactor: 공통 응답 스펙 수정 * docs: swagger 문서 수정 * docs: 코드,메세지 설명 추가 * feat: 응답 Response에 Schema 추가 (#36) * chore: 토큰의 만료시간 임의 변경 * locale (#39) * fix: locale 수정 * JsonFormat pattern 변경 (#41) * fix: localtime 형식 변경 * [Feature/#37] 회고 도메인 생성 및 저장, 조회 로직 구현 (#42) * refactor: fcmToken 조회 로직 수정, logout, withdraw에 추가 * feat: 회고 도메인 생성 (#37) * feat: 회고 도메인의 Repository 생성 * feat: 회고 저장 및 조회 로직 구현 (#37) * feat: 회고의 내용을 담는 RetroSpectContent 분리 (#37) * test: 회고 저장 및 조회 서비스 로직 테스트 * feat: 회고 저장 및 조회 컨트롤러 구현 (#37) * refactor: 의존성 관련 오류 수정 * [Refactor/#37] 회고 기능 관련 리팩토링 (#44) * LocalDate JSON 응답 형식 변경 (#45) * refactor: GoalController LocalTime 형식 변경 * chore: JPA의 ddl-auto를 create로 설정 * build: 빌드 시 워크플로우에서 secrets 불러오는 것 제외 * feat: Retrospect Response에 hasGuide 컬럼 추가 * build: build.yml 워크플로우에서 서브모듈 로직 제외 * refactor: contents의 @Column 삭제 * chore: run_new_was.sh의 내용 일부 수정 * chore: config의 application-dev.yml 수정 * refactor: Retrospect와 Content를 양방향 매핑으로 변경 * refactor: retrospect content가 retrospect를 가져오지 못하는 부분 수정 * chore: ddl-auto를 create로 변경 * [Feature/#38] FCM 푸시 알림 기능 연동 후 하위 목표 알림 스케쥴러와 연결한다 (#46) * feat: FCM 알림 설정 및 연동 (#38) * refactor: GoalController LocalTime 형식 변경 * feat: FCM 알림 스케쥴링 기능 복구 * refactor: 테스트 수정 및 도메인 디렉토리 구조 변경 * refactor: DetailGoal 수정 시 locale 정보 변경 * submodule내 FCM 설정 파일 경로 수정 (#50) * fix: submodule에 fcm_key.json 경로 수정 * 하위 목표 시간 locale 응답 ko로 수정 (#51) * refactor: 하위 목표 응답 시간 locale ko로 수정 * 상위 목표 개수 카운트 쿼리 수정 (#52) - 삭제 여부 판단 로직을 쿼리의 having 부분에서 where 절로 옮김 * [Refactor/#47] 사용자 인증 필터에서 발생하는 예외 핸들러 필터 구현 (#49) * feat: 예외를 발생시키는 JwtExceptionFilter 클래스 생성 * refactor: TokenProvider의 validateToken이 JwtException을 반환하도록 수정 * refactor: 따로 처리가 필요한 인증 예외 클래스를 생성하고, Token 검증 오류 시 Throw한다. * feat: 블랙 리스트 Exception 클래스 생성, Authentication 필터 수정 * feat: 필터의 오류 핸들링 필터를 추가 (#47) * feat: 인증 필터를 핸들링하는 필터 작성 (#47) 추가로 궁금한 점 : (1) 토큰 만료 시 = reissue 요청 (2) 토큰이 없는 경우 = 로그인 요청 (3) 토큰이 잘못된 경우 = 올바르지 않다는 메시지 반환 이런 식으로 구현할려고 했는데, ErrorCode가 달라지는 것 말고는 별 다른 처리할 것이 없다고 생각해서 setErrorResponse 메소드 하나로 통일하여 예외를 처리하였다. 이렇게 획일화하고 나니 그냥 JwtException 하나로만 서로 다른 ErrorCode를 넣어서 구현해도 같은 결과를 가져올 것 같아서, Exception 클래스의 분리의 필요성을 못느꼈다. * refactor: jwt 토큰의 유효성 검증은 따로 Exception 처리 제외 * chore: config 수정 사항 반영 * 상위 목표 수정 기능 Service 파라미터 수정 및 Security Config 변경 (#53) * refactor: 상위 목표 수정 컨트롤러에서 Path Variable 사용하도록 변경 * refactor: SecurityConfig 설정 변경 * 기존에 전체 허용으로 열려있던 URL 경로 권한을 인증 URL로 제한 * Spring Security내 permitAll 동작할 수 있도록 JwtFilter 수정 (#54) * refactor: permitAll한 url은 token 예외가 발생하지 않도록 처리 * GoalResponse @JsonFormat 수정 (#55) * refactor: GoalResponse의 JsonFormat 수정 * JWT 유효기간 수정 (#56) * refactor: JWT 만료 시간 수정 * 하위 목표 수정 문제 해결 및 상위 목표 페이징 커서 수정 (#57) * refactor: 페이징 커서 null에서 -1일때 전체 조회하도록 수정 * 회고 작성 시 보관함 내 목표 리마인드 기능 추가 (#58) * feat: 보관함 목표 리마인드 기능 개발 * 상위 목표 리스트 조회 시 리마인드 여부 컬럼 추가 (#59) * refactor: 상위 목표 리스트 반환 시 reminder 여부 컬럼 추가 * 보관함에 있는 목표 조회 시 d-day 0으로 계산 (#60) * 보관함에 있는 목표 d-day 계산 시 0 반환하도록 변경 * 보관함에 있는 목표 조회 시 d-day 값을 음수로 반환 (#61) * 보관함에 있는 목표 조회 시 d-day 값을 음수로 반환 * feat: 토큰 재발급 시, 재발급된 refresh token을 redis에 저장 * refactor: SecurityConfig 에서 exception Handling 제외 * refactor: reissue 시 refresh token을 조회할 수 없는 부분 수정 * hotfix: import 시 오타 수정 * refactor: /auth 경로를 Authentication Filter를 거치지 않도록 설정 * hotfix: URI와 Request Method 확인을 위한 로깅 처리 * refactor: 허용한 URL 이외의 요청에 인증을 거치도록 설정 * feat: 상위 목표, 세부 목표 API JWT 인증 거치도록 설정 * feat: 최초 로그인 시 관련 정보를 함께 Response에 전달 * 인증 유저 정보를 사용해서 사용자 상위 목표를 식별한다 (#62) - @AuthenticationPrincipal을 통해 인증 유저 식별자 가져옴 * refactor: 재로그인 시 refresh, fcm token 삭제하는 코드 제거 * refactor: 애플리케이션 실행 시 Asia/Seoul Timezone 설정 * test: 토큰 테스트를 위해 유효 기간을 짧게 설정 * refactor: 저장되지 않은 토큰 입력 시 401 에러 반환하도록 수정 * refactor: 토큰의 유효시간 정상화 * test: 토큰 유효시간 짧게 설정 * refactor: 리프레시 토큰 유효기간 정상화 * test: reissue 테스트를 위한 로그 추가 * refactor: refresh Token 객체 timeToLive설정 * refactor: @Column에 unique=true를 추가하여 해결 (#65) * refactor: 토큰 유효시간 정상화 * 목표 달성 후 보상 지급 시, 로그인한 사용자의 달성 목표만 카운트 하도록 설정한다 (#66) * refactor: 보상 지급 시 달성 목표 카운트 수정 --------- Co-authored-by: Seo Jemin <82302520+jemlog@users.noreply.github.com> Co-authored-by: MingyeomKim <67851124+MingyeomKim@users.noreply.github.com> --- .github/workflows/build.yml | 22 +- .github/workflows/deploy.yml | 16 +- .gitignore | 5 +- .gitmodules | 4 + build.gradle | 56 +++- scripts/run_new_was.sh | 17 +- .../detailgoal/domain/QDetailGoal.java | 60 ++++ .../backend/global/entity/QBaseEntity.java | 39 +++ .../com/backend/goal/domain/QGoal.java | 69 +++++ .../com/backend/member/domain/QMember.java | 55 ++++ .../retrospect/domain/QRetrospect.java | 56 ++++ .../retrospect/domain/QRetrospectContent.java | 55 ++++ .../java/com/backend/BackendApplication.java | 5 + .../auth/application/BlackListService.java | 30 ++ .../auth/application/FcmTokenService.java | 33 ++ .../auth/application/OAuthService.java | 83 +++++ .../auth/application/RefreshTokenService.java | 39 +++ .../com/backend/auth/domain/BlackList.java | 18 ++ .../auth/domain/BlackListRepository.java | 10 + .../com/backend/auth/domain/FcmToken.java | 16 + .../auth/domain/FcmTokenRepository.java | 8 + .../com/backend/auth/domain/RefreshToken.java | 18 ++ .../auth/domain/RefreshTokenRepository.java | 11 + .../com/backend/auth/jwt/TokenProvider.java | 133 ++++++++ .../jwt/exception/BlackListJwtException.java | 11 + .../jwt/exception/InvalidJwtException.java | 13 + .../jwt/exception/JwtExpiredException.java | 13 + .../auth/jwt/exception/NullJwtException.java | 11 + .../auth/jwt/filter/AuthenticationFilter.java | 46 +++ .../auth/jwt/filter/JwtExceptionFilter.java | 42 +++ .../jwt/handler/JwtAccessDeniedHandler.java | 20 ++ .../handler/JwtAuthenticationEntryPoint.java | 20 ++ .../auth/presentation/OAuthController.java | 61 ++++ .../dto/request/LoginRequestDto.java | 15 + .../dto/response/LoginResponse.java | 16 + .../dto/response/ReissueResponse.java | 11 + .../com/backend/config/SwaggerConfig.java | 21 -- .../application/DetailGoalService.java | 130 ++++++++ .../detailgoal/application/RewardService.java | 22 ++ .../dto/response/DetailGoalAlarmResponse.java | 7 + .../dto/response/DetailGoalListResponse.java | 15 + .../dto/response/DetailGoalResponse.java | 40 +++ .../dto/response/GoalCompletedResponse.java | 11 + .../backend/detailgoal/domain/DetailGoal.java | 112 +++++++ .../detailgoal/domain/event/AlarmEvent.java | 9 + .../repository/DetailGoalQueryRepository.java | 41 +++ .../repository/DetailGoalRepository.java | 19 ++ .../presentation/DetailGoalController.java | 89 ++++++ .../dto/request/DetailGoalSaveRequest.java | 45 +++ .../dto/request/DetailGoalUpdateRequest.java | 31 ++ .../global/api/AuthTestController.java | 15 + .../{ => global}/api/LoggingController.java | 2 +- .../backend/global/common/code/ErrorCode.java | 103 +++++++ .../global/common/code/SuccessCode.java | 22 ++ .../common/response/CustomResponse.java | 33 ++ .../global/common/response/ErrorResponse.java | 91 ++++++ .../backend/global/config/AysncConfig.java | 9 + .../com/backend/global/config/FcmConfig.java | 55 ++++ .../backend/global/config/QuerydslConfig.java | 19 ++ .../backend/global/config/RedisConfig.java | 28 ++ .../global/config/SchedulerConfig.java | 9 + .../backend/global/config/SecurityConfig.java | 64 ++++ .../backend/global/config/SwaggerConfig.java | 43 +++ .../com/backend/global/config/WebConfig.java | 19 ++ .../com/backend/global/entity/BaseEntity.java | 23 ++ .../global/event/AlarmEventHandler.java | 24 ++ .../global/event/GoalEventHandler.java | 31 ++ .../global/event/ReminderEventHandler.java | 23 ++ .../global/exception/BusinessException.java | 20 ++ .../exception/GlobalExceptionHandler.java | 129 ++++++++ .../global/scheduler/SchedulerService.java | 86 ++++++ .../backend/goal/application/GoalService.java | 148 +++++++++ .../dto/response/GoalCountResponse.java | 13 + .../dto/response/GoalListResponse.java | 11 + .../dto/response/GoalResponse.java | 27 ++ .../RetrospectEnabledGoalCountResponse.java | 6 + .../java/com/backend/goal/domain/Goal.java | 220 ++++++++++++++ .../backend/goal/domain/ReminderEvent.java | 9 + .../backend/goal/domain/enums/GoalStatus.java | 28 ++ .../backend/goal/domain/enums/RewardType.java | 25 ++ .../goal/domain/event/ReminderEvent.java | 9 + .../event/RemoveRelatedDetailGoalEvent.java | 8 + .../repository/GoalListResponseDto.java | 51 ++++ .../repository/GoalQueryRepository.java | 131 ++++++++ .../domain/repository/GoalRepository.java | 25 ++ .../goal/presentation/GoalController.java | 114 +++++++ .../presentation/dto/GoalRecoverRequest.java | 26 ++ .../presentation/dto/GoalSaveRequest.java | 39 +++ .../presentation/dto/GoalUpdateRequest.java | 32 ++ .../infrastructure/fcm/FcmService.java | 55 ++++ .../backend/infrastructure/fcm/PushWord.java | 7 + .../member/application/MemberService.java | 32 ++ .../com/backend/member/domain/Member.java | 70 +++++ .../member/domain/MemberRepository.java | 17 ++ .../backend/member/domain/MemberStatus.java | 8 + .../com/backend/member/domain/Provider.java | 18 ++ .../java/com/backend/member/domain/Role.java | 6 + .../application/RetrospectService.java | 64 ++++ .../dto/response/RetrospectResponse.java | 30 ++ .../com/backend/retrospect/domain/Guide.java | 31 ++ .../backend/retrospect/domain/Retrospect.java | 57 ++++ .../retrospect/domain/RetrospectContent.java | 32 ++ .../domain/RetrospectRepository.java | 9 + .../retrospect/domain/SuccessLevel.java | 31 ++ .../presentation/RetrospectController.java | 36 +++ .../dto/request/RetrospectSaveRequest.java | 30 ++ src/main/resources/application-test.yml | 42 +++ src/main/resources/application.yml | 14 - src/main/resources/config | 1 + .../com/backend/BackendApplicationTests.java | 2 +- .../auth/application/OAuthServiceTest.java | 96 ++++++ .../application/RefreshTokenServiceTest.java | 32 ++ .../domain/RefreshTokenRepositoryTest.java | 31 ++ .../backend/auth/jwt/TokenProviderTest.java | 94 ++++++ .../application/DetailGoalServiceTest.java | 284 ++++++++++++++++++ .../domain/DetailGoalQueryRepositoryTest.java | 65 ++++ .../com/backend/global/DatabaseCleaner.java | 43 +++ .../goal/application/GoalServiceTest.java | 225 ++++++++++++++ .../com/backend/goal/domain/GoalTest.java | 150 +++++++++ .../application/RetrospectServiceTest.java | 122 ++++++++ .../scheduler/SchedulerServiceTest.java | 74 +++++ 121 files changed, 5251 insertions(+), 56 deletions(-) create mode 100644 .gitmodules create mode 100644 src/main/generated/com/backend/detailgoal/domain/QDetailGoal.java create mode 100644 src/main/generated/com/backend/global/entity/QBaseEntity.java create mode 100644 src/main/generated/com/backend/goal/domain/QGoal.java create mode 100644 src/main/generated/com/backend/member/domain/QMember.java create mode 100644 src/main/generated/com/backend/retrospect/domain/QRetrospect.java create mode 100644 src/main/generated/com/backend/retrospect/domain/QRetrospectContent.java create mode 100644 src/main/java/com/backend/auth/application/BlackListService.java create mode 100644 src/main/java/com/backend/auth/application/FcmTokenService.java create mode 100644 src/main/java/com/backend/auth/application/OAuthService.java create mode 100644 src/main/java/com/backend/auth/application/RefreshTokenService.java create mode 100644 src/main/java/com/backend/auth/domain/BlackList.java create mode 100644 src/main/java/com/backend/auth/domain/BlackListRepository.java create mode 100644 src/main/java/com/backend/auth/domain/FcmToken.java create mode 100644 src/main/java/com/backend/auth/domain/FcmTokenRepository.java create mode 100644 src/main/java/com/backend/auth/domain/RefreshToken.java create mode 100644 src/main/java/com/backend/auth/domain/RefreshTokenRepository.java create mode 100644 src/main/java/com/backend/auth/jwt/TokenProvider.java create mode 100644 src/main/java/com/backend/auth/jwt/exception/BlackListJwtException.java create mode 100644 src/main/java/com/backend/auth/jwt/exception/InvalidJwtException.java create mode 100644 src/main/java/com/backend/auth/jwt/exception/JwtExpiredException.java create mode 100644 src/main/java/com/backend/auth/jwt/exception/NullJwtException.java create mode 100644 src/main/java/com/backend/auth/jwt/filter/AuthenticationFilter.java create mode 100644 src/main/java/com/backend/auth/jwt/filter/JwtExceptionFilter.java create mode 100644 src/main/java/com/backend/auth/jwt/handler/JwtAccessDeniedHandler.java create mode 100644 src/main/java/com/backend/auth/jwt/handler/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/com/backend/auth/presentation/OAuthController.java create mode 100644 src/main/java/com/backend/auth/presentation/dto/request/LoginRequestDto.java create mode 100644 src/main/java/com/backend/auth/presentation/dto/response/LoginResponse.java create mode 100644 src/main/java/com/backend/auth/presentation/dto/response/ReissueResponse.java delete mode 100644 src/main/java/com/backend/config/SwaggerConfig.java create mode 100644 src/main/java/com/backend/detailgoal/application/DetailGoalService.java create mode 100644 src/main/java/com/backend/detailgoal/application/RewardService.java create mode 100644 src/main/java/com/backend/detailgoal/application/dto/response/DetailGoalAlarmResponse.java create mode 100644 src/main/java/com/backend/detailgoal/application/dto/response/DetailGoalListResponse.java create mode 100644 src/main/java/com/backend/detailgoal/application/dto/response/DetailGoalResponse.java create mode 100644 src/main/java/com/backend/detailgoal/application/dto/response/GoalCompletedResponse.java create mode 100644 src/main/java/com/backend/detailgoal/domain/DetailGoal.java create mode 100644 src/main/java/com/backend/detailgoal/domain/event/AlarmEvent.java create mode 100644 src/main/java/com/backend/detailgoal/domain/repository/DetailGoalQueryRepository.java create mode 100644 src/main/java/com/backend/detailgoal/domain/repository/DetailGoalRepository.java create mode 100644 src/main/java/com/backend/detailgoal/presentation/DetailGoalController.java create mode 100644 src/main/java/com/backend/detailgoal/presentation/dto/request/DetailGoalSaveRequest.java create mode 100644 src/main/java/com/backend/detailgoal/presentation/dto/request/DetailGoalUpdateRequest.java create mode 100644 src/main/java/com/backend/global/api/AuthTestController.java rename src/main/java/com/backend/{ => global}/api/LoggingController.java (94%) create mode 100644 src/main/java/com/backend/global/common/code/ErrorCode.java create mode 100644 src/main/java/com/backend/global/common/code/SuccessCode.java create mode 100644 src/main/java/com/backend/global/common/response/CustomResponse.java create mode 100644 src/main/java/com/backend/global/common/response/ErrorResponse.java create mode 100644 src/main/java/com/backend/global/config/AysncConfig.java create mode 100644 src/main/java/com/backend/global/config/FcmConfig.java create mode 100644 src/main/java/com/backend/global/config/QuerydslConfig.java create mode 100644 src/main/java/com/backend/global/config/RedisConfig.java create mode 100644 src/main/java/com/backend/global/config/SchedulerConfig.java create mode 100644 src/main/java/com/backend/global/config/SecurityConfig.java create mode 100644 src/main/java/com/backend/global/config/SwaggerConfig.java create mode 100644 src/main/java/com/backend/global/config/WebConfig.java create mode 100644 src/main/java/com/backend/global/entity/BaseEntity.java create mode 100644 src/main/java/com/backend/global/event/AlarmEventHandler.java create mode 100644 src/main/java/com/backend/global/event/GoalEventHandler.java create mode 100644 src/main/java/com/backend/global/event/ReminderEventHandler.java create mode 100644 src/main/java/com/backend/global/exception/BusinessException.java create mode 100644 src/main/java/com/backend/global/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/backend/global/scheduler/SchedulerService.java create mode 100644 src/main/java/com/backend/goal/application/GoalService.java create mode 100644 src/main/java/com/backend/goal/application/dto/response/GoalCountResponse.java create mode 100644 src/main/java/com/backend/goal/application/dto/response/GoalListResponse.java create mode 100644 src/main/java/com/backend/goal/application/dto/response/GoalResponse.java create mode 100644 src/main/java/com/backend/goal/application/dto/response/RetrospectEnabledGoalCountResponse.java create mode 100644 src/main/java/com/backend/goal/domain/Goal.java create mode 100644 src/main/java/com/backend/goal/domain/ReminderEvent.java create mode 100644 src/main/java/com/backend/goal/domain/enums/GoalStatus.java create mode 100644 src/main/java/com/backend/goal/domain/enums/RewardType.java create mode 100644 src/main/java/com/backend/goal/domain/event/ReminderEvent.java create mode 100644 src/main/java/com/backend/goal/domain/event/RemoveRelatedDetailGoalEvent.java create mode 100644 src/main/java/com/backend/goal/domain/repository/GoalListResponseDto.java create mode 100644 src/main/java/com/backend/goal/domain/repository/GoalQueryRepository.java create mode 100644 src/main/java/com/backend/goal/domain/repository/GoalRepository.java create mode 100644 src/main/java/com/backend/goal/presentation/GoalController.java create mode 100644 src/main/java/com/backend/goal/presentation/dto/GoalRecoverRequest.java create mode 100644 src/main/java/com/backend/goal/presentation/dto/GoalSaveRequest.java create mode 100644 src/main/java/com/backend/goal/presentation/dto/GoalUpdateRequest.java create mode 100644 src/main/java/com/backend/infrastructure/fcm/FcmService.java create mode 100644 src/main/java/com/backend/infrastructure/fcm/PushWord.java create mode 100644 src/main/java/com/backend/member/application/MemberService.java create mode 100644 src/main/java/com/backend/member/domain/Member.java create mode 100644 src/main/java/com/backend/member/domain/MemberRepository.java create mode 100644 src/main/java/com/backend/member/domain/MemberStatus.java create mode 100644 src/main/java/com/backend/member/domain/Provider.java create mode 100644 src/main/java/com/backend/member/domain/Role.java create mode 100644 src/main/java/com/backend/retrospect/application/RetrospectService.java create mode 100644 src/main/java/com/backend/retrospect/application/dto/response/RetrospectResponse.java create mode 100644 src/main/java/com/backend/retrospect/domain/Guide.java create mode 100644 src/main/java/com/backend/retrospect/domain/Retrospect.java create mode 100644 src/main/java/com/backend/retrospect/domain/RetrospectContent.java create mode 100644 src/main/java/com/backend/retrospect/domain/RetrospectRepository.java create mode 100644 src/main/java/com/backend/retrospect/domain/SuccessLevel.java create mode 100644 src/main/java/com/backend/retrospect/presentation/RetrospectController.java create mode 100644 src/main/java/com/backend/retrospect/presentation/dto/request/RetrospectSaveRequest.java create mode 100644 src/main/resources/application-test.yml delete mode 100644 src/main/resources/application.yml create mode 160000 src/main/resources/config create mode 100644 src/test/java/com/backend/auth/application/OAuthServiceTest.java create mode 100644 src/test/java/com/backend/auth/application/RefreshTokenServiceTest.java create mode 100644 src/test/java/com/backend/auth/domain/RefreshTokenRepositoryTest.java create mode 100644 src/test/java/com/backend/auth/jwt/TokenProviderTest.java create mode 100644 src/test/java/com/backend/detailgoal/application/DetailGoalServiceTest.java create mode 100644 src/test/java/com/backend/detailgoal/domain/DetailGoalQueryRepositoryTest.java create mode 100644 src/test/java/com/backend/global/DatabaseCleaner.java create mode 100644 src/test/java/com/backend/goal/application/GoalServiceTest.java create mode 100644 src/test/java/com/backend/goal/domain/GoalTest.java create mode 100644 src/test/java/com/backend/retrospect/application/RetrospectServiceTest.java create mode 100644 src/test/java/com/backend/scheduler/SchedulerServiceTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1344ec1..6caf03d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,13 +8,20 @@ on: # when the workflows should be triggered ? permissions: contents: read + checks: write + issues: write jobs: # defining jobs, executed in this workflows build: runs-on: ubuntu-latest - + services: + redis: + image: redis + ports: + - 6379:6379 steps: - - uses: actions/checkout@v3 # clone repository + - name: Checkout repository + uses: actions/checkout@v3 # clone repository # Caching Gradle - name: Cache Gradle @@ -23,7 +30,7 @@ jobs: # defining jobs, executed in this workflows path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- @@ -31,23 +38,24 @@ jobs: # defining jobs, executed in this workflows uses: actions/setup-java@v3 # set up the required java version with: java-version: '17' + distribution: 'temurin' - name: Gradle Authorization run: chmod +x gradlew # Gradle run - name: Gradle Build Run - run: ./gradlew build + run: ./gradlew -Dspring.profiles.active=test build # Gradle test - name: Test with Gradle - run: ./gradlew --info test + run: ./gradlew -Dspring.profiles.active=test --info test # Publish Unit Test Results - - nane: Publish Unit Test Results + - name: Publish Unit Test Results uses: EnricoMi/publish-unit-test-result-action@v1 if: ${{ always() }} with: - files: build/test-results/**/*.xml + files: "build/test-results/**/*.xml" - name: Cleanup Gradle Cache if: ${{ always() }} run: | diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 941de8c..a80c8ce 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,14 +1,21 @@ name: deploy on: push: - branches: ["main", "dev"] + branches: ["dev"] permissions: contents: read jobs: build: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 + steps: + - name: Checkout repository + uses: actions/checkout@v3 # clone repository + with: + token: ${{ secrets.ACTION_TOKEN }} + submodules: true + + - name: Update submodules + run: git submodule update --init --remote # Caching Gradle - name: Cache Gradle @@ -28,8 +35,9 @@ jobs: - name: Gradle Authorization run: chmod +x gradlew + - name: Gradle Build Run - run: ./gradlew build + run: ./gradlew build -x test - name: Create Zip files run: zip -r ./$GITHUB_SHA.zip . diff --git a/.gitignore b/.gitignore index c2065bc..8402a98 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,8 @@ out/ /nbdist/ /.nb-gradle/ -### VS Code ### +## VS Code ## .vscode/ +src/main/resources/firebase/fcm_key.json +src/main/resources/application.yml +src/main/resources/application-dev.yml \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a03c2c6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "src/main/resources/config"] + path = src/main/resources/config + url = https://github.com/Mingyum-Kim/config.git + branch = main \ No newline at end of file diff --git a/build.gradle b/build.gradle index 8ad06b0..f73bdfa 100644 --- a/build.gradle +++ b/build.gradle @@ -16,12 +16,66 @@ repositories { } dependencies { + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // jpa + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // lombok + implementation 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // database + implementation 'org.mariadb.jdbc:mariadb-java-client:3.1.4' + runtimeOnly 'com.h2database:h2' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Querydsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + + + implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' - testImplementation 'org.springframework.boot:spring-boot-starter-test' + + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + + // FCM + implementation 'com.google.firebase:firebase-admin:9.1.1' } tasks.named('test') { useJUnitPlatform() } + +task copyPrivate(type: Copy) { + copy { + from './src/main/resources/config' + include "*.yml" + into 'src/main/resources' + } +} + +tasks.withType(Test) { + systemProperties = System.getProperties() +} + +bootRun { + String activeProfile = System.properties['spring.profiles.active'] + systemProperty "spring.profiles.active", activeProfile +} \ No newline at end of file diff --git a/scripts/run_new_was.sh b/scripts/run_new_was.sh index 8c0b51e..b6a7908 100644 --- a/scripts/run_new_was.sh +++ b/scripts/run_new_was.sh @@ -1,5 +1,7 @@ #!/bin/bash +echo "> Start run_new_was.sh" + # Parse port number from 'service_url.inc' CURRENT_PORT=$(cat /home/ubuntu/service_url.inc | grep -Po '[0-9]+' | tail -1) TARGET_PORT=0 @@ -16,14 +18,17 @@ else fi # query pid using the TCP protocol and using the port 'TARGET_PORT' -TARGET_PID=$(lsof -Fp -i TCP:${TARGET_PORT} | grep -Po 'p[0-9]+' | grep -Po '[0-9]+') +# TARGET_PID=$(lsof -Fp -i TCP:${TARGET_PORT} | grep -Po 'p[0-9]+' | grep -Po '[0-9]+') -if [ ! -z ${TARGET_PID} ]; then - echo "> Kill WAS running at ${TARGET_PORT}." - sudo kill ${TARGET_PID} -fi +# if [ ! -z ${TARGET_PID} ]; then +# echo "> Kill WAS running at ${TARGET_PORT}." +# sudo kill ${TARGET_PID} +# fi + +echo "> Kill WAS running at ${TARGET_PORT}." +sudo kill $(sudo lsof -t -i:${TARGET_PORT}) # run jar file in background -nohup java -jar -Dserver.port=${TARGET_PORT} /home/ubuntu/app/build/libs/backend-0.0.1-SNAPSHOT.jar > /home/ubuntu/nohup.out 2>&1 & +nohup sudo java -jar -Duser.timezone="Asia/Seoul" -Dspring.profiles.active=dev -Dserver.port=${TARGET_PORT} /home/ubuntu/app/build/libs/backend-0.0.1-SNAPSHOT.jar > /home/ubuntu/nohup.out 2>&1 & echo "> Now new WAS runs at ${TARGET_PORT}." exit 0 \ No newline at end of file diff --git a/src/main/generated/com/backend/detailgoal/domain/QDetailGoal.java b/src/main/generated/com/backend/detailgoal/domain/QDetailGoal.java new file mode 100644 index 0000000..c8cdac6 --- /dev/null +++ b/src/main/generated/com/backend/detailgoal/domain/QDetailGoal.java @@ -0,0 +1,60 @@ +package com.backend.detailgoal.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QDetailGoal is a Querydsl query type for DetailGoal + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QDetailGoal extends EntityPathBase { + + private static final long serialVersionUID = -440401045L; + + public static final QDetailGoal detailGoal = new QDetailGoal("detailGoal"); + + public final com.backend.global.entity.QBaseEntity _super = new com.backend.global.entity.QBaseEntity(this); + + public final ListPath> alarmDays = this.>createList("alarmDays", java.time.DayOfWeek.class, EnumPath.class, PathInits.DIRECT2); + + public final BooleanPath alarmEnabled = createBoolean("alarmEnabled"); + + public final TimePath alarmTime = createTime("alarmTime", java.time.LocalTime.class); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + public final NumberPath goalId = createNumber("goalId", Long.class); + + public final NumberPath id = createNumber("id", Long.class); + + public final BooleanPath isCompleted = createBoolean("isCompleted"); + + public final BooleanPath isDeleted = createBoolean("isDeleted"); + + public final StringPath title = createString("title"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QDetailGoal(String variable) { + super(DetailGoal.class, forVariable(variable)); + } + + public QDetailGoal(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QDetailGoal(PathMetadata metadata) { + super(DetailGoal.class, metadata); + } + +} + diff --git a/src/main/generated/com/backend/global/entity/QBaseEntity.java b/src/main/generated/com/backend/global/entity/QBaseEntity.java new file mode 100644 index 0000000..900ddde --- /dev/null +++ b/src/main/generated/com/backend/global/entity/QBaseEntity.java @@ -0,0 +1,39 @@ +package com.backend.global.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QBaseEntity is a Querydsl query type for BaseEntity + */ +@Generated("com.querydsl.codegen.DefaultSupertypeSerializer") +public class QBaseEntity extends EntityPathBase { + + private static final long serialVersionUID = 1431342203L; + + public static final QBaseEntity baseEntity = new QBaseEntity("baseEntity"); + + public final DateTimePath createdAt = createDateTime("createdAt", java.time.LocalDateTime.class); + + public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.LocalDateTime.class); + + public QBaseEntity(String variable) { + super(BaseEntity.class, forVariable(variable)); + } + + public QBaseEntity(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QBaseEntity(PathMetadata metadata) { + super(BaseEntity.class, metadata); + } + +} + diff --git a/src/main/generated/com/backend/goal/domain/QGoal.java b/src/main/generated/com/backend/goal/domain/QGoal.java new file mode 100644 index 0000000..052c374 --- /dev/null +++ b/src/main/generated/com/backend/goal/domain/QGoal.java @@ -0,0 +1,69 @@ +package com.backend.goal.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QGoal is a Querydsl query type for Goal + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QGoal extends EntityPathBase { + + private static final long serialVersionUID = -1085434967L; + + public static final QGoal goal = new QGoal("goal"); + + public final com.backend.global.entity.QBaseEntity _super = new com.backend.global.entity.QBaseEntity(this); + + public final NumberPath completedDetailGoalCnt = createNumber("completedDetailGoalCnt", Integer.class); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + public final DatePath endDate = createDate("endDate", java.time.LocalDate.class); + + public final NumberPath entireDetailGoalCnt = createNumber("entireDetailGoalCnt", Integer.class); + + public final EnumPath goalStatus = createEnum("goalStatus", com.backend.goal.domain.enums.GoalStatus.class); + + public final BooleanPath hasRetrospect = createBoolean("hasRetrospect"); + + public final NumberPath id = createNumber("id", Long.class); + + public final BooleanPath isDeleted = createBoolean("isDeleted"); + + public final DatePath lastRemindDate = createDate("lastRemindDate", java.time.LocalDate.class); + + public final NumberPath memberId = createNumber("memberId", Long.class); + + public final BooleanPath reminderEnabled = createBoolean("reminderEnabled"); + + public final EnumPath reward = createEnum("reward", com.backend.goal.domain.enums.RewardType.class); + + public final DatePath startDate = createDate("startDate", java.time.LocalDate.class); + + public final StringPath title = createString("title"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QGoal(String variable) { + super(Goal.class, forVariable(variable)); + } + + public QGoal(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QGoal(PathMetadata metadata) { + super(Goal.class, metadata); + } + +} + diff --git a/src/main/generated/com/backend/member/domain/QMember.java b/src/main/generated/com/backend/member/domain/QMember.java new file mode 100644 index 0000000..22f3c9a --- /dev/null +++ b/src/main/generated/com/backend/member/domain/QMember.java @@ -0,0 +1,55 @@ +package com.backend.member.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QMember is a Querydsl query type for Member + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QMember extends EntityPathBase { + + private static final long serialVersionUID = 1362671415L; + + public static final QMember member = new QMember("member1"); + + public final com.backend.global.entity.QBaseEntity _super = new com.backend.global.entity.QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + public final BooleanPath enabledPush = createBoolean("enabledPush"); + + public final NumberPath id = createNumber("id", Long.class); + + public final EnumPath memberStatus = createEnum("memberStatus", MemberStatus.class); + + public final EnumPath provider = createEnum("provider", Provider.class); + + public final EnumPath role = createEnum("role", Role.class); + + public final StringPath uid = createString("uid"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QMember(String variable) { + super(Member.class, forVariable(variable)); + } + + public QMember(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QMember(PathMetadata metadata) { + super(Member.class, metadata); + } + +} + diff --git a/src/main/generated/com/backend/retrospect/domain/QRetrospect.java b/src/main/generated/com/backend/retrospect/domain/QRetrospect.java new file mode 100644 index 0000000..2b9a289 --- /dev/null +++ b/src/main/generated/com/backend/retrospect/domain/QRetrospect.java @@ -0,0 +1,56 @@ +package com.backend.retrospect.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QRetrospect is a Querydsl query type for Retrospect + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QRetrospect extends EntityPathBase { + + private static final long serialVersionUID = 723065401L; + + public static final QRetrospect retrospect = new QRetrospect("retrospect"); + + public final com.backend.global.entity.QBaseEntity _super = new com.backend.global.entity.QBaseEntity(this); + + public final ListPath contents = this.createList("contents", RetrospectContent.class, QRetrospectContent.class, PathInits.DIRECT2); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + public final NumberPath goalId = createNumber("goalId", Long.class); + + public final BooleanPath hasGuide = createBoolean("hasGuide"); + + public final NumberPath id = createNumber("id", Long.class); + + public final BooleanPath isDeleted = createBoolean("isDeleted"); + + public final EnumPath successLevel = createEnum("successLevel", SuccessLevel.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QRetrospect(String variable) { + super(Retrospect.class, forVariable(variable)); + } + + public QRetrospect(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QRetrospect(PathMetadata metadata) { + super(Retrospect.class, metadata); + } + +} + diff --git a/src/main/generated/com/backend/retrospect/domain/QRetrospectContent.java b/src/main/generated/com/backend/retrospect/domain/QRetrospectContent.java new file mode 100644 index 0000000..a86aad0 --- /dev/null +++ b/src/main/generated/com/backend/retrospect/domain/QRetrospectContent.java @@ -0,0 +1,55 @@ +package com.backend.retrospect.domain; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QRetrospectContent is a Querydsl query type for RetrospectContent + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QRetrospectContent extends EntityPathBase { + + private static final long serialVersionUID = 1919447040L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QRetrospectContent retrospectContent = new QRetrospectContent("retrospectContent"); + + public final StringPath content = createString("content"); + + public final EnumPath guide = createEnum("guide", Guide.class); + + public final NumberPath id = createNumber("id", Long.class); + + public final QRetrospect retrospect; + + public QRetrospectContent(String variable) { + this(RetrospectContent.class, forVariable(variable), INITS); + } + + public QRetrospectContent(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QRetrospectContent(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QRetrospectContent(PathMetadata metadata, PathInits inits) { + this(RetrospectContent.class, metadata, inits); + } + + public QRetrospectContent(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.retrospect = inits.isInitialized("retrospect") ? new QRetrospect(forProperty("retrospect")) : null; + } + +} + diff --git a/src/main/java/com/backend/BackendApplication.java b/src/main/java/com/backend/BackendApplication.java index 2bd17d8..c3cd0a0 100644 --- a/src/main/java/com/backend/BackendApplication.java +++ b/src/main/java/com/backend/BackendApplication.java @@ -1,9 +1,14 @@ package com.backend; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.servers.Server; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing +//@OpenAPIDefinition(servers = {@Server(url = "https://dnd9th.site", description = "Default Server URL")}) public class BackendApplication { public static void main(String[] args) { diff --git a/src/main/java/com/backend/auth/application/BlackListService.java b/src/main/java/com/backend/auth/application/BlackListService.java new file mode 100644 index 0000000..8c5710d --- /dev/null +++ b/src/main/java/com/backend/auth/application/BlackListService.java @@ -0,0 +1,30 @@ +package com.backend.auth.application; + +import com.backend.auth.domain.BlackList; +import com.backend.auth.domain.BlackListRepository; +import com.backend.auth.jwt.exception.BlackListJwtException; +import com.backend.auth.jwt.exception.InvalidJwtException; +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class BlackListService { + private final BlackListRepository blackListRepository; + + public void saveBlackList(String accessToken, Long expiration){ + blackListRepository.save(new BlackList(accessToken, expiration)); + } + + public void checkBlackList(String accessToken){ + Optional blackList = blackListRepository.findByAccessToken(accessToken); + if(blackList.isPresent()){ + throw new BlackListJwtException(ErrorCode.BLACK_LIST_TOKEN); + } + } + +} diff --git a/src/main/java/com/backend/auth/application/FcmTokenService.java b/src/main/java/com/backend/auth/application/FcmTokenService.java new file mode 100644 index 0000000..c46ae93 --- /dev/null +++ b/src/main/java/com/backend/auth/application/FcmTokenService.java @@ -0,0 +1,33 @@ +package com.backend.auth.application; + +import com.backend.auth.domain.FcmToken; +import com.backend.auth.domain.FcmTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class FcmTokenService { + + private final FcmTokenRepository fcmTokenRepository; + + private static final String FCM_TOKEN_PREFIX = "fcm"; + + public void saveFcmToken(String uid, String fcmToken){ + fcmTokenRepository.save(new FcmToken(FCM_TOKEN_PREFIX + uid, fcmToken)); + } + + public String findFcmToken(String uid) { + Optional fcmToken = fcmTokenRepository.findById(FCM_TOKEN_PREFIX + uid); + if(fcmToken.isPresent()){ + return fcmToken.get().getFcmToken(); + } + return null; + } + + public void deleteByUid(String uid) { + fcmTokenRepository.deleteById(uid); + } +} diff --git a/src/main/java/com/backend/auth/application/OAuthService.java b/src/main/java/com/backend/auth/application/OAuthService.java new file mode 100644 index 0000000..d2acb68 --- /dev/null +++ b/src/main/java/com/backend/auth/application/OAuthService.java @@ -0,0 +1,83 @@ +package com.backend.auth.application; + +import com.backend.auth.jwt.TokenProvider; +import com.backend.auth.presentation.dto.response.LoginResponse; +import com.backend.auth.presentation.dto.response.ReissueResponse; +import com.backend.member.application.MemberService; +import com.backend.member.domain.Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthService { + + private final MemberService memberService; + + private final TokenProvider tokenProvider; + + private final RefreshTokenService refreshTokenService; + + private final BlackListService blackListService; + + private final FcmTokenService fcmTokenService; + + public LoginResponse login(String provider, String uid, String fcmToken) { + Boolean isFirstLogin = memberService.findMemberOrRegister(Provider.from(provider), uid); + + String accessToken = tokenProvider.generateAccessToken(uid); + String refreshToken = tokenProvider.generateRefreshToken(uid); + + log.info("save refresh token to redis : uid = {}, refresh token = {}", uid, refreshToken); + refreshTokenService.saveRefreshToken(uid, refreshToken); + + boolean checkRefreshTokenSaved = refreshTokenService.checkRefreshTokenSaved(uid, refreshToken); + log.info("check uid and refresh token saved : {}" , checkRefreshTokenSaved); + + fcmTokenService.saveFcmToken(uid, fcmToken); + + return new LoginResponse(isFirstLogin, accessToken, refreshToken); + } + + public ReissueResponse reissue(String bearerRefreshToken) throws Exception { + String refreshToken = tokenProvider.getToken(bearerRefreshToken); + + log.info("refresh token : " + refreshToken); + String uid = refreshTokenService.findUidByRefreshToken(refreshToken); + + log.info("uid : " + uid); + String renewAccessToken = tokenProvider.generateAccessToken(uid); + String renewRefreshToken = tokenProvider.generateRefreshToken(uid); + + refreshTokenService.deleteByUid(uid); + refreshTokenService.saveRefreshToken(uid, renewRefreshToken); + + return new ReissueResponse(renewAccessToken, renewRefreshToken); + } + + public void logout(String bearerAccessToken) { + String accessToken = tokenProvider.getToken(bearerAccessToken); + String uid = tokenProvider.getPayload(accessToken); + + refreshTokenService.deleteByUid(uid); + fcmTokenService.deleteByUid(uid); + + Long expiration = tokenProvider.getExpiration(accessToken); + blackListService.saveBlackList(accessToken, expiration); + } + + public void withdraw(String bearerAccessToken) { + String accessToken = tokenProvider.getToken(bearerAccessToken); + + String uid = tokenProvider.getPayload(accessToken); + memberService.withdraw(uid); + refreshTokenService.deleteByUid(uid); + fcmTokenService.deleteByUid(uid); + + Long expiration = tokenProvider.getExpiration(accessToken); + blackListService.saveBlackList(accessToken, expiration); + } + +} diff --git a/src/main/java/com/backend/auth/application/RefreshTokenService.java b/src/main/java/com/backend/auth/application/RefreshTokenService.java new file mode 100644 index 0000000..aebcdb6 --- /dev/null +++ b/src/main/java/com/backend/auth/application/RefreshTokenService.java @@ -0,0 +1,39 @@ +package com.backend.auth.application; + +import com.backend.auth.domain.RefreshToken; +import com.backend.auth.domain.RefreshTokenRepository; +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + + public void saveRefreshToken(String uid, String refreshToken){ + refreshTokenRepository.save(new RefreshToken(uid, refreshToken)); + } + + public String findUidByRefreshToken(String refreshToken){ + RefreshToken result = refreshTokenRepository.findByTokenValue(refreshToken) + .orElseThrow(() -> new BusinessException(ErrorCode.TOKEN_NOT_FOUND)); + return result.getUid(); + } + + public boolean checkRefreshTokenSaved(String uid, String refreshToken){ + Optional result = refreshTokenRepository.findByUidAndTokenValue(uid, refreshToken); + if(result.isPresent()) return true; + return false; + } + + public void deleteByUid(String uid) { + refreshTokenRepository.findById(uid).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + refreshTokenRepository.deleteById(uid); + } + +} diff --git a/src/main/java/com/backend/auth/domain/BlackList.java b/src/main/java/com/backend/auth/domain/BlackList.java new file mode 100644 index 0000000..ffdfb34 --- /dev/null +++ b/src/main/java/com/backend/auth/domain/BlackList.java @@ -0,0 +1,18 @@ +package com.backend.auth.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@AllArgsConstructor +@RedisHash(value = "blackList") +public class BlackList { + @Id + private String accessToken; + + @TimeToLive + private Long expiration; +} diff --git a/src/main/java/com/backend/auth/domain/BlackListRepository.java b/src/main/java/com/backend/auth/domain/BlackListRepository.java new file mode 100644 index 0000000..914d012 --- /dev/null +++ b/src/main/java/com/backend/auth/domain/BlackListRepository.java @@ -0,0 +1,10 @@ +package com.backend.auth.domain; + +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface BlackListRepository extends CrudRepository { + Optional findByAccessToken(String accessToken); + +} diff --git a/src/main/java/com/backend/auth/domain/FcmToken.java b/src/main/java/com/backend/auth/domain/FcmToken.java new file mode 100644 index 0000000..9b40e29 --- /dev/null +++ b/src/main/java/com/backend/auth/domain/FcmToken.java @@ -0,0 +1,16 @@ +package com.backend.auth.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +@Getter +@AllArgsConstructor +@RedisHash(value="fcmToken") +public class FcmToken { + @Id + private String uid; + + private String fcmToken; +} diff --git a/src/main/java/com/backend/auth/domain/FcmTokenRepository.java b/src/main/java/com/backend/auth/domain/FcmTokenRepository.java new file mode 100644 index 0000000..721c416 --- /dev/null +++ b/src/main/java/com/backend/auth/domain/FcmTokenRepository.java @@ -0,0 +1,8 @@ +package com.backend.auth.domain; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import org.springframework.data.repository.CrudRepository; + +public interface FcmTokenRepository extends CrudRepository { +} diff --git a/src/main/java/com/backend/auth/domain/RefreshToken.java b/src/main/java/com/backend/auth/domain/RefreshToken.java new file mode 100644 index 0000000..29d3bde --- /dev/null +++ b/src/main/java/com/backend/auth/domain/RefreshToken.java @@ -0,0 +1,18 @@ +package com.backend.auth.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@AllArgsConstructor +@RedisHash(value = "refreshToken", timeToLive = 1000 * 60 * 60 * 24 * 14) +public class RefreshToken { + @Id + private String uid; + + @Indexed + private String tokenValue; +} diff --git a/src/main/java/com/backend/auth/domain/RefreshTokenRepository.java b/src/main/java/com/backend/auth/domain/RefreshTokenRepository.java new file mode 100644 index 0000000..db1a5b2 --- /dev/null +++ b/src/main/java/com/backend/auth/domain/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package com.backend.auth.domain; + +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends CrudRepository { + Optional findByTokenValue(String tokenValue); + + Optional findByUidAndTokenValue(String uid, String tokenValue); +} diff --git a/src/main/java/com/backend/auth/jwt/TokenProvider.java b/src/main/java/com/backend/auth/jwt/TokenProvider.java new file mode 100644 index 0000000..ed531b1 --- /dev/null +++ b/src/main/java/com/backend/auth/jwt/TokenProvider.java @@ -0,0 +1,133 @@ +package com.backend.auth.jwt; + +import com.backend.auth.application.RefreshTokenService; +import com.backend.auth.jwt.exception.InvalidJwtException; +import com.backend.auth.jwt.exception.JwtExpiredException; +import com.backend.auth.jwt.exception.NullJwtException; +import com.backend.global.common.code.ErrorCode; +import com.backend.member.domain.Member; +import com.backend.member.domain.MemberRepository; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.Objects; + +@Slf4j +@Component +public class TokenProvider { + private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 2; // 2시간 + +// private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 30; // 30초 + + private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 14; // 2주 + +// private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60; // 1분 + + private static final String TOKEN_HEADER_PREFIX = "Bearer "; + + private static final String AUTHORITIES_KEY = "auth"; + + private final Key key; + + private final MemberRepository memberRepository; + + public TokenProvider(@Value("${jwt.secret}") String secretKey, RefreshTokenService refreshTokenService, MemberRepository memberRepository){ + this.memberRepository = memberRepository; + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String generateAccessToken(String uid){ + return generateToken(uid, ACCESS_TOKEN_EXPIRE_TIME); + } + + public String generateRefreshToken(String uid){ + return generateToken(uid, REFRESH_TOKEN_EXPIRE_TIME); + } + + public String generateToken(String uid, Long expireTime){ + Member member = memberRepository.getByUid(uid); + Date now = new Date(); + return Jwts.builder() + .setSubject(uid) + .claim(AUTHORITIES_KEY, member.getRole()) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + expireTime)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String getPayload(String token){ + return getClaims(token).getSubject(); + } + + public Claims getClaims(String token){ + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public void validateToken(String token) { + try{ + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + } catch (ExpiredJwtException e){ + log.info("토큰의 기한이 만료되었습니다."); + throw new JwtExpiredException(ErrorCode.TOKEN_EXPIRED); + } catch (SecurityException e) { + log.info("잘못된 토큰 시그니처입니다."); + } catch (UnsupportedJwtException e) { + log.info("지원하지 않는 형식의 토큰입니다."); + } catch (MalformedJwtException | IllegalArgumentException e) { + log.info("유효하지 않은 토큰입니다. "); + } + } + + public String getToken(String bearerToken) { + if(Objects.isNull(bearerToken) || bearerToken.isEmpty()){ + throw new NullJwtException(ErrorCode.NO_TOKEN_PROVIDED); + } else if (!bearerToken.startsWith(TOKEN_HEADER_PREFIX)){ + throw new InvalidJwtException(ErrorCode.INVALID_TOKEN); + } + return bearerToken.split(" ")[1].trim(); + } + + public Long getExpiration(String accessToken) { + Date expiration = Jwts.parserBuilder().setSigningKey(key).build() + .parseClaimsJws(accessToken).getBody().getExpiration(); + Date now = new Date(); + return (expiration.getTime() - now.getTime()); + } + + public Authentication getAuthentication(String accessToken) { + Claims claims = getClaims(accessToken); + + // 권한 정보 추출 + Collection authorities = + Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .toList(); + + // UserDetails 객체에서 Authentication 반환 + UserDetails principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } +} diff --git a/src/main/java/com/backend/auth/jwt/exception/BlackListJwtException.java b/src/main/java/com/backend/auth/jwt/exception/BlackListJwtException.java new file mode 100644 index 0000000..4f09ba0 --- /dev/null +++ b/src/main/java/com/backend/auth/jwt/exception/BlackListJwtException.java @@ -0,0 +1,11 @@ +package com.backend.auth.jwt.exception; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; + +public class BlackListJwtException extends BusinessException { + + public BlackListJwtException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/backend/auth/jwt/exception/InvalidJwtException.java b/src/main/java/com/backend/auth/jwt/exception/InvalidJwtException.java new file mode 100644 index 0000000..2704a2d --- /dev/null +++ b/src/main/java/com/backend/auth/jwt/exception/InvalidJwtException.java @@ -0,0 +1,13 @@ +package com.backend.auth.jwt.exception; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import lombok.Getter; + +@Getter +public class InvalidJwtException extends BusinessException { + + public InvalidJwtException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/backend/auth/jwt/exception/JwtExpiredException.java b/src/main/java/com/backend/auth/jwt/exception/JwtExpiredException.java new file mode 100644 index 0000000..fa5579e --- /dev/null +++ b/src/main/java/com/backend/auth/jwt/exception/JwtExpiredException.java @@ -0,0 +1,13 @@ +package com.backend.auth.jwt.exception; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import lombok.Getter; + +@Getter +public class JwtExpiredException extends BusinessException { + + public JwtExpiredException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/backend/auth/jwt/exception/NullJwtException.java b/src/main/java/com/backend/auth/jwt/exception/NullJwtException.java new file mode 100644 index 0000000..2b07bbd --- /dev/null +++ b/src/main/java/com/backend/auth/jwt/exception/NullJwtException.java @@ -0,0 +1,11 @@ +package com.backend.auth.jwt.exception; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; + +public class NullJwtException extends BusinessException { + + public NullJwtException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/backend/auth/jwt/filter/AuthenticationFilter.java b/src/main/java/com/backend/auth/jwt/filter/AuthenticationFilter.java new file mode 100644 index 0000000..2ba10b9 --- /dev/null +++ b/src/main/java/com/backend/auth/jwt/filter/AuthenticationFilter.java @@ -0,0 +1,46 @@ +package com.backend.auth.jwt.filter; + +import com.backend.auth.application.BlackListService; +import com.backend.auth.jwt.TokenProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class AuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + + private final TokenProvider tokenProvider; + + private final BlackListService blackListService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + String accessToken = tokenProvider.getToken(request.getHeader(AUTHORIZATION_HEADER)); + + // 토큰의 유효성을 검증 + tokenProvider.validateToken(accessToken); + blackListService.checkBlackList(accessToken); + + // 인증 정보를 Security Context에 설정 후 다음 단계를 진행 + Authentication authentication = tokenProvider.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + catch (Exception e) { + e.printStackTrace(); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/backend/auth/jwt/filter/JwtExceptionFilter.java b/src/main/java/com/backend/auth/jwt/filter/JwtExceptionFilter.java new file mode 100644 index 0000000..0c28ab0 --- /dev/null +++ b/src/main/java/com/backend/auth/jwt/filter/JwtExceptionFilter.java @@ -0,0 +1,42 @@ +package com.backend.auth.jwt.filter; + +import com.backend.auth.jwt.exception.BlackListJwtException; +import com.backend.auth.jwt.exception.InvalidJwtException; +import com.backend.auth.jwt.exception.JwtExpiredException; +import com.backend.auth.jwt.exception.NullJwtException; +import com.backend.global.common.code.ErrorCode; +import com.backend.global.common.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class JwtExceptionFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (JwtExpiredException e){ + setErrorResponse(response, e.getErrorCode()); + } catch (NullJwtException e){ + setErrorResponse(response, e.getErrorCode()); + } catch (BlackListJwtException e){ + setErrorResponse(response, e.getErrorCode()); + } + } + + private void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { + response.setStatus(errorCode.getStatus()); + response.setContentType("application/json; charset=UTF-8"); + + ErrorResponse errorResponse = ErrorResponse.of(errorCode); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} diff --git a/src/main/java/com/backend/auth/jwt/handler/JwtAccessDeniedHandler.java b/src/main/java/com/backend/auth/jwt/handler/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..698a750 --- /dev/null +++ b/src/main/java/com/backend/auth/jwt/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,20 @@ +package com.backend.auth.jwt.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + // 사용자가 접근 권한이 없는 요청 (AccessDeniedException) 을 보냈을 때 403 에러를 반환한다. + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } +} diff --git a/src/main/java/com/backend/auth/jwt/handler/JwtAuthenticationEntryPoint.java b/src/main/java/com/backend/auth/jwt/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..9a54f80 --- /dev/null +++ b/src/main/java/com/backend/auth/jwt/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,20 @@ +package com.backend.auth.jwt.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + // JWT 인증 실패 (Authentication Exception) 시 401 에러를 반환한다. + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } +} diff --git a/src/main/java/com/backend/auth/presentation/OAuthController.java b/src/main/java/com/backend/auth/presentation/OAuthController.java new file mode 100644 index 0000000..2ae6199 --- /dev/null +++ b/src/main/java/com/backend/auth/presentation/OAuthController.java @@ -0,0 +1,61 @@ +package com.backend.auth.presentation; + +import com.backend.auth.application.OAuthService; +import com.backend.auth.application.RefreshTokenService; +import com.backend.auth.presentation.dto.request.LoginRequestDto; +import com.backend.auth.presentation.dto.response.LoginResponse; +import com.backend.auth.presentation.dto.response.ReissueResponse; +import com.backend.global.common.response.CustomResponse; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import static com.backend.global.common.code.SuccessCode.*; + +@Tag(name = "auth", description = "소셜 로그인 API입니다.") +@RequiredArgsConstructor +@RequestMapping("/auth") +@RestController +public class OAuthController { + + private final OAuthService oauthService; + + private final RefreshTokenService refreshTokenService; + + @Operation(summary = "소셜 로그인", + description = "카카오, 애플 서버에서 로그인한 사용자의 userId를 통해 access token과 refresh token을 반환합니다.") + @PostMapping("/{provider}") + public ResponseEntity> login ( + @Parameter(description = "kakao, apple 중 현재 로그인하는 소셜 타입", in = ParameterIn.PATH) @PathVariable String provider, + @RequestBody LoginRequestDto loginRequestDto) { + return CustomResponse.success(LOGIN_SUCCESS, oauthService.login(provider, loginRequestDto.userId(), loginRequestDto.fcmToken())); + } + + @Operation(summary = "토큰 재발급", + description = "access token 만료 시 refresh token을 통해 access token을 재발급합니다.") + @PostMapping("/reissue") + @ExceptionHandler({UnsupportedJwtException.class, MalformedJwtException.class, IllegalArgumentException.class}) + public ResponseEntity> reissue(@RequestHeader(value = "Authorization") String bearerRefreshToken) throws Exception { + return CustomResponse.success(LOGIN_SUCCESS, oauthService.reissue(bearerRefreshToken)); + } + + @Operation(summary = "로그아웃", description = "사용자의 refresh token을 삭제하여 앱에서 로그아웃 처리합니다.") + @PostMapping("/logout") + public ResponseEntity> logout (@RequestHeader(value = "Authorization") String bearerAccessToken) throws Exception { + oauthService.logout(bearerAccessToken); + return CustomResponse.success(LOGOUT_SUCCESS); + } + + @Operation(summary = "회원 탈퇴", description = "회원 탈퇴 요청 시 사용자의 상태를 DELETE로 변경한다.") + @PostMapping("/withdraw") + public ResponseEntity> withdraw(@RequestHeader(value = "Authorization") String bearerAccessToken) throws Exception { + oauthService.withdraw(bearerAccessToken); + return CustomResponse.success(DELETE_SUCCESS); + } +} \ No newline at end of file diff --git a/src/main/java/com/backend/auth/presentation/dto/request/LoginRequestDto.java b/src/main/java/com/backend/auth/presentation/dto/request/LoginRequestDto.java new file mode 100644 index 0000000..00d942b --- /dev/null +++ b/src/main/java/com/backend/auth/presentation/dto/request/LoginRequestDto.java @@ -0,0 +1,15 @@ +package com.backend.auth.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record LoginRequestDto ( + + @NotNull(message = "userId는 빈 값일 수 없습니다.") + @Schema(example = "1234567890") + String userId, + + @NotNull(message = "fcm token은 빈 값일 수 없습니다.") + @Schema(example = "e3lMQkbQftspO12ei34bzp5xVu3wQp2R") + String fcmToken +) { } \ No newline at end of file diff --git a/src/main/java/com/backend/auth/presentation/dto/response/LoginResponse.java b/src/main/java/com/backend/auth/presentation/dto/response/LoginResponse.java new file mode 100644 index 0000000..bc63a9e --- /dev/null +++ b/src/main/java/com/backend/auth/presentation/dto/response/LoginResponse.java @@ -0,0 +1,16 @@ +package com.backend.auth.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record LoginResponse( + + @Schema(description = "사용자가 처음 로그인한 경우인지 확인", example = "false") + Boolean isFirstLogin, + + @Schema(description = "사용자 인증 후 발급한 access token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9") + String accessToken, + + @Schema(description = "사용자 인증 후 발급한 refresh token", example = "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZ") + String refreshToken +){} + diff --git a/src/main/java/com/backend/auth/presentation/dto/response/ReissueResponse.java b/src/main/java/com/backend/auth/presentation/dto/response/ReissueResponse.java new file mode 100644 index 0000000..b590a1c --- /dev/null +++ b/src/main/java/com/backend/auth/presentation/dto/response/ReissueResponse.java @@ -0,0 +1,11 @@ +package com.backend.auth.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ReissueResponse( + @Schema(description = "사용자 인증 후 발급한 access token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9") + String accessToken, + + @Schema(description = "사용자 인증 후 발급한 refresh token", example = "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZ") + String refreshToken +) {} diff --git a/src/main/java/com/backend/config/SwaggerConfig.java b/src/main/java/com/backend/config/SwaggerConfig.java deleted file mode 100644 index d5cd2ee..0000000 --- a/src/main/java/com/backend/config/SwaggerConfig.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.backend.config; - -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class SwaggerConfig { - @Bean - public OpenAPI openAPI(){ - Info info = new Info() - .title("DND-9th-1 'Milestone' API Document") - .version("v0.0.1") - .description("DND 9기 1팀 'Milestone' 프로젝트의 API 명세서입니다."); - return new OpenAPI() - .components(new Components()) - .info(info); - } -} diff --git a/src/main/java/com/backend/detailgoal/application/DetailGoalService.java b/src/main/java/com/backend/detailgoal/application/DetailGoalService.java new file mode 100644 index 0000000..00d66fa --- /dev/null +++ b/src/main/java/com/backend/detailgoal/application/DetailGoalService.java @@ -0,0 +1,130 @@ +package com.backend.detailgoal.application; + +import com.backend.detailgoal.application.dto.response.DetailGoalListResponse; +import com.backend.detailgoal.application.dto.response.DetailGoalResponse; +import com.backend.detailgoal.application.dto.response.GoalCompletedResponse; +import com.backend.detailgoal.domain.DetailGoal; +import com.backend.detailgoal.domain.repository.DetailGoalRepository; +import com.backend.detailgoal.presentation.dto.request.DetailGoalSaveRequest; +import com.backend.detailgoal.presentation.dto.request.DetailGoalUpdateRequest; +import com.backend.goal.domain.Goal; +import com.backend.goal.domain.repository.GoalRepository; +import com.backend.goal.domain.enums.GoalStatus; +import com.backend.goal.domain.enums.RewardType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DetailGoalService { + + private final DetailGoalRepository detailGoalRepository; + + private final GoalRepository goalRepository; + + private final RewardService rewardService; + + + + public List getDetailGoalList(Long goalId) + { + List detailGoalList = detailGoalRepository.findAllByGoalIdAndIsDeletedFalse(goalId); + return detailGoalList.stream().map(DetailGoalListResponse::from).collect(Collectors.toList()); + } + + public DetailGoalResponse getDetailGoal(Long detailGoalId) + { + DetailGoal detailGoal = detailGoalRepository.getByIdAndIsDeletedFalse(detailGoalId); + return DetailGoalResponse.from(detailGoal); + } + + @Transactional + public DetailGoal saveDetailGoal(Long goalId, DetailGoalSaveRequest detailGoalSaveRequest) + { + DetailGoal detailGoal = detailGoalSaveRequest.toEntity(); + detailGoal.setGoalId(goalId); + detailGoalRepository.save(detailGoal); + + Goal goal = goalRepository.getByIdAndIsDeletedFalse(goalId); + goal.increaseEntireDetailGoalCnt(); // 전체 하위 목표 개수 증가 + return detailGoal; + } + + @Transactional + public GoalCompletedResponse removeDetailGoal(Long detailGoalId) + { + DetailGoal detailGoal = detailGoalRepository.getByIdAndIsDeletedFalse(detailGoalId); + detailGoal.remove(); + + Goal goal = goalRepository.getByIdAndIsDeletedFalse(detailGoal.getGoalId()); + goal.decreaseEntireDetailGoalCnt(); // 전체 하위 목표 감소 + + if(detailGoal.getIsCompleted()) // 만약 이미 성취된 목표였다면, 성취된 목표 개수까지 함께 제거 + { + goal.decreaseCompletedDetailGoalCnt(); + } + + boolean isCompleted = goal.checkGoalCompleted(); + + if(isCompleted) + { + RewardType reward = rewardService.provideReward(); + goal.achieveReward(reward); + goal.complete(); + + int count = goalRepository.countByGoalStatusAndMemberIdAndIsDeletedFalse(GoalStatus.COMPLETE, goal.getMemberId()); + return new GoalCompletedResponse(isCompleted, goal.getReward(), count); + } + + return new GoalCompletedResponse(isCompleted, goal.getReward(), 0); + } + + @Transactional + public DetailGoal updateDetailGoal(Long detailGoalId, DetailGoalUpdateRequest detailGoalUpdateRequest) + { + DetailGoal detailGoal = detailGoalRepository.getByIdAndIsDeletedFalse(detailGoalId); + detailGoal.update(detailGoalUpdateRequest.title(), + detailGoalUpdateRequest.alarmEnabled(), + detailGoalUpdateRequest.alarmTime(), + detailGoalUpdateRequest.alarmDays()); + return detailGoal; + } + + @Transactional + public GoalCompletedResponse completeDetailGoal(Long detailGoalId) + { + DetailGoal detailGoal = detailGoalRepository.getByIdAndIsDeletedFalse(detailGoalId); // 1. 삭제되지 않은 하위 목표 가져온다 + detailGoal.complete(); // 2. 하위 목표를 완료 상태로 변경한다. + + Goal goal = goalRepository.getByIdAndIsDeletedFalse(detailGoal.getGoalId()); // 3. 전체 목표를 가져온다. + goal.increaseCompletedDetailGoalCnt(); // 4. 완료한 하위 목표 개수를 증가시킨다. + + boolean isCompleted = goal.checkGoalCompleted(); // 5. 전체 하위 목표 개수와 완료한 하위 목표 개수가 같은지 체크한다. + + if(isCompleted) + { + RewardType reward = rewardService.provideReward(); // 6. 리워드를 랜덤으로 지급한다. + goal.achieveReward(reward); + goal.complete(); + + int count = goalRepository.countByGoalStatusAndMemberIdAndIsDeletedFalse(GoalStatus.COMPLETE, goal.getMemberId()); + return new GoalCompletedResponse(isCompleted, goal.getReward(), count); + } + + return new GoalCompletedResponse(isCompleted, goal.getReward(), 0); + } + + + @Transactional + public void inCompleteDetailGoal(Long detailGoalId) + { + DetailGoal detailGoal = detailGoalRepository.getByIdAndIsDeletedFalse(detailGoalId); + detailGoal.inComplete(); + Goal goal = goalRepository.getByIdAndIsDeletedFalse(detailGoal.getGoalId()); + goal.decreaseCompletedDetailGoalCnt(); + } +} diff --git a/src/main/java/com/backend/detailgoal/application/RewardService.java b/src/main/java/com/backend/detailgoal/application/RewardService.java new file mode 100644 index 0000000..a1bb3e5 --- /dev/null +++ b/src/main/java/com/backend/detailgoal/application/RewardService.java @@ -0,0 +1,22 @@ +package com.backend.detailgoal.application; + +import com.backend.goal.domain.enums.RewardType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Random; + +@Service +@RequiredArgsConstructor +public class RewardService { + + private Random random = new Random(); + private static final int NUM_GEM_TYPES = RewardType.values().length; + + public RewardType provideReward() + { + int randomIndex = random.nextInt(NUM_GEM_TYPES); + return RewardType.values()[randomIndex]; + } + +} diff --git a/src/main/java/com/backend/detailgoal/application/dto/response/DetailGoalAlarmResponse.java b/src/main/java/com/backend/detailgoal/application/dto/response/DetailGoalAlarmResponse.java new file mode 100644 index 0000000..7318003 --- /dev/null +++ b/src/main/java/com/backend/detailgoal/application/dto/response/DetailGoalAlarmResponse.java @@ -0,0 +1,7 @@ +package com.backend.detailgoal.application.dto.response; + +public record DetailGoalAlarmResponse( + String uid, + String detailGoalTitle +) { +} diff --git a/src/main/java/com/backend/detailgoal/application/dto/response/DetailGoalListResponse.java b/src/main/java/com/backend/detailgoal/application/dto/response/DetailGoalListResponse.java new file mode 100644 index 0000000..eacf6cf --- /dev/null +++ b/src/main/java/com/backend/detailgoal/application/dto/response/DetailGoalListResponse.java @@ -0,0 +1,15 @@ +package com.backend.detailgoal.application.dto.response; + +import com.backend.detailgoal.domain.DetailGoal; + +public record DetailGoalListResponse(Long detailGoalId, String title, Boolean isCompleted) +{ + public static DetailGoalListResponse from(DetailGoal detailGoal) + { + return new DetailGoalListResponse( + detailGoal.getId(), + detailGoal.getTitle(), + detailGoal.getIsCompleted() + ); + } +} diff --git a/src/main/java/com/backend/detailgoal/application/dto/response/DetailGoalResponse.java b/src/main/java/com/backend/detailgoal/application/dto/response/DetailGoalResponse.java new file mode 100644 index 0000000..9dd2a36 --- /dev/null +++ b/src/main/java/com/backend/detailgoal/application/dto/response/DetailGoalResponse.java @@ -0,0 +1,40 @@ +package com.backend.detailgoal.application.dto.response; + +import com.backend.detailgoal.domain.DetailGoal; +import com.fasterxml.jackson.annotation.JsonFormat; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +public record DetailGoalResponse( + + Long detailGoalId, + + String title, + + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "a hh:mm", timezone = "Asia/Seoul", locale = "ko") + LocalTime alarmTime, + + List alarmDays, + + Boolean alarmEnabled +) + +{ + public static DetailGoalResponse from(DetailGoal detailGoal) + { + return new DetailGoalResponse( + detailGoal.getId(), + detailGoal.getTitle(), + detailGoal.getAlarmTime(), + detailGoal.getAlarmDays(), + detailGoal.getAlarmEnabled() + ); + } +} diff --git a/src/main/java/com/backend/detailgoal/application/dto/response/GoalCompletedResponse.java b/src/main/java/com/backend/detailgoal/application/dto/response/GoalCompletedResponse.java new file mode 100644 index 0000000..9b5c4a2 --- /dev/null +++ b/src/main/java/com/backend/detailgoal/application/dto/response/GoalCompletedResponse.java @@ -0,0 +1,11 @@ +package com.backend.detailgoal.application.dto.response; + +import com.backend.goal.domain.enums.RewardType; + +public record GoalCompletedResponse( + Boolean isGoalCompleted, + RewardType rewardType, + Integer completedGoalCount + +) { +} diff --git a/src/main/java/com/backend/detailgoal/domain/DetailGoal.java b/src/main/java/com/backend/detailgoal/domain/DetailGoal.java new file mode 100644 index 0000000..aeaba5b --- /dev/null +++ b/src/main/java/com/backend/detailgoal/domain/DetailGoal.java @@ -0,0 +1,112 @@ +package com.backend.detailgoal.domain; + +import com.backend.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.util.*; +import java.util.stream.Collectors; + + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "detail_goal") +public class DetailGoal extends BaseEntity { + + private static final int MAX_TITLE_LENGTH = 15; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "detail_goal_id") + private Long id; + + @Column(name = "goal_id") + private Long goalId; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "is_completed", nullable = false) + private Boolean isCompleted; + + @Column(name = "alarm_enabled", nullable = false) + private Boolean alarmEnabled; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "detail_goals_alarm_days", joinColumns = @JoinColumn(name = "detail_goal_id")) + @Column(name = "alarm_days") + @Enumerated(EnumType.STRING) + private List alarmDays; + + @Column(name = "alarm_time") + private LocalTime alarmTime; + + public void setGoalId(Long goalId) + { + this.goalId = goalId; + } + + public void remove() + { + this.isDeleted = Boolean.TRUE; + } + + + public void update(String title, Boolean alarmEnabled, LocalTime alarmTime, List alarmDays) + { + validateTitleLength(title); + this.title = title; + this.alarmEnabled = alarmEnabled; + this.alarmTime = alarmTime; + updateAlarmDays(alarmDays); + } + + private void validateTitleLength(final String title) { + + if (title.length() > MAX_TITLE_LENGTH) { + throw new IllegalArgumentException(String.format("상위 목표 제목의 길이는 %d을 초과할 수 없습니다.", MAX_TITLE_LENGTH)); + } + } + + @Builder + public DetailGoal(Long goalId, String title, Boolean isCompleted, Boolean alarmEnabled, List alarmDays, LocalTime alarmTime) { + + this.goalId = goalId; + this.title = title; + this.isCompleted = isCompleted; + this.alarmEnabled = alarmEnabled; + this.alarmDays = alarmDays; + this.alarmTime = alarmTime; + } + + @PrePersist + public void init() + { + this.isDeleted = Boolean.FALSE; + this.isCompleted = Boolean.FALSE; + } + + public void complete() + { + this.isCompleted = Boolean.TRUE; + } + + public void inComplete() + { + this.isCompleted = Boolean.FALSE; + } + + private void updateAlarmDays(List alarmDays) + { + this.alarmDays = alarmDays.stream().map(DayOfWeek::valueOf).collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/backend/detailgoal/domain/event/AlarmEvent.java b/src/main/java/com/backend/detailgoal/domain/event/AlarmEvent.java new file mode 100644 index 0000000..165f385 --- /dev/null +++ b/src/main/java/com/backend/detailgoal/domain/event/AlarmEvent.java @@ -0,0 +1,9 @@ +package com.backend.detailgoal.domain.event; + +public record AlarmEvent( + String uid, + String detailGoalTitle +) { + + +} diff --git a/src/main/java/com/backend/detailgoal/domain/repository/DetailGoalQueryRepository.java b/src/main/java/com/backend/detailgoal/domain/repository/DetailGoalQueryRepository.java new file mode 100644 index 0000000..42bb77e --- /dev/null +++ b/src/main/java/com/backend/detailgoal/domain/repository/DetailGoalQueryRepository.java @@ -0,0 +1,41 @@ +package com.backend.detailgoal.domain.repository; + +import com.backend.detailgoal.application.dto.response.DetailGoalAlarmResponse; +import com.backend.goal.domain.QGoal; +import com.backend.member.domain.QMember; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.util.List; +import static com.backend.detailgoal.domain.QDetailGoal.*; +import static com.backend.goal.domain.QGoal.*; +import static com.backend.member.domain.QMember.*; + + +@Repository +@RequiredArgsConstructor +public class DetailGoalQueryRepository { + + private final JPAQueryFactory query; + + public List getMemberIdListDetailGoalAlarmTimeArrived(DayOfWeek dayOfWeek, LocalTime alarmTime) + { + return query.select(Projections.constructor(DetailGoalAlarmResponse.class, member.uid, detailGoal.title)) + .from(detailGoal) + .leftJoin(goal).on(goal.id.eq(detailGoal.goalId)) + .leftJoin(member).on(member.id.eq(goal.memberId)) + .where( + detailGoal.isDeleted.isFalse(), // 삭제 되지 않은 것들만 조회 + detailGoal.isCompleted.isFalse(), // 아직 완료되지 않은 것들 조회 + detailGoal.alarmEnabled.isTrue(), // 알람을 허용한 하위 댓글 조회 + detailGoal.alarmDays.contains(dayOfWeek), // 해당 요일에 알림을 주기로 되있는 댓글 조회 + detailGoal.alarmTime.eq(alarmTime) + ) + .fetch(); + } + +} diff --git a/src/main/java/com/backend/detailgoal/domain/repository/DetailGoalRepository.java b/src/main/java/com/backend/detailgoal/domain/repository/DetailGoalRepository.java new file mode 100644 index 0000000..3871b20 --- /dev/null +++ b/src/main/java/com/backend/detailgoal/domain/repository/DetailGoalRepository.java @@ -0,0 +1,19 @@ +package com.backend.detailgoal.domain.repository; + +import com.backend.detailgoal.domain.DetailGoal; +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface DetailGoalRepository extends JpaRepository { + + List findAllByGoalIdAndIsDeletedFalse(Long goalId); + + default DetailGoal getByIdAndIsDeletedFalse(Long detailGoalId){ + + return findById(detailGoalId).orElseThrow(() -> new BusinessException(ErrorCode.DETAIL_GOAL_NOT_FOUND)); + } + +} diff --git a/src/main/java/com/backend/detailgoal/presentation/DetailGoalController.java b/src/main/java/com/backend/detailgoal/presentation/DetailGoalController.java new file mode 100644 index 0000000..a772ea8 --- /dev/null +++ b/src/main/java/com/backend/detailgoal/presentation/DetailGoalController.java @@ -0,0 +1,89 @@ +package com.backend.detailgoal.presentation; + +import com.backend.detailgoal.application.DetailGoalService; +import com.backend.detailgoal.application.dto.response.DetailGoalListResponse; +import com.backend.detailgoal.application.dto.response.DetailGoalResponse; +import com.backend.detailgoal.application.dto.response.GoalCompletedResponse; +import com.backend.detailgoal.presentation.dto.request.DetailGoalSaveRequest; +import com.backend.detailgoal.presentation.dto.request.DetailGoalUpdateRequest; +import com.backend.global.common.response.CustomResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +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.*; + +import java.util.List; + +import static com.backend.global.common.code.SuccessCode.*; + +@RestController +@RequiredArgsConstructor +@Tag(name = "detailGoal", description = "하위 목표 API") +public class DetailGoalController { + + private final DetailGoalService detailGoalService; + + @Operation(summary = "하위 목표 리스트 조회", description = "하위 목표 리스트를 조회하는 API 입니다.") + @ApiResponse(responseCode = "200", description = "code : 200, message : SELECT_SUCCESS") + @GetMapping("/goals/{id}/detail-goals") + public ResponseEntity>> getDetailGoalList(@Parameter(description = "상위 목표 ID") @PathVariable Long id) + { + return CustomResponse.success(SELECT_SUCCESS, detailGoalService.getDetailGoalList(id)); + } + + @Operation(summary = "하위 목표 상세 조회", description = "하위 목표를 상세 조회하는 API 입니다.") + @ApiResponse(responseCode = "200", description = "code : 200, message : SELECT_SUCCESS") + @GetMapping("/detail-goals/{id}") + public ResponseEntity> getDetailGoal(@Parameter(description = "하위 목표 ID") @PathVariable Long id) + { + return CustomResponse.success(SELECT_SUCCESS, detailGoalService.getDetailGoal(id)); + } + + @Operation(summary = "하위 목표 생성", description = "하위 목표를 생성하는 API 입니다.") + @ApiResponse(responseCode = "201", description = "code : 201, message : INSERT_SUCCESS") + @PostMapping("/goals/{id}/detail-goals") + public ResponseEntity> saveDetailGoal(@Parameter(description = "상위 목표 ID") @PathVariable Long id, @RequestBody @Valid DetailGoalSaveRequest detailGoalSaveRequest) + { + detailGoalService.saveDetailGoal(id, detailGoalSaveRequest); + return CustomResponse.success(INSERT_SUCCESS); + } + + @Operation(summary = "하위 목표 수정", description = "하위 목표를 수정하는 API 입니다.") + @ApiResponse(responseCode = "200", description = "code : 200, message : UPDATE_SUCCESS") + @PatchMapping("/detail-goals/{id}") + public ResponseEntity> updateDetailGoal(@Parameter(description = "하위 목표 ID") @PathVariable Long id, @RequestBody @Valid DetailGoalUpdateRequest detailGoalUpdateRequest) + { + detailGoalService.updateDetailGoal(id, detailGoalUpdateRequest); + return CustomResponse.success(UPDATE_SUCCESS); + } + + @Operation(summary = "하위 목표 삭제", description = "하위 목표를 삭제하는 API 입니다.") + @ApiResponse(responseCode = "200", description = "code : 200, message : DELETE_SUCCESS") + @DeleteMapping("/detail-goals/{id}") + public ResponseEntity> removeDetailGoal(@Parameter(description = "하위 목표 ID") @PathVariable Long id) + { + + return CustomResponse.success(DELETE_SUCCESS, detailGoalService.removeDetailGoal(id)); + } + + @Operation(summary = "하위 목표 달성", description = "하위 목표를 달성하는 API 입니다.") + @ApiResponse(responseCode = "200", description = "code : 200, message : UPDATE_SUCCESS") + @PatchMapping("/detail-goals/{id}/complete") + public ResponseEntity> completeDetailGoal(@Parameter(description = "하위 목표 ID") @PathVariable Long id) + { + return CustomResponse.success(UPDATE_SUCCESS, detailGoalService.completeDetailGoal(id)); + } + + @Operation(summary = "하위 목표 달성 취소", description = "하위 목표 달성을 취소하는 API 입니다.") + @ApiResponse(responseCode = "200", description = "code : 200, message : DELETE_SUCCESS") + @PatchMapping("/detail-goals/{id}/incomplete") + public ResponseEntity> incompleteDetailGoal(@Parameter(description = "하위 목표 ID") @PathVariable Long id) + { + detailGoalService.inCompleteDetailGoal(id); + return CustomResponse.success(UPDATE_SUCCESS); + } +} diff --git a/src/main/java/com/backend/detailgoal/presentation/dto/request/DetailGoalSaveRequest.java b/src/main/java/com/backend/detailgoal/presentation/dto/request/DetailGoalSaveRequest.java new file mode 100644 index 0000000..e9f1f56 --- /dev/null +++ b/src/main/java/com/backend/detailgoal/presentation/dto/request/DetailGoalSaveRequest.java @@ -0,0 +1,45 @@ +package com.backend.detailgoal.presentation.dto.request; + +import com.backend.detailgoal.domain.DetailGoal; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +public record DetailGoalSaveRequest( + + @Size(max = 15, message = "상위 목표 제목은 15자를 초과할 수 없습니다.") + @Schema(description = "하위 목표 제목", example = "오픽 노잼 IH 시리즈 보기") + String title, + + @NotNull(message = "알림 설정 여부는 빈값일 수 없습니다.") + @Schema(description = "알람 수행 여부") + Boolean alarmEnabled, + + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "a hh:mm", timezone = "Asia/Seoul", locale = "ko") + @Schema(description = "알람 받을 시각", example = "오후 11:30") + LocalTime alarmTime, + + @Schema(description = "요일 정보", example = "[\"MONDAY\", \"TUSEDAY\", \"FRIDAY\"]") + List alarmDays +) { + + public DetailGoal toEntity() + { + List days = alarmDays.stream().map(DayOfWeek::valueOf).collect(Collectors.toList()); + return DetailGoal.builder() + .title(title) + .alarmEnabled(alarmEnabled) + .alarmTime(alarmTime) + .alarmDays(days) + .build(); + } +} diff --git a/src/main/java/com/backend/detailgoal/presentation/dto/request/DetailGoalUpdateRequest.java b/src/main/java/com/backend/detailgoal/presentation/dto/request/DetailGoalUpdateRequest.java new file mode 100644 index 0000000..6dccce0 --- /dev/null +++ b/src/main/java/com/backend/detailgoal/presentation/dto/request/DetailGoalUpdateRequest.java @@ -0,0 +1,31 @@ +package com.backend.detailgoal.presentation.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalTime; +import java.util.List; + +public record DetailGoalUpdateRequest( + + + @Size(max = 15, message = "상위 목표 제목은 15자를 초과할 수 없습니다.") + @Schema(description = "하위 목표 제목", example = "오픽 노잼 IH 영상 보기") + String title, + + @NotNull(message = "알림 설정 여부는 빈값일 수 없습니다.") + @Schema(description = "알람 수행 여부") + Boolean alarmEnabled, + + + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "a hh:mm", timezone = "Asia/Seoul", locale = "ko") + @Schema(description = "알람 받을 시각", example = "오후 11:30") + LocalTime alarmTime, + + @Schema(description = "요일 정보", example = "[\"MONDAY\", \"TUSEDAY\", \"FRIDAY\"]") + List alarmDays +) { +} diff --git a/src/main/java/com/backend/global/api/AuthTestController.java b/src/main/java/com/backend/global/api/AuthTestController.java new file mode 100644 index 0000000..95adf9b --- /dev/null +++ b/src/main/java/com/backend/global/api/AuthTestController.java @@ -0,0 +1,15 @@ +package com.backend.global.api; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class AuthTestController { + + @GetMapping("/token") + public String tokenTest() + { + return "token valid!"; + } +} diff --git a/src/main/java/com/backend/api/LoggingController.java b/src/main/java/com/backend/global/api/LoggingController.java similarity index 94% rename from src/main/java/com/backend/api/LoggingController.java rename to src/main/java/com/backend/global/api/LoggingController.java index 6d6191e..8ae6b43 100644 --- a/src/main/java/com/backend/api/LoggingController.java +++ b/src/main/java/com/backend/global/api/LoggingController.java @@ -1,4 +1,4 @@ -package com.backend.api; +package com.backend.global.api; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/com/backend/global/common/code/ErrorCode.java b/src/main/java/com/backend/global/common/code/ErrorCode.java new file mode 100644 index 0000000..c91c75d --- /dev/null +++ b/src/main/java/com/backend/global/common/code/ErrorCode.java @@ -0,0 +1,103 @@ +package com.backend.global.common.code; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +@Getter +public enum ErrorCode { + + /* + Global Error + */ + SERVER_ERROR(INTERNAL_SERVER_ERROR.value(), "COMMON-001", "서버에서 처리할 수 없습니다."), + + INVALID_INPUT_VALUE(BAD_REQUEST.value(), "COMMON-002", "유효성 검증에 실패했습니다."), + + BINDING_ERROR(BAD_REQUEST.value(), "COMMON-003", "요청 값 바인딩에 실패했습니다."), + + BAD_REQUEST_ERROR(BAD_REQUEST.value(), "COMMON-004", "Bad Request Exception"), + + // @RequestBody 데이터 미 존재 + REQUEST_BODY_MISSING_ERROR(BAD_REQUEST.value(), "COMMON-005", "Required request body is missing"), + + // 유효하지 않은 타입 + INVALID_TYPE_VALUE(BAD_REQUEST.value(), "COMMON-006", " Invalid Type Value"), + + // Request Parameter 로 데이터가 전달되지 않을 경우 + MISSING_REQUEST_PARAMETER_ERROR(BAD_REQUEST.value(), "COMMON-007", "Missing Servlet RequestParameter Exception"), + + // 입력/출력 값이 유효하지 않음 + IO_ERROR(BAD_REQUEST.value(), "COMMON-008", "I/O Exception"), + + // com.google.gson JSON 파싱 실패 + JSON_PARSE_ERROR(BAD_REQUEST.value(), "COMMON-009", "JsonParseException"), + + // com.fasterxml.jackson.core Processing Error + JACKSON_PROCESS_ERROR(BAD_REQUEST.value(), "COMMON-010", "com.fasterxml.jackson.core Exception"), + + // 서버로 요청한 리소스가 존재하지 않음 + NOT_FOUND_ERROR(NOT_FOUND.value(), "COMMON-011", "Not Found Exception"), + + // NULL Point Exception 발생 + NULL_POINT_ERROR(NOT_FOUND.value(), "COMMON-012", "Null Point Exception"), + + // @RequestBody 및 @RequestParam, @PathVariable 값이 유효하지 않음 + NOT_VALID_ERROR(NOT_FOUND.value(), "COMMON-013", "handle Validation Exception"), + + // @RequestBody 및 @RequestParam, @PathVariable 값이 유효하지 않음 + NOT_VALID_HEADER_ERROR(NOT_FOUND.value(), "COMMON-014", "Header에 데이터가 존재하지 않는 경우 "), + + /* + Business Error + */ + + + /* Goal */ + GOAL_NOT_FOUND(NOT_FOUND.value(), "GOAL-001", "상위 목표가 존재하지 않습니다."), + + ENTIRE_DETAIL_GOAL_CNT_INVALID(BAD_REQUEST.value(), "GOAL-002", "총 하위 목표 개수가 0개일때는 뺄수 없습니다."), + + COMPLETED_DETAIL_GOAL_CNT_INVALID(BAD_REQUEST.value(), "GOAL-003", "성공한 하위목표 개수가 0개 일때는 뺄 수 없습니다."), + + RECOVER_GOAL_IMPOSSIBLE(BAD_REQUEST.value(), "GOAL-004", "상위 목표가 보관함에 있을때 채움함으로 복구할 수 있습니다."), + + /* Detail Goal */ + DETAIL_GOAL_NOT_FOUND(NOT_FOUND.value(), "DETAIL-GOAL-001", "하위 목표가 존재하지 않습니다."), + + + /* Auth */ + TOKEN_EXPIRED(UNAUTHORIZED.value(), "AUTH-001", "토큰의 유효기간이 만료되었습니다."), + + INVALID_TOKEN(BAD_REQUEST.value(), "AUTH-002", "잘못된 형식의 토큰 입력입니다."), + + MEMBER_NOT_FOUND(NOT_FOUND.value(), "AUTH-003", "회원이 존재하지 않습니다."), + + NO_TOKEN_PROVIDED(UNAUTHORIZED.value(), "AUTH-004", "토큰이 입력되지 않았습니다."), + + BLACK_LIST_TOKEN(UNAUTHORIZED.value(), "AUTH-005", "로그아웃하거나 회원탈퇴한 사용자입니다."), + + TOKEN_NOT_FOUND(UNAUTHORIZED.value(), "AUTH-006", "인증되지 않은 토큰의 입력입니다."), + + /* Retrospect */ + RETROSPECT_IS_NOT_WRITTEN(NOT_FOUND.value(), "RETROSPECT-001", "회고가 작성되지 않은 상위 목표입니다."), + + ALREADY_HAS_RETROSPECT(BAD_REQUEST.value(), "RETROSPECT-002", "이미 회고를 작성한 상위목표입니다."), + + CONTENT_TOO_LONG(BAD_REQUEST.value(), "RETROSPECT-003", "회고글의 길이가 제한된 길이를 초과하였습니다."); + + private final int status; + + private final String code; + + private final String message; + + ErrorCode(final int status, final String code, final String message) { + this.status = status; + this.code = code; + this.message = message; + } +} diff --git a/src/main/java/com/backend/global/common/code/SuccessCode.java b/src/main/java/com/backend/global/common/code/SuccessCode.java new file mode 100644 index 0000000..7ac6e99 --- /dev/null +++ b/src/main/java/com/backend/global/common/code/SuccessCode.java @@ -0,0 +1,22 @@ +package com.backend.global.common.code; + +import lombok.Getter; + +@Getter +public enum SuccessCode { + + SELECT_SUCCESS(200, "SELECT SUCCESS"), + INSERT_SUCCESS(201, "INSERT SUCCESS"), + DELETE_SUCCESS(200, "DELETE SUCCESS"), + UPDATE_SUCCESS(200, "UPDATE SUCCESS"), + LOGIN_SUCCESS(200, "LOGIN SUCCESS"), + LOGOUT_SUCCESS(200, "LOGOUT SUCCESS"); + + private final int status; + private final String message; + + SuccessCode(final int status, final String message) { + this.status = status; + this.message = message; + } +} \ No newline at end of file diff --git a/src/main/java/com/backend/global/common/response/CustomResponse.java b/src/main/java/com/backend/global/common/response/CustomResponse.java new file mode 100644 index 0000000..bacc53a --- /dev/null +++ b/src/main/java/com/backend/global/common/response/CustomResponse.java @@ -0,0 +1,33 @@ +package com.backend.global.common.response; + +import com.backend.global.common.code.SuccessCode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.http.ResponseEntity; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CustomResponse { + + private int code; + private String message; + private T data; + + + public CustomResponse(final int code, final String message) { + this.code = code; + this.message = message; + } + + public static ResponseEntity> success(final SuccessCode successCode, final T data) { + return ResponseEntity.status(successCode.getStatus()) + .body(new CustomResponse<>(successCode.getStatus(), successCode.getMessage(), data)); + } + + public static ResponseEntity> success(final SuccessCode successCode) { + return ResponseEntity.status(successCode.getStatus()) + .body(new CustomResponse<>(successCode.getStatus(), successCode.getMessage(), null)); + } +} \ No newline at end of file diff --git a/src/main/java/com/backend/global/common/response/ErrorResponse.java b/src/main/java/com/backend/global/common/response/ErrorResponse.java new file mode 100644 index 0000000..5d89ed8 --- /dev/null +++ b/src/main/java/com/backend/global/common/response/ErrorResponse.java @@ -0,0 +1,91 @@ +package com.backend.global.common.response; + +import com.backend.global.common.code.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.validation.BindingResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@AllArgsConstructor +public class ErrorResponse { + + private final int status; + private final String code; + private final String message; + private List errors; + private String reason; + + + @Builder + protected ErrorResponse(final ErrorCode code, final List errors) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.errors = errors; + this.code = code.getCode(); + } + + @Builder + protected ErrorResponse(final ErrorCode code) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.code = code.getCode(); + this.errors = new ArrayList<>(); + } + + @Builder + protected ErrorResponse(final ErrorCode code, final String reason) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.code = code.getCode(); + this.reason = reason; + } + + public static ErrorResponse of(final ErrorCode code, final String reason) { + return new ErrorResponse(code, reason); + } + + + public static ErrorResponse of(final ErrorCode code) { + return new ErrorResponse(code); + } + + public static ErrorResponse of(final ErrorCode code, final BindingResult bindingResult) { + return new ErrorResponse(code, FieldError.of(bindingResult)); + } + + + @Getter + public static class FieldError { + private final String field; + private final String value; + private final String reason; + + public static List of(final String field, final String value, final String reason) { + List fieldErrors = new ArrayList<>(); + fieldErrors.add(new FieldError(field, value, reason)); + return fieldErrors; + } + + private static List of(final BindingResult bindingResult) { + final List fieldErrors = bindingResult.getFieldErrors(); + return fieldErrors.stream() + .map(error -> new FieldError( + error.getField(), + error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(), + error.getDefaultMessage())) + .collect(Collectors.toList()); + } + + @Builder + FieldError(String field, String value, String reason) { + this.field = field; + this.value = value; + this.reason = reason; + } + } +} diff --git a/src/main/java/com/backend/global/config/AysncConfig.java b/src/main/java/com/backend/global/config/AysncConfig.java new file mode 100644 index 0000000..58fa6e0 --- /dev/null +++ b/src/main/java/com/backend/global/config/AysncConfig.java @@ -0,0 +1,9 @@ +package com.backend.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@EnableAsync +@Configuration +public class AysncConfig { +} diff --git a/src/main/java/com/backend/global/config/FcmConfig.java b/src/main/java/com/backend/global/config/FcmConfig.java new file mode 100644 index 0000000..969d924 --- /dev/null +++ b/src/main/java/com/backend/global/config/FcmConfig.java @@ -0,0 +1,55 @@ +package com.backend.global.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +@Slf4j +@Configuration +public class FcmConfig { + + + @Value("${fcm.certification}") + private String googleApplicationCredentials; + + @Bean + public FirebaseMessaging firebaseMessaging() throws IOException { + + ClassPathResource resource = new ClassPathResource(googleApplicationCredentials); + + InputStream in = resource.getInputStream(); + + FirebaseApp firebaseApp = null; + List firebaseAppList = FirebaseApp.getApps(); + + if(firebaseAppList != null && !firebaseAppList.isEmpty()) + { + for (FirebaseApp app : firebaseAppList) { + if(app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) + { + firebaseApp = app; + } + } + }else { + FirebaseOptions options = FirebaseOptions.builder().setCredentials(GoogleCredentials.fromStream(in)).build(); + + firebaseApp = FirebaseApp.initializeApp(options); + } + + return FirebaseMessaging.getInstance(firebaseApp); + } + + +} diff --git a/src/main/java/com/backend/global/config/QuerydslConfig.java b/src/main/java/com/backend/global/config/QuerydslConfig.java new file mode 100644 index 0000000..5dc15ec --- /dev/null +++ b/src/main/java/com/backend/global/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package com.backend.global.config; + +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 + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/backend/global/config/RedisConfig.java b/src/main/java/com/backend/global/config/RedisConfig.java new file mode 100644 index 0000000..a92866a --- /dev/null +++ b/src/main/java/com/backend/global/config/RedisConfig.java @@ -0,0 +1,28 @@ +package com.backend.auth.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory connectionFactory(){ + RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration(); + redisConfiguration.setHostName(host); + redisConfiguration.setPort(port); + return new LettuceConnectionFactory(redisConfiguration); + } +} \ No newline at end of file diff --git a/src/main/java/com/backend/global/config/SchedulerConfig.java b/src/main/java/com/backend/global/config/SchedulerConfig.java new file mode 100644 index 0000000..7a2bc00 --- /dev/null +++ b/src/main/java/com/backend/global/config/SchedulerConfig.java @@ -0,0 +1,9 @@ +package com.backend.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@Configuration +public class SchedulerConfig { +} diff --git a/src/main/java/com/backend/global/config/SecurityConfig.java b/src/main/java/com/backend/global/config/SecurityConfig.java new file mode 100644 index 0000000..d476d98 --- /dev/null +++ b/src/main/java/com/backend/global/config/SecurityConfig.java @@ -0,0 +1,64 @@ +package com.backend.auth.config; + +import com.backend.auth.application.BlackListService; +import com.backend.auth.jwt.filter.JwtExceptionFilter; +import com.backend.auth.jwt.handler.JwtAccessDeniedHandler; +import com.backend.auth.jwt.handler.JwtAuthenticationEntryPoint; +import com.backend.auth.jwt.filter.AuthenticationFilter; +import com.backend.auth.jwt.TokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final TokenProvider tokenProvider; + private final BlackListService blackListService; + + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + @Bean + public WebSecurityCustomizer webSecurityCustomizer(){ + return web -> web.ignoring() + .requestMatchers("/swagger-ui/**", "/api-docs/**", "/health", "/auth/**"); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + httpSecurity + .httpBasic().disable() + .csrf().disable() + .cors().disable() + + .exceptionHandling() + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + .and() + .headers() + .frameOptions().disable() + .and() + .authorizeHttpRequests() + + .anyRequest().authenticated() + + .and() + + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + + .formLogin().disable() + .addFilterBefore(new AuthenticationFilter(tokenProvider, blackListService), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new JwtExceptionFilter(), AuthenticationFilter.class); // AuthenticationFilter에서 발생한 예외가 JwtExceptionFilter에서 처리된다. + return httpSecurity.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/backend/global/config/SwaggerConfig.java b/src/main/java/com/backend/global/config/SwaggerConfig.java new file mode 100644 index 0000000..971ca78 --- /dev/null +++ b/src/main/java/com/backend/global/config/SwaggerConfig.java @@ -0,0 +1,43 @@ +package com.backend.global.config; + +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 org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Collections; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + + Server testServer = new Server(); + testServer.setUrl("http://192.168.1.190:8080"); + + return new OpenAPI() + .addServersItem(new Server().url("/")) + .info( + new Info() + .title("DND-9th-1 'Milestone' API Document") + .description("DND 9기 1팀 'Milestone' 프로젝트의 API 명세서입니다.") + .version("v0.0.1") + ) + .addSecurityItem(new SecurityRequirement().addList("Authorization")) + .addServersItem(testServer) + .components( + new Components() + .addSecuritySchemes( + "Authorization", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("Bearer") + .bearerFormat("JWT") + ) + ); + } +} diff --git a/src/main/java/com/backend/global/config/WebConfig.java b/src/main/java/com/backend/global/config/WebConfig.java new file mode 100644 index 0000000..15dd188 --- /dev/null +++ b/src/main/java/com/backend/global/config/WebConfig.java @@ -0,0 +1,19 @@ +package com.backend.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("https://dnd9th.site") // 허용할 도메인 설정 + .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH") // 허용할 HTTP 메소드 설정 + .allowedHeaders("Origin", "Content-Type", "Accept") // 허용할 헤더 설정 + .allowCredentials(true) // 인증정보 허용 여부 + .maxAge(3600); // preflight 요청의 유효시간 설정 + } +} diff --git a/src/main/java/com/backend/global/entity/BaseEntity.java b/src/main/java/com/backend/global/entity/BaseEntity.java new file mode 100644 index 0000000..04a91dd --- /dev/null +++ b/src/main/java/com/backend/global/entity/BaseEntity.java @@ -0,0 +1,23 @@ +package com.backend.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import org.mariadb.jdbc.plugin.codec.LocalDateTimeCodec; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + @CreatedDate + @Column(name="created_at", nullable=false, updatable=false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name="updated_at", nullable=false) + private LocalDateTime updatedAt ; +} diff --git a/src/main/java/com/backend/global/event/AlarmEventHandler.java b/src/main/java/com/backend/global/event/AlarmEventHandler.java new file mode 100644 index 0000000..348d5ff --- /dev/null +++ b/src/main/java/com/backend/global/event/AlarmEventHandler.java @@ -0,0 +1,24 @@ +package com.backend.global.event; + + +import com.backend.detailgoal.domain.event.AlarmEvent; +import com.backend.infrastructure.fcm.FcmService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class AlarmEventHandler { + + + private final FcmService fcmService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendAlarm(AlarmEvent event) { + + fcmService.sendMessage(event.uid(), event.detailGoalTitle()); + } + +} diff --git a/src/main/java/com/backend/global/event/GoalEventHandler.java b/src/main/java/com/backend/global/event/GoalEventHandler.java new file mode 100644 index 0000000..2ec8daf --- /dev/null +++ b/src/main/java/com/backend/global/event/GoalEventHandler.java @@ -0,0 +1,31 @@ +package com.backend.global.event; + +import com.backend.detailgoal.domain.DetailGoal; +import com.backend.detailgoal.domain.repository.DetailGoalRepository; +import com.backend.goal.domain.event.RemoveRelatedDetailGoalEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class GoalEventHandler { + + private final DetailGoalRepository detailGoalRepository; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void removeDetailGoalList(RemoveRelatedDetailGoalEvent event) { + + List detailGoalList = detailGoalRepository.findAllByGoalIdAndIsDeletedFalse(event.goalId()); + detailGoalList.forEach((DetailGoal::remove)); + } + + + +} diff --git a/src/main/java/com/backend/global/event/ReminderEventHandler.java b/src/main/java/com/backend/global/event/ReminderEventHandler.java new file mode 100644 index 0000000..b2f3d1b --- /dev/null +++ b/src/main/java/com/backend/global/event/ReminderEventHandler.java @@ -0,0 +1,23 @@ +package com.backend.global.event; + + +import com.backend.goal.domain.event.ReminderEvent; +import com.backend.infrastructure.fcm.FcmService; +import com.backend.member.domain.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class ReminderEventHandler { + + private final FcmService fcmService; + private final MemberRepository memberRepository; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendAlarm(ReminderEvent event) { + + } +} diff --git a/src/main/java/com/backend/global/exception/BusinessException.java b/src/main/java/com/backend/global/exception/BusinessException.java new file mode 100644 index 0000000..574c42a --- /dev/null +++ b/src/main/java/com/backend/global/exception/BusinessException.java @@ -0,0 +1,20 @@ +package com.backend.global.exception; + +import com.backend.global.common.code.ErrorCode; + +public class BusinessException extends RuntimeException { + + private ErrorCode errorCode; + + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } + +} + diff --git a/src/main/java/com/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/com/backend/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..f8b0bb8 --- /dev/null +++ b/src/main/java/com/backend/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,129 @@ +package com.backend.global.exception; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.common.response.ErrorResponse; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.servlet.NoHandlerFoundException; + +import java.io.IOException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + private final HttpStatus HTTP_STATUS_OK = HttpStatus.OK; + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidException(MethodArgumentNotValidException e) { + log.error("handleValidException", e); + + ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE; + BindingResult bindingResult = e.getBindingResult(); + + final ErrorResponse response = ErrorResponse.of(errorCode, bindingResult); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + + @ExceptionHandler(BindException.class) + public ResponseEntity handleBindException(BindException e) { + log.error("bindException", e); + + ErrorCode errorCode = ErrorCode.BINDING_ERROR; + BindingResult bindingResult = e.getBindingResult(); + + final ErrorResponse response = ErrorResponse.of(errorCode, bindingResult); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + + // 비즈니스 예외 일괄 처리 + @ExceptionHandler(BusinessException.class) + protected ResponseEntity handleBusinessException(BusinessException e) { + ErrorCode errorCode = e.getErrorCode(); + ErrorResponse response = ErrorResponse.of(errorCode); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + @ExceptionHandler(IOException.class) + protected ResponseEntity handleIOException(IOException ex) { + log.error("handleIOException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.IO_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + @ExceptionHandler(NullPointerException.class) + protected ResponseEntity handleNullPointerException(NullPointerException e) { + log.error("handleNullPointerException", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.NULL_POINT_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + @ExceptionHandler(NoHandlerFoundException.class) + protected ResponseEntity handleNoHandlerFoundExceptionException(NoHandlerFoundException e) { + log.error("handleNoHandlerFoundExceptionException", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.NOT_FOUND_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException ex) { + log.error("HttpMessageNotReadableException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.REQUEST_BODY_MISSING_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + + @ExceptionHandler(MissingServletRequestParameterException.class) + protected ResponseEntity handleMissingRequestHeaderExceptionException( + MissingServletRequestParameterException ex) { + log.error("handleMissingServletRequestParameterException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.MISSING_REQUEST_PARAMETER_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + + @ExceptionHandler(HttpClientErrorException.BadRequest.class) + protected ResponseEntity handleBadRequestException(HttpClientErrorException e) { + log.error("HttpClientErrorException.BadRequest", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.BAD_REQUEST_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + + @ExceptionHandler(JsonParseException.class) + protected ResponseEntity handleJsonParseExceptionException(JsonParseException ex) { + log.error("handleJsonParseExceptionException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.JSON_PARSE_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + @ExceptionHandler(JsonProcessingException.class) + protected ResponseEntity handleJsonProcessingException(JsonProcessingException ex) { + log.error("handleJsonProcessingException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.REQUEST_BODY_MISSING_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + // 잡히지 않은 에러들을 일괄 처리 + @ExceptionHandler(Exception.class) + protected ResponseEntity handleException(Exception ex) { + log.error("internalServerError", ex); + ErrorResponse response = ErrorResponse.of(ErrorCode.SERVER_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } +} \ No newline at end of file diff --git a/src/main/java/com/backend/global/scheduler/SchedulerService.java b/src/main/java/com/backend/global/scheduler/SchedulerService.java new file mode 100644 index 0000000..26a0bea --- /dev/null +++ b/src/main/java/com/backend/global/scheduler/SchedulerService.java @@ -0,0 +1,86 @@ +package com.backend.global.scheduler; + +import com.backend.detailgoal.application.dto.response.DetailGoalAlarmResponse; + +import com.backend.detailgoal.domain.event.AlarmEvent; +import com.backend.detailgoal.domain.repository.DetailGoalQueryRepository; +import com.backend.goal.domain.Goal; +import com.backend.goal.domain.repository.GoalQueryRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class SchedulerService { + + private final GoalQueryRepository goalQueryRepository; + + private final DetailGoalQueryRepository detailGoalQueryRepository; + + private final ApplicationEventPublisher applicationEventPublisher; + + private static final int RAND_COUNT = 2; + + private static final int REMIND_INTERVAL = 14; + + @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul") + public void storeOutDateGoal() { + + List goalList = goalQueryRepository.findGoalListEndDateExpired(LocalDate.now()); + goalList.forEach(Goal::store); + } + + @Scheduled(cron = "0 */30 * * * *", zone = "Asia/Seoul") + public void sendAlarm() + { + DayOfWeek dayOfWeek = LocalDate.now().getDayOfWeek(); + LocalTime localTime = LocalTime.now(); + LocalTime now = LocalTime.of(localTime.getHour(), localTime.getMinute(), 0); + + List detailGoalAlarmList = detailGoalQueryRepository.getMemberIdListDetailGoalAlarmTimeArrived(dayOfWeek, now); + log.info("{}",detailGoalAlarmList.size()); + detailGoalAlarmList.forEach(alarmDto -> + applicationEventPublisher.publishEvent(new AlarmEvent(alarmDto.uid(), alarmDto.detailGoalTitle()))); + } + +// @Scheduled(cron = "0 19 * * 0 *", zone = "Asia/Seoul") +// public void sendReminder() +// { +// List goalListReminderEnabled = goalQueryRepository.findGoalListReminderEnabled(); +// +// Random random = new Random(); +// +// // 랜덤하게 2개 선택 +// for (int i = 0; i < RAND_COUNT; i++) { +// +// int randomIndex = random.nextInt(goalListReminderEnabled.size()); +// Goal goal = goalListReminderEnabled.get(randomIndex); +// +// if(Objects.nonNull(goal.getLastRemindDate()) && isIntervalDateExpired(goal)) +// { +// goal.updateLastRemindDate(LocalDate.now()); +// applicationEventPublisher.publishEvent(new ReminderEvent(goal.getMemberId(), goal.getTitle())); +// } +// } +// } +// +// private boolean isIntervalDateExpired(Goal goal) { +// return goal.getLastRemindDate().isBefore(LocalDate.now().minusDays(REMIND_INTERVAL)); +// } + + +} diff --git a/src/main/java/com/backend/goal/application/GoalService.java b/src/main/java/com/backend/goal/application/GoalService.java new file mode 100644 index 0000000..b8edc72 --- /dev/null +++ b/src/main/java/com/backend/goal/application/GoalService.java @@ -0,0 +1,148 @@ +package com.backend.goal.application; + + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import com.backend.goal.application.dto.response.GoalCountResponse; +import com.backend.goal.application.dto.response.GoalListResponse; +import com.backend.goal.application.dto.response.RetrospectEnabledGoalCountResponse; +import com.backend.goal.domain.*; +import com.backend.goal.application.dto.response.GoalResponse; +import com.backend.goal.domain.enums.GoalStatus; +import com.backend.goal.domain.enums.RewardType; +import com.backend.goal.domain.event.RemoveRelatedDetailGoalEvent; +import com.backend.goal.domain.repository.GoalListResponseDto; +import com.backend.goal.domain.repository.GoalQueryRepository; +import com.backend.goal.domain.repository.GoalRepository; +import com.backend.goal.presentation.dto.GoalRecoverRequest; +import com.backend.goal.presentation.dto.GoalSaveRequest; +import com.backend.goal.presentation.dto.GoalUpdateRequest; +import com.backend.member.domain.Member; +import com.backend.member.domain.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.stream.Collectors; + + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GoalService { + + private static final int RANDOM_GOAL_COUNT = 3; + + private final GoalRepository goalRepository; + + private final MemberRepository memberRepository; + + private final GoalQueryRepository goalQueryRepository; + + private final ApplicationEventPublisher applicationEventPublisher; + + + public GoalListResponse getGoalList(final String uid, final Long goalId, final Pageable pageable, final String goalStatus) + { + Member member = memberRepository.findByUid(uid).orElseThrow(() -> { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + }); + Slice goalList = goalQueryRepository.getGoalList(member.getId(), goalId, pageable, GoalStatus.from(goalStatus)); + Slice result = goalList.map(GoalListResponseDto::from); + List contents = result.getContent(); + + Boolean next = goalList.hasNext(); + return new GoalListResponse(contents, next); + } + + public GoalCountResponse getGoalCounts(final String uid) + { + Member member = memberRepository.findByUid(uid).orElseThrow(() -> { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + }); + + Map statusCounts = goalQueryRepository.getStatusCounts(member.getId()); + return new GoalCountResponse(statusCounts); + } + + public RetrospectEnabledGoalCountResponse getGoalCountRetrospectEnabled(final String uid) + { + Member member = memberRepository.findByUid(uid).orElseThrow(() -> { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + }); + + Long count = goalQueryRepository.getGoalCountRetrospectEnabled(member.getId()); + return new RetrospectEnabledGoalCountResponse(count); + } + + + @Transactional + public Long saveGoal(final String uid, final GoalSaveRequest goalSaveRequest) + { + Member member = memberRepository.findByUid(uid).orElseThrow(() -> { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + }); + + Goal goal = goalSaveRequest.toEntity(member.getId()); + return goalRepository.save(goal).getId(); + } + + @Transactional + public GoalResponse updateGoal(final Long id, final GoalUpdateRequest goalSaveRequest) { + + Goal goal = goalRepository.getByIdAndIsDeletedFalse(id); + goal.update(goalSaveRequest.title(),goalSaveRequest.startDate(),goalSaveRequest.endDate(),goalSaveRequest.reminderEnabled()); + return GoalResponse.from(goal, goal.calculateDday(LocalDate.now())); + } + + @Transactional + public void removeGoal(final Long goalId) + { + Goal goal = goalRepository.getByIdAndIsDeletedFalse(goalId); + goal.remove(); + + applicationEventPublisher.publishEvent(new RemoveRelatedDetailGoalEvent(goal.getId())); + } + + @Transactional + public void recoverGoal(final Long goalId, final GoalRecoverRequest goalRecoverRequest) + { + Goal goal = goalRepository.getByIdAndIsDeletedFalse(goalId); + goal.recover(goalRecoverRequest.startDate(), goalRecoverRequest.endDate(), goalRecoverRequest.reminderEnabled()); + } + + public List getStoredGoalList(final String uid) { + + Member member = memberRepository.findByUid(uid).orElseThrow(() -> { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + }); + + List storedGoalList = goalRepository.getGoalsByGoalStatusAndMemberIdAndIsDeletedFalse(GoalStatus.STORE, member.getId()); + + Random random = new Random(); + + List randomStoredGoalList = new ArrayList<>(); + + int goalCount = Math.min(storedGoalList.size(), RANDOM_GOAL_COUNT); + + while (randomStoredGoalList.size() < goalCount) { + + int index = random.nextInt(storedGoalList.size()); + Goal goal = storedGoalList.get(index); + + if (!randomStoredGoalList.contains(goal)) { + randomStoredGoalList.add(goal); + } + } + + return randomStoredGoalList.stream().map(GoalListResponseDto::from).collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/backend/goal/application/dto/response/GoalCountResponse.java b/src/main/java/com/backend/goal/application/dto/response/GoalCountResponse.java new file mode 100644 index 0000000..703a650 --- /dev/null +++ b/src/main/java/com/backend/goal/application/dto/response/GoalCountResponse.java @@ -0,0 +1,13 @@ +package com.backend.goal.application.dto.response; + +import com.backend.goal.domain.enums.GoalStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Map; + +public record GoalCountResponse( + + @Schema(implementation = Map.class, example = "{\"STORE\": \"1\", \"PROCESS\": \"2\", \"COMPLETE\": \"0\"}") + Map counts +) { +} diff --git a/src/main/java/com/backend/goal/application/dto/response/GoalListResponse.java b/src/main/java/com/backend/goal/application/dto/response/GoalListResponse.java new file mode 100644 index 0000000..549e6f9 --- /dev/null +++ b/src/main/java/com/backend/goal/application/dto/response/GoalListResponse.java @@ -0,0 +1,11 @@ +package com.backend.goal.application.dto.response; + +import com.backend.goal.domain.repository.GoalListResponseDto; + +import java.util.List; + +public record GoalListResponse(List contents, Boolean next) { + + + +} diff --git a/src/main/java/com/backend/goal/application/dto/response/GoalResponse.java b/src/main/java/com/backend/goal/application/dto/response/GoalResponse.java new file mode 100644 index 0000000..2c38d4a --- /dev/null +++ b/src/main/java/com/backend/goal/application/dto/response/GoalResponse.java @@ -0,0 +1,27 @@ +package com.backend.goal.application.dto.response; + + +import com.backend.goal.domain.Goal; +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDate; + +public record GoalResponse( + + Long goalId, + String title, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy.MM.dd", timezone = "Asia/Seoul") + LocalDate startDate, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy.MM.dd", timezone = "Asia/Seoul") + LocalDate endDate, + Long dDay + +) { + + public static GoalResponse from(Goal goal, Long dDay) + { + return new GoalResponse(goal.getId(), goal.getTitle(), goal.getStartDate(), goal.getEndDate(), dDay); + } +} diff --git a/src/main/java/com/backend/goal/application/dto/response/RetrospectEnabledGoalCountResponse.java b/src/main/java/com/backend/goal/application/dto/response/RetrospectEnabledGoalCountResponse.java new file mode 100644 index 0000000..867d0c9 --- /dev/null +++ b/src/main/java/com/backend/goal/application/dto/response/RetrospectEnabledGoalCountResponse.java @@ -0,0 +1,6 @@ +package com.backend.goal.application.dto.response; + +public record RetrospectEnabledGoalCountResponse( + Long count +) { +} diff --git a/src/main/java/com/backend/goal/domain/Goal.java b/src/main/java/com/backend/goal/domain/Goal.java new file mode 100644 index 0000000..cf869e9 --- /dev/null +++ b/src/main/java/com/backend/goal/domain/Goal.java @@ -0,0 +1,220 @@ +package com.backend.goal.domain; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.entity.BaseEntity; +import com.backend.global.exception.BusinessException; +import com.backend.goal.domain.enums.GoalStatus; +import com.backend.goal.domain.enums.RewardType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; + +import static com.backend.global.common.code.ErrorCode.RECOVER_GOAL_IMPOSSIBLE; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "goal") +public class Goal extends BaseEntity { + + private static final int MAX_TITLE_LENGTH = 15; + private static final LocalDate MIN_DATE = LocalDate.of(1000, 1, 1); + private static final LocalDate MAX_DATE = LocalDate.of(9999, 12, 31); + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "goal_id") + private Long id; + + @Column(name = "member_id") + private Long memberId; // 상위 목표를 작성한 사용자의 ID + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "goal_status", nullable = false) + @Enumerated(value = EnumType.STRING) + private GoalStatus goalStatus; + + @Column(name = "entire_detail_goal_cnt", nullable = false) + private Integer entireDetailGoalCnt; + + @Column(name = "completed_detail_goal_cnt", nullable = false) + private Integer completedDetailGoalCnt; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + @Column(name = "reminder_enabled", nullable = false) + private Boolean reminderEnabled; + + @Column(name = "last_remind_date") + private LocalDate lastRemindDate; + + @Column(name = "has_retrospect", nullable = false) + private Boolean hasRetrospect; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted; + + @Column(name = "reward") + private RewardType reward; + + public void remove() + { + this.isDeleted = Boolean.TRUE; + } + + public void writeRetrospect() + { + this.hasRetrospect = Boolean.TRUE; + } + + public void updateLastRemindDate(LocalDate now) + { + this.lastRemindDate = now; + } + + public void store() + { + this.goalStatus = GoalStatus.STORE; + } + + @PrePersist + private void init() + { + isDeleted = Boolean.FALSE; + hasRetrospect = Boolean.FALSE; + entireDetailGoalCnt = 0; + completedDetailGoalCnt = 0; + } + + public void complete() + { + this.goalStatus = GoalStatus.COMPLETE; + } + + public void increaseEntireDetailGoalCnt() + { + this.entireDetailGoalCnt +=1; + } + + public void decreaseEntireDetailGoalCnt() + { + if(entireDetailGoalCnt < 1) + { + throw new BusinessException(ErrorCode.ENTIRE_DETAIL_GOAL_CNT_INVALID); + } + + this.entireDetailGoalCnt -=1; + } + + + public void increaseCompletedDetailGoalCnt() + { + this.completedDetailGoalCnt +=1; + } + + public void decreaseCompletedDetailGoalCnt() + { + if(completedDetailGoalCnt < 1) + { + throw new BusinessException(ErrorCode.COMPLETED_DETAIL_GOAL_CNT_INVALID); + } + + this.completedDetailGoalCnt -=1; + } + + public boolean checkGoalCompleted() + { + // 만약 전체 개수가 0개라면 체크 하면 안됨 + if (entireDetailGoalCnt == 0) + { + return false; + } + + return completedDetailGoalCnt == entireDetailGoalCnt; + } + + public void achieveReward(RewardType reward) + { + this.reward = reward; + } + + public void update(final String title, final LocalDate startDate, final LocalDate endDate, final Boolean reminderEnabled) + { + this.title = title; + this.startDate = startDate; + this.endDate = endDate; + this.reminderEnabled = reminderEnabled; + } + + public Goal(final Long memberId, final String title, final LocalDate startDate, final LocalDate endDate, final Boolean reminderEnabled, final GoalStatus goalStatus) + { + validateTitleLength(title); + validatePeriod(startDate, endDate); + this.memberId = memberId; + this.title = title; + this.startDate = startDate; + this.endDate = endDate; + this.reminderEnabled = reminderEnabled; + this.goalStatus = goalStatus; + } + + public void recover(final LocalDate startDate, final LocalDate endDate, final Boolean reminderEnabled) + { + if(!isRecoveringEnable()) + { + throw new BusinessException(RECOVER_GOAL_IMPOSSIBLE); + } + + this.goalStatus = GoalStatus.PROCESS; + this.reminderEnabled = reminderEnabled; + this.startDate = startDate; + this.endDate = endDate; + } + + + private void validateTitleLength(final String title) { + + if (title.length() > MAX_TITLE_LENGTH) { + throw new IllegalArgumentException(String.format("상위 목표 제목의 길이는 %d을 초과할 수 없습니다.", MAX_TITLE_LENGTH)); + } + } + + private void validatePeriod(final LocalDate startDate, final LocalDate endDate) { + + if (startDate.isAfter(endDate)) { + throw new IllegalArgumentException("종료일시가 시작일시보다 이전일 수 없습니다."); + } + + if (isNotValidDateTimeRange(startDate) || isNotValidDateTimeRange(endDate)) { + throw new IllegalArgumentException(String.format("상위 목표는 %s부터 %s까지 등록할 수 있습니다.", MIN_DATE, MAX_DATE) + ); + } + } + + public Long calculateDday(final LocalDate now) + { + if(now.isAfter(endDate)) + { + ChronoUnit.DAYS.between(endDate, now); + } + + return ChronoUnit.DAYS.between(now, endDate); + } + + private boolean isNotValidDateTimeRange(final LocalDate date) { + return date.isBefore(MIN_DATE) || date.isAfter(MAX_DATE); + } + + private boolean isRecoveringEnable() { + return goalStatus.equals(GoalStatus.STORE); + } +} diff --git a/src/main/java/com/backend/goal/domain/ReminderEvent.java b/src/main/java/com/backend/goal/domain/ReminderEvent.java new file mode 100644 index 0000000..27ec130 --- /dev/null +++ b/src/main/java/com/backend/goal/domain/ReminderEvent.java @@ -0,0 +1,9 @@ +package com.backend.goal.domain; + +public record ReminderEvent( + + Long memberId, + String goalTitle + +) { +} diff --git a/src/main/java/com/backend/goal/domain/enums/GoalStatus.java b/src/main/java/com/backend/goal/domain/enums/GoalStatus.java new file mode 100644 index 0000000..55588f0 --- /dev/null +++ b/src/main/java/com/backend/goal/domain/enums/GoalStatus.java @@ -0,0 +1,28 @@ +package com.backend.goal.domain.enums; + +public enum GoalStatus { + + STORE("보관함"), + PROCESS("채움함"), + COMPLETE("완료함"); + + private String description; + + GoalStatus(String description) + { + this.description = description; + } + + public static GoalStatus from(final String value) { + try { + return GoalStatus.valueOf(value.toUpperCase()); + } catch (final IllegalArgumentException e) { + throw new IllegalArgumentException(); + } + } + + public String getDescription() + { + return description; + } +} diff --git a/src/main/java/com/backend/goal/domain/enums/RewardType.java b/src/main/java/com/backend/goal/domain/enums/RewardType.java new file mode 100644 index 0000000..7dfc49d --- /dev/null +++ b/src/main/java/com/backend/goal/domain/enums/RewardType.java @@ -0,0 +1,25 @@ +package com.backend.goal.domain.enums; + +public enum RewardType { + BLUE_JEWEL_1, + BLUE_JEWEL_2, + BLUE_JEWEL_3, + BLUE_JEWEL_4, + BLUE_JEWEL_5, + PURPLE_JEWEL_1, + PURPLE_JEWEL_2, + PURPLE_JEWEL_3, + PURPLE_JEWEL_4, + PURPLE_JEWEL_5, + PINK_JEWEL_1, + PINK_JEWEL_2, + PINK_JEWEL_3, + PINK_JEWEL_4, + PINK_JEWEL_5, + GREEN_JEWEL_1, + GREEN_JEWEL_2, + GREEN_JEWEL_3, + GREEN_JEWEL_4, + GREEN_JEWEL_5; + +} diff --git a/src/main/java/com/backend/goal/domain/event/ReminderEvent.java b/src/main/java/com/backend/goal/domain/event/ReminderEvent.java new file mode 100644 index 0000000..1679105 --- /dev/null +++ b/src/main/java/com/backend/goal/domain/event/ReminderEvent.java @@ -0,0 +1,9 @@ +package com.backend.goal.domain.event; + +public record ReminderEvent( + + Long memberId, + String goalTitle + +) { +} diff --git a/src/main/java/com/backend/goal/domain/event/RemoveRelatedDetailGoalEvent.java b/src/main/java/com/backend/goal/domain/event/RemoveRelatedDetailGoalEvent.java new file mode 100644 index 0000000..de759ef --- /dev/null +++ b/src/main/java/com/backend/goal/domain/event/RemoveRelatedDetailGoalEvent.java @@ -0,0 +1,8 @@ +package com.backend.goal.domain.event; + +public record RemoveRelatedDetailGoalEvent( + Long goalId +) { + + +} diff --git a/src/main/java/com/backend/goal/domain/repository/GoalListResponseDto.java b/src/main/java/com/backend/goal/domain/repository/GoalListResponseDto.java new file mode 100644 index 0000000..fd83c62 --- /dev/null +++ b/src/main/java/com/backend/goal/domain/repository/GoalListResponseDto.java @@ -0,0 +1,51 @@ +package com.backend.goal.domain.repository; + + +import com.backend.goal.domain.Goal; +import com.backend.goal.domain.enums.RewardType; +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDate; + +public record GoalListResponseDto( + + Long goalId, + + String title, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy.MM.dd", timezone = "Asia/Seoul") + LocalDate startDate, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy.MM.dd", timezone = "Asia/Seoul") + LocalDate endDate, + + Integer entireDetailGoalCnt, + + Integer completedDetailGoalCnt, + + Long dDay, + + Boolean hasRetrospect, + + Boolean reminderEnabled, + + RewardType reward +) { + + public static GoalListResponseDto from(Goal goal) + { + return new GoalListResponseDto(goal.getId(), + goal.getTitle(), + goal.getStartDate(), + goal.getEndDate(), + goal.getEntireDetailGoalCnt(), + goal.getCompletedDetailGoalCnt(), + goal.calculateDday(LocalDate.now()), + goal.getHasRetrospect(), + goal.getReminderEnabled(), + goal.getReward() + ) + ; + } +} + diff --git a/src/main/java/com/backend/goal/domain/repository/GoalQueryRepository.java b/src/main/java/com/backend/goal/domain/repository/GoalQueryRepository.java new file mode 100644 index 0000000..dec4562 --- /dev/null +++ b/src/main/java/com/backend/goal/domain/repository/GoalQueryRepository.java @@ -0,0 +1,131 @@ +package com.backend.goal.domain.repository; + +import com.backend.goal.domain.Goal; +import com.backend.goal.domain.enums.GoalStatus; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.backend.goal.domain.QGoal.*; + +@Repository +@RequiredArgsConstructor +public class GoalQueryRepository { + + private final JPAQueryFactory query; + + public Slice getGoalList(Long memberId, Long goalId, Pageable pageable, GoalStatus goalStatus) + { + List goalList = query.select(goal) + .from(goal) + .where( + goal.memberId.eq(memberId), + goal.isDeleted.isFalse(), // 삭제되지 않은 상위 목표들만 선택 + ltGoalId(goalId), + goal.goalStatus.eq(goalStatus) + ) + .orderBy(goal.id.desc()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + + boolean hasNext = false; + + if (goalList.size() > pageable.getPageSize()) { + hasNext = true; + goalList.remove(pageable.getPageSize()); + } + + return new SliceImpl<>(goalList, pageable, hasNext); + } + + public Long getGoalCountRetrospectEnabled(Long memberId) + { + return query.select(goal.count()) + .from(goal) + .where( + goal.isDeleted.isFalse(), // 삭제되지 않은 상위 목표들만 선택 + goal.hasRetrospect.isFalse(), // 아직 회고를 작성하지 않는 것들 조회 + goal.goalStatus.eq(GoalStatus.COMPLETE), // 완료상태인것들 체크 + goal.memberId.eq(memberId) + ) + .fetchOne(); + } + + public List findGoalListReminderEnabled() + { + return query.select(goal) + .from(goal) + .where( + goal.isDeleted.isFalse(), // 삭제되지 않은 상위 목표들만 선택 + goal.goalStatus.eq(GoalStatus.PROCESS), + goal.reminderEnabled.isTrue() + ) + .fetch(); + } + + public List findGoalListEndDateExpired(LocalDate today) + { + return query.select(goal) + .from(goal) + .where( + goal.isDeleted.isFalse(), // 삭제되지 않은 상위 목표들만 선택 + goal.goalStatus.eq(GoalStatus.PROCESS), + goal.endDate.before(today) + ) + .fetch(); + } + + + public Map getStatusCounts(Long memberId) { + + List counts = query + .select( + goal.goalStatus, + goal.goalStatus.count() + ) + .from(goal) + .where( + goal.isDeleted.isFalse(), + goal.memberId.eq(memberId) + ) + .groupBy(goal.goalStatus) + .fetch(); + + Map statusCounts = new HashMap<>(); + + for (Tuple tuple : counts) { + + statusCounts.put(tuple.get(goal.goalStatus), tuple.get(goal.goalStatus.count())); + } + + // 진행상태별로 데이터가 없는 경우 0을 추가 + for (GoalStatus status : Arrays.asList(GoalStatus.PROCESS, GoalStatus.COMPLETE, GoalStatus.STORE)) { + statusCounts.putIfAbsent(status, 0L); + } + + return statusCounts; + } + +private BooleanExpression ltGoalId(Long goalId) { + + if (goalId == -1) { + return null; + } + + return goal.id.lt(goalId); + } + + +} diff --git a/src/main/java/com/backend/goal/domain/repository/GoalRepository.java b/src/main/java/com/backend/goal/domain/repository/GoalRepository.java new file mode 100644 index 0000000..036c6db --- /dev/null +++ b/src/main/java/com/backend/goal/domain/repository/GoalRepository.java @@ -0,0 +1,25 @@ +package com.backend.goal.domain.repository; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import com.backend.goal.domain.Goal; +import com.backend.goal.domain.enums.GoalStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + + +public interface GoalRepository extends JpaRepository { + + default Goal getByIdAndIsDeletedFalse(Long id){ + + return findById(id).orElseThrow(() -> {throw new BusinessException(ErrorCode.GOAL_NOT_FOUND); + }); + } + + int countByGoalStatusAndMemberIdAndIsDeletedFalse(GoalStatus goalStatus, Long memberId); + + List getGoalsByGoalStatusAndMemberIdAndIsDeletedFalse(GoalStatus goalStatus, Long memberId); + + +} diff --git a/src/main/java/com/backend/goal/presentation/GoalController.java b/src/main/java/com/backend/goal/presentation/GoalController.java new file mode 100644 index 0000000..4121f79 --- /dev/null +++ b/src/main/java/com/backend/goal/presentation/GoalController.java @@ -0,0 +1,114 @@ +package com.backend.goal.presentation; + +import com.backend.global.common.response.CustomResponse; +import com.backend.goal.application.GoalService; +import com.backend.goal.application.dto.response.GoalCountResponse; +import com.backend.goal.application.dto.response.GoalListResponse; +import com.backend.goal.application.dto.response.GoalResponse; +import com.backend.goal.application.dto.response.RetrospectEnabledGoalCountResponse; +import com.backend.goal.domain.repository.GoalListResponseDto; +import com.backend.goal.presentation.dto.GoalRecoverRequest; +import com.backend.goal.presentation.dto.GoalSaveRequest; +import com.backend.goal.presentation.dto.GoalUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static com.backend.global.common.code.SuccessCode.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/goals") +@Tag(name = "goal", description = "상위 목표 API") +public class GoalController { + + private final GoalService goalService; + + @Operation(summary = "상위 목표 리스트 조회", description = "상위 목표 리스트를 조회하는 API 입니다.") + @ApiResponse(responseCode = "200", description = "code : 200, message : SELECT_SUCCESS") + @GetMapping + public ResponseEntity> getGoalList( + @AuthenticationPrincipal UserDetails userDetails, + @Parameter(hidden = true) @PageableDefault(size = 10) Pageable pageable, + @Parameter(description = "처음 조회 시 Null 전달, 이후부터는 이전 응답 데이터 중 마지막 ID를 전달") @RequestParam(required = false) Long lastId, + @Parameter(description = "store(보관함), process(채움함), complete(완료함) 중 하나로 호출") @RequestParam String goalStatus) + { + return CustomResponse.success(SELECT_SUCCESS,goalService.getGoalList(userDetails.getUsername(),lastId,pageable,goalStatus)); + } + + + @Operation(summary = "상위 목표 상태별 개수 조회", description = "상위 목표 상태별 개수를 조회하는 API 입니다.") + @ApiResponse(responseCode = "200", description = "code : 200, message : SELECT_SUCCESS") + @GetMapping("/count") + public ResponseEntity> getGoalCounts(@AuthenticationPrincipal UserDetails userDetails) + { + return CustomResponse.success(SELECT_SUCCESS,goalService.getGoalCounts(userDetails.getUsername())); + } + + @Operation(summary = "회고 작성 가능한 목표 개수 조회", description = "회고 작성 가능한 목표 개수를 조회하는 API 입니다.") + @ApiResponse(responseCode = "200", description = "code : 200, message : SELECT_SUCCESS") + @GetMapping("/retrospect-enabled/count") + public ResponseEntity> getRetrospectEnabledGoalCount(@AuthenticationPrincipal UserDetails userDetails) + { + return CustomResponse.success(SELECT_SUCCESS,goalService.getGoalCountRetrospectEnabled(userDetails.getUsername())); + } + + @Operation(summary = "회고 완료 후 보관함 내 목표 추천", description = "보관함에 들어있는 목표들 중 랜덤하게 3개를 추천해줍니다") + @ApiResponse(responseCode = "200", description = "code : 200, message : SELECT_SUCCESS") + @GetMapping("/stored-goals") + public ResponseEntity>> getStoredGoalList(@AuthenticationPrincipal UserDetails userDetails) + { + return CustomResponse.success(SELECT_SUCCESS,goalService.getStoredGoalList(userDetails.getUsername())); + } + + + @Operation(summary = "상위 목표 삭제", description = "상위 목표를 삭제하는 API 입니다.") + @ApiResponse(responseCode = "200", description = "code : 200, message : DELETE_SUCCESS") + @DeleteMapping("/{id}") + public ResponseEntity> removeGoal(@Parameter(description = "상위 목표 ID") @PathVariable Long id) + { + goalService.removeGoal(id); + return CustomResponse.success(DELETE_SUCCESS); + } + + @Operation(summary = "보관함 내 상위 목표 복구", description = "보관함에 들어간 상위 목표를 복구하는 API 입니다.") + @ApiResponse(responseCode = "200", description = "code : 200, message : UPDATE_SUCCESS") + @PatchMapping("/{id}/recover") + public ResponseEntity> recoverGoal(@Parameter(description = "상위 목표 ID") @PathVariable Long id, @RequestBody @Valid GoalRecoverRequest goalRecoverRequest) + { + goalService.recoverGoal(id, goalRecoverRequest); + return CustomResponse.success(UPDATE_SUCCESS); + } + + + @Operation(summary = "상위 목표 수정", description = "상위 목표를 수정하는 API 입니다.") + @ApiResponse(responseCode = "200", description = "code : 200, message : UPDATE_SUCCESS") + @PatchMapping("/{id}") + public ResponseEntity> updateGoal(@Parameter(description = "상위 목표 ID") @PathVariable Long id, @RequestBody @Valid GoalUpdateRequest goalSaveRequest) + { + return CustomResponse.success(UPDATE_SUCCESS, goalService.updateGoal(id, goalSaveRequest)); + } + + @Operation(summary = "상위 목표 생성", description = "상위 목표를 생성하는 API 입니다.") + @ApiResponse(responseCode = "201", description = "code : 201, message : INSERT_SUCCESS") + @PostMapping + public ResponseEntity> saveGoal(@AuthenticationPrincipal UserDetails userDetails, @RequestBody @Valid GoalSaveRequest goalSaveRequest) + { + // 아직 유저 식별 값으로 뭐가 들어올지 몰라 1L로 설정해놨습니다. + goalService.saveGoal(userDetails.getUsername(), goalSaveRequest); + return CustomResponse.success(INSERT_SUCCESS); + } + + +} diff --git a/src/main/java/com/backend/goal/presentation/dto/GoalRecoverRequest.java b/src/main/java/com/backend/goal/presentation/dto/GoalRecoverRequest.java new file mode 100644 index 0000000..2245e59 --- /dev/null +++ b/src/main/java/com/backend/goal/presentation/dto/GoalRecoverRequest.java @@ -0,0 +1,26 @@ +package com.backend.goal.presentation.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record GoalRecoverRequest( + + @Schema(example = "2023 / 08 / 27", description = "현재 날짜보다 뒤의 날짜를 골라야 합니다") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy / MM / dd", timezone = "Asia/Seoul") + LocalDate startDate, + + @Schema(example = "2023 / 08 / 28", description = "현재 날짜보다 뒤의 날짜를 골라야 합니다") + @FutureOrPresent(message = "상위 목표 종료 일자는 과거 시점일 수 없습니다") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy / MM / dd", timezone = "Asia/Seoul") + LocalDate endDate, + + @Schema(description = "리마인드 설정 여부") + @NotNull(message = "리마인드 알림 여부를 필수적으로 선택해야 합니다") + Boolean reminderEnabled + +) { +} diff --git a/src/main/java/com/backend/goal/presentation/dto/GoalSaveRequest.java b/src/main/java/com/backend/goal/presentation/dto/GoalSaveRequest.java new file mode 100644 index 0000000..f8d76b0 --- /dev/null +++ b/src/main/java/com/backend/goal/presentation/dto/GoalSaveRequest.java @@ -0,0 +1,39 @@ +package com.backend.goal.presentation.dto; + +import com.backend.goal.domain.Goal; +import com.backend.goal.domain.enums.GoalStatus; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +public record GoalSaveRequest( + + @Schema(example = "오픽 IH 달성하기") + @Size(max = 15, message = "상위 목표 제목은 15자를 초과할 수 없습니다") + String title, + + @Schema(example = "2023 / 08 / 27", description = "현재 날짜보다 뒤의 날짜를 골라야 합니다") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy / MM / dd", timezone = "Asia/Seoul") + LocalDate startDate, + + @Schema(example = "2023 / 08 / 28", description = "현재 날짜보다 뒤의 날짜를 골라야 합니다") + @FutureOrPresent(message = "상위 목표 종료 일자는 과거 시점일 수 없습니다") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy / MM / dd", timezone = "Asia/Seoul") + LocalDate endDate, + + @Schema(description = "리마인드 설정 여부") + @NotNull(message = "리마인드 알림 여부를 필수적으로 선택해야 합니다") + Boolean reminderEnabled) +{ + + public Goal toEntity(Long memberId) + { + return new Goal(memberId, title,startDate,endDate,reminderEnabled, GoalStatus.PROCESS); + } + +} diff --git a/src/main/java/com/backend/goal/presentation/dto/GoalUpdateRequest.java b/src/main/java/com/backend/goal/presentation/dto/GoalUpdateRequest.java new file mode 100644 index 0000000..a0318f2 --- /dev/null +++ b/src/main/java/com/backend/goal/presentation/dto/GoalUpdateRequest.java @@ -0,0 +1,32 @@ +package com.backend.goal.presentation.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; + +public record GoalUpdateRequest( + + + @Schema(example = "토익 900점 넘기기") + @Size(max = 15, message = "상위 목표 제목은 15자를 초과할 수 없습니다") + String title, + + @Schema(example = "2023 / 08 / 27", description = "현재 날짜보다 뒤의 날짜를 골라야 합니다") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy / MM / dd", timezone = "Asia/Seoul") + LocalDate startDate, + + @Schema(example = "2023 / 08 / 28", description = "현재 날짜보다 뒤의 날짜를 골라야 합니다") + @FutureOrPresent(message = "상위 목표 종료 일자는 과거 시점일 수 없습니다") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy / MM / dd", timezone = "Asia/Seoul") + LocalDate endDate, + + @Schema(description = "리마인드 설정 여부") + @NotNull(message = "리마인드 알림 여부를 필수적으로 선택해야 합니다") + Boolean reminderEnabled + +) { +} diff --git a/src/main/java/com/backend/infrastructure/fcm/FcmService.java b/src/main/java/com/backend/infrastructure/fcm/FcmService.java new file mode 100644 index 0000000..001b31b --- /dev/null +++ b/src/main/java/com/backend/infrastructure/fcm/FcmService.java @@ -0,0 +1,55 @@ +package com.backend.infrastructure.fcm; + +import com.backend.auth.application.FcmTokenService; +import com.backend.global.exception.BusinessException; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FcmService { + + + private final FcmTokenService fcmTokenService; + + private final FirebaseMessaging firebaseMessaging; + + public void sendMessage(String uid, String detailGoalTitle) + { + String fcmToken = fcmTokenService.findFcmToken(uid); + + if(Objects.isNull(fcmToken)) + { + return; + } + + Notification notification = Notification.builder() + .setTitle(PushWord.PUSH_TITLE) + .setBody(detailGoalTitle + PushWord.PUSH_CONTENT) + .build(); + + Message message = Message.builder() + .setToken(fcmToken) + .setNotification(notification) + .build(); + + try { + + log.info("message send start..."); + String send = firebaseMessaging.send(message); + log.info("message send finished, {}", send); + + } catch (FirebaseMessagingException e) { + e.printStackTrace(); + } + } + +} diff --git a/src/main/java/com/backend/infrastructure/fcm/PushWord.java b/src/main/java/com/backend/infrastructure/fcm/PushWord.java new file mode 100644 index 0000000..33a62c6 --- /dev/null +++ b/src/main/java/com/backend/infrastructure/fcm/PushWord.java @@ -0,0 +1,7 @@ +package com.backend.infrastructure.fcm; + +public abstract class PushWord { + + public static final String PUSH_TITLE = "💎 마일이가 기다리고 있어요!"; + public static final String PUSH_CONTENT = ", 이루고 계신가요?"; +} diff --git a/src/main/java/com/backend/member/application/MemberService.java b/src/main/java/com/backend/member/application/MemberService.java new file mode 100644 index 0000000..93edbf6 --- /dev/null +++ b/src/main/java/com/backend/member/application/MemberService.java @@ -0,0 +1,32 @@ +package com.backend.member.application; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import com.backend.member.domain.Member; +import com.backend.member.domain.MemberRepository; +import com.backend.member.domain.Provider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@RequiredArgsConstructor +@Service +public class MemberService { + + private final MemberRepository memberRepository; + + public Boolean findMemberOrRegister(Provider provider, String uid) { + Optional member = memberRepository.findByUid(uid); + if(member.isPresent()) // 기등록된 회원인 경우, 다시 저장하지 않는다. + return false; + memberRepository.save(Member.from(provider, uid)); + return true; + } + + public void withdraw(String uid) { + Member member = memberRepository.getByUid(uid); + member.withdraw(); + } + +} diff --git a/src/main/java/com/backend/member/domain/Member.java b/src/main/java/com/backend/member/domain/Member.java new file mode 100644 index 0000000..b83aada --- /dev/null +++ b/src/main/java/com/backend/member/domain/Member.java @@ -0,0 +1,70 @@ +package com.backend.member.domain; + +import com.backend.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +@Table(name="member") +public class Member extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @Column(nullable = false) + private String uid; + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false) + private Provider provider; + + @Column(nullable = false) + private Boolean enabledPush; + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false) + private MemberStatus memberStatus; + + @Enumerated(EnumType.STRING) + private Role role; + + @Builder + private Member ( + final String uid, + final Provider provider, + final Boolean enabledPush, + final MemberStatus memberStatus, + final Role role + ) { + this.enabledPush = enabledPush; + this.provider = provider; + this.uid = uid; + this.memberStatus = memberStatus; + this.role = role; + } + + public static Member from(Provider provider, String uid){ + return Member.builder() + .uid(uid) + .provider(provider) + .memberStatus(MemberStatus.ACTIVE) + .role(Role.ROLE_USER) + .build(); + } + + @PrePersist + private void setting(){ + this.enabledPush = false; + this.memberStatus = MemberStatus.ACTIVE; + } + + public void withdraw() { + this.memberStatus = MemberStatus.DELETE; + } +} diff --git a/src/main/java/com/backend/member/domain/MemberRepository.java b/src/main/java/com/backend/member/domain/MemberRepository.java new file mode 100644 index 0000000..6f2fdbb --- /dev/null +++ b/src/main/java/com/backend/member/domain/MemberRepository.java @@ -0,0 +1,17 @@ +package com.backend.member.domain; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByUid(String uid); + + default Member getByUid(String uid){ + return findByUid(uid).orElseThrow(() -> { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + }); + } +} diff --git a/src/main/java/com/backend/member/domain/MemberStatus.java b/src/main/java/com/backend/member/domain/MemberStatus.java new file mode 100644 index 0000000..e49c0a9 --- /dev/null +++ b/src/main/java/com/backend/member/domain/MemberStatus.java @@ -0,0 +1,8 @@ +package com.backend.member.domain; + +public enum MemberStatus { + ACTIVE, + INACTIVE, + BLOCK, + DELETE +} diff --git a/src/main/java/com/backend/member/domain/Provider.java b/src/main/java/com/backend/member/domain/Provider.java new file mode 100644 index 0000000..0ed469d --- /dev/null +++ b/src/main/java/com/backend/member/domain/Provider.java @@ -0,0 +1,18 @@ +package com.backend.member.domain; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; + +import java.util.Locale; + +public enum Provider { + KAKAO, APPLE; + + public static Provider from(String provider){ + try { + return Provider.valueOf(provider.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e){ + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/backend/member/domain/Role.java b/src/main/java/com/backend/member/domain/Role.java new file mode 100644 index 0000000..0267bdc --- /dev/null +++ b/src/main/java/com/backend/member/domain/Role.java @@ -0,0 +1,6 @@ +package com.backend.member.domain; + +public enum Role { + ROLE_USER, + ROLE_ADMIN +} diff --git a/src/main/java/com/backend/retrospect/application/RetrospectService.java b/src/main/java/com/backend/retrospect/application/RetrospectService.java new file mode 100644 index 0000000..8ed5610 --- /dev/null +++ b/src/main/java/com/backend/retrospect/application/RetrospectService.java @@ -0,0 +1,64 @@ +package com.backend.retrospect.application; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import com.backend.goal.domain.Goal; +import com.backend.goal.domain.repository.GoalRepository; +import com.backend.retrospect.application.dto.response.RetrospectResponse; +import com.backend.retrospect.domain.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional +public class RetrospectService { + + private final GoalRepository goalRepository; + + private final RetrospectRepository retrospectRepository; + + public Long saveRetrospect(Long goalId, Boolean hasGuide, Map contents, SuccessLevel successLevel) { + findGoalAndCheckRetrospect(goalId); + validateContentLength(hasGuide, contents); + return retrospectRepository.save(new Retrospect(goalId, hasGuide, contents, successLevel)).getId(); + } + + public RetrospectResponse getRetrospect(Long goalId) { + Retrospect retrospect = retrospectRepository.findByGoalId(goalId) + .orElseThrow(() -> new BusinessException(ErrorCode.RETROSPECT_IS_NOT_WRITTEN)); + return RetrospectResponse.from(retrospect.getHasGuide(), retrospect.getContents(), retrospect.getSuccessLevel()); + } + + private void findGoalAndCheckRetrospect(Long goalId) { + Goal goal = goalRepository.getByIdAndIsDeletedFalse(goalId); + + // 이미 회고를 작성한 상위 목표인 경우 회고를 작성할 수 없다. + if(goal.getHasRetrospect()){ + throw new BusinessException(ErrorCode.ALREADY_HAS_RETROSPECT); + } + goal.writeRetrospect(); + } + + private void validateContentLength(Boolean hasGuide, Map contents){ + if(hasGuide){ // 가이드 질문이 있는 경우에는 각 내용마다 최대 200자 + for(Map.Entry entry: contents.entrySet()){ + String content = entry.getValue(); + if(content.length() > 200){ + throw new BusinessException(ErrorCode.CONTENT_TOO_LONG); + } + } + } else { // 가이드 질문이 없는 경우에는 하나의 내용이 최대 1000자 + String content = contents.entrySet().iterator().next().getValue(); + if(content.length() > 1000){ + throw new BusinessException(ErrorCode.CONTENT_TOO_LONG); + } + } + } + +} diff --git a/src/main/java/com/backend/retrospect/application/dto/response/RetrospectResponse.java b/src/main/java/com/backend/retrospect/application/dto/response/RetrospectResponse.java new file mode 100644 index 0000000..ec8881b --- /dev/null +++ b/src/main/java/com/backend/retrospect/application/dto/response/RetrospectResponse.java @@ -0,0 +1,30 @@ +package com.backend.retrospect.application.dto.response; + +import com.backend.retrospect.domain.Guide; +import com.backend.retrospect.domain.RetrospectContent; +import com.backend.retrospect.domain.SuccessLevel; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public record RetrospectResponse( + @Schema(description = "가이드 질문의 유무", example = "true") + Boolean hasGuide, + + @Schema(description = "가이드 질문과 사용자의 회고 글", example = "{\"LIKED\": \"단기간 집중을 했던 점이 좋았어요.\", \"LACKED\": \"아쉬웠던 점은 없었어요.\", \"LEARNED\": \"하나에 집중해야 원하는 것을 빠르게 얻을 수 있다는 것을 배웠어요.\", \"LONGED_FOR\": \"돈을 많이 벌고자 하였어요.\"}") + Map contents, + + @Schema(description = "마음 채움도", example = "LEVEL3") + SuccessLevel successLevel + +) { + public static RetrospectResponse from(Boolean hasGuide, List contents, SuccessLevel successLevel) { + Map retrospectContents = new HashMap<>(); + for(RetrospectContent content : contents){ + retrospectContents.put(content.getGuide(), content.getContent()); + } + return new RetrospectResponse(hasGuide, retrospectContents, successLevel); + } +} diff --git a/src/main/java/com/backend/retrospect/domain/Guide.java b/src/main/java/com/backend/retrospect/domain/Guide.java new file mode 100644 index 0000000..40d59c8 --- /dev/null +++ b/src/main/java/com/backend/retrospect/domain/Guide.java @@ -0,0 +1,31 @@ +package com.backend.retrospect.domain; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Locale; + +@RequiredArgsConstructor +public enum Guide { + + LIKED("좋았던 점은 무엇인가요?"), + LACKED("아쉬웠던 점이나, 부족했던 점은 무엇인가요?"), + LEARNED("배운 점은 무엇인가요?"), + LONGED_FOR("목표를 통해 뭘 얻고자 하셨나요?"), + NONE("가이드 질문이 없음"); + + @Getter + private final String description; + + @JsonCreator + public static Guide from(String guide){ + try { + return Guide.valueOf(guide.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e){ + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + } +} diff --git a/src/main/java/com/backend/retrospect/domain/Retrospect.java b/src/main/java/com/backend/retrospect/domain/Retrospect.java new file mode 100644 index 0000000..11971d2 --- /dev/null +++ b/src/main/java/com/backend/retrospect/domain/Retrospect.java @@ -0,0 +1,57 @@ +package com.backend.retrospect.domain; + +import com.backend.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "retrospect") +public class Retrospect extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "retrospect_id") + private Long id; + + @Column(name = "goal_id", unique = true) + private Long goalId; + + @Column(name = "has_guide") + private Boolean hasGuide; + + @OneToMany(mappedBy = "retrospect", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + private List contents; + + @Enumerated(EnumType.STRING) + @Column(name = "success_level") + private SuccessLevel successLevel; + + @Column(name = "is_deleted") + private boolean isDeleted; + + @PrePersist + private void init(){ + isDeleted = false; + } + + public Retrospect(Long goalId, Boolean hasGuide, Map contents, SuccessLevel successLevel){ + this.goalId = goalId; + this.hasGuide = hasGuide; + this.successLevel = successLevel; + + List retrospectContents = new ArrayList<>(); + for(Map.Entry entry : contents.entrySet()){ + Guide guide = entry.getKey(); + String content = entry.getValue(); + + retrospectContents.add(new RetrospectContent(guide, content, this)); + } + this.contents = retrospectContents; + } +} diff --git a/src/main/java/com/backend/retrospect/domain/RetrospectContent.java b/src/main/java/com/backend/retrospect/domain/RetrospectContent.java new file mode 100644 index 0000000..6d87794 --- /dev/null +++ b/src/main/java/com/backend/retrospect/domain/RetrospectContent.java @@ -0,0 +1,32 @@ +package com.backend.retrospect.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "retrospect_content") +public class RetrospectContent { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "retrospect_content_id") + private Long id; + + @Enumerated + @Column(name = "guide") + private Guide guide; + + @Column(name = "content", length = 1000) + private String content; + + @ManyToOne + @JoinColumn(name = "retrospect_id") + private Retrospect retrospect; + + public RetrospectContent(Guide guide, String content, Retrospect retrospect){ + this.guide = guide; + this.content = content; + this.retrospect = retrospect; + } +} diff --git a/src/main/java/com/backend/retrospect/domain/RetrospectRepository.java b/src/main/java/com/backend/retrospect/domain/RetrospectRepository.java new file mode 100644 index 0000000..4d34341 --- /dev/null +++ b/src/main/java/com/backend/retrospect/domain/RetrospectRepository.java @@ -0,0 +1,9 @@ +package com.backend.retrospect.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RetrospectRepository extends JpaRepository { + Optional findByGoalId(Long goalId); +} diff --git a/src/main/java/com/backend/retrospect/domain/SuccessLevel.java b/src/main/java/com/backend/retrospect/domain/SuccessLevel.java new file mode 100644 index 0000000..bffd6fd --- /dev/null +++ b/src/main/java/com/backend/retrospect/domain/SuccessLevel.java @@ -0,0 +1,31 @@ +package com.backend.retrospect.domain; + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Locale; + +@RequiredArgsConstructor +public enum SuccessLevel { + + LEVEL1("별로예요"), + LEVEL2("아쉬워요"), + LEVEL3("그저 그랬어요"), + LEVEL4("만족해요"), + LEVEL5("완전 만족해요"); + + @Getter + private final String description; + + @JsonCreator + public static SuccessLevel from(String successLevel){ + try { + return SuccessLevel.valueOf(successLevel.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e){ + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + } +} diff --git a/src/main/java/com/backend/retrospect/presentation/RetrospectController.java b/src/main/java/com/backend/retrospect/presentation/RetrospectController.java new file mode 100644 index 0000000..664e831 --- /dev/null +++ b/src/main/java/com/backend/retrospect/presentation/RetrospectController.java @@ -0,0 +1,36 @@ +package com.backend.retrospect.presentation; + +import com.backend.global.common.response.CustomResponse; +import com.backend.retrospect.application.RetrospectService; +import com.backend.retrospect.application.dto.response.RetrospectResponse; +import com.backend.retrospect.presentation.dto.request.RetrospectSaveRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import static com.backend.global.common.code.SuccessCode.*; + +@Tag(name = "retrospect", description = "회고 작성, 조회, 삭제 API입니다.") +@RestController +@RequiredArgsConstructor +public class RetrospectController { + private final RetrospectService retrospectService; + @Operation(summary = "회고 작성", description = "완료함의 상위 목표에 대한 회고를 작성합니다.") + @PostMapping("/goals/{goal_id}/retrospects") + public ResponseEntity> saveRetrospect( + @Parameter(description = "상위 목표 아이디") @PathVariable(value = "goal_id") Long goalId, + @RequestBody RetrospectSaveRequest saveRequest) { + return CustomResponse.success(INSERT_SUCCESS, + retrospectService.saveRetrospect(goalId, saveRequest.hasGuide(), saveRequest.contents(), saveRequest.successLevel())); + } + + @Operation(summary = "회고 조회", description = "완료함의 상위 목표에 대한 회고를 조회합니다.") + @GetMapping("/goals/{goal_id}/retrospects") + public ResponseEntity> getRetrospect ( + @Parameter(description = "상위 목표 아이디") @PathVariable(value = "goal_id") Long goalId) { + return CustomResponse.success(SELECT_SUCCESS, retrospectService.getRetrospect(goalId)); + } +} diff --git a/src/main/java/com/backend/retrospect/presentation/dto/request/RetrospectSaveRequest.java b/src/main/java/com/backend/retrospect/presentation/dto/request/RetrospectSaveRequest.java new file mode 100644 index 0000000..2a349c0 --- /dev/null +++ b/src/main/java/com/backend/retrospect/presentation/dto/request/RetrospectSaveRequest.java @@ -0,0 +1,30 @@ +package com.backend.retrospect.presentation.dto.request; + +import com.backend.retrospect.domain.Guide; +import com.backend.retrospect.domain.SuccessLevel; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.Map; + +public record RetrospectSaveRequest( + + @JsonProperty("has_guide") + @Schema(description = "가이드 질문 유무", example = "true") + @NotNull(message = "가이드 질문의 유무를 선택해주세요.") + Boolean hasGuide, + + @JsonProperty("contents") + @Schema(description = "회고 글", + example = "{\"LIKED\": \"단기간 집중을 했던 점이 좋았어요.\", \"LACKED\": \"아쉬웠던 점은 없었어요.\", \"LEARNED\": \"하나에 집중해야 원하는 것을 빠르게 얻을 수 있다는 것을 배웠어요.\", \"LONGED_FOR\": \"돈을 많이 벌고자 하였어요.\"}", + implementation = Map.class) + @NotNull(message = "회고글의 내용은 빈칸일 수 없습니다.") + Map contents, + + @JsonProperty("success_level") + @Schema(description = "마음 채움도", example = "LEVEL3") + @NotNull(message = "마음 채움도를 선택해주세요.") + SuccessLevel successLevel +) { +} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..ebddd21 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,42 @@ +logging-module: + version: 0.0.1 + +springdoc: + swagger-ui: + groups-order: DESC + disable-swagger-default-url: true + display-request-duration: true + operations-sorter: method + api-docs: + path: /api-docs + show-actuator: true + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + +jwt: + secret: dGhpcyBpcyBhIHNhbXBsZSAyNTYtYml0IEJhc2U2dGhpcyBpcyBhIHNhbXBsZSAyNTYtYml0IEJhc2U2 + +spring: + jpa: + hibernate: + ddl-auto: create + database-platform: org.hibernate.dialect.H2Dialect + properties: + hibernate: + format_sql: true + show_sql: true + dialect: org.hibernate.dialect.H2Dialect + h2: + console: + enabled: false + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:test + username: sa + password: + data: + redis: + host: localhost + port: 6379 +fcm: + certification: config/fcm_key.json \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 2a0fb96..0000000 --- a/src/main/resources/application.yml +++ /dev/null @@ -1,14 +0,0 @@ -logging-module: - version: 0.0.1 - -springdoc: - swagger-ui: - groups-order: DESC - disable-swagger-default-url: true - display-request-duration: true - operations-sorter: method - api-docs: - path: /api-docs - show-actuator: true - default-consumes-media-type: application/json;charset=UTF-8 - default-produces-media-type: application/json;charset=UTF-8 \ No newline at end of file diff --git a/src/main/resources/config b/src/main/resources/config new file mode 160000 index 0000000..8fdbd81 --- /dev/null +++ b/src/main/resources/config @@ -0,0 +1 @@ +Subproject commit 8fdbd8110bcd10eb4505de90aba3649240d95a59 diff --git a/src/test/java/com/backend/BackendApplicationTests.java b/src/test/java/com/backend/BackendApplicationTests.java index f692c24..d00ba67 100644 --- a/src/test/java/com/backend/BackendApplicationTests.java +++ b/src/test/java/com/backend/BackendApplicationTests.java @@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest +// @SpringBootTest class BackendApplicationTests { @Test diff --git a/src/test/java/com/backend/auth/application/OAuthServiceTest.java b/src/test/java/com/backend/auth/application/OAuthServiceTest.java new file mode 100644 index 0000000..24272b5 --- /dev/null +++ b/src/test/java/com/backend/auth/application/OAuthServiceTest.java @@ -0,0 +1,96 @@ +package com.backend.auth.application; + +import com.backend.auth.presentation.dto.response.LoginResponse; +import com.backend.auth.presentation.dto.response.ReissueResponse; +import com.backend.global.exception.BusinessException; +import org.junit.jupiter.api.DisplayName; +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.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.Transactional; + +import static java.lang.Thread.sleep; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ExtendWith(SpringExtension.class) +@ActiveProfiles(value = "test") +public class OAuthServiceTest { + + @Autowired + private OAuthService oAuthService; + + private static String UID = "user1234"; + + private static String BEARER_TOKEN_PREFIX = "Bearer "; + + private static String FCM_TOKEN = "fcm_token"; + + private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 30; // 30초 + + @DisplayName("Access Token을 이용해 OAuth 인증 후 JWT를 발급한다.") + @Test + public void loginSuccess() { + // given + String provider = "kakao"; + + // when + LoginResponse response = oAuthService.login(provider, UID, FCM_TOKEN); + + // then + assertThat(response.accessToken()).isNotNull(); + } + + @DisplayName("kakao, apple 이외의 요청이 들어온 경우에 예외가 발생한다.") + @Test + @jakarta.transaction.Transactional + public void loginFailedByInvalidProvider() { + // given + String provider = "naver"; + + // when & then + assertThrows(BusinessException.class, + () -> oAuthService.login(provider, UID , FCM_TOKEN)); + } + + @DisplayName("access token이 만료되어 refresh token을 통해 재발급한다.") + @Test + @Transactional + public void reissueRefreshToken() throws Exception { + // given + LoginResponse loginResponse = oAuthService.login("kakao", UID, FCM_TOKEN); + sleep(ACCESS_TOKEN_EXPIRE_TIME); + + // when + ReissueResponse reissueResponse = oAuthService.reissue(BEARER_TOKEN_PREFIX + loginResponse.refreshToken()); + + // then + assertThat(reissueResponse.accessToken()).isNotNull(); + assertThat(loginResponse.refreshToken()).isNotNull(); + } + + @DisplayName("저장되어 있지 않은 refresh token이 입력되면 예외가 발생한다. ") + @Test + public void refreshTokenNotFound() { + // given + String invalidRefreshToken = "mock_refresh_token"; + // when & then + assertThrows(Exception.class, () -> oAuthService.reissue(BEARER_TOKEN_PREFIX + invalidRefreshToken)); + } + + @DisplayName("로그아웃을 성공적으로 완료한다.") + @Test + @Transactional + public void logoutSuccess(){ + // given + LoginResponse loginResponse = oAuthService.login("kakao", UID, FCM_TOKEN); + + // when & then + assertDoesNotThrow(() -> oAuthService.withdraw( BEARER_TOKEN_PREFIX + loginResponse.accessToken())); + } + +} \ No newline at end of file diff --git a/src/test/java/com/backend/auth/application/RefreshTokenServiceTest.java b/src/test/java/com/backend/auth/application/RefreshTokenServiceTest.java new file mode 100644 index 0000000..19bb03a --- /dev/null +++ b/src/test/java/com/backend/auth/application/RefreshTokenServiceTest.java @@ -0,0 +1,32 @@ +package com.backend.auth.application; + +import com.backend.auth.domain.RefreshToken; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +class RefreshTokenServiceTest { + + @Autowired + private RefreshTokenService refreshTokenService; + + @Test + @DisplayName("사용자의 refresh token으로 UID를 반환한다.") + void findUidByRefreshTokenTest(){ + // given + RefreshToken refreshToken = new RefreshToken("uid", "token value"); + + // when + refreshTokenService.saveRefreshToken(refreshToken.getUid(), refreshToken.getTokenValue()); + String extractedUID = refreshTokenService.findUidByRefreshToken(refreshToken.getTokenValue()); + + // then + assertThat(extractedUID).isEqualTo(refreshToken.getUid()); + } +} \ No newline at end of file diff --git a/src/test/java/com/backend/auth/domain/RefreshTokenRepositoryTest.java b/src/test/java/com/backend/auth/domain/RefreshTokenRepositoryTest.java new file mode 100644 index 0000000..bba14bc --- /dev/null +++ b/src/test/java/com/backend/auth/domain/RefreshTokenRepositoryTest.java @@ -0,0 +1,31 @@ +package com.backend.auth.domain; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class RefreshTokenRepositoryTest { + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Test + void insertAndGetTest() { + // given + RefreshToken refreshToken = new RefreshToken("uid", "refresh token"); + + // when + refreshTokenRepository.save(refreshToken); // (refreshToken:"uid", refreshToken:tokenValue:"refresh token") + RefreshToken extractedRefreshToken = refreshTokenRepository.findByTokenValue(refreshToken.getTokenValue()).get(); + + // then + assertThat(extractedRefreshToken.getUid()).isEqualTo(refreshToken.getUid()); + } + +} \ No newline at end of file diff --git a/src/test/java/com/backend/auth/jwt/TokenProviderTest.java b/src/test/java/com/backend/auth/jwt/TokenProviderTest.java new file mode 100644 index 0000000..5a9f894 --- /dev/null +++ b/src/test/java/com/backend/auth/jwt/TokenProviderTest.java @@ -0,0 +1,94 @@ +package com.backend.auth.jwt; + +import com.backend.auth.application.OAuthService; +import com.backend.member.domain.Member; +import com.backend.member.domain.MemberRepository; +import com.backend.member.domain.Provider; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; + +@SpringBootTest +@ActiveProfiles("test") +class TokenProviderTest { + + @Autowired + TokenProvider tokenProvider; + + @MockBean + OAuthService oAuthService; + + @MockBean + MemberRepository memberRepository; + + String uid; + + @BeforeEach + void setUp(){ + uid = "mock_social_uid"; + } + + @Test + void getTokenTest(){ + // given + String expectedToken = "expectedToken"; + String bearerToken = "Bearer " + expectedToken; + // when + String token = tokenProvider.getToken(bearerToken); + // then + Assertions.assertThat(token).isEqualTo(expectedToken); + } + + @Test + void generateAccessToken() { + // given + Member member = Member.from(Provider.APPLE, uid); + BDDMockito.given(memberRepository.getByUid(anyString())).willReturn(member); + // when + String accessToken = tokenProvider.generateAccessToken(uid); + // then + assertNotNull(accessToken); + } + + @Test + void generateRefreshToken() { + // given + Member member = Member.from(Provider.APPLE, uid); + BDDMockito.given(memberRepository.getByUid(anyString())).willReturn(member); + // when + String refreshToken = tokenProvider.generateRefreshToken(uid); + // then + assertNotNull(refreshToken); + } + + @Test + void getPayload() { + // given + Member member = Member.from(Provider.APPLE, uid); + BDDMockito.given(memberRepository.getByUid(anyString())).willReturn(member); + String accessToken = tokenProvider.generateAccessToken(uid); + // when + String extractedUid = tokenProvider.getPayload(accessToken); + // then + assertEquals(uid, extractedUid); + } + + @Test + void validateToken() { + // given + Member member = Member.from(Provider.APPLE, uid); + BDDMockito.given(memberRepository.getByUid(anyString())).willReturn(member); + String accessToken = tokenProvider.generateAccessToken(uid); + // when & then + Assertions.assertThatCode(() -> tokenProvider.validateToken(accessToken)) + .doesNotThrowAnyException(); + } +} \ No newline at end of file diff --git a/src/test/java/com/backend/detailgoal/application/DetailGoalServiceTest.java b/src/test/java/com/backend/detailgoal/application/DetailGoalServiceTest.java new file mode 100644 index 0000000..4f68371 --- /dev/null +++ b/src/test/java/com/backend/detailgoal/application/DetailGoalServiceTest.java @@ -0,0 +1,284 @@ +package com.backend.detailgoal.application; + +import com.backend.detailgoal.application.dto.response.DetailGoalListResponse; +import com.backend.detailgoal.application.dto.response.DetailGoalResponse; +import com.backend.detailgoal.application.dto.response.GoalCompletedResponse; +import com.backend.detailgoal.domain.DetailGoal; +import com.backend.detailgoal.domain.repository.DetailGoalRepository; +import com.backend.detailgoal.presentation.dto.request.DetailGoalSaveRequest; +import com.backend.detailgoal.presentation.dto.request.DetailGoalUpdateRequest; +import com.backend.global.DatabaseCleaner; +import com.backend.goal.domain.Goal; +import com.backend.goal.domain.repository.GoalRepository; +import com.backend.goal.domain.enums.GoalStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +public class DetailGoalServiceTest { + + @Autowired + DatabaseCleaner databaseCleaner; + + @Autowired + private DetailGoalRepository detailGoalRepository; + + @Autowired + private DetailGoalService detailGoalService; + + @Autowired + private GoalRepository goalRepository; + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + } + + + + @DisplayName("하위 목표를 생성하면 상위 목표의 하위 목표 카운트가 증가한다.") + @Test + void 하위목표를_생성하면_상위목표의_하위목표_카운트가_증가한다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 20), LocalDate.of(2023, 8, 24), true, GoalStatus.PROCESS); + Goal savedGoal = goalRepository.save(goal); + + DetailGoalSaveRequest detailGoalSaveRequest = new DetailGoalSaveRequest("테스트 제목", true, LocalTime.of(10, 0), List.of("MONDAY", "TUESDAY")); + detailGoalService.saveDetailGoal(savedGoal.getId() ,detailGoalSaveRequest); + + // when + List detailGoalList = detailGoalRepository.findAll(); + Goal findGoal = goalRepository.getByIdAndIsDeletedFalse(savedGoal.getId()); + + // then + assertThat(detailGoalList).hasSize(1); + assertThat(findGoal.getEntireDetailGoalCnt()).isEqualTo(1); + } + + + @DisplayName("하위 목표를 수정한다.") + @Test + void 하위_목표를_수정한다() + { + // given + DetailGoal detailGoal = new DetailGoal(1L, "테스트 제목", false, true, List.of(DayOfWeek.MONDAY), LocalTime.of(10, 0)); + DetailGoal savedDetailGoal = detailGoalRepository.save(detailGoal); + + // when + DetailGoalUpdateRequest detailGoalUpdateRequest = new DetailGoalUpdateRequest("수정된 제목", true, LocalTime.of(10, 0), List.of("THURSDAY", "FRIDAY")); + detailGoalService.updateDetailGoal(savedDetailGoal.getId(), detailGoalUpdateRequest); + + // then + DetailGoal updatedDetailGoal = detailGoalRepository.getByIdAndIsDeletedFalse(savedDetailGoal.getId()); + assertThat(updatedDetailGoal.getAlarmDays()).usingRecursiveComparison().isEqualTo(List.of(DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)); + } + + @DisplayName("하위 목표를 삭제하면 상위 목표의 하위 목표 카운트가 감소한다.") + @Test + void 하위목표를_삭제하면_상위목표의_하위목표_카운트가_감소한다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 20), LocalDate.of(2023, 8, 24), true, GoalStatus.PROCESS); + Goal savedGoal = goalRepository.save(goal); + + DetailGoalSaveRequest detailGoalSaveRequest = new DetailGoalSaveRequest("테스트 제목", true, LocalTime.of(10, 0), List.of("MONDAY", "TUESDAY")); + detailGoalService.saveDetailGoal(savedGoal.getId() ,detailGoalSaveRequest); + + DetailGoalSaveRequest detailGoalSaveRequest2 = new DetailGoalSaveRequest("테스트 제목", true, LocalTime.of(10, 0), List.of("MONDAY", "TUESDAY")); + detailGoalService.saveDetailGoal(savedGoal.getId() ,detailGoalSaveRequest2); + + detailGoalService.removeDetailGoal(savedGoal.getId()); + + // when + Goal findGoal = goalRepository.getByIdAndIsDeletedFalse(savedGoal.getId()); + + // then + assertThat(findGoal.getEntireDetailGoalCnt()).isEqualTo(1); + } + + + @DisplayName("하위 목표 상세 정보를 조회한다.") + @Test + void 하위_목표_상세정보를_조회한다() + { + // given + DetailGoal detailGoal = new DetailGoal(1L, "테스트 제목", false, true, List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY), LocalTime.of(10, 0)); + DetailGoal savedDetailGoal = detailGoalRepository.save(detailGoal); + + // when + DetailGoalResponse detailGoalResponse = detailGoalService.getDetailGoal(savedDetailGoal.getId()); + + // then + assertThat(detailGoalResponse.alarmDays()).usingRecursiveComparison().isEqualTo(List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY)); + assertThat(detailGoalResponse.alarmTime()).isEqualTo(LocalTime.of(10, 0)); + } + + @DisplayName("하위 목표 리스트를 조회한다.") + @Test + void 하위_목표_리스트를_조회한다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 20), LocalDate.of(2023, 8, 24), true, GoalStatus.PROCESS); + Goal savedGoal = goalRepository.save(goal); + + for(int i = 0; i < 5; i++) + { + DetailGoalSaveRequest detailGoalSaveRequest = new DetailGoalSaveRequest("테스트 제목", true, LocalTime.of(10, 0), List.of("MONDAY", "TUESDAY")); + detailGoalService.saveDetailGoal(savedGoal.getId() ,detailGoalSaveRequest); + } + + // when + List detailGoalList = detailGoalService.getDetailGoalList(savedGoal.getId()); + + // then + assertThat(detailGoalList).hasSize(5); + } + + @DisplayName("하위 목표를 체크하면 완료 상태로 변한다") + @Test + void 하위목표를_체크하면_완료상태로_변한다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 20), LocalDate.of(2023, 8, 24), true, GoalStatus.PROCESS); + Goal savedGoal = goalRepository.save(goal); + + DetailGoal detailGoal = new DetailGoal(savedGoal.getId(), "테스트 제목", false, true, List.of(DayOfWeek.MONDAY), LocalTime.of(10, 0)); + DetailGoal savedDetailGoal = detailGoalRepository.save(detailGoal); + + // when + detailGoalService.completeDetailGoal(savedDetailGoal.getId()); + + // then + List result = detailGoalRepository.findAllByGoalIdAndIsDeletedFalse(savedDetailGoal.getGoalId()); + assertThat(result.get(0).getIsCompleted()).isTrue(); + } + + @DisplayName("하위 목표를 체크 해제하면 미완료 상태로 변한다") + @Test + void 하위목표를_체크해제_하면_미완료_상태로_변한다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 20), LocalDate.of(2023, 8, 24), true, GoalStatus.PROCESS); + Goal savedGoal = goalRepository.save(goal); + + DetailGoal detailGoal = new DetailGoal(savedGoal.getId(), "테스트 제목", true, true, List.of(DayOfWeek.MONDAY), LocalTime.of(10, 0)); + DetailGoal savedDetailGoal = detailGoalRepository.save(detailGoal); + + // when + detailGoalService.completeDetailGoal(savedDetailGoal.getId()); + detailGoalService.inCompleteDetailGoal(savedDetailGoal.getId()); + + // then + List result = detailGoalRepository.findAllByGoalIdAndIsDeletedFalse(savedDetailGoal.getGoalId()); + assertThat(result.get(0).getIsCompleted()).isFalse(); + } + + @DisplayName("완료 상태 하위 목표의 개수와 전체 하위 목표 개수가 같아지면 상위 목표가 성공한다") + @Test + void 완료_하위목표_개수와_전체_하위목표_개수가_같아지면_상위목표가_성공한다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 20), LocalDate.of(2023, 8, 24), true, GoalStatus.PROCESS); + Goal savedGoal = goalRepository.save(goal); + + DetailGoalSaveRequest detailGoalSaveRequest = new DetailGoalSaveRequest("테스트 제목", true, LocalTime.of(10, 0), List.of("MONDAY", "TUESDAY")); + DetailGoal detailGoal = detailGoalService.saveDetailGoal(savedGoal.getId(), detailGoalSaveRequest); + + DetailGoalSaveRequest detailGoalSaveRequest2 = new DetailGoalSaveRequest("테스트 제목", true, LocalTime.of(10, 0), List.of("MONDAY", "TUESDAY")); + DetailGoal detailGoal1 = detailGoalService.saveDetailGoal(savedGoal.getId(), detailGoalSaveRequest2); + + // when + detailGoalService.completeDetailGoal(detailGoal.getId()); + GoalCompletedResponse goalCompletedResponse = detailGoalService.completeDetailGoal(detailGoal1.getId()); + + // then + assertThat(goalCompletedResponse.isGoalCompleted()).isTrue(); + } + + @Nested + @DisplayName("하위 목표를 삭제했을때 하위목표 개수와 달성한하위목표 개수가 같으면") + class 하위목표를_삭제했을때_하위목표개수와_달성한_하위목표_개수가_같으면{ + + @DisplayName("상위 목표가 성공한다") + @Test + void 상위목표가_성공한다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 20), LocalDate.of(2023, 8, 24), true, GoalStatus.PROCESS); + Goal savedGoal = goalRepository.save(goal); + + DetailGoalSaveRequest detailGoalSaveRequest = new DetailGoalSaveRequest("테스트 제목", true, LocalTime.of(10, 0), List.of("MONDAY", "TUESDAY")); + DetailGoal detailGoal = detailGoalService.saveDetailGoal(savedGoal.getId(), detailGoalSaveRequest); + + DetailGoalSaveRequest detailGoalSaveRequest2 = new DetailGoalSaveRequest("테스트 제목", true, LocalTime.of(10, 0), List.of("MONDAY", "TUESDAY")); + DetailGoal detailGoal1 = detailGoalService.saveDetailGoal(savedGoal.getId(), detailGoalSaveRequest2); + + // when + GoalCompletedResponse beforeRemoveResponse = detailGoalService.completeDetailGoal(detailGoal.getId()); + GoalCompletedResponse afterRemovedResponse = detailGoalService.removeDetailGoal(detailGoal1.getId()); + + // then + assertThat(beforeRemoveResponse.isGoalCompleted()).isFalse(); + assertThat(afterRemovedResponse.isGoalCompleted()).isTrue(); + } + + @DisplayName("보석을 지급한다") + @Test + void 보석을_지급한다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 20), LocalDate.of(2023, 8, 24), true, GoalStatus.PROCESS); + Goal savedGoal = goalRepository.save(goal); + + DetailGoalSaveRequest detailGoalSaveRequest = new DetailGoalSaveRequest("테스트 제목", true, LocalTime.of(10, 0), List.of("MONDAY", "TUESDAY")); + DetailGoal detailGoal = detailGoalService.saveDetailGoal(savedGoal.getId(), detailGoalSaveRequest); + + DetailGoalSaveRequest detailGoalSaveRequest2 = new DetailGoalSaveRequest("테스트 제목", true, LocalTime.of(10, 0), List.of("MONDAY", "TUESDAY")); + DetailGoal detailGoal1 = detailGoalService.saveDetailGoal(savedGoal.getId(), detailGoalSaveRequest2); + + // when + detailGoalService.completeDetailGoal(detailGoal.getId()); + GoalCompletedResponse afterRemovedResponse = detailGoalService.removeDetailGoal(detailGoal1.getId()); + + // then + assertThat(afterRemovedResponse.rewardType()).isNotNull(); + } + + @DisplayName("지금까지 성공한 상위목표 개수를 반환한다") + @Test + void 지금까지_성공한_상위목표_개수를_반환한다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 20), LocalDate.of(2023, 8, 24), true, GoalStatus.PROCESS); + Goal savedGoal = goalRepository.save(goal); + + DetailGoalSaveRequest detailGoalSaveRequest = new DetailGoalSaveRequest("테스트 제목", true, LocalTime.of(10, 0), List.of("MONDAY", "TUESDAY")); + DetailGoal detailGoal = detailGoalService.saveDetailGoal(savedGoal.getId(), detailGoalSaveRequest); + + DetailGoalSaveRequest detailGoalSaveRequest2 = new DetailGoalSaveRequest("테스트 제목", true, LocalTime.of(10, 0), List.of("MONDAY", "TUESDAY")); + DetailGoal detailGoal1 = detailGoalService.saveDetailGoal(savedGoal.getId(), detailGoalSaveRequest2); + + // when + detailGoalService.completeDetailGoal(detailGoal.getId()); + GoalCompletedResponse afterRemovedResponse = detailGoalService.removeDetailGoal(detailGoal1.getId()); + + // then + assertThat(afterRemovedResponse.completedGoalCount()).isEqualTo(1); + } + } + +} diff --git a/src/test/java/com/backend/detailgoal/domain/DetailGoalQueryRepositoryTest.java b/src/test/java/com/backend/detailgoal/domain/DetailGoalQueryRepositoryTest.java new file mode 100644 index 0000000..785a987 --- /dev/null +++ b/src/test/java/com/backend/detailgoal/domain/DetailGoalQueryRepositoryTest.java @@ -0,0 +1,65 @@ +package com.backend.detailgoal.domain; + + +import com.backend.detailgoal.application.dto.response.DetailGoalAlarmResponse; +import com.backend.detailgoal.domain.repository.DetailGoalQueryRepository; +import com.backend.detailgoal.domain.repository.DetailGoalRepository; +import com.backend.global.DatabaseCleaner; +import com.backend.goal.domain.Goal; +import com.backend.goal.domain.repository.GoalRepository; +import com.backend.goal.domain.enums.GoalStatus; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + + +@SpringBootTest +@ActiveProfiles("test") +public class DetailGoalQueryRepositoryTest { + + @Autowired + private DetailGoalQueryRepository detailGoalQueryRepository; + + @Autowired + private GoalRepository goalRepository; + + @Autowired + private DetailGoalRepository detailGoalRepository; + + @Autowired + private DatabaseCleaner databaseCleaner; + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + } + + @DisplayName("하위 목표 설정시 선택한 요일과 시간에 해당하는 하위 목표 리스트를 조회한다") + @Test + void 하위목표_설정시_선택한_요일과_시간에_해당하는_하위목표_리스트를_조회한다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 1), LocalDate.of(2023, 8, 1), true, GoalStatus.STORE); + Goal savedGoal = goalRepository.save(goal); + + DetailGoal detailGoal = new DetailGoal(savedGoal.getId(), "테스트 제목", false, true, List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY), LocalTime.of(10, 0, 0)); + detailGoalRepository.save(detailGoal); + + // when + List results = detailGoalQueryRepository.getMemberIdListDetailGoalAlarmTimeArrived(DayOfWeek.MONDAY, LocalTime.of(10, 0, 0)); + + // then + assertThat(results).hasSize(1); + } +} diff --git a/src/test/java/com/backend/global/DatabaseCleaner.java b/src/test/java/com/backend/global/DatabaseCleaner.java new file mode 100644 index 0000000..c0fcf00 --- /dev/null +++ b/src/test/java/com/backend/global/DatabaseCleaner.java @@ -0,0 +1,43 @@ +package com.backend.global; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Table; +import jakarta.persistence.metamodel.Type; +import org.springframework.stereotype.Component; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +@ActiveProfiles("test") +public class DatabaseCleaner { + + private final EntityManager entityManager; + private final List tableNames; + + public DatabaseCleaner(final EntityManager entityManager) { + this.entityManager = entityManager; + this.tableNames = entityManager.getMetamodel() + .getEntities() + .stream() + .map(Type::getJavaType) + .map(javaType -> javaType.getAnnotation(Table.class)) + .map(Table::name) + .collect(Collectors.toList()); + } + + @Transactional + public void execute() { + entityManager.flush(); + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + + for (String tableName : tableNames) { + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN " + tableName+"_id" + " RESTART WITH 1").executeUpdate(); + } + + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + } +} \ No newline at end of file diff --git a/src/test/java/com/backend/goal/application/GoalServiceTest.java b/src/test/java/com/backend/goal/application/GoalServiceTest.java new file mode 100644 index 0000000..7c75c87 --- /dev/null +++ b/src/test/java/com/backend/goal/application/GoalServiceTest.java @@ -0,0 +1,225 @@ +package com.backend.goal.application; + +import com.backend.global.DatabaseCleaner; +import com.backend.goal.application.dto.response.GoalCountResponse; +import com.backend.goal.application.dto.response.GoalListResponse; +import com.backend.goal.application.dto.response.RetrospectEnabledGoalCountResponse; +import com.backend.goal.domain.Goal; +import com.backend.goal.domain.repository.GoalRepository; +import com.backend.goal.domain.enums.GoalStatus; +import com.backend.goal.presentation.dto.GoalRecoverRequest; +import com.backend.goal.presentation.dto.GoalSaveRequest; +import com.backend.goal.presentation.dto.GoalUpdateRequest; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +public class GoalServiceTest { + + @Autowired + DatabaseCleaner databaseCleaner; + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + } + + @Autowired + private GoalRepository goalRepository; + + @Autowired + private GoalService goalService; + + + @DisplayName("상위 목표를 저장할 수 있다.") + @Test + void 상위목표를_저장할수_있다() + { + // given + GoalSaveRequest placeSaveRequest = new GoalSaveRequest("제목", LocalDate.now(), LocalDate.now(), true); + Long goalId = goalService.saveGoal("userId", placeSaveRequest); + + + // then + Goal savedUser = goalRepository.getById(goalId); + Assertions.assertThat(savedUser.getTitle()).isEqualTo(placeSaveRequest.title()); + } + + @DisplayName("상위 목표 리스트를 처음 조회할때는 제일 최근 데이터부터 조회한다.") + @Test + void 상위목표_리스트를_처음_조회한다() + { + // given + for(int i =0; i < 10; i++) + { + GoalSaveRequest placeSaveRequest = new GoalSaveRequest("제목 "+i, LocalDate.now(), LocalDate.now(), true); + goalService.saveGoal("userId", placeSaveRequest); + } + + // when + GoalListResponse goalList = goalService.getGoalList("userId", 10L, Pageable.ofSize(5), "process"); + + // then + Assertions.assertThat(goalList.contents()).hasSize(5); + Assertions.assertThat(goalList.contents().get(0).goalId()).isEqualTo(10L); + Assertions.assertThat(goalList.next()).isTrue(); + } + + @DisplayName("상위 목표 리스트를 조회할때 커서 값이 있다면 이후 값부터 조회한다.") + @Test + void 상위목표_리스트를_커서값_이후부터_조회한다() + { + // given + for(int i =0; i < 10; i++) + { + GoalSaveRequest placeSaveRequest = new GoalSaveRequest("제목 "+i, LocalDate.now(), LocalDate.now(), true); + goalService.saveGoal("userId", placeSaveRequest); + } + + // when + GoalListResponse goalList = goalService.getGoalList("userId", 7L, Pageable.ofSize(5), "process"); + + // then + Assertions.assertThat(goalList.contents()).hasSize(5); + Assertions.assertThat(goalList.contents().get(0).goalId()).isEqualTo(6L); + Assertions.assertThat(goalList.next()).isTrue(); + } + + @DisplayName("상위 목표 리스트를 조회할때 페이지 크기만큼 조회했지만 다음 데이터가 없다면 hasNext가 false를 반환한다") + @Test + void 상위목표_리스트의_페이지크기보다_데이터가_없다면_false를_반환한다() + { + // given + for(int i =0; i < 10; i++) + { + GoalSaveRequest placeSaveRequest = new GoalSaveRequest("제목 "+i, LocalDate.now(), LocalDate.now(), true); + goalService.saveGoal("userId", placeSaveRequest); + } + + // when + GoalListResponse goalList = goalService.getGoalList("userId", 3L, Pageable.ofSize(5), "process"); + + // then + Assertions.assertThat(goalList.contents()).hasSize(2); + Assertions.assertThat(goalList.contents().get(0).goalId()).isEqualTo(2L); + Assertions.assertThat(goalList.next()).isFalse(); + } + + @DisplayName("상위 목표 리스트를 조회할때 페이지 크기 보다 적은 데이터가 남았다면 hasNext가 false를 반환한다") + @Test + void 상위목표_리스트의_페이지크기만큼_조회하고_남은_데이터가_없다면_false를_반환한다() + { + // given + for(int i =0; i < 10; i++) + { + GoalSaveRequest placeSaveRequest = new GoalSaveRequest("제목 "+i, LocalDate.now(), LocalDate.now(), true); + goalService.saveGoal("userId", placeSaveRequest); + } + + // when + GoalListResponse goalList = goalService.getGoalList("userId", 6L, Pageable.ofSize(5), "process"); + + // then + Assertions.assertThat(goalList.contents()).hasSize(5); + Assertions.assertThat(goalList.contents().get(0).goalId()).isEqualTo(5L); + Assertions.assertThat(goalList.next()).isFalse(); + } + + @DisplayName("상위 목표를 수정할 수 있다.") + @Test + void 상위목표를_수정할수_있다() + { + // given + + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 1), LocalDate.of(2023, 8, 1), true, GoalStatus.PROCESS); + Goal savedGoal = goalRepository.save(goal); + + GoalUpdateRequest goalUpdateRequest = new GoalUpdateRequest("수정된 제목", LocalDate.now(), LocalDate.now(), false); + // when + goalService.updateGoal(savedGoal.getId(), goalUpdateRequest); + + // then + Goal updatedGoal = goalRepository.getById(savedGoal.getId()); + Assertions.assertThat(updatedGoal.getTitle()).isEqualTo(goalUpdateRequest.title()); + } + + @DisplayName("상위 목표를 삭제할 수 있다.") + @Test + void 상위목표를_삭제할수_있다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 1), LocalDate.of(2023, 8, 1), true , GoalStatus.PROCESS); + Goal savedGoal = goalRepository.save(goal); + + // when + goalService.removeGoal(savedGoal.getId()); + + // then + Goal removedGoal = goalRepository.getById(savedGoal.getId()); + Assertions.assertThat(removedGoal.getIsDeleted()).isTrue(); + } + + @DisplayName("상위 목표 상태에 따라 통계를 제공한다.") + @Test + void 상위목표_상태에_따라_통계를_제공한다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 1), LocalDate.of(2023, 8, 1), true, GoalStatus.PROCESS); + goalRepository.save(goal); + + // when + GoalCountResponse goalCounts = goalService.getGoalCounts("uid"); + + // then + Assertions.assertThat(goalCounts.counts().keySet()).hasSize(3); + } + + @DisplayName("상위 목표를 보관함에서 채움함으로 복구한다") + @Test + void 상위목표를_보관함에서_채움함으로_복구한다() + { + // given + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 1), LocalDate.of(2023, 8, 1), true, GoalStatus.STORE); + Goal savedGoal = goalRepository.save(goal); + GoalRecoverRequest goalRecoverRequest = new GoalRecoverRequest(LocalDate.of(2023, 8, 1), LocalDate.of(2023, 9, 30), false); + + // when + goalService.recoverGoal(savedGoal.getId(), goalRecoverRequest); + + // then + Goal recoverdGoal = goalRepository.getById(savedGoal.getId()); + Assertions.assertThat(recoverdGoal.getEndDate()).isEqualTo(LocalDate.of(2023, 9, 30)); + Assertions.assertThat(recoverdGoal.getGoalStatus()).isEqualTo(GoalStatus.PROCESS); + Assertions.assertThat(recoverdGoal.getReminderEnabled()).isFalse(); + } + + @DisplayName("완료함의_목표들중_회고가능한_목표수를_계산한다") + @Test + void 완료함의_목표들중_회고가능한_목표수를_계산한다() + { + // given + for(int i =0; i < 10; i++) + { + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 1), LocalDate.of(2023, 8, 1), true, GoalStatus.COMPLETE); + goalRepository.save(goal); + } + + // when + RetrospectEnabledGoalCountResponse count = goalService.getGoalCountRetrospectEnabled("userId"); + + // then + Assertions.assertThat(count.count()).isEqualTo(10); + } +} diff --git a/src/test/java/com/backend/goal/domain/GoalTest.java b/src/test/java/com/backend/goal/domain/GoalTest.java new file mode 100644 index 0000000..7d63083 --- /dev/null +++ b/src/test/java/com/backend/goal/domain/GoalTest.java @@ -0,0 +1,150 @@ +package com.backend.goal.domain; + + +import com.backend.global.common.code.ErrorCode; +import com.backend.global.exception.BusinessException; +import com.backend.goal.domain.enums.GoalStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; + +public class GoalTest { + + @DisplayName("상위 목표 종료 날짜가 시작날짜보다 빠르면 예외가 발생한다.") + @Test + void 상위목표_종료날짜가_시작날짜보다_빠르면_예외가_발생한다() + { + // given + LocalDate startDate = LocalDate.of(2023,8,10); + LocalDate endDate = LocalDate.of(2023,8,9); + + // when & then + assertThatThrownBy(() -> { + new Goal(1L, "테스트 제목", startDate, endDate, true, GoalStatus.PROCESS); + }).isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("상위 목표의 시작날짜가 최소 범위보다 작으면 예외가 발생한다.") + @Test + void 상위목표_시작날짜가_최소범위보다_작으면_예외가_발생한다() + { + // given + LocalDate startDate = LocalDate.of(999,12,31); + LocalDate endDate = LocalDate.of(2023,8,9); + + // when & then + assertThatThrownBy(() -> { + new Goal(1L, "테스트 제목", startDate, endDate, true, GoalStatus.PROCESS); + }).isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("상위 목표의 종료날짜가 최대 범위보다 크면 예외가 발생한다.") + @Test + void 상위목표_종료날짜가_최대범위보다_크면_예외가_발생한다() + { + // given + LocalDate startDate = LocalDate.of(2023,8,10); + LocalDate endDate = LocalDate.of(10000,1,1); + + // when & then + assertThatThrownBy(() -> { + new Goal(1L, "테스트 제목", startDate, endDate, true, GoalStatus.PROCESS); + }).isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("상위 목표의 제목이 15자를 초과하면 예외가 발생한다.") + @Test + void 상위목표의_제목길이가_15자를_초과하면_예외가_발생한다() + { + // given + String title = "상위목표 제목 길이 체크하겠습니다"; + LocalDate startDate = LocalDate.of(2023,7,1); + LocalDate endDate = LocalDate.of(2023,8,10); + + // when & then + assertThatThrownBy(() -> { + new Goal(1L, title, startDate, endDate, true, GoalStatus.PROCESS); + }).isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("상위 목표 종료 날짜까지의 디데이를 구한다.") + @Test + void 상위목표의_종료날짜까지의_디데이를_구한다() + { + // given + LocalDate startDate = LocalDate.of(2023,7,1); + LocalDate endDate = LocalDate.of(2023,8,10); + Goal goal = new Goal(1L, "테스트 제목", startDate, endDate, true, GoalStatus.PROCESS); + + // when + Long dDay = goal.calculateDday(LocalDate.of(2023, 7, 1)); + + // then + assertThat(dDay).isEqualTo(40L); + } + + @DisplayName("현재 날짜가 상위 목표 날짜보다 뒤라면 디데이 구할때 예외가 발생한다.") + @Test + void 현재날짜가_상위목표날짜보다_뒤라면_디데이_연산시_예외가_발생한다() + { + // given + LocalDate startDate = LocalDate.of(2023,7,1); + LocalDate endDate = LocalDate.of(2023,8,10); + Goal goal = new Goal(1L, "테스트 제목", startDate, endDate, true, GoalStatus.PROCESS); + + // when & then + Long aLong = goal.calculateDday(LocalDate.of(2023, 8, 11)); + System.out.println(aLong); + + } + + @DisplayName("현재 날짜와 종료 날짜가 같을때 디데이 0을 반환한다.") + @Test + void 현재날짜와_종료날짜가_같을때_디데이_0을_반환한다() + { + // given + LocalDate startDate = LocalDate.of(2023,7,1); + LocalDate endDate = LocalDate.of(2023,8,10); + Goal goal = new Goal(1L, "테스트 제목", startDate, endDate, true, GoalStatus.PROCESS); + + // when + Long dDay = goal.calculateDday(LocalDate.of(2023, 8, 10)); + + // then + assertThat(dDay).isEqualTo(0L); + } + + @DisplayName("현재 보관함에 있는 상태라면 상위 목표를 복구할 수 있다") + @Test + void 현재_보관함에_있는_상태라면_상위목표를_복구할수_있다() + { + // given + LocalDate startDate = LocalDate.of(2023,7,1); + LocalDate endDate = LocalDate.of(2023,8,10); + Goal goal = new Goal(1L, "테스트 제목", startDate, endDate, true, GoalStatus.STORE); + + // when + goal.recover(LocalDate.of(2023,7,3), LocalDate.of(2023,8,10),false); + + // then + assertThat(goal.getGoalStatus()).isEqualTo(GoalStatus.PROCESS); + assertThat(goal.getReminderEnabled()).isFalse(); + } + + @DisplayName("목표가 현재 보관함에 없다면 복구시 예외가 발생한다") + @Test + void 목표가_현재_보관함에_없다면_예외가_발생한다() + { + // given + LocalDate startDate = LocalDate.of(2023,7,1); + LocalDate endDate = LocalDate.of(2023,8,10); + Goal goal = new Goal(1L, "테스트 제목", startDate, endDate, true, GoalStatus.PROCESS); + + // when & then + assertThatThrownBy(() -> { + goal.recover(LocalDate.of(2023,7,3), LocalDate.of(2023,8,10),false);} + ).isInstanceOf(BusinessException.class).hasMessage(ErrorCode.RECOVER_GOAL_IMPOSSIBLE.getMessage()); + } +} diff --git a/src/test/java/com/backend/retrospect/application/RetrospectServiceTest.java b/src/test/java/com/backend/retrospect/application/RetrospectServiceTest.java new file mode 100644 index 0000000..53dde00 --- /dev/null +++ b/src/test/java/com/backend/retrospect/application/RetrospectServiceTest.java @@ -0,0 +1,122 @@ +package com.backend.retrospect.application; + +import com.backend.global.DatabaseCleaner; +import com.backend.global.exception.BusinessException; +import com.backend.goal.domain.Goal; +import com.backend.goal.domain.repository.GoalRepository; +import com.backend.goal.domain.enums.GoalStatus; +import com.backend.retrospect.application.dto.response.RetrospectResponse; +import com.backend.retrospect.domain.Guide; +import com.backend.retrospect.domain.SuccessLevel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles(value = "test") +public class RetrospectServiceTest { + + @Autowired + private RetrospectService retrospectService; + + @Autowired + private GoalRepository goalRepository; + + @Autowired + private DatabaseCleaner databaseCleaner; + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + } + + @DisplayName("회고 저장에 성공한다.") + @Test + void saveRetrospect() { + // given + Goal goal = new Goal(1L, "상위 목표", LocalDate.of(2023, 7, 10), LocalDate.of(2023, 8, 15), true, GoalStatus.COMPLETE); + Long goalId = goalRepository.save(goal).getId(); + + Map contents = new HashMap<>(); + contents.put(Guide.LIKED, "좋았던 점은 이것입니다."); + contents.put(Guide.LACKED, "부족한 건 없습니다."); + contents.put(Guide.LEARNED, "배운 것은 너무 많습니다."); + contents.put(Guide.LONGED_FOR, "꿈은 없고요, 놀고 싶습니다."); + + // when + Long retrospectId = retrospectService.saveRetrospect(goalId, Boolean.TRUE, contents, SuccessLevel.LEVEL5); + + // then + Goal savedGoal = goalRepository.getByIdAndIsDeletedFalse(goalId); + assertThat(retrospectId).isNotNull(); + assertThat(savedGoal.getHasRetrospect()).isEqualTo(Boolean.TRUE); + } + + @DisplayName("회고 조회에 성공한다.") + @Test + void getRetrospect() { + // given + Boolean hasGuide = true; + + Goal goal = new Goal(1L, "상위 목표", LocalDate.of(2023, 7, 10), LocalDate.of(2023, 8, 15), true, GoalStatus.COMPLETE); + Long goalId = goalRepository.save(goal).getId(); + + Map contents = new HashMap<>(); + contents.put(Guide.LIKED, "좋았던 점은 이것입니다."); + contents.put(Guide.LACKED, "부족한 건 없습니다."); + contents.put(Guide.LEARNED, "배운 것은 너무 많습니다."); + contents.put(Guide.LONGED_FOR, "꿈은 없고요, 놀고 싶습니다."); + + retrospectService.saveRetrospect(1L, true, contents, SuccessLevel.LEVEL5); + + RetrospectResponse expectedResponse = new RetrospectResponse(hasGuide, contents, SuccessLevel.LEVEL5); + + // when + RetrospectResponse retrospect = retrospectService.getRetrospect(goalId); + + // then + assertThat(retrospect).isEqualTo(expectedResponse); + } + + @DisplayName("회고 글 입력의 길이가 1000자인 경우에도 성공적으로 저장된다.") + @Test + void saveRetrospectWithOneThousandContent(){ + // given + Goal goal = new Goal(1L, "상위 목표", LocalDate.of(2023, 7, 10), LocalDate.of(2023, 8, 15), true, GoalStatus.COMPLETE); + Long goalId = goalRepository.save(goal).getId(); + + char[] chars = new char[1000]; + Arrays.fill(chars, 'a'); + String content = new String(chars); + + Map contents = new HashMap<>(); + contents.put(Guide.NONE, content); + + // when & then + assertThatNoException().isThrownBy(() -> retrospectService.saveRetrospect(goalId, false, contents, SuccessLevel.LEVEL1)); + } + + @DisplayName("상위 목표가 회고를 작성하지 않아 회고를 조회하는 것에 실패한다.") + @Test + void failToGetRetrospect(){ + // given + Goal goal = new Goal(1L, "상위 목표", LocalDate.of(2023, 7, 10), LocalDate.of(2023, 8, 15), true, GoalStatus.COMPLETE); + Long goalId = goalRepository.save(goal).getId(); + + // when & then + assertThrows(BusinessException.class, + () -> retrospectService.getRetrospect(goalId)); + } +} \ No newline at end of file diff --git a/src/test/java/com/backend/scheduler/SchedulerServiceTest.java b/src/test/java/com/backend/scheduler/SchedulerServiceTest.java new file mode 100644 index 0000000..f687fc0 --- /dev/null +++ b/src/test/java/com/backend/scheduler/SchedulerServiceTest.java @@ -0,0 +1,74 @@ +package com.backend.scheduler; + +import com.backend.global.scheduler.SchedulerService; +import com.backend.goal.application.GoalService; +import com.backend.goal.domain.Goal; +import com.backend.goal.domain.repository.GoalRepository; +import com.backend.goal.domain.enums.GoalStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +public class SchedulerServiceTest { + + @Autowired + SchedulerService schedulerService; + + @Autowired + GoalRepository goalRepository; + + @Autowired + GoalService goalService; + + + @DisplayName("채움함 내의 종료 일자가 만료된 상위 목표는 보관함으로 이동한다.") + @Test + void 채움함내의_종료알자가_만료된_상위목표는_보관함으로_이동한다() + { + // given + + /* + 종료 기간이 만료된 채움함 내 상위 목표 + */ + for(int i =0; i < 3; i++) + { + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 1), LocalDate.of(2023, 8, 1), true, GoalStatus.PROCESS); + goalRepository.save(goal); + } + + /* + 종료 기간이 만료되지 않은 채움함 내 상위 목표 + */ + for(int i =0; i < 3; i++) + { + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 1), LocalDate.of(9999, 8, 1), true, GoalStatus.PROCESS); + goalRepository.save(goal); + } + + /* + 종료 기간이 만료됐지만 완료함에 있는 상위 목표 + */ + for(int i =0; i < 3; i++) + { + Goal goal = new Goal(1L, "테스트 제목", LocalDate.of(2023, 8, 1), LocalDate.of(2023, 8, 1), true, GoalStatus.COMPLETE); + goalRepository.save(goal); + } + + // when + schedulerService.storeOutDateGoal(); + + // then + List goalList = goalRepository.getGoalsByGoalStatusAndMemberIdAndIsDeletedFalse(GoalStatus.STORE, 1L); + assertThat(goalList).hasSize(3); + } + +}