From db01eebf99b0a20db6184fe5e4952b393d99636a Mon Sep 17 00:00:00 2001 From: 34-43 <121276581+34-43@users.noreply.github.com> Date: Tue, 24 Dec 2024 18:42:05 +0900 Subject: [PATCH 01/37] =?UTF-8?q?CI/CD=20=EA=B5=AC=EC=B6=95=20=EA=B3=BC?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=9C=20main=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: feat: Spring Boot 프로젝트 생성 및 테스트 #1 * :construction_worker: chore: .gitignore 갱신 #1 * :construction_worker: chore: application.yml 기본 설정 #1 * :sparkles: feat: GlobalExceptionHandler 전역 예외 처리 추가 #1 * :sparkles: feat: Auditing 엔티티 'Timestamped' 및 설정 추가 #1 * :sparkles: feat: Plant, Diary, Post, Comment 도메인과 3-Layer 추가 #1 * :sparkles: feat: Plant record Dto 예시 추가 #1 * :truck: rename: 등록되지 않은 빈 경로 추가 #1 * :construction_worker: chore: .gitignore 갱신 #5 * :bug: fix: Timestamped 삭제 관련 필드 추가 #5 * :sparkles: feat: 모든 엔티티 필드 추가 #5 * :truck: rename: 패키지 구조 재배치 #5 * :sparkles: feat: Dto, Enum 등 추가 #5 * :construction_worker: chore: 기타 정리 #5 * :construction_worker: chore: 불필요 import 제거 #5 * 공통부분 발견된 사소한 문제점 수정 (#13) * fix: 'User' 엔티티의 @Enumerated 누락 수정 #12 * rename: PlantRegisterReqDto 이름과 경로 수정 #12 * rename: 전역 예외처리 Dto, Enum 이름과 경로 수정 #12 * fix: 전역 예외처리 dto 직렬화 불가 문제 해결 #12 * Timestamped 상속체들의 softDelete 메서드 임시 구현 (#15) * 성장일지(Diary) CRUD (#17) * feat: garden 관련 ExceptionStatus 패턴 추가 #11 * feat: 'Diary' 관련 요청, 응답 Dto 추가 #11 * fix: 'Diary' 엔티티 접근성 수정 #11 * feat: 'Diary' Mapper 추가 #11 * feat: 'Diary' 서비스에 필요한 default 메서드 PlantRepository 에 추가 #11 * feat: 'Diary' 3-Layer 구현 #11 * chore: 불필요한 .gitkeep 삭제 #11 * Timestamped 상속체들의 softDelete 메서드 임시 구현 (#15) * feat: 'Diary' 업데이트 API 두 가지 방식 추가 #11 * v1: toBuilder save 사용 * v2: setter dirtyChecking 사용 * feat: garden 관련 ExceptionStatus 패턴 추가 #11 * feat: 'Diary' 관련 요청, 응답 Dto 추가 #11 * fix: 'Diary' 엔티티 접근성 수정 #11 * feat: 'Diary' Mapper 추가 #11 * feat: 'Diary' 서비스에 필요한 default 메서드 PlantRepository 에 추가 #11 * feat: 'Diary' 3-Layer 구현 #11 * chore: 불필요한 .gitkeep 삭제 #11 * fix: 일지 로직에 필요한 'Plant' 엔티티 접근 권한 수정 #11 * feat: 'Diary' setter 지양 관련 피드백 반영 #11 * chore: 불필요한 Controller v2 구현 삭제 #11 * Docker에 MYSQL 8.0 추가 및 Github Actions(CI 기능 추가) (#14) * feat:Docker 에 MYSQL8.0 이미지 추가 완료 및 컨테이너 생성 완료 * feat:Docker 연결 완료 * fix:Docker 연결 문제 해결 * feat:Github Action(CI 기능 구현) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix: format_sql gitignore 에 추가 #18 * Spring Security, Filter, 로그인/회원가입 구현 (#20) * feat:Docker 에 MYSQL8.0 이미지 추가 완료 및 컨테이너 생성 완료 * feat:Docker 연결 완료 * fix:Docker 연결 문제 해결 * feat:Github Action(CI 기능 구현) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * feat: Spring Security 적용 및 Filter(인증) Handler(인가) & 로그인 회원가입 구현(쿠키로 JWT 넘겨줌), CustomException logError() 메서드를 추가하여 예외 생성 시 자동으로 에러 로그를 기록, mapper Repository 에 구현 refactor: jpa 대신 직접 SQL 쿼리를 작성해야 할 수도 있기 때문에 build.gradle 에 jdbc 의존성 추가 / security, jwt 의존성 추가 #18 * fix: 지원하지 않는 SignatureException 삭제 #18 --------- Co-authored-by: 34-43 <121276581+34-43@users.noreply.github.com> * Spring Security 오류 해결 (#26) * feat:Docker 에 MYSQL8.0 이미지 추가 완료 및 컨테이너 생성 완료 * feat:Docker 연결 완료 * fix:Docker 연결 문제 해결 * feat:Github Action(CI 기능 구현) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * feat: Spring Security 적용 및 Filter(인증) Handler(인가) & 로그인 회원가입 구현(쿠키로 JWT 넘겨줌), CustomException logError() 메서드를 추가하여 예외 생성 시 자동으로 에러 로그를 기록, mapper Repository 에 구현 refactor: jpa 대신 직접 SQL 쿼리를 작성해야 할 수도 있기 때문에 build.gradle 에 jdbc 의존성 추가 / security, jwt 의존성 추가 #18 * fix: 지원하지 않는 SignatureException 삭제 #18 * feat: 컨벤션에 맞게 MapStruct 추가 fix: null 값 오류 해결 #18 * feat: 컨벤션에 맞게 MapStruct 추가 fix: null 값 오류 해결 #18 * 게시글(POST) CRUD (#19) * feat: Mapper 인터페이스 추가 및 MapStruct 의존성 추가 #9 * feat: 엔티티 어노테이션 추가, viewCounts 기본값 0 설정, 생성자 추가 #9 * feat: 게시글 생성 기능 구현 #9 * feat: 페이징 처리를 위한 PageDto 생성 #9 * feat: 게시글 조회 - 다건조회 기능 구현 #9 * feat: postId로 해당 게시글 댓글 찾기 #9 * feat: 게시글에 해당 되는 댓글 응답 dto #9 * feat: 게시글 단건 조회 dto 추가 #9 * feat: 게시글 예외처리 추가 #9 * feat: 게시글 단건 조회 및 조회시 조회수 증가 기능 추가 #9 * feat: 게시글 수정 기능 #9 * feat: 게시글 삭제 기능 #9 * feat: 생성자 삭제, builder 추가 #9 * refactor: 메서드명 변경 #9 * feat: PageDto 대신 EnableSpringDataWebSupport 어노테이션 사용 #9 * refactor: ResponseEntity 생성 로직 단순화 및 일관성 유지 #9 * refactor: mapper 메서드명 변경 #9 * refactor: 게시글 조회시 사용 할 임시 commentResDto 클래스명 수정 #9 * feat: 게시글(post) 에러코드 추가 #9 * feat: 게시글 삭제 softDelete 구현 #9 * feat: 게시글 전체 조회 기존과 다르게 구현 #9 * feat: 게시글 전체 조회 query 조건문 추가 및 서비스 로직에서 불필요한 로직 제거 #9 * feat: 인증 user 로직 추가 #9 * feat: 전체조회 댓글 username 추가 및 게시글 소유자 확인 메서드 에러코드 변경 #9 * feat: 특정 API에서 JWT 인증을 우회하도록 설정 #9 * feat: PostWithCommentResDto mapper 생성 #9 * 댓글(Comment) CRUD (#21) * fix: User entity에 annotation 추가 #8 * 공통부분 발견된 사소한 문제점 수정 (#13) * fix: 'User' 엔티티의 @Enumerated 누락 수정 #12 * rename: PlantRegisterReqDto 이름과 경로 수정 #12 * rename: 전역 예외처리 Dto, Enum 이름과 경로 수정 #12 * fix: 전역 예외처리 dto 직렬화 불가 문제 해결 #12 * fix: Comment entity annotations 추가 및 column 수정 #8 * feat: Comment Request Dto 구현 #8 * feat: Comment Response Dto 구현 #8 * feat: Comment Mapper Class 구현 #8 * feat: Comment Service 댓글 생성 method 구현 #8 * fix: 게시글의 존재 확인 및 유효한 댓글의 확인 예외 처리 message 추가 #8 * feat: Comment Controller 댓글 생성 API 구현 #8 * Timestamped 상속체들의 softDelete 메서드 임시 구현 (#15) * feat: Comment Controller 대댓글 생성 API 구현 #8 * fix: 대댓글 생성을 위해 null 값을 바꾸기 #8 * feat: 대댓글 생성 method 구현 #8 * fix: 댓글의 존재 확인 예외 처리 message 추가 #8 * fix: Comment entity 댓글의 수정(update) method 구현 #8 * feat: 댓글의 수정 API 구현 #8 * fix: CommentMapper의 toUpdateResDto method 구현 #8 * feat: 댓글의 수정(updateComment) method 구현 #8 * fix: 댓글 작성자만 수정할 수 있도록 예외 처리 message 추가 #8 * feat: Comment Controller 댓글 삭제 API 구현 #8 * feat: Comment Service 댓글 삭제 method 구현 #8 * feat: Comment Repository methods 구현 #8 * fix: 댓글 작성자만 수정, 삭제할 수 있도록 예외 처리 message 수정 #8 * fix: Comment entity에 부모 댓글 삭제 시 모든 자식 댓글에 soft delete 적용하는 method 구현 #8 * fix: Comment Controller에 user 인증 적용 #8 * fix: Comment Service에 user 인증 적용 #8 * feat: Jwt Authorization Class 구현 #8 * fix: Security Filter Chain에 Jwt Authorization Handler 추가 #8 * fix: Null Pointer 예외 해결을 위해 JwtUtil의 ㅎgetAuthentication() method 수정 #8 * fix: UserDetailsImpl에 annotations 추가 #8 * fix: Jwt Authentication Filter에 인증 실패 method 추가 #8 * remove: .gitkeep 파일 삭제 #8 * fix: CommentReqDto와 CommentResDto가 record class로 변경 #8 * fix: MapStruct 활용 #8 --------- Co-authored-by: 34-43 <121276581+34-43@users.noreply.github.com> * 일지(DIARY) 기능 개선, 컨벤션 통일 (#27) * refactor: 컨벤션에 따른 Mapper 호출 위치 변경(dto in service layer) #23 * feat: 'Diary' 서비스 Mapper 인터페이스로 변경 및 MapStruct 의존성 추가 #23 * feat: 인증/인가 관련 연계 로직 적용 #23 * fix: Comment entity에서 'CascadeType.ALL' 삭제 #33 * fix: PostService에서 게시글과 관련된 모든 댓글의 soft delete 추가 #33 * fix: 이미 삭제된 댓글의 확인 logic 추가 #33 * fix: 이미 삭제된 댓글의 예외 처리 message 추가 #33 * 식물(PLANT) CRUD (#22) * feat:식물 생성기능 구현 중 #7 * feat:식물 생성기능 구현 중 #7 * feat:식물 생성기능 구현 #7 * feat:식물 조회기능 구현 중 #16 * feat:식물 조회기능 구현 완료 #16 * feat:식물 수정기능 구현 #17 * feat:식물 삭제기능 구현 #18 * feat:식물 조회 페이징 기능 추가 #19 * feat:식물 조회 페이징 기능 추가 #19 * Revert "feat:식물 조회 페이징 기능 추가" This reverts commit 56543c94c5342a2420d545c652f36c2f3547e4c8. * feat:식물 CRUD 피드백 #7 * 게시글(Post) - 병합 후 변경 사항 (#32) * feat: 기본 페이지 0에서 1로 변경 #31 * feat: softDelete한 댓글 조회 하지 않게 조건 추가 #31 * feat: 대댓글 없을 경우 응답 데이터에서 제외 처리 #31 * 댓글(Comment) CRUD V1 - 병합 후 변경 사항 (#34) * fix: Comment entity에서 'CascadeType.ALL' 삭제 #33 * fix: PostService에서 게시글과 관련된 모든 댓글의 soft delete 추가 #33 * fix: 이미 삭제된 댓글의 확인 logic 추가 #33 * fix: 이미 삭제된 댓글의 예외 처리 message 추가 #33 * fix: 주석 처리 #33 * 품종(Species) CRUD 간단 구현 (#37) * feat: 'Species' 엔티티 접근 및 수정 메서드 추가 #24 * feat: 'Species' 관련 Dto 추가 #24 * feat: 'Species' 관련 전역 예외 상태 코드 추가 #24 * feat: 'Species' Mapper 컴포넌트로 추가 #24 * feat: 'Species' 3-Layer 구현 #24 * feat: Page 직렬 구조 단순화 어노테이션 추가 #24 * fix: 'Species' 인증 인가 연계 추가를 포함한 문제 수정 #24 * feat: 'Species' ADMIN 권한 WhiteList 등록 #24 * fix: 'Species' 예외 코드 중복 수정 #24 * USER CRUD + 리뷰 피드백 반영 (#40) * feat:Docker 에 MYSQL8.0 이미지 추가 완료 및 컨테이너 생성 완료 * feat:Docker 연결 완료 * fix:Docker 연결 문제 해결 * feat:Github Action(CI 기능 구현) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * fix:docker 에 MYSQL 띄우는 거 성공(admin admin) #10 * feat: Spring Security 적용 및 Filter(인증) Handler(인가) & 로그인 회원가입 구현(쿠키로 JWT 넘겨줌), CustomException logError() 메서드를 추가하여 예외 생성 시 자동으로 에러 로그를 기록, mapper Repository 에 구현 refactor: jpa 대신 직접 SQL 쿼리를 작성해야 할 수도 있기 때문에 build.gradle 에 jdbc 의존성 추가 / security, jwt 의존성 추가 #18 * fix: 지원하지 않는 SignatureException 삭제 #18 * feat: 컨벤션에 맞게 MapStruct 추가 fix: null 값 오류 해결 #18 * feat: 컨벤션에 맞게 MapStruct 추가 fix: null 값 오류 해결 #18 * fix: docker-compose.yml timezone 수정, #29 * fix: Reloved Conversation #29 * fix: Conflict 해결 #29 * feat: 마이페이지(+식물, 게시글) 조회 기능 구현 #29 * feat: 회원 정보 수정 / 삭제 기능 구현 fix: signin 메서드 Cookie 생성 Controller 로 이동, 필요 없는 UserReqDto 삭제 #29 * fix: 사용되지 않는 import 문 삭제 #29 * 1차 통합 개선 사항 (사용자 지정 기본 응답 통일 등) (#30) * feat: 'Species' 엔티티 접근 및 수정 메서드 추가 #24 * feat: 'Species' 관련 Dto 추가 #24 * feat: 'Species' 관련 전역 예외 상태 코드 추가 #24 * feat: 'Species' Mapper 컴포넌트로 추가 #24 * feat: 'Species' 3-Layer 구현 #24 * feat: Page 직렬 구조 단순화 어노테이션 추가 #24 * fix: 'Species' 인증 인가 연계 추가를 포함한 문제 수정 #24 * feat: 'Species' ADMIN 권한 WhiteList 등록 #24 * fix: 'Species' 예외 코드 중복 수정 #24 * feat: 사용자 지정 기본 응답 DTO 추가 #28 * fix: CustomException Logging 레벨 변경 및 일관성 향상 #28 * fix: 'Plant' 통합 개선 #28 - 타입, 접근성, 직렬화 등 * fix: 'Post' 통합 개선 #28 - softDelete 관련 디테일 - 게시글 조회 시 id 출력 * fix: 'Comment' 통합 개선 #28 - 삭제 게시글, 부모 댓글에 대한 댓글 또는 대댓글 작성 방지 * refactor: 'CommonResDto' 전체 프로젝트 적용 #28 * delete: 불필요한 기존 Dto 일괄 삭제 #28 * refactor: 모든 dto 타입 record 로 변경 #28 * chore: redis environment 설정 #41 * feat: redis config class 구현 #41 * 게시글(Post) - 단건조회 queryDsl (#39) * feat: 게시글 전체조회 jpql 구현 #35 * feat: queryDsl 의존성, config 추가 #35 * feat: findCommentsByPostId queryDsl 적용 #35 * feat: findUsernamesByIds 쿼리 반환 타입 Object[]에서 UserProjection으로 변경 #35 * 1차 통합 개선 사항 (사용자 지정 기본 응답 통일 등) 병합 수작업 (#30) --------- Co-authored-by: 34-43 * fix: redis config class 수정 #41 * feat: 인기글 response DTO record class 구현 #41 * feat: 인기글 redis repository class 구현 #41 * fix: redis config class 수정 #41 * feat: Post Mapper에 인기글 Dto 추가 #41 * feat: 인기글 service class 구현 #41 * feat: 인기글 service 주입 #41 * feat: 게시글, 댓글의 생성과 수정 시 인기글 점수 update logic 추가 #41 * feat: 인기글 Controller class 구현 #41 * feat: 인기글 API URI 수정 #41 * 관리자 사용자 프로필 조회 기능 구현 + review 해결 + Docker에 Redis 띄우기 (#44) * fix:UserInfo 식물/게시물 조회 수정, UserService 중복되는 코드 메서드화, #29 * fix: 페이지네이션 문제 해결 #29 * fix: me/plants 조회 시 다이어리 제외, 내 식물에 대한 정보만 조회되도록 수정 #29 * fix: login -> signin, 탈퇴된 회원 로그인 불가능하게 고침, 중복되는 코드 메서드화 시킴 #42 * feat: ADMIN 사용자 정보 조회 기능 추가 #42 * feat: 비밀번호 정규표현식으로 `최소 8자리 이상, 영문 대소문자, 숫자, 특수문자가 각각 1개 이상 포함` 조건 추가 #42 * feat: docker Redis 띄우기 완료 #42 * fix: signin, delete dto에서 `@Pattern 삭제`, `PostReqDto`, `PostUpdateReqDto`, `PostListResDto` record 타입으로 다시 변경, UserService 주석 삭제 #42 * fix: record 한 칸 씩 당김 #42 * fix: 게시글 API URI 수정 #41 * fix: 인기글 API URI 수정 #41 * fix: 인기글 API URI 수정 #41 * fix: redis config 수정 #41 * 인기글 캐싱 - 주기적 점수 update의 scheduling (#46) * feat: Scheduling annotation 추가 #41 * feat: 인기글 Service에 scheduling method 추가 #41 * feat: PostRepository에 method 추가 #41 * feat: 'countCommentsByPostIds()' method 선언 및 구현 #41 * feat: 'updateAllPostScores()' method 수정 #41 * 게시글(Post) 조회수 중복 방지 (#47) * feat: 조회기록 엔티티 추가 #43 * feat: 조회기록 Repository 추가 #43 - Repository - CustomRepository,RepositoryImpl(QueryDsl) * feat: 조회기록 Dto 추가 #43 * feat: 조회기록 Mapper 추가 #43 * feat: 토큰에서 userId 추출 로직 추가 #43 * feat: 조회수 증가 로직 추가 #43 - 회원만 조회수 증가 - 하루(00:00~23:59)기준으로 조회수 1회 증가 * remove: dev브랜치 merge전 사용하던 redis config파일 삭제 #43 * feat: Redis 캐시 추가 #43 * feat: 스케줄러 추가 #43 * feat: 스케줄러 추가 및 ViewHistory 삭제 기능 구현 #43 * remove: 필요없는 파일 삭제 #43 * refactor: readPost 메서드에서 userId 매개변수를 제거하고 서비스에서 viewedAt을 LocalDate.now()로 설정 #43 * refactor: fetchCount에서 fetchFirst로 수정 #43 * feat: entityManager.clear 추가 #43 * remove: dev 병합 후 중복 어노테이션 삭제 #43 * feat: 조회이력 redis 자료구조 List에서 Hash로 변경 #43 - 조회 이력을 Redis Hash 타입으로 변경하여 효율적으로 관리 - Key: "view_history:post_id:"+postId, Field: userId, Value: viewedAt 구조로 저장 * chore: V1에 사용하는 코드 주석 처리 #43 * POST 도메인 기준 테스트 코드 작성 (#51) * test: PostService 테스트 추가 #38 * test: Post 엔티티 테스트 추가 #38 * test: PostService 테스트 추가 #38 * test: Post 엔티티 테스트 추가 #38 * rename: 테스트 환경 프로필(application-test.yml) 이동 #38 * feat: 'Post' 3-Layer 구현 및 서비스 코드 변경사항 반영 #38 * feat: 조회기록 단위 테스트 코드 작성 #49 (#52) * 1:1 private 실시간 채팅 기능 구현 (#56) * feat: WebSocket 설정 #50 * feat: 채팅 세션을 관리하는 ChatRoom entity 구현 #50 * feat: 개별 메시지를 위한 ChatMessage entity 구현 #50 * feat: Chat Message와 관련된 Repository interface와 class 구현 #50 * feat: Chat Room과 관련된 Repository interface와 class 구현 #50 * feat: Chat Room Repository에 pagination 처리 추가 #50 * feat: Chat Message Repository에 pagination 처리 추가 #50 * feat: Chat Message Service class 구현 #50 * feat: Chat Room Service class 구현 #50 * fix: chat과 관련된 예외 처리 message 추가 #50 * fix: 실시간 메사지 전송의 관리를 위해 WebSocketHandler 수정 #50 * feat: Chat message Dto class 구현 #50 * feat: Error message Dto class 구현 #50 * fix: WebSocket 연동 적용 #50 * feat: ChatController class 구현 #50 * rename: Class 이름 변경, file 옮김 #50 * feat: ChatRoomMapper class 구현 #50 * feat: ChatRoomResDto class 구현 #50 * feat: ChatMessageResDto class 구현 #50 * feat: ChatMessageMapper class 구현 #50 * fix: ChatController class 구현 #50 * feat: 채팅 단일 메시지 삭제 method 구현 #50 * feat: 채팅방 삭제 method 구현 #50 * fix: 예외 처리 message 수정 및 추가 #50 * feat: 채팅 단일 메시지 삭제 API 및 채팅방 삭제 API 추가 #50 * 소셜 로그인 기능 구현 (#54) * fix:UserInfo 식물/게시물 조회 수정, UserService 중복되는 코드 메서드화, #29 * fix: 페이지네이션 문제 해결 #29 * fix: me/plants 조회 시 다이어리 제외, 내 식물에 대한 정보만 조회되도록 수정 #29 * fix: login -> signin, 탈퇴된 회원 로그인 불가능하게 고침, 중복되는 코드 메서드화 시킴 #42 * feat: ADMIN 사용자 정보 조회 기능 추가 #42 * feat: 비밀번호 정규표현식으로 `최소 8자리 이상, 영문 대소문자, 숫자, 특수문자가 각각 1개 이상 포함` 조건 추가 #42 * feat: docker Redis 띄우기 완료 #42 * fix: signin, delete dto에서 `@Pattern 삭제`, `PostReqDto`, `PostUpdateReqDto`, `PostListResDto` record 타입으로 다시 변경, UserService 주석 삭제 #42 * fix: record 한 칸 씩 당김 #42 * feat: 소셜 로그인 기능 추가 #53 * refactor: `findUsernamesByIds`, `updateUserInfo` QueryDsl로 리팩토링 #53 * optimization: `@Transaction` 추가 및 `existsByEmail` 메서드 추가로 데이터베이스 성능 최적화 #53 * chore: localstack(local s3) environment 설정 #48 * fix: conflict 해결 #53 --------- Co-authored-by: 34-43 * 이미지 업로드 기능 구현 (#59) * chore: localstack(local s3) environment 설정 #48 * chore: localstack(local s3) environment 개선 #48 * chore: localstack(local s3) 개발환경 등록 #48 * feat: 'S3' Config & 2-Layer & Dto 구현 #48 * chore: localstack(local s3) environment 개선 및 CORS 설정 추가 #48 * fix: 'S3' 반환 주소 오류 수정 & 삭제 로직과 로깅 개선 #48 * feat: Post 도메인 'imageUrl' 컬럼, 삭제 로직 추가 #48 * chore: redis 관련 경로 .gitignore 등록 #48 * fix: PreSigned POST 요청의 status 를 202(ACCEPTED)로 변경 #48 * fix: docker-compose 종속 스크립트 셸 환경 변경 #48 * feat: 기본 CORS 설정 #48 * 회원 날씨 조회 기능 추가 (#61) * feat: weather 회원 날씨 조회 기능 추가 #57 * feat: 필요없는 종속성 삭제 #57 * 댓글과 대댓글 code의 개선 및 최적화 (#62) * fix: 대댓글 깊이 제한 logic 추가 #58 * fix: N+1 문제 최적화 logic 추가 #58 * fix: ChatController bug 수정 #58 * fix: NullPointer bug 수정 #58 * feat: GoogleJwtAuthenticationProvider -> nx, ny 추가 #63 (#64) * Feat/comment#58 (#67) * fix: 대댓글 깊이 제한 logic 추가 #58 * fix: N+1 문제 최적화 logic 추가 #58 * fix: ChatController bug 수정 #58 * fix: NullPointer bug 수정 #58 * chore(image upload): create bucket 및 set cors #58 --------- Co-authored-by: Despereaux <157133321+Despereaux-MAU@users.noreply.github.com> Co-authored-by: Minji Ji <145173900+JIMINJI1@users.noreply.github.com> Co-authored-by: Soung Likane Co-authored-by: arago07 --- .gitattributes | 3 + .github/workflows/runtest.yml | 50 ++++ .gitignore | 42 +++ aws/01-create-bucket.sh | 13 + aws/02-set-cors.sh | 18 ++ build.gradle | 102 +++++++ docker-compose.yml | 79 ++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle | 1 + .../botanify/BotanifyApplication.java | 20 ++ .../botanify/common/config/AppConfig.java | 28 ++ .../common/config/QueryDslConfig.java | 17 ++ .../common/config/SecurityConfig.java | 76 ++++++ .../botanify/common/config/WebConfig.java | 22 ++ .../common/config/redis/RedisConfig.java | 43 +++ .../botanify/common/config/s3/S3Config.java | 56 ++++ .../websocket/ChatWebSocketHandler.java | 121 +++++++++ .../config/websocket/WebSocketConfig.java | 22 ++ .../botanify/common/dto/res/CommonResDto.java | 26 ++ .../common/dto/res/ExceptionGroupResDto.java | 20 ++ .../common/dto/res/ExceptionResDto.java | 13 + .../botanify/common/entity/Timestamped.java | 37 +++ .../common/exception/CustomException.java | 20 ++ .../common/exception/ExceptionStatus.java | 81 ++++++ .../exception/GlobalExceptionHandler.java | 56 ++++ .../filter/GoogleJwtAuthenticationFilter.java | 83 ++++++ .../filter/JwtAuthenticationFilter.java | 81 ++++++ .../common/filter/JwtAuthorizationFilter.java | 57 ++++ .../handler/JwtAuthorizationHandler.java | 38 +++ .../common/security/UserDetailsImpl.java | 40 +++ .../util/GoogleJwtAuthenticationProvider.java | 68 +++++ .../common/util/GoogleJwtValidator.java | 36 +++ .../botanify/common/util/JwtUtil.java | 141 ++++++++++ .../admin/controller/AdminController.java | 41 +++ .../domain/admin/service/AdminService.java | 92 +++++++ .../auth/controller/AuthController.java | 52 ++++ .../domain/auth/dto/req/SigninReqDto.java | 12 + .../domain/auth/dto/req/SignupReqDto.java | 33 +++ .../domain/auth/service/AuthService.java | 93 +++++++ .../chat/controller/ChatController.java | 88 ++++++ .../chat/dto/req/ChatMessageReqDto.java | 12 + .../chat/dto/res/ChatMessageResDto.java | 15 ++ .../domain/chat/dto/res/ChatRoomResDto.java | 10 + .../domain/chat/dto/res/ErrorMessageDto.java | 5 + .../domain/chat/entity/ChatMessage.java | 37 +++ .../botanify/domain/chat/entity/ChatRoom.java | 31 +++ .../domain/chat/mapper/ChatMessageMapper.java | 22 ++ .../domain/chat/mapper/ChatRoomMapper.java | 21 ++ .../ChatMessageCustomRepository.java | 16 ++ .../ChatMessageCustomRepositoryImpl.java | 79 ++++++ .../repository/ChatMessageRepository.java | 9 + .../repository/ChatRoomCustomRepository.java | 15 ++ .../ChatRoomCustomRepositoryImpl.java | 82 ++++++ .../chat/repository/ChatRoomRepository.java | 9 + .../chat/service/ChatMessageService.java | 60 +++++ .../domain/chat/service/ChatRoomService.java | 71 +++++ .../controller/CommentController.java | 73 +++++ .../controller/PopularPostController.java | 28 ++ .../community/controller/PostController.java | 76 ++++++ .../community/dto/req/CommentReqDto.java | 10 + .../domain/community/dto/req/PostReqDto.java | 17 ++ .../community/dto/req/PostUpdateReqDto.java | 12 + .../community/dto/req/ViewHistoryDto.java | 10 + .../community/dto/res/CommentTempDto.java | 22 ++ .../community/dto/res/PopularPostResDto.java | 13 + .../community/dto/res/PostListResDto.java | 15 ++ .../dto/res/PostWithCommentResDto.java | 15 ++ .../domain/community/entity/Comment.java | 66 +++++ .../domain/community/entity/Post.java | 54 ++++ .../domain/community/entity/ViewHistory.java | 26 ++ .../botanify/domain/community/enums/.gitkeep | 0 .../community/mapper/CommentMapper.java | 30 +++ .../domain/community/mapper/PostMapper.java | 62 +++++ .../community/mapper/ViewHistoryMapper.java | 12 + .../repository/CommentCustomRepository.java | 14 + .../CommentCustomRepositoryImpl.java | 77 ++++++ .../repository/CommentRepository.java | 7 + .../PopularPostRedisRepository.java | 70 +++++ .../community/repository/PostRepository.java | 15 ++ .../ViewHistoryCustomRepository.java | 7 + .../ViewHistoryCustomRepositoryImpl.java | 27 ++ .../repository/ViewHistoryRepository.java | 14 + .../community/service/CommentService.java | 131 +++++++++ .../community/service/PopularPostService.java | 88 ++++++ .../domain/community/service/PostService.java | 222 +++++++++++++++ .../community/service/SchedulerService.java | 24 ++ .../service/ViewHistoryRedisService.java | 55 ++++ .../garden/controller/DiaryController.java | 60 +++++ .../garden/controller/PlantController.java | 51 ++++ .../garden/controller/SpeciesController.java | 64 +++++ .../domain/garden/dto/req/DiaryReqDto.java | 13 + .../domain/garden/dto/req/PlantReqDto.java | 20 ++ .../domain/garden/dto/req/SpeciesReqDto.java | 13 + .../domain/garden/dto/res/DiaryResDto.java | 11 + .../domain/garden/dto/res/PlantResDto.java | 17 ++ .../domain/garden/dto/res/SpeciesResDto.java | 7 + .../botanify/domain/garden/entity/Diary.java | 39 +++ .../botanify/domain/garden/entity/Plant.java | 44 +++ .../domain/garden/entity/Species.java | 30 +++ .../botanify/domain/garden/enums/.gitkeep | 0 .../domain/garden/mapper/DiaryMapper.java | 44 +++ .../domain/garden/mapper/PlantMapper.java | 16 ++ .../domain/garden/mapper/SpeciesMapper.java | 42 +++ .../garden/repository/DiaryRepository.java | 18 ++ .../garden/repository/PlantRepository.java | 23 ++ .../garden/repository/SpeciesRepository.java | 20 ++ .../domain/garden/service/DiaryService.java | 110 ++++++++ .../domain/garden/service/PlantService.java | 82 ++++++ .../domain/garden/service/SpeciesService.java | 75 ++++++ .../domain/s3/controller/S3Controller.java | 32 +++ .../domain/s3/dto/req/ImageUploadReqDto.java | 5 + .../domain/s3/dto/res/ImageUrlResDto.java | 6 + .../botanify/domain/s3/service/S3Service.java | 104 ++++++++ .../user/controller/UserController.java | 75 ++++++ .../domain/user/dto/req/UserDeleteReqDto.java | 8 + .../domain/user/dto/req/UserUpdateReqDto.java | 22 ++ .../domain/user/dto/res/UserPlantsResDto.java | 8 + .../domain/user/dto/res/UserPostsResDto.java | 8 + .../domain/user/dto/res/UserResDto.java | 10 + .../botanify/domain/user/entity/User.java | 68 +++++ .../botanify/domain/user/enums/UserRole.java | 5 + .../domain/user/mapper/UserMapper.java | 10 + .../user/projection/UserProjection.java | 6 + .../user/repository/UserCustomRepository.java | 10 + .../repository/UserCustomRepositoryImpl.java | 41 +++ .../user/repository/UserRepository.java | 12 + .../user/service/UserDetailsServiceImpl.java | 29 ++ .../domain/user/service/UserService.java | 131 +++++++++ .../weather/controller/WeatherController.java | 24 ++ .../weather/service/LocationService.java | 95 +++++++ .../weather/service/WeatherService.java | 113 ++++++++ src/main/resources/application.yml | 33 +++ .../botanify/BotanifyApplicationTests.java | 13 + .../ViewHistory/ViewHistorySchedulerTest.java | 96 +++++++ .../controller/PostControllerTest.java | 172 ++++++++++++ .../domain/community/entity/PostTest.java | 61 +++++ .../repository/PostRepositoryTest.java | 81 ++++++ .../community/service/PostServiceTest.java | 197 ++++++++++++++ src/test/resources/application-test.yml | 19 ++ 142 files changed, 6353 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/runtest.yml create mode 100644 .gitignore create mode 100755 aws/01-create-bucket.sh create mode 100755 aws/02-set-cors.sh create mode 100644 build.gradle create mode 100644 docker-compose.yml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/sounganization/botanify/BotanifyApplication.java create mode 100644 src/main/java/com/sounganization/botanify/common/config/AppConfig.java create mode 100644 src/main/java/com/sounganization/botanify/common/config/QueryDslConfig.java create mode 100644 src/main/java/com/sounganization/botanify/common/config/SecurityConfig.java create mode 100644 src/main/java/com/sounganization/botanify/common/config/WebConfig.java create mode 100644 src/main/java/com/sounganization/botanify/common/config/redis/RedisConfig.java create mode 100644 src/main/java/com/sounganization/botanify/common/config/s3/S3Config.java create mode 100644 src/main/java/com/sounganization/botanify/common/config/websocket/ChatWebSocketHandler.java create mode 100644 src/main/java/com/sounganization/botanify/common/config/websocket/WebSocketConfig.java create mode 100644 src/main/java/com/sounganization/botanify/common/dto/res/CommonResDto.java create mode 100644 src/main/java/com/sounganization/botanify/common/dto/res/ExceptionGroupResDto.java create mode 100644 src/main/java/com/sounganization/botanify/common/dto/res/ExceptionResDto.java create mode 100644 src/main/java/com/sounganization/botanify/common/entity/Timestamped.java create mode 100644 src/main/java/com/sounganization/botanify/common/exception/CustomException.java create mode 100644 src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java create mode 100644 src/main/java/com/sounganization/botanify/common/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/sounganization/botanify/common/filter/GoogleJwtAuthenticationFilter.java create mode 100644 src/main/java/com/sounganization/botanify/common/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/sounganization/botanify/common/filter/JwtAuthorizationFilter.java create mode 100644 src/main/java/com/sounganization/botanify/common/handler/JwtAuthorizationHandler.java create mode 100644 src/main/java/com/sounganization/botanify/common/security/UserDetailsImpl.java create mode 100644 src/main/java/com/sounganization/botanify/common/util/GoogleJwtAuthenticationProvider.java create mode 100644 src/main/java/com/sounganization/botanify/common/util/GoogleJwtValidator.java create mode 100644 src/main/java/com/sounganization/botanify/common/util/JwtUtil.java create mode 100644 src/main/java/com/sounganization/botanify/domain/admin/controller/AdminController.java create mode 100644 src/main/java/com/sounganization/botanify/domain/admin/service/AdminService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/auth/controller/AuthController.java create mode 100644 src/main/java/com/sounganization/botanify/domain/auth/dto/req/SigninReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/auth/dto/req/SignupReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/auth/service/AuthService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/controller/ChatController.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/dto/req/ChatMessageReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/dto/res/ChatMessageResDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/dto/res/ChatRoomResDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/dto/res/ErrorMessageDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/entity/ChatMessage.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/entity/ChatRoom.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/mapper/ChatMessageMapper.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/mapper/ChatRoomMapper.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepositoryImpl.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepositoryImpl.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/service/ChatMessageService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/chat/service/ChatRoomService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/controller/CommentController.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/controller/PopularPostController.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/controller/PostController.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/dto/req/CommentReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/dto/req/PostReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/dto/req/PostUpdateReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/dto/req/ViewHistoryDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/dto/res/CommentTempDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/dto/res/PopularPostResDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/dto/res/PostListResDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/dto/res/PostWithCommentResDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/entity/Comment.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/entity/Post.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/entity/ViewHistory.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/enums/.gitkeep create mode 100644 src/main/java/com/sounganization/botanify/domain/community/mapper/CommentMapper.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/mapper/PostMapper.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/mapper/ViewHistoryMapper.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/repository/CommentCustomRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/repository/CommentCustomRepositoryImpl.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/repository/CommentRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/repository/PopularPostRedisRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/repository/PostRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/repository/ViewHistoryCustomRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/repository/ViewHistoryCustomRepositoryImpl.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/repository/ViewHistoryRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/service/CommentService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/service/PopularPostService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/service/PostService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/service/SchedulerService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/community/service/ViewHistoryRedisService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/controller/DiaryController.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/controller/PlantController.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/controller/SpeciesController.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/dto/req/DiaryReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/dto/req/SpeciesReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/dto/res/DiaryResDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/dto/res/PlantResDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/dto/res/SpeciesResDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/entity/Diary.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/entity/Plant.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/entity/Species.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/enums/.gitkeep create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/mapper/DiaryMapper.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/mapper/PlantMapper.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/mapper/SpeciesMapper.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/repository/DiaryRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/repository/PlantRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/repository/SpeciesRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/service/DiaryService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/service/PlantService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/service/SpeciesService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/s3/controller/S3Controller.java create mode 100644 src/main/java/com/sounganization/botanify/domain/s3/dto/req/ImageUploadReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/s3/dto/res/ImageUrlResDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/s3/service/S3Service.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/controller/UserController.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/dto/req/UserDeleteReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/dto/req/UserUpdateReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/dto/res/UserPlantsResDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/dto/res/UserPostsResDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/dto/res/UserResDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/entity/User.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/enums/UserRole.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/mapper/UserMapper.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/projection/UserProjection.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/repository/UserCustomRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/repository/UserCustomRepositoryImpl.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/repository/UserRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/service/UserDetailsServiceImpl.java create mode 100644 src/main/java/com/sounganization/botanify/domain/user/service/UserService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/weather/controller/WeatherController.java create mode 100644 src/main/java/com/sounganization/botanify/domain/weather/service/LocationService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/weather/service/WeatherService.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/com/sounganization/botanify/BotanifyApplicationTests.java create mode 100644 src/test/java/com/sounganization/botanify/ViewHistory/ViewHistorySchedulerTest.java create mode 100644 src/test/java/com/sounganization/botanify/domain/community/controller/PostControllerTest.java create mode 100644 src/test/java/com/sounganization/botanify/domain/community/entity/PostTest.java create mode 100644 src/test/java/com/sounganization/botanify/domain/community/repository/PostRepositoryTest.java create mode 100644 src/test/java/com/sounganization/botanify/domain/community/service/PostServiceTest.java create mode 100644 src/test/resources/application-test.yml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/workflows/runtest.yml b/.github/workflows/runtest.yml new file mode 100644 index 0000000..ff07937 --- /dev/null +++ b/.github/workflows/runtest.yml @@ -0,0 +1,50 @@ +name: Run Test + +# Event Trigger 특정 액션 (Push, Pull_Request)등이 명시한 Branch에서 일어나면 동작을 수행한다. +on: + push: + # 배열로 여러 브랜치를 넣을 수 있다. + branches: + - dev + - feat/* + - fix/* + - refactor/* + - style/* + - comment/* + - rename/* + - remove/* + - docs/* + - test/* + - chore/* + pull_request: + branches: + - dev + - main + # 실제 어떤 작업을 실행할지에 대한 명시 +jobs: + build: + # 스크립트 실행 환경 (OS) + # 배열로 선언시 개수 만큼 반복해서 실행한다. ( 예제 : 1번 실행) + runs-on: ubuntu-latest + + # 실제 실행 스크립트 + steps: + # uses는 github actions에서 제공하는 플러그인을 실행.(git checkout 실행) + - name: checkout + uses: actions/checkout@v4 + + # with은 plugin 파라미터 입니다. (java 17버전 셋업) + - name: java setup + uses: actions/setup-java@v2 + with: + distribution: 'adopt' # See 'Supported distributions' for available options + java-version: '17' + + - name: make executable gradlew + run: chmod +x ./gradlew + +# # run 은 사용자 지정 스크립트 실행 +# - name: run unittest +# if: github.ref == 'refs/heads/main' # 일단 main 브랜치에서만 실행(추후 수정 필요) +# run: | +# ./gradlew clean test \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..168aa43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Custom ### +src/main/resources/application-dev.yml +/data/db.mv.db +/db diff --git a/aws/01-create-bucket.sh b/aws/01-create-bucket.sh new file mode 100755 index 0000000..5fc4460 --- /dev/null +++ b/aws/01-create-bucket.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh + +# docker-compose 설정에 의해, localstack 이미지의 시작과 함께 실행되는 스크립트. +# 지정된 명칭의 버킷을 생성한다. + +# S3 서비스가 준비될 때까지 기다리기 +until awslocal s3 ls > /dev/null 2>&1; do + echo "Waiting for S3 service to be ready..." + sleep 2 +done + +# 버킷 생성 +awslocal s3 mb s3://botanify-backend-bucket \ No newline at end of file diff --git a/aws/02-set-cors.sh b/aws/02-set-cors.sh new file mode 100755 index 0000000..ca68886 --- /dev/null +++ b/aws/02-set-cors.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env sh + +# docker-compose 설정에 의해, localstack 이미지의 시작과 함께 실행되는 스크립트. +# 버킷 생성 스크립트 동작 후에 동작하여야 함. + +# LocalStack S3에 CORS 정책 적용 +BUCKET_NAME="botanify-backend-bucket" +CORS_POLICY='{ + "CORSRules": [ + { + "AllowedOrigins": ["https://example.com"], + "AllowedMethods": ["GET"], + "AllowedHeaders": ["*"] + } + ] +}' + +awslocal s3api put-bucket-cors --bucket $BUCKET_NAME --cors-configuration "$CORS_POLICY" \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..01dab5f --- /dev/null +++ b/build.gradle @@ -0,0 +1,102 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.0' + id 'io.spring.dependency-management' version '1.1.6' +} + +group = 'com.sounganization' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + // Spring + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + + // Bcrypt + implementation 'at.favre.lib:bcrypt:0.10.2' + + // JWT + compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + // DB + runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // S3 + implementation 'software.amazon.awssdk:s3:2.29.35' + implementation 'cloud.localstack:localstack-utils:0.2.23' + + // Build + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Test + runtimeOnly 'com.h2database:h2' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // MapStruct + implementation 'org.mapstruct:mapstruct:1.5.3.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final' + annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' + + // 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" + + // OAuth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'com.auth0:java-jwt:3.18.2' + implementation 'com.auth0:jwks-rsa:0.21.1' + implementation 'org.springframework.security:spring-security-oauth2-jose' + + //WebSocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' +} + +tasks.named('test') { + useJUnitPlatform() +} + +// querydsl 빌드옵션 +String generated = "${buildDir}/generated/querydsl" +// querydsl QClass 파일 생성 위치 +tasks.withType(JavaCompile) { + options.getGeneratedSourceOutputDirectory().set(file(generated)) +} +// java source set 에 querydsl QClass 위치 추가 +sourceSets { + main.java.srcDirs += [ generated ] +} +// gradle clean 시에 QClass 디렉토리 삭제 +clean { + delete file(generated) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..361c4d1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,79 @@ +services: + mysql: + image: mysql:8.0 + container_name: botanify-mysql + environment: + MYSQL_ROOT_PASSWORD: admin + MYSQL_DATABASE: botanify + MYSQL_USER: admin + MYSQL_PASSWORD: admin + TZ: Asia/Seoul + ports: + - "3306:3306" + volumes: + - db-data:/var/lib/mysql + networks: + - app-network + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] + timeout: 20s + retries: 10 + restart: always + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + + redis-master: + image: redis:7.0 + hostname: redis-master + container_name: redis-master + ports: + - "6379:6379" + volumes: + - ./db/redis/data:/data + networks: + - app-network + environment: + TZ: Asia/Seoul + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 10s + timeout: 5s + retries: 5 + restart: always + command: redis-server --save 20 1 --loglevel warning + + localstack: + image: localstack/localstack:latest + container_name: localstack + ports: + - "4566:4566" + environment: + SERVICES: s3 + EDGE_PORT: 4566 + DEBUG: 1 + DATA_DIR: /var/lib/localstack/data + AWS_DEFAULT_REGION: ap-northeast-2 + AWS_ACCESS_KEY_ID: admin + AWS_SECRET_ACCESS_KEY: admin + PERSISTENCE: 1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - localstack-data:/var/lib/localstack + - ./aws:/etc/localstack/init/ready.d + networks: + - app-network + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:4566/_localstack/health" ] + interval: 10s + timeout: 5s + retries: 5 + +networks: + app-network: + driver: bridge + +volumes: + db-data: + redis-data: + localstack-data: \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..57f7f7d --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'Botanify' diff --git a/src/main/java/com/sounganization/botanify/BotanifyApplication.java b/src/main/java/com/sounganization/botanify/BotanifyApplication.java new file mode 100644 index 0000000..418ec3c --- /dev/null +++ b/src/main/java/com/sounganization/botanify/BotanifyApplication.java @@ -0,0 +1,20 @@ +package com.sounganization.botanify; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.web.config.EnableSpringDataWebSupport; +import org.springframework.scheduling.annotation.EnableScheduling; + + +@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) +@SpringBootApplication +@EnableJpaAuditing +@EnableScheduling +public class BotanifyApplication { + + public static void main(String[] args) { + SpringApplication.run(BotanifyApplication.class, args); + } + +} diff --git a/src/main/java/com/sounganization/botanify/common/config/AppConfig.java b/src/main/java/com/sounganization/botanify/common/config/AppConfig.java new file mode 100644 index 0000000..847593c --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/AppConfig.java @@ -0,0 +1,28 @@ +package com.sounganization.botanify.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.DefaultUriBuilderFactory; + +@Configuration +public class AppConfig { + + @Bean(name = "weatherRestTemplate") + public RestTemplate weatherRestTemplate() { + RestTemplate restTemplate = new RestTemplate(); + DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(); + factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY); // 쿼리 파라미터만 인코딩 + restTemplate.setUriTemplateHandler(factory); + return restTemplate; + } + + @Bean(name = "locationRestTemplate") + public RestTemplate locationRestTemplate() { + RestTemplate restTemplate = new RestTemplate(); + DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(); + factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.URI_COMPONENT); + restTemplate.setUriTemplateHandler(factory); + return restTemplate; + } +} diff --git a/src/main/java/com/sounganization/botanify/common/config/QueryDslConfig.java b/src/main/java/com/sounganization/botanify/common/config/QueryDslConfig.java new file mode 100644 index 0000000..36d49c7 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/QueryDslConfig.java @@ -0,0 +1,17 @@ +package com.sounganization.botanify.common.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/sounganization/botanify/common/config/SecurityConfig.java b/src/main/java/com/sounganization/botanify/common/config/SecurityConfig.java new file mode 100644 index 0000000..9a531b1 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/SecurityConfig.java @@ -0,0 +1,76 @@ +package com.sounganization.botanify.common.config; + +import com.sounganization.botanify.common.filter.GoogleJwtAuthenticationFilter; +import com.sounganization.botanify.common.filter.JwtAuthorizationFilter; +import com.sounganization.botanify.common.handler.JwtAuthorizationHandler; +import com.sounganization.botanify.common.util.JwtUtil; +import com.sounganization.botanify.domain.user.service.UserDetailsServiceImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final UserDetailsServiceImpl userDetailsService; + private final JwtAuthorizationHandler jwtAuthorizationHandler; + private final JwtUtil jwtUtil; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/v1/auth/**", + "/ws/chat", + "/ws/chat/**", + "/ws/**" + ).permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/posts", "/api/v1/posts/{postId}").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/species", "/api/v1/species/{speciesId}").permitAll() + .requestMatchers("/api/v1/admin/**").hasAuthority("ADMIN") + .anyRequest().authenticated() + ) + .addFilterBefore(new GoogleJwtAuthenticationFilter( + "/api/v1/auth/signin/google", authenticationManager(http), jwtUtil), + UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new JwtAuthorizationFilter( + jwtAuthorizationHandler), UsernamePasswordAuthenticationFilter.class); + + http.cors(cors -> cors.disable()); + http.headers(headers -> + headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable) + ); + + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception { + AuthenticationManagerBuilder authenticationManagerBuilder = + http.getSharedObject(AuthenticationManagerBuilder.class); + authenticationManagerBuilder.userDetailsService(userDetailsService) + .passwordEncoder(passwordEncoder()); + return authenticationManagerBuilder.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/config/WebConfig.java b/src/main/java/com/sounganization/botanify/common/config/WebConfig.java new file mode 100644 index 0000000..1e5766b --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/WebConfig.java @@ -0,0 +1,22 @@ +package com.sounganization.botanify.common.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) { + String nextOrigin = "http://localhost:3000"; + + registry.addMapping("/**") + .allowedOrigins(nextOrigin) + .allowedMethods("GET", "POST", "PUT", "DELETE") + .allowedHeaders("*") + .exposedHeaders("Custom-Header") + .allowCredentials(true) + .maxAge(3600); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/config/redis/RedisConfig.java b/src/main/java/com/sounganization/botanify/common/config/redis/RedisConfig.java new file mode 100644 index 0000000..fdfa0b6 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/redis/RedisConfig.java @@ -0,0 +1,43 @@ +package com.sounganization.botanify.common.config.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.redis.master.host}") + private String host; + + @Value("${spring.redis.master.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + // Serializer 설정 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + + // Transaction support 활성화 + redisTemplate.setEnableTransactionSupport(true); + redisTemplate.afterPropertiesSet(); + + return redisTemplate; + } +} diff --git a/src/main/java/com/sounganization/botanify/common/config/s3/S3Config.java b/src/main/java/com/sounganization/botanify/common/config/s3/S3Config.java new file mode 100644 index 0000000..34c2a4d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/s3/S3Config.java @@ -0,0 +1,56 @@ +package com.sounganization.botanify.common.config.s3; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.context.annotation.RequestScope; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +@Configuration +public class S3Config { + @Value("${aws.s3.endpoint}") private String endpoint; + @Value("${aws.access-key}") private String accessKey; + @Value("${aws.secret-key}") private String secretKey; + + @Bean + public S3Client s3Client() { + AwsCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + + S3Configuration configuration = S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build(); + + return S3Client.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .endpointOverride(URI.create(endpoint)) + .serviceConfiguration(configuration) + .build(); + } + + @Bean + @RequestScope + public S3Presigner s3Presigner(S3Client s3Client) { + AwsCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + + S3Configuration configuration = S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build(); + + return S3Presigner.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .endpointOverride(URI.create(endpoint)) + .serviceConfiguration(configuration) + .build(); + } + +} diff --git a/src/main/java/com/sounganization/botanify/common/config/websocket/ChatWebSocketHandler.java b/src/main/java/com/sounganization/botanify/common/config/websocket/ChatWebSocketHandler.java new file mode 100644 index 0000000..a5333b2 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/websocket/ChatWebSocketHandler.java @@ -0,0 +1,121 @@ +package com.sounganization.botanify.common.config.websocket; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sounganization.botanify.domain.chat.dto.req.ChatMessageReqDto; +import com.sounganization.botanify.domain.chat.dto.res.ErrorMessageDto; +import com.sounganization.botanify.domain.chat.entity.ChatMessage; +import com.sounganization.botanify.domain.chat.service.ChatMessageService; +import com.sounganization.botanify.domain.chat.service.ChatRoomService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ChatWebSocketHandler extends TextWebSocketHandler { + + private final ObjectMapper objectMapper; + private final ChatMessageService chatMessageService; + private final ChatRoomService chatRoomService; + + private final Map> chatRoomSessions = new ConcurrentHashMap<>(); + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + log.info("새로운 WebSocket 연결이 열렸습니다. 세션 ID: {}", session.getId()); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + String payload = message.getPayload(); + ChatMessageReqDto chatMessage = objectMapper.readValue(payload, ChatMessageReqDto.class); + + Long roomId = chatMessage.roomId(); + Long userId = chatMessage.senderId(); + + try { + switch (chatMessage.type()) { + case ENTER: + handleEnterMessage(session, roomId, userId); + break; + case TALK: + handleChatMessage(session, chatMessage); + break; + case LEAVE: + handleLeaveMessage(session, roomId, userId); + break; + } + } catch (Exception e) { + handleError(session, e); + } + } + + private void handleEnterMessage(WebSocketSession session, Long roomId, Long userId) { + + chatRoomService.getChatRoom(roomId, userId); + + chatRoomSessions.computeIfAbsent(roomId, k -> new ConcurrentHashMap<>()) + .put(userId, session); + + log.info("사용자 {}가 채팅방 {}에 입장했습니다.", userId, roomId); + } + + private void handleChatMessage(WebSocketSession session, ChatMessageReqDto chatMessage) throws IOException { + + ChatMessage savedMessage = chatMessageService.saveMessage( + chatMessage.roomId(), + chatMessage.senderId(), + chatMessage.content() + ); + + String messageJson = objectMapper.writeValueAsString(chatMessage); + broadcastMessage(savedMessage.getChatRoom().getId(), new TextMessage(messageJson)); + } + + private void handleLeaveMessage(WebSocketSession session, Long roomId, Long userId) { + + if (chatRoomSessions.containsKey(roomId)) { + chatRoomSessions.get(roomId).remove(userId); + if (chatRoomSessions.get(roomId).isEmpty()) { + chatRoomSessions.remove(roomId); + } + } + + log.info("사용자 {}가 채팅방 {}에서 퇴장했습니다.", userId, roomId); + } + + private void broadcastMessage(Long roomId, TextMessage message) { + Map roomSessions = chatRoomSessions.get(roomId); + if (roomSessions != null) { + roomSessions.values().forEach(session -> { + try { + if (session.isOpen()) { + session.sendMessage(message); + } + } catch (IOException e) { + log.error("메시지 전송 중 오류 발생: {}", e.getMessage()); + } + }); + } + } + + private void handleError(WebSocketSession session, Exception e) throws IOException { + log.error("WebSocket 오류 발생: {}", e.getMessage()); + ErrorMessageDto errorMessage = new ErrorMessageDto("오류가 발생했습니다: " + e.getMessage()); + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(errorMessage))); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + log.info("WebSocket 연결이 닫혔습니다. 세션 ID: {}", session.getId()); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/config/websocket/WebSocketConfig.java b/src/main/java/com/sounganization/botanify/common/config/websocket/WebSocketConfig.java new file mode 100644 index 0000000..71c9c4d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/websocket/WebSocketConfig.java @@ -0,0 +1,22 @@ +package com.sounganization.botanify.common.config.websocket; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +@Configuration +@EnableWebSocket +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketConfigurer { + + private final ChatWebSocketHandler chatWebSocketHandler; + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(chatWebSocketHandler, "/ws/chat") + .setAllowedOrigins("*") + .setAllowedOriginPatterns("*"); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/dto/res/CommonResDto.java b/src/main/java/com/sounganization/botanify/common/dto/res/CommonResDto.java new file mode 100644 index 0000000..74febdf --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/dto/res/CommonResDto.java @@ -0,0 +1,26 @@ +package com.sounganization.botanify.common.dto.res; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import org.springframework.http.HttpStatus; + +public record CommonResDto( + Integer status, + String message, + @JsonInclude(JsonInclude.Include.NON_NULL) + Long id, + @JsonIgnore + String token +) { + public CommonResDto(HttpStatus httpStatus, String message, Long id) { + this(httpStatus.value(), message, id, null); + } + + public CommonResDto(HttpStatus httpStatus, String message) { + this(httpStatus.value(), message, null, null); + } + + public CommonResDto(HttpStatus httpStatus, String message, String token) { + this(httpStatus.value(), message, null, token); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/dto/res/ExceptionGroupResDto.java b/src/main/java/com/sounganization/botanify/common/dto/res/ExceptionGroupResDto.java new file mode 100644 index 0000000..770363d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/dto/res/ExceptionGroupResDto.java @@ -0,0 +1,20 @@ +package com.sounganization.botanify.common.dto.res; + +import org.springframework.http.HttpStatus; + +import java.util.HashMap; +import java.util.Map; + +public record ExceptionGroupResDto( + Integer status, + Map cases +) { + public ExceptionGroupResDto(HttpStatus status) { + this(status.value(), new HashMap<>()); + } + + public void addCase(String key, String value) { + cases.put(key, value); + } + +} diff --git a/src/main/java/com/sounganization/botanify/common/dto/res/ExceptionResDto.java b/src/main/java/com/sounganization/botanify/common/dto/res/ExceptionResDto.java new file mode 100644 index 0000000..15d5ab9 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/dto/res/ExceptionResDto.java @@ -0,0 +1,13 @@ +package com.sounganization.botanify.common.dto.res; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public record ExceptionResDto( + Integer status, + String message +) { + public static ResponseEntity toResponseEntityWith(HttpStatus status, String message) { + return new ResponseEntity<>(new ExceptionResDto(status.value(), message), status); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/entity/Timestamped.java b/src/main/java/com/sounganization/botanify/common/entity/Timestamped.java new file mode 100644 index 0000000..bedadba --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/entity/Timestamped.java @@ -0,0 +1,37 @@ +package com.sounganization.botanify.common.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class Timestamped { + + @CreatedDate + @Column(updatable = false) + @Temporal(TemporalType.TIMESTAMP) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column + @Temporal(TemporalType.TIMESTAMP) + private LocalDateTime updatedAt; + + @Column + @Temporal(TemporalType.TIMESTAMP) + private LocalDateTime deletedAt; + + @Column + private Boolean deletedYn = Boolean.FALSE; + + public void softDelete() { + deletedAt = LocalDateTime.now(); + deletedYn = Boolean.TRUE; + } +} diff --git a/src/main/java/com/sounganization/botanify/common/exception/CustomException.java b/src/main/java/com/sounganization/botanify/common/exception/CustomException.java new file mode 100644 index 0000000..ccacd45 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/exception/CustomException.java @@ -0,0 +1,20 @@ +package com.sounganization.botanify.common.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ExceptionStatus status; + + public CustomException(ExceptionStatus status) { + super(status.getMessage()); + this.status = status; + } + + public CustomException(ExceptionStatus status, Throwable cause) { + super(status.getMessage(), cause); + this.status = status; + } + +} diff --git a/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java b/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java new file mode 100644 index 0000000..85a168f --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java @@ -0,0 +1,81 @@ +package com.sounganization.botanify.common.exception; + +import com.sounganization.botanify.common.dto.res.ExceptionResDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@Getter +@AllArgsConstructor +public enum ExceptionStatus { + // common + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에서 알 수 없는 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + BODY_NOT_FOUND(HttpStatus.BAD_REQUEST, "요청 본문을 찾을 수 없습니다."), + UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + + // token + TOKEN_NOT_PROVIDED(HttpStatus.UNAUTHORIZED, "토큰이 제공되지 않았습니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다."), + + // auth + DUPLICATED_EMAIL(HttpStatus.CONFLICT, "이미 존재하는 이메일입니다."), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 일치하지 않습니다."), + PASSWORDS_DO_NOT_MATCH(HttpStatus.BAD_REQUEST, "비밀번호와 비밀번호 확인이 일치하지 않습니다."), + INVALID_ROLE(HttpStatus.UNAUTHORIZED, "유효하지 않은 사용자 권한입니다."), + LOGIN_REQUIRED(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."), + ACCOUNT_DELETED(HttpStatus.FORBIDDEN, "탈퇴된 사용자입니다."), + USER_DETAILS_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자 정보를 찾을 수 없습니다."), + + // user + DELETED_USER(HttpStatus.FORBIDDEN, "탈퇴된 사용자입니다."), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."), + INVALID_UPDATE_REQUEST(HttpStatus.UNAUTHORIZED, "수정할 정보가 없습니다."), + + // plant + PLANT_NOT_FOUND(HttpStatus.NOT_FOUND, "식물을 찾을 수 없습니다."), + PLANT_NOT_OWNED(HttpStatus.UNAUTHORIZED, "식물의 주인이 아닙니다."), + + // species + SPECIES_NOT_FOUND(HttpStatus.NOT_FOUND, "품종을 찾을 수 없습니다."), + + // diary + DIARY_NOT_FOUND(HttpStatus.NOT_FOUND, "성장 일지를 찾을 수 없습니다."), + DIARY_NOT_OWNED(HttpStatus.UNAUTHORIZED, "성장 일지의 주인이 아닙니다."), + + // post + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 게시글이 존재하지 않습니다."), + POST_ALREADY_DELETED(HttpStatus.CONFLICT, "이미 삭제된 게시글입니다."), + UNAUTHORIZED_POST_ACCESS(HttpStatus.UNAUTHORIZED, "이 게시글에 대한 권한이 없습니다."), + + // comment + INVALID_COMMENT_CONTENT(HttpStatus.BAD_REQUEST, "댓글 내용을 입력해주세요."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다."), + COMMENT_NOT_OWNED(HttpStatus.FORBIDDEN, "댓글 작성자만 댓글을 수정하거나 삭제할 수 있습니다."), + COMMENT_ALREADY_DELETED(HttpStatus.CONFLICT, "이미 삭제된 댓글입니다."), + MAX_COMMENT_DEPTH_EXCEEDED(HttpStatus.BAD_REQUEST, "댓글의 최대 깊이를 초과하였습니다. 직접 댓글을 작성해주세요."), + + // chat + CHAT_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "채팅방을 찾을 수 없습니다."), + UNAUTHORIZED_CHAT_ACCESS(HttpStatus.FORBIDDEN, "채팅방에 접근 권한이 없습니다."), + CHAT_MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "메시지를 찾을 수 없습니다."), + MESSAGE_NOT_OWNED(HttpStatus.FORBIDDEN, "메시지 작성자만 삭제할 수 있습니다."), + NOT_CHAT_ROOM_PARTICIPANT(HttpStatus.FORBIDDEN, "채팅방 참여자만 삭제할 수 있습니다."), + + // API + API_INVALID_REQUEST(HttpStatus.BAD_REQUEST, "API 호출 중 문제가 발생했습니다."), + API_DATA_PARSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "API 데이터 파싱 중 문제가 발생했습니다."), + + // Kakao API + INVALID_COORDINATES(HttpStatus.BAD_REQUEST, "좌표를 찾을 수 없습니다."); + + private final HttpStatus status; + private final String message; + + public ResponseEntity toResponseEntity() { + return ResponseEntity.status(this.status).body(new ExceptionResDto(this.status.value(), this.message)); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/exception/GlobalExceptionHandler.java b/src/main/java/com/sounganization/botanify/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..c2ecd6d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,56 @@ +package com.sounganization.botanify.common.exception; + +import com.sounganization.botanify.common.dto.res.ExceptionGroupResDto; +import com.sounganization.botanify.common.dto.res.ExceptionResDto; +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.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException(RuntimeException ex) { + log.error("처리되지 않은 런타임 예외 : {}", ex.getMessage(), ex); + return ExceptionStatus.INTERNAL_SERVER_ERROR.toResponseEntity(); + } + + @ExceptionHandler(NullPointerException.class) + public ResponseEntity handleNullPointerException(NullPointerException ex) { + log.error("Null 포인터 참조 예외", ex); + return ExceptionResDto.toResponseEntityWith(HttpStatus.INTERNAL_SERVER_ERROR, "서버에서 존재하지 않는 값을 참조하였습니다."); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleHttpRequestMethodNotSupportedExceptionException() { + return ExceptionResDto.toResponseEntityWith(HttpStatus.BAD_REQUEST, "허용되지 않는 요청 메서드입니다."); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ignoredEx) { + log.info("요청 본문의 누락을 감지"); + return ExceptionStatus.BODY_NOT_FOUND.toResponseEntity(); + } + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException ex) { + log.info("서비스 예외 발생: {} - {}", ex.getStatus().getStatus(), ex.getStatus().getMessage()); + log.debug(ex.getMessage(), ex); + return ex.getStatus().toResponseEntity(); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + log.info("유효하지 않은 요청 본문을 감지"); + ExceptionGroupResDto res = new ExceptionGroupResDto(HttpStatus.BAD_REQUEST); + ex.getBindingResult().getAllErrors().forEach((error) -> res.addCase(((FieldError) error).getField(), error.getDefaultMessage())); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(res); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/filter/GoogleJwtAuthenticationFilter.java b/src/main/java/com/sounganization/botanify/common/filter/GoogleJwtAuthenticationFilter.java new file mode 100644 index 0000000..e3a97aa --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/filter/GoogleJwtAuthenticationFilter.java @@ -0,0 +1,83 @@ +package com.sounganization.botanify.common.filter; + +import com.sounganization.botanify.common.util.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.core.Authentication; + +import java.io.IOException; +import java.io.PrintWriter; + +public class GoogleJwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { + + private final JwtUtil jwtUtil; + + public GoogleJwtAuthenticationFilter(String defaultFilterProcessesUrl, + AuthenticationManager authenticationManager, + JwtUtil jwtUtil) { + super(defaultFilterProcessesUrl); + setAuthenticationManager(authenticationManager); + this.jwtUtil = jwtUtil; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException { + + // Authorization 헤더 확인 및 Null 체크 + String authorizationHeader = request.getHeader("Authorization"); + + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + throw new RuntimeException("인증 헤더가 없거나 잘못되었습니다"); + } + + String token = authorizationHeader.replace("Bearer ", ""); + return getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(null, token)); + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { + // JWT 생성 + String jwt = jwtUtil.generateToken(authResult); + + // JWT를 쿠키에 추가 + jwtUtil.addJwtToCookie(jwt, response); + + // JSON 응답 반환 + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); + + String jsonResponse = "{\n" + + " \"status\": 200,\n" + + " \"message\": \"Google 로그인이 성공되었습니다.\"\n" + + "}"; + + PrintWriter out = response.getWriter(); + out.print(jsonResponse); + out.flush(); + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException { + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + String jsonResponse = "{\n" + + " \"status\": 401,\n" + + " \"message\": \"Google 로그인 실패: " + failed.getMessage() + "\"\n" + + "}"; + + PrintWriter out = response.getWriter(); + out.print(jsonResponse); + out.flush(); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/filter/JwtAuthenticationFilter.java b/src/main/java/com/sounganization/botanify/common/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..ee1edee --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/filter/JwtAuthenticationFilter.java @@ -0,0 +1,81 @@ +package com.sounganization.botanify.common.filter; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.sounganization.botanify.common.dto.res.ExceptionResDto; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.common.util.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import java.io.IOException; +import java.util.Map; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException { + try { + ObjectMapper mapper = new ObjectMapper(); + Map authRequest = mapper.readValue(request.getInputStream(), new TypeReference<>() {}); + String email = authRequest.get("email"); + String password = authRequest.get("password"); + + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(email, password); + + return authenticationManager.authenticate(authenticationToken); + } catch (IOException e) { + throw new RuntimeException("잘못된 인증 요청입니다.", e); + } + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, + FilterChain chain, Authentication authResult) + throws IOException, ServletException { + String token = jwtUtil.generateToken(authResult); + + Cookie jwtCookie = new Cookie("Authorization", token); + jwtCookie.setHttpOnly(true); + jwtCookie.setPath("/"); + + response.addCookie(jwtCookie); + + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write("{\"status\": 200, \"message\": \"로그인이 성공되었습니다.\"}"); + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException failed) + throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + ExceptionStatus status = ExceptionStatus.INVALID_CREDENTIALS; + String errorResponse = new ObjectMapper().writeValueAsString( + new ExceptionResDto(status.getStatus().value(), status.getMessage()) + ); + + response.getWriter().write(errorResponse); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/filter/JwtAuthorizationFilter.java b/src/main/java/com/sounganization/botanify/common/filter/JwtAuthorizationFilter.java new file mode 100644 index 0000000..706ae16 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/filter/JwtAuthorizationFilter.java @@ -0,0 +1,57 @@ +package com.sounganization.botanify.common.filter; + +import com.sounganization.botanify.common.handler.JwtAuthorizationHandler; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthorizationFilter extends OncePerRequestFilter { + + private final JwtAuthorizationHandler jwtAuthorizationHandler; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + if (request.getServletPath().startsWith("/ws")) { + filterChain.doFilter(request, response); + return; + } + + if (request.getServletPath().startsWith("/api/v1/auth")) { + filterChain.doFilter(request, response); + return; + } + + if ("GET".equals(request.getMethod()) && + ( + request.getServletPath().equals("/api/v1/posts") || + request.getServletPath().matches("/api/v1/posts/\\d+") || + request.getServletPath().matches("/api/v1/species/\\d+") + ) + ) { + filterChain.doFilter(request, response); + return; + } + + + try { + jwtAuthorizationHandler.handleAuthorization(request); + } catch (Exception e) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write("{\"status\": 403, \"message\": \"접근 거부된 페이지입니다.\"}"); + return; + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/handler/JwtAuthorizationHandler.java b/src/main/java/com/sounganization/botanify/common/handler/JwtAuthorizationHandler.java new file mode 100644 index 0000000..4fc167a --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/handler/JwtAuthorizationHandler.java @@ -0,0 +1,38 @@ +package com.sounganization.botanify.common.handler; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.common.util.JwtUtil; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtAuthorizationHandler { + + private final JwtUtil jwtUtil; + + public void handleAuthorization(HttpServletRequest request) { + String token = null; + + // 쿠키에서 JWT 토큰 추출 + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("Authorization".equals(cookie.getName())) { + token = cookie.getValue(); + break; + } + } + } + + // 토큰 검증 및 인증 객체 설정 + if (token != null && jwtUtil.validateToken(token)) { + SecurityContextHolder.getContext().setAuthentication(jwtUtil.getAuthentication(token)); + } else { + throw new CustomException(ExceptionStatus.INVALID_TOKEN); + } + } +} diff --git a/src/main/java/com/sounganization/botanify/common/security/UserDetailsImpl.java b/src/main/java/com/sounganization/botanify/common/security/UserDetailsImpl.java new file mode 100644 index 0000000..cf75eb2 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/security/UserDetailsImpl.java @@ -0,0 +1,40 @@ +package com.sounganization.botanify.common.security; + +import com.sounganization.botanify.domain.user.enums.UserRole; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +@Getter +@RequiredArgsConstructor +public class UserDetailsImpl implements UserDetails { + + private final Long id; + private final String email; + private final String username; + private final String password; + private final String city; + private final String town; + private final UserRole role; + private final String nx; + private final String ny; + + @Override + public Collection getAuthorities() { + return Collections.singleton(role::name); + } + + @Override + public String getUsername() { + return email; + } + + @Override + public String getPassword() { + return password; + } +} diff --git a/src/main/java/com/sounganization/botanify/common/util/GoogleJwtAuthenticationProvider.java b/src/main/java/com/sounganization/botanify/common/util/GoogleJwtAuthenticationProvider.java new file mode 100644 index 0000000..a6b9d3d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/util/GoogleJwtAuthenticationProvider.java @@ -0,0 +1,68 @@ +package com.sounganization.botanify.common.util; + +import com.auth0.jwt.interfaces.DecodedJWT; +import com.sounganization.botanify.common.security.UserDetailsImpl; +import com.sounganization.botanify.domain.user.entity.User; +import com.sounganization.botanify.domain.user.enums.UserRole; +import com.sounganization.botanify.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class GoogleJwtAuthenticationProvider implements AuthenticationProvider { + + private final UserRepository userRepository; + private final GoogleJwtValidator googleJwtValidator; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String token = (String) authentication.getCredentials(); + + // GoogleJwt 에서 사용자 정보 추출 + DecodedJWT jwt = googleJwtValidator.validate(token); + String email = jwt.getClaim("email").asString(); + String username = jwt.getClaim("name").asString(); + + Optional optionalUser = userRepository.findByEmail(email); + + User user = optionalUser.orElseGet(() -> { + User newUser = User.builder() + .email(email) + .username(username) + .password("") // OAuth 로그인이라 비밀번호는 빈 값 + .role(UserRole.USER) + .city("") + .town("") + .address("") + .build(); + return userRepository.save(newUser); + }); + + UserDetailsImpl userDetails = new UserDetailsImpl( + user.getId(), + email, + username, + "", + "", + "", + user.getRole(), + "", + "" + ); + + // 인증 토큰 반환 + return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + } + + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/util/GoogleJwtValidator.java b/src/main/java/com/sounganization/botanify/common/util/GoogleJwtValidator.java new file mode 100644 index 0000000..b135f05 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/util/GoogleJwtValidator.java @@ -0,0 +1,36 @@ +package com.sounganization.botanify.common.util; + +import com.auth0.jwk.Jwk; +import com.auth0.jwk.JwkProvider; +import com.auth0.jwk.UrlJwkProvider; +import com.auth0.jwt.JWT; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import org.springframework.stereotype.Component; + +import java.net.URL; +import java.security.interfaces.RSAPublicKey; + +@Component +public class GoogleJwtValidator { + + private static final String GOOGLE_JWK_URL = "https://www.googleapis.com/oauth2/v3/certs"; + private static final String ISSUER = "https://accounts.google.com"; + + public DecodedJWT validate(String token) { + try { + DecodedJWT jwt = JWT.decode(token); + JwkProvider provider = new UrlJwkProvider(new URL(GOOGLE_JWK_URL)); + Jwk jwk = provider.get(jwt.getKeyId()); + RSAPublicKey publicKey = (RSAPublicKey) jwk.getPublicKey(); + + return JWT.require(com.auth0.jwt.algorithms.Algorithm.RSA256(publicKey, null)) + .withIssuer(ISSUER) + .build() + .verify(token); + } catch (Exception e) { + throw new CustomException(ExceptionStatus.INVALID_TOKEN); + } + } +} diff --git a/src/main/java/com/sounganization/botanify/common/util/JwtUtil.java b/src/main/java/com/sounganization/botanify/common/util/JwtUtil.java new file mode 100644 index 0000000..e4b82c6 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/util/JwtUtil.java @@ -0,0 +1,141 @@ +package com.sounganization.botanify.common.util; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.common.security.UserDetailsImpl; +import com.sounganization.botanify.domain.user.enums.UserRole; +import io.jsonwebtoken.*; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import javax.crypto.spec.SecretKeySpec; +import java.security.Key; +import java.util.Date; + +@Component +public class JwtUtil { + + @Value("${spring.jwt.secret.key}") + private String secretKey; + + @Getter + @Value("${spring.jwt.secret.expiration}") + private long expirationTime; + + private Key signingKey; + + @PostConstruct + public void init() { + this.signingKey = getSigningKey(); + } + + // JWT 서명 키 생성 + private Key getSigningKey() { + return new SecretKeySpec(secretKey.getBytes(), SignatureAlgorithm.HS512.getJcaName()); + } + + public String getCurrentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication != null && authentication.isAuthenticated()) { + Object principal = authentication.getPrincipal(); + + if (principal instanceof UserDetailsImpl userDetails) { + return String.valueOf(userDetails.getId()); + } + } + + throw new CustomException(ExceptionStatus.UNAUTHORIZED_ACCESS); + } + + // JWT 토큰 생성 + public String generateToken(Authentication authentication) { + UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); + + return Jwts.builder() + .setSubject(String.valueOf(userDetails.getId())) + .claim("username", userDetails.getUsername()) + .claim("role", userDetails.getRole().name()) + .claim("city", userDetails.getCity()) + .claim("town", userDetails.getTown()) + .claim("nx", userDetails.getNx()) + .claim("ny", userDetails.getNy()) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + expirationTime)) + .signWith(signingKey) + .compact(); + } + + // JWT를 쿠키에 담아 응답에 추가 + public void addJwtToCookie(String jwt, HttpServletResponse response) { + Cookie jwtCookie = new Cookie("Authorization", jwt); + jwtCookie.setHttpOnly(true); + jwtCookie.setSecure(false); // HTTPS 사용 시 true 로 설정 + jwtCookie.setPath("/"); + jwtCookie.setMaxAge((int) (expirationTime)); + + response.addCookie(jwtCookie); + } + + // 토큰에서 Claims 추출 + public Claims getClaimsFromToken(String token) { + return parseToken(token); + } + + // 토큰 유효성 검사 + public boolean validateToken(String token) { + parseToken(token); + return true; + } + + // 토큰을 파싱하여 Claims 객체로 변환 + private Claims parseToken(String token) { + if (token == null || token.trim().isEmpty()) { + throw new CustomException(ExceptionStatus.TOKEN_NOT_PROVIDED); + } + + try { + return Jwts.parserBuilder() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + throw new CustomException(ExceptionStatus.TOKEN_EXPIRED); + } catch (UnsupportedJwtException | MalformedJwtException e) { + throw new CustomException(ExceptionStatus.INVALID_TOKEN); + } catch (Exception e) { + throw new CustomException(ExceptionStatus.INTERNAL_SERVER_ERROR); + } + } + + public Authentication getAuthentication(String token) { + Claims claims = getClaimsFromToken(token); + + Long id = Long.valueOf(claims.getSubject()); + String username = claims.get("username", String.class); + String email = ""; // 이메일은 토큰에 저장하지 않음 + String password = ""; // 비밀번호는 토큰에 저장하지 않음 + String city = claims.get("city", String.class); + String town = claims.get("town", String.class); + String role = claims.get("role", String.class); + String nx = claims.get("nx", String.class); + String ny = claims.get("ny", String.class); + + UserDetailsImpl userDetails = new UserDetailsImpl( + id, username, email, password, city, town, UserRole.valueOf(role), nx, ny); + + return new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/admin/controller/AdminController.java b/src/main/java/com/sounganization/botanify/domain/admin/controller/AdminController.java new file mode 100644 index 0000000..036c899 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/admin/controller/AdminController.java @@ -0,0 +1,41 @@ +package com.sounganization.botanify.domain.admin.controller; + +import com.sounganization.botanify.domain.admin.service.AdminService; +import com.sounganization.botanify.domain.user.dto.res.UserPlantsResDto; +import com.sounganization.botanify.domain.user.dto.res.UserPostsResDto; +import com.sounganization.botanify.domain.user.dto.res.UserResDto; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin") +@RequiredArgsConstructor +public class AdminController { + + private final AdminService adminService; + + @GetMapping("/users/{userId}") + public ResponseEntity getUserProfile(@PathVariable Long userId) { + UserResDto response = adminService.getUserProfile(userId); + return ResponseEntity.ok(response); + } + + @GetMapping("/users/{userId}/plants") + public ResponseEntity getUserProfileWithPlants(@PathVariable Long userId, + @RequestParam int plantPage, + @RequestParam int plantSize, + @RequestParam int diaryPage, + @RequestParam int diarySize) { + UserPlantsResDto response = adminService.getUserProfileWithPlants(userId, plantPage, plantSize, diaryPage, diarySize); + return ResponseEntity.ok(response); + } + + @GetMapping("/users/{userId}/posts") + public ResponseEntity getUserProfileWithPosts(@PathVariable Long userId, + @RequestParam int page, + @RequestParam int size) { + UserPostsResDto response = adminService.getUserProfileWithPosts(userId, page, size); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/admin/service/AdminService.java b/src/main/java/com/sounganization/botanify/domain/admin/service/AdminService.java new file mode 100644 index 0000000..c0aa323 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/admin/service/AdminService.java @@ -0,0 +1,92 @@ +package com.sounganization.botanify.domain.admin.service; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.community.dto.res.PostListResDto; +import com.sounganization.botanify.domain.community.entity.Post; +import com.sounganization.botanify.domain.community.mapper.PostMapper; +import com.sounganization.botanify.domain.community.repository.PostRepository; +import com.sounganization.botanify.domain.garden.dto.res.DiaryResDto; +import com.sounganization.botanify.domain.garden.dto.res.PlantResDto; +import com.sounganization.botanify.domain.garden.entity.Plant; +import com.sounganization.botanify.domain.garden.mapper.DiaryMapper; +import com.sounganization.botanify.domain.garden.repository.DiaryRepository; +import com.sounganization.botanify.domain.garden.repository.PlantRepository; +import com.sounganization.botanify.domain.user.dto.res.UserPlantsResDto; +import com.sounganization.botanify.domain.user.dto.res.UserPostsResDto; +import com.sounganization.botanify.domain.user.dto.res.UserResDto; +import com.sounganization.botanify.domain.user.entity.User; +import com.sounganization.botanify.domain.user.mapper.UserMapper; +import com.sounganization.botanify.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AdminService { + + private final UserRepository userRepository; + private final UserMapper userMapper; + private final PlantRepository plantRepository; + private final DiaryRepository diaryRepository; + private final DiaryMapper diaryMapper; + private final PostRepository postRepository; + private final PostMapper postMapper; + + public UserResDto getUserProfile(Long userId) { + User user = getUserById(userId); + return userMapper.toResDto(user); + } + + public UserPlantsResDto getUserProfileWithPlants(Long userId, int plantPage, int plantSize, int diaryPage, int diarySize) { + User user = getUserById(userId); + Pageable plantPageable = PageRequest.of(plantPage - 1, plantSize); + Pageable diaryPageable = PageRequest.of(diaryPage - 1, diarySize); + Page plants = plantRepository.findAllByUserIdAndDeletedYnFalse(user.getId(), plantPageable); + + Page plantResDtos = getPlantResDtos(plants, diaryPageable); + + UserResDto userResDto = userMapper.toResDto(user); + return new UserPlantsResDto(userResDto, plantResDtos); + } + + public UserPostsResDto getUserProfileWithPosts(Long userId, int page, int size) { + User user = getUserById(userId); + Pageable pageable = PageRequest.of(page - 1, size); + Page posts = postRepository.findAllByUserIdAndDeletedYnFalse(user.getId(), pageable); + + Page postResDtos = posts.map(postMapper::entityToResDto); + UserResDto userResDto = userMapper.toResDto(user); + return new UserPostsResDto(userResDto, postResDtos); + } + + private User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ExceptionStatus.USER_DETAILS_NOT_FOUND)); + } + + private Page getPlantResDtos(Page plants, Pageable diaryPageable) { + return plants.map(plant -> { + List diaryResDtos = getDiaryResDtos(plant, diaryPageable); + return new PlantResDto(plant.getId(), + plant.getPlantName(), + plant.getAdoptionDate(), + plant.getSpecies().getSpeciesName(), + new PageImpl<>(diaryResDtos, diaryPageable, diaryResDtos.size())); + }); + } + + private List getDiaryResDtos(Plant plant, Pageable pageable) { + return diaryRepository.findAllByPlantIdAndDeletedYnFalse(plant.getId(), pageable) + .stream() + .map(diaryMapper::toDto) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/auth/controller/AuthController.java b/src/main/java/com/sounganization/botanify/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..72aa7c2 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/auth/controller/AuthController.java @@ -0,0 +1,52 @@ +package com.sounganization.botanify.domain.auth.controller; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.auth.dto.req.SigninReqDto; +import com.sounganization.botanify.domain.auth.dto.req.SignupReqDto; +import com.sounganization.botanify.domain.auth.service.AuthService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Objects; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/signup") + public ResponseEntity signup(@Valid @RequestBody SignupReqDto request) { + return authService.signup(request); + } + + @PostMapping("/signin") + public ResponseEntity signin(@Valid @RequestBody SigninReqDto request, HttpServletResponse response) { + ResponseEntity authResDtoResponseEntity = authService.signin(request); + CommonResDto commonResDto = authResDtoResponseEntity.getBody(); + + // JWT 쿠키 생성 + try { + String token = Objects.requireNonNull(commonResDto).token(); + Cookie jwtCookie = new Cookie("Authorization", token); + jwtCookie.setHttpOnly(true); + jwtCookie.setSecure(false); + jwtCookie.setPath("/"); + jwtCookie.setMaxAge((int) (authService.getExpirationTime())); + response.addCookie(jwtCookie); + } catch (NullPointerException ex) { + throw new CustomException(ExceptionStatus.INVALID_TOKEN); + } + return ResponseEntity.ok(commonResDto); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/auth/dto/req/SigninReqDto.java b/src/main/java/com/sounganization/botanify/domain/auth/dto/req/SigninReqDto.java new file mode 100644 index 0000000..67ae3e3 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/auth/dto/req/SigninReqDto.java @@ -0,0 +1,12 @@ +package com.sounganization.botanify.domain.auth.dto.req; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record SigninReqDto ( + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + @NotBlank(message = "비밀번호는 필수 입력 값입니다.") + String password) +{} diff --git a/src/main/java/com/sounganization/botanify/domain/auth/dto/req/SignupReqDto.java b/src/main/java/com/sounganization/botanify/domain/auth/dto/req/SignupReqDto.java new file mode 100644 index 0000000..8569fbc --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/auth/dto/req/SignupReqDto.java @@ -0,0 +1,33 @@ +package com.sounganization.botanify.domain.auth.dto.req; + +import com.sounganization.botanify.domain.user.enums.UserRole; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record SignupReqDto( + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + @NotBlank(message = "비밀번호는 필수 입력 값입니다.") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#\\$%\\^&\\*])[A-Za-z\\d!@#\\$%\\^&\\*]{8,}$", + message = "비밀번호는 최소 8자리 이상, 영문 대소문자, 숫자, 특수문자가 각각 1개 이상 포함되어야 합니다." + ) + String password, + @NotBlank(message = "비밀번호 확인은 필수 입력 값입니다.") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#\\$%\\^&\\*])[A-Za-z\\d!@#\\$%\\^&\\*]{8,}$", + message = "비밀번호는 최소 8자리 이상, 영문 대소문자, 숫자, 특수문자가 각각 1개 이상 포함되어야 합니다." + ) + String passwordCheck, + @NotBlank(message = "이름은 필수 입력 값입니다.") + String username, + @NotBlank(message = "도시는 필수 입력 값입니다.") + String city, + @NotBlank(message = "동/읍/면은 필수 입력 값입니다.") + String town, + @NotBlank(message = "상세 주소는 필수 입력 값입니다.") + String address, + UserRole role +) {} diff --git a/src/main/java/com/sounganization/botanify/domain/auth/service/AuthService.java b/src/main/java/com/sounganization/botanify/domain/auth/service/AuthService.java new file mode 100644 index 0000000..b343e44 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/auth/service/AuthService.java @@ -0,0 +1,93 @@ +package com.sounganization.botanify.domain.auth.service; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.common.util.JwtUtil; +import com.sounganization.botanify.domain.auth.dto.req.SigninReqDto; +import com.sounganization.botanify.domain.auth.dto.req.SignupReqDto; +import com.sounganization.botanify.domain.user.entity.User; +import com.sounganization.botanify.domain.user.enums.UserRole; +import com.sounganization.botanify.domain.user.repository.UserRepository; +import com.sounganization.botanify.domain.weather.service.LocationService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; + private final LocationService locationService; + + @Transactional + public ResponseEntity signup(SignupReqDto request) { + if (userRepository.existsByEmail((request.email()))) { + throw new CustomException(ExceptionStatus.DUPLICATED_EMAIL); + } + + if (!request.password().equals(request.passwordCheck())) { + throw new CustomException(ExceptionStatus.PASSWORDS_DO_NOT_MATCH); + } + + String[] coordinates = locationService.getCoordinates(request.city(), request.town()); + if (coordinates == null) { + throw new CustomException(ExceptionStatus.INVALID_COORDINATES); + } + + String nx = coordinates[0]; + String ny = coordinates[1]; + + UserRole role = request.role() != null ? request.role() : UserRole.USER; // 기본값 USER + + User newUser = User.builder() + .email(request.email()) + .username(request.username()) + .password(passwordEncoder.encode(request.password())) + .city(request.city()) + .town(request.town()) + .address(request.address()) + .role(role) + .nx(nx) + .ny(ny) + .build(); + + Long userId = userRepository.save(newUser).getId(); + + CommonResDto response = new CommonResDto(HttpStatus.CREATED, "회원가입이 성공되었습니다.", userId); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Transactional(readOnly = true) + public ResponseEntity signin(SigninReqDto request) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new CustomException(ExceptionStatus.USER_DETAILS_NOT_FOUND)); + + if (user.getDeletedYn()) { + throw new CustomException(ExceptionStatus.ACCOUNT_DELETED); + } + // Spring Security 인증 처리 + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.email(), request.password()) + ); + + String token = jwtUtil.generateToken(authentication); + + CommonResDto response = new CommonResDto(HttpStatus.OK, "로그인이 성공되었습니다.", token); + return ResponseEntity.ok(response); + } + + public long getExpirationTime() { + return jwtUtil.getExpirationTime(); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/controller/ChatController.java b/src/main/java/com/sounganization/botanify/domain/chat/controller/ChatController.java new file mode 100644 index 0000000..dedbb25 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/controller/ChatController.java @@ -0,0 +1,88 @@ +package com.sounganization.botanify.domain.chat.controller; + +import com.sounganization.botanify.common.security.UserDetailsImpl; +import com.sounganization.botanify.domain.chat.dto.res.ChatMessageResDto; +import com.sounganization.botanify.domain.chat.dto.res.ChatRoomResDto; +import com.sounganization.botanify.domain.chat.entity.ChatMessage; +import com.sounganization.botanify.domain.chat.entity.ChatRoom; +import com.sounganization.botanify.domain.chat.mapper.ChatMessageMapper; +import com.sounganization.botanify.domain.chat.mapper.ChatRoomMapper; +import com.sounganization.botanify.domain.chat.service.ChatMessageService; +import com.sounganization.botanify.domain.chat.service.ChatRoomService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/chat") +@RequiredArgsConstructor +public class ChatController { + + private final ChatRoomService chatRoomService; + private final ChatMessageService chatMessageService; + private final ChatRoomMapper chatRoomMapper; + private final ChatMessageMapper chatMessageMapper; + + @PostMapping("/rooms") + public ResponseEntity createChatRoom( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam Long receiverId + ) { + ChatRoom chatRoom = chatRoomService.createChatRoom(userDetails.getId(), receiverId); + return ResponseEntity.status(HttpStatus.CREATED) + .body(chatRoomMapper.toResDto(chatRoom)); + } + + @GetMapping("/rooms") + public ResponseEntity> getUserChatRooms( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Page chatRooms = chatRoomService.getUserChatRooms( + userDetails.getId(), + PageRequest.of(page, size) + ); + return ResponseEntity.ok(chatRoomMapper.toChatRoomResDtoPage(chatRooms)); + } + + @GetMapping("/rooms/{roomId}/messages") + public ResponseEntity> getRoomMessages( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long roomId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + + Page messages = chatMessageService.getRoomMessages( + roomId, + userDetails.getId(), + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")) + ); + return ResponseEntity.ok(chatMessageMapper.toChatMessageResDtoPage(messages)); + } + + @DeleteMapping("/messages/{messageId}") + public ResponseEntity deleteMessage( + @PathVariable Long messageId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + chatMessageService.deleteMessage(messageId, userDetails.getId()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/rooms/{roomId}") + public ResponseEntity deleteChatRoom( + @PathVariable Long roomId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + chatRoomService.deleteChatRoom(roomId, userDetails.getId()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/dto/req/ChatMessageReqDto.java b/src/main/java/com/sounganization/botanify/domain/chat/dto/req/ChatMessageReqDto.java new file mode 100644 index 0000000..c423965 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/dto/req/ChatMessageReqDto.java @@ -0,0 +1,12 @@ +package com.sounganization.botanify.domain.chat.dto.req; + +public record ChatMessageReqDto( + MessageType type, + Long roomId, + Long senderId, + String content +) { + public enum MessageType { + ENTER, TALK, LEAVE + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/dto/res/ChatMessageResDto.java b/src/main/java/com/sounganization/botanify/domain/chat/dto/res/ChatMessageResDto.java new file mode 100644 index 0000000..ee44e6b --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/dto/res/ChatMessageResDto.java @@ -0,0 +1,15 @@ +package com.sounganization.botanify.domain.chat.dto.res; + +import java.time.LocalDateTime; + +public record ChatMessageResDto( + Long id, + MessageType type, + Long senderId, + String content, + LocalDateTime createdAt +) { + public enum MessageType { + ENTER, TALK, LEAVE + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/dto/res/ChatRoomResDto.java b/src/main/java/com/sounganization/botanify/domain/chat/dto/res/ChatRoomResDto.java new file mode 100644 index 0000000..d10e3ec --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/dto/res/ChatRoomResDto.java @@ -0,0 +1,10 @@ +package com.sounganization.botanify.domain.chat.dto.res; + +import java.time.LocalDateTime; + +public record ChatRoomResDto( + Long id, + Long senderUserId, + Long receiverUserId, + LocalDateTime createdAt +) {} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/dto/res/ErrorMessageDto.java b/src/main/java/com/sounganization/botanify/domain/chat/dto/res/ErrorMessageDto.java new file mode 100644 index 0000000..d66fdf3 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/dto/res/ErrorMessageDto.java @@ -0,0 +1,5 @@ +package com.sounganization.botanify.domain.chat.dto.res; + +public record ErrorMessageDto( + String error +) {} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatMessage.java b/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatMessage.java new file mode 100644 index 0000000..fe8bc4f --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatMessage.java @@ -0,0 +1,37 @@ +package com.sounganization.botanify.domain.chat.entity; + +import com.sounganization.botanify.common.entity.Timestamped; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChatMessage extends Timestamped { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private MessageType type; + + @Column(nullable = false) + private Long senderId; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private ChatRoom chatRoom; + + public enum MessageType { + ENTER, TALK, LEAVE + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatRoom.java b/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatRoom.java new file mode 100644 index 0000000..6a5c4b4 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatRoom.java @@ -0,0 +1,31 @@ +package com.sounganization.botanify.domain.chat.entity; + +import com.sounganization.botanify.common.entity.Timestamped; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChatRoom extends Timestamped { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long senderUserId; + + @Column(nullable = false) + private Long receiverUserId; + + @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL) + private List messages = new ArrayList<>(); +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/mapper/ChatMessageMapper.java b/src/main/java/com/sounganization/botanify/domain/chat/mapper/ChatMessageMapper.java new file mode 100644 index 0000000..f8fa586 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/mapper/ChatMessageMapper.java @@ -0,0 +1,22 @@ +package com.sounganization.botanify.domain.chat.mapper; + +import com.sounganization.botanify.domain.chat.dto.res.ChatMessageResDto; +import com.sounganization.botanify.domain.chat.entity.ChatMessage; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.springframework.data.domain.Page; + +@Mapper(componentModel = "spring") +public interface ChatMessageMapper { + + @Mapping(target = "id", source = "id") + @Mapping(target = "type", source = "type") + @Mapping(target = "senderId", source = "senderId") + @Mapping(target = "content", source = "content") + @Mapping(target = "createdAt", source = "createdAt") + ChatMessageResDto toResDto(ChatMessage chatMessage); + + default Page toChatMessageResDtoPage(Page messages) { + return messages.map(this::toResDto); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/mapper/ChatRoomMapper.java b/src/main/java/com/sounganization/botanify/domain/chat/mapper/ChatRoomMapper.java new file mode 100644 index 0000000..ca503e1 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/mapper/ChatRoomMapper.java @@ -0,0 +1,21 @@ +package com.sounganization.botanify.domain.chat.mapper; + +import com.sounganization.botanify.domain.chat.dto.res.ChatRoomResDto; +import com.sounganization.botanify.domain.chat.entity.ChatRoom; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.springframework.data.domain.Page; + +@Mapper(componentModel = "spring") +public interface ChatRoomMapper { + + @Mapping(target = "id", source = "id") + @Mapping(target = "senderUserId", source = "senderUserId") + @Mapping(target = "receiverUserId", source = "receiverUserId") + @Mapping(target = "createdAt", source = "createdAt") + ChatRoomResDto toResDto(ChatRoom chatRoom); + + default Page toChatRoomResDtoPage(Page chatRooms) { + return chatRooms.map(this::toResDto); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepository.java b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepository.java new file mode 100644 index 0000000..6f38b91 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepository.java @@ -0,0 +1,16 @@ +package com.sounganization.botanify.domain.chat.repository; + +import com.sounganization.botanify.domain.chat.entity.ChatMessage; +import com.sounganization.botanify.domain.chat.entity.ChatRoom; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; + +public interface ChatMessageCustomRepository { + List findMessagesByRoomId(Long roomId); + List findRecentMessages(ChatRoom room, LocalDateTime after); + List findMessagesWithRoomByRoomId(Long roomId); + Page findMessagesByRoomIdWithPagination(Long roomId, Pageable pageable); +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepositoryImpl.java b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepositoryImpl.java new file mode 100644 index 0000000..108ace9 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepositoryImpl.java @@ -0,0 +1,79 @@ +package com.sounganization.botanify.domain.chat.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.sounganization.botanify.domain.chat.entity.ChatMessage; +import com.sounganization.botanify.domain.chat.entity.ChatRoom; +import com.sounganization.botanify.domain.chat.entity.QChatMessage; +import com.sounganization.botanify.domain.chat.entity.QChatRoom; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ChatMessageCustomRepositoryImpl implements ChatMessageCustomRepository { + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findMessagesByRoomId(Long roomId) { + QChatMessage message = QChatMessage.chatMessage; + + return jpaQueryFactory + .selectFrom(message) + .where(message.chatRoom.id.eq(roomId)) + .orderBy(message.createdAt.asc()) + .fetch(); + } + + @Override + public List findRecentMessages(ChatRoom room, LocalDateTime after) { + QChatMessage message = QChatMessage.chatMessage; + + return jpaQueryFactory + .selectFrom(message) + .where(message.chatRoom.eq(room) + .and(message.createdAt.after(after))) + .orderBy(message.createdAt.asc()) + .fetch(); + } + + @Override + public List findMessagesWithRoomByRoomId(Long roomId) { + QChatMessage message = QChatMessage.chatMessage; + QChatRoom room = QChatRoom.chatRoom; + + return jpaQueryFactory + .selectFrom(message) + .join(message.chatRoom, room) + .fetchJoin() + .where(message.chatRoom.id.eq(roomId)) + .orderBy(message.createdAt.asc()) + .fetch(); + } + + @Override + public Page findMessagesByRoomIdWithPagination(Long roomId, Pageable pageable) { + QChatMessage message = QChatMessage.chatMessage; + + List messages = jpaQueryFactory + .selectFrom(message) + .where(message.chatRoom.id.eq(roomId)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(message.createdAt.desc()) + .fetch(); + + Long total = jpaQueryFactory + .select(message.count()) + .from(message) + .where(message.chatRoom.id.eq(roomId)) + .fetchOne(); + + return new PageImpl<>(messages, pageable, total); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageRepository.java b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageRepository.java new file mode 100644 index 0000000..8de78a4 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageRepository.java @@ -0,0 +1,9 @@ +package com.sounganization.botanify.domain.chat.repository; + +import com.sounganization.botanify.domain.chat.entity.ChatMessage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ChatMessageRepository extends JpaRepository, ChatMessageCustomRepository { +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepository.java b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepository.java new file mode 100644 index 0000000..19da9c8 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepository.java @@ -0,0 +1,15 @@ +package com.sounganization.botanify.domain.chat.repository; + +import com.sounganization.botanify.domain.chat.entity.ChatRoom; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ChatRoomCustomRepository { + List findRoomsByUserId(Long userId); + Optional findRoomByUsers(Long senderUserId, Long receiverUserId); + List findRoomsWithMessagesById(Long userId); + Page findRoomsByUserIdWithPagination(Long userId, Pageable pageable); +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepositoryImpl.java b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepositoryImpl.java new file mode 100644 index 0000000..de36893 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepositoryImpl.java @@ -0,0 +1,82 @@ +package com.sounganization.botanify.domain.chat.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.sounganization.botanify.domain.chat.entity.ChatRoom; +import com.sounganization.botanify.domain.chat.entity.QChatMessage; +import com.sounganization.botanify.domain.chat.entity.QChatRoom; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ChatRoomCustomRepositoryImpl implements ChatRoomCustomRepository { + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findRoomsByUserId(Long userId) { + QChatRoom chatRoom = QChatRoom.chatRoom; + + return jpaQueryFactory + .selectFrom(chatRoom) + .where(chatRoom.senderUserId.eq(userId) + .or(chatRoom.receiverUserId.eq(userId))) + .fetch(); + } + + @Override + public Optional findRoomByUsers(Long senderUserId, Long receiverUserId) { + QChatRoom chatRoom = QChatRoom.chatRoom; + + ChatRoom result = jpaQueryFactory + .selectFrom(chatRoom) + .where(chatRoom.senderUserId.eq(senderUserId) + .and(chatRoom.receiverUserId.eq(receiverUserId))) + .fetchFirst(); + + return Optional.ofNullable(result); + } + + @Override + public List findRoomsWithMessagesById(Long userId) { + QChatRoom chatRoom = QChatRoom.chatRoom; + QChatMessage message = QChatMessage.chatMessage; + + return jpaQueryFactory + .selectFrom(chatRoom) + .distinct() + .leftJoin(chatRoom.messages, message) + .fetchJoin() + .where(chatRoom.senderUserId.eq(userId) + .or(chatRoom.receiverUserId.eq(userId))) + .fetch(); + } + + @Override + public Page findRoomsByUserIdWithPagination(Long userId, Pageable pageable) { + QChatRoom chatRoom = QChatRoom.chatRoom; + + List rooms = jpaQueryFactory + .selectFrom(chatRoom) + .where(chatRoom.senderUserId.eq(userId) + .or(chatRoom.receiverUserId.eq(userId))) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(chatRoom.updatedAt.desc()) + .fetch(); + + Long total = jpaQueryFactory + .select(chatRoom.count()) + .from(chatRoom) + .where(chatRoom.senderUserId.eq(userId) + .or(chatRoom.receiverUserId.eq(userId))) + .fetchOne(); + + return new PageImpl<>(rooms, pageable, total); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomRepository.java b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomRepository.java new file mode 100644 index 0000000..e4765f0 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomRepository.java @@ -0,0 +1,9 @@ +package com.sounganization.botanify.domain.chat.repository; + +import com.sounganization.botanify.domain.chat.entity.ChatRoom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ChatRoomRepository extends JpaRepository, ChatRoomCustomRepository { +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/service/ChatMessageService.java b/src/main/java/com/sounganization/botanify/domain/chat/service/ChatMessageService.java new file mode 100644 index 0000000..5b1b056 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/service/ChatMessageService.java @@ -0,0 +1,60 @@ +package com.sounganization.botanify.domain.chat.service; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.chat.entity.ChatMessage; +import com.sounganization.botanify.domain.chat.entity.ChatRoom; +import com.sounganization.botanify.domain.chat.repository.ChatMessageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ChatMessageService { + private final ChatMessageRepository chatMessageRepository; + private final ChatRoomService chatRoomService; + + @Transactional + public ChatMessage saveMessage(Long roomId, Long senderId, String content) { + ChatRoom chatRoom = chatRoomService.getChatRoom(roomId, senderId); + + if (!chatRoom.getSenderUserId().equals(senderId) && !chatRoom.getReceiverUserId().equals(senderId)) { + throw new CustomException(ExceptionStatus.UNAUTHORIZED_CHAT_ACCESS); + } + + ChatMessage message = ChatMessage.builder() + .type(ChatMessage.MessageType.TALK) + .senderId(senderId) + .content(content) + .chatRoom(chatRoom) + .build(); + + return chatMessageRepository.save(message); + } + + @Transactional(readOnly = true) + public Page getRoomMessages(Long roomId, Long userId, Pageable pageable) { + + chatRoomService.getChatRoom(roomId, userId); + + return chatMessageRepository.findMessagesByRoomIdWithPagination(roomId, pageable); + } + + @Transactional + public void deleteMessage(Long messageId, Long userId) { + + ChatMessage message = chatMessageRepository.findById(messageId) + .orElseThrow(() -> new CustomException(ExceptionStatus.CHAT_MESSAGE_NOT_FOUND)); + + if (!message.getSenderId().equals(userId)) { + throw new CustomException(ExceptionStatus.MESSAGE_NOT_OWNED); + } + + message.softDelete(); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/chat/service/ChatRoomService.java b/src/main/java/com/sounganization/botanify/domain/chat/service/ChatRoomService.java new file mode 100644 index 0000000..1d86b59 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/chat/service/ChatRoomService.java @@ -0,0 +1,71 @@ +package com.sounganization.botanify.domain.chat.service; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.chat.entity.ChatMessage; +import com.sounganization.botanify.domain.chat.entity.ChatRoom; +import com.sounganization.botanify.domain.chat.repository.ChatRoomRepository; +import com.sounganization.botanify.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ChatRoomService { + private final ChatRoomRepository chatRoomRepository; + private final UserRepository userRepository; + + @Transactional + public ChatRoom createChatRoom(Long senderUserId, Long receiverUserId) { + + userRepository.findById(senderUserId) + .orElseThrow(() -> new CustomException(ExceptionStatus.USER_NOT_FOUND)); + userRepository.findById(receiverUserId) + .orElseThrow(() -> new CustomException(ExceptionStatus.USER_NOT_FOUND)); + + return chatRoomRepository.findRoomByUsers(senderUserId, receiverUserId) + .orElseGet(() -> chatRoomRepository.save(ChatRoom.builder() + .senderUserId(senderUserId) + .receiverUserId(receiverUserId) + .build())); + } + + @Transactional(readOnly = true) + public Page getUserChatRooms(Long userId, Pageable pageable) { + + userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ExceptionStatus.USER_NOT_FOUND)); + + return chatRoomRepository.findRoomsByUserIdWithPagination(userId, pageable); + } + + @Transactional(readOnly = true) + public ChatRoom getChatRoom(Long roomId, Long userId) { + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ExceptionStatus.CHAT_ROOM_NOT_FOUND)); + + if (!chatRoom.getSenderUserId().equals(userId) && !chatRoom.getReceiverUserId().equals(userId)) { + throw new CustomException(ExceptionStatus.UNAUTHORIZED_CHAT_ACCESS); + } + + return chatRoom; + } + + @Transactional + public void deleteChatRoom(Long roomId, Long userId) { + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ExceptionStatus.CHAT_ROOM_NOT_FOUND)); + + if (!chatRoom.getSenderUserId().equals(userId) && !chatRoom.getReceiverUserId().equals(userId)) { + throw new CustomException(ExceptionStatus.NOT_CHAT_ROOM_PARTICIPANT); + } + + chatRoom.softDelete(); + chatRoom.getMessages().forEach(ChatMessage::softDelete); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/controller/CommentController.java b/src/main/java/com/sounganization/botanify/domain/community/controller/CommentController.java new file mode 100644 index 0000000..781fb17 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/controller/CommentController.java @@ -0,0 +1,73 @@ +package com.sounganization.botanify.domain.community.controller; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.security.UserDetailsImpl; +import com.sounganization.botanify.domain.community.dto.req.CommentReqDto; +import com.sounganization.botanify.domain.community.service.CommentService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/comments") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + @PostMapping("/posts/{postId}/comments") + public ResponseEntity createComment( + @PathVariable Long postId, + @Valid @RequestBody CommentReqDto requestDto, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + + CommonResDto responseDto = commentService.createComment(postId, requestDto, userDetails.getId()); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(responseDto); + } + + @PostMapping("/{parentCommentId}/replies") + public ResponseEntity createReply( + @PathVariable Long parentCommentId, + @Valid @RequestBody CommentReqDto requestDto, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + + CommonResDto responseDto = commentService.createReply(parentCommentId, requestDto, userDetails.getId()); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(responseDto); + } + + @PutMapping("/{id}") + public ResponseEntity updateComment( + @PathVariable Long id, + @Valid @RequestBody CommentReqDto requestDto, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + + CommonResDto responseDto = commentService.updateComment(id, requestDto, userDetails.getId()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(responseDto); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteComment( + @PathVariable Long id, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + commentService.deleteComment(id, userDetails.getId()); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/controller/PopularPostController.java b/src/main/java/com/sounganization/botanify/domain/community/controller/PopularPostController.java new file mode 100644 index 0000000..1456331 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/controller/PopularPostController.java @@ -0,0 +1,28 @@ +package com.sounganization.botanify.domain.community.controller; + +import com.sounganization.botanify.domain.community.dto.res.PopularPostResDto; +import com.sounganization.botanify.domain.community.service.PopularPostService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/posts/popular") +@RequiredArgsConstructor +public class PopularPostController { + + private final PopularPostService popularPostService; + + @GetMapping + public ResponseEntity> getPopularPosts( + @RequestParam(defaultValue = "10") int limit + ) { + List popularPosts = popularPostService.getPopularPosts(limit); + return ResponseEntity.ok(popularPosts); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/controller/PostController.java b/src/main/java/com/sounganization/botanify/domain/community/controller/PostController.java new file mode 100644 index 0000000..e800358 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/controller/PostController.java @@ -0,0 +1,76 @@ +package com.sounganization.botanify.domain.community.controller; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.security.UserDetailsImpl; +import com.sounganization.botanify.common.util.JwtUtil; +import com.sounganization.botanify.domain.community.dto.req.PostReqDto; +import com.sounganization.botanify.domain.community.dto.req.PostUpdateReqDto; +import com.sounganization.botanify.domain.community.dto.res.PostListResDto; +import com.sounganization.botanify.domain.community.dto.res.PostWithCommentResDto; +import com.sounganization.botanify.domain.community.service.PostService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@RestController +@RequestMapping("/api/v1/posts") +@RequiredArgsConstructor +public class PostController { + + private final PostService postService; + private final JwtUtil jwtUtil; + + //게시글 작성 + @PostMapping + public ResponseEntity createPost(@Valid @RequestBody PostReqDto postReqDto, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + CommonResDto postResDto = postService.createPost(postReqDto, userDetails.getId()); + return ResponseEntity.status(HttpStatus.CREATED).body(postResDto); + } + + //게시글 조회 - 다건조회 + @GetMapping + public ResponseEntity> readPosts( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + Page postListResDto = postService.readPosts(page, size); + return ResponseEntity.ok(postListResDto); + } + + //게시글 조회 - 단건조회 + @GetMapping("/{postId}") + public ResponseEntity readPost(@PathVariable Long postId, + @CookieValue(value = "Authorization", required = false) String token) { + Long userId = null; + if (token != null) { + Authentication authentication = jwtUtil.getAuthentication(token); + userId = ((UserDetailsImpl) authentication.getPrincipal()).getId(); + } + PostWithCommentResDto postWithCommentResDto = postService.readPost(postId, userId); + return ResponseEntity.ok(postWithCommentResDto); + } + + //게시글 수정 + @PutMapping("/{postId}") + public ResponseEntity updatePost(@PathVariable Long postId, + @Valid @RequestBody PostUpdateReqDto postUpdateReqDto, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + CommonResDto postResDto = postService.updatePost(postId, postUpdateReqDto, userDetails.getId()); + return ResponseEntity.ok(postResDto); + } + + //게시글 삭제 + @DeleteMapping("/{postId}") + public ResponseEntity deletePost(@PathVariable Long postId, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + postService.deletePost(postId, userDetails.getId()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/dto/req/CommentReqDto.java b/src/main/java/com/sounganization/botanify/domain/community/dto/req/CommentReqDto.java new file mode 100644 index 0000000..654e4d9 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/dto/req/CommentReqDto.java @@ -0,0 +1,10 @@ +package com.sounganization.botanify.domain.community.dto.req; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentReqDto( + @NotBlank(message = "댓글 내용을 입력해주세요.") + @Size(max = 500, message = "댓글은 500자 이내로 작성해주세요.") + String content +) {} diff --git a/src/main/java/com/sounganization/botanify/domain/community/dto/req/PostReqDto.java b/src/main/java/com/sounganization/botanify/domain/community/dto/req/PostReqDto.java new file mode 100644 index 0000000..cb0f2e3 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/dto/req/PostReqDto.java @@ -0,0 +1,17 @@ +package com.sounganization.botanify.domain.community.dto.req; + +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.URL; + +public record PostReqDto ( + @NotBlank(message = "게시글 제목은 필수 입력입니다") + @Length(max = 100, message = "게시글 제목은 100자 이하로 입력해야 합니다") + String title, + + @NotBlank(message = "게시글 내용은 필수 입력입니다") + String content, + + @URL(message = "이미지 URL 형식이 올바르지 않습니다") + String imageUrl +) {} diff --git a/src/main/java/com/sounganization/botanify/domain/community/dto/req/PostUpdateReqDto.java b/src/main/java/com/sounganization/botanify/domain/community/dto/req/PostUpdateReqDto.java new file mode 100644 index 0000000..a00c875 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/dto/req/PostUpdateReqDto.java @@ -0,0 +1,12 @@ +package com.sounganization.botanify.domain.community.dto.req; + +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.URL; + +public record PostUpdateReqDto ( + @Length(max = 100, message = "게시글 제목은 100자 이하로 입력해야 합니다") + String title, + String content, + @URL(message = "이미지 URL 형식이 올바르지 않습니다") + String imageUrl +) {} diff --git a/src/main/java/com/sounganization/botanify/domain/community/dto/req/ViewHistoryDto.java b/src/main/java/com/sounganization/botanify/domain/community/dto/req/ViewHistoryDto.java new file mode 100644 index 0000000..cc4d431 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/dto/req/ViewHistoryDto.java @@ -0,0 +1,10 @@ +package com.sounganization.botanify.domain.community.dto.req; + +import java.time.LocalDate; + +public record ViewHistoryDto( + Long postId, + Long userId, + LocalDate viewedAt +) { +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/dto/res/CommentTempDto.java b/src/main/java/com/sounganization/botanify/domain/community/dto/res/CommentTempDto.java new file mode 100644 index 0000000..e955308 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/dto/res/CommentTempDto.java @@ -0,0 +1,22 @@ +package com.sounganization.botanify.domain.community.dto.res; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; + +import java.util.ArrayList; +import java.util.List; + +@Builder +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CommentTempDto ( + Long commentId, + Long userId, + String username, + String content, + List replies +) { + + public CommentTempDto(Long commentId, Long userId, String username, String content) { + this(commentId, userId, username, content, new ArrayList<>()); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/dto/res/PopularPostResDto.java b/src/main/java/com/sounganization/botanify/domain/community/dto/res/PopularPostResDto.java new file mode 100644 index 0000000..bfcb522 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/dto/res/PopularPostResDto.java @@ -0,0 +1,13 @@ +package com.sounganization.botanify.domain.community.dto.res; + +import com.fasterxml.jackson.annotation.JsonInclude; + +public record PopularPostResDto( + Long postId, + String title, + Integer viewCount, + @JsonInclude(JsonInclude.Include.NON_NULL) + String imageUrl, + Integer commentCount, + Double score +) {} \ No newline at end of file diff --git a/src/main/java/com/sounganization/botanify/domain/community/dto/res/PostListResDto.java b/src/main/java/com/sounganization/botanify/domain/community/dto/res/PostListResDto.java new file mode 100644 index 0000000..3c96f4e --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/dto/res/PostListResDto.java @@ -0,0 +1,15 @@ +package com.sounganization.botanify.domain.community.dto.res; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; + +@Builder +public record PostListResDto ( + Long id, + String title, + String content, + Integer viewCounts, + + @JsonInclude(JsonInclude.Include.NON_NULL) + String imageUrl +) {} diff --git a/src/main/java/com/sounganization/botanify/domain/community/dto/res/PostWithCommentResDto.java b/src/main/java/com/sounganization/botanify/domain/community/dto/res/PostWithCommentResDto.java new file mode 100644 index 0000000..a7652c4 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/dto/res/PostWithCommentResDto.java @@ -0,0 +1,15 @@ +package com.sounganization.botanify.domain.community.dto.res; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import java.util.List; + +@Builder +public record PostWithCommentResDto ( + String title, + String content, + Integer viewCounts, + @JsonInclude(JsonInclude.Include.NON_NULL) + String imageUrl, + List comments +) { } diff --git a/src/main/java/com/sounganization/botanify/domain/community/entity/Comment.java b/src/main/java/com/sounganization/botanify/domain/community/entity/Comment.java new file mode 100644 index 0000000..3a65f83 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/entity/Comment.java @@ -0,0 +1,66 @@ +package com.sounganization.botanify.domain.community.entity; + +import com.sounganization.botanify.common.entity.Timestamped; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Comment extends Timestamped { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parentComment; + + @OneToMany(mappedBy = "parentComment", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List childComments = new ArrayList<>(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private Integer depth; + + public static class CommentBuilder { + private Integer depth = 0; + } + + public void update(String content) { + if(content == null || content.trim().isEmpty()) { + throw new CustomException(ExceptionStatus.INVALID_COMMENT_CONTENT); + } + this.content = content; + } + + public void softDelete() { + // 부모 댓글 soft delete + super.softDelete(); + + // 모든 자식 댓글을 재귀적으로 soft delete + if (!childComments.isEmpty()) { + childComments.forEach(Comment::softDelete); + } + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/entity/Post.java b/src/main/java/com/sounganization/botanify/domain/community/entity/Post.java new file mode 100644 index 0000000..4e39787 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/entity/Post.java @@ -0,0 +1,54 @@ +package com.sounganization.botanify.domain.community.entity; + +import com.sounganization.botanify.common.entity.Timestamped; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@Entity +@AllArgsConstructor +@NoArgsConstructor +public class Post extends Timestamped { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false) + private Integer viewCounts; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = true) + private String imageUrl; + + //조회수 증가 + public void incrementViewCounts() { + viewCounts++; + } + + //게시글 수정 + public void updatePost(String title, String content) { + if (title != null) { + this.title = title; + } + if (content != null) { + this.content = content; + } + } + + //이미 삭제된 게시글인지 확인 + public boolean isDeletedYn() { + return super.getDeletedYn(); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/entity/ViewHistory.java b/src/main/java/com/sounganization/botanify/domain/community/entity/ViewHistory.java new file mode 100644 index 0000000..c936078 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/entity/ViewHistory.java @@ -0,0 +1,26 @@ +package com.sounganization.botanify.domain.community.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Entity +public class ViewHistory{ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) + private Long postId; + @Column(nullable = false) + private Long userId; + @Column(nullable = false) + private LocalDate viewedAt; +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/enums/.gitkeep b/src/main/java/com/sounganization/botanify/domain/community/enums/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/sounganization/botanify/domain/community/mapper/CommentMapper.java b/src/main/java/com/sounganization/botanify/domain/community/mapper/CommentMapper.java new file mode 100644 index 0000000..331d1b4 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/mapper/CommentMapper.java @@ -0,0 +1,30 @@ +package com.sounganization.botanify.domain.community.mapper; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.domain.community.dto.req.CommentReqDto; +import com.sounganization.botanify.domain.community.entity.Comment; +import com.sounganization.botanify.domain.community.entity.Post; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.springframework.http.HttpStatus; + +@Mapper(componentModel = "spring") +public interface CommentMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "post", source = "post") + @Mapping(target = "userId", source = "userId") + @Mapping(target = "parentComment", source = "parentComment") + @Mapping(target = "content", source = "requestDto.content") + @Mapping(target = "childComments", ignore = true) + @Mapping(target = "depth", expression = "java(parentComment == null ? 0 : parentComment.getDepth() + 1)") + Comment toEntity(CommentReqDto requestDto, Post post, Long userId, Comment parentComment); + + default CommonResDto toResDto(Comment comment) { + return new CommonResDto(HttpStatus.CREATED,"댓글이 추가되었습니다.", comment.getId()); + } + + default CommonResDto toUpdateResDto(Comment comment) { + return new CommonResDto(HttpStatus.OK, "댓글이 수정되었습니다." , comment.getId()); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/mapper/PostMapper.java b/src/main/java/com/sounganization/botanify/domain/community/mapper/PostMapper.java new file mode 100644 index 0000000..2d82972 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/mapper/PostMapper.java @@ -0,0 +1,62 @@ +package com.sounganization.botanify.domain.community.mapper; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.domain.community.dto.req.PostReqDto; +import com.sounganization.botanify.domain.community.dto.res.CommentTempDto; +import com.sounganization.botanify.domain.community.dto.res.PopularPostResDto; +import com.sounganization.botanify.domain.community.dto.res.PostListResDto; +import com.sounganization.botanify.domain.community.dto.res.PostWithCommentResDto; +import com.sounganization.botanify.domain.community.entity.Post; +import org.mapstruct.Mapper; +import org.springframework.http.HttpStatus; + +import java.util.List; + +@Mapper(componentModel = "Spring") +public interface PostMapper { + + default Post reqDtoToEntity(PostReqDto postReqDto, Long userId) { + return Post.builder() + .title(postReqDto.title()) + .content(postReqDto.content()) + .viewCounts(0) + .userId(userId) + .imageUrl(postReqDto.imageUrl()) + .build(); + } + + default CommonResDto entityToResDto(Post post, HttpStatus status) { + return new CommonResDto(status, "게시글이 등록되었습니다.", post.getId()); + } + + default PostListResDto entityToResDto(Post post) { + return PostListResDto.builder() + .id(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .viewCounts(post.getViewCounts()) + .imageUrl(post.getImageUrl()) + .build(); + } + + default PostWithCommentResDto entityToResDto(Post post, List comments) { + return PostWithCommentResDto.builder() + .title(post.getTitle()) + .content(post.getContent()) + .viewCounts(post.getViewCounts()) + .imageUrl(post.getImageUrl()) + .comments(comments) + .build(); + } + + default PopularPostResDto entityToPopularDto(Post post, Integer commentCount, Double score) { + return new PopularPostResDto( + post.getId(), + post.getTitle(), + post.getViewCounts(), + post.getImageUrl(), + commentCount, + score + ); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/mapper/ViewHistoryMapper.java b/src/main/java/com/sounganization/botanify/domain/community/mapper/ViewHistoryMapper.java new file mode 100644 index 0000000..c3bfaee --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/mapper/ViewHistoryMapper.java @@ -0,0 +1,12 @@ +package com.sounganization.botanify.domain.community.mapper; + +import com.sounganization.botanify.domain.community.dto.req.ViewHistoryDto; +import com.sounganization.botanify.domain.community.entity.ViewHistory; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface ViewHistoryMapper { + @Mapping(target = "id", ignore = true) + ViewHistory dtoToEntity(ViewHistoryDto viewHistoryDto); +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/repository/CommentCustomRepository.java b/src/main/java/com/sounganization/botanify/domain/community/repository/CommentCustomRepository.java new file mode 100644 index 0000000..7776962 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/repository/CommentCustomRepository.java @@ -0,0 +1,14 @@ +package com.sounganization.botanify.domain.community.repository; + +import com.sounganization.botanify.domain.community.entity.Comment; + + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface CommentCustomRepository { + List findCommentsByPostId(Long postId); + Map countCommentsByPostIds(List postIds); + Optional findCommentsById(Long commentId); +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/repository/CommentCustomRepositoryImpl.java b/src/main/java/com/sounganization/botanify/domain/community/repository/CommentCustomRepositoryImpl.java new file mode 100644 index 0000000..6590d30 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/repository/CommentCustomRepositoryImpl.java @@ -0,0 +1,77 @@ +package com.sounganization.botanify.domain.community.repository; + +import com.querydsl.core.Tuple; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.sounganization.botanify.domain.community.entity.Comment; +import com.sounganization.botanify.domain.community.entity.QComment; +import com.sounganization.botanify.domain.community.entity.QPost; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +public class CommentCustomRepositoryImpl implements CommentCustomRepository { + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findCommentsByPostId(Long postId) { + QComment comment = QComment.comment; + QComment parentComment = new QComment("parentComment"); + + List result = jpaQueryFactory + .selectFrom(comment) + .distinct() + .leftJoin(comment.childComments).fetchJoin() + .leftJoin(comment.parentComment, parentComment).fetchJoin() + .where(comment.post.id.eq(postId) + .and(comment.deletedYn.isFalse())) + .orderBy( + comment.parentComment.id.coalesce(comment.id).asc(), + comment.id.asc() + ) + .fetch(); + return List.copyOf(result); + } + + @Override + public Map countCommentsByPostIds(List postIds) { + QComment comment = QComment.comment; + QPost post = QPost.post; + + List results = jpaQueryFactory + .select(comment.post.id, comment.count()) + .from(comment) + .join(comment.post, post) + .where(comment.post.id.in(postIds) + .and(comment.deletedYn.isFalse())) + .groupBy(comment.post.id) + .fetch(); + + return results.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(0, Long.class), + tuple -> tuple.get(1, Long.class), + (a, b) -> b + )); + } + + @Override + public Optional findCommentsById(Long commentId) { + QComment comment = QComment.comment; + + Comment result = jpaQueryFactory + .selectFrom(comment) + .distinct() + .leftJoin(comment.childComments).fetchJoin() + .where(comment.id.eq(commentId) + .and(comment.deletedYn.isFalse())) + .fetchOne(); + + return Optional.ofNullable(result); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/repository/CommentRepository.java b/src/main/java/com/sounganization/botanify/domain/community/repository/CommentRepository.java new file mode 100644 index 0000000..7033fe0 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/repository/CommentRepository.java @@ -0,0 +1,7 @@ +package com.sounganization.botanify.domain.community.repository; + +import com.sounganization.botanify.domain.community.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository, CommentCustomRepository{ + } diff --git a/src/main/java/com/sounganization/botanify/domain/community/repository/PopularPostRedisRepository.java b/src/main/java/com/sounganization/botanify/domain/community/repository/PopularPostRedisRepository.java new file mode 100644 index 0000000..661509d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/repository/PopularPostRedisRepository.java @@ -0,0 +1,70 @@ +package com.sounganization.botanify.domain.community.repository; + +import com.sounganization.botanify.domain.community.dto.res.PopularPostResDto; +import com.sounganization.botanify.domain.community.entity.Post; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +public class PopularPostRedisRepository { + private final RedisTemplate redisTemplate; + private static final String KEY = "popular_posts"; + + // 점수와 함께 게시글을 Redis sorted set에 저장 + public void savePopularPost(PopularPostResDto post) { + redisTemplate.opsForZSet().add(KEY + ":scores", String.valueOf(post.postId()), post.score()); + + redisTemplate.opsForHash().put( + KEY + ":details", + String.valueOf(post.postId()), + post + ); + } + + // Top N 인기글 보기 + public List getTopNPosts(int limit) { + Set> topPostsWithScores = redisTemplate.opsForZSet() + .reverseRangeWithScores(KEY + ":scores", 0, limit - 1); + + if (topPostsWithScores == null || topPostsWithScores.isEmpty()) { + return new ArrayList<>(); + } + + return topPostsWithScores.stream() + .map(tuple -> (PopularPostResDto) redisTemplate.opsForHash() + .get(KEY + ":details", tuple.getValue())) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + // 점수 계산 및 게시글 update + public void updatePopularPost(Post post, int commentCount) { + double score = (post.getViewCounts() * 0.4) + (commentCount * 0.6); + + PopularPostResDto popularPost = new PopularPostResDto( + post.getId(), + post.getTitle(), + post.getViewCounts(), + post.getImageUrl(), + commentCount, + score + ); + + savePopularPost(popularPost); + } + + // 인기글에서 게시글 제거 + public void removePost(Long postId) { + redisTemplate.opsForZSet().remove(KEY + ":scores", postId.toString()); + redisTemplate.opsForHash().delete(KEY + ":details", postId.toString()); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/repository/PostRepository.java b/src/main/java/com/sounganization/botanify/domain/community/repository/PostRepository.java new file mode 100644 index 0000000..c85838a --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/repository/PostRepository.java @@ -0,0 +1,15 @@ +package com.sounganization.botanify.domain.community.repository; + +import com.sounganization.botanify.domain.community.entity.Post; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PostRepository extends JpaRepository { + Page findAllByDeletedYnFalse(Pageable pageable); + Page findAllByUserIdAndDeletedYnFalse(Long userId, Pageable pageable); + + List findAllByDeletedYnFalse(); +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/repository/ViewHistoryCustomRepository.java b/src/main/java/com/sounganization/botanify/domain/community/repository/ViewHistoryCustomRepository.java new file mode 100644 index 0000000..ee64d30 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/repository/ViewHistoryCustomRepository.java @@ -0,0 +1,7 @@ +package com.sounganization.botanify.domain.community.repository; + +import java.time.LocalDate; + +public interface ViewHistoryCustomRepository { + boolean existViewHistory( Long postId,Long userId,LocalDate viewedAt); +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/repository/ViewHistoryCustomRepositoryImpl.java b/src/main/java/com/sounganization/botanify/domain/community/repository/ViewHistoryCustomRepositoryImpl.java new file mode 100644 index 0000000..6a0f678 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/repository/ViewHistoryCustomRepositoryImpl.java @@ -0,0 +1,27 @@ +package com.sounganization.botanify.domain.community.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.sounganization.botanify.domain.community.entity.QViewHistory; +import com.sounganization.botanify.domain.community.entity.ViewHistory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; + +@Repository +@RequiredArgsConstructor +public class ViewHistoryCustomRepositoryImpl implements ViewHistoryCustomRepository { + private final JPAQueryFactory jpaQueryFactory; + + @Override + public boolean existViewHistory(Long postId, Long userId, LocalDate viewedAt) { + QViewHistory viewHistory = QViewHistory.viewHistory; + + ViewHistory result = jpaQueryFactory.selectFrom(viewHistory) + .where(viewHistory.postId.eq(postId) + .and(viewHistory.userId.eq(userId)) + .and(viewHistory.viewedAt.eq(viewedAt))) + .fetchFirst(); + return result != null; + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/repository/ViewHistoryRepository.java b/src/main/java/com/sounganization/botanify/domain/community/repository/ViewHistoryRepository.java new file mode 100644 index 0000000..b340c21 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/repository/ViewHistoryRepository.java @@ -0,0 +1,14 @@ +package com.sounganization.botanify.domain.community.repository; + +import com.sounganization.botanify.domain.community.entity.ViewHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +public interface ViewHistoryRepository extends JpaRepository, ViewHistoryCustomRepository { + @Modifying + @Transactional + @Query("DELETE FROM ViewHistory v") + void deleteAll(); +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/service/CommentService.java b/src/main/java/com/sounganization/botanify/domain/community/service/CommentService.java new file mode 100644 index 0000000..8af9041 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/service/CommentService.java @@ -0,0 +1,131 @@ +package com.sounganization.botanify.domain.community.service; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.community.dto.req.CommentReqDto; +import com.sounganization.botanify.domain.community.entity.Comment; +import com.sounganization.botanify.domain.community.entity.Post; +import com.sounganization.botanify.domain.community.mapper.CommentMapper; +import com.sounganization.botanify.domain.community.repository.CommentRepository; +import com.sounganization.botanify.domain.community.repository.PostRepository; +import com.sounganization.botanify.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + private final CommentMapper commentMapper; + private final PopularPostService popularPostService; + + private static final int MAX_COMMENT_DEPTH = 1; + + @Transactional + public CommonResDto createComment(Long postId, CommentReqDto requestDto, Long userId) { + + userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ExceptionStatus.USER_NOT_FOUND)); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ExceptionStatus.POST_NOT_FOUND)); + + if (post.isDeletedYn()) { + throw new CustomException(ExceptionStatus.POST_ALREADY_DELETED); + } + + if (requestDto.content() == null || requestDto.content().trim().isEmpty()) { + throw new CustomException(ExceptionStatus.INVALID_COMMENT_CONTENT); + } + + Comment comment = commentMapper.toEntity(requestDto, post, userId, null); + Comment savedComment = commentRepository.save(comment); + + //댓글 생성 시 인기글 update + popularPostService.updatePostScore(postId); + + return commentMapper.toResDto(savedComment); + } + + @Transactional + public CommonResDto createReply(Long parentCommentId, CommentReqDto requestDto, Long userId) { + + userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ExceptionStatus.USER_NOT_FOUND)); + + Comment parentComment = commentRepository.findById(parentCommentId) + .orElseThrow(() -> new CustomException(ExceptionStatus.COMMENT_NOT_FOUND)); + + if (parentComment.getDepth() >= MAX_COMMENT_DEPTH) { + throw new CustomException(ExceptionStatus.MAX_COMMENT_DEPTH_EXCEEDED); + } + + if (parentComment.getDeletedYn()) { + throw new CustomException(ExceptionStatus.COMMENT_ALREADY_DELETED); + } + + if (requestDto.content() == null || requestDto.content().trim().isEmpty()) { + throw new CustomException(ExceptionStatus.INVALID_COMMENT_CONTENT); + } + + Comment reply = commentMapper.toEntity(requestDto, parentComment.getPost(), userId, parentComment); + Comment savedReply = commentRepository.save(reply); + + // 대댓글 생성시 점수 update + popularPostService.updatePostScore(savedReply.getPost().getId()); + + return commentMapper.toResDto(savedReply); + } + + @Transactional + public CommonResDto updateComment(Long commentId, CommentReqDto requestDto, Long userId) { + + userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ExceptionStatus.USER_NOT_FOUND)); + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ExceptionStatus.COMMENT_NOT_FOUND)); + + // 댓글 작성자 확인 + if(!comment.getUserId().equals(userId)){ + throw new CustomException(ExceptionStatus.COMMENT_NOT_OWNED); + } + + comment.update(requestDto.content()); + + //댓글 수정 시 점수 update + popularPostService.updatePostScore(comment.getPost().getId()); + + return commentMapper.toUpdateResDto(comment); + } + + @Transactional + public void deleteComment(Long commentId, Long userId) { + + userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ExceptionStatus.USER_NOT_FOUND)); + + Comment comment = commentRepository.findCommentsById(commentId) + .orElseThrow(() -> new CustomException(ExceptionStatus.COMMENT_NOT_FOUND)); + + if (comment.getDeletedYn()) { + throw new CustomException(ExceptionStatus.COMMENT_ALREADY_DELETED); + } + + // 댓글 작성자 확인 + if (!comment.getUserId().equals(userId)) { + throw new CustomException(ExceptionStatus.COMMENT_NOT_OWNED); + } + + comment.softDelete(); + + // 댓글 삭제 시 인기글 update + popularPostService.updatePostScore(comment.getPost().getId()); + } + +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/service/PopularPostService.java b/src/main/java/com/sounganization/botanify/domain/community/service/PopularPostService.java new file mode 100644 index 0000000..44014c6 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/service/PopularPostService.java @@ -0,0 +1,88 @@ +package com.sounganization.botanify.domain.community.service; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.community.dto.res.PopularPostResDto; +import com.sounganization.botanify.domain.community.entity.Post; +import com.sounganization.botanify.domain.community.mapper.PostMapper; +import com.sounganization.botanify.domain.community.repository.CommentRepository; +import com.sounganization.botanify.domain.community.repository.PopularPostRedisRepository; +import com.sounganization.botanify.domain.community.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PopularPostService { + + private final PopularPostRedisRepository popularPostRedisRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final PostMapper postMapper; + + // 인기 게시글 목록 조회 + public List getPopularPosts(int limit) { + return popularPostRedisRepository.getTopNPosts(limit); + } + + // 게시글 점수 업데이트 (조회수나 댓글 수 변경 시 호출) + @Transactional + public void updatePostScore(Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ExceptionStatus.POST_NOT_FOUND)); + + // 삭제된 게시글 체크 + if (post.isDeletedYn()) { + popularPostRedisRepository.removePost(postId); + return; + } + + // 댓글 수 계산 + int commentCount = commentRepository.findCommentsByPostId(postId).size(); + + // Redis 업데이트 + popularPostRedisRepository.updatePopularPost(post, commentCount); + } + + @Scheduled(fixedRate = 3600000) // 1시간 마다 + @Transactional + public void updateAllPostScores() { + log.info("인기 게시글 점수 업데이트 시작"); + try { + // 삭제되지 않은 모든 게시글 가져오기 + List activePosts = postRepository.findAllByDeletedYnFalse(); + + // 게시글 IDs 가져오기 + List postIds = activePosts.stream() + .map(Post::getId) + .collect(Collectors.toList()); + + // 한 번의 query로 모든 게시글에 대한 댓글 수를 가져오기 + Map commentCounts = commentRepository.countCommentsByPostIds(postIds); + + // 점수 update + for (Post post : activePosts) { + int commentCount = commentCounts.getOrDefault(post.getId(), 0L).intValue(); + popularPostRedisRepository.updatePopularPost(post, commentCount); + } + + log.info("총 {}개의 게시글 점수 업데이트 완료", activePosts.size()); + } catch (Exception e) { + log.error("인기 게시글 점수 업데이트 중 오류 발생: {}", e.getMessage(), e); + } + } + + // 게시글 삭제 시 인기 게시글에서도 제거 + @Transactional + public void removeFromPopularPosts(Long postId) { + popularPostRedisRepository.removePost(postId); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/service/PostService.java b/src/main/java/com/sounganization/botanify/domain/community/service/PostService.java new file mode 100644 index 0000000..5740d3c --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/service/PostService.java @@ -0,0 +1,222 @@ +package com.sounganization.botanify.domain.community.service; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.community.dto.req.PostReqDto; +import com.sounganization.botanify.domain.community.dto.req.PostUpdateReqDto; +import com.sounganization.botanify.domain.community.dto.res.CommentTempDto; +import com.sounganization.botanify.domain.community.dto.res.PostListResDto; +import com.sounganization.botanify.domain.community.dto.res.PostWithCommentResDto; +import com.sounganization.botanify.domain.community.entity.Comment; +import com.sounganization.botanify.domain.community.entity.Post; +import com.sounganization.botanify.domain.community.mapper.PostMapper; +import com.sounganization.botanify.domain.community.mapper.ViewHistoryMapper; +import com.sounganization.botanify.domain.community.repository.CommentRepository; +import com.sounganization.botanify.domain.community.repository.PostRepository; +import com.sounganization.botanify.domain.community.repository.ViewHistoryRepository; +import com.sounganization.botanify.domain.s3.service.S3Service; +import com.sounganization.botanify.domain.user.projection.UserProjection; +import com.sounganization.botanify.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +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.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PostService { + private final PostRepository postRepository; + private final PostMapper postMapper; + private final ViewHistoryMapper viewHistoryMapper; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final PopularPostService popularPostService; + private final ViewHistoryRepository viewHistoryRepository; + private final ViewHistoryRedisService viewHistoryRedisService; + private final S3Service s3Service; + + + // 게시글 작성 + @Transactional + public CommonResDto createPost(PostReqDto postReqDto, Long userId) { + //사용자 존재 여부 확인 + userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ExceptionStatus.USER_NOT_FOUND)); + //dto -> entity + Post post = postMapper.reqDtoToEntity(postReqDto, userId); + // DB 저장 + Post savedPost = postRepository.save(post); + + // 인기글 시스템에서 게시글 초기화 + popularPostService.updatePostScore(savedPost.getId()); + + //entity -> dto + return postMapper.entityToResDto(savedPost, HttpStatus.CREATED); + } + + // 게시글 조회 - 다건 조회 + public Page readPosts(int page, int size) { + //pageable + Pageable pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending()); + Page posts = postRepository.findAllByDeletedYnFalse(pageable); + return posts.map(postMapper::entityToResDto); + } + + // 게시글 조회 - 단건조회 + @Transactional + public PostWithCommentResDto readPost(Long postId, Long userId) { + LocalDate viewedAt = LocalDate.now(); + // 게시글 존재 여부 확인 + Post post = existPost(postId); + //이미 삭제된 게시글인지 + checkPostNotDeleted(post); + //Redis에서 조회 이력 확인 + boolean isHistoryExist = viewHistoryRedisService.isViewHistoryExist(postId, userId, viewedAt); + + // 조회수 증가 + if (userId != null && !isHistoryExist) { + post.incrementViewCounts(); + viewHistoryRedisService.saveViewHistory(postId, userId, viewedAt); + // 조회수 증가 시 인기글 update + popularPostService.updatePostScore(postId); + + //V1에서 사용 + //ViewHistoryDto viewHistoryDto = new ViewHistoryDto(postId, userId, viewedAt); + //ViewHistory viewHistory = viewHistoryMapper.dtoToEntity(viewHistoryDto); + //viewHistoryRepository.save(viewHistory); + } + + // 댓글 조회 + List comments = commentRepository.findCommentsByPostId(postId); + + // userId로 username 매핑 + List userIds = comments.stream() + .map(Comment::getUserId) + .distinct() + .collect(Collectors.toList()); + + List userProjections = userRepository.findUsernamesByIds(userIds); + Map userMap = userProjections.stream() + .collect(Collectors.toMap(UserProjection::getId, UserProjection::getUsername) + ); + + // 댓글을 Map 으로 그룹화 (ParentCommentId 기준) + Map> commentMap = comments.stream() + .map(comment -> new CommentTempDto( + comment.getId(), + comment.getUserId(), + userMap.getOrDefault(comment.getUserId(), "알수없는 유저"), + comment.getContent() + )) + .collect(Collectors.groupingBy(commentDto -> { + Comment parentComment = comments.stream() + .filter(c -> c.getId().equals(commentDto.commentId())) + .findFirst() + .orElse(null); + + // parentComment 가 null 일 경우 루트 댓글로 처리 + return (parentComment != null && parentComment.getParentComment() != null) + ? parentComment.getParentComment().getId() + : -1L; // -1L을 null 대신 사용 루트 댓글을 구분 + })); + + // 루트 댓글에 대댓글 매핑 + List rootComments = commentMap.get(-1L); + if (rootComments != null) { + rootComments.forEach(comment -> { + List replies = commentMap.get(comment.commentId()); + if (replies != null) { + comment.replies().addAll(replies); + } + }); + } + return postMapper.entityToResDto(post, rootComments != null ? rootComments : new ArrayList<>()); + } + + // 게시글 수정 + @Transactional + public CommonResDto updatePost(Long postId, PostUpdateReqDto postUpdateReqDto, Long userId) { + // 게시글 존재 여부 확인 + Post post = existPost(postId); + //소유자 확인 + validatePostOwner(post, userId); + //이미 삭제된 게시글인지 확인 + checkPostNotDeleted(post); + // 게시글 수정 + post.updatePost(postUpdateReqDto.title(), postUpdateReqDto.content()); + // DB 저장 + Post savedPost = postRepository.save(post); + + // 게시글 수정시 점수 update + popularPostService.updatePostScore(postId); + + //entity -> dto + return postMapper.entityToResDto(savedPost, HttpStatus.OK); + } + + // 게시글 삭제 + @Transactional + public void deletePost(Long postId, Long userId) { + //게시글 존재 여부 확인 + Post post = existPost(postId); + //게시글 소유자 확인 + validatePostOwner(post, userId); + //이미 삭제된 게시글인지 확인 + checkPostNotDeleted(post); + + //게시글과 관련된 모든 댓글의 soft delete + List comments = commentRepository.findCommentsByPostId(postId); + comments.forEach(Comment::softDelete); + //삭제 + post.softDelete(); + + //인기글에서 삭제된 게시글 제거 + popularPostService.removeFromPopularPosts(postId); + //게시글이 보유한 이미지 삭제 요청 + s3Service.deleteImage(post.getImageUrl()); + } + + // 게시글 존재 확인 메서드 + private Post existPost(Long postId) { + return postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ExceptionStatus.POST_NOT_FOUND)); + } + + + // 게시글 소유자 확인 + private void validatePostOwner(Post post, Long userId) { + if (!post.getUserId().equals(userId)) { + throw new CustomException(ExceptionStatus.UNAUTHORIZED_POST_ACCESS); + } + } + + //이미 삭제된 게시글인지 확인 + private void checkPostNotDeleted(Post post) { + if (post.isDeletedYn()) { + throw new CustomException(ExceptionStatus.POST_ALREADY_DELETED); + } + } + + // 조회 이력 확인(v1에 사용 queryDsl) + private boolean isexistViewHistory(Long postId, Long userId, LocalDate viewedAt) { + if (userId == null) { + return false; + } + return viewHistoryRepository.existViewHistory(postId, userId, viewedAt); + } +} + + diff --git a/src/main/java/com/sounganization/botanify/domain/community/service/SchedulerService.java b/src/main/java/com/sounganization/botanify/domain/community/service/SchedulerService.java new file mode 100644 index 0000000..2afa8ab --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/service/SchedulerService.java @@ -0,0 +1,24 @@ +package com.sounganization.botanify.domain.community.service; + +import com.sounganization.botanify.domain.community.repository.ViewHistoryRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SchedulerService { + + private final ViewHistoryRepository viewHistoryRepository; + private final EntityManager entityManager; + + @Scheduled(cron = "0 0 00 L * ?", zone = "Asia/Seoul") + public void deleteAll() { + viewHistoryRepository.deleteAll(); + entityManager.clear(); + log.info("viewHistory 삭제 스케줄러 동작"); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/community/service/ViewHistoryRedisService.java b/src/main/java/com/sounganization/botanify/domain/community/service/ViewHistoryRedisService.java new file mode 100644 index 0000000..530852d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/community/service/ViewHistoryRedisService.java @@ -0,0 +1,55 @@ +package com.sounganization.botanify.domain.community.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; + + +@Service +@RequiredArgsConstructor +public class ViewHistoryRedisService { + + private final RedisTemplate redisTemplate; + + //조회이력 있는지 확인 + public boolean isViewHistoryExist(Long postId, Long userId, LocalDate viewedAt) { + String redisKey = "view_history:post_id:" + postId; + String field = String.valueOf(userId); + String value = viewedAt.toString(); + + String storedValue = (String) redisTemplate.opsForHash().get(redisKey, field); + return value.equals(storedValue); + } + + //캐시 저장 + public void saveViewHistory(Long postId, Long userId, LocalDate viewedAt) { + String redisKey = "view_history:post_id:" + postId; + String field = String.valueOf(userId); + String value = viewedAt.toString(); + + if (isOneDayPassed(viewedAt)) { + redisTemplate.opsForHash().put(redisKey, field, value); + long ttl = remainingTime(); + redisTemplate.expire(redisKey, ttl, TimeUnit.SECONDS); + } + } + + + //하루계산 + public boolean isOneDayPassed(LocalDate viewedAt) { + LocalDate today = LocalDate.now(); + return !viewedAt.isAfter(today); + } + + //남은시간계산 + private long remainingTime() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime midnight = LocalDateTime.of(now.toLocalDate().plusDays(1), java.time.LocalTime.MIDNIGHT); + return ChronoUnit.SECONDS.between(now, midnight); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/controller/DiaryController.java b/src/main/java/com/sounganization/botanify/domain/garden/controller/DiaryController.java new file mode 100644 index 0000000..edb7f10 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/controller/DiaryController.java @@ -0,0 +1,60 @@ +package com.sounganization.botanify.domain.garden.controller; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.security.UserDetailsImpl; +import com.sounganization.botanify.domain.garden.dto.req.DiaryReqDto; +import com.sounganization.botanify.domain.garden.dto.res.DiaryResDto; +import com.sounganization.botanify.domain.garden.service.DiaryService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; + +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class DiaryController { + private final DiaryService diaryService; + + @PostMapping("/plants/{plantId}/diaries") + public ResponseEntity createDiary( + @AuthenticationPrincipal UserDetailsImpl userDetails, + HttpServletRequest httpReq, + @PathVariable Long plantId, + @Valid @RequestBody DiaryReqDto reqDto + ) { + CommonResDto resDto = diaryService.createDiary(userDetails.getId(), plantId, reqDto); + String createdURI = httpReq.getRequestURI() + "/" + resDto.id(); + return ResponseEntity.created(URI.create(createdURI)).body(resDto); + } + + @GetMapping("/diaries/{id}") + public ResponseEntity readDiary( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long id + ) { + return ResponseEntity.ok(diaryService.readDiary(userDetails.getId(), id)); + } + + @PutMapping("/diaries/{id}") + public ResponseEntity updateDiary( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long id, + @Valid @RequestBody DiaryReqDto reqDto + ) { + return ResponseEntity.ok(diaryService.updateDiary(userDetails.getId(), id, reqDto)); + } + + @DeleteMapping("/diaries/{id}") + public ResponseEntity deleteDiary( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long id + ) { + diaryService.deleteDiary(userDetails.getId(), id); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/sounganization/botanify/domain/garden/controller/PlantController.java b/src/main/java/com/sounganization/botanify/domain/garden/controller/PlantController.java new file mode 100644 index 0000000..0f5da8d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/controller/PlantController.java @@ -0,0 +1,51 @@ +package com.sounganization.botanify.domain.garden.controller; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.security.UserDetailsImpl; +import com.sounganization.botanify.domain.garden.dto.req.PlantReqDto; +import com.sounganization.botanify.domain.garden.dto.res.PlantResDto; +import com.sounganization.botanify.domain.garden.service.PlantService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + + +@RestController +@RequestMapping("/api/v1/plants") +@RequiredArgsConstructor +public class PlantController { + + public final PlantService plantService; + + //식물 등록 + @PostMapping + public ResponseEntity createPlant(@AuthenticationPrincipal UserDetailsImpl userDetails, @RequestBody PlantReqDto plantReqDto) { + Long createdId = plantService.createPlant(userDetails.getId(), plantReqDto); + return ResponseEntity.status(HttpStatus.CREATED).body(new CommonResDto(HttpStatus.CREATED,"식물이 등록되었습니다.",createdId)); + } + + @GetMapping("/{id}") + public ResponseEntity readPlant( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long id, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size + ) { + PlantResDto plantResDto = plantService.readPlant(userDetails.getId(), id, page, size); + return ResponseEntity.ok(plantResDto); + } + + @PutMapping("/{id}") + public ResponseEntity updatePlant(@AuthenticationPrincipal UserDetailsImpl userDetails, @PathVariable Long id, @RequestBody PlantReqDto plantReqDto) { + Long updatedId = plantService.updatePlant(userDetails.getId(), id, plantReqDto); + return ResponseEntity.ok(new CommonResDto(HttpStatus.OK,"식물이 수정되었습니다.",updatedId)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deletePlant(@AuthenticationPrincipal UserDetailsImpl userDetails, @PathVariable Long id) { + plantService.deletePlant(userDetails.getId(), id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/controller/SpeciesController.java b/src/main/java/com/sounganization/botanify/domain/garden/controller/SpeciesController.java new file mode 100644 index 0000000..cdc899a --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/controller/SpeciesController.java @@ -0,0 +1,64 @@ +package com.sounganization.botanify.domain.garden.controller; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.security.UserDetailsImpl; +import com.sounganization.botanify.domain.garden.dto.req.SpeciesReqDto; +import com.sounganization.botanify.domain.garden.dto.res.SpeciesResDto; +import com.sounganization.botanify.domain.garden.service.SpeciesService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; + +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class SpeciesController { + private final SpeciesService speciesService; + + @PostMapping("/admin/species") + public ResponseEntity createSpecies( + @AuthenticationPrincipal UserDetailsImpl userDetails, + HttpServletRequest httpReq, + @Valid @RequestBody SpeciesReqDto reqDto + ) { + CommonResDto resDto = speciesService.createSpecies(userDetails.getRole(), reqDto); + String createdUri = httpReq.getRequestURI() + "/" + resDto.id(); + return ResponseEntity.created(URI.create(createdUri)).body(resDto); + } + + @GetMapping("/species") + public ResponseEntity getAllSpecies( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size + ) { + return ResponseEntity.ok(speciesService.readAllSpecies(page, size)); + } + + @GetMapping("/species/{id}") + public ResponseEntity getSpecies(@PathVariable Long id) { + return ResponseEntity.ok(speciesService.readSpecies(id)); + } + + @PutMapping("/species/{id}") + public ResponseEntity updateSpecies( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long id, + @Valid @RequestBody SpeciesReqDto reqDto + ) { + return ResponseEntity.ok(speciesService.updateSpecies(userDetails.getRole(), id, reqDto)); + } + + @DeleteMapping("/species/{id}") + public ResponseEntity deleteSpecies( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long id + ) { + speciesService.deleteSpecies(userDetails.getRole(), id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/DiaryReqDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/DiaryReqDto.java new file mode 100644 index 0000000..c6f8130 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/DiaryReqDto.java @@ -0,0 +1,13 @@ +package com.sounganization.botanify.domain.garden.dto.req; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.hibernate.validator.constraints.Length; + +public record DiaryReqDto( + @NotBlank @Length(max = 100) + String title, + + @NotNull + String content +) {} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantReqDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantReqDto.java new file mode 100644 index 0000000..bdfed50 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantReqDto.java @@ -0,0 +1,20 @@ +package com.sounganization.botanify.domain.garden.dto.req; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; + +import java.time.LocalDate; + +public record PlantReqDto ( + @NotBlank(message = "공백일 수 없습니다.") + String plantName, + + @NotNull @Positive + Long speciesId, + + // todo - yyyy-MM-dd 형식이 LocalDate로 잘 변환되는지 테스트 후 주석 삭제 + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "날짜는 yyyy-MM-dd 형식이어야 합니다.") + LocalDate adoptionDate +) { } diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/SpeciesReqDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/SpeciesReqDto.java new file mode 100644 index 0000000..fdd10d4 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/SpeciesReqDto.java @@ -0,0 +1,13 @@ +package com.sounganization.botanify.domain.garden.dto.req; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.hibernate.validator.constraints.Length; + +public record SpeciesReqDto( + @NotBlank @Length(max = 50) + String speciesName, + + @NotNull + String description +) { } diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/res/DiaryResDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/res/DiaryResDto.java new file mode 100644 index 0000000..85f3f76 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/res/DiaryResDto.java @@ -0,0 +1,11 @@ +package com.sounganization.botanify.domain.garden.dto.res; + +import java.time.LocalDateTime; + +public record DiaryResDto( + Long id, + String title, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { } diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/res/PlantResDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/res/PlantResDto.java new file mode 100644 index 0000000..e262a87 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/res/PlantResDto.java @@ -0,0 +1,17 @@ +package com.sounganization.botanify.domain.garden.dto.res; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import org.springframework.data.domain.Page; + +import java.time.LocalDate; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) // null 값은 제외하고 json 으로 변환 +public record PlantResDto ( + Long id, + String plantName, + LocalDate adoptionDate, + String speciesName, + Page diaries +) { } \ No newline at end of file diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/res/SpeciesResDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/res/SpeciesResDto.java new file mode 100644 index 0000000..e0daba0 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/res/SpeciesResDto.java @@ -0,0 +1,7 @@ +package com.sounganization.botanify.domain.garden.dto.res; + +public record SpeciesResDto( + Long id, + String speciesName, + String description +) { } diff --git a/src/main/java/com/sounganization/botanify/domain/garden/entity/Diary.java b/src/main/java/com/sounganization/botanify/domain/garden/entity/Diary.java new file mode 100644 index 0000000..93f3f57 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/entity/Diary.java @@ -0,0 +1,39 @@ +package com.sounganization.botanify.domain.garden.entity; + +import com.sounganization.botanify.common.entity.Timestamped; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class Diary extends Timestamped { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "plant_id") + private Plant plant; + + @Column(nullable = false) + private Long userId; + + public void addRelations(Plant plant, Long userId) { + this.plant = plant; + this.userId = userId; + } + + public void update(String title, String content) { + this.title = title; + this.content = content; + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/entity/Plant.java b/src/main/java/com/sounganization/botanify/domain/garden/entity/Plant.java new file mode 100644 index 0000000..b6dd36b --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/entity/Plant.java @@ -0,0 +1,44 @@ +package com.sounganization.botanify.domain.garden.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.sounganization.botanify.common.entity.Timestamped; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Entity +@Builder +public class Plant extends Timestamped { + @Id + @Getter + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String plantName; + + @Column(nullable = false) + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate adoptionDate; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "species_id") + private Species species; + + @Column(nullable = false) + private Long userId; + + public void addRelations(Species species, Long userId) { + this.species = species; + this.userId = userId; + } + + public void update(String plantName, LocalDate adoptionDate) { + this.plantName = plantName; + this.adoptionDate = adoptionDate; + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/entity/Species.java b/src/main/java/com/sounganization/botanify/domain/garden/entity/Species.java new file mode 100644 index 0000000..b5fba10 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/entity/Species.java @@ -0,0 +1,30 @@ +package com.sounganization.botanify.domain.garden.entity; + +import com.sounganization.botanify.common.entity.Timestamped; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class Species extends Timestamped { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(nullable = false, length = 50) + private String speciesName; + + @Column(nullable = false, columnDefinition = "TEXT") + private String description; + + public void update(String speciesName, String description) { + this.speciesName = speciesName; + this.description = description; + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/enums/.gitkeep b/src/main/java/com/sounganization/botanify/domain/garden/enums/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/sounganization/botanify/domain/garden/mapper/DiaryMapper.java b/src/main/java/com/sounganization/botanify/domain/garden/mapper/DiaryMapper.java new file mode 100644 index 0000000..47ab65e --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/mapper/DiaryMapper.java @@ -0,0 +1,44 @@ +package com.sounganization.botanify.domain.garden.mapper; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.domain.garden.dto.req.DiaryReqDto; +import com.sounganization.botanify.domain.garden.dto.res.DiaryResDto; +import com.sounganization.botanify.domain.garden.entity.Diary; +import org.mapstruct.Mapper; +import org.springframework.http.HttpStatus; + +@Mapper(componentModel = "Spring") +public interface DiaryMapper { + default Diary toEntity(DiaryReqDto req) { + return Diary.builder() + .title(req.title()) + .content(req.content()) + .build(); + } + + default DiaryResDto toDto(Diary diary) { + return new DiaryResDto( + diary.getId(), + diary.getTitle(), + diary.getContent(), + diary.getCreatedAt(), + diary.getUpdatedAt() + ); + } + + default CommonResDto toCreatedDto(Long createdId) { + return new CommonResDto( + HttpStatus.CREATED, + "성장 일지가 추가되었습니다.", + createdId + ); + } + + default CommonResDto toUpdatedDto(Long updatedId) { + return new CommonResDto( + HttpStatus.OK, + "성장 일지가 수정되었습니다.", + updatedId + ); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/mapper/PlantMapper.java b/src/main/java/com/sounganization/botanify/domain/garden/mapper/PlantMapper.java new file mode 100644 index 0000000..afc236f --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/mapper/PlantMapper.java @@ -0,0 +1,16 @@ +package com.sounganization.botanify.domain.garden.mapper; + +import com.sounganization.botanify.domain.garden.dto.req.PlantReqDto; +import com.sounganization.botanify.domain.garden.entity.Plant; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "Spring") +public interface PlantMapper { + default Plant toEntity(PlantReqDto reqDto) { + return Plant.builder() + .plantName(reqDto.plantName()) + .adoptionDate(reqDto.adoptionDate()) + .build(); + } + +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/mapper/SpeciesMapper.java b/src/main/java/com/sounganization/botanify/domain/garden/mapper/SpeciesMapper.java new file mode 100644 index 0000000..815de0d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/mapper/SpeciesMapper.java @@ -0,0 +1,42 @@ +package com.sounganization.botanify.domain.garden.mapper; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.domain.garden.dto.req.SpeciesReqDto; +import com.sounganization.botanify.domain.garden.dto.res.SpeciesResDto; +import com.sounganization.botanify.domain.garden.entity.Species; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +@Component +public class SpeciesMapper { + public Species toEntity(SpeciesReqDto req) { + return Species.builder() + .speciesName(req.speciesName()) + .description(req.description()) + .build(); + } + + public SpeciesResDto toDto(Species species) { + return new SpeciesResDto( + species.getId(), + species.getSpeciesName(), + species.getDescription() + ); + } + + public CommonResDto toCreatedDto(Long createdId) { + return new CommonResDto( + HttpStatus.CREATED, + "식물 품종이 추가되었습니다.", + createdId + ); + } + + public CommonResDto toUpdatedDto(Long updatedId) { + return new CommonResDto( + HttpStatus.OK, + "식물 품종이 수정되었습니다.", + updatedId + ); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/repository/DiaryRepository.java b/src/main/java/com/sounganization/botanify/domain/garden/repository/DiaryRepository.java new file mode 100644 index 0000000..6a8ab97 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/repository/DiaryRepository.java @@ -0,0 +1,18 @@ +package com.sounganization.botanify.domain.garden.repository; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.garden.entity.Diary; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface DiaryRepository extends JpaRepository { + Optional findByIdAndDeletedYnFalse(Long id); + default Diary findByIdCustom(Long id) { + return findByIdAndDeletedYnFalse(id).orElseThrow(() -> new CustomException(ExceptionStatus.DIARY_NOT_FOUND)); + } + Page findAllByPlantIdAndDeletedYnFalse(Long plantId, Pageable pageable); +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantRepository.java b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantRepository.java new file mode 100644 index 0000000..3a774ed --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantRepository.java @@ -0,0 +1,23 @@ +package com.sounganization.botanify.domain.garden.repository; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.garden.entity.Plant; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface PlantRepository extends JpaRepository { + + Optional findByIdAndDeletedYnFalse(Long id); + + default Plant findByIdCustom(Long id) { + return this.findByIdAndDeletedYnFalse(id).orElseThrow(() -> new CustomException(ExceptionStatus.PLANT_NOT_FOUND)); + } + + @Query("SELECT p FROM Plant p LEFT JOIN FETCH p.species WHERE p.userId = :userId AND p.deletedYn = false") + Page findAllByUserIdAndDeletedYnFalse(Long userId, Pageable pageable); +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/repository/SpeciesRepository.java b/src/main/java/com/sounganization/botanify/domain/garden/repository/SpeciesRepository.java new file mode 100644 index 0000000..a43899a --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/repository/SpeciesRepository.java @@ -0,0 +1,20 @@ +package com.sounganization.botanify.domain.garden.repository; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.garden.entity.Species; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SpeciesRepository extends JpaRepository { + Page findAllByDeletedYnFalse(Pageable pageable); + + Optional findByIdAndDeletedYnFalse(Long id); + + default Species findByIdCustom(Long id) { + return findByIdAndDeletedYnFalse(id).orElseThrow(()-> new CustomException(ExceptionStatus.SPECIES_NOT_FOUND)); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/service/DiaryService.java b/src/main/java/com/sounganization/botanify/domain/garden/service/DiaryService.java new file mode 100644 index 0000000..f46bd6d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/service/DiaryService.java @@ -0,0 +1,110 @@ +package com.sounganization.botanify.domain.garden.service; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.garden.dto.req.DiaryReqDto; +import com.sounganization.botanify.domain.garden.dto.res.DiaryResDto; +import com.sounganization.botanify.domain.garden.entity.Diary; +import com.sounganization.botanify.domain.garden.entity.Plant; +import com.sounganization.botanify.domain.garden.mapper.DiaryMapper; +import com.sounganization.botanify.domain.garden.repository.DiaryRepository; +import com.sounganization.botanify.domain.garden.repository.PlantRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class DiaryService { + private final DiaryRepository diaryRepository; + private final PlantRepository plantRepository; + private final DiaryMapper diaryMapper; + + /** + * 사용자 id, 식물 id와 저장하고자 하는 일지 DTO 를 받아서, + * 사용자의 식물이 맞을 경우에만 일지를 등록하는 서비스 메서드 + * @param userId 사용자 id + * @param plantId 식물 id + * @param reqDto 저장하고자 하는 일지 DTO + * @return 저장된 일지 엔티티 + */ + @Transactional + public CommonResDto createDiary(Long userId, Long plantId, DiaryReqDto reqDto) { + // plant 획득 및 소유권 검증 + Plant plant = plantRepository.findByIdCustom(plantId); + if (!Objects.equals(userId, plant.getUserId())) throw new CustomException(ExceptionStatus.PLANT_NOT_OWNED); + // 요청 DTO 를 diary 로 변환 후 연관 설정 + Diary reqDiary = diaryMapper.toEntity(reqDto); + reqDiary.addRelations(plant, userId); + // 저장 (영속화) + Diary resDiary = diaryRepository.save(reqDiary); + // id 전달하여 반환 + return diaryMapper.toCreatedDto(resDiary.getId()); + } + + /** + * 사용자 id와 불러오고자 하는 일지 id를 받아서, + * 사용자의 일지가 맞을 경우에만 해당 일지를 반환하는 서비스 메서드 + * @param userId 사용자 id + * @param id 불러오고자 하는 일지 id + * @return 불러 온 일지 엔티티 + */ + @Transactional(readOnly = true) + public DiaryResDto readDiary(Long userId, Long id) { + // diary 획득 및 소유권 검증 + Diary diary = findAuthoredDiaryPersist(userId, id); + // DTO 로 변환하여 반환 + return diaryMapper.toDto(diary); + } + + /** + * 사용자 id와 수정하고자 하는 일지 id, 수정 사항이 담긴 일지 DTO 를 받아서, + * 사용자의 일지가 맞을 경우에만 해당 일지를 수정하는 서비스 메서드 + * @param userId 사용자 id + * @param id 수정하고자 하는 일지 id + * @param reqDto 수정 사항이 담긴 일지 DTO + * @return 수정된 일지 엔티티 + */ + @Transactional + public CommonResDto updateDiary(Long userId, Long id, DiaryReqDto reqDto) { + // diary 획득 및 소유권 검증 (영속화) + Diary diary = findAuthoredDiaryPersist(userId, id); + // 요청 Dto 에서 바로 diary 갱신 + diary.update(reqDto.title(), reqDto.content()); + // id 전달하여 반환 + return diaryMapper.toUpdatedDto(diary.getId()); + } + + /** + * 사용자 id와 삭제하고자 하는 일지 id를 받아서, + * 사용자의 일지가 맞을 경우에만 Soft Delete 를 통해 삭제하는 서비스 메서드 + * @param userId 사용자 id + * @param id 삭제하고자 하는 일지 id + */ + @Transactional + public void deleteDiary(Long userId, Long id) { + // diary 획득 및 소유권 검증 (영속화) + Diary diary = findAuthoredDiaryPersist(userId, id); + // SoftDelete 전용 메서드를 이용한 삭제 필드 갱신 + diary.softDelete(); + } + + /** + * 사용자 id와 일지 id를 받아서, + * 사용자의 소유인 일지를 찾아서 반환하고, 그렇지 않은 경우 해당하는 예외를 던지는 서브루틴 + * @param userId 사용자 id + * @param id 조회 및 소유권 확인하고자 하는 일지 id + * @return 소유권이 확인된 존재하는 일지 엔티티 + */ + @Transactional + protected Diary findAuthoredDiaryPersist(Long userId, Long id) { + // diary 획득 + Diary diary = diaryRepository.findByIdCustom(id); + // 소유권 검증 + if (!Objects.equals(userId, diary.getUserId())) throw new CustomException(ExceptionStatus.DIARY_NOT_OWNED); + return diary; + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/service/PlantService.java b/src/main/java/com/sounganization/botanify/domain/garden/service/PlantService.java new file mode 100644 index 0000000..4160dbd --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/service/PlantService.java @@ -0,0 +1,82 @@ +package com.sounganization.botanify.domain.garden.service; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.garden.dto.req.PlantReqDto; +import com.sounganization.botanify.domain.garden.dto.res.DiaryResDto; +import com.sounganization.botanify.domain.garden.dto.res.PlantResDto; +import com.sounganization.botanify.domain.garden.entity.Diary; +import com.sounganization.botanify.domain.garden.entity.Plant; +import com.sounganization.botanify.domain.garden.entity.Species; +import com.sounganization.botanify.domain.garden.mapper.DiaryMapper; +import com.sounganization.botanify.domain.garden.mapper.PlantMapper; +import com.sounganization.botanify.domain.garden.repository.DiaryRepository; +import com.sounganization.botanify.domain.garden.repository.PlantRepository; +import com.sounganization.botanify.domain.garden.repository.SpeciesRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class PlantService { + private final PlantRepository plantRepository; + private final SpeciesRepository speciesRepository; + private final DiaryRepository diaryRepository; + private final PlantMapper plantMapper; + private final DiaryMapper diaryMapper; + + @Transactional + public Long createPlant(Long userId, PlantReqDto plantReqDto) { + + Species species = speciesRepository.findByIdCustom(plantReqDto.speciesId()); + + Plant plant = plantMapper.toEntity(plantReqDto); + plant.addRelations(species, userId); + + return plantRepository.save(plant).getId(); + } + + @Transactional(readOnly = true) + public PlantResDto readPlant(Long userId, Long id, int page, int size) { + + Plant plant = plantRepository.findByIdCustom(id); + if(!Objects.equals(userId, plant.getUserId())) throw new CustomException(ExceptionStatus.PLANT_NOT_OWNED); + + Species species = plant.getSpecies(); + if (Objects.isNull(species)) { + throw new CustomException(ExceptionStatus.SPECIES_NOT_FOUND); + } + + Pageable pageable = PageRequest.of(page - 1, size); + + Page diaryPage = diaryRepository.findAllByPlantIdAndDeletedYnFalse(id, pageable); + + Page diaries = diaryPage.map(diaryMapper::toDto); + + return new PlantResDto(plant.getId(), plant.getPlantName(), plant.getAdoptionDate(), species.getSpeciesName(), diaries); + } + + @Transactional + public Long updatePlant(Long userId, Long id, PlantReqDto reqDto) { + // 식물 찾아와서 소유권 확인 + Plant plant = plantRepository.findByIdCustom(id); + if(!Objects.equals(userId, plant.getUserId())) throw new CustomException(ExceptionStatus.PLANT_NOT_OWNED); + + plant.update(reqDto.plantName(), reqDto.adoptionDate()); + + return plant.getId(); + } + + @Transactional + public void deletePlant(Long userId, Long id) { + Plant plant = plantRepository.findByIdCustom(id); + if(!Objects.equals(userId, plant.getUserId())) throw new CustomException(ExceptionStatus.PLANT_NOT_OWNED); + plant.softDelete(); + } +} \ No newline at end of file diff --git a/src/main/java/com/sounganization/botanify/domain/garden/service/SpeciesService.java b/src/main/java/com/sounganization/botanify/domain/garden/service/SpeciesService.java new file mode 100644 index 0000000..3034d2f --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/service/SpeciesService.java @@ -0,0 +1,75 @@ +package com.sounganization.botanify.domain.garden.service; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.garden.dto.req.SpeciesReqDto; +import com.sounganization.botanify.domain.garden.dto.res.SpeciesResDto; +import com.sounganization.botanify.domain.garden.entity.Species; +import com.sounganization.botanify.domain.garden.mapper.SpeciesMapper; +import com.sounganization.botanify.domain.garden.repository.SpeciesRepository; +import com.sounganization.botanify.domain.user.enums.UserRole; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SpeciesService { + private final SpeciesRepository speciesRepository; + private final SpeciesMapper speciesMapper; + + @Transactional + public CommonResDto createSpecies(UserRole userRole, SpeciesReqDto reqDto) { + + if (!userRole.equals(UserRole.ADMIN)) throw new CustomException(ExceptionStatus.INVALID_ROLE); + + Species reqSpecies = speciesMapper.toEntity(reqDto); + + Species resSpecies = speciesRepository.save(reqSpecies); + + return speciesMapper.toCreatedDto(resSpecies.getId()); + } + + public Page readAllSpecies(int page, int size) { + + Pageable pageable = PageRequest.of(page - 1, size); + + Page speciesList = speciesRepository.findAllByDeletedYnFalse(pageable); + + return speciesList.map(speciesMapper::toDto); + } + + public SpeciesResDto readSpecies(Long id) { + + Species species = speciesRepository.findByIdCustom(id); + + return speciesMapper.toDto(species); + } + + @Transactional + public CommonResDto updateSpecies(UserRole userRole, Long id, SpeciesReqDto reqDto) { + + if (!userRole.equals(UserRole.ADMIN)) throw new CustomException(ExceptionStatus.INVALID_ROLE); + + Species species = speciesRepository.findByIdCustom(id); + + species.update(reqDto.speciesName(), reqDto.description()); + + return speciesMapper.toUpdatedDto(species.getId()); + } + + @Transactional + public void deleteSpecies(UserRole userRole, Long speciesId) { + + if (!userRole.equals(UserRole.ADMIN)) throw new CustomException(ExceptionStatus.INVALID_ROLE); + + Species species = speciesRepository.findByIdCustom(speciesId); + + species.softDelete(); + } + +} diff --git a/src/main/java/com/sounganization/botanify/domain/s3/controller/S3Controller.java b/src/main/java/com/sounganization/botanify/domain/s3/controller/S3Controller.java new file mode 100644 index 0000000..3f56dab --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/s3/controller/S3Controller.java @@ -0,0 +1,32 @@ +package com.sounganization.botanify.domain.s3.controller; + +import com.sounganization.botanify.domain.s3.dto.req.ImageUploadReqDto; +import com.sounganization.botanify.domain.s3.dto.res.ImageUrlResDto; +import com.sounganization.botanify.domain.s3.service.S3Service; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class S3Controller { + private final S3Service s3service; + + public enum ImageDomainPath { + users, posts, plants, diaries + } + + @PostMapping("/{domain}/images") + public ResponseEntity uploadImage(@PathVariable ImageDomainPath domain, @RequestBody ImageUploadReqDto reqDto) { + ImageUrlResDto resDto = s3service.getPreSignedUrl(String.valueOf(domain), reqDto); + return ResponseEntity.accepted().body(resDto); + } + + @DeleteMapping("/images") + public ResponseEntity deleteImage(@RequestBody ImageUploadReqDto reqDto) { + s3service.deleteImage(reqDto.fileName()); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/com/sounganization/botanify/domain/s3/dto/req/ImageUploadReqDto.java b/src/main/java/com/sounganization/botanify/domain/s3/dto/req/ImageUploadReqDto.java new file mode 100644 index 0000000..394eb29 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/s3/dto/req/ImageUploadReqDto.java @@ -0,0 +1,5 @@ +package com.sounganization.botanify.domain.s3.dto.req; + +public record ImageUploadReqDto( + String fileName +) { } diff --git a/src/main/java/com/sounganization/botanify/domain/s3/dto/res/ImageUrlResDto.java b/src/main/java/com/sounganization/botanify/domain/s3/dto/res/ImageUrlResDto.java new file mode 100644 index 0000000..1e54d94 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/s3/dto/res/ImageUrlResDto.java @@ -0,0 +1,6 @@ +package com.sounganization.botanify.domain.s3.dto.res; + +public record ImageUrlResDto( + String uploadUrl, + String imageUrl +) { } diff --git a/src/main/java/com/sounganization/botanify/domain/s3/service/S3Service.java b/src/main/java/com/sounganization/botanify/domain/s3/service/S3Service.java new file mode 100644 index 0000000..1ec1250 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/s3/service/S3Service.java @@ -0,0 +1,104 @@ +package com.sounganization.botanify.domain.s3.service; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.s3.dto.req.ImageUploadReqDto; +import com.sounganization.botanify.domain.s3.dto.res.ImageUrlResDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.ObjectCannedACL; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3Service { + private final S3Presigner s3Presigner; + private final S3Client s3Client; + + @Value("${aws.s3.bucket}") private String bucket; + @Value("${aws.s3.endpoint}") private String endpoint; + + private static final Long SIGN_LIFE_TIME = 1000*60L; // 1분 + private static final List ALLOWED_EXTENSIONS = List.of(".jpg",".jpeg",".png",".webp"); + + // 전달된 접두사와 파일명으로 key 를 생성하는 pre-signing 기본 로직 메서드 + public ImageUrlResDto getPreSignedUrl(String prefix, ImageUploadReqDto reqDto) { + + // 파일 확장자 검사 + this.checkExtensionAllowed(reqDto.fileName()); + + // prefix:UUID:fileName 규칙의 키를 생성 + String rawKey = String.format("%s:%s:%s", prefix, UUID.randomUUID(), reqDto.fileName()); + + // 업로드를 위한 PreSigned URL 생성 + String preSignedUrl = generatePreSignedUrl(rawKey); + + // 업로드 URL & 공개 조회용 URL 반환 (key 직접 URL 용으로 인코딩 후 문자열에 더하여 반환) + String encodedKey = URLEncoder.encode(rawKey, StandardCharsets.UTF_8); + return new ImageUrlResDto(preSignedUrl, String.format("%s/%s/%s", endpoint, bucket, encodedKey)); + } + + // 리스트 요청 처리용 번들 메서드 + public List getPreSignedUrls(String prefix, List reqDtos) { + return reqDtos.stream().map(reqDto -> this.getPreSignedUrl(prefix, reqDto)).toList(); + } + + public void deleteImage(String imageUrl) { + // endpoint 가 포함되지 않았다면 잘못 저장된 URL 이므로 단락 반환 + if(!imageUrl.startsWith(endpoint)) { + log.warn("S3 로의 잘못된 URL 삭제 요청"); + return; + } + + String encodedKey = imageUrl.substring(imageUrl.lastIndexOf("/") + 1); + String decodedKey = URLDecoder.decode(encodedKey, StandardCharsets.UTF_8); + + try { + s3Client.deleteObject(b -> b.bucket(bucket).key(decodedKey)); + log.info("이미지 삭제 요청. KEY - {}", decodedKey); + } + catch (S3Exception ex) { + log.error("S3 에서 이미지 삭제 실패.", ex); + } + } + + private String generatePreSignedUrl(String key) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .acl(ObjectCannedACL.PUBLIC_READ_WRITE) + .build(); + + PresignedPutObjectRequest preSignedRequest = s3Presigner.presignPutObject(p -> p + .signatureDuration(Duration.ofMillis(SIGN_LIFE_TIME)) + .putObjectRequest(putObjectRequest)); + + return preSignedRequest.url().toString(); + } + + // 서비스에서 허용하는 확장자인지 검사하는 유틸 메서드 + private void checkExtensionAllowed(String fileName) { + // 전달된 실제 파일명의 확장자를 분리하며 소문자로 변환한 값 + String extension = fileName.substring(fileName.lastIndexOf('.')).toLowerCase(); + + // 확장자 값이 정적 패턴 목록에 존재하지 않으면 예외 반환 + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new CustomException(ExceptionStatus.BAD_REQUEST); + } + } + +} diff --git a/src/main/java/com/sounganization/botanify/domain/user/controller/UserController.java b/src/main/java/com/sounganization/botanify/domain/user/controller/UserController.java new file mode 100644 index 0000000..4878ca9 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/controller/UserController.java @@ -0,0 +1,75 @@ +package com.sounganization.botanify.domain.user.controller; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.domain.user.dto.req.UserDeleteReqDto; +import com.sounganization.botanify.domain.user.dto.req.UserUpdateReqDto; +import com.sounganization.botanify.domain.user.dto.res.UserPlantsResDto; +import com.sounganization.botanify.domain.user.dto.res.UserPostsResDto; +import com.sounganization.botanify.domain.user.dto.res.UserResDto; +import com.sounganization.botanify.domain.user.service.UserService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @GetMapping("/me") + public ResponseEntity getUserInfo() { + return ResponseEntity.ok(userService.getUserInfo()); + } + + // 슬픈 친구에요... 지우지 마세요... +// @GetMapping("/me/plants") +// public ResponseEntity getUserInfoWithPlants( +// @RequestParam(defaultValue = "1") int plantPage, +// @RequestParam(defaultValue = "10") int plantSize, +// @RequestParam(defaultValue = "1") int diaryPage, +// @RequestParam(defaultValue = "10") int diarySize) { +// UserPlantsResDto userInfoWithPlants = userService.getUserInfoWithPlants(plantPage, plantSize, diaryPage, diarySize); +// return ResponseEntity.ok(userInfoWithPlants); +// } + + @GetMapping("/me/plants") + public ResponseEntity getUserInfoWithPlants( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + UserPlantsResDto userInfoWithPlants = userService.getUserInfoWithPlants(page, size); + return ResponseEntity.ok(userInfoWithPlants); + } + + @GetMapping("/me/posts") + public ResponseEntity getUserInfoWithPosts( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + UserPostsResDto userInfoWithPosts = userService.getUserInfoWithPosts(page, size); + return ResponseEntity.ok(userInfoWithPosts); + } + + @PutMapping("/me") + public ResponseEntity updateUserInfo(@Valid @RequestBody UserUpdateReqDto userUpdateReqDto) { + CommonResDto response = userService.updateUserInfo(userUpdateReqDto); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/me") + public ResponseEntity deleteUser(@Valid @RequestBody + UserDeleteReqDto userDeleteReqDto, + HttpServletResponse response) { + userService.deleteUser(userDeleteReqDto); + + Cookie cookie = new Cookie("Authorization", null); + cookie.setMaxAge(0); + cookie.setPath("/"); + response.addCookie(cookie); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/user/dto/req/UserDeleteReqDto.java b/src/main/java/com/sounganization/botanify/domain/user/dto/req/UserDeleteReqDto.java new file mode 100644 index 0000000..2e04d62 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/dto/req/UserDeleteReqDto.java @@ -0,0 +1,8 @@ +package com.sounganization.botanify.domain.user.dto.req; + +import jakarta.validation.constraints.NotBlank; + +public record UserDeleteReqDto( + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) {} diff --git a/src/main/java/com/sounganization/botanify/domain/user/dto/req/UserUpdateReqDto.java b/src/main/java/com/sounganization/botanify/domain/user/dto/req/UserUpdateReqDto.java new file mode 100644 index 0000000..25eee1d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/dto/req/UserUpdateReqDto.java @@ -0,0 +1,22 @@ +package com.sounganization.botanify.domain.user.dto.req; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record UserUpdateReqDto( + @NotBlank(message = "비밀번호를 입력해주세요.") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#\\$%\\^&\\*])[A-Za-z\\d!@#\\$%\\^&\\*]{8,}$", + message = "비밀번호는 최소 8자리 이상, 영문 대소문자, 숫자, 특수문자가 각각 1개 이상 포함되어야 합니다." + ) + String password, + String username, + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#\\$%\\^&\\*])[A-Za-z\\d!@#\\$%\\^&\\*]{8,}$", + message = "비밀번호는 최소 8자리 이상, 영문 대소문자, 숫자, 특수문자가 각각 1개 이상 포함되어야 합니다." + ) + String newPassword, + String city, + String town, + String address +) {} diff --git a/src/main/java/com/sounganization/botanify/domain/user/dto/res/UserPlantsResDto.java b/src/main/java/com/sounganization/botanify/domain/user/dto/res/UserPlantsResDto.java new file mode 100644 index 0000000..7d07ed8 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/dto/res/UserPlantsResDto.java @@ -0,0 +1,8 @@ +package com.sounganization.botanify.domain.user.dto.res; + +import com.sounganization.botanify.domain.garden.dto.res.PlantResDto; +import org.springframework.data.domain.Page; + +public record UserPlantsResDto( + UserResDto userInfo, + Page plants) {} diff --git a/src/main/java/com/sounganization/botanify/domain/user/dto/res/UserPostsResDto.java b/src/main/java/com/sounganization/botanify/domain/user/dto/res/UserPostsResDto.java new file mode 100644 index 0000000..0e39aba --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/dto/res/UserPostsResDto.java @@ -0,0 +1,8 @@ +package com.sounganization.botanify.domain.user.dto.res; + +import com.sounganization.botanify.domain.community.dto.res.PostListResDto; +import org.springframework.data.domain.Page; + +public record UserPostsResDto( + UserResDto userInfo, + Page posts) {} diff --git a/src/main/java/com/sounganization/botanify/domain/user/dto/res/UserResDto.java b/src/main/java/com/sounganization/botanify/domain/user/dto/res/UserResDto.java new file mode 100644 index 0000000..34e488c --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/dto/res/UserResDto.java @@ -0,0 +1,10 @@ +package com.sounganization.botanify.domain.user.dto.res; + +import com.sounganization.botanify.domain.user.enums.UserRole; + +public record UserResDto ( + String username, + UserRole role, + String city, + String town, + String address) {} diff --git a/src/main/java/com/sounganization/botanify/domain/user/entity/User.java b/src/main/java/com/sounganization/botanify/domain/user/entity/User.java new file mode 100644 index 0000000..83a713d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/entity/User.java @@ -0,0 +1,68 @@ +package com.sounganization.botanify.domain.user.entity; + +import com.sounganization.botanify.common.entity.Timestamped; +import com.sounganization.botanify.common.security.UserDetailsImpl; +import com.sounganization.botanify.domain.user.enums.UserRole; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "users") +@NoArgsConstructor(access = AccessLevel.PROTECTED) // Builder 사용을 위해 추가, 무분별한 접근을 막기 위해 추가 +public class User extends Timestamped { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Getter + private long id; + + @Column(nullable = false, unique = true, length = 100) + private String email; + + @Getter + @Column(nullable = false, length = 50) + private String username; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + @Getter + @Enumerated(EnumType.STRING) + private UserRole role; + + // 주소 관련 + @Column(nullable = false, length = 50) + @Getter + private String city; + + @Column(nullable = false, length = 50) + @Getter + private String town; + + @Column(nullable = false) + @Getter + private String address; + + private String nx; + private String ny; + + @Builder + public User(String email, String username, String password, UserRole role, String city, String town, String address, String nx, String ny) { + this.email = email; + this.username = username; + this.password = password; + this.role = role; + this.city = city; + this.town = town; + this.address = address; + this.nx = nx; + this.ny = ny; + } + + public UserDetailsImpl toUserDetails() { + return new UserDetailsImpl(id, username, email, password, city, town, role, nx, ny); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/user/enums/UserRole.java b/src/main/java/com/sounganization/botanify/domain/user/enums/UserRole.java new file mode 100644 index 0000000..db68a11 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/enums/UserRole.java @@ -0,0 +1,5 @@ +package com.sounganization.botanify.domain.user.enums; + +public enum UserRole { + ADMIN, USER +} diff --git a/src/main/java/com/sounganization/botanify/domain/user/mapper/UserMapper.java b/src/main/java/com/sounganization/botanify/domain/user/mapper/UserMapper.java new file mode 100644 index 0000000..e776904 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/mapper/UserMapper.java @@ -0,0 +1,10 @@ +package com.sounganization.botanify.domain.user.mapper; + +import com.sounganization.botanify.domain.user.dto.res.UserResDto; +import com.sounganization.botanify.domain.user.entity.User; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface UserMapper { + UserResDto toResDto(User user); +} diff --git a/src/main/java/com/sounganization/botanify/domain/user/projection/UserProjection.java b/src/main/java/com/sounganization/botanify/domain/user/projection/UserProjection.java new file mode 100644 index 0000000..99232aa --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/projection/UserProjection.java @@ -0,0 +1,6 @@ +package com.sounganization.botanify.domain.user.projection; + +public interface UserProjection { + Long getId(); + String getUsername(); +} diff --git a/src/main/java/com/sounganization/botanify/domain/user/repository/UserCustomRepository.java b/src/main/java/com/sounganization/botanify/domain/user/repository/UserCustomRepository.java new file mode 100644 index 0000000..25fc217 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/repository/UserCustomRepository.java @@ -0,0 +1,10 @@ +package com.sounganization.botanify.domain.user.repository; + +import com.sounganization.botanify.domain.user.projection.UserProjection; + +import java.util.List; + +public interface UserCustomRepository { + List findUsernamesByIds(List userIds); + void updateUserInfo(Long id, String username, String password, String city, String town, String address); +} diff --git a/src/main/java/com/sounganization/botanify/domain/user/repository/UserCustomRepositoryImpl.java b/src/main/java/com/sounganization/botanify/domain/user/repository/UserCustomRepositoryImpl.java new file mode 100644 index 0000000..5a7249f --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/repository/UserCustomRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.sounganization.botanify.domain.user.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.sounganization.botanify.domain.user.entity.QUser; +import com.sounganization.botanify.domain.user.projection.UserProjection; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class UserCustomRepositoryImpl implements UserCustomRepository{ + + private final JPAQueryFactory jpaQueryFactory; + private final QUser user = QUser.user; + + @Override + public List findUsernamesByIds(List userIds) { + return jpaQueryFactory.select(Projections.fields(UserProjection.class, + user.id.as("id"), + user.username.as("username") + )) + .from(user) + .where(user.id.in(userIds)) + .fetch(); + } + + @Override + public void updateUserInfo(Long id, String username, String password, String city, String town, String address) { + jpaQueryFactory.update(user) + .set(user.username, username) + .set(user.password, password) + .set(user.city, city) + .set(user.town, town) + .set(user.address, address) + .where(user.id.eq(id)) + .execute(); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/user/repository/UserRepository.java b/src/main/java/com/sounganization/botanify/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..881fdda --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/repository/UserRepository.java @@ -0,0 +1,12 @@ +package com.sounganization.botanify.domain.user.repository; + +import com.sounganization.botanify.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository, UserCustomRepository { + Optional findByEmail(String email); + + boolean existsByEmail(String email); +} diff --git a/src/main/java/com/sounganization/botanify/domain/user/service/UserDetailsServiceImpl.java b/src/main/java/com/sounganization/botanify/domain/user/service/UserDetailsServiceImpl.java new file mode 100644 index 0000000..bbf5d4e --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/service/UserDetailsServiceImpl.java @@ -0,0 +1,29 @@ +package com.sounganization.botanify.domain.user.service; + +import com.sounganization.botanify.domain.user.entity.User; +import com.sounganization.botanify.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) + throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + + if (user.getDeletedYn()) { + throw new UsernameNotFoundException("탈퇴한 사용자입니다."); + } + + return user.toUserDetails(); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/user/service/UserService.java b/src/main/java/com/sounganization/botanify/domain/user/service/UserService.java new file mode 100644 index 0000000..365f8a9 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/user/service/UserService.java @@ -0,0 +1,131 @@ +package com.sounganization.botanify.domain.user.service; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.common.util.JwtUtil; +import com.sounganization.botanify.domain.community.dto.res.PostListResDto; +import com.sounganization.botanify.domain.community.entity.Post; +import com.sounganization.botanify.domain.community.mapper.PostMapper; +import com.sounganization.botanify.domain.community.repository.PostRepository; +import com.sounganization.botanify.domain.garden.dto.res.PlantResDto; +import com.sounganization.botanify.domain.garden.entity.Plant; +import com.sounganization.botanify.domain.garden.repository.PlantRepository; +import com.sounganization.botanify.domain.user.dto.req.UserDeleteReqDto; +import com.sounganization.botanify.domain.user.dto.req.UserUpdateReqDto; +import com.sounganization.botanify.domain.user.dto.res.UserPlantsResDto; +import com.sounganization.botanify.domain.user.dto.res.UserPostsResDto; +import com.sounganization.botanify.domain.user.dto.res.UserResDto; +import com.sounganization.botanify.domain.user.entity.User; +import com.sounganization.botanify.domain.user.mapper.UserMapper; +import com.sounganization.botanify.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.function.Function; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final UserMapper userMapper; + private final PostRepository postRepository; + private final PostMapper postMapper; + private final PlantRepository plantRepository; + private final JwtUtil jwtUtil; + private final PasswordEncoder passwordEncoder; + + public UserResDto getUserInfo() { + User user = getAuthenticatedUser(); + return userMapper.toResDto(user); + } + + public UserPlantsResDto getUserInfoWithPlants(int page, int size) { + User user = getAuthenticatedUser(); + Page plants = getPlants(user.getId(), page, size); + Page plantResDtos = mapPage(plants, this::toPlantResDto); + UserResDto userResDto = userMapper.toResDto(user); + return new UserPlantsResDto(userResDto, plantResDtos); + } + + public UserPostsResDto getUserInfoWithPosts(int page, int size) { + User user = getAuthenticatedUser(); + Page posts = getPosts(user.getId(), page, size); + Page postResDtos = mapPage(posts, postMapper::entityToResDto); + UserResDto userResDto = userMapper.toResDto(user); + return new UserPostsResDto(userResDto, postResDtos); + } + + @Transactional + public CommonResDto updateUserInfo(UserUpdateReqDto updateReqDto) { + User user = getAuthenticatedUser(); + validatePassword(updateReqDto.password(), user.toUserDetails().getPassword()); + String newPassword = getNewPassword(updateReqDto.newPassword(), user.toUserDetails().getPassword()); + userRepository.updateUserInfo(user.getId(), + updateReqDto.username(), + newPassword, + updateReqDto.city(), + updateReqDto.town(), + updateReqDto.address()); + return new CommonResDto(HttpStatus.OK, "회원정보가 수정되었습니다.", user.getId()); + } + + @Transactional + public void deleteUser(UserDeleteReqDto userDeleteReqDto) { + User user = getAuthenticatedUser(); + validatePassword(userDeleteReqDto.password(), user.toUserDetails().getPassword()); + user.softDelete(); + userRepository.save(user); + } + + private User getAuthenticatedUser() { + String userIdStr = jwtUtil.getCurrentUserId(); + Long userId = Long.parseLong(userIdStr); + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ExceptionStatus.USER_DETAILS_NOT_FOUND)); + } + + private Pageable createPageable(int page, int size) { + return PageRequest.of(page - 1, size); + } + + private Page mapPage(Page source, Function mapper) { + return source.map(mapper); + } + + private Page getPlants(Long userId, int page, int size) { + Pageable pageable = createPageable(page, size); + return plantRepository.findAllByUserIdAndDeletedYnFalse(userId, pageable); + } + + private Page getPosts(Long userId, int page, int size) { + Pageable pageable = createPageable(page, size); + return postRepository.findAllByUserIdAndDeletedYnFalse(userId, pageable); + } + + private PlantResDto toPlantResDto(Plant plant) { + return PlantResDto.builder() + .id(plant.getId()) + .plantName(plant.getPlantName()) + .adoptionDate(plant.getAdoptionDate()) + .speciesName(plant.getSpecies().getSpeciesName()) + .build(); + } + + private void validatePassword(String inputPassword, String storedPassword) { + if (!passwordEncoder.matches(inputPassword, storedPassword)) { + throw new CustomException(ExceptionStatus.INVALID_PASSWORD); + } + } + + private String getNewPassword(String newPassword, String currentPassword) { + return newPassword != null ? passwordEncoder.encode(newPassword) : currentPassword; + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/weather/controller/WeatherController.java b/src/main/java/com/sounganization/botanify/domain/weather/controller/WeatherController.java new file mode 100644 index 0000000..f7abfc7 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/weather/controller/WeatherController.java @@ -0,0 +1,24 @@ +package com.sounganization.botanify.domain.weather.controller; + +import com.sounganization.botanify.common.security.UserDetailsImpl; +import com.sounganization.botanify.domain.weather.service.WeatherService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/weather") +public class WeatherController { + + private final WeatherService weatherService; + + @GetMapping("/current") + public String getCurrentWeather(@AuthenticationPrincipal UserDetailsImpl userDetails) { + String nx = userDetails.getNx(); // 사용자의 x 좌표 + String ny = userDetails.getNy(); // 사용자의 y 좌표 + return weatherService.getCurrentWeather(nx, ny); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/weather/service/LocationService.java b/src/main/java/com/sounganization/botanify/domain/weather/service/LocationService.java new file mode 100644 index 0000000..dd1c07b --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/weather/service/LocationService.java @@ -0,0 +1,95 @@ +package com.sounganization.botanify.domain.weather.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +public class LocationService { + + private final RestTemplate restTemplate; // Qualifier 사용을 위해 생성자 주입 + private final String apiKey; + private final String baseUrl; + + public LocationService(@Qualifier("locationRestTemplate") RestTemplate restTemplate, + @Value("${kakao.api.key}") String apiKey, + @Value("${kakao.api.base-url}") String baseUrl) { + this.restTemplate = restTemplate; + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + + public String[] getCoordinates(String city, String town) { + String address = city + " " + town; + String url = String.format("%s?query=%s", baseUrl, address); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "KakaoAK " + apiKey); + + HttpEntity entity = new HttpEntity<>(headers); + + try { + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode root = objectMapper.readTree(response.getBody()); + JsonNode documents = root.path("documents"); + + if (documents.isEmpty()) { + return null; + } + + String longitude = documents.get(0).path("address").path("x").asText(); + String latitude = documents.get(0).path("address").path("y").asText(); + + // 위경도를 격자 좌표로 변환 + return convertToGrid(Double.parseDouble(longitude), Double.parseDouble(latitude)); + } catch (Exception e) { + throw new CustomException(ExceptionStatus.API_INVALID_REQUEST); + } + } + + // 위경도를 격자 좌표로 변환하는 메서드 + private String[] convertToGrid(double lon, double lat) { + double RE = 6371.00877; // 지구 반경(km) + double GRID = 5.0; // 격자 간격(km) + double SLAT1 = 30.0; // 표준 위도1 + double SLAT2 = 60.0; // 표준 위도2 + double OLON = 126.0; // 기준점 경도 + double OLAT = 38.0; // 기준점 위도 + double XO = 43; // 기준점 X좌표 + double YO = 136; // 기준점 Y좌표 + + double DEGRAD = Math.PI / 180.0; + double re = RE / GRID; + double slat1 = SLAT1 * DEGRAD; + double slat2 = SLAT2 * DEGRAD; + double olon = OLON * DEGRAD; + double olat = OLAT * DEGRAD; + + double sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5); + sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn); + double sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5); + sf = Math.pow(sf, sn) * Math.cos(slat1) / sn; + double ro = Math.tan(Math.PI * 0.25 + olat * 0.5); + ro = re * sf / Math.pow(ro, sn); + + double ra = Math.tan(Math.PI * 0.25 + lat * DEGRAD * 0.5); + ra = re * sf / Math.pow(ra, sn); + double theta = lon * DEGRAD - olon; + if (theta > Math.PI) theta -= 2.0 * Math.PI; + if (theta < -Math.PI) theta += 2.0 * Math.PI; + theta *= sn; + + int x = (int) (ra * Math.sin(theta) + XO + 0.5); + int y = (int) (ro - ra * Math.cos(theta) + YO + 0.5); + + return new String[]{String.valueOf(x), String.valueOf(y)}; + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/weather/service/WeatherService.java b/src/main/java/com/sounganization/botanify/domain/weather/service/WeatherService.java new file mode 100644 index 0000000..4d215ff --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/weather/service/WeatherService.java @@ -0,0 +1,113 @@ +package com.sounganization.botanify.domain.weather.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Service +public class WeatherService { + + private final RestTemplate restTemplate; + private final String apiKey; + private final String baseUrl; + + public WeatherService(@Qualifier("weatherRestTemplate") RestTemplate restTemplate, + @Value("${weather.api.key}") String apiKey, + @Value("${weather.api.base-url}") String baseUrl) { + this.restTemplate = restTemplate; + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + + public String getCurrentWeather(String nx, String ny) { + String baseDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String baseTime = getClosestBaseTime(LocalDateTime.now()); + + // UriComponentsBuilder 를 사용하여 URL 생성 + String apiUrl = UriComponentsBuilder.fromHttpUrl(baseUrl) + .path("/getUltraSrtNcst") + .queryParam("serviceKey", "{serviceKey}") // 중괄호로 변수화 + .queryParam("pageNo", 1) + .queryParam("numOfRows", 10) + .queryParam("base_date", baseDate) + .queryParam("base_time", baseTime) + .queryParam("nx", nx) + .queryParam("ny", ny) + .queryParam("dataType", "JSON") + .build(false) // 자동 인코딩 방지 + .expand(apiKey) // {serviceKey} 변수에 apiKey 값 삽입 + .toUriString(); + + try { + ResponseEntity response = restTemplate.getForEntity(apiUrl, String.class); + System.out.println("API Response: " + response.getBody()); // 응답 본문 출력 + + return extractWeatherData(response.getBody()); + } catch (Exception e) { + e.printStackTrace(); + throw new CustomException(ExceptionStatus.API_INVALID_REQUEST); + } + } + + private String extractWeatherData(String jsonResponse) { + try { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode root = objectMapper.readTree(jsonResponse); + JsonNode items = root.path("response").path("body").path("items").path("item"); + + if (items.isMissingNode() || items.isEmpty()) { + return "주어진 위치와 시간에 대한 기상 데이터가 없습니다."; + } + + StringBuilder result = new StringBuilder(); + for (JsonNode item : items) { + String category = item.path("category").asText(); + String obsrValue = item.path("obsrValue").asText(); + + switch (category) { + case "T1H": + result.append("기온: ").append(obsrValue).append("℃\n"); + break; + case "REH": + result.append("습도: ").append(obsrValue).append("%\n"); + break; + case "RN1": + result.append("1시간 강수량: ").append(obsrValue).append("mm\n"); + break; + case "WSD": + result.append("풍속: ").append(obsrValue).append("m/s\n"); + break; + } + } + return result.toString(); + } catch (Exception e) { + e.printStackTrace(); + throw new CustomException(ExceptionStatus.API_DATA_PARSING_ERROR); + } + } + + private String getClosestBaseTime(LocalDateTime now) { + int minute = now.getMinute(); + int hour = now.getHour(); + + if (minute < 45) { + hour -= 1; + } + + if (hour < 0) { + hour = 23; + } + + return String.format("%02d00", hour); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..58bab82 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,33 @@ +spring: + application: + name: Botanify + profiles: + active: dev + jackson: + time-zone: Asia/Seoul + jpa: + open-in-view: false + hibernate: + ddl-auto: update + properties: + hibernate: + show_sql: true + dialect: org.hibernate.dialect.MySQL8Dialect + datasource: + url: jdbc:mysql://localhost:3306/botanify?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul + username: admin + password: admin + driver-class-name: com.mysql.cj.jdbc.Driver + redis: + master: + host: localhost + port: 6379 + cache: + redis: + time-to-live: 600000 # 10분 +aws: + s3: + endpoint: http://localhost:4566 + bucket: botanify-backend-bucket + access-key: admin + secret-key: admin diff --git a/src/test/java/com/sounganization/botanify/BotanifyApplicationTests.java b/src/test/java/com/sounganization/botanify/BotanifyApplicationTests.java new file mode 100644 index 0000000..f828c4e --- /dev/null +++ b/src/test/java/com/sounganization/botanify/BotanifyApplicationTests.java @@ -0,0 +1,13 @@ +package com.sounganization.botanify; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BotanifyApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/sounganization/botanify/ViewHistory/ViewHistorySchedulerTest.java b/src/test/java/com/sounganization/botanify/ViewHistory/ViewHistorySchedulerTest.java new file mode 100644 index 0000000..44b840c --- /dev/null +++ b/src/test/java/com/sounganization/botanify/ViewHistory/ViewHistorySchedulerTest.java @@ -0,0 +1,96 @@ +package com.sounganization.botanify.ViewHistory; + +import com.sounganization.botanify.domain.community.entity.ViewHistory; +import com.sounganization.botanify.domain.community.repository.ViewHistoryRepository; +import com.sounganization.botanify.domain.community.service.SchedulerService; +import jakarta.persistence.EntityManager; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class ViewHistorySchedulerTest { + + @Mock + private ViewHistoryRepository viewHistoryRepository; + @Mock + private EntityManager entityManager; + private SchedulerService schedulerService; + private AtomicBoolean taskExecuted; + + @BeforeEach + void setUp() { + //taskExecuted 플래그를 false로 초기화 + taskExecuted = new AtomicBoolean(false); + + //schedulerService 생성 + schedulerService = new SchedulerService(viewHistoryRepository, entityManager) { + @Override + public void deleteAll() { + super.deleteAll(); // 실제 deleteAll + taskExecuted.set(true); // deleteAll 호출되면 taskExecuted를 true로 설정 + } + }; + } + + @Test + @DisplayName("deleteAll 메서드 호출 테스트") + public void Schedulertest() { + //deleteAll 메서드 별도의 스레드에서 실행 + new Thread(schedulerService::deleteAll).start(); + // Awaitility 사용해서 taskExecuted true 될 때 까지 기다림 + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .untilTrue(taskExecuted); + // deleteAll 메서드가 호출되었는지 확인 + verify(viewHistoryRepository, times(1)).deleteAll(); + verify(entityManager, times(1)).clear(); + } + + @Test + @DisplayName("deleteAll 데이터 삭제 테스트") + public void test() { + //given + ViewHistory viewHistory = ViewHistory.builder() + .postId(1L) + .userId(1L) + .viewedAt(LocalDate.of(2024, 1, 1)) + .build(); + + when(viewHistoryRepository.findAll()) + // 삭제 전 반환값 + .thenReturn(List.of(viewHistory)) + // 삭제 후 반환값 + .thenReturn(Collections.emptyList()); + + //when + //deleteAll 호출 전 데이터 확인 + List beforeDelete = viewHistoryRepository.findAll(); + assertEquals(1, beforeDelete.size(), "삭제 전 데이터는 1개여야 합니다."); + + //deleteAll 호출 + schedulerService.deleteAll(); + + //then + //deleteAll 호출 후 데이터 확인 + List afterDelete = viewHistoryRepository.findAll(); + assertEquals(0, afterDelete.size(), "삭제 후 데이터는 없어야 합니다."); + + //deleteAll 메서드 호출 확인 + verify(viewHistoryRepository, times(1)).deleteAll(); + } + +} diff --git a/src/test/java/com/sounganization/botanify/domain/community/controller/PostControllerTest.java b/src/test/java/com/sounganization/botanify/domain/community/controller/PostControllerTest.java new file mode 100644 index 0000000..fb05b90 --- /dev/null +++ b/src/test/java/com/sounganization/botanify/domain/community/controller/PostControllerTest.java @@ -0,0 +1,172 @@ +package com.sounganization.botanify.domain.community.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.common.security.UserDetailsImpl; +import com.sounganization.botanify.domain.community.dto.req.PostReqDto; +import com.sounganization.botanify.domain.community.dto.req.PostUpdateReqDto; +import com.sounganization.botanify.domain.community.dto.res.PostListResDto; +import com.sounganization.botanify.domain.community.dto.res.PostWithCommentResDto; +import com.sounganization.botanify.domain.community.service.PostService; +import com.sounganization.botanify.domain.user.enums.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@Transactional +@AutoConfigureMockMvc +class PostControllerTest { + + // Controller 테스트를 위한 MockMvc 주입 + @Autowired + private MockMvc mockMvc; + + @Autowired + private WebApplicationContext context; + + @MockitoBean + private PostService postService; + + // dto 직렬화 도구 + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static String serialize(Object object) throws JsonProcessingException { + return objectMapper.writeValueAsString(object); + } + + private Long userId; + private PostReqDto reqDto; + private CommonResDto createdResDto; + private CommonResDto updatedResDto; + private Page resDtos; + private PostWithCommentResDto resDto; + + @BeforeEach + void setUp() { + userId = 1L; + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + reqDto = new PostReqDto("test title", "test content"); + createdResDto = new CommonResDto(HttpStatus.CREATED,"test message"); + updatedResDto = new CommonResDto(HttpStatus.OK,"test message"); + UserDetailsImpl userDetails = new UserDetailsImpl( + userId, + "test user", + "test@email", + "test pw", + "test city", + "test town", + UserRole.USER); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()) + ); + + resDtos = new PageImpl<>(List.of(),PageRequest.of(1,10),0); + resDto = mock(PostWithCommentResDto.class); + } + + @Test + void createPost_Success() throws Exception { + // given + given(postService.createPost(any(PostReqDto.class),eq(userId))).willReturn(createdResDto); + + // when + ResultActions result = mockMvc.perform(post("/api/v1/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(serialize(reqDto))); + + // then + result.andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("test message")) + .andDo(print()); + } + + @Test + void readPosts_Success() throws Exception { + // given + given(postService.readPosts(1,10)).willReturn(resDtos); + + // when + ResultActions result = mockMvc.perform(get("/api/v1/posts") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.page.size").value(10)) + .andDo(print()); + } + + @Test + void readPost_Success() throws Exception { + // given + Long postId = 1L; + given(postService.readPost(postId, null)).willReturn(resDto); + + // when + ResultActions result = mockMvc.perform(get("/api/v1/posts/{postId}", postId) + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()) + .andExpect(content().json(serialize(resDto))) + .andDo(print()); + } + + @Test + void updatePost_Success() throws Exception { + // given + Long postId = 1L; + given(postService.updatePost(eq(postId), any(PostUpdateReqDto.class), eq(userId))).willReturn(updatedResDto); + + // when + ResultActions result = mockMvc.perform(put("/api/v1/posts/{postId}", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(serialize(reqDto))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("test message")) + .andDo(print()); + } + + @Test + void deletePost_Success() throws Exception { + // given + Long postId = 1L; + doNothing().when(postService).deletePost(postId, userId); + + // when + ResultActions result = mockMvc.perform(delete("/api/v1/posts/{postId}", postId) + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isNoContent()) + .andDo(print()); + verify(postService).deletePost(postId, userId); + } +} \ No newline at end of file diff --git a/src/test/java/com/sounganization/botanify/domain/community/entity/PostTest.java b/src/test/java/com/sounganization/botanify/domain/community/entity/PostTest.java new file mode 100644 index 0000000..65b9a25 --- /dev/null +++ b/src/test/java/com/sounganization/botanify/domain/community/entity/PostTest.java @@ -0,0 +1,61 @@ +package com.sounganization.botanify.domain.community.entity; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PostTest { + + private Post post; + + @BeforeEach + void setUp() { + post = Post.builder() + .id(1L) + .title("test title") + .content("test content") + .viewCounts(0) + .userId(1L) + .build(); + } + + @Test + void updatePost_Success() { + // given + String updatedTitle = "updated title"; + String updatedContent = "updated content"; + // when + post.updatePost(updatedTitle, updatedContent); + // then + assertEquals(updatedTitle, post.getTitle()); + assertEquals(updatedContent, post.getContent()); + } + + @Test + void updatePost_Fail() { + // when + post.updatePost(null, null); + // then + assertNotNull(post.getTitle()); + assertNotNull(post.getContent()); + } + + @Test + void before_softDelete_isDeletedYn() { + // when + boolean result = post.isDeletedYn(); + // then + assertFalse(result); + } + + @Test + void after_softDelete_isDeletedYn() { + // given + post.softDelete(); + // when + boolean result = post.isDeletedYn(); + // then + assertTrue(result); + } +} \ No newline at end of file diff --git a/src/test/java/com/sounganization/botanify/domain/community/repository/PostRepositoryTest.java b/src/test/java/com/sounganization/botanify/domain/community/repository/PostRepositoryTest.java new file mode 100644 index 0000000..d8b75df --- /dev/null +++ b/src/test/java/com/sounganization/botanify/domain/community/repository/PostRepositoryTest.java @@ -0,0 +1,81 @@ +package com.sounganization.botanify.domain.community.repository; + +import com.sounganization.botanify.common.config.QueryDslConfig; +import com.sounganization.botanify.domain.community.entity.Post; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@ActiveProfiles("test") +@Import(QueryDslConfig.class) +class PostRepositoryTest { + + @Autowired + private PostRepository postRepository; + + private Post post; + private Post postAlt; + + @BeforeEach + @Transactional + void setUp() { + post = Post.builder() + .title("test title") + .content("test content") + .viewCounts(1).userId(1L).build(); + postAlt = Post.builder() + .title("") + .content("") + .viewCounts(0).userId(2L).build(); + Post deletedPost = Post.builder() + .title("test title") + .content("test content") + .viewCounts(1).userId(1L).build(); + deletedPost.softDelete(); + + postRepository.save(post); + postRepository.save(postAlt); + postRepository.save(deletedPost); + } + + @Test + @Transactional + void findAllByDeletedYnFalse_Success() { + // given + Pageable pageable = PageRequest.of(0,10); + // when + Page result = postRepository.findAllByDeletedYnFalse(pageable); + // then + assertNotNull(result); + assertEquals(2L, result.getTotalElements()); + assertTrue(result.getContent().containsAll(List.of(post,postAlt))); + assertTrue(result.getContent().stream().noneMatch(Post::isDeletedYn)); + } + + @Test + @Transactional + void findAllByUserIdAndDeletedYnFalse_Success() { + // given + Long userId = 1L; + Pageable pageable = PageRequest.of(0,10); + // when + Page result = postRepository.findAllByUserIdAndDeletedYnFalse(userId, pageable); + // then + assertNotNull(result); + assertEquals(1L, result.getTotalElements()); + assertTrue(result.getContent().contains(post)); + assertTrue(result.getContent().stream().noneMatch(Post::isDeletedYn)); + } +} \ No newline at end of file diff --git a/src/test/java/com/sounganization/botanify/domain/community/service/PostServiceTest.java b/src/test/java/com/sounganization/botanify/domain/community/service/PostServiceTest.java new file mode 100644 index 0000000..6fa7cbe --- /dev/null +++ b/src/test/java/com/sounganization/botanify/domain/community/service/PostServiceTest.java @@ -0,0 +1,197 @@ +package com.sounganization.botanify.domain.community.service; + +import com.sounganization.botanify.common.dto.res.CommonResDto; +import com.sounganization.botanify.domain.community.dto.req.PostReqDto; +import com.sounganization.botanify.domain.community.dto.req.PostUpdateReqDto; +import com.sounganization.botanify.domain.community.dto.res.CommentTempDto; +import com.sounganization.botanify.domain.community.dto.res.PostListResDto; +import com.sounganization.botanify.domain.community.dto.res.PostWithCommentResDto; +import com.sounganization.botanify.domain.community.entity.Comment; +import com.sounganization.botanify.domain.community.entity.Post; +import com.sounganization.botanify.domain.community.mapper.PostMapper; +import com.sounganization.botanify.domain.community.repository.CommentRepository; +import com.sounganization.botanify.domain.community.repository.PostRepository; +import com.sounganization.botanify.domain.user.entity.User; +import com.sounganization.botanify.domain.user.projection.UserProjection; +import com.sounganization.botanify.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.*; +import org.springframework.http.HttpStatus; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.*; + +class PostServiceTest { + + // 테스트 대상 + @InjectMocks + private PostService postService; + + // 종속 계층 + @Mock private PostRepository postRepository; + @Mock private CommentRepository commentRepository; + @Mock private UserRepository userRepository; + @Mock private PostMapper postMapper; + @Mock private PopularPostService popularPostService; + + // 종속 계층 - 조회수 어뷰징 관련 + @Mock private ViewHistoryRedisService viewHistoryRedisService; + + + private PostReqDto postReqDto; + private Long userId; + private Post post; + private Post savedPost; + private final List replies = new ArrayList<>(); + private final List thread = new ArrayList<>(); + private final List userIds = new ArrayList<>(); + private final List userProjections = new ArrayList<>(); + private final List commentDtos = new ArrayList<>(); + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + postReqDto = new PostReqDto("test title", "test content"); + userId = 1L; + post = mock(Post.class); + savedPost = Post.builder().id(1L).build(); + + Comment childComment = Comment.builder().id(2L).build(); + replies.add(childComment); + Comment parentComment = Comment.builder().id(1L).userId(1L).childComments(replies).build(); + thread.add(parentComment); + + userIds.add(userId); + userProjections.add(new UserProjection() { + @Override + public Long getId() { + return userId; + } + + @Override + public String getUsername() { + return "test user"; + } + }); + + commentDtos.add(new CommentTempDto(1L,userId,"test user",null)); + } + + @Test + void createPost_Success() { + // given + when(userRepository.findById(userId)).thenReturn(Optional.of(mock(User.class))); + when(postMapper.reqDtoToEntity(postReqDto, userId)).thenReturn(post); + when(postRepository.save(post)).thenReturn(savedPost); + when(postMapper.entityToResDto(savedPost, HttpStatus.CREATED)).thenReturn(mock(CommonResDto.class)); + + // when + CommonResDto result = postService.createPost(postReqDto, userId); + + // then + assertNotNull(result); + verify(userRepository).findById(userId); + verify(postMapper).reqDtoToEntity(postReqDto, userId); + verify(postRepository).save(post); + verify(postMapper).entityToResDto(savedPost, HttpStatus.CREATED); + } + + @Test + void readPosts_Success() { + // given + int page = 1; + int size = 10; + Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending()); + List postList = new ArrayList<>(); + postList.add(Post.builder().id(1L).build()); + Page postPage = new PageImpl<>(postList, pageable, size); + when(postRepository.findAllByDeletedYnFalse(any(Pageable.class))).thenReturn(postPage); + when(postMapper.entityToResDto(any(Post.class))).thenReturn(mock(PostListResDto.class)); + + // when + Page result = postService.readPosts(page, size); + + // then + assertNotNull(result); + assertEquals(1, result.getContent().size()); + verify(postRepository).findAllByDeletedYnFalse(pageable); + verify(postMapper).entityToResDto(postList.get(0)); + } + + @Test + void readPost_Success() { + // given + Long postId = 1L; + Long userId = 1L; + when(postRepository.findById(postId)).thenReturn(Optional.of(post)); + doNothing().when(popularPostService).updatePostScore(postId); + when(commentRepository.findCommentsByPostId(postId)).thenReturn(thread); + when(userRepository.findUsernamesByIds(userIds)).thenReturn(userProjections); + when(postMapper.entityToResDto(eq(post), eq(commentDtos))) + .thenReturn(mock(PostWithCommentResDto.class)); + when(viewHistoryRedisService.isViewHistoryExist(anyLong(),anyLong(),any(LocalDate.class))) + .thenReturn(false); + + // when + PostWithCommentResDto result = postService.readPost(postId, userId); + // then + assertNotNull(result); + verify(postRepository).findById(postId); + verify(popularPostService).updatePostScore(postId); + verify(commentRepository).findCommentsByPostId(postId); + verify(userRepository).findUsernamesByIds(userIds); + verify(postMapper).entityToResDto(eq(post), eq(commentDtos)); + } + + @Test + void updatePost_Success() { + // given + Long postId = 1L; + Long userId = 1L; + PostUpdateReqDto updateReqDto = new PostUpdateReqDto("test title", "test content"); + Post post = Post.builder().userId(userId).build(); + when(postRepository.findById(postId)).thenReturn(Optional.of(post)); + when(postRepository.save(post)).thenReturn(post); + when(postMapper.entityToResDto(post, HttpStatus.OK)).thenReturn(mock(CommonResDto.class)); + + // when + CommonResDto result = postService.updatePost(postId, updateReqDto, userId); + + // then + assertNotNull(result); + verify(postRepository).findById(postId); + verify(postRepository).save(post); + verify(postMapper).entityToResDto(post, HttpStatus.OK); + } + + @Test + void deletePost_Success() { + // given + Long postId = 1L; + Long userId = 1L; + Post post = Post.builder().userId(userId).build(); + List comments = new ArrayList<>(); + comments.add(Comment.builder().id(1L).build()); + when(postRepository.findById(postId)).thenReturn(Optional.of(post)); + when(commentRepository.findCommentsByPostId(postId)).thenReturn(comments); + + // when + postService.deletePost(postId, userId); + + // then + assertEquals(Boolean.TRUE, comments.get(0).getDeletedYn()); + verify(postRepository).findById(postId); + verify(commentRepository).findCommentsByPostId(postId); + } + +} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..a5a982c --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,19 @@ +spring: + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:test + username: sa + password: +# redis: +# master: +# host: localhost +# port: 6379 +# cache: +# redis: +# time-to-live: 600000 # 10분 From 89ac47ac60b81afca287b9364f6962538732c676 Mon Sep 17 00:00:00 2001 From: soung90 Date: Sat, 28 Dec 2024 20:36:30 +0900 Subject: [PATCH 02/37] =?UTF-8?q?remove:=20=EC=A4=91=EB=B3=B5=EB=90=9C=20C?= =?UTF-8?q?hatWebSocketHandler=EC=9D=98=20file=20=EC=82=AD=EC=A0=9C=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/ChatWebSocketHandler.java | 121 ------------------ 1 file changed, 121 deletions(-) delete mode 100644 src/main/java/com/sounganization/botanify/common/config/websocket/ChatWebSocketHandler.java diff --git a/src/main/java/com/sounganization/botanify/common/config/websocket/ChatWebSocketHandler.java b/src/main/java/com/sounganization/botanify/common/config/websocket/ChatWebSocketHandler.java deleted file mode 100644 index a5333b2..0000000 --- a/src/main/java/com/sounganization/botanify/common/config/websocket/ChatWebSocketHandler.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.sounganization.botanify.common.config.websocket; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sounganization.botanify.domain.chat.dto.req.ChatMessageReqDto; -import com.sounganization.botanify.domain.chat.dto.res.ErrorMessageDto; -import com.sounganization.botanify.domain.chat.entity.ChatMessage; -import com.sounganization.botanify.domain.chat.service.ChatMessageService; -import com.sounganization.botanify.domain.chat.service.ChatRoomService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -import java.io.IOException; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -@Component -@Slf4j -@RequiredArgsConstructor -public class ChatWebSocketHandler extends TextWebSocketHandler { - - private final ObjectMapper objectMapper; - private final ChatMessageService chatMessageService; - private final ChatRoomService chatRoomService; - - private final Map> chatRoomSessions = new ConcurrentHashMap<>(); - - @Override - public void afterConnectionEstablished(WebSocketSession session) { - log.info("새로운 WebSocket 연결이 열렸습니다. 세션 ID: {}", session.getId()); - } - - @Override - protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { - String payload = message.getPayload(); - ChatMessageReqDto chatMessage = objectMapper.readValue(payload, ChatMessageReqDto.class); - - Long roomId = chatMessage.roomId(); - Long userId = chatMessage.senderId(); - - try { - switch (chatMessage.type()) { - case ENTER: - handleEnterMessage(session, roomId, userId); - break; - case TALK: - handleChatMessage(session, chatMessage); - break; - case LEAVE: - handleLeaveMessage(session, roomId, userId); - break; - } - } catch (Exception e) { - handleError(session, e); - } - } - - private void handleEnterMessage(WebSocketSession session, Long roomId, Long userId) { - - chatRoomService.getChatRoom(roomId, userId); - - chatRoomSessions.computeIfAbsent(roomId, k -> new ConcurrentHashMap<>()) - .put(userId, session); - - log.info("사용자 {}가 채팅방 {}에 입장했습니다.", userId, roomId); - } - - private void handleChatMessage(WebSocketSession session, ChatMessageReqDto chatMessage) throws IOException { - - ChatMessage savedMessage = chatMessageService.saveMessage( - chatMessage.roomId(), - chatMessage.senderId(), - chatMessage.content() - ); - - String messageJson = objectMapper.writeValueAsString(chatMessage); - broadcastMessage(savedMessage.getChatRoom().getId(), new TextMessage(messageJson)); - } - - private void handleLeaveMessage(WebSocketSession session, Long roomId, Long userId) { - - if (chatRoomSessions.containsKey(roomId)) { - chatRoomSessions.get(roomId).remove(userId); - if (chatRoomSessions.get(roomId).isEmpty()) { - chatRoomSessions.remove(roomId); - } - } - - log.info("사용자 {}가 채팅방 {}에서 퇴장했습니다.", userId, roomId); - } - - private void broadcastMessage(Long roomId, TextMessage message) { - Map roomSessions = chatRoomSessions.get(roomId); - if (roomSessions != null) { - roomSessions.values().forEach(session -> { - try { - if (session.isOpen()) { - session.sendMessage(message); - } - } catch (IOException e) { - log.error("메시지 전송 중 오류 발생: {}", e.getMessage()); - } - }); - } - } - - private void handleError(WebSocketSession session, Exception e) throws IOException { - log.error("WebSocket 오류 발생: {}", e.getMessage()); - ErrorMessageDto errorMessage = new ErrorMessageDto("오류가 발생했습니다: " + e.getMessage()); - session.sendMessage(new TextMessage(objectMapper.writeValueAsString(errorMessage))); - } - - @Override - public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { - log.info("WebSocket 연결이 닫혔습니다. 세션 ID: {}", session.getId()); - } -} From 08c3e8448e433836b52ec8cfa04d5d91a76f46ec Mon Sep 17 00:00:00 2001 From: soung90 Date: Sat, 28 Dec 2024 21:22:29 +0900 Subject: [PATCH 03/37] =?UTF-8?q?feat:=20PlantAlarm=20entity=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/garden/entity/PlantAlarm.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/entity/PlantAlarm.java diff --git a/src/main/java/com/sounganization/botanify/domain/garden/entity/PlantAlarm.java b/src/main/java/com/sounganization/botanify/domain/garden/entity/PlantAlarm.java new file mode 100644 index 0000000..c91c509 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/entity/PlantAlarm.java @@ -0,0 +1,67 @@ +package com.sounganization.botanify.domain.garden.entity; + +import com.sounganization.botanify.common.entity.Timestamped; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class PlantAlarm extends Timestamped { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "plant_id", nullable = false) + private Plant plant; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private AlarmType type; + + @Column(nullable = false) + private LocalDate startDate; + + @Column(nullable = false) + private Integer intervalDays; + + @Column(nullable = false) + private Boolean isEnabled; + + public enum AlarmType { + WATER("물"), + FERTILIZER("비료"), + PESTICIDE("살충제"); + + private final String description; + + AlarmType(String description) { + this.description = description; + } + } + + public void update(LocalDate startDate, Integer intervalDays, Boolean isEnabled) { + this.startDate = startDate; + this.intervalDays = intervalDays; + this.isEnabled = isEnabled; + } + + public void addPlant(Plant plant) { + this.plant = plant; + } + + public void addUserId(Long userId) { + this.userId = userId; + } +} From 7cea908d1b2537020911be471b0393bff5579670 Mon Sep 17 00:00:00 2001 From: soung90 Date: Sat, 28 Dec 2024 21:22:56 +0900 Subject: [PATCH 04/37] =?UTF-8?q?feat:=20PlantAlarm=20DTOs=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/garden/dto/req/PlantAlarmReqDto.java | 13 +++++++++++++ .../garden/dto/req/PlantAlarmUpdateReqDto.java | 10 ++++++++++ .../domain/garden/dto/res/PlantAlarmResDto.java | 16 ++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmUpdateReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/dto/res/PlantAlarmResDto.java diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmReqDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmReqDto.java new file mode 100644 index 0000000..df1d47f --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmReqDto.java @@ -0,0 +1,13 @@ +package com.sounganization.botanify.domain.garden.dto.req; + +import com.sounganization.botanify.domain.garden.entity.PlantAlarm; + +import java.time.LocalDate; + +public record PlantAlarmReqDto( + LocalDate startDate, + Integer intervalDays, + Boolean isEnabled, + PlantAlarm.AlarmType type +) { +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmUpdateReqDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmUpdateReqDto.java new file mode 100644 index 0000000..ed7de1d --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmUpdateReqDto.java @@ -0,0 +1,10 @@ +package com.sounganization.botanify.domain.garden.dto.req; + +import java.time.LocalDate; + +public record PlantAlarmUpdateReqDto( + LocalDate startDate, + Integer intervalDays, + Boolean isEnabled +) { +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/res/PlantAlarmResDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/res/PlantAlarmResDto.java new file mode 100644 index 0000000..d5a152f --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/res/PlantAlarmResDto.java @@ -0,0 +1,16 @@ +package com.sounganization.botanify.domain.garden.dto.res; + +import com.sounganization.botanify.domain.garden.entity.PlantAlarm; + +import java.time.LocalDate; + +public record PlantAlarmResDto( + Long id, + Long plantId, + String plantName, + LocalDate startDate, + Integer intervalDays, + Boolean isEnabled, + PlantAlarm.AlarmType type +) { +} From 610327924447ada2f747fcdc0d423a8505688ac0 Mon Sep 17 00:00:00 2001 From: soung90 Date: Sat, 28 Dec 2024 21:23:33 +0900 Subject: [PATCH 05/37] =?UTF-8?q?feat:=20PlantAlarm=20mapper=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../garden/mapper/PlantAlarmMapper.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/mapper/PlantAlarmMapper.java diff --git a/src/main/java/com/sounganization/botanify/domain/garden/mapper/PlantAlarmMapper.java b/src/main/java/com/sounganization/botanify/domain/garden/mapper/PlantAlarmMapper.java new file mode 100644 index 0000000..7f98426 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/mapper/PlantAlarmMapper.java @@ -0,0 +1,19 @@ +package com.sounganization.botanify.domain.garden.mapper; + +import com.sounganization.botanify.domain.garden.dto.req.PlantAlarmReqDto; +import com.sounganization.botanify.domain.garden.dto.res.PlantAlarmResDto; +import com.sounganization.botanify.domain.garden.entity.PlantAlarm; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface PlantAlarmMapper { + @Mapping(target = "id", ignore = true) + @Mapping(target = "plant", ignore = true) + @Mapping(target = "userId", ignore = true) + PlantAlarm toEntity(PlantAlarmReqDto dto); + + @Mapping(source = "plant.id", target = "plantId") + @Mapping(source = "plant.plantName", target = "plantName") + PlantAlarmResDto toDto(PlantAlarm entity); +} From 6284d62d8047e5e23e61461ba59b572025ecb604 Mon Sep 17 00:00:00 2001 From: soung90 Date: Sat, 28 Dec 2024 21:23:49 +0900 Subject: [PATCH 06/37] =?UTF-8?q?feat:=20PlantAlarm=20repository=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlantAlarmCustomRepository.java | 10 +++++++ .../PlantAlarmCustomRepositoryImpl.java | 27 +++++++++++++++++++ .../repository/PlantAlarmRepository.java | 25 +++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepository.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepositoryImpl.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmRepository.java diff --git a/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepository.java b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepository.java new file mode 100644 index 0000000..3ad6264 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepository.java @@ -0,0 +1,10 @@ +package com.sounganization.botanify.domain.garden.repository; + +import com.sounganization.botanify.domain.garden.entity.PlantAlarm; + +import java.time.LocalDate; +import java.util.List; + +public interface PlantAlarmCustomRepository { + List findDueAlarms(LocalDate date); +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepositoryImpl.java b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepositoryImpl.java new file mode 100644 index 0000000..c1b8e5a --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepositoryImpl.java @@ -0,0 +1,27 @@ +package com.sounganization.botanify.domain.garden.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.sounganization.botanify.domain.garden.entity.PlantAlarm; +import com.sounganization.botanify.domain.garden.entity.QPlantAlarm; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +public class PlantAlarmCustomRepositoryImpl implements PlantAlarmCustomRepository { + private final JPAQueryFactory queryFactory; + + @Override + public List findDueAlarms(LocalDate date) { + QPlantAlarm alarm = QPlantAlarm.plantAlarm; + + return queryFactory + .selectFrom(alarm) + .where( + alarm.isEnabled.isTrue(), + alarm.startDate.loe(date) + ) + .fetch(); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmRepository.java b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmRepository.java new file mode 100644 index 0000000..69a34fc --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmRepository.java @@ -0,0 +1,25 @@ +package com.sounganization.botanify.domain.garden.repository; + +import com.sounganization.botanify.domain.garden.entity.PlantAlarm; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface PlantAlarmRepository extends JpaRepository { + List findByUserIdAndIsEnabledTrue(Long userId); + + List findByPlantIdAndUserId(Long plantId, Long userId); + + Optional findByIdAndUserId(Long id, Long userId); + + @Query("SELECT pa FROM PlantAlarm pa WHERE pa.isEnabled = true AND pa.startDate <= :today") + List findActiveAlarms(@Param("today") LocalDate today); + + void deleteByPlantId(Long plantId); +} From 525a964ec4b9a7d752ff16cd615f26abaeef1220 Mon Sep 17 00:00:00 2001 From: soung90 Date: Sat, 28 Dec 2024 21:24:04 +0900 Subject: [PATCH 07/37] =?UTF-8?q?feat:=20PlantAlarm=20service=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../garden/service/PlantAlarmService.java | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/service/PlantAlarmService.java diff --git a/src/main/java/com/sounganization/botanify/domain/garden/service/PlantAlarmService.java b/src/main/java/com/sounganization/botanify/domain/garden/service/PlantAlarmService.java new file mode 100644 index 0000000..93b69a2 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/service/PlantAlarmService.java @@ -0,0 +1,80 @@ +package com.sounganization.botanify.domain.garden.service; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.garden.dto.req.PlantAlarmReqDto; +import com.sounganization.botanify.domain.garden.dto.req.PlantAlarmUpdateReqDto; +import com.sounganization.botanify.domain.garden.dto.res.PlantAlarmResDto; +import com.sounganization.botanify.domain.garden.entity.Plant; +import com.sounganization.botanify.domain.garden.entity.PlantAlarm; +import com.sounganization.botanify.domain.garden.mapper.PlantAlarmMapper; +import com.sounganization.botanify.domain.garden.repository.PlantAlarmRepository; +import com.sounganization.botanify.domain.garden.repository.PlantRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PlantAlarmService { + private final PlantAlarmRepository plantAlarmRepository; + private final PlantRepository plantRepository; + private final PlantAlarmMapper plantAlarmMapper; + + @Transactional + public Long createAlarm(Long userId, Long plantId, PlantAlarmReqDto reqDto) { + Plant plant = plantRepository.findByIdCustom(plantId); + if (!Objects.equals(userId, plant.getUserId())) { + throw new CustomException(ExceptionStatus.PLANT_NOT_OWNED); + } + + PlantAlarm alarm = plantAlarmMapper.toEntity(reqDto); + alarm.addPlant(plant); + alarm.addUserId(userId); + + return plantAlarmRepository.save(alarm).getId(); + } + + @Transactional(readOnly = true) + public List getUserAlarms(Long userId) { + return plantAlarmRepository.findByUserIdAndIsEnabledTrue(userId) + .stream() + .map(plantAlarmMapper::toDto) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getPlantAlarms(Long userId, Long plantId) { + Plant plant = plantRepository.findByIdCustom(plantId); + if (!Objects.equals(userId, plant.getUserId())) { + throw new CustomException(ExceptionStatus.PLANT_NOT_OWNED); + } + + return plantAlarmRepository.findByPlantIdAndUserId(plantId, userId) + .stream() + .map(plantAlarmMapper::toDto) + .collect(Collectors.toList()); + } + + @Transactional + public void updateAlarm(Long userId, Long alarmId, PlantAlarmUpdateReqDto reqDto) { + PlantAlarm alarm = plantAlarmRepository.findByIdAndUserId(alarmId, userId) + .orElseThrow(() -> new CustomException(ExceptionStatus.ALARM_NOT_FOUND)); + + alarm.update(reqDto.startDate(), reqDto.intervalDays(), reqDto.isEnabled()); + } + + @Transactional + public void deleteAlarm(Long userId, Long alarmId) { + PlantAlarm alarm = plantAlarmRepository.findByIdAndUserId(alarmId, userId) + .orElseThrow(() -> new CustomException(ExceptionStatus.ALARM_NOT_FOUND)); + + plantAlarmRepository.delete(alarm); + } +} From 8657423cd54d5933d2ecceed27231a92ae23d07e Mon Sep 17 00:00:00 2001 From: soung90 Date: Sat, 28 Dec 2024 21:24:38 +0900 Subject: [PATCH 08/37] =?UTF-8?q?fix:=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20message=20=EC=B6=94=EA=B0=80=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../botanify/common/exception/ExceptionStatus.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java b/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java index 60839b8..ee9a57e 100644 --- a/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java +++ b/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java @@ -39,6 +39,9 @@ public enum ExceptionStatus { PLANT_NOT_FOUND(HttpStatus.NOT_FOUND, "식물을 찾을 수 없습니다."), PLANT_NOT_OWNED(HttpStatus.UNAUTHORIZED, "식물의 주인이 아닙니다."), + //plant_alarm + ALARM_NOT_FOUND(HttpStatus.NOT_FOUND, "알람을 찾을 수 없습니다."), + // species SPECIES_NOT_FOUND(HttpStatus.NOT_FOUND, "품종을 찾을 수 없습니다."), From 24b5c236f0401a0fd1b8f8f30b2b64e7e4a5b2e2 Mon Sep 17 00:00:00 2001 From: soung90 Date: Sat, 28 Dec 2024 21:28:47 +0900 Subject: [PATCH 09/37] =?UTF-8?q?feat:=20PlantAlarm=20controller=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PlantAlarmController.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/controller/PlantAlarmController.java diff --git a/src/main/java/com/sounganization/botanify/domain/garden/controller/PlantAlarmController.java b/src/main/java/com/sounganization/botanify/domain/garden/controller/PlantAlarmController.java new file mode 100644 index 0000000..9cdc684 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/controller/PlantAlarmController.java @@ -0,0 +1,67 @@ +package com.sounganization.botanify.domain.garden.controller; + +import com.sounganization.botanify.common.security.UserDetailsImpl; +import com.sounganization.botanify.domain.garden.dto.req.PlantAlarmReqDto; +import com.sounganization.botanify.domain.garden.dto.req.PlantAlarmUpdateReqDto; +import com.sounganization.botanify.domain.garden.dto.res.PlantAlarmResDto; +import com.sounganization.botanify.domain.garden.service.PlantAlarmService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/plants") +@RequiredArgsConstructor +public class PlantAlarmController { + private final PlantAlarmService plantAlarmService; + + @PostMapping("/{plantId}/alarms") + public ResponseEntity createAlarm( + @PathVariable Long plantId, + @RequestBody @Valid PlantAlarmReqDto reqDto, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + Long alarmId = plantAlarmService.createAlarm(userDetails.getId(), plantId, reqDto); + return ResponseEntity.ok(alarmId); + } + + @GetMapping("/alarms") + public ResponseEntity> getUserAlarms( + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + List alarms = plantAlarmService.getUserAlarms(userDetails.getId()); + return ResponseEntity.ok(alarms); + } + + @GetMapping("/{plantId}/alarms") + public ResponseEntity> getPlantAlarms( + @PathVariable Long plantId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + List alarms = plantAlarmService.getPlantAlarms(userDetails.getId(), plantId); + return ResponseEntity.ok(alarms); + } + + @PutMapping("/alarms/{alarmId}") + public ResponseEntity updateAlarm( + @PathVariable Long alarmId, + @RequestBody @Valid PlantAlarmUpdateReqDto reqDto, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + plantAlarmService.updateAlarm(userDetails.getId(), alarmId, reqDto); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/alarms/{alarmId}") + public ResponseEntity deleteAlarm( + @PathVariable Long alarmId, + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + plantAlarmService.deleteAlarm(userDetails.getId(), alarmId); + return ResponseEntity.ok().build(); + } +} From e2c8a565393e0f2cbda5c45253fa3cb9e05a8d24 Mon Sep 17 00:00:00 2001 From: soung90 Date: Sat, 28 Dec 2024 21:54:00 +0900 Subject: [PATCH 10/37] =?UTF-8?q?fix:=20N+1=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EB=8C=80=EB=B9=84=EB=A5=BC=20=EC=9C=84=ED=95=9C=20PlantAlarm?= =?UTF-8?q?=20repository=EC=97=90=20JPA=20fetch=20join=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlantAlarmCustomRepositoryImpl.java | 3 +++ .../garden/repository/PlantAlarmRepository.java | 15 ++++++--------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepositoryImpl.java b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepositoryImpl.java index c1b8e5a..862c5e4 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepositoryImpl.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepositoryImpl.java @@ -2,6 +2,7 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import com.sounganization.botanify.domain.garden.entity.PlantAlarm; +import com.sounganization.botanify.domain.garden.entity.QPlant; import com.sounganization.botanify.domain.garden.entity.QPlantAlarm; import lombok.RequiredArgsConstructor; @@ -15,9 +16,11 @@ public class PlantAlarmCustomRepositoryImpl implements PlantAlarmCustomRepositor @Override public List findDueAlarms(LocalDate date) { QPlantAlarm alarm = QPlantAlarm.plantAlarm; + QPlant plant = QPlant.plant; return queryFactory .selectFrom(alarm) + .join(alarm.plant, plant).fetchJoin() .where( alarm.isEnabled.isTrue(), alarm.startDate.loe(date) diff --git a/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmRepository.java b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmRepository.java index 69a34fc..d6466ea 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmRepository.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmRepository.java @@ -6,20 +6,17 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.time.LocalDate; import java.util.List; import java.util.Optional; @Repository -public interface PlantAlarmRepository extends JpaRepository { - List findByUserIdAndIsEnabledTrue(Long userId); +public interface PlantAlarmRepository extends JpaRepository, PlantAlarmCustomRepository { - List findByPlantIdAndUserId(Long plantId, Long userId); + @Query("SELECT pa FROM PlantAlarm pa JOIN FETCH pa.plant WHERE pa.userId = :userId AND pa.isEnabled = true") + List findByUserIdAndIsEnabledTrue(@Param("userId") Long userId); - Optional findByIdAndUserId(Long id, Long userId); - - @Query("SELECT pa FROM PlantAlarm pa WHERE pa.isEnabled = true AND pa.startDate <= :today") - List findActiveAlarms(@Param("today") LocalDate today); + @Query("SELECT pa FROM PlantAlarm pa JOIN FETCH pa.plant WHERE pa.plant.id = :plantId AND pa.userId = :userId") + List findByPlantIdAndUserId(@Param("plantId") Long plantId, @Param("userId") Long userId); - void deleteByPlantId(Long plantId); + Optional findByIdAndUserId(Long id, Long userId); } From 08266f14dc2b88092277b1ce5f3006b8b77fc0cd Mon Sep 17 00:00:00 2001 From: soung90 Date: Mon, 30 Dec 2024 17:39:24 +0900 Subject: [PATCH 11/37] =?UTF-8?q?feat:=20=EC=8B=9D=EB=AC=BC=20=EB=AC=BC?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20=EC=95=8C=EB=A6=BC=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EC=84=A4=EC=A0=95=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../botanify/common/config/WebConfig.java | 7 ++ .../config/onesignal/OneSignalClient.java | 69 +++++++++++++++++++ .../common/service/NotificationService.java | 66 ++++++++++++++++++ .../garden/scheduler/PlantAlarmScheduler.java | 53 ++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 src/main/java/com/sounganization/botanify/common/config/onesignal/OneSignalClient.java create mode 100644 src/main/java/com/sounganization/botanify/common/service/NotificationService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/garden/scheduler/PlantAlarmScheduler.java diff --git a/src/main/java/com/sounganization/botanify/common/config/WebConfig.java b/src/main/java/com/sounganization/botanify/common/config/WebConfig.java index 1e5766b..4c904a3 100644 --- a/src/main/java/com/sounganization/botanify/common/config/WebConfig.java +++ b/src/main/java/com/sounganization/botanify/common/config/WebConfig.java @@ -1,6 +1,8 @@ package com.sounganization.botanify.common.config; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -19,4 +21,9 @@ public void addCorsMappings(CorsRegistry registry) { .allowCredentials(true) .maxAge(3600); } + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } } diff --git a/src/main/java/com/sounganization/botanify/common/config/onesignal/OneSignalClient.java b/src/main/java/com/sounganization/botanify/common/config/onesignal/OneSignalClient.java new file mode 100644 index 0000000..d995062 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/onesignal/OneSignalClient.java @@ -0,0 +1,69 @@ +package com.sounganization.botanify.common.config.onesignal; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@Component +@Slf4j +public class OneSignalClient { + + @Value("${onesignal.app-id}") + private String appId; + + @Value("${onesignal.rest-api-key}") + private String restApiKey; + + private final RestTemplate restTemplate; + + private static final String ONESIGNAL_API_URL = "https://onesignal.com/api/v1/notifications"; + + public OneSignalClient(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + public void sendNotification(String title, String message, String userId, String platform, Map additionalData) { + Map requestBody = new HashMap<>(); + requestBody.put("app_id", appId); + requestBody.put("headings", Collections.singletonMap("en", title)); + requestBody.put("contents", Collections.singletonMap("en", message)); + requestBody.put("include_external_user_ids", Collections.singletonList(userId)); + + if ("mobile".equals(platform)) { + requestBody.put("data", additionalData); + requestBody.put("ios_badgeType", "Increase"); + requestBody.put("ios_badgeCount", 1); + requestBody.put("android_channel_id", "plant-care-alerts"); + } else { + requestBody.put("web_push_topic", "plant-care"); + requestBody.put("web_buttons", Collections.singletonList( + Map.of("id", "view-plant", "text", "식물 보기", "url", additionalData.get("redirectUrl")) + )); + } + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Basic " + restApiKey); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> request = new HttpEntity<>(requestBody, headers); + + try { + restTemplate.postForEntity(ONESIGNAL_API_URL, request, String.class); + log.info("OneSignal notification 전송 성공 - userId: {}, platform: {}", userId, platform); + } catch (Exception e) { + log.error("OneSignal notification 전송 실패 - userId: {}, platform: {}, error: {}", + userId, platform, e.getMessage()); + throw new CustomException(ExceptionStatus.NOTIFICATION_SEND_FAILED); + } + } +} diff --git a/src/main/java/com/sounganization/botanify/common/service/NotificationService.java b/src/main/java/com/sounganization/botanify/common/service/NotificationService.java new file mode 100644 index 0000000..0ea2e9a --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/service/NotificationService.java @@ -0,0 +1,66 @@ +package com.sounganization.botanify.common.service; + +import com.sounganization.botanify.common.config.onesignal.OneSignalClient; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.garden.entity.PlantAlarm; +import com.sounganization.botanify.domain.user.entity.User; +import com.sounganization.botanify.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Service +@Slf4j +@RequiredArgsConstructor +public class NotificationService { + private final OneSignalClient oneSignalClient; + private final UserRepository userRepository; + + public void sendPlantAlarmNotification(Long userId, String plantName, PlantAlarm.AlarmType alarmType, Long plantId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ExceptionStatus.USER_NOT_FOUND)); + + String title = "Botanify 식물 관리 알림"; + String message = String.format("%s님의 %s의 %s 알림입니다.", + user.getUsername(), + plantName, + getAlarmTypeDescription(alarmType) + ); + + // Web 알림 전송 + try { + Map webData = new HashMap<>(); + webData.put("redirectUrl", "/plants/" + plantId); + oneSignalClient.sendNotification(title, message, userId.toString(), "web", webData); + } catch (Exception e) { + log.error("웹 알림 전송 실패 - 사용자: {}, 식물: {}, 오류: {}", + user.getUsername(), plantName, e.getMessage()); + } + + // Mobile 알림 전송 + try { + Map mobileData = new HashMap<>(); + mobileData.put("plantId", plantId); + mobileData.put("alarmType", alarmType.name()); + mobileData.put("redirectScreen", "PlantDetail"); + mobileData.put("screenParams", Map.of("plantId", plantId)); + + oneSignalClient.sendNotification(title, message, userId.toString(), "mobile", mobileData); + } catch (Exception e) { + log.error("모바일 알림 전송 실패 - 사용자: {}, 식물: {}, 오류: {}", + user.getUsername(), plantName, e.getMessage()); + } + } + + private String getAlarmTypeDescription(PlantAlarm.AlarmType type) { + return switch (type) { + case WATER -> "물 주기"; + case FERTILIZER -> "비료 주기"; + case PESTICIDE -> "살충제 뿌리기"; + }; + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/garden/scheduler/PlantAlarmScheduler.java b/src/main/java/com/sounganization/botanify/domain/garden/scheduler/PlantAlarmScheduler.java new file mode 100644 index 0000000..23a0162 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/garden/scheduler/PlantAlarmScheduler.java @@ -0,0 +1,53 @@ +package com.sounganization.botanify.domain.garden.scheduler; + +import com.sounganization.botanify.common.service.NotificationService; +import com.sounganization.botanify.domain.garden.entity.PlantAlarm; +import com.sounganization.botanify.domain.garden.repository.PlantAlarmRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Component +@Slf4j +@RequiredArgsConstructor +public class PlantAlarmScheduler { + private final PlantAlarmRepository plantAlarmRepository; + private final NotificationService notificationService; + + @Scheduled(cron = "0 0 9 * * *") // 매일 오전 9시 마다 실행 + public void checkPlantAlarms() { + log.info("식물 알림 체크 시작"); + LocalDate today = LocalDate.now(); + + List dueAlarms; + try { + dueAlarms = plantAlarmRepository.findDueAlarms(today); + } catch (Exception e) { + log.error("알람 조회 중 오류 발생: {}", e.getMessage()); + return; + } + + for (PlantAlarm alarm : dueAlarms) { + try { + if (alarm.getIsEnabled()) { + long daysSinceStart = ChronoUnit.DAYS.between(alarm.getStartDate(), today); + if (daysSinceStart % alarm.getIntervalDays() == 0) { + notificationService.sendPlantAlarmNotification( + alarm.getUserId(), + alarm.getPlant().getPlantName(), + alarm.getType(), + alarm.getPlant().getId() + ); + } + } + } catch (Exception e) { + log.error("알림 처리 실패 - 알람 ID: {}, 오류: {}", alarm.getId(), e.getMessage()); + } + } + } +} From e60af0dd6a616cfb224eb2813d61c638495aec39 Mon Sep 17 00:00:00 2001 From: soung90 Date: Mon, 30 Dec 2024 17:41:00 +0900 Subject: [PATCH 12/37] =?UTF-8?q?fix:=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20message=20=EC=B6=94=EA=B0=80=EA=B0=80=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../botanify/common/exception/ExceptionStatus.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java b/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java index ee9a57e..11f08ae 100644 --- a/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java +++ b/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java @@ -68,6 +68,9 @@ public enum ExceptionStatus { MESSAGE_NOT_OWNED(HttpStatus.FORBIDDEN, "메시지 작성자만 삭제할 수 있습니다."), NOT_CHAT_ROOM_PARTICIPANT(HttpStatus.FORBIDDEN, "채팅방 참여자만 삭제할 수 있습니다."), + //OneSignal + NOTIFICATION_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "알림 전송에 실패했습니다"), + // API API_INVALID_REQUEST(HttpStatus.BAD_REQUEST, "API 호출 중 문제가 발생했습니다."), API_DATA_PARSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "API 데이터 파싱 중 문제가 발생했습니다."), From 107e9a8adb55a127da5e438ebdff136356b4e4a4 Mon Sep 17 00:00:00 2001 From: soung90 Date: Mon, 30 Dec 2024 17:42:07 +0900 Subject: [PATCH 13/37] =?UTF-8?q?fix:=20=EC=8B=9D=EB=AC=BC=20=EB=AC=BC?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=EC=9D=98=20test=20code?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationServiceTest.java | 201 +++++++++++++++++ .../service/PlantAlarmSchedulerTest.java | 202 ++++++++++++++++++ .../botanify/utils/TestUtils.java | 87 ++++++++ 3 files changed, 490 insertions(+) create mode 100644 src/test/java/com/sounganization/botanify/domain/garden/service/NotificationServiceTest.java create mode 100644 src/test/java/com/sounganization/botanify/domain/garden/service/PlantAlarmSchedulerTest.java create mode 100644 src/test/java/com/sounganization/botanify/utils/TestUtils.java diff --git a/src/test/java/com/sounganization/botanify/domain/garden/service/NotificationServiceTest.java b/src/test/java/com/sounganization/botanify/domain/garden/service/NotificationServiceTest.java new file mode 100644 index 0000000..f2a4c35 --- /dev/null +++ b/src/test/java/com/sounganization/botanify/domain/garden/service/NotificationServiceTest.java @@ -0,0 +1,201 @@ +package com.sounganization.botanify.domain.garden.service; + +import com.sounganization.botanify.common.config.onesignal.OneSignalClient; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.common.service.NotificationService; +import com.sounganization.botanify.domain.garden.entity.PlantAlarm; +import com.sounganization.botanify.domain.garden.repository.PlantAlarmRepository; +import com.sounganization.botanify.domain.garden.scheduler.PlantAlarmScheduler; +import com.sounganization.botanify.domain.user.entity.User; +import com.sounganization.botanify.domain.user.enums.UserRole; +import com.sounganization.botanify.domain.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static com.sounganization.botanify.utils.TestUtils.createTestUser; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + @Mock + private OneSignalClient oneSignalClient; + + @Mock + private UserRepository userRepository; + + @Mock + private PlantAlarmRepository plantAlarmRepository; + + @InjectMocks + private NotificationService notificationService; + + @InjectMocks + private PlantAlarmScheduler plantAlarmScheduler; + + @Test + @DisplayName("식물 알림 전송 성공 (웹 및 모바일)") + void sendPlantAlarmNotification_Success() { + // Given + Long userId = 1L; + String plantName = "테스트 식물"; + Long plantId = 1L; + PlantAlarm.AlarmType alarmType = PlantAlarm.AlarmType.WATER; + + User user = User.builder() + .email("test@email.com") + .username("testUser") + .password("password") + .role(UserRole.USER) + .city("Seoul") + .town("Gangnam") + .address("123 Test Street") + .nx("1") + .ny("1") + .build(); + + ReflectionTestUtils.setField(user, "id", userId); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + doNothing().when(oneSignalClient).sendNotification(anyString(), anyString(), anyString(), anyString(), anyMap()); + + // When + notificationService.sendPlantAlarmNotification(userId, plantName, alarmType, plantId); + + // Then + verify(oneSignalClient).sendNotification( + eq("Botanify 식물 관리 알림"), + eq("testUser님의 테스트 식물의 물 주기 알림입니다."), + eq("1"), + eq("web"), + argThat(map -> map.get("redirectUrl").equals("/plants/1")) + ); + + verify(oneSignalClient).sendNotification( + eq("Botanify 식물 관리 알림"), + eq("testUser님의 테스트 식물의 물 주기 알림입니다."), + eq("1"), + eq("mobile"), + argThat(map -> map.get("plantId").equals(plantId) + && map.get("alarmType").equals("WATER") + && map.get("redirectScreen").equals("PlantDetail")) + ); + } + + @Test + @DisplayName("사용자를 찾을 수 없을 때 예외 발생") + void sendPlantAlarmNotification_UserNotFound() { + // Given + Long userId = 1L; + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // When & Then + assertThrows(CustomException.class, () -> + notificationService.sendPlantAlarmNotification(userId, "테스트 식물", PlantAlarm.AlarmType.WATER, 1L) + ); + } + + + @Test + @DisplayName("웹 알림 전송 실패 시 모바일 알림은 계속 진행") + void sendPlantAlarmNotification_WebFailureContinuesMobile() { + // Given + Long userId = 1L; + String plantName = "테스트 식물"; + Long plantId = 1L; + PlantAlarm.AlarmType alarmType = PlantAlarm.AlarmType.WATER; + + User user = User.builder() + .email("test@email.com") + .username("testUser") + .password("password") + .role(UserRole.USER) + .city("Seoul") + .town("Gangnam") + .address("123 Test Street") + .nx("1") + .ny("1") + .build(); + + ReflectionTestUtils.setField(user, "id", userId); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + doThrow(new CustomException(ExceptionStatus.NOTIFICATION_SEND_FAILED)) + .when(oneSignalClient) + .sendNotification(anyString(), anyString(), anyString(), eq("web"), anyMap()); + doNothing() + .when(oneSignalClient) + .sendNotification(anyString(), anyString(), anyString(), eq("mobile"), anyMap()); + + // When + notificationService.sendPlantAlarmNotification(userId, plantName, alarmType, plantId); + + // Then + verify(oneSignalClient).sendNotification( + anyString(), anyString(), anyString(), eq("mobile"), anyMap() + ); + } + + @Test + @DisplayName("모바일 알림 전송 실패 시 웹 알림은 계속 진행") + void sendPlantAlarmNotification_MobileFailureContinuesWeb() { + // Given + Long userId = 1L; + String plantName = "테스트 식물"; + Long plantId = 1L; + PlantAlarm.AlarmType alarmType = PlantAlarm.AlarmType.WATER; + + User user = createTestUser(userId); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + // Web succeeds, Mobile fails + doNothing() + .when(oneSignalClient) + .sendNotification(anyString(), anyString(), anyString(), eq("web"), anyMap()); + doThrow(new CustomException(ExceptionStatus.NOTIFICATION_SEND_FAILED)) + .when(oneSignalClient) + .sendNotification(anyString(), anyString(), anyString(), eq("mobile"), anyMap()); + + // When + notificationService.sendPlantAlarmNotification(userId, plantName, alarmType, plantId); + + // Then + verify(oneSignalClient).sendNotification( + anyString(), anyString(), anyString(), eq("web"), anyMap() + ); + } + + @Test + @DisplayName("웹과 모바일 모두 전송 실패시 예외 처리") + void sendPlantAlarmNotification_BothPlatformsFail() { + // Given + Long userId = 1L; + String plantName = "테스트 식물"; + Long plantId = 1L; + PlantAlarm.AlarmType alarmType = PlantAlarm.AlarmType.WATER; + + User user = createTestUser(userId); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + doThrow(new CustomException(ExceptionStatus.NOTIFICATION_SEND_FAILED)) + .when(oneSignalClient) + .sendNotification(anyString(), anyString(), anyString(), anyString(), anyMap()); + + // When + notificationService.sendPlantAlarmNotification(userId, plantName, alarmType, plantId); + + // Then + verify(oneSignalClient, times(2)).sendNotification( + anyString(), anyString(), anyString(), anyString(), anyMap() + ); + } +} diff --git a/src/test/java/com/sounganization/botanify/domain/garden/service/PlantAlarmSchedulerTest.java b/src/test/java/com/sounganization/botanify/domain/garden/service/PlantAlarmSchedulerTest.java new file mode 100644 index 0000000..2cc89a1 --- /dev/null +++ b/src/test/java/com/sounganization/botanify/domain/garden/service/PlantAlarmSchedulerTest.java @@ -0,0 +1,202 @@ +package com.sounganization.botanify.domain.garden.service; + +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.common.service.NotificationService; +import com.sounganization.botanify.domain.garden.entity.Plant; +import com.sounganization.botanify.domain.garden.entity.PlantAlarm; +import com.sounganization.botanify.domain.garden.repository.PlantAlarmRepository; +import com.sounganization.botanify.domain.garden.scheduler.PlantAlarmScheduler; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +import static com.sounganization.botanify.utils.TestUtils.createMixedAlarms; +import static com.sounganization.botanify.utils.TestUtils.createValidAlarms; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PlantAlarmSchedulerTest { + @Mock + private PlantAlarmRepository plantAlarmRepository; + + @Mock + private NotificationService notificationService; + + @InjectMocks + private PlantAlarmScheduler plantAlarmScheduler; + + @Test + @DisplayName("스케줄러 알림 체크 성공") + void checkPlantAlarms_Success() { + // Given + LocalDate today = LocalDate.now(); + Plant plant = Plant.builder() + .plantName("테스트 식물") + .build(); + + PlantAlarm alarm = PlantAlarm.builder() + .plant(plant) + .userId(1L) + .type(PlantAlarm.AlarmType.WATER) + .startDate(today.minusDays(7)) + .intervalDays(7) + .isEnabled(true) + .build(); + + ReflectionTestUtils.setField(plant, "id", 1L); + ReflectionTestUtils.setField(alarm, "id", 1L); + + when(plantAlarmRepository.findDueAlarms(any())).thenReturn(List.of(alarm)); + doNothing().when(notificationService).sendPlantAlarmNotification(anyLong(), anyString(), any(), anyLong()); + + // When + plantAlarmScheduler.checkPlantAlarms(); + + // Then + verify(plantAlarmRepository).findDueAlarms(today); + verify(notificationService).sendPlantAlarmNotification( + eq(1L), + eq("테스트 식물"), + eq(PlantAlarm.AlarmType.WATER), + eq(1L) + ); + } + + @Test + @DisplayName("스케줄러 알림 체크 중 예외 발생") + void checkPlantAlarms_Exception() { + // Given + LocalDate today = LocalDate.now(); + when(plantAlarmRepository.findDueAlarms(any())) + .thenThrow(new RuntimeException("Database error")); + + // When + plantAlarmScheduler.checkPlantAlarms(); + + // Then + verify(plantAlarmRepository).findDueAlarms(today); + verify(notificationService, never()).sendPlantAlarmNotification( + anyLong(), + anyString(), + any(PlantAlarm.AlarmType.class), + eq(1L) + ); + } + + @Test + @DisplayName("비활성화된 알람은 알림을 보내지 않음") + void checkPlantAlarms_DisabledAlarm() { + // Given + LocalDate today = LocalDate.now(); + Plant plant = Plant.builder() + .plantName("테스트 식물") + .build(); + + PlantAlarm alarm = PlantAlarm.builder() + .plant(plant) + .userId(1L) + .type(PlantAlarm.AlarmType.WATER) + .startDate(today.minusDays(7)) + .intervalDays(7) + .isEnabled(false) + .build(); + + ReflectionTestUtils.setField(plant, "id", 1L); + ReflectionTestUtils.setField(alarm, "id", 1L); + + when(plantAlarmRepository.findDueAlarms(any())).thenReturn(List.of(alarm)); + + // When + plantAlarmScheduler.checkPlantAlarms(); + + // Then + verify(notificationService, never()).sendPlantAlarmNotification( + anyLong(), + anyString(), + any(PlantAlarm.AlarmType.class), + anyLong() + ); + } + + @Test + @DisplayName("알람 간격이 맞지 않으면 알림을 보내지 않음") + void checkPlantAlarms_NotDueYet() { + // Given + LocalDate today = LocalDate.now(); + Plant plant = Plant.builder() + .id(1L) + .plantName("테스트 식물") + .build(); + + PlantAlarm alarm = PlantAlarm.builder() + .id(1L) + .plant(plant) + .userId(1L) + .type(PlantAlarm.AlarmType.WATER) + .startDate(today.minusDays(6)) // 7일 간격인데 6일만 지남 + .intervalDays(7) + .isEnabled(true) + .build(); + + when(plantAlarmRepository.findDueAlarms(any())).thenReturn(List.of(alarm)); + + // When + plantAlarmScheduler.checkPlantAlarms(); + + // Then + verify(notificationService, never()).sendPlantAlarmNotification( + anyLong(), + anyString(), + any(PlantAlarm.AlarmType.class), + anyLong() + ); + } + + @Test + @DisplayName("여러 알람 중 일부만 전송 시간이 된 경우") + void checkPlantAlarms_PartialDueAlarms() { + // Given + LocalDate today = LocalDate.now(); + List alarms = createMixedAlarms(today); + when(plantAlarmRepository.findDueAlarms(any())).thenReturn(alarms); + + // When + plantAlarmScheduler.checkPlantAlarms(); + + // Then + verify(notificationService, times(1)).sendPlantAlarmNotification( + anyLong(), anyString(), any(PlantAlarm.AlarmType.class), anyLong() + ); + } + + @Test + @DisplayName("알림 전송 중 일부 실패하는 경우") + void checkPlantAlarms_PartialNotificationFailure() { + // Given + LocalDate today = LocalDate.now(); + List alarms = createValidAlarms(today); + when(plantAlarmRepository.findDueAlarms(any())).thenReturn(alarms); + + doNothing() + .doThrow(new CustomException(ExceptionStatus.NOTIFICATION_SEND_FAILED)) + .when(notificationService) + .sendPlantAlarmNotification(anyLong(), anyString(), any(), anyLong()); + + // When + plantAlarmScheduler.checkPlantAlarms(); + + // Then + verify(notificationService, times(2)).sendPlantAlarmNotification( + anyLong(), anyString(), any(PlantAlarm.AlarmType.class), anyLong() + ); + } +} diff --git a/src/test/java/com/sounganization/botanify/utils/TestUtils.java b/src/test/java/com/sounganization/botanify/utils/TestUtils.java new file mode 100644 index 0000000..d57e5ca --- /dev/null +++ b/src/test/java/com/sounganization/botanify/utils/TestUtils.java @@ -0,0 +1,87 @@ +package com.sounganization.botanify.utils; + +import com.sounganization.botanify.domain.garden.entity.Plant; +import com.sounganization.botanify.domain.garden.entity.PlantAlarm; +import com.sounganization.botanify.domain.user.entity.User; +import com.sounganization.botanify.domain.user.enums.UserRole; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +public class TestUtils { + + private TestUtils() { + } + + public static List createMixedAlarms(LocalDate today) { + Plant plant1 = Plant.builder().plantName("식물1").build(); + Plant plant2 = Plant.builder().plantName("식물2").build(); + ReflectionTestUtils.setField(plant1, "id", 1L); + ReflectionTestUtils.setField(plant2, "id", 2L); + + PlantAlarm dueAlarm = PlantAlarm.builder() + .plant(plant1) + .userId(1L) + .type(PlantAlarm.AlarmType.WATER) + .startDate(today.minusDays(7)) + .intervalDays(7) + .isEnabled(true) + .build(); + + PlantAlarm notDueAlarm = PlantAlarm.builder() + .plant(plant2) + .userId(1L) + .type(PlantAlarm.AlarmType.WATER) + .startDate(today.minusDays(3)) + .intervalDays(7) + .isEnabled(true) + .build(); + + return List.of(dueAlarm, notDueAlarm); + } + + public static List createValidAlarms(LocalDate today) { + Plant plant1 = Plant.builder().plantName("식물1").build(); + Plant plant2 = Plant.builder().plantName("식물2").build(); + ReflectionTestUtils.setField(plant1, "id", 1L); + ReflectionTestUtils.setField(plant2, "id", 2L); + + PlantAlarm alarm1 = PlantAlarm.builder() + .plant(plant1) + .userId(1L) + .type(PlantAlarm.AlarmType.WATER) + .startDate(today.minusDays(7)) + .intervalDays(7) + .isEnabled(true) + .build(); + + PlantAlarm alarm2 = PlantAlarm.builder() + .plant(plant2) + .userId(2L) + .type(PlantAlarm.AlarmType.FERTILIZER) + .startDate(today.minusDays(14)) + .intervalDays(14) + .isEnabled(true) + .build(); + + return List.of(alarm1, alarm2); + } + + public static User createTestUser(Long userId) { + User user = User.builder() + .email("test@email.com") + .username("testUser") + .password("password") + .role(UserRole.USER) + .city("Seoul") + .town("Gangnam") + .address("123 Test Street") + .nx("1") + .ny("1") + .build(); + + ReflectionTestUtils.setField(user, "id", userId); + return user; + } +} From ea0ed673276d8bb33dd10fa80642d8cadd7e795c Mon Sep 17 00:00:00 2001 From: soung90 Date: Mon, 30 Dec 2024 18:22:49 +0900 Subject: [PATCH 14/37] =?UTF-8?q?chore:=20graledlew=20env(=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=97=86=EC=9D=8C)=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradlew | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 gradlew diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 6fecb31f4e7712a31c77ff7370dedc50ae9d285f Mon Sep 17 00:00:00 2001 From: soung90 Date: Mon, 30 Dec 2024 19:20:39 +0900 Subject: [PATCH 15/37] =?UTF-8?q?fix:=20Chat=20entity=EC=97=90=20'@Builder?= =?UTF-8?q?.Default'=20annotation=20=EC=B6=94=EA=B0=80=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sounganization/botanify/domain/chat/entity/ChatMessage.java | 1 + .../com/sounganization/botanify/domain/chat/entity/ChatRoom.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatMessage.java b/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatMessage.java index 6f3522c..1aac2c1 100644 --- a/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatMessage.java +++ b/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatMessage.java @@ -43,6 +43,7 @@ public void setExpirationDate() { } @Column(nullable = false) + @Builder.Default private Boolean delivered = false; public void markAsDelivered() { diff --git a/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatRoom.java b/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatRoom.java index 6a5c4b4..7c391d3 100644 --- a/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatRoom.java +++ b/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatRoom.java @@ -27,5 +27,6 @@ public class ChatRoom extends Timestamped { private Long receiverUserId; @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL) + @Builder.Default private List messages = new ArrayList<>(); } From 013a19001735c1f7fa3ac4f9d362c6c0c3948015 Mon Sep 17 00:00:00 2001 From: soung90 Date: Tue, 31 Dec 2024 09:31:43 +0900 Subject: [PATCH 16/37] =?UTF-8?q?remove:=20WebConfig=EC=97=90=EC=84=9C=20r?= =?UTF-8?q?estTemplate=20Bean=20=EB=B6=84=EB=A6=AC=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sounganization/botanify/common/config/WebConfig.java | 5 ----- .../common/config/restTemplate/RestTemplateConfig.java | 5 +++++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/sounganization/botanify/common/config/WebConfig.java b/src/main/java/com/sounganization/botanify/common/config/WebConfig.java index 4c904a3..589963d 100644 --- a/src/main/java/com/sounganization/botanify/common/config/WebConfig.java +++ b/src/main/java/com/sounganization/botanify/common/config/WebConfig.java @@ -21,9 +21,4 @@ public void addCorsMappings(CorsRegistry registry) { .allowCredentials(true) .maxAge(3600); } - - @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); - } } diff --git a/src/main/java/com/sounganization/botanify/common/config/restTemplate/RestTemplateConfig.java b/src/main/java/com/sounganization/botanify/common/config/restTemplate/RestTemplateConfig.java index 2f79d51..e4714f0 100644 --- a/src/main/java/com/sounganization/botanify/common/config/restTemplate/RestTemplateConfig.java +++ b/src/main/java/com/sounganization/botanify/common/config/restTemplate/RestTemplateConfig.java @@ -15,4 +15,9 @@ public RestTemplate locationRestTemplate() { restTemplate.setUriTemplateHandler(factory); return restTemplate; } + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } } From b31d70e06457e69b66531ba84de7990e9c7416e2 Mon Sep 17 00:00:00 2001 From: soung90 Date: Tue, 31 Dec 2024 09:34:17 +0900 Subject: [PATCH 17/37] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=80=20import=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sounganization/botanify/common/config/WebConfig.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/sounganization/botanify/common/config/WebConfig.java b/src/main/java/com/sounganization/botanify/common/config/WebConfig.java index 589963d..1e5766b 100644 --- a/src/main/java/com/sounganization/botanify/common/config/WebConfig.java +++ b/src/main/java/com/sounganization/botanify/common/config/WebConfig.java @@ -1,8 +1,6 @@ package com.sounganization.botanify.common.config; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestTemplate; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; From aded8845594372ad64a2484f38fa337cc1be088e Mon Sep 17 00:00:00 2001 From: soung90 Date: Tue, 31 Dec 2024 11:50:59 +0900 Subject: [PATCH 18/37] =?UTF-8?q?refactor:=20'TestUtils'=20class=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9D=B4=20'PlantAlarmTestUtils'=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/garden/service/NotificationServiceTest.java | 2 +- .../domain/garden/service/PlantAlarmSchedulerTest.java | 4 ++-- .../utils/{TestUtils.java => PlantAlarmTestUtils.java} | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename src/test/java/com/sounganization/botanify/utils/{TestUtils.java => PlantAlarmTestUtils.java} (97%) diff --git a/src/test/java/com/sounganization/botanify/domain/garden/service/NotificationServiceTest.java b/src/test/java/com/sounganization/botanify/domain/garden/service/NotificationServiceTest.java index f2a4c35..6ad55da 100644 --- a/src/test/java/com/sounganization/botanify/domain/garden/service/NotificationServiceTest.java +++ b/src/test/java/com/sounganization/botanify/domain/garden/service/NotificationServiceTest.java @@ -20,7 +20,7 @@ import java.util.Optional; -import static com.sounganization.botanify.utils.TestUtils.createTestUser; +import static com.sounganization.botanify.utils.PlantAlarmTestUtils.createTestUser; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; diff --git a/src/test/java/com/sounganization/botanify/domain/garden/service/PlantAlarmSchedulerTest.java b/src/test/java/com/sounganization/botanify/domain/garden/service/PlantAlarmSchedulerTest.java index 2cc89a1..9f1b956 100644 --- a/src/test/java/com/sounganization/botanify/domain/garden/service/PlantAlarmSchedulerTest.java +++ b/src/test/java/com/sounganization/botanify/domain/garden/service/PlantAlarmSchedulerTest.java @@ -18,8 +18,8 @@ import java.time.LocalDate; import java.util.List; -import static com.sounganization.botanify.utils.TestUtils.createMixedAlarms; -import static com.sounganization.botanify.utils.TestUtils.createValidAlarms; +import static com.sounganization.botanify.utils.PlantAlarmTestUtils.createMixedAlarms; +import static com.sounganization.botanify.utils.PlantAlarmTestUtils.createValidAlarms; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; diff --git a/src/test/java/com/sounganization/botanify/utils/TestUtils.java b/src/test/java/com/sounganization/botanify/utils/PlantAlarmTestUtils.java similarity index 97% rename from src/test/java/com/sounganization/botanify/utils/TestUtils.java rename to src/test/java/com/sounganization/botanify/utils/PlantAlarmTestUtils.java index d57e5ca..995c71e 100644 --- a/src/test/java/com/sounganization/botanify/utils/TestUtils.java +++ b/src/test/java/com/sounganization/botanify/utils/PlantAlarmTestUtils.java @@ -9,9 +9,9 @@ import java.time.LocalDate; import java.util.List; -public class TestUtils { +public class PlantAlarmTestUtils { - private TestUtils() { + private PlantAlarmTestUtils() { } public static List createMixedAlarms(LocalDate today) { From 53d07225f80046c9705d78541d64c2c21c8a0522 Mon Sep 17 00:00:00 2001 From: soung90 Date: Tue, 31 Dec 2024 16:44:20 +0900 Subject: [PATCH 19/37] =?UTF-8?q?fix:=20=EC=8B=9C=EA=B0=84=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95=EA=B3=BC=20=EA=B0=84=EA=B2=A9=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=ED=95=B4=EA=B2=B0=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=9C=84=ED=95=B4=20field=EA=B3=BC=20method=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../garden/dto/req/PlantAlarmReqDto.java | 10 ++- .../dto/req/PlantAlarmUpdateReqDto.java | 10 ++- .../garden/dto/res/PlantAlarmResDto.java | 10 ++- .../domain/garden/entity/PlantAlarm.java | 64 ++++++++++++++++--- .../garden/mapper/PlantAlarmMapper.java | 4 ++ .../PlantAlarmCustomRepository.java | 4 +- .../PlantAlarmCustomRepositoryImpl.java | 6 +- .../garden/scheduler/PlantAlarmScheduler.java | 27 ++++---- .../garden/service/PlantAlarmService.java | 7 +- 9 files changed, 104 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmReqDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmReqDto.java index df1d47f..5a7140f 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmReqDto.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmReqDto.java @@ -2,11 +2,15 @@ import com.sounganization.botanify.domain.garden.entity.PlantAlarm; -import java.time.LocalDate; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Set; public record PlantAlarmReqDto( - LocalDate startDate, - Integer intervalDays, + LocalDateTime nextAlarmDateTime, + LocalTime preferredTime, + Set alarmDays, Boolean isEnabled, PlantAlarm.AlarmType type ) { diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmUpdateReqDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmUpdateReqDto.java index ed7de1d..116e63d 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmUpdateReqDto.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmUpdateReqDto.java @@ -1,10 +1,14 @@ package com.sounganization.botanify.domain.garden.dto.req; -import java.time.LocalDate; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Set; public record PlantAlarmUpdateReqDto( - LocalDate startDate, - Integer intervalDays, + LocalDateTime nextAlarmDateTime, + LocalTime preferredTime, + Set alarmDays, Boolean isEnabled ) { } diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/res/PlantAlarmResDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/res/PlantAlarmResDto.java index d5a152f..3b4424c 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/dto/res/PlantAlarmResDto.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/res/PlantAlarmResDto.java @@ -2,14 +2,18 @@ import com.sounganization.botanify.domain.garden.entity.PlantAlarm; -import java.time.LocalDate; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Set; public record PlantAlarmResDto( Long id, Long plantId, String plantName, - LocalDate startDate, - Integer intervalDays, + LocalDateTime nextAlarmDateTime, + LocalTime preferredTime, + Set alarmDays, Boolean isEnabled, PlantAlarm.AlarmType type ) { diff --git a/src/main/java/com/sounganization/botanify/domain/garden/entity/PlantAlarm.java b/src/main/java/com/sounganization/botanify/domain/garden/entity/PlantAlarm.java index c91c509..70f4323 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/entity/PlantAlarm.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/entity/PlantAlarm.java @@ -7,7 +7,10 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDate; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Set; @Entity @Getter @@ -31,14 +34,51 @@ public class PlantAlarm extends Timestamped { private AlarmType type; @Column(nullable = false) - private LocalDate startDate; + private LocalDateTime nextAlarmDateTime; // 다음 알람이 울릴 날짜와 시간 @Column(nullable = false) - private Integer intervalDays; + private LocalTime preferredTime; // 사용자가 선호하는 알람 시간 + + @ElementCollection + @CollectionTable(name = "plant_alarm_days", + joinColumns = @JoinColumn(name = "alarm_id")) + @Column(name = "alarm_day") + @Enumerated(EnumType.STRING) + private Set alarmDays; // 알람이 울릴 요일들 @Column(nullable = false) private Boolean isEnabled; + /** + * 다음 알람 시간을 계산하여 업데이트 + */ + public void updateNextAlarm() { + this.nextAlarmDateTime = calculateNextAlarmTime(); + } + + /** + * 다음 알람 시간 계산 + * 1. 현재 시간 기준으로 다음 알람 시간 계산 + * 2. 오늘의 알람 시간이 이미 지났다면 내일부터 체크 + * 3. 설정된 요일 중 가장 가까운 다음 요일 찾기 + */ + private LocalDateTime calculateNextAlarmTime() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime candidate = now.with(preferredTime); + + // 오늘의 알람 시간이 이미 지났다면 내일부터 체크 + if (now.isAfter(candidate)) { + candidate = candidate.plusDays(1); + } + + // 설정된 요일 중 가장 가까운 다음 요일 찾기 + while (!alarmDays.contains(candidate.getDayOfWeek())) { + candidate = candidate.plusDays(1); + } + + return candidate; + } + public enum AlarmType { WATER("물"), FERTILIZER("비료"), @@ -51,12 +91,6 @@ public enum AlarmType { } } - public void update(LocalDate startDate, Integer intervalDays, Boolean isEnabled) { - this.startDate = startDate; - this.intervalDays = intervalDays; - this.isEnabled = isEnabled; - } - public void addPlant(Plant plant) { this.plant = plant; } @@ -64,4 +98,16 @@ public void addPlant(Plant plant) { public void addUserId(Long userId) { this.userId = userId; } + + public void updateSettings( + LocalDateTime nextAlarmDateTime, + LocalTime preferredTime, + Set alarmDays, + Boolean isEnabled + ) { + this.nextAlarmDateTime = nextAlarmDateTime; + this.preferredTime = preferredTime; + this.alarmDays = alarmDays; + this.isEnabled = isEnabled; + } } diff --git a/src/main/java/com/sounganization/botanify/domain/garden/mapper/PlantAlarmMapper.java b/src/main/java/com/sounganization/botanify/domain/garden/mapper/PlantAlarmMapper.java index 7f98426..28e9bc4 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/mapper/PlantAlarmMapper.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/mapper/PlantAlarmMapper.java @@ -15,5 +15,9 @@ public interface PlantAlarmMapper { @Mapping(source = "plant.id", target = "plantId") @Mapping(source = "plant.plantName", target = "plantName") + @Mapping(source = "nextAlarmDateTime", target = "nextAlarmDateTime") + @Mapping(source = "preferredTime", target = "preferredTime") + @Mapping(source = "alarmDays", target = "alarmDays") + @Mapping(source = "type", target = "type") PlantAlarmResDto toDto(PlantAlarm entity); } diff --git a/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepository.java b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepository.java index 3ad6264..5d2f9f0 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepository.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepository.java @@ -2,9 +2,9 @@ import com.sounganization.botanify.domain.garden.entity.PlantAlarm; -import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; public interface PlantAlarmCustomRepository { - List findDueAlarms(LocalDate date); + List findDueAlarms(LocalDateTime currentDateTime); } diff --git a/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepositoryImpl.java b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepositoryImpl.java index 862c5e4..cb8f51e 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepositoryImpl.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/repository/PlantAlarmCustomRepositoryImpl.java @@ -6,7 +6,7 @@ import com.sounganization.botanify.domain.garden.entity.QPlantAlarm; import lombok.RequiredArgsConstructor; -import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; @RequiredArgsConstructor @@ -14,7 +14,7 @@ public class PlantAlarmCustomRepositoryImpl implements PlantAlarmCustomRepositor private final JPAQueryFactory queryFactory; @Override - public List findDueAlarms(LocalDate date) { + public List findDueAlarms(LocalDateTime currentDateTime) { QPlantAlarm alarm = QPlantAlarm.plantAlarm; QPlant plant = QPlant.plant; @@ -23,7 +23,7 @@ public List findDueAlarms(LocalDate date) { .join(alarm.plant, plant).fetchJoin() .where( alarm.isEnabled.isTrue(), - alarm.startDate.loe(date) + alarm.nextAlarmDateTime.loe(currentDateTime) ) .fetch(); } diff --git a/src/main/java/com/sounganization/botanify/domain/garden/scheduler/PlantAlarmScheduler.java b/src/main/java/com/sounganization/botanify/domain/garden/scheduler/PlantAlarmScheduler.java index 23a0162..58b3237 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/scheduler/PlantAlarmScheduler.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/scheduler/PlantAlarmScheduler.java @@ -8,8 +8,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.time.LocalDate; -import java.time.temporal.ChronoUnit; +import java.time.LocalDateTime; import java.util.List; @Component @@ -19,14 +18,14 @@ public class PlantAlarmScheduler { private final PlantAlarmRepository plantAlarmRepository; private final NotificationService notificationService; - @Scheduled(cron = "0 0 9 * * *") // 매일 오전 9시 마다 실행 + @Scheduled(cron = "0 * * * * *") // 매분 실행 public void checkPlantAlarms() { log.info("식물 알림 체크 시작"); - LocalDate today = LocalDate.now(); + LocalDateTime now = LocalDateTime.now(); List dueAlarms; try { - dueAlarms = plantAlarmRepository.findDueAlarms(today); + dueAlarms = plantAlarmRepository.findDueAlarms(now); } catch (Exception e) { log.error("알람 조회 중 오류 발생: {}", e.getMessage()); return; @@ -35,15 +34,15 @@ public void checkPlantAlarms() { for (PlantAlarm alarm : dueAlarms) { try { if (alarm.getIsEnabled()) { - long daysSinceStart = ChronoUnit.DAYS.between(alarm.getStartDate(), today); - if (daysSinceStart % alarm.getIntervalDays() == 0) { - notificationService.sendPlantAlarmNotification( - alarm.getUserId(), - alarm.getPlant().getPlantName(), - alarm.getType(), - alarm.getPlant().getId() - ); - } + notificationService.sendPlantAlarmNotification( + alarm.getUserId(), + alarm.getPlant().getPlantName(), + alarm.getType(), + alarm.getPlant().getId() + ); + // 다음 알람 시간 업데이트 + alarm.updateNextAlarm(); + plantAlarmRepository.save(alarm); } } catch (Exception e) { log.error("알림 처리 실패 - 알람 ID: {}, 오류: {}", alarm.getId(), e.getMessage()); diff --git a/src/main/java/com/sounganization/botanify/domain/garden/service/PlantAlarmService.java b/src/main/java/com/sounganization/botanify/domain/garden/service/PlantAlarmService.java index 93b69a2..99c339c 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/service/PlantAlarmService.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/service/PlantAlarmService.java @@ -67,7 +67,12 @@ public void updateAlarm(Long userId, Long alarmId, PlantAlarmUpdateReqDto reqDto PlantAlarm alarm = plantAlarmRepository.findByIdAndUserId(alarmId, userId) .orElseThrow(() -> new CustomException(ExceptionStatus.ALARM_NOT_FOUND)); - alarm.update(reqDto.startDate(), reqDto.intervalDays(), reqDto.isEnabled()); + alarm.updateSettings( + reqDto.nextAlarmDateTime(), + reqDto.preferredTime(), + reqDto.alarmDays(), + reqDto.isEnabled() + ); } @Transactional From 212085e80f1d71128348fce29355acaac8c00ab9 Mon Sep 17 00:00:00 2001 From: soung90 Date: Tue, 31 Dec 2024 16:45:54 +0900 Subject: [PATCH 20/37] =?UTF-8?q?fix:=20PlantAlarmController=EC=97=90=20Co?= =?UTF-8?q?mmonResDto=20=EC=A0=81=EC=9A=A9=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../garden/controller/PlantAlarmController.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/sounganization/botanify/domain/garden/controller/PlantAlarmController.java b/src/main/java/com/sounganization/botanify/domain/garden/controller/PlantAlarmController.java index 9cdc684..a86f15f 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/controller/PlantAlarmController.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/controller/PlantAlarmController.java @@ -1,5 +1,6 @@ package com.sounganization.botanify.domain.garden.controller; +import com.sounganization.botanify.common.dto.res.CommonResDto; import com.sounganization.botanify.common.security.UserDetailsImpl; import com.sounganization.botanify.domain.garden.dto.req.PlantAlarmReqDto; import com.sounganization.botanify.domain.garden.dto.req.PlantAlarmUpdateReqDto; @@ -7,6 +8,7 @@ import com.sounganization.botanify.domain.garden.service.PlantAlarmService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -20,13 +22,17 @@ public class PlantAlarmController { private final PlantAlarmService plantAlarmService; @PostMapping("/{plantId}/alarms") - public ResponseEntity createAlarm( + public ResponseEntity createAlarm( @PathVariable Long plantId, @RequestBody @Valid PlantAlarmReqDto reqDto, @AuthenticationPrincipal UserDetailsImpl userDetails ) { Long alarmId = plantAlarmService.createAlarm(userDetails.getId(), plantId, reqDto); - return ResponseEntity.ok(alarmId); + return ResponseEntity.ok(new CommonResDto( + HttpStatus.CREATED, + "알람이 성공적으로 생성되었습니다.", + alarmId + )); } @GetMapping("/alarms") @@ -47,13 +53,16 @@ public ResponseEntity> getPlantAlarms( } @PutMapping("/alarms/{alarmId}") - public ResponseEntity updateAlarm( + public ResponseEntity updateAlarm( @PathVariable Long alarmId, @RequestBody @Valid PlantAlarmUpdateReqDto reqDto, @AuthenticationPrincipal UserDetailsImpl userDetails ) { plantAlarmService.updateAlarm(userDetails.getId(), alarmId, reqDto); - return ResponseEntity.ok().build(); + return ResponseEntity.ok(new CommonResDto( + HttpStatus.OK, + "알람이 성공적으로 수정되었습니다." + )); } @DeleteMapping("/alarms/{alarmId}") From c3574548233403a99aca90f36545956c439ee762 Mon Sep 17 00:00:00 2001 From: soung90 Date: Tue, 31 Dec 2024 16:46:33 +0900 Subject: [PATCH 21/37] =?UTF-8?q?fix:=20PlantAlarm=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?test=20code=20=EC=88=98=EC=A0=95=20#80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationServiceTest.java | 8 --- .../service/PlantAlarmSchedulerTest.java | 55 ++++++++++++------- .../botanify/utils/PlantAlarmTestUtils.java | 31 +++++++---- 3 files changed, 56 insertions(+), 38 deletions(-) diff --git a/src/test/java/com/sounganization/botanify/domain/garden/service/NotificationServiceTest.java b/src/test/java/com/sounganization/botanify/domain/garden/service/NotificationServiceTest.java index 6ad55da..1a2586c 100644 --- a/src/test/java/com/sounganization/botanify/domain/garden/service/NotificationServiceTest.java +++ b/src/test/java/com/sounganization/botanify/domain/garden/service/NotificationServiceTest.java @@ -5,8 +5,6 @@ import com.sounganization.botanify.common.exception.ExceptionStatus; import com.sounganization.botanify.common.service.NotificationService; import com.sounganization.botanify.domain.garden.entity.PlantAlarm; -import com.sounganization.botanify.domain.garden.repository.PlantAlarmRepository; -import com.sounganization.botanify.domain.garden.scheduler.PlantAlarmScheduler; import com.sounganization.botanify.domain.user.entity.User; import com.sounganization.botanify.domain.user.enums.UserRole; import com.sounganization.botanify.domain.user.repository.UserRepository; @@ -33,15 +31,9 @@ class NotificationServiceTest { @Mock private UserRepository userRepository; - @Mock - private PlantAlarmRepository plantAlarmRepository; - @InjectMocks private NotificationService notificationService; - @InjectMocks - private PlantAlarmScheduler plantAlarmScheduler; - @Test @DisplayName("식물 알림 전송 성공 (웹 및 모바일)") void sendPlantAlarmNotification_Success() { diff --git a/src/test/java/com/sounganization/botanify/domain/garden/service/PlantAlarmSchedulerTest.java b/src/test/java/com/sounganization/botanify/domain/garden/service/PlantAlarmSchedulerTest.java index 9f1b956..16e8e06 100644 --- a/src/test/java/com/sounganization/botanify/domain/garden/service/PlantAlarmSchedulerTest.java +++ b/src/test/java/com/sounganization/botanify/domain/garden/service/PlantAlarmSchedulerTest.java @@ -15,7 +15,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; -import java.time.LocalDate; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.util.EnumSet; import java.util.List; import static com.sounganization.botanify.utils.PlantAlarmTestUtils.createMixedAlarms; @@ -38,7 +40,7 @@ class PlantAlarmSchedulerTest { @DisplayName("스케줄러 알림 체크 성공") void checkPlantAlarms_Success() { // Given - LocalDate today = LocalDate.now(); + LocalDateTime now = LocalDateTime.now(); Plant plant = Plant.builder() .plantName("테스트 식물") .build(); @@ -47,8 +49,9 @@ void checkPlantAlarms_Success() { .plant(plant) .userId(1L) .type(PlantAlarm.AlarmType.WATER) - .startDate(today.minusDays(7)) - .intervalDays(7) + .nextAlarmDateTime(now.minusMinutes(5)) + .preferredTime(now.toLocalTime()) + .alarmDays(EnumSet.allOf(DayOfWeek.class)) .isEnabled(true) .build(); @@ -62,20 +65,20 @@ void checkPlantAlarms_Success() { plantAlarmScheduler.checkPlantAlarms(); // Then - verify(plantAlarmRepository).findDueAlarms(today); + verify(plantAlarmRepository).findDueAlarms(any(LocalDateTime.class)); verify(notificationService).sendPlantAlarmNotification( eq(1L), eq("테스트 식물"), eq(PlantAlarm.AlarmType.WATER), eq(1L) ); + verify(plantAlarmRepository).save(any(PlantAlarm.class)); } @Test @DisplayName("스케줄러 알림 체크 중 예외 발생") void checkPlantAlarms_Exception() { // Given - LocalDate today = LocalDate.now(); when(plantAlarmRepository.findDueAlarms(any())) .thenThrow(new RuntimeException("Database error")); @@ -83,12 +86,12 @@ void checkPlantAlarms_Exception() { plantAlarmScheduler.checkPlantAlarms(); // Then - verify(plantAlarmRepository).findDueAlarms(today); + verify(plantAlarmRepository).findDueAlarms(any(LocalDateTime.class)); verify(notificationService, never()).sendPlantAlarmNotification( anyLong(), anyString(), any(PlantAlarm.AlarmType.class), - eq(1L) + anyLong() ); } @@ -96,7 +99,7 @@ void checkPlantAlarms_Exception() { @DisplayName("비활성화된 알람은 알림을 보내지 않음") void checkPlantAlarms_DisabledAlarm() { // Given - LocalDate today = LocalDate.now(); + LocalDateTime now = LocalDateTime.now(); Plant plant = Plant.builder() .plantName("테스트 식물") .build(); @@ -105,8 +108,9 @@ void checkPlantAlarms_DisabledAlarm() { .plant(plant) .userId(1L) .type(PlantAlarm.AlarmType.WATER) - .startDate(today.minusDays(7)) - .intervalDays(7) + .nextAlarmDateTime(now.minusMinutes(5)) + .preferredTime(now.toLocalTime()) + .alarmDays(EnumSet.allOf(DayOfWeek.class)) .isEnabled(false) .build(); @@ -131,7 +135,7 @@ void checkPlantAlarms_DisabledAlarm() { @DisplayName("알람 간격이 맞지 않으면 알림을 보내지 않음") void checkPlantAlarms_NotDueYet() { // Given - LocalDate today = LocalDate.now(); + LocalDateTime now = LocalDateTime.now(); Plant plant = Plant.builder() .id(1L) .plantName("테스트 식물") @@ -142,9 +146,10 @@ void checkPlantAlarms_NotDueYet() { .plant(plant) .userId(1L) .type(PlantAlarm.AlarmType.WATER) - .startDate(today.minusDays(6)) // 7일 간격인데 6일만 지남 - .intervalDays(7) - .isEnabled(true) + .nextAlarmDateTime(now.minusMinutes(5)) + .preferredTime(now.toLocalTime()) + .alarmDays(EnumSet.allOf(DayOfWeek.class)) + .isEnabled(false) .build(); when(plantAlarmRepository.findDueAlarms(any())).thenReturn(List.of(alarm)); @@ -165,8 +170,8 @@ void checkPlantAlarms_NotDueYet() { @DisplayName("여러 알람 중 일부만 전송 시간이 된 경우") void checkPlantAlarms_PartialDueAlarms() { // Given - LocalDate today = LocalDate.now(); - List alarms = createMixedAlarms(today); + LocalDateTime now = LocalDateTime.now(); + List alarms = createMixedAlarms(now); when(plantAlarmRepository.findDueAlarms(any())).thenReturn(alarms); // When @@ -174,7 +179,17 @@ void checkPlantAlarms_PartialDueAlarms() { // Then verify(notificationService, times(1)).sendPlantAlarmNotification( - anyLong(), anyString(), any(PlantAlarm.AlarmType.class), anyLong() + eq(1L), + eq("식물1"), + eq(PlantAlarm.AlarmType.WATER), + eq(1L) + ); + + verify(notificationService, never()).sendPlantAlarmNotification( + eq(2L), + eq("식물2"), + any(PlantAlarm.AlarmType.class), + eq(2L) ); } @@ -182,8 +197,8 @@ void checkPlantAlarms_PartialDueAlarms() { @DisplayName("알림 전송 중 일부 실패하는 경우") void checkPlantAlarms_PartialNotificationFailure() { // Given - LocalDate today = LocalDate.now(); - List alarms = createValidAlarms(today); + LocalDateTime now = LocalDateTime.now(); + List alarms = createValidAlarms(now); when(plantAlarmRepository.findDueAlarms(any())).thenReturn(alarms); doNothing() diff --git a/src/test/java/com/sounganization/botanify/utils/PlantAlarmTestUtils.java b/src/test/java/com/sounganization/botanify/utils/PlantAlarmTestUtils.java index 995c71e..4a51f0c 100644 --- a/src/test/java/com/sounganization/botanify/utils/PlantAlarmTestUtils.java +++ b/src/test/java/com/sounganization/botanify/utils/PlantAlarmTestUtils.java @@ -6,7 +6,10 @@ import com.sounganization.botanify.domain.user.enums.UserRole; import org.springframework.test.util.ReflectionTestUtils; +import java.time.DayOfWeek; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.EnumSet; import java.util.List; public class PlantAlarmTestUtils { @@ -14,7 +17,7 @@ public class PlantAlarmTestUtils { private PlantAlarmTestUtils() { } - public static List createMixedAlarms(LocalDate today) { + public static List createMixedAlarms(LocalDateTime now) { Plant plant1 = Plant.builder().plantName("식물1").build(); Plant plant2 = Plant.builder().plantName("식물2").build(); ReflectionTestUtils.setField(plant1, "id", 1L); @@ -24,24 +27,30 @@ public static List createMixedAlarms(LocalDate today) { .plant(plant1) .userId(1L) .type(PlantAlarm.AlarmType.WATER) - .startDate(today.minusDays(7)) - .intervalDays(7) + .nextAlarmDateTime(now.minusMinutes(5)) + .preferredTime(now.toLocalTime()) + .alarmDays(EnumSet.allOf(DayOfWeek.class)) .isEnabled(true) .build(); + ReflectionTestUtils.setField(dueAlarm, "id", 1L); + PlantAlarm notDueAlarm = PlantAlarm.builder() .plant(plant2) .userId(1L) .type(PlantAlarm.AlarmType.WATER) - .startDate(today.minusDays(3)) - .intervalDays(7) + .nextAlarmDateTime(now.plusHours(1)) + .preferredTime(now.plusHours(1).toLocalTime()) + .alarmDays(EnumSet.allOf(DayOfWeek.class)) .isEnabled(true) .build(); + ReflectionTestUtils.setField(notDueAlarm, "id", 2L); + return List.of(dueAlarm, notDueAlarm); } - public static List createValidAlarms(LocalDate today) { + public static List createValidAlarms(LocalDateTime now) { Plant plant1 = Plant.builder().plantName("식물1").build(); Plant plant2 = Plant.builder().plantName("식물2").build(); ReflectionTestUtils.setField(plant1, "id", 1L); @@ -51,8 +60,9 @@ public static List createValidAlarms(LocalDate today) { .plant(plant1) .userId(1L) .type(PlantAlarm.AlarmType.WATER) - .startDate(today.minusDays(7)) - .intervalDays(7) + .nextAlarmDateTime(now.minusMinutes(5)) + .preferredTime(now.toLocalTime()) + .alarmDays(EnumSet.allOf(DayOfWeek.class)) .isEnabled(true) .build(); @@ -60,8 +70,9 @@ public static List createValidAlarms(LocalDate today) { .plant(plant2) .userId(2L) .type(PlantAlarm.AlarmType.FERTILIZER) - .startDate(today.minusDays(14)) - .intervalDays(14) + .nextAlarmDateTime(now.minusMinutes(10)) + .preferredTime(now.toLocalTime()) + .alarmDays(EnumSet.allOf(DayOfWeek.class)) .isEnabled(true) .build(); From 36f8dad498d85dad77529f3ed2c1e418dfcce6d9 Mon Sep 17 00:00:00 2001 From: soung90 Date: Wed, 1 Jan 2025 22:39:20 +0900 Subject: [PATCH 22/37] =?UTF-8?q?chore:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20de?= =?UTF-8?q?pendencies=20=EC=B6=94=EA=B0=80=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index b48e4d1..3a8ba33 100644 --- a/build.gradle +++ b/build.gradle @@ -94,6 +94,9 @@ dependencies { implementation 'javax.xml.bind:jaxb-api:2.3.1' implementation 'org.glassfish.jaxb:jaxb-runtime:2.3.3' + // Email verification + implementation 'org.springframework.boot:spring-boot-starter-mail' + } tasks.named('test') { From 761dd4f7a5b62f9e78715af98a57cd51daa8a539 Mon Sep 17 00:00:00 2001 From: soung90 Date: Wed, 1 Jan 2025 22:50:23 +0900 Subject: [PATCH 23/37] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EC=9D=98=20=EC=84=A4=EC=A0=95=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/mail/MailConfig.java | 42 +++++++ .../redis/EmailVerificationProperties.java | 12 ++ .../botanify/common/service/EmailService.java | 20 ++++ .../dto/req/EmailVerificationCodeReqDto.java | 13 ++ .../auth/dto/req/EmailVerificationReqDto.java | 10 ++ .../service/EmailVerificationService.java | 112 ++++++++++++++++++ 6 files changed, 209 insertions(+) create mode 100644 src/main/java/com/sounganization/botanify/common/config/mail/MailConfig.java create mode 100644 src/main/java/com/sounganization/botanify/common/config/redis/EmailVerificationProperties.java create mode 100644 src/main/java/com/sounganization/botanify/common/service/EmailService.java create mode 100644 src/main/java/com/sounganization/botanify/domain/auth/dto/req/EmailVerificationCodeReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/auth/dto/req/EmailVerificationReqDto.java create mode 100644 src/main/java/com/sounganization/botanify/domain/auth/service/EmailVerificationService.java diff --git a/src/main/java/com/sounganization/botanify/common/config/mail/MailConfig.java b/src/main/java/com/sounganization/botanify/common/config/mail/MailConfig.java new file mode 100644 index 0000000..c4f417b --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/mail/MailConfig.java @@ -0,0 +1,42 @@ +package com.sounganization.botanify.common.config.mail; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +@Configuration +public class MailConfig { + + @Value("${spring.mail.host}") + private String host; + + @Value("${spring.mail.port}") + private int port; + + @Value("${spring.mail.username}") + private String username; + + @Value("${spring.mail.password}") + private String password; + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(host); + mailSender.setPort(port); + mailSender.setUsername(username); + mailSender.setPassword(password); + + Properties props = mailSender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", "true"); + props.put("mail.smtp.starttls.enable", "true"); + props.put("mail.debug", "true"); + + return mailSender; + } +} diff --git a/src/main/java/com/sounganization/botanify/common/config/redis/EmailVerificationProperties.java b/src/main/java/com/sounganization/botanify/common/config/redis/EmailVerificationProperties.java new file mode 100644 index 0000000..9571832 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/redis/EmailVerificationProperties.java @@ -0,0 +1,12 @@ +package com.sounganization.botanify.common.config.redis; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class EmailVerificationProperties { + private final long verificationTtl; + private final int maxAttempts; + private final long attemptsTtl; +} diff --git a/src/main/java/com/sounganization/botanify/common/service/EmailService.java b/src/main/java/com/sounganization/botanify/common/service/EmailService.java new file mode 100644 index 0000000..0883cbd --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/service/EmailService.java @@ -0,0 +1,20 @@ +package com.sounganization.botanify.common.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailService { + private final JavaMailSender mailSender; + + public void sendVerificationEmail(String to, String verificationCode) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject("Botanify 이메일 인증"); + message.setText("인증 코드: " + verificationCode + "\n\n이 코드를 입력하여 이메일 인증을 완료해주세요."); + mailSender.send(message); + } +} diff --git a/src/main/java/com/sounganization/botanify/domain/auth/dto/req/EmailVerificationCodeReqDto.java b/src/main/java/com/sounganization/botanify/domain/auth/dto/req/EmailVerificationCodeReqDto.java new file mode 100644 index 0000000..b4b4b33 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/auth/dto/req/EmailVerificationCodeReqDto.java @@ -0,0 +1,13 @@ +package com.sounganization.botanify.domain.auth.dto.req; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record EmailVerificationCodeReqDto( + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + + @NotBlank(message = "인증 코드는 필수 입력 값입니다.") + String code +) {} diff --git a/src/main/java/com/sounganization/botanify/domain/auth/dto/req/EmailVerificationReqDto.java b/src/main/java/com/sounganization/botanify/domain/auth/dto/req/EmailVerificationReqDto.java new file mode 100644 index 0000000..1aa0226 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/auth/dto/req/EmailVerificationReqDto.java @@ -0,0 +1,10 @@ +package com.sounganization.botanify.domain.auth.dto.req; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record EmailVerificationReqDto( + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email +) {} diff --git a/src/main/java/com/sounganization/botanify/domain/auth/service/EmailVerificationService.java b/src/main/java/com/sounganization/botanify/domain/auth/service/EmailVerificationService.java new file mode 100644 index 0000000..229bd2c --- /dev/null +++ b/src/main/java/com/sounganization/botanify/domain/auth/service/EmailVerificationService.java @@ -0,0 +1,112 @@ +package com.sounganization.botanify.domain.auth.service; + +import com.sounganization.botanify.common.config.redis.EmailVerificationProperties; +import com.sounganization.botanify.common.exception.CustomException; +import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.common.service.EmailService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Random; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class EmailVerificationService { + private static final String EMAIL_VERIFICATION_PREFIX = "email:verification:"; + private static final String EMAIL_VERIFIED_PREFIX = "email:verified:"; + private static final String EMAIL_ATTEMPTS_PREFIX = "email:attempts:"; + private static final int VERIFICATION_CODE_LENGTH = 6; + private static final int VERIFICATION_CODE_BOUND = 1000000; // 10^6 + + private final RedisTemplate emailVerificationRedisTemplate; + private final EmailService emailService; + private final EmailVerificationProperties properties; + + public void sendVerificationCode(String email) { + try { + if (!canSendVerificationCode(email)) { + throw new CustomException(ExceptionStatus.VERIFICATION_CODE_RECENTLY_SENT); + } + + String verificationCode = generateVerificationCode(); + String redisKey = EMAIL_VERIFICATION_PREFIX + email; + + emailVerificationRedisTemplate.opsForValue().set( + redisKey, + verificationCode, + properties.getVerificationTtl(), + TimeUnit.SECONDS + ); + + emailService.sendVerificationEmail(email, verificationCode); + } catch (Exception e) { + throw new CustomException(ExceptionStatus.EMAIL_VERIFICATION_FAILED); + } + } + + public boolean isEmailVerified(String email) { + String redisKey = EMAIL_VERIFIED_PREFIX + email; + String verificationStatus = emailVerificationRedisTemplate.opsForValue().get(redisKey); + return Boolean.TRUE.toString().equals(verificationStatus); + } + + public boolean verifyCode(String email, String code) { + String attemptsKey = EMAIL_ATTEMPTS_PREFIX + email; + String attempts = emailVerificationRedisTemplate.opsForValue().get(attemptsKey); + int currentAttempts = attempts != null ? Integer.parseInt(attempts) : 0; + + if (currentAttempts >= properties.getMaxAttempts()) { + throw new CustomException(ExceptionStatus.MAX_VERIFICATION_ATTEMPTS_EXCEEDED); + } + + String redisKey = EMAIL_VERIFICATION_PREFIX + email; + String storedCode = emailVerificationRedisTemplate.opsForValue().get(redisKey); + + if (storedCode != null && storedCode.equals(code)) { + markEmailAsVerified(email); + emailVerificationRedisTemplate.delete(attemptsKey); + return true; + } + + emailVerificationRedisTemplate.opsForValue().set( + attemptsKey, + String.valueOf(currentAttempts + 1), + properties.getAttemptsTtl(), + TimeUnit.SECONDS + ); + return false; + } + + private void markEmailAsVerified(String email) { + String redisKey = EMAIL_VERIFIED_PREFIX + email; + emailVerificationRedisTemplate.opsForValue().set( + redisKey, + Boolean.TRUE.toString(), + properties.getVerificationTtl(), + TimeUnit.SECONDS + ); + } + + public boolean canSendVerificationCode(String email) { + String redisKey = EMAIL_VERIFICATION_PREFIX + email; + return emailVerificationRedisTemplate.opsForValue().get(redisKey) == null; + } + + public void clearVerification(String email) { + String verificationKey = EMAIL_VERIFICATION_PREFIX + email; + String verifiedKey = EMAIL_VERIFIED_PREFIX + email; + String attemptsKey = EMAIL_ATTEMPTS_PREFIX + email; + + emailVerificationRedisTemplate.delete(verificationKey); + emailVerificationRedisTemplate.delete(verifiedKey); + emailVerificationRedisTemplate.delete(attemptsKey); + } + + private String generateVerificationCode() { + Random random = new Random(); + int code = random.nextInt(VERIFICATION_CODE_BOUND); + return String.format("%0" + VERIFICATION_CODE_LENGTH + "d", code); + } +} From 70dfdfb2ee2204655dd343567ba9b9c0b9777c9e Mon Sep 17 00:00:00 2001 From: soung90 Date: Wed, 1 Jan 2025 22:52:05 +0900 Subject: [PATCH 24/37] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EC=9D=98=20Redis=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../redis/EmailVerificationRedisConfig.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/main/java/com/sounganization/botanify/common/config/redis/EmailVerificationRedisConfig.java diff --git a/src/main/java/com/sounganization/botanify/common/config/redis/EmailVerificationRedisConfig.java b/src/main/java/com/sounganization/botanify/common/config/redis/EmailVerificationRedisConfig.java new file mode 100644 index 0000000..3b976b1 --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/redis/EmailVerificationRedisConfig.java @@ -0,0 +1,37 @@ +package com.sounganization.botanify.common.config.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class EmailVerificationRedisConfig { + + @Value("${spring.redis.verification.ttl:300}") + private long verificationTtl; + + @Value("${spring.redis.verification.max-attempts:5}") + private int maxAttempts; + + @Value("${spring.redis.verification.attempts-ttl:3600}") + private long attemptsTtl; + + @Bean + public RedisTemplate emailVerificationRedisTemplate(RedisTemplate redisTemplate) { + RedisTemplate emailTemplate = new RedisTemplate<>(); + emailTemplate.setConnectionFactory(redisTemplate.getConnectionFactory()); + emailTemplate.setKeySerializer(new StringRedisSerializer()); + emailTemplate.setValueSerializer(new StringRedisSerializer()); + emailTemplate.setHashKeySerializer(new StringRedisSerializer()); + emailTemplate.setHashValueSerializer(new StringRedisSerializer()); + emailTemplate.afterPropertiesSet(); + return emailTemplate; + } + + @Bean + public EmailVerificationProperties emailVerificationProperties() { + return new EmailVerificationProperties(verificationTtl, maxAttempts, attemptsTtl); + } +} From ecc862677790f60709b113a30fffb8067f09f793 Mon Sep 17 00:00:00 2001 From: soung90 Date: Wed, 1 Jan 2025 22:56:10 +0900 Subject: [PATCH 25/37] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EC=9D=98=20API=20endpoints=EA=B3=BC=20logic?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 20 +++++++++++++++++++ .../domain/auth/service/AuthService.java | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/src/main/java/com/sounganization/botanify/domain/auth/controller/AuthController.java b/src/main/java/com/sounganization/botanify/domain/auth/controller/AuthController.java index 72aa7c2..71a81ca 100644 --- a/src/main/java/com/sounganization/botanify/domain/auth/controller/AuthController.java +++ b/src/main/java/com/sounganization/botanify/domain/auth/controller/AuthController.java @@ -3,13 +3,17 @@ import com.sounganization.botanify.common.dto.res.CommonResDto; import com.sounganization.botanify.common.exception.CustomException; import com.sounganization.botanify.common.exception.ExceptionStatus; +import com.sounganization.botanify.domain.auth.dto.req.EmailVerificationCodeReqDto; +import com.sounganization.botanify.domain.auth.dto.req.EmailVerificationReqDto; import com.sounganization.botanify.domain.auth.dto.req.SigninReqDto; import com.sounganization.botanify.domain.auth.dto.req.SignupReqDto; import com.sounganization.botanify.domain.auth.service.AuthService; +import com.sounganization.botanify.domain.auth.service.EmailVerificationService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -24,12 +28,28 @@ public class AuthController { private final AuthService authService; + private final EmailVerificationService emailVerificationService; @PostMapping("/signup") public ResponseEntity signup(@Valid @RequestBody SignupReqDto request) { return authService.signup(request); } + @PostMapping("/verify-email") + public ResponseEntity sendVerificationEmail(@Valid @RequestBody EmailVerificationReqDto request) { + emailVerificationService.sendVerificationCode(request.email()); + return ResponseEntity.ok(new CommonResDto(HttpStatus.OK, "인증 코드가 전송되었습니다.")); + } + + @PostMapping("/verify-code") + public ResponseEntity verifyCode(@Valid @RequestBody EmailVerificationCodeReqDto request) { + boolean isValid = emailVerificationService.verifyCode(request.email(), request.code()); + if (!isValid) { + throw new CustomException(ExceptionStatus.INVALID_VERIFICATION_CODE); + } + return ResponseEntity.ok(new CommonResDto(HttpStatus.OK, "이메일 인증이 완료되었습니다.")); + } + @PostMapping("/signin") public ResponseEntity signin(@Valid @RequestBody SigninReqDto request, HttpServletResponse response) { ResponseEntity authResDtoResponseEntity = authService.signin(request); diff --git a/src/main/java/com/sounganization/botanify/domain/auth/service/AuthService.java b/src/main/java/com/sounganization/botanify/domain/auth/service/AuthService.java index 9ebae73..ec898fd 100644 --- a/src/main/java/com/sounganization/botanify/domain/auth/service/AuthService.java +++ b/src/main/java/com/sounganization/botanify/domain/auth/service/AuthService.java @@ -30,6 +30,7 @@ public class AuthService { private final AuthenticationManager authenticationManager; private final JwtUtil jwtUtil; private final LocationService locationService; + private final EmailVerificationService emailVerificationService; @Transactional public ResponseEntity signup(SignupReqDto request) { @@ -37,6 +38,10 @@ public ResponseEntity signup(SignupReqDto request) { throw new CustomException(ExceptionStatus.DUPLICATED_EMAIL); } + if (!emailVerificationService.isEmailVerified(request.email())) { + throw new CustomException(ExceptionStatus.EMAIL_NOT_VERIFIED); + } + if (!request.password().equals(request.passwordCheck())) { throw new CustomException(ExceptionStatus.PASSWORDS_DO_NOT_MATCH); } @@ -58,6 +63,9 @@ public ResponseEntity signup(SignupReqDto request) { CommonResDto response = new CommonResDto( HttpStatus.CREATED, "회원가입이 성공되었습니다.", savedUser.getId()); + + emailVerificationService.clearVerification(request.email()); + return ResponseEntity.status(HttpStatus.CREATED).body(response); } From 3522b80addb782682ebcb31e572c4576bdd96d55 Mon Sep 17 00:00:00 2001 From: soung90 Date: Wed, 1 Jan 2025 22:56:55 +0900 Subject: [PATCH 26/37] =?UTF-8?q?fix:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EA=B3=BC=20=EA=B4=80=EB=A0=A8=EB=90=9C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20message=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../botanify/common/exception/ExceptionStatus.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java b/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java index 60e3542..4a1fe43 100644 --- a/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java +++ b/src/main/java/com/sounganization/botanify/common/exception/ExceptionStatus.java @@ -28,6 +28,11 @@ public enum ExceptionStatus { LOGIN_REQUIRED(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."), ACCOUNT_DELETED(HttpStatus.FORBIDDEN, "탈퇴된 사용자입니다."), USER_DETAILS_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자 정보를 찾을 수 없습니다."), + EMAIL_NOT_VERIFIED(HttpStatus.BAD_REQUEST, "이메일 인증이 필요합니다."), + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "잘못된 인증 코드입니다."), + EMAIL_VERIFICATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 인증이 실패했습니다."), + VERIFICATION_CODE_RECENTLY_SENT(HttpStatus.TOO_MANY_REQUESTS, "인증 코드가 이미 전송되었습니다. 잠시 후 다시 시도해주세요."), + MAX_VERIFICATION_ATTEMPTS_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "인증 시도 횟수를 초과했습니다. 잠시 후 다시 시도해주세요."), // user DELETED_USER(HttpStatus.FORBIDDEN, "탈퇴된 사용자입니다."), From c9109fcc253032d59fc0ab53d3c89b467b44263d Mon Sep 17 00:00:00 2001 From: soung90 Date: Sat, 4 Jan 2025 11:28:53 +0900 Subject: [PATCH 27/37] =?UTF-8?q?fix:=20=EC=8B=9D=EB=AC=BC=20=EC=95=8C?= =?UTF-8?q?=EB=9E=8C=20dto=EC=97=90=20validation=20=EC=B6=94=EA=B0=80=20#9?= =?UTF-8?q?0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/garden/dto/req/PlantAlarmReqDto.java | 15 +++++++++++++++ .../garden/dto/req/PlantAlarmUpdateReqDto.java | 14 ++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmReqDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmReqDto.java index 5a7140f..4a95871 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmReqDto.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmReqDto.java @@ -1,6 +1,9 @@ package com.sounganization.botanify.domain.garden.dto.req; import com.sounganization.botanify.domain.garden.entity.PlantAlarm; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import org.springframework.format.annotation.DateTimeFormat; import java.time.DayOfWeek; import java.time.LocalDateTime; @@ -8,10 +11,22 @@ import java.util.Set; public record PlantAlarmReqDto( + + @NotNull(message = "다음 알람 시간은 필수입니다") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime nextAlarmDateTime, + + @NotNull(message = "선호하는 알람 시간은 필수입니다") + @DateTimeFormat(pattern = "HH:mm:ss") LocalTime preferredTime, + + @NotEmpty(message = "알람 요일을 하나 이상 선택해주세요") Set alarmDays, + + @NotNull(message = "알람 활성화 여부는 필수입니다") Boolean isEnabled, + + @NotNull(message = "알람 유형은 필수입니다") PlantAlarm.AlarmType type ) { } diff --git a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmUpdateReqDto.java b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmUpdateReqDto.java index 116e63d..ceb1648 100644 --- a/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmUpdateReqDto.java +++ b/src/main/java/com/sounganization/botanify/domain/garden/dto/req/PlantAlarmUpdateReqDto.java @@ -1,14 +1,28 @@ package com.sounganization.botanify.domain.garden.dto.req; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import org.springframework.format.annotation.DateTimeFormat; + import java.time.DayOfWeek; import java.time.LocalDateTime; import java.time.LocalTime; import java.util.Set; public record PlantAlarmUpdateReqDto( + + @NotNull(message = "다음 알람 시간은 필수입니다") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime nextAlarmDateTime, + + @NotNull(message = "선호하는 알람 시간은 필수입니다") + @DateTimeFormat(pattern = "HH:mm:ss") LocalTime preferredTime, + + @NotEmpty(message = "알람 요일을 하나 이상 선택해주세요") Set alarmDays, + + @NotNull(message = "알람 활성화 여부는 필수입니다") Boolean isEnabled ) { } From 3bf10aeb8e5829dbc0baa836c9dc34cfa185103c Mon Sep 17 00:00:00 2001 From: soung90 Date: Sun, 5 Jan 2025 12:23:46 +0900 Subject: [PATCH 28/37] =?UTF-8?q?fix:=20README=20file=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 87 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 70c0e6a..21e7bb0 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ - [서비스 소개](#-서비스-소개) - [기술 스택](#-기술-스택) - [설치 및 실행 방법](#-프로젝트-설치-및-실행법) -- [프로젝트 구조 ](#프로젝트-구조 ) -- [주요 기능](#주요-기능) -- [Developer](#Developer) +- [프로젝트 구조](#-프로젝트-구조) +- [주요기능](#-주요기능) +- [Developer](#-developer) ### 💁‍♀️ 서비스 소개
@@ -29,30 +29,51 @@

🎥 시연연상

### 🔧 기술 스택 -
-#### Backend -![Java](https://img.shields.io/badge/Java-17-blue) -![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.4.0-brightgreen) -![JPA](https://img.shields.io/badge/JPA-orange) -![MySQL](https://img.shields.io/badge/MySQL-8.0-lightblue) -![RestAPI](https://img.shields.io/badge/RestAPI-red) -![Spring Security](https://img.shields.io/badge/Spring%20Security-green) -![JWT](https://img.shields.io/badge/JWT-blue) - -#### DevOps & Tools -![Jenkins](https://img.shields.io/badge/Jenkins-CI%2FCD-yellow) -![Docker](https://img.shields.io/badge/Docker-Container-blue) -![AWS](https://img.shields.io/badge/AWS-Cloud-orange) -![Redis](https://img.shields.io/badge/Redis-InMemoryDB-red) - -#### Design & Collaboration -![Figma](https://img.shields.io/badge/Figma-Design-orange) -![GitHub](https://img.shields.io/badge/GitHub-VersionControl-black) +#### 💻 Backend + + + + + + + + +#### ⚙️ DevOps & Infrastructure + + + + + + + +#### 🛠 Development & Database + + + + + +#### 🔍 Testing & Monitoring + + + + +#### 🔌 External Services + + + + + + +#### 🎨 Design & Collaboration + + + -### ⚙️ 프로젝트 설치 및 실행법
+### ⚙️ 프로젝트 설치 및 실행법 + #### 1. **필수 요구 사항** 프로젝트 실행 전에 아래 환경이 필요합니다. @@ -262,13 +283,13 @@ $java -jar Botanify-0.0.1-SNAPSHOT.jar #### ERD -![img_7.png](src/main/resources/static/images/ERD.png) +[![](https://mermaid.ink/img/pako:eNrFV02PmzAQ_SuI8-YP5NzupZdKvVWRkIMnMKo_0NgsSTf73zsGQhwwu1ltpObGvMGe9_yYcV7z0krItznQNxQVCb0zGf9aB-Sy83mzsa9Zo4Tx2TarhUuh1gWwI_SQwiUKOnFCSSDSGWUtfEHWas5qBHkssbmmDru_2c3mfM5cAyWC48RdvgdlTeUyb3d5nBpXXQglSMe1x-Fx0ShUSHEKq7uyBtmqqYbA8VKt1Rpu9YjhF4SuqNF527P2JMo_Y9qVZ0xcg3OigqCQNV6g4exYpNfhIfz2WCHvjDL7-eMafRHEC1GfboSGJdII5zpLcomAFqiW4RL9aRn1tjPLqJCSmMISMMdELFoWTKszsioqWPKxe9QwmkUWwifAtpHroAQFc3CPfoqfRgpvF5UH03yo8hgNIhcMPS-h0ZtzdDqE3mW3BxSqZgFt49GaIjz9Xy0un9fdnkuRumAXPVY82b8Zus8SOwA3AYV_g9TmYJcJFdnO1wXvAAlT161GmXQweVMcUEHRUsL1ToNSKdsfMf1BdCw0cRWEplpFW24XtIaK1rfarKEdKx-_-64lPjb52PXutfrwzszN_UfrT010an1FDQGfGvHe4TFRsoHjtcd6mKWF4tEVYMRexUf6EMZDU1_SHsAZ-Z7g9Np80dDov9IqJk-hj_ueZ3H69s-DZZmMmufDrWXD-v2kKW1rvHuEYJe5drc9WIuVTshDHMwa-J4sCwnCCxIaXz-C4c1ofgDNFJO-o4eNosKmAoar0ANGTfLT_JS3vuCT6RZzLxEHRjKVd_gQlIAvKzlfLPVyv7q32kBtbcIPRGZgWuBEo5yIwLFBEp-d-SPVoFPcIpMK5E85Tx2-3Em-2_fMd7mvgVtuHq7OEg6iVT7cm0MqTyH762TKfOuphaecbFvV-fYglOOn4Yox_j2Yoo0wv1mpWdZ3nruWxuDbP6Re4wQ?type=png)](https://mermaid.live/edit#pako:eNrFV02PmzAQ_SuI8-YP5NzupZdKvVWRkIMnMKo_0NgsSTf73zsGQhwwu1ltpObGvMGe9_yYcV7z0krItznQNxQVCb0zGf9aB-Sy83mzsa9Zo4Tx2TarhUuh1gWwI_SQwiUKOnFCSSDSGWUtfEHWas5qBHkssbmmDru_2c3mfM5cAyWC48RdvgdlTeUyb3d5nBpXXQglSMe1x-Fx0ShUSHEKq7uyBtmqqYbA8VKt1Rpu9YjhF4SuqNF527P2JMo_Y9qVZ0xcg3OigqCQNV6g4exYpNfhIfz2WCHvjDL7-eMafRHEC1GfboSGJdII5zpLcomAFqiW4RL9aRn1tjPLqJCSmMISMMdELFoWTKszsioqWPKxe9QwmkUWwifAtpHroAQFc3CPfoqfRgpvF5UH03yo8hgNIhcMPS-h0ZtzdDqE3mW3BxSqZgFt49GaIjz9Xy0un9fdnkuRumAXPVY82b8Zus8SOwA3AYV_g9TmYJcJFdnO1wXvAAlT161GmXQweVMcUEHRUsL1ToNSKdsfMf1BdCw0cRWEplpFW24XtIaK1rfarKEdKx-_-64lPjb52PXutfrwzszN_UfrT010an1FDQGfGvHe4TFRsoHjtcd6mKWF4tEVYMRexUf6EMZDU1_SHsAZ-Z7g9Np80dDov9IqJk-hj_ueZ3H69s-DZZmMmufDrWXD-v2kKW1rvHuEYJe5drc9WIuVTshDHMwa-J4sCwnCCxIaXz-C4c1ofgDNFJO-o4eNosKmAoar0ANGTfLT_JS3vuCT6RZzLxEHRjKVd_gQlIAvKzlfLPVyv7q32kBtbcIPRGZgWuBEo5yIwLFBEp-d-SPVoFPcIpMK5E85Tx2-3Em-2_fMd7mvgVtuHq7OEg6iVT7cm0MqTyH762TKfOuphaecbFvV-fYglOOn4Yox_j2Yoo0wv1mpWdZ3nruWxuDbP6Re4wQ) #### API -- [API 문서 바로가기](https://documenter.getpostman.com/view/38557384/2sAYJ99dj3) +- API 문서 바로가기 - 위 링크에서 API 엔드포인트, 요청/응답 예제, 그리고 파라미터에 대한 상세한 설명을 확인하고 Postman에서 직접 테스트할 수 있습니다. +위 링크에서 API 엔드포인트, 요청/응답 예제, 그리고 파라미터에 대한 상세한 설명을 확인하고 Postman에서 직접 테스트할 수 있습니다. ### 🌿 주요기능 @@ -334,10 +355,10 @@ $java -jar Botanify-0.0.1-SNAPSHOT.jar
-| 이름 | 역할 | GitHub | -|-----|---------|--------------------------------------------------| -| 장재혁 | Backend | [GitHub Link](https://github.com/34-43) | -| 김동주 | Backend | [GitHub Link](https://github.com/Despereaux-MAU) | -| 고아라 | Backend | [GitHub Link](https://github.com/arago07) | -| 소성 | Backend | [GitHub Link](https://github.com/gbognon25) | -| 지민지 | Backend | [GitHub Link](https://github.com/JIMINJI1) | \ No newline at end of file +| 이름 | 역할 | 담당 업무 | GitHub | Blog link | +|---------------------|-----|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------|------------------------------------------------------------------------------------| +| 장재혁 | 팀장 | | [GitHub Link](https://github.com/34-43) | [Blog link](https://mdworld.notion.site/DB-79a386824f6047bba80a7c99e4b946b5?pvs=4) | +| 김동주 | 부팀장 | | [GitHub Link](https://github.com/Despereaux-MAU) | [Blog link](https://despereaux.tistory.com/) | +| 고아라 | 팀원 | | [GitHub Link](https://github.com/arago07) | [Blog link](https://velog.io/@gteaclub/posts) | +| 소성(LIKANE SO SOUNG) | 팀원 | **✉️ User Email Authentication:**
- 회원가입시 사용자의 이메일 인증 시스템 구축
**👥 Community Service:**
- 인기 게시글 캐싱 시스템 구축
- 댓글과 답글 기능 구현
- 1:1 실시간 채팅 시스템 구축
**🌱 식물 관리:**
- 식물 push 알림 시스템 구축 | [GitHub Link](https://github.com/gbognon25) | [Blog link](https://sounglikane.tistory.com) | +| 지민지 | 팀원 | | [GitHub Link](https://github.com/JIMINJI1) | [Blog link](https://velog.io/@min01/posts) | \ No newline at end of file From 84d379dc357d36b70dd10c6dc0eb370709e45c52 Mon Sep 17 00:00:00 2001 From: soung90 Date: Sun, 5 Jan 2025 12:31:55 +0900 Subject: [PATCH 29/37] =?UTF-8?q?design:=20README=20file=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 21e7bb0..6740119 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@

🎥 시연연상

### 🔧 기술 스택 +
#### 💻 Backend @@ -70,9 +71,10 @@ -
+ ### ⚙️ 프로젝트 설치 및 실행법 +
#### 1. **필수 요구 사항** 프로젝트 실행 전에 아래 환경이 필요합니다. From e71588f72da9a952c590ab42b7ef2551533fcf32 Mon Sep 17 00:00:00 2001 From: soung90 Date: Sun, 5 Jan 2025 12:37:47 +0900 Subject: [PATCH 30/37] =?UTF-8?q?fix:=20README=20file=20=EC=88=98=EC=A0=95?= =?UTF-8?q?(Docker=20=EC=B6=94=EA=B0=80)=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6740119..bdbe6d0 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ #### ⚙️ DevOps & Infrastructure + From c683f8d57461719f055a53cd30b8edc09423f39f Mon Sep 17 00:00:00 2001 From: soung90 Date: Sun, 5 Jan 2025 12:41:50 +0900 Subject: [PATCH 31/37] =?UTF-8?q?design:=20README=20file=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bdbe6d0..839e23b 100644 --- a/README.md +++ b/README.md @@ -358,10 +358,10 @@ $java -jar Botanify-0.0.1-SNAPSHOT.jar
-| 이름 | 역할 | 담당 업무 | GitHub | Blog link | -|---------------------|-----|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------|------------------------------------------------------------------------------------| -| 장재혁 | 팀장 | | [GitHub Link](https://github.com/34-43) | [Blog link](https://mdworld.notion.site/DB-79a386824f6047bba80a7c99e4b946b5?pvs=4) | -| 김동주 | 부팀장 | | [GitHub Link](https://github.com/Despereaux-MAU) | [Blog link](https://despereaux.tistory.com/) | -| 고아라 | 팀원 | | [GitHub Link](https://github.com/arago07) | [Blog link](https://velog.io/@gteaclub/posts) | -| 소성(LIKANE SO SOUNG) | 팀원 | **✉️ User Email Authentication:**
- 회원가입시 사용자의 이메일 인증 시스템 구축
**👥 Community Service:**
- 인기 게시글 캐싱 시스템 구축
- 댓글과 답글 기능 구현
- 1:1 실시간 채팅 시스템 구축
**🌱 식물 관리:**
- 식물 push 알림 시스템 구축 | [GitHub Link](https://github.com/gbognon25) | [Blog link](https://sounglikane.tistory.com) | -| 지민지 | 팀원 | | [GitHub Link](https://github.com/JIMINJI1) | [Blog link](https://velog.io/@min01/posts) | \ No newline at end of file +| 이름 | 역할 | 담당 업무 | GitHub | Blog link | +|-----------------------|-----|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------|------------------------------------------------------------------------------------| +| 장재혁 | 팀장 | | [GitHub Link](https://github.com/34-43) | [Blog link](https://mdworld.notion.site/DB-79a386824f6047bba80a7c99e4b946b5?pvs=4) | +| 김동주 | 부팀장 | | [GitHub Link](https://github.com/Despereaux-MAU) | [Blog link](https://despereaux.tistory.com/) | +| 고아라 | 팀원 | | [GitHub Link](https://github.com/arago07) | [Blog link](https://velog.io/@gteaclub/posts) | +| 리칸소성(LIKANE SO SOUNG) | 팀원 | **✉️ User Email Authentication:**
- 회원가입시 사용자의 이메일 인증 시스템 구축
**👥 Community Service:**
- 인기 게시글 캐싱 시스템 구축
- 댓글과 답글 기능 구현
- 1:1 실시간 채팅 시스템 구축
**🌱 식물 관리:**
- 식물 push 알림 시스템 구축 | [GitHub Link](https://github.com/gbognon25) | [Blog link](https://sounglikane.tistory.com) | +| 지민지 | 팀원 | | [GitHub Link](https://github.com/JIMINJI1) | [Blog link](https://velog.io/@min01/posts) | \ No newline at end of file From 12b6e2a101b328ff35cc5c85406aa81f57f3b8d1 Mon Sep 17 00:00:00 2001 From: soung90 Date: Sun, 5 Jan 2025 15:54:09 +0900 Subject: [PATCH 32/37] =?UTF-8?q?fix:=20README=20file=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(image=20=EC=B6=94=EA=B0=80)=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +-- .../images/ERD_\354\265\234\354\242\205.jpeg" | Bin 0 -> 163420 bytes 2 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 "src/main/resources/static/images/ERD_\354\265\234\354\242\205.jpeg" diff --git a/README.md b/README.md index 839e23b..3ded05d 100644 --- a/README.md +++ b/README.md @@ -285,8 +285,7 @@ $java -jar Botanify-0.0.1-SNAPSHOT.jar ![img_6.png](src/main/resources/static/images/아키텍쳐.png) #### ERD - -[![](https://mermaid.ink/img/pako:eNrFV02PmzAQ_SuI8-YP5NzupZdKvVWRkIMnMKo_0NgsSTf73zsGQhwwu1ltpObGvMGe9_yYcV7z0krItznQNxQVCb0zGf9aB-Sy83mzsa9Zo4Tx2TarhUuh1gWwI_SQwiUKOnFCSSDSGWUtfEHWas5qBHkssbmmDru_2c3mfM5cAyWC48RdvgdlTeUyb3d5nBpXXQglSMe1x-Fx0ShUSHEKq7uyBtmqqYbA8VKt1Rpu9YjhF4SuqNF527P2JMo_Y9qVZ0xcg3OigqCQNV6g4exYpNfhIfz2WCHvjDL7-eMafRHEC1GfboSGJdII5zpLcomAFqiW4RL9aRn1tjPLqJCSmMISMMdELFoWTKszsioqWPKxe9QwmkUWwifAtpHroAQFc3CPfoqfRgpvF5UH03yo8hgNIhcMPS-h0ZtzdDqE3mW3BxSqZgFt49GaIjz9Xy0un9fdnkuRumAXPVY82b8Zus8SOwA3AYV_g9TmYJcJFdnO1wXvAAlT161GmXQweVMcUEHRUsL1ToNSKdsfMf1BdCw0cRWEplpFW24XtIaK1rfarKEdKx-_-64lPjb52PXutfrwzszN_UfrT010an1FDQGfGvHe4TFRsoHjtcd6mKWF4tEVYMRexUf6EMZDU1_SHsAZ-Z7g9Np80dDov9IqJk-hj_ueZ3H69s-DZZmMmufDrWXD-v2kKW1rvHuEYJe5drc9WIuVTshDHMwa-J4sCwnCCxIaXz-C4c1ofgDNFJO-o4eNosKmAoar0ANGTfLT_JS3vuCT6RZzLxEHRjKVd_gQlIAvKzlfLPVyv7q32kBtbcIPRGZgWuBEo5yIwLFBEp-d-SPVoFPcIpMK5E85Tx2-3Em-2_fMd7mvgVtuHq7OEg6iVT7cm0MqTyH762TKfOuphaecbFvV-fYglOOn4Yox_j2Yoo0wv1mpWdZ3nruWxuDbP6Re4wQ?type=png)](https://mermaid.live/edit#pako:eNrFV02PmzAQ_SuI8-YP5NzupZdKvVWRkIMnMKo_0NgsSTf73zsGQhwwu1ltpObGvMGe9_yYcV7z0krItznQNxQVCb0zGf9aB-Sy83mzsa9Zo4Tx2TarhUuh1gWwI_SQwiUKOnFCSSDSGWUtfEHWas5qBHkssbmmDru_2c3mfM5cAyWC48RdvgdlTeUyb3d5nBpXXQglSMe1x-Fx0ShUSHEKq7uyBtmqqYbA8VKt1Rpu9YjhF4SuqNF527P2JMo_Y9qVZ0xcg3OigqCQNV6g4exYpNfhIfz2WCHvjDL7-eMafRHEC1GfboSGJdII5zpLcomAFqiW4RL9aRn1tjPLqJCSmMISMMdELFoWTKszsioqWPKxe9QwmkUWwifAtpHroAQFc3CPfoqfRgpvF5UH03yo8hgNIhcMPS-h0ZtzdDqE3mW3BxSqZgFt49GaIjz9Xy0un9fdnkuRumAXPVY82b8Zus8SOwA3AYV_g9TmYJcJFdnO1wXvAAlT161GmXQweVMcUEHRUsL1ToNSKdsfMf1BdCw0cRWEplpFW24XtIaK1rfarKEdKx-_-64lPjb52PXutfrwzszN_UfrT010an1FDQGfGvHe4TFRsoHjtcd6mKWF4tEVYMRexUf6EMZDU1_SHsAZ-Z7g9Np80dDov9IqJk-hj_ueZ3H69s-DZZmMmufDrWXD-v2kKW1rvHuEYJe5drc9WIuVTshDHMwa-J4sCwnCCxIaXz-C4c1ofgDNFJO-o4eNosKmAoar0ANGTfLT_JS3vuCT6RZzLxEHRjKVd_gQlIAvKzlfLPVyv7q32kBtbcIPRGZgWuBEo5yIwLFBEp-d-SPVoFPcIpMK5E85Tx2-3Em-2_fMd7mvgVtuHq7OEg6iVT7cm0MqTyH762TKfOuphaecbFvV-fYglOOn4Yox_j2Yoo0wv1mpWdZ3nruWxuDbP6Re4wQ) +![img_7.jpeg](src/main/resources/static/images/ERD_최종.jpeg) #### API diff --git "a/src/main/resources/static/images/ERD_\354\265\234\354\242\205.jpeg" "b/src/main/resources/static/images/ERD_\354\265\234\354\242\205.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..fb4c89a245a52e0fe9ac9c7c58e8be019b874e26 GIT binary patch literal 163420 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
@lk0%^QST)LR@ReTJnt)(P{khT$_3&M7JNKc> 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 BWY+)y literal 0 HcmV?d00001 From 49e166b445acc98d1ef7a45837ec25916bad5890 Mon Sep 17 00:00:00 2001 From: soung90 Date: Wed, 15 Jan 2025 12:25:05 +0900 Subject: [PATCH 33/37] =?UTF-8?q?fix:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=EC=A0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=B4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/TransactionConfig.java | 19 ++++++++++ .../common/config/async/AsyncConfig.java | 36 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/main/java/com/sounganization/botanify/common/config/TransactionConfig.java create mode 100644 src/main/java/com/sounganization/botanify/common/config/async/AsyncConfig.java diff --git a/src/main/java/com/sounganization/botanify/common/config/TransactionConfig.java b/src/main/java/com/sounganization/botanify/common/config/TransactionConfig.java new file mode 100644 index 0000000..d5e336c --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/TransactionConfig.java @@ -0,0 +1,19 @@ +package com.sounganization.botanify.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +@Configuration +public class TransactionConfig { + + @Bean + public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) { + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + template.setTimeout(30); + return template; + } +} diff --git a/src/main/java/com/sounganization/botanify/common/config/async/AsyncConfig.java b/src/main/java/com/sounganization/botanify/common/config/async/AsyncConfig.java new file mode 100644 index 0000000..1cae5af --- /dev/null +++ b/src/main/java/com/sounganization/botanify/common/config/async/AsyncConfig.java @@ -0,0 +1,36 @@ +package com.sounganization.botanify.common.config.async; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +@Slf4j +public class AsyncConfig implements AsyncConfigurer { + + @Bean(name = "chatThreadPoolTaskExecutor") + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(25); + executor.setThreadNamePrefix("ChatAsync-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.initialize(); + return executor; + } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return (ex, method, params) -> { + log.error("비동기 메서드 {}에서 예외 발생: {}", method.getName(), ex.getMessage()); + }; + } +} From b48dbed07852ad28b3433d96a79639108b175e87 Mon Sep 17 00:00:00 2001 From: soung90 Date: Wed, 15 Jan 2025 12:27:23 +0900 Subject: [PATCH 34/37] =?UTF-8?q?fix:=20Redis=EC=99=80=20WebSocket=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/redis/RedisConfig.java | 29 ++++++++++++++++++- .../config/websocket/WebSocketConfig.java | 3 +- .../chat/components/ChatFailureHandler.java | 5 ++-- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/sounganization/botanify/common/config/redis/RedisConfig.java b/src/main/java/com/sounganization/botanify/common/config/redis/RedisConfig.java index 785a1d3..fd8f6ac 100644 --- a/src/main/java/com/sounganization/botanify/common/config/redis/RedisConfig.java +++ b/src/main/java/com/sounganization/botanify/common/config/redis/RedisConfig.java @@ -1,6 +1,7 @@ package com.sounganization.botanify.common.config.redis; import com.sounganization.botanify.common.config.websocket.ChatMessageListener; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; @@ -8,13 +9,16 @@ import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @Configuration @ConditionalOnProperty(name = "spring.redis.enabled", havingValue = "true", matchIfMissing = true) +@Slf4j public class RedisConfig { @Value("${spring.redis.master.host}") @@ -46,13 +50,36 @@ public RedisTemplate redisTemplate() { return redisTemplate; } + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + StringRedisTemplate template = new StringRedisTemplate(connectionFactory); + template.setEnableTransactionSupport(true); + return template; + } + + @Bean + public ThreadPoolTaskExecutor redisThreadPoolTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(10); + executor.setMaxPoolSize(20); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("RedisExecutor-"); + executor.initialize(); + return executor; + } + @Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory, ChatMessageListener chatMessageListener) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); - container.addMessageListener(chatMessageListener, new PatternTopic("chat_room_*")); + container.setTaskExecutor(redisThreadPoolTaskExecutor()); + container.setRecoveryInterval(5000L); + container.addMessageListener(chatMessageListener, new PatternTopic("chat_room_broadcast_*")); + + log.info("최적화된 설정으로 Redis message listener container 구성"); return container; } } diff --git a/src/main/java/com/sounganization/botanify/common/config/websocket/WebSocketConfig.java b/src/main/java/com/sounganization/botanify/common/config/websocket/WebSocketConfig.java index 86ab2d9..27c15de 100644 --- a/src/main/java/com/sounganization/botanify/common/config/websocket/WebSocketConfig.java +++ b/src/main/java/com/sounganization/botanify/common/config/websocket/WebSocketConfig.java @@ -2,6 +2,7 @@ import com.sounganization.botanify.common.config.websocket.handler.ChatWebSocketHandler; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; @@ -10,8 +11,8 @@ @Configuration @EnableWebSocket @RequiredArgsConstructor +@Slf4j public class WebSocketConfig implements WebSocketConfigurer { - private final ChatWebSocketHandler chatWebSocketHandler; @Override diff --git a/src/main/java/com/sounganization/botanify/domain/chat/components/ChatFailureHandler.java b/src/main/java/com/sounganization/botanify/domain/chat/components/ChatFailureHandler.java index f1fa684..8a2c82e 100644 --- a/src/main/java/com/sounganization/botanify/domain/chat/components/ChatFailureHandler.java +++ b/src/main/java/com/sounganization/botanify/domain/chat/components/ChatFailureHandler.java @@ -6,7 +6,6 @@ import com.sounganization.botanify.domain.chat.repository.ChatMessageRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import java.util.List; @@ -18,7 +17,6 @@ public class ChatFailureHandler { private final ChatMessageRepository chatMessageRepository; private final WebSocketChatService webSocketChatService; - @EventListener public void handleRedisFailure(ChatMessageReqDto message) { log.error("Redis 연결 실패 - Fallback 모드로 전환"); webSocketChatService.handleChatMessage(message); @@ -43,7 +41,8 @@ private void retryMessageDelivery(ChatMessage message) { ChatMessageReqDto.MessageType.TALK, message.getChatRoom().getId(), message.getSenderId(), - message.getContent() + message.getContent(), + ChatMessageReqDto.MessageSource.WEBSOCKET ); try { From 45b7d8885da87d5da2cff1a1161e76fde9ca98cf Mon Sep 17 00:00:00 2001 From: soung90 Date: Wed, 15 Jan 2025 12:32:11 +0900 Subject: [PATCH 35/37] =?UTF-8?q?fix:=20DB=EC=99=80=20Redis=EC=97=90=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=EC=9D=98=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EB=B0=9C=ED=96=89=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/websocket/ChatMessageListener.java | 58 +++++++-- .../handler/ChatWebSocketHandler.java | 15 +-- .../service/MessageBroadcastService.java | 48 +++++++- .../service/WebSocketChatService.java | 44 +++++-- .../chat/dto/req/ChatMessageReqDto.java | 8 +- .../domain/chat/entity/ChatMessage.java | 6 + .../ChatMessageCustomRepository.java | 7 +- .../ChatMessageCustomRepositoryImpl.java | 58 ++------- .../repository/ChatRoomCustomRepository.java | 3 - .../ChatRoomCustomRepositoryImpl.java | 28 +---- .../chat/service/ChatMessageService.java | 111 ++++++++++++------ 11 files changed, 232 insertions(+), 154 deletions(-) diff --git a/src/main/java/com/sounganization/botanify/common/config/websocket/ChatMessageListener.java b/src/main/java/com/sounganization/botanify/common/config/websocket/ChatMessageListener.java index f392f16..5208311 100644 --- a/src/main/java/com/sounganization/botanify/common/config/websocket/ChatMessageListener.java +++ b/src/main/java/com/sounganization/botanify/common/config/websocket/ChatMessageListener.java @@ -1,29 +1,67 @@ package com.sounganization.botanify.common.config.websocket; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sounganization.botanify.common.config.websocket.service.WebSocketChatService; import com.sounganization.botanify.domain.chat.dto.req.ChatMessageReqDto; +import com.sounganization.botanify.domain.chat.entity.ChatMessage; +import com.sounganization.botanify.domain.chat.service.ChatMessageService; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.connection.Message; import org.springframework.data.redis.connection.MessageListener; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; -@Component +import java.nio.charset.StandardCharsets; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +@Service @RequiredArgsConstructor @Slf4j public class ChatMessageListener implements MessageListener { private final ObjectMapper objectMapper; - private final WebSocketChatService webSocketChatService; + private final ChatMessageService chatMessageService; + private final Set processedMessages = ConcurrentHashMap.newKeySet(); @Override - public void onMessage(Message message, byte[] pattern) { + public void onMessage(@NonNull Message message, byte[] pattern) { try { - String messageBody = new String(message.getBody()); - ChatMessageReqDto chatMessage = objectMapper.readValue(messageBody, ChatMessageReqDto.class); - webSocketChatService.handleChatMessage(chatMessage); + String messageContent = new String(message.getBody(), StandardCharsets.UTF_8); + if (!processedMessages.add(messageContent)) { + log.debug("Listener를 통해 이미 처리된 메시지입니다. 건너뜁니다."); + return; + } + + ChatMessageReqDto chatMessage = objectMapper.readValue(messageContent, ChatMessageReqDto.class); + + if (chatMessage.source() != ChatMessageReqDto.MessageSource.WEBSOCKET) { + log.debug("WebSocket 메시지가 아니므로 건너뜁니다."); + return; + } + + ChatMessage savedMessage = chatMessageService.findExistingMessage( + chatMessage.roomId(), + chatMessage.senderId(), + chatMessage.content() + ); + + if (savedMessage != null) { + chatMessageService.markMessageAsDelivered(savedMessage.getId()); + log.debug("브로드캐스트 후 메시지가 전달됨으로 표시되었습니다 - messageId: {}", savedMessage.getId()); + } + + removeProcessedMessageAfterDelay(messageContent); + } catch (Exception e) { - log.error("채팅 메시지 처리 중 오류 발생: {}", e.getMessage()); + log.error("Redis Pub/Sub에서 메시지 처리 실패", e); } } -} \ No newline at end of file + + private void removeProcessedMessageAfterDelay(String messageContent) { + CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS).execute(() -> { + processedMessages.remove(messageContent); + }); + } +} diff --git a/src/main/java/com/sounganization/botanify/common/config/websocket/handler/ChatWebSocketHandler.java b/src/main/java/com/sounganization/botanify/common/config/websocket/handler/ChatWebSocketHandler.java index a9ef3dc..b1fc0c8 100644 --- a/src/main/java/com/sounganization/botanify/common/config/websocket/handler/ChatWebSocketHandler.java +++ b/src/main/java/com/sounganization/botanify/common/config/websocket/handler/ChatWebSocketHandler.java @@ -17,40 +17,41 @@ @Slf4j @RequiredArgsConstructor public class ChatWebSocketHandler extends TextWebSocketHandler { - private final ObjectMapper objectMapper; private final WebSocketChatService webSocketChatService; private final ConnectionFailureHandler connectionFailureHandler; - @Override - public void afterConnectionEstablished(WebSocketSession session) { - log.info("새로운 WebSocket 연결이 열렸습니다. 세션 ID: {}", session.getId()); - } - @Override protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) throws Exception { String payload = message.getPayload(); + log.info("WebSocket 메시지를 수신했습니다: {}", payload); + ChatMessageReqDto chatMessage = objectMapper.readValue(payload, ChatMessageReqDto.class); try { switch (chatMessage.type()) { case ENTER: + log.info("사용자 {}이(가) 방 {}에 입장합니다.", chatMessage.senderId(), chatMessage.roomId()); webSocketChatService.handleEnterRoom(session, chatMessage.roomId(), chatMessage.senderId()); break; case TALK: if (!session.isOpen()) { + log.warn("메시지를 보내는 중 세션이 종료되었습니다."); connectionFailureHandler.handleConnectionFailure(chatMessage); return; } + log.info("사용자 {}이(가) 방 {}에서 TALK 메시지를 처리 중입니다.", + chatMessage.senderId(), chatMessage.roomId()); webSocketChatService.handleChatMessage(chatMessage); break; case LEAVE: + log.info("사용자 {}이(가) 방 {}에서 나갑니다.", chatMessage.senderId(), chatMessage.roomId()); webSocketChatService.handleLeaveRoom(chatMessage.roomId(), chatMessage.senderId()); break; } } catch (Exception e) { + log.error("메시지 처리 중 오류 발생: ", e); handleError(session, e); - // 메시지 처리 중 오류 발생시 연결 실패로 처리 if (chatMessage.type() == ChatMessageReqDto.MessageType.TALK) { connectionFailureHandler.handleConnectionFailure(chatMessage); } diff --git a/src/main/java/com/sounganization/botanify/common/config/websocket/service/MessageBroadcastService.java b/src/main/java/com/sounganization/botanify/common/config/websocket/service/MessageBroadcastService.java index 652f3da..52d0a48 100644 --- a/src/main/java/com/sounganization/botanify/common/config/websocket/service/MessageBroadcastService.java +++ b/src/main/java/com/sounganization/botanify/common/config/websocket/service/MessageBroadcastService.java @@ -6,32 +6,68 @@ import com.sounganization.botanify.domain.chat.dto.req.ChatMessageReqDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.web.socket.WebSocketSession; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor @Slf4j public class MessageBroadcastService { - private final ObjectMapper objectMapper; + private final StringRedisTemplate redisTemplate; private final Map> chatRoomSessions = new ConcurrentHashMap<>(); + private final Set processedMessageIds = ConcurrentHashMap.newKeySet(); public void broadcastMessage(Long roomId, ChatMessageReqDto message) { + String messageId = generateMessageId(roomId, message); + + if (!processedMessageIds.add(messageId)) { + log.debug("이미 브로드캐스트된 메시지입니다. 건너뜁니다: {}", messageId); + return; + } + try { - String messageJson = objectMapper.writeValueAsString(message); - WebSocketUtils.broadcastMessageToRoom(roomId, messageJson, chatRoomSessions); + log.debug("방 {}에 메시지를 브로드캐스트 중입니다.", roomId); + redisTemplate.convertAndSend( + "chat_room_broadcast_" + roomId, + objectMapper.writeValueAsString(message) + ); + + removeProcessedMessageIdAfterDelay(messageId); } catch (JsonProcessingException e) { - log.error("메시지 변환 중 오류 발생: {}", e.getMessage()); + log.error("Redis에 메시지 게시 실패", e); + processedMessageIds.remove(messageId); } } + private String generateMessageId(Long roomId, ChatMessageReqDto message) { + return String.format("%d:%d:%d:%s", + roomId, + message.senderId(), + message.content().hashCode(), + LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS) + ); + } + + private void removeProcessedMessageIdAfterDelay(String messageId) { + CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS).execute(() -> { + processedMessageIds.remove(messageId); + }); + } + public void addSession(Long roomId, Long userId, WebSocketSession session) { chatRoomSessions.computeIfAbsent(roomId, k -> new ConcurrentHashMap<>()) .put(userId, session); + log.debug("roomId: {}, userId: {}에 대한 세션을 추가했습니다.", roomId, userId); } public void removeSession(Long roomId, Long userId) { @@ -40,10 +76,12 @@ public void removeSession(Long roomId, Long userId) { WebSocketSession session = roomSessions.remove(userId); if (session != null) { WebSocketUtils.closeSession(session); + log.debug("roomId: {}, userId: {}에 대한 세션을 제거했습니다.", roomId, userId); } if (roomSessions.isEmpty()) { chatRoomSessions.remove(roomId); + log.debug("빈 방을 제거했습니다: {}", roomId); } } } -} +} \ No newline at end of file diff --git a/src/main/java/com/sounganization/botanify/common/config/websocket/service/WebSocketChatService.java b/src/main/java/com/sounganization/botanify/common/config/websocket/service/WebSocketChatService.java index 8990887..a837789 100644 --- a/src/main/java/com/sounganization/botanify/common/config/websocket/service/WebSocketChatService.java +++ b/src/main/java/com/sounganization/botanify/common/config/websocket/service/WebSocketChatService.java @@ -3,41 +3,59 @@ import com.sounganization.botanify.domain.chat.dto.req.ChatMessageReqDto; import com.sounganization.botanify.domain.chat.entity.ChatMessage; import com.sounganization.botanify.domain.chat.service.ChatMessageService; -import com.sounganization.botanify.domain.chat.service.ChatRoomService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.socket.WebSocketSession; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + @Service @RequiredArgsConstructor @Slf4j public class WebSocketChatService { private final MessageBroadcastService messageBroadcastService; - private final ChatRoomService chatRoomService; private final ChatMessageService chatMessageService; public void handleChatMessage(ChatMessageReqDto chatMessage) { - // WebSocket을 통해 처음 메시지가 올 때만 저장 및 Redis 발행 - if (chatMessage.type() == ChatMessageReqDto.MessageType.TALK) { - // 저장된 메시지를 변수에 할당하여 사용 - ChatMessage savedMessage = chatMessageService.saveMessage( + log.info("handleChatMessage 호출됨, 메시지: {}", chatMessage); + + if (chatMessage.type() != ChatMessageReqDto.MessageType.TALK || + chatMessage.source() != ChatMessageReqDto.MessageSource.WEBSOCKET) { + log.warn("메시지가 거부되었습니다: 잘못된 type 또는 source입니다. Type: {}, Source: {}", + chatMessage.type(), chatMessage.source()); + return; + } + + try { + log.info("Room: {}, sender: {}에 대한 메시지 생성 중", + chatMessage.roomId(), chatMessage.senderId()); + + ChatMessage message = chatMessageService.createMessage( chatMessage.roomId(), chatMessage.senderId(), chatMessage.content() ); - // 저장 성공 시에만 브로드캐스트 실행 - if (savedMessage != null) { - messageBroadcastService.broadcastMessage(chatMessage.roomId(), chatMessage); + + if (message != null) { + log.info("메시지가 생성되었습니다. 저장 시도 중"); + Future future = chatMessageService.saveMessageAsync(message); + ChatMessage savedMessage = future.get(5, TimeUnit.SECONDS); + + if (savedMessage != null) { + log.info("메시지가 저장되었습니다. 방 {}에 브로드캐스트 중", chatMessage.roomId()); + messageBroadcastService.broadcastMessage(chatMessage.roomId(), chatMessage); + } } - } else { - // Redis 구독을 통해 받은 메시지는 브로드캐스트만 수행 - messageBroadcastService.broadcastMessage(chatMessage.roomId(), chatMessage); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + log.error("채팅 메시지 처리 실패: ", e); } } public void handleEnterRoom(WebSocketSession session, Long roomId, Long userId) { - chatRoomService.getChatRoom(roomId, userId); messageBroadcastService.addSession(roomId, userId, session); log.info("사용자 {}가 채팅방 {}에 입장했습니다.", userId, roomId); } diff --git a/src/main/java/com/sounganization/botanify/domain/chat/dto/req/ChatMessageReqDto.java b/src/main/java/com/sounganization/botanify/domain/chat/dto/req/ChatMessageReqDto.java index c423965..a213b87 100644 --- a/src/main/java/com/sounganization/botanify/domain/chat/dto/req/ChatMessageReqDto.java +++ b/src/main/java/com/sounganization/botanify/domain/chat/dto/req/ChatMessageReqDto.java @@ -4,9 +4,15 @@ public record ChatMessageReqDto( MessageType type, Long roomId, Long senderId, - String content + String content, + MessageSource source ) { public enum MessageType { ENTER, TALK, LEAVE } + + public enum MessageSource { + WEBSOCKET, + REDIS + } } diff --git a/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatMessage.java b/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatMessage.java index 1aac2c1..d072f0f 100644 --- a/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatMessage.java +++ b/src/main/java/com/sounganization/botanify/domain/chat/entity/ChatMessage.java @@ -15,6 +15,9 @@ @Builder @AllArgsConstructor @NoArgsConstructor +@Table(indexes = { + @Index(name = "idx_chat_message_unique", columnList = "room_id,sender_id,content,created_at", unique = true) +}) public class ChatMessage extends Timestamped { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -46,6 +49,9 @@ public void setExpirationDate() { @Builder.Default private Boolean delivered = false; + @Version + private Long version; + public void markAsDelivered() { this.delivered = true; } diff --git a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepository.java b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepository.java index 6ab0e62..1dc40bd 100644 --- a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepository.java +++ b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepository.java @@ -1,19 +1,16 @@ package com.sounganization.botanify.domain.chat.repository; import com.sounganization.botanify.domain.chat.entity.ChatMessage; -import com.sounganization.botanify.domain.chat.entity.ChatRoom; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; public interface ChatMessageCustomRepository { - List findMessagesByRoomId(Long roomId); - List findRecentMessages(ChatRoom room, LocalDateTime after); - List findMessagesWithRoomByRoomId(Long roomId); Page findMessagesByRoomIdWithPagination(Long roomId, Pageable pageable); int softDeleteMessagesOlderThan(LocalDateTime cutoffDate, int batchSize); - long countActiveMessagesByRoomId(Long roomId); List findUndeliveredMessages(); + Optional findDuplicateMessage(Long roomId, Long senderId, String content, LocalDateTime since); } diff --git a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepositoryImpl.java b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepositoryImpl.java index f30a390..21fa78d 100644 --- a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepositoryImpl.java +++ b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatMessageCustomRepositoryImpl.java @@ -2,9 +2,7 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import com.sounganization.botanify.domain.chat.entity.ChatMessage; -import com.sounganization.botanify.domain.chat.entity.ChatRoom; import com.sounganization.botanify.domain.chat.entity.QChatMessage; -import com.sounganization.botanify.domain.chat.entity.QChatRoom; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -13,49 +11,13 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; @Repository @RequiredArgsConstructor public class ChatMessageCustomRepositoryImpl implements ChatMessageCustomRepository { private final JPAQueryFactory jpaQueryFactory; - @Override - public List findMessagesByRoomId(Long roomId) { - QChatMessage message = QChatMessage.chatMessage; - - return jpaQueryFactory - .selectFrom(message) - .where(message.chatRoom.id.eq(roomId)) - .orderBy(message.createdAt.asc()) - .fetch(); - } - - @Override - public List findRecentMessages(ChatRoom room, LocalDateTime after) { - QChatMessage message = QChatMessage.chatMessage; - - return jpaQueryFactory - .selectFrom(message) - .where(message.chatRoom.eq(room) - .and(message.createdAt.after(after))) - .orderBy(message.createdAt.asc()) - .fetch(); - } - - @Override - public List findMessagesWithRoomByRoomId(Long roomId) { - QChatMessage message = QChatMessage.chatMessage; - QChatRoom room = QChatRoom.chatRoom; - - return jpaQueryFactory - .selectFrom(message) - .join(message.chatRoom, room) - .fetchJoin() - .where(message.chatRoom.id.eq(roomId)) - .orderBy(message.createdAt.asc()) - .fetch(); - } - @Override public Page findMessagesByRoomIdWithPagination(Long roomId, Pageable pageable) { QChatMessage message = QChatMessage.chatMessage; @@ -74,7 +36,7 @@ public Page findMessagesByRoomIdWithPagination(Long roomId, Pageabl .where(message.chatRoom.id.eq(roomId)) .fetchOne(); - return new PageImpl<>(messages, pageable, total); + return new PageImpl<>(messages, pageable, total != null ? total : 0L); } @Override @@ -115,17 +77,17 @@ public List findUndeliveredMessages() { } @Override - public long countActiveMessagesByRoomId(Long roomId) { + public Optional findDuplicateMessage(Long roomId, Long senderId, String content, LocalDateTime since) { QChatMessage message = QChatMessage.chatMessage; - Long count = jpaQueryFactory - .select(message.count()) - .from(message) + return Optional.ofNullable(jpaQueryFactory + .selectFrom(message) .where(message.chatRoom.id.eq(roomId) + .and(message.senderId.eq(senderId)) + .and(message.content.eq(content)) + .and(message.createdAt.goe(since)) .and(message.deletedYn.isFalse())) - .fetchOne(); - - return count != null ? count : 0L; - + .orderBy(message.createdAt.desc()) + .fetchFirst()); } } diff --git a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepository.java b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepository.java index d208965..5947ba2 100644 --- a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepository.java +++ b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepository.java @@ -5,13 +5,10 @@ import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; public interface ChatRoomCustomRepository { - List findRoomsByUserId(Long userId); Optional findRoomByUsers(Long senderUserId, Long receiverUserId); - List findRoomsWithMessagesById(Long userId); Page findRoomsByUserIdWithPagination(Long userId, Pageable pageable); int softDeleteEmptyRoomsOlderThan(LocalDateTime cutoffDate); } diff --git a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepositoryImpl.java b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepositoryImpl.java index c926af1..4799da7 100644 --- a/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepositoryImpl.java +++ b/src/main/java/com/sounganization/botanify/domain/chat/repository/ChatRoomCustomRepositoryImpl.java @@ -20,17 +20,6 @@ public class ChatRoomCustomRepositoryImpl implements ChatRoomCustomRepository { private final JPAQueryFactory jpaQueryFactory; - @Override - public List findRoomsByUserId(Long userId) { - QChatRoom chatRoom = QChatRoom.chatRoom; - - return jpaQueryFactory - .selectFrom(chatRoom) - .where(chatRoom.senderUserId.eq(userId) - .or(chatRoom.receiverUserId.eq(userId))) - .fetch(); - } - @Override public Optional findRoomByUsers(Long senderUserId, Long receiverUserId) { QChatRoom chatRoom = QChatRoom.chatRoom; @@ -44,21 +33,6 @@ public Optional findRoomByUsers(Long senderUserId, Long receiverUserId return Optional.ofNullable(result); } - @Override - public List findRoomsWithMessagesById(Long userId) { - QChatRoom chatRoom = QChatRoom.chatRoom; - QChatMessage message = QChatMessage.chatMessage; - - return jpaQueryFactory - .selectFrom(chatRoom) - .distinct() - .leftJoin(chatRoom.messages, message) - .fetchJoin() - .where(chatRoom.senderUserId.eq(userId) - .or(chatRoom.receiverUserId.eq(userId))) - .fetch(); - } - @Override public Page findRoomsByUserIdWithPagination(Long userId, Pageable pageable) { QChatRoom chatRoom = QChatRoom.chatRoom; @@ -79,7 +53,7 @@ public Page findRoomsByUserIdWithPagination(Long userId, Pageable page .or(chatRoom.receiverUserId.eq(userId))) .fetchOne(); - return new PageImpl<>(rooms, pageable, total); + return new PageImpl<>(rooms, pageable, total != null ? total : 0L); } @Override diff --git a/src/main/java/com/sounganization/botanify/domain/chat/service/ChatMessageService.java b/src/main/java/com/sounganization/botanify/domain/chat/service/ChatMessageService.java index e6a65b2..e5507db 100644 --- a/src/main/java/com/sounganization/botanify/domain/chat/service/ChatMessageService.java +++ b/src/main/java/com/sounganization/botanify/domain/chat/service/ChatMessageService.java @@ -1,24 +1,26 @@ package com.sounganization.botanify.domain.chat.service; -import com.fasterxml.jackson.databind.ObjectMapper; import com.sounganization.botanify.common.exception.CustomException; import com.sounganization.botanify.common.exception.ExceptionStatus; -import com.sounganization.botanify.domain.chat.components.ChatFailureHandler; -import com.sounganization.botanify.domain.chat.dto.req.ChatMessageReqDto; import com.sounganization.botanify.domain.chat.entity.ChatMessage; import com.sounganization.botanify.domain.chat.entity.ChatRoom; -import com.sounganization.botanify.domain.chat.event.ChatMessageEvent; import com.sounganization.botanify.domain.chat.repository.ChatMessageRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Future; @Service @RequiredArgsConstructor @@ -26,12 +28,32 @@ public class ChatMessageService { private final ChatMessageRepository chatMessageRepository; private final ChatRoomService chatRoomService; + private final TransactionTemplate transactionTemplate; private final StringRedisTemplate redisTemplate; - private final ObjectMapper objectMapper; - private final ApplicationEventPublisher eventPublisher; @Transactional - public ChatMessage saveMessage(Long roomId, Long senderId, String content) { + public ChatMessage createMessage(Long roomId, Long senderId, String content) { + log.debug("메시지 생성 중 - roomId: {}, senderId: {}", roomId, senderId); + + Optional existingMessage = chatMessageRepository + .findDuplicateMessage(roomId, senderId, content, LocalDateTime.now().minusSeconds(10)); + + if (existingMessage.isPresent()) { + log.debug("중복 메시지를 발견했습니다. 기존 메시지를 반환합니다."); + return existingMessage.get(); + } + + String dedupeKey = String.format("msg:dedupe:%d:%d:%d", + roomId, senderId, content.hashCode()); + + Boolean isNew = redisTemplate.opsForValue() + .setIfAbsent(dedupeKey, "1", Duration.ofSeconds(10)); + + if (Boolean.FALSE.equals(isNew)) { + log.debug("Redis에서 중복 메시지가 감지되었습니다."); + return null; + } + ChatRoom chatRoom = chatRoomService.getChatRoom(roomId, senderId); if (!chatRoom.getSenderUserId().equals(senderId) && @@ -39,43 +61,62 @@ public ChatMessage saveMessage(Long roomId, Long senderId, String content) { throw new CustomException(ExceptionStatus.UNAUTHORIZED_CHAT_ACCESS); } - ChatMessage message = ChatMessage.builder() + return ChatMessage.builder() .type(ChatMessage.MessageType.TALK) .senderId(senderId) .content(content) .chatRoom(chatRoom) + .delivered(false) .build(); + } - // Redis로 메시지 발행 + @Transactional + public void markMessageAsDelivered(Long messageId) { try { - ChatMessageReqDto messageDto = new ChatMessageReqDto( - ChatMessageReqDto.MessageType.TALK, - roomId, - senderId, - content - ); - - redisTemplate.convertAndSend( - "chat_room_" + roomId, - objectMapper.writeValueAsString(messageDto) - ); + transactionTemplate.execute(status -> { + chatMessageRepository.findById(messageId).ifPresent(message -> { + message.markAsDelivered(); + chatMessageRepository.save(message); + log.debug("메시지 {}를 전달됨으로 표시했습니다.", messageId); + }); + return null; + }); } catch (Exception e) { - log.error("메시지 발행 중 오류 발생: {}", e.getMessage()); - // Redis 실패 시 이벤트 발행 - eventPublisher.publishEvent(new ChatMessageEvent(new ChatMessageReqDto( - ChatMessageReqDto.MessageType.TALK, - roomId, - senderId, - content - ))); + log.error("메시지를 전달됨으로 표시하는 데 실패했습니다: {}", e.getMessage(), e); } + } + + @Transactional(readOnly = true) + public ChatMessage findExistingMessage(Long roomId, Long senderId, String content) { + return chatMessageRepository.findDuplicateMessage( + roomId, + senderId, + content, + LocalDateTime.now().minusSeconds(30) + ).orElse(null); + } - // 비동기적으로 DB에 저장 - return CompletableFuture.supplyAsync(() -> chatMessageRepository.save(message)) - .exceptionally(throwable -> { - log.error("메시지 저장 중 오류 발생: {}", throwable.getMessage()); - return message; - }).join(); + @Async("chatThreadPoolTaskExecutor") + public Future saveMessageAsync(ChatMessage message) { + return CompletableFuture.supplyAsync(() -> { + try { + return transactionTemplate.execute(status -> { + try { + ChatMessage saved = chatMessageRepository.save(message); + log.info("메시지가 비동기적으로 저장되었습니다. roomId: {}", + message.getChatRoom().getId()); + return saved; + } catch (Exception e) { + log.error("메시지 저장 중 오류 발생: {}", e.getMessage(), e); + status.setRollbackOnly(); + throw e; + } + }); + } catch (Exception e) { + log.error("메시지 저장 실패", e); + throw new CompletionException(e); + } + }); } @Transactional(readOnly = true) From 3dac21d0ef78dc987e6e2237596a06407fedcbba Mon Sep 17 00:00:00 2001 From: soung90 Date: Wed, 15 Jan 2025 12:32:48 +0900 Subject: [PATCH 36/37] =?UTF-8?q?fix:=20Test=20code=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/websocket/handler/ConnectionFailureHandler.java | 3 ++- .../botanify/domain/chat/ChatFailureHandlerTest.java | 3 ++- .../botanify/domain/chat/ConnectionFailureHandlerTest.java | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/sounganization/botanify/common/config/websocket/handler/ConnectionFailureHandler.java b/src/main/java/com/sounganization/botanify/common/config/websocket/handler/ConnectionFailureHandler.java index 40f7413..b1f0ee5 100644 --- a/src/main/java/com/sounganization/botanify/common/config/websocket/handler/ConnectionFailureHandler.java +++ b/src/main/java/com/sounganization/botanify/common/config/websocket/handler/ConnectionFailureHandler.java @@ -54,7 +54,8 @@ private void retryMessageDelivery(ChatMessage message) { convertToDtoMessageType(message.getType()), message.getChatRoom().getId(), message.getSenderId(), - message.getContent() + message.getContent(), + ChatMessageReqDto.MessageSource.WEBSOCKET ); try { diff --git a/src/test/java/com/sounganization/botanify/domain/chat/ChatFailureHandlerTest.java b/src/test/java/com/sounganization/botanify/domain/chat/ChatFailureHandlerTest.java index b410ed2..54efe26 100644 --- a/src/test/java/com/sounganization/botanify/domain/chat/ChatFailureHandlerTest.java +++ b/src/test/java/com/sounganization/botanify/domain/chat/ChatFailureHandlerTest.java @@ -38,7 +38,8 @@ void handleRedisFailure_ShouldDelegateToWebSocketService() { ChatMessageReqDto.MessageType.TALK, 1L, 1L, - "Test message" + "Test message", + ChatMessageReqDto.MessageSource.WEBSOCKET ); // When diff --git a/src/test/java/com/sounganization/botanify/domain/chat/ConnectionFailureHandlerTest.java b/src/test/java/com/sounganization/botanify/domain/chat/ConnectionFailureHandlerTest.java index fe0ef86..0aaba95 100644 --- a/src/test/java/com/sounganization/botanify/domain/chat/ConnectionFailureHandlerTest.java +++ b/src/test/java/com/sounganization/botanify/domain/chat/ConnectionFailureHandlerTest.java @@ -48,7 +48,8 @@ void handleConnectionFailure_ShouldSaveMessage() { ChatMessageReqDto.MessageType.TALK, 1L, 1L, - "Test message" + "Test message", + ChatMessageReqDto.MessageSource.WEBSOCKET ); when(chatRoomRepository.findById(1L)) From fcdedbef0a72707d4cb166cab67134215f663eb1 Mon Sep 17 00:00:00 2001 From: soung90 Date: Wed, 15 Jan 2025 12:33:25 +0900 Subject: [PATCH 37/37] =?UTF-8?q?remove:=20"ChatMessageEvent"=20file=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../botanify/domain/chat/event/ChatMessageEvent.java | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 src/main/java/com/sounganization/botanify/domain/chat/event/ChatMessageEvent.java diff --git a/src/main/java/com/sounganization/botanify/domain/chat/event/ChatMessageEvent.java b/src/main/java/com/sounganization/botanify/domain/chat/event/ChatMessageEvent.java deleted file mode 100644 index 5d42185..0000000 --- a/src/main/java/com/sounganization/botanify/domain/chat/event/ChatMessageEvent.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sounganization.botanify.domain.chat.event; - -import com.sounganization.botanify.domain.chat.dto.req.ChatMessageReqDto; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public class ChatMessageEvent { - private final ChatMessageReqDto message; -}