From 10f3fcea33a91d31d6bf79ee931b07410fd15d26 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 10 Feb 2025 23:03:38 +0900 Subject: [PATCH] =?UTF-8?q?Release:=20=F0=9F=8E=87=20Release=20v2.0.0=20(#?= =?UTF-8?q?240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 프로젝트 readme 재작성 * docs: 프로젝트 소개 README 오탈자 수정 * docs: PULL_REQUEST_TEMPLATE.md 작성 Co-authored-by: jinlee1703 * fix: 템플릿 경로 수정 * feat: 7-bit error code enum 및 interface 정의 && causedBy record 정의 * rename: 공통 예외 클래스 및 인터페이스 주석 추가 * feat: CausedBy 검증 로직 추가 && rename: CausedBy 메서드별 주석 추가 * rename: Domain Code 주석 수정 * rename: Field Code 주석 수정 * rename: CausedBy 정적 팩토리 메서드명 valueOf -> of로 수정 * fix: CausedBy code 자릿수 검증 로직 수정 && test: CausedBy 객체 6가지 테스트 케이스 작성 * rename: CausedBy 정적 팩토리 메서드 주석 수정 * ✨ JwtProvider 인터페이스 및 AT, RT Provider 구현체 정의 (#6) * chore: common 모듈에 spring-core 의존성 주입 && infra 모듈에 jwt 의존성 주입 * feat: Auth 상수 설정 * feat: JwtProvider 인터페이스 정의 * feat: domain & field zero 상수 작성 * feat: Jwt 예외 상수 설정 * feat: Jwt 예외 클래스 * fix: ReasonCode Zero bit 추가 * feat: JwtErrorCodeUtil 작성 * chore: application.yml profile 분리 * feat: AT, RT Qualifier 목적 커스텀 어노테이션 작성 * chore: jwt secret key & expiration time 환경변수 주입 * rename: provider annotation 네이밍 변경 -> 전략 * feat: common 모듈에 DateUtil 추가 * feat: access token claim dto && provider 작성 * test: AccessTokenProvider test 작성 * fix: test given절 축약 * refactor: AT Claims key 상수값으로 명시적 필드 지정 * feat: refresh token dto && payload key 상수화 * feat: refresh token provider 작성 * test: 서명 조작 토큰 에러 검증 * rename: getSubInfoFromToken -> getJwtClaimsFromToken 메서드명 변경 * rename: JwtProvider 주석 수정 및 메서드 순서 변경 * fix: 토큰 만료시 예외 핸들링 * test: 토큰 만료 시, true 반환 검사 테스트 코드 추가 * fix: isTokenExpired() 메서드 예외 핸들링 로직 수정 : 테스트 성공 * ✨ JDBC & JPA & QueryDsl Configuration 설정 (#7) * chore: mysql & jpa & queryDsl gradle 의존성 추가 * chore: queryDsl generated 디렉토리 git 추적 제거 * chore: jdbc, jpa application.yml 설정 추가 * chore: extenal-api 모듈 application.yml 그룹 추가 * chore: jpa 설정 profile 분리 && profile 그룹 추가 * chore: infra 모듈 application.yml 그룹 추가 * feat: Jpa config * feat: QueryDsl config * chore: EnalbeJpaAuditing auditorAwareRef 설정 제거 * fix: config 디렉토리 수정 * Conventional Commit을 위한 Git Hooks 설정 (#8) * chore: commit-lint 관련 패키지 설치 * chore: .gitignore에 node_modules 추가 * chore: commit convention 등록 * chore: commit-lint 적용 * ✨ Redis Configuration 설정 (#9) * chore: infra 모듈 내 redis 의존성 주입 (api) * chore: domain 모듈 내 redis 의존성 주입 (implementation) * chore: redis 환경변수 설정 * feat: Domain Redis Connection Bean Qualify Annotation 생성 * feat: Domain Redis CacheManager Qualify Annotation 생성 * feat: Domain Redis Template Qualify Annotation 생성 * chore: domain 모듈 redis config 작성 * feat: Infra Redis CacheManager Qualify Annotation 생성 * feat: SecurityUser Redis CacheManager Qualify Annotation 생성 * feat: Oidc Redis CacheManager Qualify Annotation 생성 * chore: infra 모듈 cache config 설정 * ✨ OpenAPI Swagger config 설정 (#10) * chore: external-api 모듈 springdoc-openapi 2.4.0 의존성 주입 * chore: external-api 모듈 내 openapi 설정 추가 * chore: swagger config 작성 * fix: application profile prod -> dev * ✏️ Reason Code Zero bit 제거 → 500번대 Zero bit 상수 추가 (#11) * fix: reason code zero bit 제거 && 500번대 0번 bit 상수 추가 * test: caused-by-test 예상 에러코드 수정 (통과 확인) * fix: jwt-error-code 변경된 reason code로 수정 * Swagger 관련 환경 변수 오타 수정 (#12) * ✨ 응답 공통화 및 전역 예외 처리 (#14) * chore: test api directory .gitignore 경로 추가 * feat: 성공 응답 클래스 정의 * feat: error-response 공통 응답 클래스 작성 * fix: success 응답 nocontent() 응답 null -> empty object * feat: method argument not valid 전역 예외 처리 * fix: reason 422 error type mismatch 코드 추가 * feat: missing request header 전역 예외 처리 * feat: request json parsing 실패 전역 예외 처리 * feat: missing request parameter 전역 예외 처리 * feat: 존재하지 않는 url 요청 전역 예외 처리 * feat: 500 internal server error 전역 예외 처리 * feat: npe & exception 전역 예외 처리 * fix: response status annotation 처리 * style: intellij code convention setting 추가하여 reformat * ✨ User Domain 설정 (#15) * feat: db, application 타입 형변환 인터페이스 생성 * feat: code <-> enum 변환 util 작성 * feat: custom converter 구현을 위한 추상 클래스 작성 * style: api & domain package 경로 수정 * feat: create, update auditable 추상 클래스 작성 * feat: role type enum & conveter 정의 * feat: visibility type enum & conveter 정의 * rename: visibility to profile-visibility 클래스명 수정 * fix: converter 생성자 수정 * feat: user entity 생성 * feat: user jpa data repository 생성 * fix: 에러 체계 7자리 수 -> 4자리 수 * test: 4자리수 에러 체계 기반 테스트 코드 수정 * fix: reason code 400번대 4번 invalid request 추가 * feat: user error code & exception 정의 * feat: user domain service 작성 * feat: user domain service create 메서드 추가 * feat: user domain service exists 메서드 추가 * fix: jwt error code 내에서 domain, field code 제거 * chore: query-dsl generated 경로 .gitignore * fix: profile-visibility converter 오주입 수정 * fix: test api 삭제 * chore: 패키지 경로 수정으로 인한 test 패키지 경로 수정 * rename: caused by '7자리 에러코드' 주석 수정 * ✨️ 회원가입 API (#16) * feat: white space validator 작성 * rename: not-white space 주석 추가 * feat: 전화번호 인증 요청 dto 작성 * rename: cerification -> verification 단어 수정 * feat: refresh token redis entity 작성 * feat: refresh token repository 작성 * fix: refresh token ttl time unit seconds -> milliseconds * feat: refresh token service 구현 * feat: 이모지 유효성 검증 어노테이션 작성 * feat: 패스워드 유효성 검증 어노테이션 작성 * feat: 일반 회원가입 dto 작성 * fix: 일반 회원가입 dto code 필드 추가 * feat: jwt tokens 편의 dto 클래스 생성 * fix: refresh token ttl timeunit milliseconde -> seconds * feat: jwts 생성 mapper 정의 * fix: refresh token provider primary bean 제거 * feat: cookie util 작성 * style: jwts dto 클래스 패키지 경로 수정 * fix: jwt auth mapper 토큰 생성 로직 수정 * feat: 회원가입 usecase 구현(인증번호 미확인) * feat: auth controller sign up api 개방 * style: test 모듈 내 경로 수정 * rename: sign-up dto 전화번호 예시 문자 수정 * fix: phone pattern \n 제거 * fix: not empty -> not blank validation check 변경 * fix: cookie util max age int -> long * fix: auth controller cookie util 의존성 주입 * test: auth controller 7가지 시나리오 유효성 검사 * test: 필드 누락 시나리오 추가 && cookie 헤더 검증 수정 * fix: jwt mapper에서 rt provider에 access claim -> refresh claim 수정 * Dockfile 작성 (#17) * chore: .gitignore에 .env 파일 경로 추가 * feat: dockerfile 작성 * fix: dockerfile의 profile을 local에서 dev로 수정 * ✨ 일반 회원가입 전화번호 인증 API (#18) * feat: 인증번호 송신 dto 작성 * feat: 인증번호 전송 & 검증 API 설계 * chore: domain 모듈 redis unit test 목적 embedded-redis 의존성 추가 * chore: redis test 라이브러리 embedded -> container * test: redis container config 작성 * chore: domain 모듈 test application.yml 작성 * chore: redis template bean primary 추가 * feat: phone validation code(회원가입, 아이디/비밀번호 찾기) 상수 지정 * test: phone validation repository 테스트 작성 * feat: phone validation repository 작성 * test: phone validation repository 삭제 테스트 케이스 추가 * feat: phone verification service 작성 * feat: validation code converter 작성 * feat: web config 설정 * feat: 전화번호 인증/검증 dto codetype 필드 추가 * fix: phone verification repository save 시, expires_at 반환 * fix: phone verification service create 메서드, expires_at 반환 * rename: read by phone error log 문구 수정 * feat: phone verification 에러 코드 정의 * feat: phone verification 에러 클래스 정의 * feat: sns dto 클래스 선언 * feat: sms provider 인터페이스 정의 * rename: request time -> request at 변수명 수정 * feat: infra module component scan 목적의 application 클래스 작성 * feat: aws sms provider mock 구현체 작성 * style: infra 상위 패키지 pennyway 추가 -> 디렉토리 이동 * rename: code -> phone-verificatio-code 클래스명 수정 * fix: 사용자에게 code type 입력받는 필드 & converter 제거 * fix: 인증번호 검증 dto내 code 필드 복구 * feat: phone verification mapper 클래스 정의 * feat: 코드 불일치 시 error 반환하도록 수정 * rename: saveCode -> sendCode * feat: 인증번호 검증 응답 dto 작성 * rename: of -> value of * feat: auth use case 전화번호 인증 추가 * fix: global exception handler내 global error exception status 삽입 메서드 수정 * rename: send_time, expire_time -> send_at, expires_at 필드명 수정 * fix: 성공 응답 상태코드 2000000 -> 2000 수정 * fix: hash table -> value && ttl 적용 * feat: user find by phone 메서드 추가 * fix: 인증번호 검증 시 oauth user 여부 확인 필드 추가 * fix: user notify 필드 feed-comment-notify -> chat-notify 수정 * feat: user 계정 연동 helper class 정의 * feat: 전화번호 인증 성공 시 ttl rollback 메서드 추가 * fix: transaction 내 exception 발생 시 rollback -> sync helper의 transaction 어노테이션 제거 * fix: user auth use case 의존성 주입 및 분기 처리 로직 추가 * test: auth controller validation test 경로 수정 * rename: phone verification mapper 메서드 주석 추가 * test: user sync helper 클래스 test 작성 * fix: 이미 회원가입한 유저인 경우, 인증 코드 cache 제거 * fix: verify-code-res 기존 사용자 존재할 시 반환 필드 추가 * fix: user-sync-helper에서 oauth 계정 있으면 username 반환 * fix: auth user case 일반 회원가입 이력 없고, oauth 계정 있으면 username 반환 * rename: phone verification repository remove() -> delete() * rename: phone-verification-code -> phone-verification-type * fix: web config 제거 * CI/CD 파이프라인 구축 (#19) * feat: ci workflow 작성 * feat: cd workflow 작성 * fix: mysql actions step 삭제 * fix: docker image 태그 제거 * fix: cd 파이프라인 trigger 브랜치명 수정(develop->dev) * CD Workflow 수정 (#21) * feat: ci workflow 작성 * feat: cd workflow 작성 * fix: mysql actions step 삭제 * fix: docker image 태그 제거 * fix: cd 파이프라인 trigger 브랜치명 수정(develop->dev) * fix: gradle build 과정 추가 * fix: gradle build 과정 추가 * fix: cd 파이프라인 임시 수정 * fix: cd 파이프라인 임시 수정 사항 삭제 * fix: gradlew 권한 수정 * fix: 테스트 실패 오류 해결 * ✨ Spring Security 초기 설정 (+ Test case 에러 관련) (#22) * chore: external-api 모듈 spring boot starter security 의존성 주입 * chore: security config 설정 * chore: method security config 설정 * fix: 기존 api 인가 권한 is-anonymous로 제한 * fix: security config 인증, 인가 예외 필터 제거 (로그인 작업 시 추가) * fix: user sync helper oauth 반환 수정 * test: user sync helper 메서드 반환 타입 수정 * test: username 반환 검증 추가 * fix: pennyway infra application @spring boot application 어노테이션 제거 * test: 성공 응답 객체 code 값 2000으로 수정 * chore: spring security test 의존성 주입 * test: auth controller 성공 응답 set cookie 헤더 존재 여부 판단으로 수정 * chore: sub project test 블럭 추가 * feat: security user details & service 정의 * chore: local 환경 내 logging level 정보 추가 * fix: user sync helper transaction 제거 * ✨ 로그인 API (#23) * feat: 일반 로그인 요청 dto 작성 * feat: user repository find-by-username 메서드 추가 * feat: user service read-user-by-username 메서드 구현 * feat: 유저 비밀번호 예외 추가 * feat: user sync helper read-user-if-valid 메서드 * feat: auth user case내 sign in 메서드 추가 * feat: sign in api 추가 * test: sign in test case 추가 * fix: sign in dto 정규표현식 검사 제거 * ✏️ 회원가입 API 개선 (+ Domain Service Runtime 예외 발생 제거) (#24) * feat: sign up dto -> phone verification dto from 메서드 추가 * fix: helper 클래스 책임과 역할 분리 * rename: user sync helper 메서드명 명시적으로 수정 * fix: sign up dto password 암호화 후 entity 생성 * fix: 일반 회원가입, 로그인 시나리오 helper 클래스 분리 * fix: 일반 회원가입, oauth 계정 연동 시나리오에 맞게 dto 분리 후 info 클래스로 통합 * fix: oauth 연동 dto -> phone, code 필드 추가 * fix: sign up api 회원가입 요청 인자 수정 * fix: 서명 헬퍼 클래스 매개변수 수정 * feat: user domain 비밀번호 업데이트 메서드 추가 * fix: dto에서 유저 생성 시, password update at 갱신 * feat: 일반 회원가입 도우미 메서드 분기 처리 * rename: helper, mapper 클래스 재지정 * fix: 전화번호 요청 코드 정적 메서드 매개변수 타입 변경 * rename: user general sign mapper 메서드 create -> save(생성 혹은 수정 기능) * fix: sync with oauth dto의 to info 메서드 인자 제거 * feat: 기존 소셜 계정 연동 api 추가 && 인증 응답 생성 도우미 메서드 분리 * feat: 회원가입 시나리오 개선 * test: user sync mapper test 변경 사항 반영 * test: auth controller validation test 변경 사항 반영 * test: user general sign mapper test 분리 * fix: domain service layer 예외 처리 제거 * refactor: user sync mapper 예외 처리 로직 수정 * refactor: user details service imple 예외 처리 로직 수정 * refactor: user general sign mapper 예외 처리 로직 수정 * refactor: user sync mapper에서 비검사 예외 발생 제거 * fix: user sync mapper 선언적 transaction 추가 * refactor: auth use case 비검사 예외 핸들링 제거 * test: test optional 반환 적용 * rename: user sync mapper 주석 수정 * ✨ Jwt 인증 필터 (#25) * feat: 403 에러 핸들러 작성 * feat: 401 에러 핸들러 작성 * feat: security config에 인증, 인가 필터 bean 등록 * fix: 인증, 인가 필터 로그 레벨 조정 error -> warn * feat: jwt 예외 필터 작성 * feat: forbedden token entity 정의 * feat: forbedden token repository 작성 * feat: forbidden token service 작성 * feat: jwt 인증 필터 추가 * fix: user details service 구현제 주입 -> 인터페이스 주입 * chore: security filter config 설정 * chore: jwt security config 설정 * chore: security config 커스텀 예외 핸들러 설정 * feat: security user to string() 재정의 * chore: security config 설정 * fix: access denied exception import 경로 수정 * fix: token 파싱 에러 해결 * style: 예외 로그 위치 수정 * feat: global exception handler no-resource-found-exception 핸들링 * fix: security config 불필요한 의존성 주입 제거 * feat: refresh api 개방 * fix: refresh token annotaion 빈 이름 수정 * feat: refresh token 탈취 예외 추가 * fix: refresh token 탈취 시나리오 핸들링 * fix: taken way token reason code 403의 이유 코드로 변경 * fix: jwt 인증 필터 내 메서드 명시적 final 매개변수 제거 * 📑 Readme v0.0.2 (#26) * docs: erd 추가 * docs: 라이브러리 버전 수정 * docs: version 관리 설명 추가 * ✏️ Swagger + Security 수정 (#30) * rename: 로그인 요청 dto @schema 추가 * chore: server domain 환경 설정 external-api -> infra * chore: server domain property bean 등록 * chore: application-infra server 블럭 수정 * fix: swagger server url 환경변수 경로 수정 * chore: cors 설정 추가 * refactor: cors 설정 파일 분리 * rename: jwt security config -> security adpater config * chore: bcryptpasswordencoder -> passwordencoder * fix: 동일한 클래스명의 DTO @Schema name 속성 설정 * refactor: 운영 환경 별 security filter chain 설정 분리 * chore: external api 모듈 내 jackson nullable module 종속성 추가 * refactor: security auth config 분리 * refactor: security config swagger endpoint 프로필 별 설정 분리 * feat: simple granted authority 역직렬화 이슈로 custom granted authority 클래스 선언 * chore: external-api 내 jackson config 설정 * fix: security user details 역직렬화 문제 해결 * fix: security config 개발 환경 옵션 수정 * fix: custom granted authority equals 수정 * chore: docker hub 경로 수정 * ✨ 닉네임 중복검사 API (#31) * rename: auth controller '일반 회원 가입' 전화번호 인증 swagger 문서 상 명시 * feat: 닉네임 중복 검사 domain service 메서드 추가 * feat: username 중복 검사 api 개방 * fix: 중복 검사 체크 url을 anonymous endpoints에 추가 * fix: swagger endpoints와 read only public endpoints 분리 * fix: 닉네임 중복 검사 인가 기준 permit-all로 변경 * rename: is-exist-nickname -> is-exist-username * rename: auth check controller 매개변수명 username으로 수정 * ✨ OIDC 기능 인터페이스화 (+ component scan에 대한 고찰) (#32) * chore: infra 모듈 내 feign 의존성 주입 * feat: oidc dto 정의 * feat: oidc public key response 객체 정의 * feat: oauth oidc client 인터페이스 정의 * feat: oidc token parsing provider 정의 * feat: oidc provider 환경 변수 정보를 가져올 인터페이스 정의 * rename: oidc 카멜케이스로 변경 * chore: provider 별 jwks-uri 및 secret 환경변수 주입 * feat: apple, google, kakao oidc 환경 변수 주입 * feat: oidc configuration properties config 세팅 * feat: default feign config 설정 * feat: common module 내 map utils 작성 * rename: oidc cache manager 빈 이름 오타 수정 * fix: oidc properties 필드 final 변경 * fix: infra properties 설정 api 모듈로 이전 * feat: provider 별 feign interface 정의 * chore: infra application 패키지 경로 수정 * chore: cache config @configuration 어노테이션 재삽입 * chore: infra config -> api 모듈에서 사용할 infra 모듈의 properties 명시 * feat: infra config maker 인터페이스 및 열거 타입 생성 * feat: infra 모듈 confg import selector 정의 * feat: infra를 의존하는 모듈에서 동적으로 인프라 구성을 명시적으로 선택하기 위한 어노테이션 작성 * feat: oidc 도우미 클래스 작성 * fix: cache config 클래스 마커 인터페이스 구현 제거 * fix: client-secret -> secret 필드 변경 * chore: feign config 설정 * fix: cache config @configuration 어노테이션 복구 * ✨ OAuth OIDC 회원가입 및 로그인 API (#33) * feat: provider enum 클래스 정의 * feat: provider converter 정의 * feat: oauth domain 정의 * feat: provider exception 정의 * feat: provider request converter 정의 * feat: web config에 provider converter 등록 * chore: infra 모듈 httpclient 라이브러리 의존성 추가 * feat: oauth 로그인&회원가입 dto 정의 * rename: getter 메서드명 중 oidc 소문자화 * fix: oauth 로그인 시 oauth id 필드 추가 * fix: oauth oidc helper 메서드 추가 * rename: provider exception -> oauth exception * feat: oauth id 불일치 예외 추가 * feat: oauth repository 정의 * feat: oauth repository 소셜 아이디 & 제공자 탐색 메서드 선언 * feat: oauth domain service 정의 * rename: provider converter 예외 이름 수정 * rename: oauth service get -> read * style: oauth exception api 모듈 -> domain 모듈 이전 * rename: oauth error code 주석 포맷 변경 * feat: oauth 매퍼클래스 - 로그인 분기처리 * feat: oauth 로그인 use case 구현 * feat: oauth 로그인 컨트롤러 정의 * fix: cache config 내 불필요한 설정 추가 제거 * chore: infra 모듈 redis 환경 변수 주입 * rename: oauth controller 스웨거 문서 설명 추가 * feat: oauth API 설계 * fix: 전화번호 인증 oauth provider 구분 * fix: 전화 번호 인증 열거 타입 oauth provider 추론 메서드 static으로 변경 * fix: 전화번호 인증 코드 응답 객체 general, oauth 정적 팩토리 분리 * fix: oauth 분기 시나리오에 따른 dto 구분 * fix: oauth api 설계 수정 * fix: aouth use case의 verify code 메서드 반환 타입 verify code res로 수정 * rename: 인증 코드 검증 dto 생성 메서드 변경 * feat: oauth provider signup된 정보 존재 시 반환하는 에러코드 추가 * feat: 사용자 아이디 & provider 기반 데이터 존재 여부 검증 도메인 서비스 메서드 추가 * feat: oauth 회원가입 요청을 phone 검증 dto로 변환하는 정적 팩토리 메서드 추가 * fix: 소셜 회원가입 시 phone, code 필수 입력 필드 추가 * feat: user sync mapper 클래스 내 oauth 회원가입 분기 결정 메서드 추가 * feat: oauth use case 메서드 작성 * feat: 소셜 회원가입 분기 등록 로직 구현 * feat: 소셜 회원가입 Use case 작성 * rename: oauth controller 주석 제거 * rename: oauth api 1, 3번 상세한 설명을 위한 swagger 어노테이션 추가 * style: 프로그래밍 코드와 문서 주석 분리 * docs: 인증 코드 검증 예외 문서 수정 * fix: 휴대폰 만료 혹은 미등록 예외 reason code 401->404 변경 * docs: 전화번호 인증 응답 포맷 수정 * ✨ External-api 모듈 통합 테스트 환경 구축 (#34) * chore: test profile yml 작성 * chore: external-api test 패키지 logback-test.xml 추가 * chore: testcontainer + redis, mysql 의존성 주입 * chore: 통합 테스트 db 컨테이너 환경 정의 * chore: api 통합 테스트 프로필 resolver 정의 * chore: common 패키지 application 클래스 정의 * chore: api 통합 테스트 base package classes 정의 * chore: api 통합 테스트 어노테이션 정의 * rename: api test application.yml -> application-test.yml * test: auth api 유효성 검사 profile locat -> test * test: api 통합 테스트 실행 테스트 * test: 통합 테스트 어노테이션에 profile=test 추가 * chore: test application 파일 제거 * chore: 환경변수 기본값 정의 * test: api 테스트 프로필 test -> local * chore: test ci gradlew test --peraller 옵션 추가 * chore: external api db config @container 제거 * chore: application 파일 내 test 프로필 추가 * test: api 통합 테스트 프로필 local -> test * test: 회원가입 유저 시나리오 통합 테스트 (동작 확인용) * 🐛 OIDC signature 검증없이 header, payload 추출 로직 수정 (#35) * rename: oauth oidc provider imple 에러 메서드 명시 * fix: token header, payload 추출 메서드 jjwt 라이브러리 의존 제거 * rename: get oidc token body 메서드 내 aud 로그 제거 * fix: oidc provider log 제거 및 get unsignedtoken() token 마지막에 . 제거 * ✨ 인증코드 SMS 전송 (#37) * chore: infra 모듈 내 aws sdk, sns 의존성 주입 * chore: aws sns 환경변수 설정 * chore: aws sns config 설정 * feat: sms dto에서 sms 전송을 위한 전화번호 파싱 메서드 추가 * rename: pheon 파싱 메서드명 수정 * fix: sms request record 제거 * fix: sms dto response 객체 제거 및 불필요한 필드 정보 제거 * feat: phone 인증 event 등록 * fix: sms dto to 레코드명 변경 및 code 필드 추가 * rename: phone verification event -> push code event * feat: push code 이벤트 핸들러 등록 * rename: event명 변경으로 인한 수정 * fix: 인증 코드 생성 로직 sms provider -> mapper로 변경 * chore: infra 환경 설정 기본값 null 제거 -> 더미값 주입 * 📝 Swagger 예외 응답 문서 개선 (#39) * rename: oauth 전화번호 인증 시, existUser -> existsUser * rename: 인증번호 검증 phone 필드 example에 하이픈 추가 * feat: json view에 적용할 구분용 클래스 생성 * rename: error response 응답 코드 설명 수정 * fix: 공통 예외 핸들러 주석 & 순서 수정 및 400 예외 핸들러 추가 * fix: auth api 문서 주석 분리 및 상세 내용 추가 * rename: 일반 회원가입 인증번호 검증에서 성공 응답 여부에 따른 분기 이동지점 명시 * 🐛 Google id token issuer mismatch 이슈 해결 (#40) * fix: 공개키 서명 검증 메서드에서 token 로그 출력 제거 * fix: oauth oidc client properties 인터페이스 get issuer 메서드 추가 * chore: infra 모듈 provider issuer 환경 변수 추가 * fix: apple, kakao 환경 get issuer 메서드 수정 * chore: kakao, apple issuer 제거 * fix: iss 인자에 get_jwks() -> get_issuer() 메서드로 삽입 * ✏️ User name 필드 유효성 검사 기준 변경 (#44) * feat: get_unsigned_token_claims 500 error -> 401 error 변환 * fix: oauth_usecase 내 payload 로그 레벨 info -> debug * fix: name 필드 정규 표현식 변경 (한글 6자, 영어 10자) * fix: name 필드 정규 표현식 변경 (한글 & 영어 소문자 8자) * fix: name 필드 한글, 영문 소문자 2~8자로 제한 * test: 인증 시 name 예외 문구 수정 * ✨ User, Oauth Entity Soft Delete 반영 (#43) * feat: user domain soft delete와 where 추가 * feat: oauth domain soft delete와 where 추가 * chore: domain module 내 mysql testcontainer 의존성 추가 * test: domain 모듈 mysql container 환경 설정 * chore: logback 설정 수정 * feat: user domain tostring 재정의 * feat: user domain service delete 메서드 추가 * test: soft delete 확인 테스트 * fix: oauth domain sql delete 쿼리 수정 * test: user soft delete test case 추가 * ✏️ OAuth 계정 연동 실패 해결 및 테스트 케이스 추가 (#45) * rename: user sync mapper 불필요한 주석 제거 * feat: oauth service create 메서드 추가 * feat: oauth sign mapper 내에서 entity 생성 메서드 호출 * rename: auth api 일반 회원가입 이력 존재 시 예외 문서 추가 * rename: oauth api 소셜 로그인 이력 존재 시 예외 문서 추가 * test: [2] 소셜 로그인 이력이 있는 경우, 200 ok를 반환하고 oauth 필드가 true고 username 필드가 존재 * rename: 소셜인증 회원가입, 계정 연동 시 성공 응답 반환 문서 추가 * test: [3-1] 일반 회원가입 테스트 * test: [3-2] 소셜 계정 연동 회원가입 테스트 * test: 일반 회원가입 전화 검증 api 예외 테스트 케이스 추가 * test: url별로 inner 클래스로 테스트 분리 * test: auth test order 지정 * chore: test 환경에서 sql log 출력 옵션 true로 변경 * test: oauth controller 통합 테스트 내부 클래스 구분 * chore: wiremock 의존성 추가 * test: feign mock test 적용 (실제로 사용은 안 함) * test: [1] 소셜 로그인 통합 테스트 * fix: oauth link 시, 기존 계정 없는 경우 예외 처리 * test: [4-1] 소셜 회원가입 계정 연동 * feat: oauth entity tostring 재정의 * fix: oauth 회원가입 시, 기존 계정이 하나라도 존재하면 예외 처리 * test: [4-2] 소셜 회원가입 * test: 저장된 oauth 정보 조회 추가 * ✏️ 인증번호 전송 api 통합 (#46) * feat: 전화번호 인증 타입 정의 * feat: sms 인증코드 전송 api 분리 * feat: security config anonymous url에 /v1/phone 추가 * docs: sms api swagger 작성 * fix: send code param value 지정 * feat: phone verification error code 400 두 가지 경우 추가 * feat: send code 시, type == oauth이면 provider null일 때 예외 처리 * feat: verification type converter 추가 * docs: 인증코드 swagger 상 type의 value 수정 * feat: verification type converter web config 등록 * docs: 예시 전화번호 수정 * docs: 인증, 소셜 인증 api deprecated 처리 * 📝 모듈 별 README.md 추가 (#47) * chore: readme.md .gitignore 제거 * docs: external api readme 추가 * docs: external api 패키지 경로 수정 * docs: infra 모듈 readme 추가 * docs: domain 모듈 readme 추가 * docs: common 모듈 readme 추가 * ✨ 로그아웃 API (#49) * feat: sign out use case 구현 * feat: sign out api 개방 * docs: user auth api swagger 문서에 sign out 추가 * feat: sign out pre authorize 추가 * fix: authorization header 파싱 추가 * test: 유효한 access token, 유효한 refresh token 시나리오 검증 * test: 3가지 테스트 시나리오 추가 작성(유효한 access token, 유효하지 않은 refresh token 시나리오 실패) * fix: authorization header 파싱 controller에서 수행 * refactor: 로그아웃 세부로직 jwt auth helper로 이동 * test: 유효한 access token을 가진 사용자가 다른 사용자의 유효한 refresh token을 전송할 시 실패 * feat: 소유권 없는 토큰 예외 추가 * fix: 다른 사용자의 유효한 refresh token 삭제 요청 시 예외 처리 * rename: jwt auth helper의 remove_access_token_and_refresh_token 메서드 주석 수정 * test: refresh token ttl 변환 로직 수정 * docs: sign out swagger 예외 응답 추가 * docs: sign out API 상세 설명 추가 * refactor: jwt auth helper 메서드 분리 * test: with_mock_user 어노테이션 제거 * fix: cookie 제거 응답 헤더 추가 * fix: 인증 필터 내 refresh token 체크 제거 * fix: cookie util delete cookie 메서드 수정 & 응답 헤더에 쿠키 제거용 헤더 추가 * test: refresh token 탈취 시나리오와 refresh 이전 token 전송 시나리오 추가 * test: scenario 2-3 pre-condition 수정 * fix: sign out api에서 sevlet request, response param 제거 * ✨ Device Token 등록/수정/삭제 API (#51) * feat: device token entity 정의 * feat: user entity 내 device entity 역방향 관계 지정 * feat: device entity 연관관계 도우미 메서드 추가 * feat: device repository 정의 * fix: device repoistory 상속 대상을 cure -> list crud로 변경 * feat: read_all_by_user_id() 메서드 repository 내 선언 * feat: device domain service 추가 * feat: user account controller 설계 및 코드 작성 * rename: controller -> api 네이밍 수정 * feat: device dto 정의 * fix: user account api에 device dto import * fix: user account controller 내 use case import * fix: device entity 생성자에 user 추가, 연관관계 도우미 메서드 제거, 토큰 업데이트 메서드 추가 * fix: device dto to_entity 파라미터에 user 추가 * fix: device service save 메서드 반환값 void -> device * feat: device 등록 use case 추가 * refactor: device 등록 usecase 내, flag 변수 제거 후 optional로 분기 제어 * feat: device domain service에서 user_id와 token으로 device 조회 메서드 추가 * feat: use case 내 임시 unregister_device 메서드 정의 * test: 신규 디바이스 등록 테스트 * test: 결과값으로 도출된 device에서 매핑 결과 확인 * refactor: register_device() 리턴값 long -> dto 객체 * feat: device custom exception 클래스 추가 * fix: 기존 디바이스 토큰 갱신 시, 기기 정보 불일치 예외 처리 * rename: 예외 상황 로그 수정 * test: 기존에 등록된 디바이스 토큰이 있는 경우, 디바이스 토큰을 갱신한다 * test: 서버에서 토큰을 비활성화 처리하여 존재하지 않는데, 클라이언트가 변경 요청을 보낸 경우 newToken으로 신규 디바이스를 등록한다 * test: 사용자가 유효한 토큰을 가지고 있지만 모델명이나 OS가 다른 경우 DEVICE_NOT_MATCH 에러를 반환한다 * feat: device entity 활성화 여부 필드 추가 && 정적 팩토리 메서드 추가 * fix: device 생성자 -> 정적 팩토리 메서드로 수정 * fix: device 생성자 private로 접근 제한 * feat: device not found error code 추가 * test: 활성화 필드 시나리오로 테스트 변경 * feat: device entity 활성화 여부 메서드 추가 * feat: device entity 활성화 메서드 추가 * fix: 활성화 필드 존재 이후 시나리오로 usecase 수정 * test: 토큰 비활성화 쿼리문 수정 * refactor: use case 추상화 수준을 위해 하위 service layer 추가 * style: 매개변수 순서 변경 * test: device_register_service 테스트 케이스 빈 등록 * rename: device request dto is_same_token() -> is_init_request() * refactor: 디바이스 생성 로직 구현 * test: 사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 삭제한다 * fix: device token id 대신 device token 자체를 쿼리 파라미터로 받도록 수정 * fix: 디바이스 정보 제거 시, 매칭되는 디바이스가 없을 때 예외 종류 수정 * test: 디바이스 삭제 실패 테스트 * docs: device token api 스웨거 응답 문서 작성 * docs: 디바이스 등록 요청 dto의 함수가 필드에 포함되는 현상 제거 * test: 유효한 토큰 & 디바이스 정보가 다를 경우 디바이스 정보를 업데이트 * feat: device entity 내 model, os 정보 수정 메서드 추가 * fix: 사용자 기기 변경 시, 비지니스 로직 수정 * docs: 디바이스 장치 정보 미스 예외 제거 * fix: 디바이스 토큰 활성 여부 조건문 변경 * fix: not_match_device 에러 코드 제거 * feat: test용 user 상수 클래스 * feat: @authentication_principal 타입 변환을 위한 테스트용 어노테이션 작성 * test: user 디바이스 저장 요청 시, 성공 응답 포맷 테스트 * test: originToken에 대한 디바이스가 이미 존재하는 경우, 디바이스 정보 변경 사항만 업데이트하고 기존 디바이스 정보를 반환한다 * fix: device 저장 시, 매칭 디바이스 부재 여부 판단 로직 추가 * rename: 테스트 [1-1] display name 수정 * test: device fixture으로 변경 * style: device register service 메서드 순서 변경 * fix: oauth entity 내 provider converter 정의 (#52) * ✨ 문의하기 API (#36) * feat: question 엔티티, repository 작성 * feat: mail 발송 로직 임시 작성 * fix: http 메서드 수정 * feat: question 예외 작성 * feat: question service 작성 * feat: 문의 발송 api 작성 * fix: 문의 발송 내용 수정 * feat: 필요한 환경변수 설정 * docs: swagger 문서 작성 및 분리 * fix: 라이브러리 버전 명시 * docs: swagger parameter 제거 및 schema 내용 수정 * fix: 컨벤션에 따른 uri 수정 * fix: controller 메소드 인가 권한 수정 * fix: dto 필드별 schema 작성 * fix: dto email 필드 유효성 검사 추가 * fix: category(enum) 필드 유효성 검사 처리 * fix: restful 원칙에 따른 request uri 수정 * fix: dto inner class 제거 * fix: transactional 어노테이션 변경 * fix: email_error 오탈자 수정 * fix: 공통 허용 endpoint 선언 * fix: createddate 를 사용하기 위한 entitylistners 추가 * fix: domainservice 연결 * fix: swagger schema 오탈자 수정 및 enum 설명 제거 * fix: transactional 어노테이션 추가 * fix: @schema 어노테이션 제거 * fix: questioncategory enum converter 적용 * fix: sendquestion 응답 nocontent로 변경 * refactor: starter-mail 의존성 이동 * refactor: starter-mail 의존성 구성 속성 변수 이동 * refactor: 메일발송 로직 infra 모듈 이전 * fix: 임시 로그 제거 * refactor: 의존성 주입을 위한 mailconfig 수정 * refactor: transactionaleventlistener를 활용한 메일발송 이벤트처리 * test: 테스트 작성 * fix: mockbean 누락 오류 수정 * fix: 테스팅간 로직 임시 주석처리 복구 * fix: 불필요한 getter 제거 * fix: main핸들링 log 수준 변경 * feat: admin_address 환경변수 기본값 추가 * fix: 메일 발송 이벤트 실패시 로그 레벨 변경 * feat: swagger 성공 응답 명시 * 🔧 인증 기능 리팩토링 (#53) * rename: user_general_sign_mapper -> service * refactor: is_sign_up_allowed() 메서드 general sign service로 이동 * rename: user_oauth_sign_mapper -> service * refactor: is_sign_up_allowed() 메서드 oauth sign service로 이동 * fix: user sign mapper 제거 * refactor: 일반 회원가입 서비스 조건문 메서드로 분리 * fix: 일반 회원가입 시 log 추가 * refactor: user 생성 코드를 dto로 이동 * fix: oauth 회원가입 시 log 추가 * fix: log 정보 수정 * refactor: oauth 매핑 메서드 분리 * rename: 도메인 phone verification -> phone code * rename: phone_verification_mapper -> service * refactor: sms controller에서 service가 아닌 usecase를 의존하도록 수정 * style: 회원가입 시 인증 코드 확인 -> 인증 코드 제거 -> 자원 접근 순으로 변경 * style: 소셜 회원가입 시 인증 코드 확인 -> 인증 코드 제거 -> 자원 접근 순으로 변경 * refactor: 소셜 회원가입 시 조건문 메서드 명시적으로 변경 * feat: 사용자 sync 목적 dto 클래스 정의 * rename: user sync dto 주석 추가 * feat: user sync dto 편의용 도우미 메서드 생성 * refactor: oauth_sign_service 내 pair -> dto로 변환 * refactor: general_sign_service 내 pair -> dto로 변환 * rename: user sync dto의 is sign up allowed 필드 주석 추가 * refactor: auth_use_case pair -> dto * refactor: oauth_use_case pair -> dto * refactor: oauth signup 유효성 검사 리팩토링 * test: test에서 pair -> user_sync_dto 응답으로 변경 * rename: user_oauth_sign_service is_sign_up_allowed() 로그 추가 * fix: pk로 user select 하도록 하여 query 횟수 절약 * ✨ 사용자 본인 프로필 조회 API (#55) * feat: get_my_account api 작성 * feat: user_profile_dto 정의 * feat: get_my_account() use case 추가 * rename: account_api requirement_security 주석 제거 * fix: local time serializaer format 추가 * fix: profile_visibility @jsonvalue 메서드 수정 * fix: 소셜 계정인 경우 password_updated_at 필드 직렬화 제외 * docs: 사용자 계정 조회 성공 응답 swagger 문서화 * docs: localdatetime 응답 포맷 수정 * feat: 사용자 응답 fe 편의용 필드 is_oauth_account 추가 * ✨ 로그인 상태 확인 API (#56) * feat: user token 검증 api 설계 * style: user_auth_controller & usecase & test 모두 auth 패키지로 이전 * test: user_auth_usecase test 생성 * test: pre-condition 설정 * test: 토큰 유효성 검사 api 4가지 시나리오 작성 * fix: get_auth_state에서 authorization 헤더 매개변수 추가 및 예외 처리 * test: controller에서 넘겨주는 데이터에 맞게 given 수정 * fix: auth_header가 비었거나 bearer로 시작하지 않을 시, 예외 처리 -> 비로그인 유저 응답 * feat: is_sign_in 구현 * rename: is_signed_in() -> is_sign_in() * refactor: auth_header 추출 로직 controller -> usecase로 이전 * test: token 접두사 bearer 추가 * test: access token을 보낸 의도가 확실할 때는 검증 실패 시 예외처리 * fix: is_token_expired() -> get_jwt_claims() 예외 핸들링 로직 수정 * fix: /v1/auth url 인가 권한 permit_all로 수정 * docs: get_auth_state() swagger 주석 추가 * feat: sigin state record 정의 * fix: state dto 분리 * test: is_sign_in 반환 타입 dto로 수정 * feat: jwt_claims value를 얻기 위한 편의용 메서드 추가 * fix: is_sign_in() 반환 타입 dto로 변경 * test: given() 인자 내에 any()로 수정 * rename: get_claim_value() 주석 추가 * docs: 성공 응답 schema로 수정 * fix: 사용자 로그인 여부 인가 권한 is_authenticated()로 수정 * fix: auth_state_dto is_sign_in 필드 제거 * fix: controller authorization 헤더 필수값으로 수정 * fix: usecase 내 is_sign_in false 처리 로직 제거 * test: jwt_auth_helper mock -> 실제 객체 생성 * docs: auth state dto id 필드 설명 수정 * test: security filter에서 걸러지는 테스트 케이스 제거 * fix: is_sign_in log 레벨 debug -> info * style: web_sucurity 인증/인가 경로 상수 클래스 분리 * fix: /v1/auth 인가 권한 authenticated로 변경 * test: jwt_auth_helper 실제 인스턴스 주입 * fix: get_claims_value 로직 수정 * 일반 회원 아이디 찾기 API 구현 (#48) * refactor: auth-check-controller request-mapping값 변경 * refactor: auth-check controller와 api 분리 * feat: 아이디 찾기 api 생성 * feat: 아이디 찾기 api 전체 허용 * feat: 아이디 찾기 api 구현 * fix: sysout 제거 * fix: 아이디 찾기 api 접근 권한 변경 * feat: 아이디 찾기 api 응답 객체 수정 및 dto 정의 * fix: auth-find-mapper 클래스 정의 및 존재하지 않는 번호 예외 처리 * feat: 존재하지 않는 아이디 error-code 정의 * feat: 계정 찾기 관련 커스텀 에러 정의 * fix: 일반 회원이 아닌 휴대폰 번호 조회 시 not-found 예외 처리 * test: 아이디 찾기 시 존재하지 않는 휴대폰 번호 예외 처리 케이스 작성 * test: 아이디 찾기 시 oauth 사용자 휴대폰 번호 예외 처리 케이스 작성 * test: 아이디 찾기 시 휴대폰 번호 조회 성공 케이스 작성 * test: 아이디 api 성공 테스트 케이스 작성 * test: 아이디 api 요청 실패 테스트 케이스 작성 * rename: auth-find-error-code의 존재하지 않는 휴대폰에 대한 상수 변경 * rename: 아이디 찾기 api 테스트명 수정 * docs: 아이디 찾기 api swagger 문서 구체화 * fix: 아이디 찾기 api의 휴대폰 번호 not-null 설정 * fix: 아이디 찾기 시 처리 예외를 user-error-exception으로 변경 * rename: auth-find-mapper에서 auth-find-service로 변경 * fix: 아이디 찾기 endpoint security 수정 * fix: 아이디 찾기 flow 수정 * fix: auth-check-controller의 테스트 단위 변경에 따른 어노테이션 수정 * fix: oauth entity 내 provider converter 정의 (#52) * fix: 아이디 찾기 api에서 phone param을 not-blank로 변경 * fix: 아이디 찾기 실패에 따른 예외 로그 레벨 및 서비스 패키지 변경 * fix: auth-find-service 어노테이션을 service로 수정 * fix: 아이디 찾기 시 인증 코드 검증 및 캐시 제거 로직 추가 * fix: 아이디 찾기 시 인증 코드 검증 및 캐시 제거 로직 추가 * docs: 아이디 찾기 로직 주석 및 api response 추가 * fix: 아이디 찾기 api query에 code 추가 * feat: phone-verification-dto의 verify-code-req에 of 메서드 추가 * refactor: 아이디 찾기 로직의 코드 검증 및 삭제 로직을 usecase로 이동 * test: 아이디 찾기 api의 query에 code 추가에 따른 테스트 코드 변경 * test: 아이디 찾기 api의 query에 code 추가에 따른 result-actions 객체 매개변수 추가 * refactor: 인증코드 관련 패키지 및 클래스명 변경에 따른 수정 * fix: required-args-constructor import문 추가 --------- Co-authored-by: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> * ✏️ 인증번호 전송 API 통합에 따른 Deprecated API 삭제 (#57) * docs: 로그인 상태 확인 api swagger 문서 수정 * fix: auth_controller send code api 제거 * fix: oauth_controller send code api 제거 * ✨ OIDC 정책에서 id token에 nonce 필드 추가 (#58) * docs: 로그인 상태 확인 api swagger 문서 수정 * fix: auth_controller send code api 제거 * fix: oauth_controller send code api 제거 * fix: oauth_oidc_provider nonce 검증 구문 추가 * fix: oauth 로그인/회원가입 요청 시 nonce 필드 추가 * fix: service 로직 내 nonce 인자 전달 * fix: sign_up_req oauth_info dto에 nonce 필드 추가 * test: oauth controller 테스트 nonce 수정 * fix: oauth_use_case 영속화 실패 -> @transactional 추가 * fix: send_code api 통합 pr에서 제거하지 않은 use case 내 send code 메서드 제거 * ✨ 사용자 아이디/이름 수정 API (#59) * test: 사용자 계정 api 내부 클래스로 분리 * test: 사용자 이름 수정 controller unit pre-condition 작성 * test: 이름 수정 요청 controller unit test case 작성 * fix: 일반 회원가입 계정이 아닌 예외 상수 추가 : 4004 * feat: 이름 변경 요청 dto 정의 * feat: put_name() controller 메서드 추가 * feat: update_name() usecase 추가 및 void 타입에 맞게 테스트 코드 given 수정 * test: 422 예상 에러 코드 수정 * test: 사용자 계정 usecase 기존 test 내부 클래스로 분리 * test: 일반 회원가입 유저 pre-condition 제거 * test: 이름 수정 usecase test case 작성 * feat: 사용자 이름 수정 로직 구현 * feat: user 도메인 이름 수정 메서드 추가 * test: user_account_use_case_test 순서 지정 * fix: 디바이스 비활성화 em.create_query() -> 메서드 호출 (기존 방식 에러 발생) * test: 사용자 닉네임 수정 controller unit test 작성 * feat: 사용자 아이디 변경 요청 dto 작성 * feat: 사용자 아이디 변경 요청 api 작성 * feat: 사용자 아이디 변경 요청 usecase 작성 * feat: 사용자 아이디 변경 service 로직 구현 * test: nickname -> username * test: 테스트 코드에서 entitymanager 주입 제거 * refactor: user account use case 사용자 조회 메서드 분리 * fix: 이름 및 아이디 수정 요청 메서드 put -> patch * test: put 요청 patch로 변경 * ✨ 사용자 알림 설정 API (#60) * feat: notify setting update 메서드 추가 * feat: notification 활성화 api 추가 * feat: notification 비활성화 api 추가 * feat: notification 활성화/비활성화 usecase 추가 * refactor: user account use case 사용자 조회 메서드 분리 * rename: notify -> flag * feat: 사용자 알림 설정 service 로직 구현 * feat: 사용자 알림 설정 응답 dto 정의 * feat: use case 응답에 dto 반영 * feat: notify type converter 정의 * feat: web_config에 notify_type_converter 등록 * fix: invalid_notify_type 400 -> 422 변경 * docs: notify api 응답 swagger 문서 작성 * test: user_account_use_case_test 빈 등록 * fix: 알림 설정 api 메서드 변경 put -> patch * 🐛 아이디 찾기 API 요청 시 휴대폰 번호 및 인증 코드를 입력하지 않을 경우 INTERNAL_SERVER_ERROR 해결 (#61) * test: 아이디 찾기 api 요청 시 휴대폰 번호 및 코드를 입력하지 않은 케이스 추가 * fix: 아이디 찾기 api의 query-string 유효성 검증을 위한 인자 수정 * fix: 아이디 찾기 api 인자값 변경에 따른 usecase 및 service 메서드 인자 수정 * fix: of 메서드 삭제 * ✨ 비밀번호 검증/변경 API (#62) * test: 현재 비밀번호 검증 api 구현 * feat: 일반 회원가입 없는 유저 예외 코드 추가 * feat: 비밀번호 검증 요청 dto 정의 * fix: 미회원가입 계정 오류 400 -> 403 * feat: 비밀번호 검증 controller 정의 * test: use case 임시 구현 및 테스트 통과 확인 * test: 비밀번호 검증 use case 테스트 케이스 작성 * feat: 패스워드 암호화 helper 클래스 정의 * feat: use case에 패스워드 helper class 의존성 주입 * test: 패스워드 헬퍼 클래스 의존성 주입 * feat: mock -> mockbean 수정 * fix: password_encoder final 한정자 추가 * test: 사용자 비밀번호 수정 controller unit test 작성 * feat: reason coe 400번대 5번 비트 client_error 추가 * feat: 동일한 비밀번호 변경 요청 에러 코드 추가 * feat: 비밀번호 변경 요청 dto 정의 * feat: 비밀번호 수정 controller 구현 * feat: 임시 비밀번호 변경 usecase 작성 && controller unit test 통과 확인 * feat: 비밀번호 수정 usecase 및 service 구현 * feat: password encoder helper 클래스의 문자열 인코딩 검증 메서드 제거 * test: 비밀번호 변경 usecase unit test 작성 * feat: use case 예외 로그 추가 * test: 동일 비밀번호 요청 시 given 절 추가 * test: given 사용자 생성 @before_each 통합 * fix: 기존과 동일한 비밀번호 검증 조건식 수정 * test: 정상 유저 비밀번호 변경 요청 given(is_same_password) 조건 분리 * ✨ External Api Controller 로깅을 위한 AOP 구현 (#64) * feat: external-api-log-aspect 구현 * fix: authorization && set-cookie && cookie 헤더는 debug 모드로 로깅 * fix: request argument가 null인 경우 제외 * feat: request header log 어노테이션 작성 * fix: cookie, authorization 문자열 의존 제거 * fix: request header log 어노테이션 인증 헤더 제거 * fix: controller의 request header log default value 제거 * 🐛 RTR 오동작 검증 (아무 문제 없었다..) (#66) * feat: redis 단위 테스트 편의용 어노테이션 작성 * test: refresh token service 단위 테스트 작성 * test: refresh token service 테스트에 불필요한 의존성 제거 * fix: jwt_auth_helper claims 추출 메서드 사용하도록 변경 && 로그 갱신 * test: jwt_auth_helper refresh 테스트 * test: refresh token service 단위 테스트 탈취 시나리오 통과 확인 * test: jwt_auth_helper 단위 테스트 탈취 시나리오 통과 확인 * refactor: refresh token 생성이 통과되면 access token 생성하도록 수정 * test: 탈취 시나리오 테스트 given 수정 * docs: refresh api 스웨거 에러 응답 개선 * ✨ 인증된 사용자의 소셜 계정 연동 API (#67) * feat: 소셜 계정 연동 api 정의 * fix: request param 추가 dto 정의 -> sign_in_req.oauth dto 활용 * docs: 소셜 계정 연동 api 상세 설명 스웨거 추가 * feat: 소셜 계정 연동 usecase 작성 * feat: user_oauth_sign_server 조건부 user 조회 메서드 추가 * fix: user_oauth_sign_server 조건부 user 조회 메서드 -> user_sync_dto 반환 메서드로 수정 * fix: 소셜 계정 연동 usecase 불필요 의존성 제거 * test: user_auth_user_case 단위 테스트 추가 의존성 주입 * fix: already_signup_oauth error code 400 -> 409 * docs: 409 에러 응답 swagger 반영 * fix: already_signup error code 400 -> 409 * docs: 일반 회원가입 전화번호 인증 409 에러 응답 swagger 반영 * test: 인증된 사용자의 소셜 계정 연동 api 통합 테스트 * docs: 소셜 계정 연동 api 스웨거 409 예외 추가 * test: 기존 already_sign_up 400 에러 예상 응답 409로 수정 * style: user_oauth_sign_service is_link_allowed() 메서드 순서 변경 * fix: like-oauth api post -> put 요청으로 수정 * fix: oauth entity @sql_restriction 어노테이션 제거 * feat: user_id와 provider로 oauth 조회 메서드 추가 * fix: user_sync_dto 연동하기 위한 oauth 정보를 추가로 받는 내부 record 추가 * fix: 사용자 연동 dto user entity -> 직접 필드를 파라미터 주입하도록 수정 * fix: 회원가입 이력 가능 user_sync_dto 생성용 편의 메서드 추가 * feat: oauth entity soft deleted 여부 확인 메서드 추가 * feat: oauth entity soft delete 취소 메서드 추가 * fix: oauth sync dto 팩터리 메서드 내 oauth null 처리 로직 추가 * feat: user_sync_dto 기존 oauth 정보 존재여부 판단용 메서드 추가 * feat: oauth_service id로 oauth 조회 메서드 추가 * feat: oauth_error_code not_found 추가 * fix: soft delete된 oauth 존재 시나리오 반영하여 oauth sign service 수정 * refactor: oauth sign service 클래스 save_user 분기 처리 중첩 if문 제거 * fix: general_sign_service user_sync_dto 인자 추가 * feat: oauth domain service delete 메서드 추가 * feat: soft delete된 oauth 등록 테스트 케이스 추가 * feat: soft delete된 oauth 재활성화 시, oauth_id 필드 given 절 수정 * fix: oauth entity revert_delete 상태 조건문 수정 * fix: oauth revert 시, 사용자가 전송한 oauth_id로 업데이트 * 🐛 OAuth 로그인/회원가입 시 Soft Delete 정책 반영 검사 (#69) * test: 같은 provider로 Oauth 로그인 이력이 soft delete 되었으면 성공 응답을 반환한다 * test: 소셜 회원가입 시 soft delete된 oauth 업데이트 여부 테스트 추가 * fix: 소셜 회원가입 dto 필드로 oauth_id 추가 * fix: oauth_oidc_helper oauth_id 메개변수 추가 * fix: get_payload() 사용하던 usecase에서 oauth_id 인자 전달 * test: get_payload() 사용하던 test에서 oauth_id 인자 전달 * test: user auth controller 통합 테스트에서 soft delete 테스트 시 expected_oauth_id 수정 * ✨ 인증된 사용자의 OAuth 연동 해지 API (#70) * feat: 소셜 로그인 연동 해제 api 정의 * feat: user oauth sign service & usecase 로직 구현 * feat: oauth_error_code cannot_unlink_oauth 에러 코드 추가 * feat: user가 연동한 oauth 모든 정보 조회 목적 domain service 메서드 추가 * test: oauth 연동 해제 테스트 케이스 작성 * test: 인가 권한을 위해 @with_security_mock_user 어노테이션으로 수정 * refactor: service의 unlink() -> read_oauth_for_unlink()로 수정 * docs: 소셜 계정 연동 해제 409 에러 응답 문서 추가 * test: 테스트 케이스 위치 이관 * ✏️ 마이 프로필 조회 시, 소셜 계정 연동 정보 추가 (#72) * feat: 유저 조회 usecase에서 delete되지 않은 oauth 계정 정보 조회 추가 * feat: oauth account 정보를 담을 dto 정의 * feat: dto binding을 위한 user_profile_mapper 생성 * test: user_account_use_case_test oauth_service mock bean 주입 * fix: user_profile_dto oauth_account 필드 nullable 제거 * fix: is_oauth_user 필드 -> is_general_sign_up 필드로 수정 * ✨ 사용자 계정 삭제 API (#73) * feat: 계정 삭제 controller 단위 테스트 * feat: 사용자 계정 삭제 controller 구현 * feat: 유저 삭제 use case -> 컴파일 에러 해결 목적 임시 구현 * test: oauth_service mock bean -> autowired * feat: oauth repository 벌크 연산 삭제 메서드 추가 * feat: oauth service 사용자 아이디로 연관 관계의 oauth 정보 제거 메서드 추가 * feat: 사용자 삭제 서비스 추가 * test: user delete service 빈 추가 * feat: user domain 벌크 연산 삭제 메서드 추가 * fix: user 영속화 제거 * fix: delete 시, 영속화 된 user -> user_id 기반으로 제거 * fix: 컨트롤러 @delete_mapping 추가 * docs: 계정 삭제 api 주의 사항 추가 * ✨ 비밀번호 찾기 API (#71) * feat: 비밀번호 변경 경로 설계 * feat: 비밀번호 내부로직 작성 * fix: @operation 오탈자 수정 * fix: dto 구조 변경 * docs: service 주석 작성 * docs: authcheckapi 작성 * fix: 변경된 멤버변수 반영 및 테스트파일명 변경 * fix: 동일 비밀번호 입력시 요청실패 수정 * feat: 비밀번호 인증 api 분리 * feat: 테스트코드 작성 * docs: swagger 예외응답 작성 * fix: 테스트코드 userfixture 사용 및 log.debug사용 * fix: preauthorize 및 @validated 누락 반영 * feat: 번호 인증 후 code 캐시 ttl 연장 * docs: swagger 404 에러코드 수정 * fix: service 로직 메서드 분리 * fix: authfindservicetest에 변경된 메서드명 반영 * ✨ 지출 관리 영역 Domain 정의 (#74) * feat: 소비 아이콘 타입 정의 * feat: 지출 아이콘 컨버터 정의 * fix: ledger -> spending 패키지명 수정 * feat: 지출 entity 정의 * feat: 지출 카테고리 entity 정의 * feat: 지출 entity와 지출 카테고리 entity 연관관계 매핑 * rename: spending_category -> spending_custom_category 수정 * feat: 지출 및 지출 카테고리 repository 인터페이스 생성 * feat: 지출 domain service 생성 * feat: 지출 커스텀 카테고리 domain service 생성 * feat: 지출 목표 금액 entity, repository, domain service 정의 * rename: spending icon -> spending category * rename: package 이름 amount -> target * 🐛 사용자 삭제 시나리오 개선 (#75) * test: 사용자 삭제 시, 디바이스 정보는 CASCADE로 삭제되어야 한다 * test: 테스트 display name 설명 수정 * ✨ QueryDsl 확장 Repository 및 Util 정의 (#76) * feat: query dsl extended repository 인터페이스 선언 * feat: jpa query를 전달하기 위한 함수형 인터페이스 추가 * docs: @see 추가 * feat: query dsl extended repository 인터페이스 구현체 정의 * feat: extended repository를 적용하기 위한 factory 정의 * chore: extended bean repository factory config 등록 * feat: query dsl util 추가 * refactor: repository 내부 메서드를 query dsl util에서 수행 * feat: slice 유틸 추가 * docs: util 클래스 범위 주석 추가 * rename: extended repository -> query dsl repository * feat: extended repository 통합 인터페이스 선언 * rename: query dsl repository factory -> extended repository factory * fix: user repository 상속 jpa repository -> extended repository * test: extended repository 사용법 공유를 위한 테스트 케이스 * docs: query_dsl_search_repository sort 사용법 수정 * fix: query dsl util 내 cast_to_query_dsl 중복 정의 제거 * rename: query_dsl_util null handling 메서드 설명 수정 * rename: query_dsl_search_repository_impl 클래스 레벨 주석 제거 && query_dsl_util 주석 수정 * ✨ 당월 목표 금액 설정 API (#77) * docs: 당월 목표 금액 등록/수정 swagger 작성 * test: 쿼리 파라미터 유효성 검사 에러 응답 테스트 케이스 작성 * test: param 유효성 검사 테스트 케이스 추가 * test: param null인 경우 400 테스트 추가 * feat: handler_method_validation_exception 전역 예외 핸들러 추가 * feat: target_amount_usecase 클래스 생성 * feat: target amount domain delete 쿼리 수정 및 delete_at 필드 제거, amount 수정 메서드 추가 * fix: amount 타입 wrapper -> primitive 타입으로 수정 * refactor: @request_param -> dto로 리팩토링 * test: expected 400 -> 422 에러로 수정 * docs: update_param_req swagger 문서 작성 * feat: target amount 에러 코드 및 예외 작성 * test: 당월에 해당하는 요청인지 검증하는 테스트 추가 * feat: 당월이 아닐 시 예외처리 * feat: 당월 목표 금액 조회 domain service 메서드 추가 * rename: domain service와 repository 메서드 명 수정 * rename: save -> create * test: 당월 목표 금액 등록 통합 테스트 작성 * refactor: target_amount_save_service 분리 * test: displayname 400 -> 422 에러로 수정 * rename: 패키지명 ledge -> ledger * fix: put mapping value 추가 * style: if문 중괄호 추가 * ✨ 월별 사용자 지출 내역 조회 API (#78) * docs: 지출 내역 조회 스웨거 문서 작성 * feat: 지출(spending) 컨트롤러 작성 * fix: 패키지명 ledge -> ledger * rename: at_month -> at_year_and_month 메서드명 수정 * feat: spending entity get_day() 메서드 추가 * feat: 지출 내역 조회 dto 정의 * feat: 월별 지출 내역 조회 usecase 구현 * rename: save -> create_spending() * fix: 지출내역 jpa repository -> extended repository * feat: spending domain service 내 read_spendings() 추가 * chore: querydsl-core 의존성 api로 수정 * chore: querydsl-jpa 의존성 api로 수정 * fix: spending 조회 시 query dsl 메서드 사용 * test: 지출 내역 테스트를 위한 batch fixture 정의 * test: 월별 지출 내역 조회 통합 테스트 작성 * fix: spending_fixture query 수정 * fix: spending_fixture query category 랜덤 수정 * fix: dto validation 추가 * refactor: spending search service 분리 * refactor: spending mapper 분리 * refactor: spending_mapper 내부 메서드 분리 * rename: spending mapper calculate 메서드 주석 추가 * docs: spending search res dto 스웨거 문서 작성 * docs: 지출 내역 조회 api 스웨거 문서 200 응답 포맷 추가 * test: 월별 지출 내역 조회 수행 시간 측정 * feat: 지출 entity sql_restriction 추가 * ✨ 사용자 커스텀 지출 카테고리 등록 API (#79) * feat: 공통된 카테고리 정보를 반환하기 위한 dto * feat: spending entity get_category() 분기 처리 메서드 추가 * fix: get_category type에 맞추어 기존 코드 타입 변환 * fix: custom category인 경우를 핸들링 하기 위한 is_custom 필드 추가 * fix: 사용자 정의/서비스 제공 카테고리에 따른 dto 정보 수정 * fix: category icon 필드 타입 spending_category로 수정 * docs: api 스웨거 문서 작성 * feat: 지출 카테고리 dto 정의 * refactor: spending category api 분리 * feat: 지출 카테고리 추가 use case 작성 * test: 유효하지 않은 카테고리명을 입력하면 422 Unprocessable Entity 에러 응답을 반환한다 * fix: name 쿼리 파라미터 @not_empty -> @not_blank * test: controller unit test 유효성 검사 추가 * feat: icon other 값 예외 처리 * feat: spending exception 정의 && other 아이콘 예외 처리 * test: other icon 입력 예외처리 테스트 * rename: save -> create_spending_custom_category * fix: spending_custom_category 테이블명 수정 * docs: param query object 스웨거 상에서 안 보이도록 처리 * docs: 성공 응답 스키마 추가 * docs: 지출 카테고리 응답 icon 필드 예시 수정 && 응답 key값 추가 * ✨ 지출 내역 등록 API (#81) * test: 소비 내역 등록 controller unit test * feat: 지출 등록 dto 생성 * feat: dto 내 to_entity() 메서드 정의 * test: given() 추가 및 mockmvc csrf 처리 * test: 101자리 랜덤 문자열 생성 방법 수정 * feat: 요청 icon이 other이면서, category_id가 -1인 경우 예외 처리 * fix: to_spending_search_res_individual private -> public * feat: 동작하도록 usecase 지출 생성 코드 작성 * feat: 지출 entity 내 커스텀 카테고리 매핑 메서드 및 생성자 유효성 검사 추가 * feat: custom_category not_found 예외 추가 * feat: spending_custom_category 조회 domain service 메서드 추가 * rename: 에러 응답 상세화 * fix: category_info 정적 팩토리 메서드 내 is_custom 판단 스니펫 수정 * test: request의 categoryId가 -1인 경우, spendingCustomCategory가 null인 Spending을 생성한다 * test: request의 categoryId가 -1이 아닌 경우, spendingCustomCategory를 참조하는 Spending을 생성한다 * test: 기존 카테고리에 등록하는 경우, icon 정보를 other로 수정 * fix: category 정보 상세한 응답으로 변경 * feat: request의 category_id, icon 조합 검증 * fix: 서비스/사용자 카테고리에 따른 to_entity() 분리 * refactor: spending_save_service 분리 * refactor: 직관적인 코드를 위해 request 내 custom_category 요청 여부를 판단하는 메서드 추가 * test: 자원 검증 시나리오 테스트 추가 * fix: is_custom_category 논리 부정 연산 추가 * feat: 커스텀 카테고리 자원 접근 관리 매니저 생성 * feat: 사용자 id와 커스텀 id가 일치하는 커스텀 지출 카테고리 검사 메서드 추가 * feat: 자원 검증 인가 로직 controller 반영 * docs: 지출 내역 추가 api 문서 작성 * docs: swagger 예외 응답 추가 * test: usecase에 대한 테스트로 수정 * ✨ 사용자 정의 지출 카테고리 조회 API (#82) * feat: 지출 카테고리 조회 controller 메서드 추가 * feat: 지출 카테고리 조회 usecase 작성 * feat: 지출 카테고리 조회 domain service 추가 * fix: 상태 검사를 위한 조건문 생성자로 이동 * rename: icon -> category * feat: category converter 필드에 정의 * docs: 지출 카테고리 조회 swagger 문서 작성 * ✏️ QueryDsl 확장 Repository의 Dto 불변성 보장을 위한 분기 처리 (#84) * feat: query dsl extended repository dto 불변식 유지를 위해 linked_hash_map 자료 구조 분기 처리 * test: hash map 테스트 케이스 추가 * rename: query_handler 사용 목적을 담은 주석 수정 * test: 테스트용 dto @to_string() 작성 * docs: 전체 보기 그룹 추가 (#87) * 🐛 사용자 계정 삭제 후 동일한 OAuth 회원가입 이후 로그인 에러 해결 (#85) * fix: oauth_id, provider로 oauth 조회 시, deleted_at is null 조건 where 절 추가 * rename: 메서드에 deleted_at이 null인 경우만 조회함을 주석으로 명시 * fix: user_oauth_sign_service read_user 시 is_deleted 체크 제거 * test: domain service deleted_at 처리 대응 -> assert_true 대신 assert_null * test: oauth repository query 정상 동작 확인 * ✏️ 테스트 작성 시 `@AuthenticatePrincipal` 어노테이션 대응 (#86) * feat: 인증 필터에서 token 파싱 후 id값 로깅 * feat: @with_security_mock_user 대신 .with(user())로 사용자 인증 대체 * fix: security_user_details get_password() 비검사 예외 발생 제거 -> null 반환 * test: user_auth_controller 통합 테스트 메서드 순서 및 seucurity mock user 어노테이션 제거 * refactor: 통합테스트 패키지를 controller -> integration으로 수정 * test: 테스트 메서드 순서 지정 어노테이션 제거 * ✨ 사용자 년/월 별 지출 총합 및 목표 금액 조회 API (#88) * feat: 목표 금액 조회 요청 파라미터 dto 추가 * feat: 목표 금액 조회 응답 dto 정의 * fix: date 쿼리 validation 추가 * feat: controller 단일 및 전체 내역 조회 * feat: domain module 사용자 년/월 별 총 지출 금액을 반환하는 dto 정의 * feat: spending custom repository 정의 && 사용자의 특정 년/월 총 지출 금액 조회 메서드 추가 * feat: 사용자 년/월 지출 금액 조회 usecas 반영 * fix: target_amount_usecase 목표 금액 리스트 조회 및 매퍼 클래스 사용 * feat: domain service 내 user_id로 목표 금액 설정 이력 조회 * fix: 목표 금액 및 총 지출 금액 조회 dto 이름 및 필드 수정 * fix: 전체 소비 금액 조회 optional ㅊ처리 * feat: 목표 금액 상세 정보를 위한 내부 dto 정의 * fix: dto null guard 구문 추가 * feat: to_with_total_spending_res mapper 메서드 추가 * feat: to_with_total_spendings_res mapper 메서드 추가 * fix: 날짜 범위 계산식 수정 * feat: target_amount entity to_string() 정의 * refactor: entity -> dto 매핑을 위해 자료구조를 list -> map으로 변경 * refactor: target_amount_mapper 함수 분리 및 메서드 명 일부 수정 * rename: target_amount_info dto 정적 팩토리 메서드 주석 추가 * rename: with_total_spending_res diff_amount 필드 notnull 조건 추가 * feat: view type 열거 타입 정의 * feat: view_type_converter 정의 및 web_config 등록 * fix: 단일 조회 시 path_parameter로 date 받도록 수정 * fix: view_type 제거 * docs: controller 스웨거 문서 작성 * fix: use_case 내 불필요한 종속성 제거 * test: user_fixture 사용자 가입일 지정하여 저장하는 메서드 추가 * fix: spending_fixture 소비일 랜덤 생성 로직 수정 * test: target_amount fixture 정의 * test: user created_at 필드를 업데이트하는 메서드로 수정 * test: 사용자 임의의 년/월 지출 총합 및 목표 금액 조회 테스트 * fix: 임의의 년/월 조회 시 group by 절 추가 * fix: 모든 기록 조회 쿼리에서 sort 조건 수정 * chore: domain.yml 테스트 환경에서 jdbc 로그 debug로 설정 * test: month 길이 계산 로직 수정 * fix: mapper 내에서 month_length 길이 계산 로직 수정 * test: user_account_use_case_test jpa_query_factory mock bean 주입 * chore: jpa_query_factory 테스트 환경 설정용 빈 등록 * test: jpa_query_factory 빈 주입 * fix: target_amount_mapper @required_args_contstructor 제거 * ✨ 당월 목표 금액 삭제 API (#90) * feat: target_amount delete controller 정의 * feat: target_amount 404 에러 코드 추가 * feat: target_amount 삭제 usecase 추가 * feat: target_amount 삭제 domain service 메서드 추가 * refactor: target amount 조회 로직 수정 * docs: 삭제 api swagger 작성 * fix: is_allocated_amount 논리 부정 제거 * docs: 스웨거 파라미터 주석 추가 * fix: parameter in import 추가 * ✨ 지출 내역 상세 조회 API (#89) * feat: 상세조회 controller 작성 * feat: 존재하지 않는 지출 내역 에러코드 추가 * feat: 접근할 수 없는 지출 내역 에러코드 추가 * feat: 지출내역 개별 조회 메소드 추가 * feat: spendingsearchservice 작성 * feat: usecase 작성 * docs: swagger 문서 작성 * fix: 컨벤션에 맞추어 응답 key 수정 * feat: 인가 처리를 위한 spendingmanager 작성 * refactor: 기존 usecase의 인가 로직 제거 * refactor: searchservice 로직 usecase로 이동 * fix: 오탈자 및 잘못된 파라미터 순서 수정 * fix: 잘못된 로그 제거 * fix: 403 에러 문서 및 코드 삭제 --------- Co-authored-by: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> * 🐛 지출 내역 조회 응답 및 스웨거 버그 픽스 (#92) * docs: 지출 내역 조회 @parameter type header -> query && 지출내역 상세 조회 @parameter 추가 * fix: 소비 내역 조회 시, 월 소비 금액 총합 데이터 제거 * fix: 응답 키 spendings -> spending * ✨ Firebase Cloud Messaging 기본 설정 (#94) * chore: firebase build 종속성 추가 * chore: fcm_config import selector group 설정 * chore: external-api infra_config에서 fcm_config 빈 스캔 * chore: firebase admin key 환경변수 등록 * feat: fcm_config 정의 * chore: firebase admin-key .gitignore 등록 * fix: fcm_config 실행 환경에서 test 제거 * rename: fcm config @profile todo 주석 추가 * chore: cd pipeline 내 fcm admin sdk json 파일 생성 step 추가 * chore: cd pipeline json 삽입 동작 확인 * chore: json name, dir 속성 분리 * chore: resource firebase 디렉토리 .gitkeep 생성 및 .gitignore 범위 수정 * chore: feature 브랜치 cd 감지 제거 * ✨ 사용자 로그인 로그를 위한 Interceptor 및 환경 구축 (#95) * feat: 로그인 로그 redis entity 정의 * feat: 로그 redis repository 작성 * fix: sign event log repository crud -> list_crud 상속으로 변경 * feat: sign_event_log_service 정의. cr 작업만 수행 * feat: sign_in_log 복합키 클래스 설정 * feat: sign_in_log entity 정의 * feat: sign_in_log repo & service 클래스 생성 * feat: web_mvc_config sign_event_log_interceptor 등록 * refactor: jwt_claims_parser_util 분리 * feat: ip_address_header type 정의 * feat: ip_address_header converter 정의 * feat: sign_in_log entity에 app_version, ip_address_header 필드 추가 * fix: sign_event_log app_version, ip_addr_header 필드 추가 * feat: sign_event interceptor 정의 * fix: sign_in_log_id 클래스 직렬화 구현 * fix: sign_in_log entity에 @entity 선언 * test: web_mvc_config 내 service 의존성 문제로 인한 controller unit test 실패 해결 * fix: interceptor response auth header 존재 여부 검증 추가 * ✨ S3 정적 파일 업로드를 위한 Presigned-URL 발급 API 구현 (#97) * chore: s3 관련 의존성 추가 * feat: s3-client 빈 생성을 위한 s3-config 클래스 선언 * feat: s3-presigner 빈 선언 * feat: s3 presigned-url 발급을 위한 provider 선언 및 발급 메서드 선언 * fix: s3 접근을 위한 credentials 선언 및 적용 * feat: s3 presigned-url 발급을 위한 api 명세 정의 * feat: s3의 object key 생성을 위한 uuid util 클래스 정의 * feat: presigned-url 발급을 위한 dto 선언 및 api 수정 * feat: object-key 생성을 위한 템플릿 및 타입 선언 * fix: s3-bucket-name 환경 변수 추가 및 관련 로직 수정 * feat: presigned-url 발급을 위한 service 및 usecase 정의 * fix: storage-usecase 패키지 이동 * fix: storage-controller 접근 권한 수정 * fix: storage 관련 예외 및 에러 코드 선언 및 예외 처리 로직 수정 * test: presigned-url 발급을 위한 api 테스트 로직 작성 * chore: presgined-url response-dto description 삭제 * fix: user error-code와 중복된 코드 제거 * fix: presgined-url 발급 요청 시 chat-id 속성 삭제 및 aws-s3-provider 주석 구체화 * docs: swagger 응답 구체화 * refactor: object-key 생성을 위한 url-generator 정의 * refactor: object-key 생성을 위한 url-generator 적용 * docs: aws-s3-provider 메서드들의 매개변수 관련 설명 추가 * fix: s3 관련 예외 위치 수정 * fix: presigned-url 발급 api swagger 응답에서 코드 제거 * fix: presigned-url 발급을 위한 dto명 변경 * test: storage-controller 테스트 profile 수정 * fix: presigned-url 발급을 위한 dto명 변경 * fix: presigned-url 발급을 위한 dto명 변경 * test: 테스트를 위한 user 설정 변경 * test: exclude-filters 추가 * ✨ 지출 내역 삭제 API (#96) * feat: 삭제 api 작성 * docs: swagger 문서 작성 * fix: 인증 처리 이동에 의한 파라미터수정 * docs: 지출 삭제 swagger 403 예외 추가 * test: 삭제 테스트코드 작성 * feat: fixture 메소드 추가 및 삭제 테스트코드 assertion 추가 * feat: fixturefixture 상수 변경 * ✨ 지출 내역 수정 API (#93) * fix: usecase단의 필요없는 파라미터 제거 * feat: 지출내역 수정 controller 작성 * feat: usecase 작성 * feat: spending 업데이트 메소드 작성 및 service 작성 * feat: swagger docs 작성 * feat: spending.update 메소드를 customcategory 포함해 업데이트 하도록 수정 * docs: 지출 내역 생성과 중복된 에러응답 제거 * feat: 커스텀 카테고리 여부 분기 로직 고려해 로직 재작성 * refactor: 불필요한 user필드 제거를 위해 req dto toentity 메소드 오버로딩 후 리팩토링 * test: spendingupdateservice 테스트 작성 * test: 지출내역 상세 조회 및 수정 통합테스트 작성 * fix: 잘못 작성된 fixture메소드 제거 * test: spendingfixture 상수값으로 변경 * feat: 통합테스트 피드백 반영 * fix: 충돌 resolve 후 에러 수정 * ✨ Redisson을 활용한 분산 락(Distributed Lock) 환경 구축 및 테스트 (#98) * chore: domain 모듈 내 redission 의존성 추가 * feat: redisson config 설정 * feat: 분산락 커스텀 어노테이션 생성 * feat: spring spel 커스텀 파서 유틸 클래스 생성 * fix: 분산락 커스텀 어노테이션 need_same_transaction 필드 추가 * rename: need_same_transaction 주석 추가 * feat: transaction propagation 레벨 지정 추상 팩토리 작성 * rename: need_same_transaction -> new 수정 * fix: redisson_call_new_transaction timout 및 주석 수정 * rename: unit -> timeunit 이름 수정 * feat: 분산락 aop 클래스 구현 * fix: redis_connection_factory & redis_cache_manager 빈 @primary 등록 * test: redisson 설정을 위한 test container redis password 설정 추가 * test: test container domain 모듈 redis password 추가 * test: domain 모듈 db & redis testcontainer config 설정 * test: 분산 락 테스트용 더미 entity 정의 * test: 분산 락 테스트용 더미 service 구현 * test: 분산락 테스트 작성 * fix: return null 수정 * rename: 락 해제 시점 로그 추가 * test: aop unit test 적용 * fix: test_jpa_config query_dsl bean conditional 옵션 적용 * test: 도메인 모듈 통합 테스트 어노테이션 생성 * fix: redis_config scan 경로 지정 * chore: could not safely identify store assignment for repository 이슈 제거 * test: @domain_integration_test 환경 추가 * test: test_coupon_repository 적용 * test: test_coupone service & repository profile test 지정 * test: 분산 락이 없는 테스트 케이스 추가 * test: jwt_auth_helper @redis_unit_test 어노테이션 제거 * test: test_jpa_config_conditional_on_missing_bean 메서드에 @bean 추가 * fix: query_dsl_config @primary 지정 * fix: lock 획득 대기시간 연장 * Batch: ✨ Batch 기초 세팅 및 CD 파이프라인 수정 (수정될 PR 컨벤션 관련 내용 포함) (#99) * chore: batch module 생성 * chore: batch 모듈 application 클래스 생성 * chore: batch 라이브러리 추가 * chore: external-api docker script 위치 수정 * chore: batch docker script 생성 * chore: java build 시 비블로킹 방식으로 수정 * chore: api cd gradlew parallel 옵션 추가 && 파일명 수정 * chore: cd 스크립트 external-api docker 경로 수정 * chore: batch cd 스크립트 추가 * fix: batch application 클래스 main 함수 추가 * chore: tag & release 자동화 actions 스크립트 작성 * chore: docker image 경로 수정 * chore: batch module 생성 * chore: batch 모듈 application 클래스 생성 * chore: batch 라이브러리 추가 * chore: external-api docker script 위치 수정 * chore: batch docker script 생성 * chore: java build 시 비블로킹 방식으로 수정 * chore: api cd gradlew parallel 옵션 추가 && 파일명 수정 * chore: cd 스크립트 external-api docker 경로 수정 * chore: batch cd 스크립트 추가 * fix: batch application 클래스 main 함수 추가 * chore: tag & release 자동화 actions 스크립트 작성 * chore: docker image 경로 수정 * rename: 태그 및 릴리즈 자동화 파이프라인 주석 추가 * chore: cd 파이프라인 버전 정보 추출 step 추가 * release: api v1.0.0 출동 * release: api-v1.0.0 (#100) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * Api: 🐛 태그 생성 & 릴리즈 자동화 파이프라인 후 배포 workflows call (#101) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * rename: run 이름 call-external-api-deploy로 변경 * chore: api & batch 모듈 workflow_call trigger 추가 * chore: tag 정보 inputs.tags로 수정 * test: 태그 생성 트리거 opened 추가 * test: merged 조건식 임시 제거 * fix: call 인자 전달 시 version -> tag 수정 * rename: batch cd 파이프라인 이름 수정 * chore: workflow_dispatch 제거 * chore: reuse workflow 호출 시 secret key 상속 옵션 추가 * chore: pr 병합 조건문 추가 * Api: 🐛 object-key의 depth 수정을 위한 템플릿 수정 (#102) * Api: ✏️ 목표 금액 플로우 변경에 따른 API 스펙 변경 (#103) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: target_amount entity is_read 필드 추가 * fix: target_amount 409 예외 코드 추가 * fix: target-amount is_exists 메서드 추가 및 query_dsl impl 구현 * rename: exists 오타 수정 & tx read_only 옵션 추가 * fix: target_amount id & user_id 조건문 탐색 메서드 추가 * fix: target_amount id, user_id exists 메서드 추가 * rename: 매개변수 순서 변경 * fix: target_amount dto query param을 위한 클래스명, 필드 정보, 문서 수정 * docs: target amount api spec 수정 * fix: api 스펙에 맞게 controller 수정 및 자원 검증 로직 추가 * fix: target_amount_info dto is_read 필드 추가 * fix: target-amount 생성 use case 분리 * fix: find_by_id 시 user_id 조건문 제거 * fix: target_amount update 반환 값 수정 및 date 기반 탐색 -> target_amount_id 기반 탐색으로 수정 * fix: authenticated_principal 주입 필요없는 곳에서 제거 * fix: target_amount_save_service 제거 * fix: target_amount 생성 시 분산 락 적용 * style: distributed_lock 어노테이션 위치 수정 * fix: 분산락 prefix 문자열 관리 클래스 분리 * style: update_target_amount 할당문 추가 * docs: target_amount patch 응답 데이터 수정 * fix: lock prefix 수정 * test: target-amount 통합 테스트 파일 통일 및 jdbc bulk query 수정 * fix: target_amount entity 당월 데이터 확인 메서드 추가 * fix: update, delete 시 당월 데이터 여부 검증 로직 추가 * fix: target_amount to_string() year, month 정보 추가 * test: target_amount 날짜 변경 후 cache 제거 * test: 목표 금액 생성 요청 테스트 * fix: target_amount 분산 락 키 spel 문법에 맞게 수정 * test: target_amount 삭제 테스트 케이스 작성 * rename: target_amount 데이터 생성 시 로그 제거 * rename: json 내부 target_amount 필드명 -> target_amount_detail 수정 * fix: target_amount 생성 요청에서 당월 요청 판단 로직 수정 * test: put -> patch 이름 수정 * test: target_amount controller unit test 수정 * fix: success response key 상수처리 * fix: use case 내 불필요한 create 호출 제거 * rename: date_param dto schema 오타 수정 * test: 특정 년/월에 대한 target_amount가 존재하지 않는 경우 404 not found 에러 응답을 반환한다 * fix: target_amount use case not found 시 예외 처리 * docs: target api 조회 시 404 예외 문서화 * docs: target_amount 수정 및 삭제 문서 작성 * fix: lock key의 date에서 day 인자 제거 * fix: 문의하기 응답속도 개선을 위한 이벤트 핸들러 비동기 처리 (#106) * docs: 📝 Readme-v0.0.3 (#108) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * docs: root readme 수정 * docs: external-api 모듈 docs 수정 * feat: ✨ 사용자 프로필 이미지 등록 요청 API 구현 (#105) * feat: 사용자 프로필 이미지 등록 요청 dto 정의 * feat: 사용자 프로필 이미지 등록 요청 api 설계 * feat: user entity에 profile-image-url 수정 로직 추가 * feat: 사용자 프로필 이미지 등록을 위한 usecase 정의 * feat: s3 파일 존재 여부 반환 로직 구현 * feat: storage 저장 실패 시 에러코드 정의 * feat: s3 파일 복사 로직 구현 * feat: 사용자 프로필 이미지 저장 api 구현 * feat: 사용자 프로필 이미지 원본 저장 시 storage-class 적용 * fix: 이미지 리사이징을 위한 storage-class 수정 * docs: 프로필 이미지 등록 swagger 응답 케이스 추가 * test: 사용자 프로필 이미지 등록 api 테스트 코드 작성 * test: user-account-usecase에 aws-s3-provider mockbean 적용 * fix: 프로필 이미지 등록 메서드 put으로 변경 * docs: 프로필 이미지 등록 성공 시 예시 응답 제거 * docs: 프로필 이미지 등록 swagger parameter 제거 * fix: request dto validate 어노테이션 추가 및 tab-character 제거 * rename: s3 파일 존재 여부 메서드명 수정 * fix: 프로필 이미지 dto에 regex 패턴 검증 로직 추가 * test: 테스트 케이스 이미지 경로 수정 * fix: 프로필 이미지 등록 요청 dto 정적 팩토리 메서드 삭제 * refactor: 사용자 프로필 이미지 경로 prefix 환경변수 처리 * refactor: s3 object-key regex 상수로 분리 * feat: s3 object-key regex 클래스 분리 및 정적 변수로 선언 * fix: ✏️ 사용자 로그 관리 정책 변경에 따른 Device API 수정 (#104) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: device entity os & model 필드 제거 * fix: device token dto model, os 필드 제거 * fix: device_dto to_entity() 메서드 복구 * fix: device update 메서드 호출 시, activate 상태로 변경 * fix: device 등록 비지니스 로직 수정 * test: device controller unit test에서 model, os 제거 * fix: device_fixture model, os 필드 제거 * rename: fixture token 변경 상수명 수정 * fix: device entity 조회 시 활성화 조건 추가 * fix: device usecase 테스트 시나리오 수정 * fix: device 쿼리 자동 조건문 수정 * test: origin_token에 대한 정보가 없는 경우 new_token 등록 * fix: @sql_restriction 제거 * test: 수정 요청 & 기존 토큰 없을 시, 수정 토큰으로 신규 등록 테스트 * fix: device_register_service 로직 수정 * fix: device_dto new_token 필드 제거 * test: device controller unit test 수정 * fix: device 응답 객체 생성 시 요청 토큰이 아닌 서버 토큰을 응답으로 사용 * fix: device_register_service 제거 && 로직 단순화 * fix: 사용자가 보낸 토큰이 서버에서 비활성화 상태일 때 예외처리 * test: test case 요청 수정 * rename: device -> device_token으로 수정 * rename: device_token entity to_string 수정 * rename: device token put 요청 응답 key 수정 * docs: device put api 스웨거 수정 * fix: device controller 경로 수정 * test: device_token controller unit test 변경된 url 및 응답 포맷 반영 * test: device_fixture -> device_token_fixture * test: 단일 token에 대한 시나리오로 테스트 수정 및 비활성화 토큰 요청 에러 케이스 추가 * fix: register_device_token 파사드 패턴 적용 * fix: device_token_unregister 파사드 패턴 적용 * test: device token 단위 테스트 분리 * test: device_token_unregister_service_test 분리 * fix: user_account_use_case 파사드 패턴 적용 * test: user_account_usecase unit test 분리 * fix: device entity os & model 필드 제거 * fix: device token dto model, os 필드 제거 * fix: device_dto to_entity() 메서드 복구 * fix: device update 메서드 호출 시, activate 상태로 변경 * fix: device 등록 비지니스 로직 수정 * test: device controller unit test에서 model, os 제거 * fix: device_fixture model, os 필드 제거 * rename: fixture token 변경 상수명 수정 * fix: device entity 조회 시 활성화 조건 추가 * fix: device usecase 테스트 시나리오 수정 * fix: device 쿼리 자동 조건문 수정 * test: origin_token에 대한 정보가 없는 경우 new_token 등록 * fix: @sql_restriction 제거 * test: 수정 요청 & 기존 토큰 없을 시, 수정 토큰으로 신규 등록 테스트 * fix: device_register_service 로직 수정 * fix: device_dto new_token 필드 제거 * test: device controller unit test 수정 * fix: device 응답 객체 생성 시 요청 토큰이 아닌 서버 토큰을 응답으로 사용 * fix: device_register_service 제거 && 로직 단순화 * fix: 사용자가 보낸 토큰이 서버에서 비활성화 상태일 때 예외처리 * test: test case 요청 수정 * rename: device -> device_token으로 수정 * rename: device_token entity to_string 수정 * rename: device token put 요청 응답 key 수정 * docs: device put api 스웨거 수정 * fix: device controller 경로 수정 * test: device_token controller unit test 변경된 url 및 응답 포맷 반영 * test: device_fixture -> device_token_fixture * test: 단일 token에 대한 시나리오로 테스트 수정 및 비활성화 토큰 요청 에러 케이스 추가 * fix: register_device_token 파사드 패턴 적용 * fix: device_token_unregister 파사드 패턴 적용 * test: device token 단위 테스트 분리 * test: device_token_unregister_service_test 분리 * fix: user_account_use_case 파사드 패턴 적용 * test: user_account_usecase unit test 분리 * fix: user_profile_update_dto merge conflict * fix: ✏️ 사용자 정의 지출 카테고리 `⋯` 아이콘 반영 (#107) * rename: 커스텀 카테고리 상수 0->custom, 12->other 수정 * style: spending key 상수화 * docs: spending category api 예시 요청 파라미터 other 추가 * fix: spending_controller ...아이콘 validation 체크 * fix: spending_category_dto validation 추가 * fix: spending entity 생성자 validation 추가 * fix: spending_category_dto validation 추가 * test: spending category 등록 시 other -> custom 거부 테스트로 수정 * test: 지출 내역 등록 시 카테고리 validation 체크 수정 * fix: 지출 등록 시 category validation 체크 조건식 수정 * docs: spending api 스웨거 문서 수정 * feat: ✨ 최근 목표 금액 조회 API (#109) * docs: 최근 설정 목표 금액 api 스웨거 문서 작성 * feat: 최근 목표 금액 조회 dto 작성 * feat: 최근 목표 금액 조회 controller 정의 * test: jpa named rule 최근 목표 금액 조회 메서드 단위 테스트 * test: jpa 메서드명 규칙 -> query dsl 메서드로 수정 * feat: user_id 기반 최근 목표 금액 데이터 조회 메서드 추가 * test: jpa query factory 의존성 주입 * test: 최근 목표 금액 없는 경우 테스트 * feat: 최근 목표 금액 조회 서비스 구현 * feat: 최근 목표 금액 조회 dto 변환 mapper 메서드 추가 * feat: 최근 목표 금액 조회 use case 작성 * fix: ✏️ 엔티티 생성자 및 수정 메서드 유효성 검사 추가 (#111) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: device entity 생성자 유효성 검사 * fix: oauth entity 유효성 검사 * fix: oauth entity 생성자 deleted_at 제거 * fix: question entity 유효성 검사 * fix: querstion entity @sql_delete 옵션 추가 * fix: spending entity 유효성 검사 * fix: spending 카테고리 업데이트 메서드 제거 * fix: target_amount update 파라미터 타입 변환 래퍼 -> 원시타입 * fix: user entity 유효성 검사 * fix: user locked 타입 기본타입으로 수정 && get_locked() -> is_locked() 메서드로 수정 * fix: user update 메서드 유효성 검사 추가 * fix: notify_setting 필드 타입 기본타입으로 수정 * test: user_fixture notify_setting 필드 추가 * test: user 유효성 체크 실패로 인한 테스트 수정 * fix: spending 업데이트 시 entity 사용 -> 인자 받아서 처리 * test: spending user validation 체크로 인한 문제로 인한 테스트 수정 * test: 계정 연동 pre-condition 수정 * test: 로그인 비밀번호 불일치 시나리오 any() 로 given 수정 * test: domain 모듈 테스트에서 mock user 생성 시 유효성 만족하도록 수정 * fix: device entity 생성자 유효성 검사 * fix: oauth entity 유효성 검사 * fix: oauth entity 생성자 deleted_at 제거 * fix: question entity 유효성 검사 * fix: querstion entity @sql_delete 옵션 추가 * fix: spending entity 유효성 검사 * fix: spending 카테고리 업데이트 메서드 제거 * fix: target_amount update 파라미터 타입 변환 래퍼 -> 원시타입 * fix: user entity 유효성 검사 * fix: user locked 타입 기본타입으로 수정 && get_locked() -> is_locked() 메서드로 수정 * fix: user update 메서드 유효성 검사 추가 * fix: notify_setting 필드 타입 기본타입으로 수정 * test: user_fixture notify_setting 필드 추가 * test: user 유효성 체크 실패로 인한 테스트 수정 * fix: spending 업데이트 시 entity 사용 -> 인자 받아서 처리 * test: spending user validation 체크로 인한 문제로 인한 테스트 수정 * test: 계정 연동 pre-condition 수정 * test: 로그인 비밀번호 불일치 시나리오 any() 로 given 수정 * test: domain 모듈 테스트에서 mock user 생성 시 유효성 만족하도록 수정 * test: 최근 목표 금액 테스트 user 유효성 검사 반영 * Api: ✏️ 월별 지출내역 조회시 발생하는 N+1 문제 개선 (#110) * test: 커스텀 카테고리를 가지는 spendingfixture를 만들기위한 customcategoryfixture 작성 * test: 커스텀 카테고리를 가지는 spending 조회시 lazy loading 확인용 테스트 작성 * test: fetch 테스트를 위한 spendingfixture 추가작성 * test: 테스트용 customcategory 벌크 삽입연산 메소드 작성 및 customcategory spending 생성 메소드 변경 * test: searchspendings customcategory fetch 테스트 작성 * fix: 월별 지출내역 조회시 N+1 문제 개선을 위한 fetchjoin 사용 * test: 테스트 assertion 수정 * test: 랜덤한 커스텀 카테고리가 아닌 동일한 커스텀 카테고리 사용 * refactor: searchspendingservice 제거를 위한 spendingcustomrepository 연월별 조회 메서드 작성 * test: desc sorted 검증 추가 * 🔧 목표 금액 유즈 케이스 리팩토링 (#112) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * refactor: create_target_amount() 리팩토링 * rename: recent_target_amount_search_service -> target_amount_search_service * refactor: get_target_amount_and_total_spending() * refactor: get_target_amounts_and_total_spendings() * refactor: update_target_amount 리팩토링 * refactor: target_amount_delete_service() 리팩토링 * style: 불필요한 transactional 어노테이션 제거 * refactor: 월별 총 지출 내역 조회 메서드 분리 * refactor: 지출 조회, 지출 목표 금액 조회 서비스 로직 분리 * refactor: 목표금액&월별 지출 내역 리스트 조회 메서드 분리 * fix: target_amount_mapper start_at 사용자 회원가입 일자 -> 가장 오래된 목표 금액 데이터 기반으로 수정 * docs: 목표금액 리스트 조회 스웨거 문서 요약 수정 * refactor: 🔧 지출 내역 유즈케이스 리팩토링 (#113) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * refactor: create_target_amount() 리팩토링 * rename: recent_target_amount_search_service -> target_amount_search_service * refactor: get_target_amount_and_total_spending() * refactor: get_target_amounts_and_total_spendings() * refactor: update_target_amount 리팩토링 * refactor: target_amount_delete_service() 리팩토링 * style: 불필요한 transactional 어노테이션 제거 * refactor: 월별 총 지출 내역 조회 메서드 분리 * refactor: 지출 조회, 지출 목표 금액 조회 서비스 로직 분리 * refactor: 목표금액&월별 지출 내역 리스트 조회 메서드 분리 * fix: target_amount_mapper start_at 사용자 회원가입 일자 -> 가장 오래된 목표 금액 데이터 기반으로 수정 * docs: 목표금액 리스트 조회 스웨거 문서 요약 수정 * refactor: create_spending 리팩토링 * fix: spending_service read_spendings 반환 타입 list에서 optional 제거 * refactor: spending_search_service 관련 메서드 분리 * fix: spending_repository find_by_year_and_month() 반환값 optional 제거 * refactor: spending_update_service 리팩토링 * refactor: spending update & delete 분리 * test: spending_update_service_test 수정 * refactor: 🔧 지출 카테고리 유즈케이스 리팩토링 (#114) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * refactor: spending_category create 유즈케이스 리팩토링 * refactor: get_spending_categories usecase 리팩토링 * fix: ✏️ 가장 최근에 설정한 목표 금액 조회 API 응답 시 날짜 데이터 추가 (#115) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: 최근 목표 금액 조회 서비스 optinal 객체 반환하도록 수정 * fix: 최근 목표 금액 응답 dto에 year, month 정보 추가 * fix: 최근 목표 금액 없을 때 정적 팩토리 메서드 추가 * fix: mapper to_recent_target_amount_response 매핑 정보 수정 * fix: use_case 영속화 상태 유지를 위해 transactional 선언 * fix: ✏️ 문의하기 API content 글자 수 제한 및 사용자 이름 정규식 수정 (#116) * fix: 문의하기 api content dto 글자수 제한 * test: content 글자수 제한 테스트 작성 * fix: username 정규표현식 수정 * test: 수정된 username 조건에 맞추어 test 수정 * fix: 🐛 월별 지출내역 조회 쿼리 버그 수정 (#117) * Api: ✨ 사용자 프로필 수정 API (#118) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * feat: 사용자 인증 코드 발급 & 전화번호 변경 dto 정의 * fix: 사용자 계정 수정 dto 변경 * rename: username-and-phone -> profile 수정 * rename: username-and-profile 수정 * docs: 아이디 & 전화번호 수정 스웨거 작성 * feat: 아이디 & 전화번호 수정 컨트롤러 추가 * feat: 아이디 & 전화번호 수정 usecase 추가 * fix: 인증코드 cache key타입 추가 * fix: 인증코드 요청 유형 phone 타입 추가 * feat: 인증 코드 요청 dto 정적 팩토리 메서드 of 추가 * feat: user entity phone 수정 메서드 추가 * feat: 인증 코드 검증 dto 정적 팩토리 메서드 of 추가 * fix: 사용자 아이디 & 전화번호 변경 dto 불필요한 메서드 제거 (인증코드 검증 dto 치환 메서드) * feat: 사용자 아이디 & 전화번호 수정 서비스 구현 * docs: 사용자 프로필 수정 dto의 code 필드 문서 수정 * docs: 사용자 프로필 수정 api 스웨거 인증번호 에러 응답 추가 * test: user profile update service test mock bean add * feat: 전화번호, 아이디 중복 검사 메서드 추가 (본인 제외) * feat: 사용자 전화번호, 아이디 중복 409 에러코드 추가 * fix: 아이디 & 전화번호 수정 서비스 유효성 검사 추가 * rename: 사용자 수정 서비스 테스트 -> 이름 수정 서비스 테스트 * fix: 나를 제외한 아이디, 전화번호 중복 검사 제거 -> 전체 중복 검사 확인 메서드 추가 * fix: 유효성 검사 시 호출 메서드 수정 * test: 사용자 아이디 & 전화번호 같은 경우 테스트 * test: 아이디 수정 요청 테스트 * test: 전화번호 변경 요청 테스트 * test: 아이디 혹은 전화번호 수정 실패 시 예외 테스트 * rename: 전화번호 예외 검사 불가 항목 주석 추가 * docs: 409 에러 스웨거 문서 추가 * feat: ✨ 카테고리에 등록된 소비 리스트 무한 스크롤 조회 API (#120) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * test: controller unit test 작성 * test: 카테고리별 지출 리스트 조회 api 경로 수정 * feat: 조회하려는 카테고리 타입 상수 정의 * feat: 지출 카테고리 타입 400 에러 추가 * feat: 지출 카테고리 타입 상수 web-config conveter 정의 후 등록 * test: spending-category-type 쿼리 파라미터 테스트 추가 * rename: 에러 상수 오타 수정 * test: 400 error -> 422 에러 수정 * feat: get-spendings-by-category controller 구현 * feat: 카테고리 별 지출 내역 조회 usecase 작성 * feat: 카테고리 타입에 따른 권한 검사 메서드 추가 * test: controller type, category_id 조합 검사 테스트 * fix: default type 카테고리에 대해 category-id 조건 검사 추가 * feat: 카테고리에 등록된 지출 내역 리스트 조회 메서드 추가 * feat: 사용자 정의 카테고리 아이디 & 시스템 제공 카테고리 code 기반 지출 내역 슬라이스 조회 메서드 추가 * feat: 타입, 카테고리 아이디 불일치 에러코드 추가 * fix: spending-search-service 내부에서 시스템 정의 카테고리 지출 내역 조회 시, 상수 타입 변환 * feat: 지출 카테고리 code 기반 상수 탐색 정적 팩토리 메서드 추가 * fix: 시스템 제공 카테고리의 지출 내역 조회 시, 도메인 서비스에서 타입 검사 조건문 추가 * feat: 커스텀 카테고리 아이디 & 카테고리 코드 별 지출 리스트 조회 repository 메서드 추가 * feat: 지출 entity to_string 재정의 * fix: 지출 기본 정렬 필드 created_at -> spend_at * rename: 디버깅용 로그 제거 * feat: mapper 내 카테고리별 지출 리스트 응답 dto 메서드 추가 && daily-list 정렬 메서드 추가 * feat: usecase 응답 시 mapper 호출 * style: usecase 내 주석 제거 * feat: 지출 월별 데이터 슬라이싱 응답 dto 정의 * fix: usecase & mapper 타입 수정 * rename: month-slice dto 필드명 months -> content * refactor: 도메인 서비스 내 custom-repository -> interface로 로직 수행 * test: usecase mock given절 처리 * refactor: spending-mapper map key 연산 시, year-month 객체를 사용하여 수정 * docs: 카테고리의 지출 리스트 조회 api 스웨거 문서 작성 * feat: 카테고리에 등록된 소비 내역 총 개수 조회 controller 추가 * docs: 카테고리 내 지출 총 개수 조회 api 스웨거 문서 작성 * feat: 카테고리 내 지출 총 개수 조회 usecase 추가 * feat: 카테고리 내 지출 리스트 총 개수 조회 도메인 서비스 & repository 메서드 추가 * feat: spending-search-service total count 확인 메서드 추가 * feat: 자원 인가 검사 추가 * feat: ✨ 사용자 정의 카테고리 수정 API (#121) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * feat: 조회하려는 카테고리 타입 상수 정의 * feat: 지출 카테고리 타입 400 에러 추가 * feat: 지출 카테고리 타입 상수 web-config conveter 정의 후 등록 * rename: 에러 상수 오타 수정 * test: 지출 카테고리 업데이트 컨트롤러 테스트 클래스 작성 * test: 카테고리 업데이트 콘트롤러 3가지 테스트 작성 * feat: 카테고리 타입에 따른 권한 검사 메서드 추가 * feat: 타입, 카테고리 아이디 불일치 에러코드 추가 * feat: 지출 카테고리 code 기반 상수 탐색 정적 팩토리 메서드 추가 * feat: 카테고리 수정 컨트롤러 정의 * test: 컨트롤러 테스트 성공 케이스에서 usecase given절 처리 * test: given 처리 시, default, custom 요청 각각에 대해 정의 * feat: 카테고리 update 유즈 케이스 정의 * rename: 지출 카테고리 save service의 execute() -> create() 메서드명 수정 * feat: save-service 클래스 update 메서드 임시 작성 * fix: category-create-param name size 조건 15자 -> 8자 수정 * test: 카테고리 수정 컨트롤러 테스트 수정 (카테고리 수정은 언제나 사용자 정의 카테고리이다) * feat: spenindg-category-manager 자원 접근 검사 시, -1 허용/비허용 메서드 분리 * fix: invalid_icon 에러 메시지 수정 other -> custom * fix: 수정된 요구 사항에 맞게 컨트롤러 로직 수정 * test: 아이콘 문자열로 인자 전달하도록 수정 * feat: 지출 카테고리 수정 서비스 구현 * fix: user_id 인자 제거 (이미 자원 접근 검증을 했으므로 필요없음) * feat: 지출 카테고리 entity to_string 재정의 * feat: 지출 카테고리 entity 정보 수정 메서드 추가 * test: @spring-boot-test random port 옵션 설정 * test: controller unit test user_id 값 삭제 * test: 자원 검증 실패 통합 테스트 작성 * fix: has_permission spel 문법에 맞게 principal.user_id 속성 전달 * test: 사용자 정의 지출 카테고리 fixture name 값 변경(8글자 이하) * test: 쿼리 파라미터 한글 깨짐 수정 * test: 반환 타입 수정 * docs: 지출 카테고리 수정 api 스웨거 작성 * fix: 🐛 n+1 문제 개선 테스트의 간헐적 실패문제 수정 (#122) * feat: ✨ 소비내역 복수 삭제 API (#119) * feat: controller 및 인가 처리 메서드 작성 * feat: usecase 작성 * feat: query dsl을 활용한 비즈니스 로직 작성 * feat: dto 작성 * fix: deleteallbyid 사용하게 변경 * fix: 권한 검사 구문 수정 * test: 소비내역 복수 삭제 통합 테스트 작성 * fix: dto 빈값 검증 수정 * docs: swagger 문서 작성 * fix: 권한 검사 로직 성능 개선을 위한 쿼리 수정 * fix: 삭제 연산 쿼리 횟수 개선을 위한 jpql 사용 * fix: 쿼리 연산을 구분하기 위한 메서드명 변경 * fix: 쿼리 연산을 구분하기 위한 domain 레벨 메서드 명 변경 * fix: repository 반환타입 long으로 통일 * fix; 암묵적 형변환을 지양하기 위한 명시적 형변환 추가 * feat: ✨ 사용자 정의 카테고리 삭제 API (#123) * feat: 커스텀 카테고리 삭제 api 구현 * test: 통합 테스트 작성 * docs: swagger 작성 * fix: 사용하지않는 securityuserdetails 제거 * fix: conflict 해결시 발생한 syntax error fix * feat: ✨ 정기 지출 등록 알림 배치 & 스케줄러 (#124) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * feat: domain 모듈 내 notify_type 정의 * fix: fcm_config @enable_async 추가 * feat: notification_event 객체 생성 * fix: 알림 이벤트 객체 image_url 필드 추가 * fix: 푸시 알림 이벤트 class -> record 타입 변경 및 편의성 메서드 추가 * feat: fcm 푸시 알림 전송을 위한 fcm_manager 작성 * fix: fcm_manager send_message() 반환 값 api_future로 수정 * feat: 푸시 알림 이벤트 핸들러 작성 * rename: 푸시 알림 핸들러 주석 수정 * chore: batch config 작성 * feat: 정치 지출 관리 알림 job 설정 * feat: 정기 지출 관리 알림 step 설정 * feat: 활성화된 디바이스 토큰 탐색 reader 정의 * feat: 푸시 알림 프로세서 정의 * feat: 푸시 알림 writer 임시 정의 * feat: job & step 빈 주입 * chore: @enable_scheduling 어노테이션 선언 * chore: yml 파일에 schedule 설정 추가 * chore: 정기 알람 cron 정보 .yml 등록 * fix: 푸시 알림 이벤트 @getter 제거 * feat: 정리 알림 batch scheduler 작성 * fix: device_token_repository list_crud_repository -> jpa_repository * fix: token 조회 시, 활성화된 토큰만 조회하도록 메서드 추가 * fix: fcm_manager getter 메서드 수정 * fix: active_device_token_reader generic 제거 * feat: device_info dto 정의 * rename: device_token dto 이름 수정 * fix: device entity 제네릭 -> device_token_owner dto로 변경 * feat: device_token_owner 활성화 토큰 페이지 조회 쿼리 추가 * feat: 공지사항 enum 타입 생성 * rename: 공지 사항 열거 타입 이름 수정 announce -> announcement * fix: notice_type legacy_common_type 구현 * feat: 알림 타입 컨버터 작성 * feat: 공지 알림 타입 컨버터 생성 * fix: job_config 실패 케이스 재실행 or 중지 플로우 추가 * feat: notification_repository 정의 * feat: device_token_owner dto 사용자 이름 필드 추가 * fix: device_token_owner dto 조회 query 수정 (user entity left join 추가) * style: 인자 순서 변경 * fix: group by u.id 조건 추가 * rename: find_activated_device_token_owners 메서드 인터페이스에 주석 추가 * feat: 알림 벌크 insert 메서드 추가 * feat: notification entity builder 클래스 생성 * feat: notification repository query dsl interface 작성 * feat: 정기 지출 등록 알림 벌크 삽입 연산 메서드 추가 * fix: 기존 푸시 알림 저장 메서드 제거" * feat: 정기 지출 푸시 알림 writer batch 구현 * fix: notification entity announcement 필드 추가 * fix: notification read batch 호출 메서드 수정 * rename: 정기 지줄 등록 알림 벌크 삽입 메서드 주석 추가 * fix: 푸시 알림 벌크 삽입 연산 조건절 수정 * rename: 푸시 알림 벌크 삽입 연산 메서드 쿼리 주석 추가 * feat: 공지 알림 타입 포매팅 메서드 추가 * fix: batch writer에서 publisher 등록 시 반복문 호출하도록 수정 * feat: 정기 지출 등록 푸시 알림 dto 정의 * fix: process 배치 반환 타입 수정 * fix: step chunk generic 값 수정 * fix: writer batch 매개변수 타입 수정 & 로직 수정 * fix: 정기 지출 등록 알림 dto 필드에서 published_at 제거 * fix: device_token_repository read_device_tokens_activate_is_true 메서드 제거 * chore: @enable_async config 파일 분리 * chore: batch_application 경로 수정 * fix: notification_event_handler @async 어노테이션 추가 * fix: fcm_manager 빈 주입 방식 수정 * feat: notification_handler interface 정의 * fix: notification event handler impl 클래스명 수정 * test: 알림 설정 허용 사용자의 활성화된 디바이스 토큰 조회 테스트 케이스 작성 * test: 사용자별 device_token 리스트 담기는 것을 테스트 * chore: mysql_container only_full_group_by 옵션 제거 * test: repository 메서드 호출 결과 map 컬렉션으로 매핑하여 결과 비교 * test: 디버깅용 log 제거 * fix: device_token_owner device_tokens -> device_token * fix: 푸시 알림 허용 사용자의 활성화된 디바이스 토큰 탐색 쿼리에서 group_by절 제거 * fix: daily_spending_notification dto device_tokens -> device_token 필드 수정 * fix: daily_spending_notification dto 정적 팩토리 메서드 수정 & 디바이스 토큰 추가 메서드 * fix: batch output 제네릭 타입 수정 * fix: 배치 writer 내부 로직 수정 -> daily_spending_notification 매핑 로직 추가 * test: notification bulk insert method 테스트 * fix: bulk insert 시, query_dsl에서 jdbc_template를 사용하는 것으로 변경 * fix: notification entity read_at nullable 허용 * test: notification 중복 저장 안 되는 것을 확인 * fix: reader batch bean 이름 수정 * chore: batch 모듈 fcm config 활성화 * chore: batch application 상위 경로로 이동 * fix: cron value application.yml에서 제거하고 직접 입력 * fix: count 쿼리에서 select 누락 수정 * rename: reader-processor-writer 주석 추가 * rename: fcm 메시지 전송 시 단일/다중 로그 추가 * fix: 푸시 알림 dto device_token 중복 제거를 위해 list -> set 타입으로 수정 * fix: 사용자 아이디 리스트 얻기 위한 로직 수정 (key_set으로 획득) * refactor: notification_event_handler interface 메서드 추가 * rename: notification_event_handler 주석에서 타입 인터페이스 -> 인터페이스로 수정 * rename: notification_event_handler 명세 수정 * rename: notification_event_handler_impl -> fcm_notification_event_handler * rename: find_activated_device_token_owners 주석에서 group by 절 제거 * rename: save_daily_spending_announce_in_bulk sql 예시 수정 * rename: reader 로그 제거 * fix: device_token 조회 시, count 쿼리를 위한 조건 추가 * 🐛 OIDC key caching ttl fix (#126) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: oidc secret key 캐싱 기간 3일로 수정 * Api: ✏️ 사용자 아이디, 전화번호 수정 API 분리 (#127) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: update_username_and_phone -> update_phone * fix: 회원가입 dto name, username 정규 표현식 수정 * fix: 사용자 프로필 수정 dto name, username 정규식 수정 및 username_phone_req -> phone_req * fix: usecase update_username_and_phone -> update_phone * fix: patch_profile -> patch_phone && 요청 메서드와 함수 이름 불일치하는 메서드 수정 * docs: swagger 문서 수정 * test: 기존 프로필 수정 테스트 변경 * fix: 사용자 아이디 변경 시, 중복 검사 추가 * docs: swagger에 사용자 아이디 중복 검사 예외 응답 추가 * test: 사용자 이름, 아이디 유효성 검사 테스트 예외 메시지 기대값 수정 * cd: ✏️ 릴리즈 CD 파이프라인 실행 스킵 키워드 추가 (#129) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * chore: tagging and release cd pipeline add ignore keyword * chore: tag_prefix 인자 수정 * chore: custome_release_rules에 cd 키워드 추가 * ci: ✨ Open API 코드 리뷰 CI 파이프라인 추가 (#130) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * chore: chat gpt code review ci pipeline 추가 * chore: chat gpt code review ci pipeline 추가 * rename: create-tag-and-release 파이프라인 주석 주가 * chore: review ci model 수정 * fix: model 옵션 제거 * cd: ✏️ 배치 CD 파이프라인 FCM Admin SDK 생성 step 추가 (#131) * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: batch cd json 생성 step 추가 * fix: 🐛 목표 금액 DELETE 유즈 케이스 잘못된 필터링 제거 (#132) * fix: target-amount 삭제 요청 시, amount == -1인 경우 필터링 로직 제거 * test: target amount의 값이 -1이어도 is_read가 성공적으로 true로 바뀌어야 한다 * fix: ✏️ 총 지출 총합 값의 정수 범위 초과 케이스를 고려하여 타입 수정 (#133) * fix: target_amount_dto 원시 타입으로 수정 * fix: spending_search_res daily_total_amount int -> long * fix: daily_total_amount total_spending 계산 시, 반환 타입 long으로 수정 * fix: recent_target_amount_res 필드 원시 타입으로 수정 * style: spending_service 총 지출 금액 조회 메서드 인접하게 위치 수정 * fix: 총 지출 금액 dto amount 필드 int -> long * style: target_amount_usecase 함수 호출 스니펫과 return 문 사이 공백 추가 * fix: 가장 최근 목표 금액의 amount가 0인 경우가 존재할 수 있으므로, 타입을 래퍼 타입으로 수정 * feat: total_spending_amount year_month 반환 메서드 추가 * fix: int 타입 long으로 변환 및 코드 정리 * fix: target amount update int -> long 타입 수정 * fix: querydsl total_spending_amount 기본 타입 expression 정의 * test: target amount controller unit test get_id() null 에러 핸들링 * test: max integer 범위 초과한 경우 diff_amount, total_spending 출력 확인 * fix: to_year_month_map 동시성 환경을 고려하여, concurrent_map 타입으로 반환 * feat: ✨ 사용자가 수신한 푸시 알림 리스트 최신순 조회 (#134) * test: notifications controller unit test 작성 * feat: notification controller 클래스 생성 * feat: notification use case 클래스 작성 * test: pageable param 전달 테스트 케이스 * test: given 절 반환 dto 수정 * feat: notification info & slice dto 정의 * fix: notification use case 껍데기 구현 * fix: controller use case 호출 * test: notification fixture 상수 및 dto 생성 메서드 추가 * test: 응답 json 경로 수정 * fix: get notifications controller success response로 응답 포맷 수정 * test: controller 응답 포맷 테스트 * fix: get notifications pageable size default 20 -> 30 * feat: notification slide select 메서드 추가 * fix: notification repository jpa_repository -> extended_repository 인터페이스 변경 * fix: controller pagable sort dirction 내림차순 옵션 추가 * feat: notificaiton search service impl * feat: notification use case 내, service 및 mapper 호출 로직 처리 * feat: notification dto info builder 추가 * fix: notification use case import notification mapper * feat: notification mapper 메서드 정의 * feat: notification table 수정 squash merge * fix: formatting 메서드 수정 * refactor: 포매팅된 title, content 로직을 notification 엔티티 메서드로 제공 * test: notification fixture to_entity 주입 방식 수정 및 dummy dto 생성 로직 제거 * refactor: notification info dto 생성 로직 mapper -> dto로 이전 * docs: swagger config에 notification와 storage 태깅 추가 * docs: swagger 문서 추가 * test: domain 모듈 notification service unit test * fix: notification 정기 지출 알림 쿼리 조건문 수정 * rename: 공지 타입 포맷팅 메서드 주석 추가 * rename: notification entity 내 포맷팅 메서드 주석 추가 * feat: ✨ 미확인 푸시 알림 읽음 처리 API (#136) * test: notification update read_at 메서드 unit 테스트 * test: save 3번 -> save_all 수정 * test: notification repository unit test 파일 통합 * test: 미확인 알림 조회 메서드 테스트 * feat: 미확인 알림 리스트 조회 메서드 쿼리 추가 * style: count 파라미터 순서 변경 * feat: notification service 메서드 count, bulk update 메서드 추가 * feat: notification manager 구현 * feat: 읽음 요청 dto 정의 * feat: notification 읽음 처리 controller 정의 * feat: notification update usecase & service 작성 * feat: 불필요한 user_id 파라미터 제거 * docs: swagger 문서 작성 * fix: notification service read_only 옵션 제거 * refactor: ✏️ 매일 정기 푸시 알림 배치 성능 개선 (#137) * chore: application-domain.yml jdbc url query parameter 수정 * style: step config 파일 삭제 -> job config에 통합 * style: dto 패키지 경로 common 하위로 수정 * fix: notificaion batch insert ; 제거 && batch size 1000으로 수정 * feat: where 함수형 인터페이스 정의 * feat: where expression 정의 * feat: order expression 상수 정의 * feat: expression 상수 정의 * feat: querydsl_no_offset_options 추상 클래스 정의 * feat: no offset의 타입이 number인 경우를 위한 구현체 정의 * rename: 정적 팩토리 메서드 주석에 주의 사항 추가 * feat: no offset의 타입이 string인 경우를 위한 구현체 정의 * feat: querydsl_paging_item_reader 추가 * feat: querydsl_no_offset_paging_item_reader 정의 * fix: repository_item_reader -> querydsl_no_offset_paging_item_reader 변경 * fix: @job_scope 및 @step_scope 추가 && step reader 수정 * fix: device_token_custom_repository 제거 * test: device_token_cutome_repository 테스트 제거 * style: device_token_owner 경로 domain -> batch로 수정 * test: redisson 테스트 ignore 처리 * chore: batch application db connection pool 2개로 수정 * fix: 🐛 Sms type 변환 시, 휴먼 에러 제거 및 예방 (#138) * fix: verification type 휴먼 에러 발생 위험 제거 * docs: sms api 쿼리 파라미터 type에 phone 추가 * feat: ✨ 미확인 푸시 알림 존재 여부 확인 API (#139) * test: repository unit test * feat: 사용자가 읽지 않은 알림 존재 확인을 위한 repository 메서드 추가 * feat: notification service has_unread_notification 메서드 추가 * feat: usecase 미확인 알림 존재 여부 체크 분기 메서드 추가 * feat: controller api 추가 * test: query join 제거가 가능하도록 test 수정 * fix: jpa method 제거 후 query dsl로 수정 * docs: swagger 문서 작성 * rename: read unread notification() -> is_exists_unread_notification() * fix: 🐛 정기 푸시 알림 배치 쿼리 픽스 & ItemReader 기능 수정 (#140) * fix: device_token_owner dto query 최적화를 위한 device_token pk 필드 추가 * fix: querydsl_no_offset_string_options id_name 받는 정적 팩토리 메서드에서 field=null 수정 * fix: query dsl no offset paging item reader id_select_query 추가 * feat: step builder 패턴을 적용한 querydsl_no_offset_paging_item_reader_builder 클래스 정의 * fix: active_device_token_reader query 수정 * feat: ✨ 매월 목표 금액 설정 공지 푸시 알림 배치 (#141) * rename: notification writer -> daily_spending_notify_writer * fix: announcement enum class not_announce 타입 필터링 * fix: daily_notication dto 범용성 확장하여 announce_notification_dto로 수정 * feat: 매월 목표 금액 설정 공지 writer 작성 * feat: monthly_target_amount_notify_config job $ step impl * feat: 매월 정기 목표 금액 설정 알림 스케줄 설정 * fix: 목표 금액 알림 메시지 조회 시, title 해당 월 삽입되도록 수정 * fix: monthly_target_amount title %s누락 수정 * fix: announce notification dto 내부에서 월별 목표 금액 title 생성 시, 분기 처리 * Api: 🐛 Spending Custom Category Delete Query Fix (#142) * fix: spending_custom_category sql_delete 쿼리 수정 * test: 테스트 db jdbc url 수정 * test: 도메인 모듈 db 컨테이너는 utc로 맞춤 * feat: ✨ 사용자 정의 카테고리 이동 API (#125) * feat: controller, usecase 작성 * feat: update service, domain service 작성 * docs: swagger 작성 * test: 지출 카테고리 수정 테스트 작성 * fix: controller 파라미터 수정 * fix: spel 표현식 제거 * test: 기본 카테고리 이동 테스트 작성 * docs: api 문서 이동 * refactor: controller 및 usecase를 spending 에서 spendingcategory로 이전 * feat: 기본 카테고리로부터의 이전 기능 추가 * feat: 권한검사 추가 * test: 각케이스별 테스트케이스 작성 * fix: 권한검사 로직 이동 * fix: spendingerrorcode 상수 제거 * fix: service 메서드 접두사 udpate로 수정 * fix: 접두사 udpate로 추가 수정 * fix: 불필요한 schema 제거 * fix: service 및 repository 메서드명 prefix customcategory로변경 * fix: ✏️ 사용자 삭제 로직 추가 (#143) * rename: oauth bulk delete 메서드 컨벤션에 맞게 in_query 접미사 추가 * feat: 사용자 아이디 기반 spending 삭제 bulk 메서드 추가 * feat: 사용자 아이디 기반 spending_custom_category 삭제 bulk 메서드 추가 * fix: 사용자 삭제 서비스 내에서 지출 데이터 삭제 로직 추가 * feat: device_token delete bulk 메서드 추가 * fix: device_token_service 사용하지 않는 메서드 제거 * fix: 사용자 삭제 시, device token 비활성화 추가 * test: 사용자 삭제 테스트 시 디바이스 삭제 -> 비활성화 테스트로 수정 * fix: user_id로 device_token 조회 시, join되는 문제 제거 * test: spending 삭제 테스트 * fix: 불필요한 delete_at is null 옵션 제거 (이미 sql_restriction으로 쿼리에 반영됨) * fix: batch application timezone 설정 (#144) * feat: ✨ 프로필 사진 삭제 API 및 수정 API 픽스, 리팩토링 (#145) * feat: aws_s3_provider delete object 메서드 추가 * fix: copy_object return 값 추가 * refactor: aws s3 provider 호출 로직 tx 외부로 분리 * fix: presigned_url 발급 api 사용자 아이디 쿼리 파라미터 제거 * test: storage controller test 수정 * feat: s3 adapter get prefix 메서드 추가 * fix: 사용자 프로필 주소 dto 삽입 전 prefix 추가 * feat: 프로필 이미지 삭제 api * fix: 사용자 프로필 업데이트 시, 기존 이미지가 있다면 삭제 로직 추가 * feat: 사용자 프로필 not found 예외 추가 * fix: 사용자 프로필이 없는 경우 404 예외 처리 * docs: 사용자 프로필 삭제 api 스웨거 작성 * fix: put_profile_image 응답으로 저장된 경로 반환 * docs: 이미지 수정 스웨거 문서 수정 * test: 프로필 정상 요청 테스트 케이스 수정 * fix: 안정성을 위해 user_dto에서 profile_image_url null 방지 스니펫 유지 * feat: ✨ 애플리케이션 헬스 체크를 위한 Actuator 의존성 추가 (#146) * chore: api 모듈 actuator 의존성 추가 * fix: security filter actuator public endpoint 옵션 설정 * fix: http not support method exception 핸들러에 반영 (#147) * Ignore: 🐛 Devcie Token 삭제 시, 사용자가 정보 삭제되는 에러 핸들링 (#148) * fix: user entity의 device token 역참조 제거 * fix: device token unregister service에서 user 영속화 제거 * fix: device_entity의 자식의 cascade 옵션 제거 * fix: missing request cookie exception 핸들러 추가 (#149) * refactor: 🔧 Swagger 에러 정의 어노테이션 (#150) * feat: api_exception_explain 어노테이션 정의 * fix: 에러 설명을 위한 필드 추가 * feat: 복수개의 error response를 담기 위한 중간 어노테이션 * feat: 에러 응답 파서 구현 * chore: swagger config에 파서 빈 등록 * rename: explanation 오타 수정 * fix: enum 상수 추론을 위한 field 추가 및 파서 로직 수정 * docs: 지출 내역 상세 조회 예외에 api_response_explanations 적용 * chore: swagger config grouped_open_api add_operation_customizer 적용 * refactor: 🔧 Redisson Auto Configuration 제거 및 도메인 모듈 의존 제어 (#151) * feat: pennyway domain config group 열거 타입 정의 * feat: domain config 인터페이스 및 어노테이션 정의 * feat: domain config import select 구현 * chore: redisson @configuration 제거, config group 상수 추가 * rename: distributed lock aop -> aspect * chore: batch 모듈 redisson auth configure exclude * chore: auto configurate 옵션 domain yml로 이전 * chore: redisson 관련 클래스 모두 @component 제거 -> 수동 bean 등록 * chore: api 모듈 domain config에 redisson 설정 추가 * test: redisson 테스트 disabled * fix: ✏️ Device Token Session 관리를 위한 로직 수정 (#152) * fix: device token entity updated_at auditing 제거 -> 조회할 때마다 last_sigend_in 필드 업데이트 * fix: register service에서 이미 존재하는 토큰 조회 시, signed in at 갱신 * fix: device token 만료 에러 상수 제거 * docs: put_device 활성화되지 않은 토큰 에러 제거 * fix: device_token 삭제 요청시, deactivate 호출하는 로직으로 변경 * style: 사용하지 않는 의존성 제거 * test: 토큰 활성화, 비활성화 테스트 수정 * fix: device token 조회 시, 7일 이내의 데이터만 가져오는 조건 추가 (#153) * fix: 🐛 교통비 -> 교통 (#155) * fix: ✏️ OAuth 계정 연동 누락된 예외처리 수정 (OAuth 연동 정책 수정) (#156) * test: provider, oauth_id candidate test * feat: already_used_oauth 에러 코드 추가 * feat: oauth_id, provider 조건으로 oauth entity 조회 메서드 추가 * fix: oauth 데이터 복구 시, user 파라미터 추가 * rename: test display name을 직관적으로 수정 * fix: 다른 사용자가 이미 사용 중인 경우 예외 처리 * test: oauth user_id 업데이트 테스트 조건 추가 * fix: oauth to_string에서 user 제거 * test: tx 제거 * test: oauth 삭제 이력 복구 로직 제거 테스트 * fix: oauth 복수 개 데이터가 조회될 수 있는 경우의 수 제거 * fix: oauth entity reverse_delete 메서드 제거 * fix: user_sync_dto 내부 sync_oauth dto 제거 * docs: swagger 에러 문서 갱신 * fix: oauth service 불필요 메서드 및 주석 제거 * fix: 🐛 카테고리 별 소비 내역 리스트 조회 정렬 조건 수정 (#157) * fix: 정렬 조건 spend_at 내림차순, id 오름차순으로 수정 * docs: swagger error 커스텀 어노테이션으로 지정 * 🔧 Blue-Green 배포를 위한 deploy script 수정 (#154) * feat: blue-green 배포를 위한 deploy script 수정 * feat: deploy script에 workflow dispatch 추가 * fix: 무중단 배포 파이프라인 수정 및 script 별도 정의 * fix: ✏️ Proxy 서버로 IP Logging 위임을 통한 WAS 애플리케이션 내 인터셉터 제거 (#158) * fix: domain sigin_in table 정보 제거 * fix: sigin in event redis 코드 제거 * fix: sigin event interceptor 제거 * fix: web_config jwt provider 의존성 제거 * fix: ip header converter 제거 * test: 모든 컨트롤러 테스트에서 web config 클래스 exclude 제거 * fix: ✏️ 소켓 서버 생성 전 자잘한 버그 픽스 (#160) * style: api 모듈의 jwt_claims_parser_util을 infra 모듈로 수정 * style: refresh_token_provider 내 access_token_claims 주입 수정 * fix: open ai review ci 제거 * fix: ✏️ 확인 알림과 미확인 알림 조회 API 분리 (#161) * rename: 미확인 알림 조회 api 메서드 명 수정 * rename: 수신한 알림 무한 스크롤 조회 api 메서드명 수정 * feat: 미확인 알림 조회 api 추가 * fix: 미확인 알림 조회 controller http method 및 경로 설정 * feat: 미확인 알림 조회 service 구현 * feat: notification list -> notification dto info list 변환 mapper 구현 * feat: 미확인 알림 조회 usecase 구현 * fix: 확인한 알림 조회 쿼리에서 is_read is null 조건 추가 * fix: 미확인 알림 조회 시, query join 되는 문제 해결 * docs: swagger operation 요약 보다 명시적으로 수정 * test: 읽음 알림 조회 repository 단위 테스트에서 read_at is not null 조건에 추가됨에 따른 테스트 수정 * feat: ✨ 분산 코디네이션 도입을 고려한 서비스 탐색 서비스 API (#162) * chore: 분산 코디네이션 설정 - 채팅 서버 url 고정값 설정 * chore: 분산 코디네이터 의존성 api 모듈 제어권 설정 * feat: 유효한 채팅 서버 url을 반환하기 위한 dto 정의 * feat: infra 모듈 내, 분산 코디네이션 인터페이스 및 기본 구현체 정의 * docs: 서비스 탐색 api 문서 정의 * feat: 채팅 서버 url 반환 usecase 구현 * feat: 채팅 서버 탐색 컨트롤러 구현 * fix: swagger config group에 서비스 탐색 서비스 항목 추가 * fix: 소켓 usecase와 채팅 서버 탐색 서비스 분리 * chore: ✨ Socket 모듈 및 Socket Relay 모듈 추가 및 CD 파이프라인 수정 (#163) * chore: socket 모듈 생성 * chore: socket-relay 모듈 생성 * chore: socket 모듈 dockerfile * chore: socket-relay 모듈 dockerfile * feat: socket 모듈 cd 파이프라인 * feat: socket-relay 모듈 cd 파이프라인 (socket cd 파이프라인 오타 수정) * fix: 태깅 및 릴리즈 파이프라인 수정 * docs: external-api readme 모듈명 오타 수정 * docs: socket 모듈 readme 작성 * docs: socket-relay readme * fix: 태깅 릴리즈 파이프라인 sh 스크립트 수정 * feat: ✨ RabbitMQ 및 Socket 서버 연결 설정 (#164) * chore: infra 모듈 websocket, amqp, tsid-creator 의존성 추가 * chore: rabbitmq yml 설정 * fix: message broker config * rename: distributed_coordination_config 파스칼 케이스 -> upper snake case로 수정 * feat: message broker adapter * chore: socket 모듈 web-socket 의존성 추가 * chore: socket 모듈 외부 브로커 및 소켓 서버 설정 application.yml 설정 * feat: application 속성 객체 바인딩 * feat: web_socket_message_brocker_config 설정 * feat: socket 모듈에 infra message broker config 설정 주입 * rename: bean 이름 충돌로 인한 이름 수정 * fix: 채팅 exchange 설정과 rabbitmq 설정 객체 분리 * feat: web_socket_message_broker_config custom connection factory 빈 생성 * feat: reactor netty tcp client 생성 -> rabbitmq 연결 client 지정 * fix: security cors set_origin -> set_origin_patterns * chore: test를 위한 디렉토리 설정 * fix: properties 객체들 to_string 재정의 * fix: stomp client 연결 계정 수정 * chore: ✨ WebSocket Inbound & Exception Interceptor (#165) * feat: interceptor handler marker interface 정의 * feat: stomp_command 별 handler 관리를 위한 factory 정의 * feat: stomp inbound interceptor * feat: server to client 메시지 전달을 위한 dto * feat: server_side_message -> message 변환을 위한 편의용 유틸 정의 * feat: access token 복호화를 위한 설정 * feat: socket 연결된 사용자를 식별하기 위한 principal * feat: 예외 처리를 위한 hander marker interface * feat: interceptor error 객체 정의 * feat: 예외처리 핸들러의 공통점을 활용한 템플릿 메서드 패턴의 추상 팩토리 handler 정의 * feat: interceptor 예외 핸들러 구현 * feat: web_socket_broker interceptor 반영 * chore: socket 모듈 내 access token secret key 환경 변수 등록 * feat: ✨ Authenticate the Inbound and Exception Interceptor when using the Connect Frame (#166) * feat: connect 프레임 인증 핸들러 * feat: jwt_exception 예외 핸들러 * feat: socket 모듈 application 경로 수정 * chore: ✨ Authorize the Inbound and Exception Interceptor when using the Subscribe Frame (#167) * feat: 자원 접근 확인 마커 인터페이스 정의 * feat: chat_room entity 정의 * feat: chat_member entity 정의 * feat: chat_member is_exists 메서드를 위한 쿼리 최적화 서비스 구현 * feat: chat_room 접근 권한 검사기 * feat: 리소스 접근 권한 체커 관리를 위한 registry 정의 * feat: resource_access_registry 설정 파일 * feat: subscribe_event 객체 정의 * feat: subscribe 성공 & 실패 이벤트 핸들러 정의 * feat: 채팅방 구독 검사 interceptor 추가 * feat: 구독 실패 interceptor * fix: receipt 핸들러 비동기 로직 내 로그 제거 * fix: chat_exchange_autorize_handler prefix 오타 수정 * fix: 불필요한 event 및 event_handler 제거 * style: handler 패키지 경로 수정 (인바운드, 아웃바운드, 예외 구분) * fix: 경로 수정에 따른 config 변경 * chore: ✨ Custom Socket PreAuthorize AOP & Global Exception Handler (#168) * feat: 자원 접근 확인 마커 인터페이스 정의 * feat: chat_room entity 정의 * feat: chat_member entity 정의 * feat: chat_member is_exists 메서드를 위한 쿼리 최적화 서비스 구현 * feat: chat_room 접근 권한 검사기 * feat: 리소스 접근 권한 체커 관리를 위한 registry 정의 * feat: resource_access_registry 설정 파일 * feat: subscribe_event 객체 정의 * feat: subscribe 성공 & 실패 이벤트 핸들러 정의 * feat: 채팅방 구독 검사 interceptor 추가 * feat: 구독 실패 interceptor * fix: receipt 핸들러 비동기 로직 내 로그 제거 * fix: chat_exchange_autorize_handler prefix 오타 수정 * fix: 불필요한 event 및 event_handler 제거 * style: handler 패키지 경로 수정 (인바운드, 아웃바운드, 예외 구분) * fix: 경로 수정에 따른 config 변경 * feat: pre_authorize_error code 및 예외 정의 * feat: pre_authorize annotation 정의 * feat: spel 정규식을 파악하기 위한 parser 구현 * feat: pre_authorize 어노테이션을 핸들링할 aspect 구현 * rename: pre_authorize_ascpect handle_unauthorized 주석 수정 * feat: web_socket global exception handler 정의 * fix: 로그 레벨 조정 * feat: ✨ Controller for updating the principal's expiresAt field (#169) * feat: refresh event 객체 정의 * feat: refresh 이벤트 핸들러 * feat: refresh 서비스 * feat: auth_controller * fix: 불필요한 로그 제거 및 로그 레벨 수정 * feat: ✨ GUID Configuration based on a Time-Sorted Identifier (#170) * feat: guid generator interface * feat: tsid_generator * docs: id_generator 개발 관행 주석 추가 * refactor: guid_config를 사용하여, 변경이 용이하도록 수정 * fix: 🐛 Resolving the issue where Spring Boot does not create a RabbitMQ connection factory (#172) * chore: infra & socket gradle version 명시 * fix: chat_exchange_properties 객체 to_string 재정의 * fix: connection factory 빈 강제 생성 옵션 추가 * feat: 각 모듈별 docker파일 분리 * feat: ✨ 채팅방 생성 API (#173) * feat: 각 모듈별 docker파일 분리 * fix: chat_member 엔티티 image_url 제거 * feat: 채팅방 생성 repo & service 정의 * feat: 채팅 멤버 생성 service 정의 * test: 채팅방 생성 컨트롤러 단위 테스트 성공 시나리오 작성 * feat: chat_room_request dto 정의 * feat: chat_room craete controller * feat: chat_room_usecase 및 request & response schema 추가 * fix: controller success response 추가 * test: chat_room_fixture * fix: chat_room_res detail dto to_string 수정 * fix: controller 내 usecase final 키워드 추가 * feat: 채팅방 역할 상수 정의 * fix: chat_memeber role 필드 추가 * fix: chat_room create dto to_entity 메서드 static 제거 * feat: chat_member entity 정적 팩토리 메서드 추가 * feat: 채팅방 생성 서비스 로직 구현 * feat: chat_room_mapper 정의 * test: 채팅방 생성 통합 테스트 구현 * fix: 채팅방 생성 요청 background_image_url pattern 수정 * feat: 채팅방 생성 요청 캐싱 entity * feat: 생성 지연 채팅방 서비스 로직 * fix: pended_chat_room user_id 필드 추가 및 유효성 검사 & to_chat_room 메서드 추가 * fix: chat_room_req pend, create 요청 분리 * fix: 캐싱된 채팅방 정보 예외 정의 * fix: 채팅방 생성 서비스 로직 대기, 생성 메서드 분리 * feat: external-api 모듈 guid config 의존성 추가 * fix: pended_chat_room hash code 수정 * fix: 사용자 아이디로 캐싱된 정보 조회 -> 사용자 권한 예외 제거 가능하므로 삭제 * fix: controller 및 usecase 요청 분리 * test: controller 단위 테스트 pend, create 분리 * feat: 채팅방 entity pk auto_increment 속성 제거 * test: chat_room_fixture url 정보 수정 * fix: 캐싱된 채팅방 정보 로깅 추가 * test: 채팅방 생성 시나리오 테스트 수정 * fix: 채팅방 멤버 생성 시, entity 반환하도록 chat_member_service 로직 수정 * feat: 채팅방 저장 시, image url 변환 작업 수행 로직 추가 * test: aws_adapter mock bean 설정 추가 * feat: 채팅방 swagger group 등록 * docs: 채팅방 생성 대기 및 확정 api 스웨거 문서 작성 * chore: 불필요한 dockerfile 모두 제거 * chore: socket-relay dockerfile 복구 * feat: ✨가입한 채팅방 목록 조회 API (#174) * feat: 각 모듈별 docker파일 분리 * chore: common, infra, domain dockerfile 제거 * feat: db - domain 채팅방 데이터 조회 결과를 담을 dto 생성 * feat: 참여한 채팅방 리스트 조회 domain service 로직 구현 * feat: 내가 가입한 채팅방 조회 usecase, application-service, mapper 구현 * fix: 채팅방 응답 dto 생성 유효성 로직 정적 팩토리 메서드에서 생성자로 이전 * feat: 내가 가입한 채팅방 목록 조회 api 개방 * docs: swagger 문서 작성 * fix: ✏️ modify the TTL of pending chat room (#175) * feat: 각 모듈별 docker파일 분리 * feat: 불필요한 dockerfile 정리 * fix: pended_chat_room ttl 수정 * fix: 불필요한 dockerfile 제거 * feat: ✨ Managing user session status on a socket server (#176) * feat: user_status 상수 정의 * feat: user_status 상수 converter 정의 * test: redis test container 7.4 upgrade * feat: user_session_repository 인터페이스 정의 * test: user_sessiion_repository unit test * chore: domain 모듈 내 jackson-jsr 의존성 추가 * feat: local_date_time 직렬화 적용 * feat: 직렬화/역직렬화 가능한 user_session 엔티티 정의 * feat: user_session_repository 구현 * refactor: user_session_repository 리팩토링 * fix: user_session_repository 로그 추가 * feat: user_session_domain_service * fix: 검사 예외 제거 * feat: user session 존재 여부 확인 메서드 추가 * feat: add invalid header interceptor error code * feat: activate user session when client try to connect on chat server * test: is_exists lua script 추가 * feat: 사용자 상태 변경 도메인 서비스 로직 추가 및, is_exists 쿼리 수정 * test: 테스트 이후 redis 캐시 제거 teardown 정의 * feat: connect 이후 사용자 세션 상태 활성화 로직 추가 * fix: native header 추출 로직 추가 * fix: connect 로직 내 모든 상수 분리 * fix: entity 유효성 검사 로직 service -> entity * fix: 사용자 상태 device_name, device_id 추가 * fix: native header value 조회 전략 수정 및 user_principal 정보 업데이트 * fix: 상태 업데이트 메시지 dto에서 device-id 필드 제거 * fix: principal 변경사항 controller & service 로직 반영 * rename: refresh event -> receipt event * fix: outbound pre_send 매개변수 non_null 어노테이션 추가 * feat: status 업데이트 성공 시, receipt event 처리 * fix: update_statue 메서드 내, receipt 미작동 사실 주석 추가 * feat: message error code 정의 * fix: status_message 유효성 검사 실패 시, 시스템 정의 예외를 던지도록 수정 * feat: disconnect handler annotation 정의 * feat: disconnect 프레임 수신 시, 사용자 상태 비활성화 * test: 사용자 session 필드에 device_id 추가 * fix: session 필드 device_id 비지니스 로직 및 엔티티에 반영 * fix: update_user_status 시, session ttl도 초기화 * style: header 두번 파싱 로직 -> principal에 삽입된 값을 조회하는 것으로 수정 * fix: spending_req dto is_custom_category 직렬화 대상에서 제외 * fix: 사용자 세션 갱신 시, device name -> device id 인자값 수정 (#177) * fix: 🐛🔧 Modification and refactoring of the presigned URL creation and conversion logic (#179) * style: url_generator 패키지 경로 수정 * rename: user_id 로우 스네이크 케이스로 통일 * rename: storage service -> presigend_url_generate_adapter * fix: presigned_url_dto 유효성 검사 추가 * fix: presigned_url dto ext 유효성 옵션 추가 및 id 타입 수정 * fix: generator util로 대체 * feat: type별 property data type 객체 생성 * feat: presiend_url_property_factory * fix: object key template 메서드 url generator에 통합 * fix: service 로직에서 파라미터 팩토리 전달하도록 수정 * fix: use_case가 service를 호출하지 않는 문제 수정 * refactor: key pattern -> generator로 역할 양도 * fix: 불필요해진 object key type 메서드 제거 * fix: 수정된 origin url generator 적용 * test: storage_controller_test 수정 * test: 모든 type에 대해 올바른 케이스 테스트 * test: 테스트 실패 시, 422에러로 수정 * fix: constructor 오류로 인해, validator 체커 외부로 이전 * feat: init binder를 사용하여 커스텀 validator 등록 * feat: cusom_validation_exception handler 등록 * test: presigned_url_property_factory_test * test: url generator test * fix: object key type chat_profile { 누락 수정 * fix: 각 property type 유효성 검사 수정 * docs: swagger 문서 업그레이드 * fix: presentation 계층 log aspcet에서 response_entity is null인 경우 예외처리 * fix: 🐛 Modify the business logic of chat room creation (#180) * fix: presigned_url_property_factory 불필요한 builder 제거 * refactor: property 추상 클래스를 사용한 자원 생성 전략 수정 * fix: presigned-url 발급을 위한 validator 수정 * fix: query param chat_id, feed_id 삭제 * test: storage_controller_test 수정 * test: factory test 수정 * fix: factory 생성 전략 수정 * fix: object_key_type에서 필수로 변경되어야 할 아이디 리스트 제공 메서드 추가 * fix: 변경할 아이디 전달 객체 생성하여 반영 * fix: aws_adapter 파라미터 수정 * test: url_generator test 수정 * fix: pending chatroom entity 및 비지니스 로직 제거 * test: chat_room create controller unit test 수정 * fix: chat_room_controller pend 경로 제거 및 dto 통합 * docs: 채팅방 생성 스웨거 수정 * test: 채팅방 생성 통합 테스트 기대값 수정 * fix: chat_room_req password 유효성 검사를 위한 필드 타입 수정 및 null 분기 조건 추가 * fix: 채팅방 생성 비지니스 로직 수정 * rename: actual_id_provider 모호함을 유발할 수 있는 주석 수정 * feat: ✨ 채팅방 검색 API (#181) * fix: 내 채팅방 리스트 조회 api 경로 수정 * chore: mysql full-text-index query setting * fix: mysql match function constant to public * feat: add qeury dsl utils method that match against two element in natural mode * fix: query_dsl_util match_against function return type is changed to boolean_expression * feat: implement search chatrooms business logic * fix: 채팅방 검색 쿼리 개선 * fix: 채팅방 조회 service에서 user_id 파라미터 전달 추가 * rename: list 접미사 복수형으로 수정 * feat: chat search service 메서드 추가 * feat: slice 응답 포맷 통일을 위한 공통 컴포넌트 * feat: chat rooms slice to slice response mapper * feat: impl chat rooms search use case * feat: query dto * feat: controller 추가 * docs: swagger 정의 * rename: function_contributor 경로 service -> services * chore: query dsl sub-query를 위한 jpa_sql_query 설정 import * fix: repository 에러 핸들링 -> 쿼리 결과가 정확하지 않은 상태 * fix: match against 표현식 수정 * fix: query_dsl_util match_against 메서드 함께 수정 * fix: 내가 가입한 채팅방을 필터링 하는 쿼리 추가 * fix: 쿼리 결과로 사용자 admin 여부 조회 추가 (내방 조회 쿼리 수정) * fix: 채팅방 정보의 is_admin 필드 api 응답으로 반영 * fix: repository 내 불필요한 로깅 제거 및 상수 upper_snake_case로 수정 * docs: slice_response_template 및 chat_room 검색 api swagger 반영 * test: jpa_config sql_template 빌드 문제 해결 * fix: ✏️ Modify the format of chat search api (#182) * rename: content -> contents 수정 * fix: size 쿼리 파라미터 기본값 할당 * docs: swagger 문서에서 query param으로 표현되도록 수정 * rename: jsend protocol 명세에 의해 slice의 domain 위치에 단수 명사가 오도록 수정 * fix: ✏️ Heartbeat negotiation interceptor (#183) * fix: add system heartbeat interval env * fix: server - mq heartbeath interver 25s to 20s * chore: rabbitmq requested-heartbeat set 20 sec * feat: create heartbeat negotiation interceptor * fix: modify negotiation rule when client send 0,0 or null heartbeat header * style: 협상 결과 log 위치 수정 * feat: ✨ Implementing Chatroom Join API: A Bounded Context Approach (#184) * chore: external-api module messabe broker config 주입 * chore: add chat_join_event_message exchange properties * chore: add chat.join exchange * style: domain service 역할을 구분하기 위한 chat_member 생성 로직 위치 수정 * fix: modify the chat_member entity to don't check exsists member is_deleted * feat: add domain logic(is_active(), is_banned_member()) in the chat_member entity * feat: add ban domain method in entity * feat: add chat_member exception & error code * test: add user_fixture & chat_room_fixture within the domain test package * feat: impl chat_member_repository * feat: impl create_member business logic within chat_member_service * test: chat_member create business logic unit test * fix: exclude nickname parameter when create chat_member * feat: add password check and verify domain logic whitin the chat room entity * feat: add chat_room error code with exception * fix: add chat_room not_found error code * feat: chat_room_service.read_by_id() * feat: add count_chat_member in chat_room logic * feat: chat_member_join business service impl * feat: add chat_room_join event handler within infra module * fix: when member join finish, call the chat_room join event handler * feat: join_chat_room_usecase & fix chat_member_join_service return value * fix: chat_member_join_service return value is adding plus 1 about current_member_count * feat: impl join chat room controller * docs: write join_api swagger * fix: join_req_dto is added getter method for swagger ui presenting * fix: chat_member_repository method rule find_by_chat_room_id -> find_by_chatroom_id * test: add chat_member_fixture in the external-api module * test: chat_member_join_service_unit_test * test: refactor & add chat_member_join_service test case * chore: prevent automatic rabbitmq connection creation during application startup * fix: add two types of getter whitin chat_member_req * fix: add default constructor within dto * fix: modify distributed_lock's key correctly about the spel * rename: add log within chat_member_join_service * test: fix chat_room_id of the chat_room_fixture due to integration failure when id is fixed 1 * rename: join_service_test (usecase package) move to (service package) * fix: chat_room_join_event_hander bean create within the message_brocker_config * fix: modify join_event_hander phase after_commit to before_commit due to at_least_one condition * test: chat_member_join integration test * feat: ✨ Chatroom Join Event Relay (#185) * feat: add message_content_type enum class * feat: add message_category_type enum class * feat: two types of converter impl * feat: chat_message entity with step builder * rename: type package path transfer * feat: chat_message repository & domain service * fix: chat_message redis entity @id package spring to jakarta * test: dao layer test * chore: configuring the rabbit listener container factory & socket application fix * chore: add rabbitmq dependency into the socket module * feat: impl chat_join_event_listener * refactor: seperate business logic from listener * rename: contants -> constants * feat: add system message enum classes * refactor: using send_message_command for dto * chore: when integration tests run in the external-api, amqp connect is blocked * feat: ✨ Send Chat Message Usecase (#186) * rename: transfer command to command package * chore: add jackson validation dependency in to the socket module * chore: add spring-validation dependency in to the socket module * rename: package path modification * fix: add type field in the rabbit_listener exchange property's type field * fix: delete constants in the listener * feat: chat_message_dto * feat: chat_message_controller * feat: add dto validation * fix: chat_message entity's @redis_hash is chatroom * fix: dto wrap final class * fix: when message send to rabbitmq, using response dto * rename: delete send_message_commend unused annotation * fix: get_chat_room_id and get_chat_id method in the chat_message fix index * refactor: when chat message send to rabbitmq, it's will be dto type * test: dao test expected redis key fix * feat: ✨ Fetch Summary of Chatrooms Joined by User (#187) * feat: find chatroom ids where user is joined * rename: change from find to read by the convention * feat: search chat_room_ids business logic where user is joined * feat: add chat_room summary response dto * feat: impl get my chatroom id in summary * feat: add to query parameter where chatroom_controller.get_my_chat_rooms() * rename: when fetch chatrooms summary info, domain-name's place is allocated singular nouns * feat: ✨ Receive Chatroom Detail Informations (#188) * fix: chatmessage data structure changed to sorted set * fix: chat_message_service.delete() deprecated due to unnecessary * fix: chat_message hasn't responsibility about id * chore: change lecttuce debug level to debug in test profile due to test * fix: change chat_message_repository from jpa interface to concrete class * test: modify chat_message_repository test * test: add boundary value analysis test & verify sorting with same create_at time * refactor: add repository interface * rename: chat_message_service save to create * feat: add functions into chat_message_service * feat: not_found error code added in chat_member_error_code * feat: add three type of find method in chat_member_service * feat: add chat_member_res_detail dto * fix: insert joined_at field in the chat_member_res.detail dto * feat: add chat_message response dto * feat: chat_room_res.room_with_participants dto * feat: chat_room_with_paritipants mapper * feat: impl chat_room_with_participants_search_service * style: sperate from service login hard code to contants & delete annotation * test: chat_room_with_participants_search_service test case * feat: add chat_room_manager for authorization * feat: add chat_room_usecase * feat: add get_chatroom controller & api * docs: add information about recent_messages field's sorted in the chat_room_res * chore: delete lecttuce log * test: chat_room detail integration test * feat: ✨ Get Chat Member Informations By Batch (#189) * feat: add read method in to the chat_member_search_service * feat: add api_error_code & exception * feat: chat_member response mapper * feat: get chat_member list usecase * docs: get chat member swagger * feat: impl controller * fix: query param @not_null -> @not_empty * test: controller unit test * test: integration test * fix: 혼동을 주는 member_id -> user_id로 수정 및 멤버 정보 조회 시 chat_member_id를 사용하도록 쿼리 수정 * docs: 채팅방 멤버 조회 시, 경고 사항 스웨거에 추가 * test: 테스트 시, 사용자 아이디 -> 채팅방 멤버 아이디로 수정 * feat: ✏️ Append UnreadMessageCount Field in the ChatRoomResponse (#190) * feat: chat-message-status entity * feat: chat_message_status_repository * feat: impl cache_repository * feat: impl chat message status domain business logic * rename: add read_last_read_message_id docs about if absent data, return null * fix: add constructor in the chat_message_status entity * fix: add domain service validation logic * test: chat_message_status_service unit test * rename: add custom & impl into name because of avoidance jpa auto scan * fix: set chat_message_status unique constraints * test: chat_message_status_service integration test * fix: add unread_message_count field into chat_room_res * fix: add flow in the chat_room_search_service * fix: join service result type tuple to triple * fix: change chat_room_detail mapper mechanism * fix: change flow in the use_case * test: add test_jpa_config rabbitmq null bean * test: all test fix * test: chat_room_search_test * rename: convert from chat_member_res.detail to member_detail due to swagger docs * rename: convert from chat_res.detail to chat_detail due to swagger docs * fix: logic modified when get chat room detail info * fix: ✏️ 채팅방 정보 조회 응답 수정 (#191) * fix: 멤버 정보 조회 시, user_id 정보를 위한 쿼리 수정 * fix: 채팅 멤버 및 방 응답 dto 필드 추가 * fix: dto 변경에 따른 mapping 전략 수정 * fix: api 모듈 내 service 비즈니스 로직 수정 * feat: admin 조회 쿼리 추가 * fix: query dsl 응답 dto name 필드 타입 수정 long -> string * rename: other_member_ids -> other_participants 필드 이름 수정 * fix: admin 조회 시, query dsl을 활용하여 dto 타입으로 조회하도록 수정 * rename: dto 필드명 수정에 따른 네이밍 수정 * fix: 불변식 list add 에러 추가 * test: 비즈니스 로직 및 응답 타입 수정에 따른 테스트 수정 * fix: 최근 활동 이력에 관리자 정보 포함된 경우 쿼리 호출 제외 * fix: 관리자 정보 추가 조회 조건식 수정 * feat: ✨ Path to Retrieve Last Message ID (#192) * feat: impl last_message_id_save_service * feat: add message path about read_message * fix: outstream debug mesage level is changed from info to debug * rename: fix message_mapping end-point * fix: 🔧 Fix CD Pipeline (#193) * chore: fix batch command * chore: fix relay cd command * chore: fix socket cd command * feat: ✨ Batch Job For Write Back & Write Allocate Strategy (This is Last Piece 🤗) (#194) * feat: add key_value record in batch module * feat: impl last_message_id reader * feat: impl last_message_id processor * feat: impl last_message_id writer * fix: convert prefix_pattern from member various to static * feat: last_message_id_job_config setting * chore: batch module test log setting * refactor: remove state from the reader * test: last_message_id job batch test * chore: add testcontainer dependency within batch module * chore: batch integration test setting * chore: add sql script in batch test package due to init job instance table * fix: sperate cursor bean from job_config to batch_redis_config * chore: insert init sql into batch mysql testcontainer config * fix: add @spring_batch_test in @batch_integration_test * rename: add logging in reader, processor, writer * chore: convert reader component to bean in job config * fix: add try-catch about number_format_exception in processor * feat: chat_message_statue entity overrid to_string * test: last_message_id integration test * test: add integartion test case for large data * chore: last_message_id job scheuling * feat: ✨ Chat History Search API (redis score system changed) (#195) * test: happy path integration test * rename: fix concrete dependency in chat_message_service * feat: chat search service * feat: impl chat mapper * feat: impl chat use case * feat: impl chat_controller * fix: fix redis zrevrangebyscore command * test: fix perform_request isn't using last_message_id and size parameter * test: additional integration test * fix: using scores, max value is last_message_id - 100 * refactor: convert score soring to lecxicographical sorting * test: chat_message repository test apply tsid * test: fix intergration test * docs: apply swagger * test: chatroom detail integration test fix * fix: add validation within chat_message_repository * fix: ✏️ Append WHERE condition in readNotification Query (#197) * fix: add notice_type parameter in notification read method * rename: fix notification search service method name (add 'announce') * test: add notice_type parameter in method where on when clauses * fix: ✏️ Users are unable to delete their accounts while they hold ownership of any chatrooms (#198) * feat: add method of find existence that user has chatroom's ownership anyone on repository * feat: add method in domain service * feat: add 409 error for user's signout blocking because user has chat room ownership * feat: resolve todo annotation * test: add delete test about has ownership chatroom user can't delete account * fix: ✏️ Append LastMessage Field in ChatRoomDetail Response (#196) * feat: add chat_room_res info dto for service return data mapping * fix: chat_room_mapper use chat_room_res.info dto within parameter * fix: when search my chatroom, add flow for read last message * fix: usecase is fixed by dto spec * test: fix unit test * fix: ✏️ Append a userId Field in User Session (#199) * fix: append user_id field in user_session entity * fix: when connect hander creates user_session entity, provides user_id parameter * test: rename find_all_user_sessions_test's displayname because original name is ambiguous * fix: ✏️ Append Device Id and Name Fields In Device Token Entity (#200) * fix: append device id & name field in device_token entity * fix: append device id&name field in device_token put dto * fix: add id&name parameter in device_token_register_service * fix: usecase provides device id&name to register_service * test: device_token_fixture fixed * test: fix device_token_register_service_Test * feat:✨Implemented a new readAll function within DeviceTokenService * fix: ✏️ Append Data Field to Notification DTO for Push Notification Metadata * fix: ✏️ Modification of Refresh Token Specification (#203) * fix: add id & device_id field in refresh token entity * fix: add device_id parameter in save, refresh, delete method * fix: change repository id type from long to string * fix: add device_id field in refresh token * fix: add device id field in signin & singup request dto * fix: usecases provide device_id to jwt_auth_helper * fix: add default constructor in refresh token entity * fix: add devce_id field in refresh token provider * fix: add device id field in auth synchronize dto * fix: convert from rt to device_id when jwt_auth_helper delete token * test: all of tests fix refresh token create logic * feat: impl delete_all_by_user_id method in custom repository * fix: refresh token service's delete method to delete_all * test: all of user's device tokens are deleted * test: apply to delete method changed * feat: ✨ Push Notifications for Users Not in Chat Room or Chat Room List View (#204) * feat: add terrible code to chat_message_send_event_listener * feat: find user_ids_by_chatroom_id in chat_member_repository * refactor: separate business logic from listener to service class * feat: impl domain service to determine push notification target member * feat: chat_message_relay_service in socket module * fix: is_activated method in device token entity add rule about last_signed_in_at * refactor: seperate device_logic from application service to domain service * rename: upgrade javadoc what chat_message_relay_service doesn't consider about retry logic * feat: add notify enable/disable method in chat_member * test: add static method in chat_room & user fixture for custom setting * test: impl bdd flow builder pattern * fix: set notify_enabled field to default value in chat_member entity * test: impl test builder flow * fix: modify business rule: if even one session is looking chat_room, all of user's sessions exclude * rename: delete logs in chat_notification_coordinator_service * test: add unit test * rename: add domain rule in javadoc * fix: 🐛 Issue with Detecting Push Notification Events (#205) * fix: remove exclusive mode in rabbit listener * chore: add fcm dependency in socket module * rename: add context log in chat_message_relay_service * fix: except for system category message in chat_message_relay_event_listener * rename: convert log level from debug to log in fcm_event_handler * fix: add tx at chat_message_relay_service * refactor: 🔧 Divide Domain Modules For Multiple Infrastructure (#206) * refactor: add domain-redis module in domain module * fix: application class -> location interface * refactor: redis configs move to redis module * refactor: redis qualify annotation move to redis module * chore: add aop dependency in redis module * refactor: move distributed lock components to redis module * refactor: create rds module * refactor: create domain-service module * chore: fix project setting in root gradle * refactor: move rds config to rds module * refactor: move repositories to rds module * refactor: move rds utils to rds module * refactor: move date_auditable to rds module * refactor: move converters to rds module * chore: add funtion_contibutor meta data in rds module * refactor: move domains to rds module * fix: delete user_status_converter * chore: append testcontainer dependency in domain service module * rename: forbidden token service -> redis service * refactor: move chat_notification_coordinator_service to domain service module * rename: all of rds's services rename from service to rds_service * chore: add logback-test.xml in all of domain modules * chore: rds application * chore: redis application * chore: domain service application * fix: remove legacy_type from type in redis module * refactor: separate lettuce config from redis config * feat: add rds domain module importer * rename: rds config -> redis config * rename: rds-module -> rdb-module * rename: rds_domain_config_import_selector -> redis_ * rename: domain_rds_location -> domain_rdb_location * rename: rds_service -> rdb_service * feat: add rdb module import selecter * feat: impl interface pennyway_rdb_domain_confing where jpa_config & query_dsl_config * chore: add domain_config in domain service module * refactor: separate chat_message_status_cache_logic from domain service * refactor: seperate rdb business logic from domain service module's service * feat: add user-service in domain service module * refactor: add chat-message-service in domain-service-module * refactor: add chat-room-service in domain-service-module * refactor: input value verify logic is moved from domain-service to entity-service * refactor: add oauth-service in domain-service-module * refactor: add device-token-service in domain-service-module * refactor: add notification-service in domain-service-module * refactor: add chat-member-service in domain-service-module * refactor: add question-service in domain-service-module * refactor: add target-amount-service in domain-service-module * refactor: add spending-service in domain-service-module * refactor: add spending-category-service in domain-service-module * refactor: add user-session-service in domain-service-module * refactor: add phone-code-redis-service in domain-service-module * refactor: add refresh-service in domain-service-module * refactor: add forbidden-token-service in domain-service-module * feat: set redis-test-config * refactor: move chat_message_repository_impl_test to domain-redis module * chore: when redis_connection_factory creation cycle, call factory.start() * refactor: move phone_validation_dao_test to domain-redis module * refactor: move refresh_token_dao_test to domain-redis module * refactor: move user_session_dao_test to domain-redis module * feat: set mysql-test-config * refactor: move domain_fixture to rdb module * fix: delete unused test & rename mysql test container * chore: add generated package in gitignore * refactor: move user_extended_repository_test to rdb module * refactor: move user_soft_delete_repository_test to rdb module * refactor: move recent_target_amount_search_dao_test to rdb module * refactor: oauth_dao_test to rdb module * refactor: notification_dao_test to rdb module * refactor: chat_member_create_dao_test to rdb module * refactor: move chat_notification_coordinator_service_unit_test to domain-service module * feat: add chat_message_status bulk_update method * feat: add chat_message_status_redis_service delete_read_status method * feat: add chat_message_status bulk memory synchronize method * refactor: chat_message_status_service_test to domain-service module * fix: delete chat_message read_status synchronize business logic * test: delete bulk update test from chat_message_status_service_test * chore: delete domain module's src package * rename: application-domain-rds.yml -> domain-rdb * chore: add domain-redis & domain-rdb dependency in batch gradle * chore: remove redis auto configure * chore: add domain config setting in batch module * fix: delete jpa & query dsl importer (bean duplicated problem) * fix: delete domain config in batch module * chore: fix batch test profile config about domain to domain-rdb & domain-redis * chore: fix domain dependency in socket module * fix: modify package path in last_message_id_service * fix: modify package path in status_message * fix: modify package path in chat_message_relay_service * fix: modify package path in chat_message_dto * fix: modify package path in send_message_command * fix: modify package path in chat_message_send_service * fix: modify package path in status_service * fix: modify package path in chat_message_relay_event_listener * fix: modify package path in chat_room_access_checker * fix: modify package path in connect_authenticate_handler * style: move chat_message_dto to common/dto * chore: fix domain dependency in external-api module * fix: delete domain_config in domain-service module * feat: add importer in domain-service module * feat: add lettuce & redie & redisson config in domain-service * chore: add domain_config in socket module * fix: domain-config in external-api module fixed about path * fix: separate constants of url_paths from security_config * fix: modify package path in jwt_authentication_filter * fix: modify package path in security_filter_config * fix: modify package path in user_detail_service_impl * fix: modify package path in chat_room_manager * fix: modify package path in notification_manager * fix: modify package path in spending_category_manager * fix: modify package path in spending_manager * fix: modify package path in target_amount_manager * fix: modify package path in verification_type and fix to_phone_verification_type method * fix: modify package path in jwt_auth_helper * fix: modify package path in auth_find_service * fix: change phone_code_service's return type from void to local_date_time * fix: modify package path in phone_verification_service * fix: modify package path in user_general_sign_service * fix: modify package path in user_oauth_sign_service * fix: modify package path in auth_check_use_case * fix: modify package path in auth_use_case * fix: modify package path in oauth_use_case & fix position of get_aoauth_sign_up_type method * fix: modify package path in user_auth_use_case * fix: modify package path in chat_member_join_service * fix: modify package path in chat_member_search_service * fix: modify package path in chat_room_save_service * fix: modify package path in chat_res_dto * fix: modify package path in chat_room_search_service * fix: modify package path in chat_room_mapper * fix: modify package path in chat_room_with_participants_search_service * fix: modify package path in chat_search_service * fix: modify package path in chat_mapper * fix: modify package path in chat_use_case * fix: modify package path in question_use_case * fix: modify package path in notification_save_service * fix: modify package path in notification_search_service * fix: modify package path in device_token_register_service * fix: modify package path in device_token_unregister_service * fix: modify package path in password_update_service * rename: delete suffix (in query) at spending category delete method * fix: modify package path in user_delete_service * fix: modify package path in user_profile_search_service * fix: modify package path in user_profile_update_service * fix: modify package path in spending_category_delete_service * fix: modify package path in spending_category_save_service * fix: modify package path in spending_category_search_service * fix: modify package path in spending_delete_service * fix: modify package path in spending_save_service * fix: modify package path in spending_search_service * fix: modify package path in spending_update_service * fix: modify package path in target_amount_delete_service * fix: modify package path in target_amount_save_service * fix: modify package path in target_amount_search_service * fix: modify package path in target_amount_usecase * fix: modify spring profiles group in socket's application script * fix: modify spring profiles group in external-api's application script * fix: external env setting in external-api test module * test: fix package path in auth's test * test: fix package path in chat's test * test: fix package path in ledger's test * test: fix package path in users's test * rename: divide identification domain config group's constants name * rename: redis_config -> redis_domain_config in domain-service module * rename: distributed_lock's around path * chore: delete redis auto configuration exclude clauses * rename: redis template -> string_key_redis_template (due to bean name conflicting) * fix: remove lettuce_config & redis_config from redis_domain_config_group * fix: remove lettuce_config & redis_config from domain_service_config_group * fix: remove lettuce_config & redis_config from external-api module's domain config * fix: delete domain config from socket module * fix: change domain config annotation in batch's domain_config * fix: modify package path in disconnect_handler * test: modify jwt_auth_helper_test collectly (delete dao test annotation) * test: delete name_update_service_test * test: delete db config from jwt_auth_helper_test * test: delete db config from device_token_register_service_test * test: delete device_token_unregister_service_test * test: convert user_delete_service_test to integration test * test: convert password_update_service_test to integration test * docs: 📝 Update Readme 0.0.4 (#207) * docs: update root module's readme to version 0.0.4 * docs: update domain module's readme * docs: update external-api module's readme * docs: upgrade socket's module readme * fix: ✏️ Fix Entity By ChatMember's Name Domain Rule (#208) * fix: delete name field from chat_member * fix: update query for receiving chat_member's name field from user table * fix: update query-dsl for receiving chat_member's name field from user table * test: read member query dao test * fix: add find user step in chatroom_with_participants_search_service * fix: change name parameter source from chat_member to user_name in chat_member_join_service * test: add given clauses in chatroom-with-participant-search-service-test * fix: 🐛 Modify Device Token Management Policy (#209) * feat: add handle_owner method in device_token entity * feat: device_token_regiter_service in domain-service module * feat: add two type of device_token search query * feat: add device-token-rdb-service * fix: change read-by-token to read-by-device-id-and-token * fix: add activated condition in to device_token entity's is_expired * feat: add device token duplicated error code * fix: add conflict error handling when create_device in device_rdb_service * fix: convert from by-device-id-and-token to by-token for unique * fix: add validation logic for token value's uniqueness and pair of device-id and token * fix: add device_id param on the handle-owner in device-token-entity * fix: add conflict condition if different device-id but expired, it will be passed * test: device-token-register-service-unit test * fix: update device-token-register-service path in usecase and delete original service * style: divide create and update logic * chore: add testcontainer in domain-service module * chore: add profile-resolver in domain-service module * chore: add jpa-test-config in test package in the domain-service-module * fix: add 'test' profile in domain-service-integration-profile-resolver * test: device-token-register-service integration test * refactor: 🔧 Seperate Business Logic From DeviceTokenRegisterService (#210) * feat: add device_token_collection * test: new device token register test * feat: add device_token owner update logic * test: device_token owner update test * rename: device_token_collection to device_token_register_collection * feat: all business rule input in the device-token-reigster-collection * test: device-token-register-collection test * refactor: apply first-level object at the service * rename: minimalize service's javadoc * fix: add persistence logic in service * test: fix device-token-register-service integration test * test: add db verify test code * rename: add status change log in the collection * refactor: 🔧 Migrating the Socket Module from Java to Kotlin (#211) * rename: application file java to kotlin * chore: convert language from java to kotlin in the socket module * fix: change application file to kotlin style * fix: move @post-construct method function into the class * feat: add kotlin log util * refactor: convert auth_service from java to kotlin * refactor: convert chat_message_send_service from java to kotlin * refactor: convert stomp-message-util from java to kotlin * refactor: convert last-message-id-save-service from java to kotlin * refactor: convert status-service from java to kotlin * refactor: convert chat-message-relay-service from java to kotlin * refactor: convert auth-controller from java to kotlin * refactor: convert chat-message-controller from java to kotlin * refactor: convert status-controller from java to kotlin * refactor: convert pre-authorized-spel-parser & aspect from java to kotlin * refactor: convert user-principal from java to kotlin * fix: principal.expired cannot use(lombok compile time), so convert domain method * fix: add jvm-static annotation at create-method in the stomp-message-util * rename: add evaluate prameter name * fix: modify is_authenticated to static method * chore: modify kotlin compile option cause method parameter data lost * fix: divide evaluate logic from execution logic * fix: ✏️ Add Send Message Headers to the Error Message (#212) * feat: add default server error handler * feat: add message-id in the error response's header * fix: include request message headers in the error message * fix: It Allows the Aspect Class to Return null (#213) * fix: 🔧 The server sends a success message after a chat message is saved successfully (#214) * fix: add sender-name & message-id-header field in the send-message-command * fix: success message send to client after finished chat send process * fix: extract x-message-id header at the send-message-controller * fix: convert header parameter type to string * style: convert send-message-service like kotlin * feat: ✨ Leave Chat Room API (#215) * feat: add chat-member-delete method in rdb-service * feat: add read-chat-member-by-chat-member-id method in rdb-service and repository * fix: when search chat-member entity by chat-member-id, exclude chat-room-id * fix: delete sql-delete annotation in the chat-member entity * fix: remove delete method and add update method in chat-member-rdb-service * feat: impl chat-room-leave-service * feat: add chatroom leave usecase * feat: add is-exists method with chat-room-id, user-id, chat-member-id in repository * feat: impl is-exists method in rdb service for adding chat-member-id condition * feat: add authorization method in the chat-member-service * feat: add chat leave controller * feat: add is_admin method in chat-member entity * feat: add check chat-member-size to leave admin in the chat-room entity * fix: add admin leave validation rule * feat: add chat room delete method in chat-room-rdb-service * feat: add admin-cannot-leave error code * fix: sperate business logic from service * style: add log to alter normal member status is changed * feat: add association mapping helper method chat-room with chat-member * test: chat room leave collection unit test * test: chat room leave integration test * docs: chatroom leave controller swagger docs * test: disable chat-room-detaill-integration-test temporarily * test: resolve chat-room-detail-integration test error * refactor: 🔧 30x Performance Improvement in PreAuthorize AOP (#216) * chore: add kotlin reflection dependency in the socket module * style: remove unnecessary log in preauthorize-spel-parser * feat: impl pre-authorizer using kolin trailing lambda * test: authorization test * chore: add logback xml in socket test module * test: pre-authorize benchmark * feat: ✨ Ban Chat Member API (#217) * feat: chat-member-ban-command * rename: modify admin-id to user-id cause it isn't identicated already * feat: add not-admin errorcode * feat: impl chat-member-ban-service * feat: add ban-chat-member usecase * feat: ban-chat-member controller * docs: chat member ban api swagger * feat: ✨ Patch Chat Room Information API (#218) * feat: create chat-room-patch-command * feat: add update method to chat-room-rdb-service * feat: impl chat-room-patch-service * fix: add static factory method to command * feat: add chatroom-req update dto * feat: impl chat-room-patch-helper * feat: add update chat room usecase * feat: add has_admin_permission method to the chat-room-manager * feat: impl chat-room update controller * docs: write swagger * fix: 🐛 Fix Put Device API throws NonUniqueResultException (#219) * docs: fix leave chat room exception wrong constant * style: extract user validation check rule from the business collection * test: update test * fix: 🐛 Revising the Refresh Event Handler (#220) * feat: ✨ Log System Implementation (#221) * chore: ignore log directory * feat: logging xml * chore: add log property in the external-api-module * chore: external-api logging setting * chore: external-api logback-spring.xml * fix: external-api log total-size-cap to 10mb * chore: add file max capacity * chore: ignore logs directory * chore: socket log config setting * chore: socket server logback setting * feat: ✨ Chat Room Delete API (#222) * feat: add chat-room-delete-command * feat: chat-member delete by chat-room-id query * feat: impl service logic * style: using var key-word * fix: add static factory method to the command * rename: from find-by-chat-room-id to find-by-chat-member-id in chat-member-repository * style: add chat-room-delete-status-log in the delete-service * test: chat-room-delete-service-integration-test * rename: from delete-chat-room to execute in the chat-room-delete-service * feat: chat room delete usecase * feat: chat-room-delete controller * docs: chat-room delete api swagger * chore: fix log setting simple * fix: 🐛 Improvement of Log System and Update Chat Room API (#223) * fix: change from id to user_id in the has-admin-permission method * fix: revising external-api log policy * fix: revising socket log policy * fix: delete chat-room-id field in chat room update request * fix: add chat-room-id parameter * feat: ✨ Retrieve Chatroom Information in Admin Mode (#224) * feat: add chatroom-read method for admin view in the chat-room-search-service * feat: add get-chatroom method for admin view in the usecase * feat: add admin view dto in the chat room res * feat: add mappter to convert chatroom entity to admin-view res dto * feat: add api which get chatroom for admin * fix: 🐛 The NotifyEnabled field has been added to the Get Chat Room API in Version 2 (#225) * fix: add notify_enabled field into the chat-room-detail * fix: add notify_enabled select field when chat-member select query * feat: add chat-room detail response dto for v2 * feat: add method to map entity to detail dto v2 * feat: add v2 usecase * docs: write about getting chat room v2 api swagger * feat: impl v2 chat room controller * fix: deprecate v2 get-my-chat-rooms api * test: fix given clauses for chat-room-detail * feat: ✨ Turn On/Off Chat Room's Notification Setting API (#226) * feat: add chat-room-toggle-command * feat: impl chat-room-notification-toggle-service * feat: add static factory method $ validator in command * test: service layer slice test for transation verifying * feat: add notification turn on/off usecase * style: move toggle service from chat-member-usecase to chat-room-usecase * feat: open notification turn on/off api * docs: write notification turn on/off api swagger * fix: move notify_enabled select to my_rooms_query (#227) * fix: 🐛 Add "Is Null" Filter on the Foreign Entity in the Chat Room Entity (#228) * fix: add restriction to filter deleted_at is null in the chat-room * test: check that added 'is null' query into the count query * fix: 🐛 Fix Chat Room Background Image Process Logic (#229) * feat: add method is-object-exist in the aws-s3-adapter * fix: delete background-image url validation condition * fix: apply chat room image change scenario in the chat-room-patch-helper * fix: all static factory method delete from the chat room res dto * fix: apply object-prefix to the chat room's background-image-url * fix: wrong validation of chatroom-patch-helper-test background image url * test: apply modification about chat room detail's static factory method are deleted * fix: add get-object-prefix in the chat-member usecase * test: chat-room-patch-helper s3 flow check * feat: add 422 error to storage error code * fix: helper class's exception handling * test: apply prefix test to create chat room * style: change to var keyword * fix: 🔧 Remove Chat Member Id from Leave Chat Room API Spec (#230) * fix: change chat member search method in the leave service * fix: change parameter controller and use case * docs: fix leave chat room api's swagger docs * test: fix test * feat: ✨ Chat Room Admin Delegation API (#231) * feat: add delegate action to the chat member entity * feat: chat room admin delegate operation * fix: apply correct error type to the chat member's delegate method * test: add exceptional scenario to the test * feat: delegate service * rename: add log field to chat-member's to-string() * test: happy path integration test * feat: add exception for trying delete to member who joined other chat room * test: about delegating chat member who joined other chat romm * feat: add filter to check active member * feat: apply distributed lock in the delete method before start with * fix: apply command to the service parameter * feat: delegate usecase * feat: delegate controller * feat: add not_same_chat_room error code to chat-member-error-code * fix: chat member will throw 409 error when try to delegate chat member who joined other chat room * docs: write swagger * test: fix expected error when try to delegate other chat room's member * fix: distributed lock's key is null due to changing parameter type * fix: 🐛 Added Missing Field in the Chat Member Detail Response (#232) * fix: delete chat-member-search-service * fix: add profile-img field to the result view * fix: handle to aftermatch by deleting chat-member-search-serivce * test: add profile image url parameter * fix: add profile image url to chat-member-res * fix: replace method to create member detail with function * fix: replace method to create member detail with function in chat-member-mapper * fix: add object-prefix to mapper * test: add aws s3 adapter dependency * fix: add profile image field to custom-chat-member-repo-impl * feat: ✨ Spending History Sharing API (#233) * feat: add spending list read method with day to spending repository * feat: add spending list read method with day to spending service * feat: impl daily spending aggregate service * test: aggregate slice test * fix: change return type to pair * rename: fix test name * feat: spending-chat-share-event * feat: add spending-chat-share-exchange-properties * chore: add spending chat share property to infra yml * feat: impl spending chat share event handler * fix: spending-on-dates type error in the spending-chat-share-event * feat: impl spending-chat-share-helper * feat: add share-to-chat-room to spending usecase * feat: add invalid share type error code to spending-error-code * feat: impl spending share enum type * feat: add spending-chat-share-query dto * feat: add missing-share-param error code to spending error code * feat: add share-spending api to controller * docs: write swagger docs about spending share api * chore: apply binder & queue & event handler for share to chat room * fix: invalid validation in the spending chat share event's name field * feat: add share constant to the message category type * feat: add default create message method to the send-message-command * fix: add sender id field to spending-chat-share-event * fix: add user id to event parameter when publish event * feat: impl sending-share-event listener * fix: add headers to send message command * refactor: convert spending-share-event-listener to kotlin * fix: add date field to spending-chat-share-event * fix: add date to chat message's header * fix: 🐛 Remove Prefix During S3 Image Validation & Fix Infinite Invitation Messages (#234) * fix: substring s3 prefix from image url in the chat room image exist validation * fix: infinity message send error handle * test: remove obj prefix from mock test * fix: 🔧 Move the Date Field to the Payload in Spending Sharing Message (#235) * fix: move date to payload * chore: add jackson config to socket module * fix: 🐛 Distributed Lock Failure Causing Duplicate Chat Room Joins (#236) * feat: add aready joined error code to chat room error code * fix: add joined member validation in chat member join service * fix: rollback chat room error code * test: the same user's multi requests thest * fix: 🐛 Reduce distributed lock duration by separating domain logic (#238) * chore: cut log max history back 7 to 3 * refactor: impl chat-member-join-operation * feat: add chat member join command & result * fix: type fix in the opration constructor * refactor: separate chat room business logic from the api service * fix: change from chat-room-id to chat-room-entity in the join result dto * fix: add member-name field and plus current-member-count in the join result dto * refactor: chat-member-join-helper * refactor: apply helper to usecase * fix: match-password validation bug * test: chat-member-join-operation-test * test: chat-member-join-service integration test * refactor: delete original services and unit test * fix: extend lease time to 10 seconds * feat: add read chat member method by chat room id * feat: add create and read by chat member id method to rdb service * refactor: modify method to check the current member number * refactor: move current member count to domain logic * fix: change user field from refrence to value type in the chat-member entity * fix: handle change propagation * style: remove unnecesary step * fix: add unactivated member filtering * release: ✨ Demo Account for Review (#239) * chore: add admin account environment * feat: admin mode phone verification * chore: add default value to admin env --------- Co-authored-by: jinlee1703 Co-authored-by: Jinwoo Lee Co-authored-by: DinoDeveloper <79460319+asn6878@users.noreply.github.com> --- .github/workflows/create-tag-and-release.yml | 20 +- .github/workflows/deploy-batch.yml | 2 +- .github/workflows/deploy-socket-relay.yml | 77 ++ .github/workflows/deploy-socket.yml | 77 ++ README.md | 9 +- build.gradle | 2 + pennyway-app-external-api/.gitignore | 5 +- pennyway-app-external-api/README.md | 30 +- pennyway-app-external-api/build.gradle | 4 +- .../pennyway/api/apis/auth/dto/SignInReq.java | 10 +- .../pennyway/api/apis/auth/dto/SignUpReq.java | 32 +- .../api/apis/auth/helper/JwtAuthHelper.java | 33 +- .../apis/auth/service/AuthFindService.java | 2 +- .../service/PhoneVerificationService.java | 156 ++- .../auth/service/UserGeneralSignService.java | 2 +- .../auth/service/UserOauthSignService.java | 4 +- .../apis/auth/usecase/AuthCheckUseCase.java | 6 +- .../api/apis/auth/usecase/AuthUseCase.java | 8 +- .../api/apis/auth/usecase/OauthUseCase.java | 31 +- .../apis/auth/usecase/UserAuthUseCase.java | 4 +- .../pennyway/api/apis/chat/api/ChatApi.java | 31 + .../api/apis/chat/api/ChatMemberApi.java | 98 ++ .../api/apis/chat/api/ChatRoomApi.java | 80 ++ .../api/apis/chat/api/ChatRoomApiV2.java | 23 + .../apis/chat/controller/ChatController.java | 31 + .../chat/controller/ChatMemberController.java | 86 ++ .../chat/controller/ChatRoomController.java | 100 ++ .../chat/controller/ChatRoomControllerV2.java | 35 + .../api/apis/chat/dto/ChatMemberReq.java | 30 + .../api/apis/chat/dto/ChatMemberRes.java | 50 + .../pennyway/api/apis/chat/dto/ChatRes.java | 45 + .../api/apis/chat/dto/ChatRoomReq.java | 66 + .../api/apis/chat/dto/ChatRoomRes.java | 160 +++ .../chat/helper/ChatMemberJoinHelper.java | 33 + .../api/apis/chat/mapper/ChatMapper.java | 20 + .../apis/chat/mapper/ChatMemberMapper.java | 28 + .../api/apis/chat/mapper/ChatRoomMapper.java | 151 +++ .../chat/service/ChatRoomPatchHelper.java | 87 ++ .../chat/service/ChatRoomSaveService.java | 45 + .../chat/service/ChatRoomSearchService.java | 61 + ...ChatRoomWithParticipantsSearchService.java | 76 ++ .../apis/chat/service/ChatSearchService.java | 19 + .../apis/chat/usecase/ChatMemberUseCase.java | 60 + .../apis/chat/usecase/ChatRoomUseCase.java | 100 ++ .../api/apis/chat/usecase/ChatUseCase.java | 24 + .../api/apis/ledger/api/SpendingApi.java | 27 +- .../ledger/controller/SpendingController.java | 26 + .../api/apis/ledger/dto/SpendingReq.java | 2 + .../api/apis/ledger/dto/SpendingShareReq.java | 20 + .../helper/SpendingChatShareHelper.java | 49 + .../DailySpendingAggregateService.java | 39 + .../SpendingCategoryDeleteService.java | 10 +- .../service/SpendingCategorySaveService.java | 10 +- .../SpendingCategorySearchService.java | 6 +- .../ledger/service/SpendingDeleteService.java | 4 +- .../ledger/service/SpendingSaveService.java | 10 +- .../ledger/service/SpendingSearchService.java | 4 +- .../ledger/service/SpendingUpdateService.java | 10 +- .../service/TargetAmountDeleteService.java | 2 +- .../service/TargetAmountSaveService.java | 6 +- .../service/TargetAmountSearchService.java | 2 +- .../apis/ledger/usecase/SpendingUseCase.java | 7 + .../ledger/usecase/TargetAmountUseCase.java | 2 +- .../notification/api/NotificationApi.java | 10 +- .../controller/NotificationController.java | 11 +- .../notification/dto/NotificationDto.java | 10 + .../mapper/NotificationMapper.java | 13 + .../service/NotificationSaveService.java | 4 +- .../service/NotificationSearchService.java | 14 +- .../usecase/NotificationUseCase.java | 10 +- .../question/usecase/QuestionUseCase.java | 2 +- .../api/apis/socket/api/SocketApi.java | 16 + .../socket/controller/SocketController.java | 26 + .../service/ChatServerSearchService.java | 20 + .../apis/socket/usecase/SocketUseCase.java | 17 + .../adapter/PresignedUrlGenerateAdapter.java | 23 + .../api/apis/storage/api/StorageApi.java | 40 +- .../storage/controller/StorageController.java | 16 +- .../api/apis/storage/dto/PresignedUrlDto.java | 14 +- .../apis/storage/usecase/StorageUseCase.java | 8 +- .../api/apis/users/dto/DeviceTokenDto.java | 10 +- .../service/DeviceTokenUnregisterService.java | 4 +- .../users/service/PasswordUpdateService.java | 2 +- .../apis/users/service/UserDeleteService.java | 28 +- .../service/UserProfileSearchService.java | 4 +- .../service/UserProfileUpdateService.java | 8 +- .../users/usecase/UserAccountUseCase.java | 7 +- .../api/common/aop/ExternalApiLogAspect.java | 5 + .../converter/SpendingShareTypeConverter.java | 17 + .../api/common/exception/ApiErrorCode.java | 30 + .../common/exception/ApiErrorException.java | 22 + .../exception/CustomValidationException.java | 15 + .../api/common/query/SpendingShareType.java | 11 + .../api/common/query/VerificationType.java | 10 +- .../response/SliceResponseTemplate.java | 25 + .../handler/GlobalExceptionHandler.java | 16 + .../authentication/UserDetailServiceImpl.java | 2 +- .../authorization/ChatRoomManager.java | 48 + .../authorization/NotificationManager.java | 2 +- .../SpendingCategoryManager.java | 8 +- .../authorization/SpendingManager.java | 4 +- .../authorization/TargetAmountManager.java | 2 +- .../filter/JwtAuthenticationFilter.java | 2 +- .../jwt/refresh/RefreshTokenClaim.java | 6 +- .../jwt/refresh/RefreshTokenClaimKeys.java | 3 +- .../jwt/refresh/RefreshTokenProvider.java | 10 +- .../api/common/storage/AwsS3Adapter.java | 18 +- .../PresignedUrlDtoReqValidator.java | 31 + .../co/pennyway/api/config/DomainConfig.java | 8 +- .../co/pennyway/api/config/InfraConfig.java | 5 +- .../co/pennyway/api/config/SwaggerConfig.java | 22 + .../api/config/security/CorsConfig.java | 4 +- .../api/config/security/SecurityConfig.java | 7 +- .../config/security/SecurityFilterConfig.java | 2 +- .../api/config/security/WebSecurityUrls.java | 12 +- .../src/main/resources/application.yml | 28 +- .../src/main/resources/logback-spring.xml | 170 +++ .../AuthControllerValidationTest.java | 19 +- .../apis/auth/helper/JwtAuthHelperTest.java | 115 +- .../AuthControllerIntegrationTest.java | 13 +- .../OAuthControllerIntegrationTest.java | 53 +- .../UserAuthControllerIntegrationTest.java | 47 +- .../auth/service/AuthFindServiceTest.java | 2 +- .../service/UserGeneralSignServiceTest.java | 2 +- .../auth/usecase/UserAuthUseCaseUnitTest.java | 6 +- .../ChatMemberBathGetControllerTest.java | 126 ++ .../ChatRoomSaveControllerUnitTest.java | 119 ++ .../ChatMemberBatchGetIntegrationTest.java | 109 ++ .../ChatMemberJoinIntegrationTest.java | 333 +++++ .../ChatPaginationGetIntegrationTest.java | 257 ++++ .../ChatRoomCreateIntegrationTest.java | 90 ++ .../ChatRoomDetailIntegrationTest.java | 238 ++++ .../chat/service/ChatRoomPatchHelperTest.java | 136 ++ .../service/ChatRoomSearchServiceTest.java | 145 +++ ...RoomWithParticipantsSearchServiceTest.java | 199 +++ .../SpendingCategoryIntegrationTest.java | 8 +- ...SpendingCategoryUpdateIntegrationTest.java | 8 +- .../SpendingControllerIntegrationTest.java | 12 +- .../TargetAmountIntegrationTest.java | 6 +- .../DailySpendingAggregateServiceTest.java | 78 ++ .../service/SpendingSearchServiceTest.java | 10 +- .../service/SpendingUpdateServiceTest.java | 15 +- .../GetNotificationsControllerUnitTest.java | 4 +- .../controller/StorageControllerTest.java | 108 +- .../users/service/PhoneUpdateServiceTest.java | 6 +- .../usecase/PasswordUpdateServiceTest.java | 18 +- .../users/usecase/UserDeleteServiceTest.java | 63 +- .../api/common/util/ApiTestHelper.java | 182 +++ .../api/common/util/RequestParameters.java | 118 ++ .../api/config/ExternalApiDBTestConfig.java | 2 +- ...ExternalApiIntegrationProfileResolver.java | 2 +- .../ExternalApiIntegrationTestConfig.java | 6 +- .../co/pennyway/api/config/TestJpaConfig.java | 15 + .../api/config/fixture/ChatMemberFixture.java | 22 + .../api/config/fixture/ChatRoomFixture.java | 40 + .../config/fixture/DeviceTokenFixture.java | 15 +- pennyway-batch/build.gradle | 11 +- .../pennyway/batch/common/dto/KeyValue.java | 7 + .../batch/job/LastMessageIdJobConfig.java | 63 + .../processor/LastMessageIdProcessor.java | 36 + .../batch/reader/LastMessageIdReader.java | 37 + .../scheduler/SpendingNotifyScheduler.java | 17 + .../batch/writer/LastMessageIdWriter.java | 44 + .../src/main/resources/application.yml | 12 +- .../batch/config/BatchDBTestConfig.java | 48 + .../BatchIntegrationProfileResolver.java | 12 + .../batch/config/BatchIntegrationTest.java | 16 + .../config/BatchIntegrationTestConfig.java | 22 + .../pennyway/batch/config/TestJpaConfig.java | 35 + .../LastMessageIdIntegrationTest.java | 221 ++++ .../batch/job/LastMessageIdJobBatchTest.java | 170 +++ .../src/test/resources/logback-test.xml | 7 + .../src/test/resources/sql/schema-mysql.sql | 98 ++ pennyway-common/Dockerfile | 6 + pennyway-domain/README.md | 126 +- pennyway-domain/build.gradle | 4 + pennyway-domain/domain-rdb/.gitignore | 44 + pennyway-domain/domain-rdb/build.gradle | 46 + .../co/pennyway/domain/DomainRdbLocation.java | 4 + .../AbstractLegacyEnumAttributeConverter.java | 48 + .../converter/AnnouncementConverter.java | 13 + .../converter/ChatMemberRoleConverter.java | 13 + .../common/converter/LegacyCommonType.java | 10 + .../common/converter/NoticeTypeConverter.java | 13 + .../converter/ProfileVisibilityConverter.java | 13 + .../common/converter/ProviderConverter.java | 13 + .../converter/QuestionCategoryConverter.java | 13 + .../common/converter/RoleConverter.java | 13 + .../converter/SpendingCategoryConverter.java | 13 + .../EnablePennywayRdbDomainConfig.java | 15 + .../importer/PennywayRdbDomainConfig.java | 7 + .../PennywayRdbDomainConfigGroup.java | 12 + ...PennywayRdbDomainConfigImportSelector.java | 24 + .../domain/common/model/DateAuditable.java | 24 + .../common/repository/ExtendedRepository.java | 10 + .../repository/ExtendedRepositoryFactory.java | 53 + .../repository/QueryDslSearchRepository.java | 183 +++ .../QueryDslSearchRepositoryImpl.java | 136 ++ .../common/repository/QueryHandler.java | 13 + .../util/LegacyEnumValueConvertUtil.java | 28 + .../domain/common/util/QueryDslUtil.java | 116 ++ .../domain/common/util/SliceUtil.java | 35 + .../co/pennyway/domain/config/JpaConfig.java | 16 + .../config/MySqlFunctionContributor.java | 20 + .../domain/config/QueryDslConfig.java | 27 + .../domain/domains/JpaPackageLocation.java | 4 + .../domains/chatroom/domain/ChatRoom.java | 111 ++ .../domains/chatroom/dto/ChatRoomDetail.java | 16 + .../chatroom/exception/ChatRoomErrorCode.java | 34 + .../exception/ChatRoomErrorException.java | 21 + .../repository/ChatRoomCustomRepository.java | 26 + .../ChatRoomCustomRepositoryImpl.java | 202 +++ .../repository/ChatRoomRepository.java | 7 + .../chatroom/service/ChatRoomRdbService.java | 51 + .../chatstatus/domain/ChatMessageStatus.java | 60 + .../ChatMessageStatusRepository.java | 27 + .../service/ChatMessageStatusRdbService.java | 22 + .../domains/device/domain/DeviceToken.java | 115 ++ .../exception/DeviceTokenErrorCode.java | 31 + .../exception/DeviceTokenErrorException.java | 20 + .../repository/DeviceTokenRepository.java | 26 + .../device/service/DeviceTokenRdbService.java | 66 + .../domains/member/domain/ChatMember.java | 149 +++ .../domains/member/dto/ChatMemberResult.java | 24 + .../member/exception/ChatMemberErrorCode.java | 37 + .../exception/ChatMemberErrorException.java | 21 + .../repository/ChatMemberRepository.java | 52 + .../CustomChatMemberRepository.java | 25 + .../CustomChatMemberRepositoryImpl.java | 78 ++ .../member/service/ChatMemberRdbService.java | 184 +++ .../domains/member/type/ChatMemberRole.java | 29 + .../notification/domain/Notification.java | 138 +++ .../NotificationCustomRepository.java | 36 + .../NotificationCustomRepositoryImpl.java | 93 ++ .../repository/NotificationRepository.java | 20 + .../service/NotificationRdbService.java | 66 + .../notification/type/Announcement.java | 70 ++ .../domains/notification/type/NoticeType.java | 32 + .../domain/domains/oauth/domain/Oauth.java | 81 ++ .../oauth/exception/OauthErrorCode.java | 43 + .../oauth/exception/OauthException.java | 22 + .../oauth/repository/OauthRepository.java | 28 + .../oauth/service/OauthRdbService.java | 66 + .../domain/domains/oauth/type/Provider.java | 36 + .../domains/question/domain/Question.java | 50 + .../question/domain/QuestionCategory.java | 19 + .../question/exception/QuestionErrorCode.java | 26 + .../exception/QuestionErrorException.java | 24 + .../repository/QuestionRepository.java | 7 + .../question/service/QuestionRdbService.java | 19 + .../domains/spending/domain/Spending.java | 108 ++ .../domain/SpendingCustomCategory.java | 67 + .../domains/spending/dto/CategoryInfo.java | 42 + .../spending/dto/TotalSpendingAmount.java | 28 + .../spending/exception/SpendingErrorCode.java | 38 + .../exception/SpendingErrorException.java | 22 + .../SpendingCustomCategoryRepository.java | 22 + .../repository/SpendingCustomRepository.java | 15 + .../SpendingCustomRepositoryImpl.java | 78 ++ .../repository/SpendingRepository.java | 59 + .../SpendingCustomCategoryRdbService.java | 48 + .../spending/service/SpendingRdbService.java | 182 +++ .../spending/type/SpendingCategory.java | 35 + .../domains/target/domain/TargetAmount.java | 72 ++ .../exception/TargetAmountErrorCode.java | 34 + .../exception/TargetAmountErrorException.java | 21 + .../TargetAmountCustomRepository.java | 12 + .../TargetAmountCustomRepositoryImpl.java | 49 + .../repository/TargetAmountRepository.java | 22 + .../service/TargetAmountRdbService.java | 60 + .../domains/user/domain/NotifySetting.java | 61 + .../domain/domains/user/domain/User.java | 126 ++ .../domains/user/exception/UserErrorCode.java | 50 + .../user/exception/UserErrorException.java | 21 + .../user/repository/UserRepository.java | 24 + .../domains/user/service/UserRdbService.java | 60 + .../domains/user/type/ProfileVisibility.java | 34 + .../domain/domains/user/type/Role.java | 42 + ...g.hibernate.boot.model.FunctionContributor | 1 + .../main/resources/application-domain-rdb.yml | 79 ++ .../common/fixture/ChatRoomFixture.java | 40 + .../domain/common/fixture/UserFixture.java | 66 + .../config/ContainerMySqlTestConfig.java | 33 + .../pennyway/domain/config/JpaTestConfig.java | 28 + .../NotificationRepositoryUnitTest.java | 190 +++ .../ReadNotificationsSliceUnitTest.java | 124 ++ .../oauth/repository/OauthRepositoryTest.java | 79 ++ .../RecentTargetAmountSearchTest.java | 124 ++ .../UserExtendedRepositoryTest.java | 297 +++++ .../user/repository/UserSoftDeleteTest.java | 124 ++ .../service/ChatMemberCreateServiceTest.java | 110 ++ .../service/ChatMemberNameSearchTest.java | 133 ++ .../src/test/resources/logback-test.xml | 7 + pennyway-domain/domain-redis/.gitignore | 42 + pennyway-domain/domain-redis/build.gradle | 23 + .../pennyway/domain/RedisPackageLocation.java | 4 + .../common/annotation/DistributedLock.java | 40 + .../annotation/DistributedLockPrefix.java | 8 + .../annotation/DomainRedisCacheManager.java | 13 + .../DomainRedisConnectionFactory.java | 13 + .../annotation/DomainRedisTemplate.java | 13 + .../domain/common/aop/CallTransaction.java | 7 + .../common/aop/CallTransactionFactory.java | 13 + .../common/aop/DistributedLockAspect.java | 56 + .../aop/RedissonCallNewTransaction.java | 19 + .../aop/RedissonCallSameTransaction.java | 19 + .../EnablePennywayRedisDomainConfig.java | 15 + .../importer/PennywayRedisDomainConfig.java | 7 + .../PennywayRedisDomainConfigGroup.java | 13 + ...nnywayRedisDomainConfigImportSelector.java | 24 + .../common/util/CustomSpringELParser.java | 30 + .../pennyway/domain/config/LettuceConfig.java | 88 ++ .../pennyway/domain/config/RedisConfig.java | 19 + .../domain/config/RedissonConfig.java | 58 + .../ChatMessageStatusCacheRepository.java | 20 + .../ChatMessageStatusCacheRepositoryImpl.java | 51 + .../ChatMessageStatusRedisService.java | 37 + .../forbidden/domain/ForbiddenToken.java | 42 + .../repository/ForbiddenTokenRepository.java | 7 + .../service/ForbiddenTokenRedisService.java | 45 + .../domains/message/domain/ChatMessage.java | 54 + .../message/domain/ChatMessageBuilder.java | 229 ++++ .../repository/ChatMessageRepository.java | 50 + .../repository/ChatMessageRepositoryImpl.java | 142 +++ .../service/ChatMessageRedisService.java | 33 + .../message/type/MessageCategoryType.java | 32 + .../message/type/MessageContentType.java | 33 + .../phone/repository/PhoneCodeRepository.java | 37 + .../phone/service/PhoneCodeRedisService.java | 67 + .../domains/phone/type/PhoneCodeKeyType.java | 18 + .../domains/refresh/domain/RefreshToken.java | 45 + .../RefreshTokenCustomRepository.java | 5 + .../RefreshTokenCustomRepositoryImpl.java | 23 + .../repository/RefreshTokenRepository.java | 7 + .../service/RefreshTokenRedisService.java | 32 + .../service/RefreshTokenRedisServiceImpl.java | 70 ++ .../domains/session/domain/UserSession.java | 173 +++ .../session/repository/SessionLuaScripts.java | 50 + .../repository/UserSessionRepository.java | 22 + .../repository/UserSessionRepositoryImpl.java | 112 ++ .../service/UserSessionRedisService.java | 83 ++ .../domains/session/type/UserStatus.java | 25 + .../resources/application-domain-redis.yml | 44 + .../config/ContainerRedisTestConfig.java | 30 + .../pennyway/config/RedisDataTestConfig.java | 15 + .../ChatMessageRepositoryImplTest.java | 382 ++++++ .../domains/phone/PhoneValidationDaoTest.java | 91 ++ .../RefreshTokenServiceIntegrationTest.java | 132 ++ .../UserSessionCustomRepositoryTest.java | 182 +++ .../src/test/resources/logback-test.xml | 7 + pennyway-domain/domain-service/.gitignore | 42 + pennyway-domain/domain-service/build.gradle | 14 + .../importer/EnablePennywayDomainConfig.java | 15 + .../common/importer/PennywayDomainConfig.java | 7 + .../importer/PennywayDomainConfigGroup.java | 13 + .../PennywayDomainConfigImportSelector.java | 24 + .../domain/config/RedissonDomainConfig.java | 9 + .../DeviceTokenRegisterCollection.java | 107 ++ .../service/DeviceTokenRegisterService.java | 51 + .../account/service/DeviceTokenService.java | 44 + .../service/ForbiddenTokenService.java | 24 + .../context/account/service/OauthService.java | 60 + .../account/service/PhoneCodeService.java | 32 + .../account/service/RefreshTokenService.java | 26 + .../context/account/service/UserService.java | 62 + .../account/service/UserSessionService.java | 46 + .../alter/service/NotificationService.java | 45 + .../collection/ChatMemberJoinOperation.java | 77 ++ .../ChatRoomAdminDelegateOperation.java | 24 + .../collection/ChatRoomLeaveCollection.java | 63 + .../chat/dto/ChatMemberBanCommand.java | 23 + .../chat/dto/ChatMemberJoinCommand.java | 13 + .../chat/dto/ChatMemberJoinResult.java | 19 + .../chat/dto/ChatPushNotificationContext.java | 13 + .../dto/ChatRoomAdminDelegateCommand.java | 25 + .../chat/dto/ChatRoomDeleteCommand.java | 19 + .../chat/dto/ChatRoomPatchCommand.java | 37 + .../chat/dto/ChatRoomToggleCommand.java | 19 + .../chat/service/ChatMemberBanService.java | 36 + .../chat/service/ChatMemberJoinService.java | 42 + .../chat/service/ChatMemberService.java | 94 ++ .../chat/service/ChatMessageService.java | 57 + .../service/ChatMessageStatusService.java | 43 + .../ChatNotificationCoordinatorService.java | 154 +++ .../service/ChatRoomAdminDelegateService.java | 34 + .../chat/service/ChatRoomDeleteService.java | 34 + .../chat/service/ChatRoomLeaveService.java | 36 + .../ChatRoomNotificationToggleService.java | 37 + .../chat/service/ChatRoomPatchService.java | 27 + .../context/chat/service/ChatRoomService.java | 41 + .../service/SpendingCategoryService.java | 48 + .../finance/service/SpendingService.java | 118 ++ .../finance/service/TargetAmountService.java | 59 + .../support/service/QuestionService.java | 20 + .../resources/application-domain-service.yml | 23 + ...mainServiceIntegrationProfileResolver.java | 12 + .../config/DomainServiceTestInfraConfig.java | 45 + .../pennyway/domain/config/JpaTestConfig.java | 27 + ...ceTokenRegisterServiceIntegrationTest.java | 176 +++ .../DeviceTokenRegisterServiceTest.java | 158 +++ .../ChatMemberJoinOperationTest.java | 130 ++ .../ChatRoomAdminDelegateOperationTest.java | 96 ++ .../ChatRoomLeaveCollectionTest.java | 76 ++ .../ChatMemberJoinServiceIntegrationTest.java | 137 +++ ...omAdminDelegateServiceIntegrationTest.java | 77 ++ .../ChatRoomDeleteServiceIntegrationTest.java | 151 +++ .../ChatRoomLeaveServiceIntegrationTest.java | 143 +++ ...ificationToggleServiceIntegrationTest.java | 91 ++ .../service/ChatMessageStatusServiceTest.java | 145 +++ ...otificationCoordinatorServiceUnitTest.java | 1093 +++++++++++++++++ .../common/fixture/ChatRoomFixture.java | 40 + .../context/common/fixture/UserFixture.java | 66 + .../src/test/resources/logback-test.xml | 7 + pennyway-infra/build.gradle | 8 + .../infra/client/aws/s3/ActualIdProvider.java | 76 ++ .../infra/client/aws/s3/AwsS3Provider.java | 61 +- .../infra/client/aws/s3/ObjectKeyType.java | 56 +- .../aws/s3/url/generator/UrlGenerator.java | 92 ++ .../s3/url/properties/BaseUrlProperty.java | 46 + .../properties/ChatProfileUrlProperty.java | 28 + .../ChatRoomProfileUrlProperty.java | 25 + .../s3/url/properties/ChatUrlProperty.java | 29 + .../s3/url/properties/FeedUrlProperty.java | 25 + .../url/properties/PresignedUrlProperty.java | 21 + .../PresignedUrlPropertyFactory.java | 26 + .../s3/url/properties/ProfileUrlProperty.java | 25 + .../client/broker/MessageBrokerAdapter.java | 41 + .../coordinator/CoordinatorService.java | 13 + .../DefaultCoordinatorService.java | 20 + .../infra/client/coordinator/WebSocket.java | 15 + .../infra/client/guid/IdGenerator.java | 10 + .../infra/client/guid/TsidGenerator.java | 20 + .../infra/common/event/ChatRoomJoinEvent.java | 10 + .../event/ChatRoomJoinEventHandler.java | 36 + .../event/FcmNotificationEventHandler.java | 2 +- .../infra/common/event/NotificationEvent.java | 34 +- .../common/event/SpendingChatShareEvent.java | 53 + .../event/SpendingChatShareEventHandler.java | 36 + .../common/exception/StorageErrorCode.java | 40 +- .../importer/PennywayInfraConfigGroup.java | 8 +- .../properties/ChatExchangeProperties.java | 23 + .../ChatJoinEventExchangeProperties.java | 21 + .../common/properties/RabbitMqProperties.java | 29 + .../SpendingChatShareExchangeProperties.java | 21 + .../common/util/JwtClaimsParserUtil.java | 35 + .../config/DistributedCoordinationConfig.java | 20 + .../co/pennyway/infra/config/GuidConfig.java | 13 + .../infra/config/MessageBrokerConfig.java | 175 +++ .../src/main/resources/application-infra.yml | 24 + .../s3/url/generator/UrlGeneratorTest.java | 186 +++ .../PresignedUrlPropertyFactoryTest.java | 92 ++ pennyway-socket-relay/.gitignore | 42 + pennyway-socket-relay/Dockerfile | 8 + pennyway-socket-relay/README.md | 31 + pennyway-socket-relay/build.gradle | 19 + .../PennywaySocketRelayApplication.java | 19 + .../src/main/resources/application.yml | 23 + pennyway-socket/.gitignore | 49 + pennyway-socket/Dockerfile | 8 + pennyway-socket/README.md | 37 + pennyway-socket/build.gradle | 48 + .../co/pennyway/PennywaySocketApplication.kt | 18 + .../socket/command/SendMessageCommand.java | 97 ++ .../common/annotation/PreAuthorize.java | 21 + .../socket/common/aop/PreAuthorizeAspect.kt | 91 ++ .../socket/common/aop/PreAuthorizer.kt | 147 +++ .../constants/StompNativeHeaderFields.java | 17 + .../constants/SystemMessageConstants.java | 9 + .../constants/SystemMessageTemplate.java | 25 + .../socket/common/dto/ChatMessageDto.java | 47 + .../socket/common/dto/ServerSideMessage.java | 23 + .../socket/common/dto/StatusMessage.java | 26 + .../socket/common/event/ReceiptEvent.java | 22 + .../common/event/ReceiptEventHandler.java | 47 + .../exception/InterceptorErrorCode.java | 54 + .../exception/InterceptorErrorException.java | 21 + .../common/exception/MessageErrorCode.java | 31 + .../exception/MessageErrorException.java | 21 + .../exception/PreAuthorizeErrorCode.java | 32 + .../exception/PreAuthorizeErrorException.java | 21 + .../WebSocketGlobalExceptionHandler.java | 82 ++ .../StompCommandHandlerFactory.java | 36 + .../StompExceptionInterceptor.java | 34 + .../interceptor/StompInboundInterceptor.java | 34 + .../AbstractStompExceptionHandler.java | 68 + .../AuthenticateExceptionHandler.java | 37 + .../exception/SubscribeExceptionHandler.java | 54 + .../inbound/ChatExchangeAuthorizeHandler.java | 65 + .../inbound/ConnectAuthenticateHandler.java | 109 ++ .../handler/inbound/DisconnectHandler.java | 31 + .../HeartBeatNegotiationInterceptor.java | 55 + .../marker/ConnectCommandHandler.java | 4 + .../marker/DisconnectCommandHandler.java | 4 + .../marker/StompCommandHandler.java | 20 + .../marker/StompExceptionHandler.java | 50 + .../marker/SubscribeCommandHandler.java | 4 + .../properties/ChatServerProperties.java | 23 + .../properties/MessageBrokerProperties.java | 37 + .../registry/ChatRoomAccessChecker.java | 39 + .../registry/ResourceAccessChecker.java | 17 + .../registry/ResourceAccessRegistry.java | 35 + .../security/authenticate/UserPrincipal.kt | 48 + .../common/security/jwt/AccessTokenClaim.java | 25 + .../security/jwt/AccessTokenClaimKeys.java | 16 + .../security/jwt/AccessTokenProvider.java | 93 ++ .../common/util/PreAuthorizeSpELParser.kt | 129 ++ .../socket/common/util/StompMessageUtil.kt | 46 + .../kr/co/pennyway/socket/common/util/log.kt | 5 + .../pennyway/socket/config/InfraConfig.java | 14 + .../pennyway/socket/config/JacksonConfig.kt | 18 + .../config/ResourceAccessRegistryConfig.java | 22 + .../config/WebSocketMessageBrokerConfig.java | 90 ++ .../socket/controller/AuthController.kt | 42 + .../controller/ChatMessageController.kt | 54 + .../socket/controller/StatusController.kt | 18 + .../socket/relay/ChatJoinEventListener.java | 39 + .../relay/ChatMessageRelayEventListener.java | 38 + .../relay/SpendingShareEventListener.kt | 71 ++ .../co/pennyway/socket/service/AuthService.kt | 49 + .../socket/service/ChatMessageRelayService.kt | 40 + .../socket/service/ChatMessageSendService.kt | 59 + .../service/LastMessageIdSaveService.kt | 11 + .../pennyway/socket/service/StatusService.kt | 36 + .../src/main/resources/application.yml | 63 + .../src/main/resources/logback-spring.xml | 166 +++ .../socket/common/aop/AuthorizationTest.kt | 154 +++ .../common/aop/PreAuthorizeAopBenchmark.kt | 337 +++++ .../src/test/resources/logback-test.xml | 7 + settings.gradle | 8 + 529 files changed, 24939 insertions(+), 669 deletions(-) create mode 100644 .github/workflows/deploy-socket-relay.yml create mode 100644 .github/workflows/deploy-socket.yml create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatMemberApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatRoomApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatRoomApiV2.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomControllerV2.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberReq.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberRes.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRes.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRoomReq.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRoomRes.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/helper/ChatMemberJoinHelper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMapper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMemberMapper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatRoomMapper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomPatchHelper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSaveService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomWithParticipantsSearchService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatSearchService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatMemberUseCase.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatRoomUseCase.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatUseCase.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingShareReq.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/helper/SpendingChatShareHelper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/api/SocketApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/controller/SocketController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/service/ChatServerSearchService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/usecase/SocketUseCase.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/adapter/PresignedUrlGenerateAdapter.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingShareTypeConverter.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/ApiErrorCode.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/ApiErrorException.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/CustomValidationException.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingShareType.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/SliceResponseTemplate.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/ChatRoomManager.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/PresignedUrlDtoReqValidator.java create mode 100644 pennyway-app-external-api/src/main/resources/logback-spring.xml create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberBathGetControllerTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomSaveControllerUnitTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberBatchGetIntegrationTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberJoinIntegrationTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatPaginationGetIntegrationTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatRoomCreateIntegrationTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatRoomDetailIntegrationTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomPatchHelperTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchServiceTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomWithParticipantsSearchServiceTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateServiceTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/ApiTestHelper.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/RequestParameters.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatMemberFixture.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatRoomFixture.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/KeyValue.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/job/LastMessageIdJobConfig.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/LastMessageIdProcessor.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/LastMessageIdReader.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/LastMessageIdWriter.java create mode 100644 pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchDBTestConfig.java create mode 100644 pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchIntegrationProfileResolver.java create mode 100644 pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchIntegrationTest.java create mode 100644 pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchIntegrationTestConfig.java create mode 100644 pennyway-batch/src/test/java/kr/co/pennyway/batch/config/TestJpaConfig.java create mode 100644 pennyway-batch/src/test/java/kr/co/pennyway/batch/integration/LastMessageIdIntegrationTest.java create mode 100644 pennyway-batch/src/test/java/kr/co/pennyway/batch/job/LastMessageIdJobBatchTest.java create mode 100644 pennyway-batch/src/test/resources/logback-test.xml create mode 100644 pennyway-batch/src/test/resources/sql/schema-mysql.sql create mode 100644 pennyway-common/Dockerfile create mode 100644 pennyway-domain/domain-rdb/.gitignore create mode 100644 pennyway-domain/domain-rdb/build.gradle create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/DomainRdbLocation.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/ChatMemberRoleConverter.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/QuestionCategoryConverter.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayRdbDomainConfig.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRdbDomainConfig.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRdbDomainConfigGroup.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRdbDomainConfigImportSelector.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/config/MySqlFunctionContributor.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/domain/ChatRoom.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/dto/ChatRoomDetail.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorCode.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorException.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/repository/ChatRoomCustomRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/repository/ChatRoomCustomRepositoryImpl.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/repository/ChatRoomRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/service/ChatRoomRdbService.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatstatus/domain/ChatMessageStatus.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusRdbService.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenRdbService.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/domain/ChatMember.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/dto/ChatMemberResult.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorCode.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorException.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/repository/ChatMemberRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepositoryImpl.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/service/ChatMemberRdbService.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/type/ChatMemberRole.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationRdbService.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthRdbService.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionRdbService.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryRdbService.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingRdbService.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountRdbService.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/service/UserRdbService.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java create mode 100644 pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java create mode 100644 pennyway-domain/domain-rdb/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor create mode 100644 pennyway-domain/domain-rdb/src/main/resources/application-domain-rdb.yml create mode 100644 pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/common/fixture/ChatRoomFixture.java create mode 100644 pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/common/fixture/UserFixture.java create mode 100644 pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java create mode 100644 pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/config/JpaTestConfig.java create mode 100644 pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java create mode 100644 pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java create mode 100644 pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java create mode 100644 pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java create mode 100644 pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java create mode 100644 pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java create mode 100644 pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/member/service/ChatMemberCreateServiceTest.java create mode 100644 pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/member/service/ChatMemberNameSearchTest.java create mode 100644 pennyway-domain/domain-rdb/src/test/resources/logback-test.xml create mode 100644 pennyway-domain/domain-redis/.gitignore create mode 100644 pennyway-domain/domain-redis/build.gradle create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/RedisPackageLocation.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DistributedLock.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DistributedLockPrefix.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayRedisDomainConfig.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRedisDomainConfig.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRedisDomainConfigGroup.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRedisDomainConfigImportSelector.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/config/LettuceConfig.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheRepository.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheRepositoryImpl.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusRedisService.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/forbidden/domain/ForbiddenToken.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/forbidden/repository/ForbiddenTokenRepository.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/forbidden/service/ForbiddenTokenRedisService.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/domain/ChatMessage.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/domain/ChatMessageBuilder.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/repository/ChatMessageRepository.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/repository/ChatMessageRepositoryImpl.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/service/ChatMessageRedisService.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/type/MessageCategoryType.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/type/MessageContentType.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/phone/repository/PhoneCodeRepository.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/phone/service/PhoneCodeRedisService.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/phone/type/PhoneCodeKeyType.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/domain/RefreshToken.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/repository/RefreshTokenCustomRepository.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/repository/RefreshTokenCustomRepositoryImpl.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/repository/RefreshTokenRepository.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/service/RefreshTokenRedisService.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/service/RefreshTokenRedisServiceImpl.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/domain/UserSession.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/repository/SessionLuaScripts.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/repository/UserSessionRepository.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/repository/UserSessionRepositoryImpl.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/service/UserSessionRedisService.java create mode 100644 pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/type/UserStatus.java create mode 100644 pennyway-domain/domain-redis/src/main/resources/application-domain-redis.yml create mode 100644 pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/config/ContainerRedisTestConfig.java create mode 100644 pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/config/RedisDataTestConfig.java create mode 100644 pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/message/ChatMessageRepositoryImplTest.java create mode 100644 pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/phone/PhoneValidationDaoTest.java create mode 100644 pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/refresh/RefreshTokenServiceIntegrationTest.java create mode 100644 pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/session/UserSessionCustomRepositoryTest.java create mode 100644 pennyway-domain/domain-redis/src/test/resources/logback-test.xml create mode 100644 pennyway-domain/domain-service/.gitignore create mode 100644 pennyway-domain/domain-service/build.gradle create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/config/RedissonDomainConfig.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/collection/DeviceTokenRegisterCollection.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/DeviceTokenRegisterService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/DeviceTokenService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/ForbiddenTokenService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/OauthService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/PhoneCodeService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/RefreshTokenService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/UserService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/UserSessionService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/alter/service/NotificationService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/collection/ChatMemberJoinOperation.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomAdminDelegateOperation.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomLeaveCollection.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatMemberBanCommand.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatMemberJoinCommand.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatMemberJoinResult.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatPushNotificationContext.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomAdminDelegateCommand.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomDeleteCommand.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomPatchCommand.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomToggleCommand.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMemberBanService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMemberJoinService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMemberService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMessageService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMessageStatusService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatNotificationCoordinatorService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomAdminDelegateService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomDeleteService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomLeaveService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomNotificationToggleService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomPatchService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/finance/service/SpendingCategoryService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/finance/service/SpendingService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/finance/service/TargetAmountService.java create mode 100644 pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/support/service/QuestionService.java create mode 100644 pennyway-domain/domain-service/src/main/resources/application-domain-service.yml create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/config/DomainServiceIntegrationProfileResolver.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/config/DomainServiceTestInfraConfig.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/config/JpaTestConfig.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/account/integration/DeviceTokenRegisterServiceIntegrationTest.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/account/service/DeviceTokenRegisterServiceTest.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/collection/ChatMemberJoinOperationTest.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomAdminDelegateOperationTest.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomLeaveCollectionTest.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatMemberJoinServiceIntegrationTest.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomAdminDelegateServiceIntegrationTest.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomDeleteServiceIntegrationTest.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomLeaveServiceIntegrationTest.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomNotificationToggleServiceIntegrationTest.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/service/ChatMessageStatusServiceTest.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/service/ChatNotificationCoordinatorServiceUnitTest.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/common/fixture/ChatRoomFixture.java create mode 100644 pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/common/fixture/UserFixture.java create mode 100644 pennyway-domain/domain-service/src/test/resources/logback-test.xml create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ActualIdProvider.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/generator/UrlGenerator.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/BaseUrlProperty.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ChatProfileUrlProperty.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ChatRoomProfileUrlProperty.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ChatUrlProperty.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/FeedUrlProperty.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/PresignedUrlProperty.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/PresignedUrlPropertyFactory.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ProfileUrlProperty.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/broker/MessageBrokerAdapter.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/coordinator/CoordinatorService.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/coordinator/DefaultCoordinatorService.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/coordinator/WebSocket.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/guid/IdGenerator.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/guid/TsidGenerator.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEvent.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEventHandler.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEvent.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEventHandler.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ChatExchangeProperties.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ChatJoinEventExchangeProperties.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/RabbitMqProperties.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/SpendingChatShareExchangeProperties.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/util/JwtClaimsParserUtil.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/config/DistributedCoordinationConfig.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/config/GuidConfig.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java create mode 100644 pennyway-infra/src/test/java/kr/co/infra/client/aws/s3/url/generator/UrlGeneratorTest.java create mode 100644 pennyway-infra/src/test/java/kr/co/infra/client/aws/s3/url/properties/PresignedUrlPropertyFactoryTest.java create mode 100644 pennyway-socket-relay/.gitignore create mode 100644 pennyway-socket-relay/Dockerfile create mode 100644 pennyway-socket-relay/README.md create mode 100644 pennyway-socket-relay/build.gradle create mode 100644 pennyway-socket-relay/src/main/java/kr/co/pennyway/PennywaySocketRelayApplication.java create mode 100644 pennyway-socket-relay/src/main/resources/application.yml create mode 100644 pennyway-socket/.gitignore create mode 100644 pennyway-socket/Dockerfile create mode 100644 pennyway-socket/README.md create mode 100644 pennyway-socket/build.gradle create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/PennywaySocketApplication.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/command/SendMessageCommand.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/annotation/PreAuthorize.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAspect.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizer.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/StompNativeHeaderFields.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageConstants.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageTemplate.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/dto/ChatMessageDto.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/dto/ServerSideMessage.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/dto/StatusMessage.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/event/ReceiptEvent.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/event/ReceiptEventHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/InterceptorErrorCode.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/InterceptorErrorException.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/MessageErrorCode.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/MessageErrorException.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/PreAuthorizeErrorCode.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/PreAuthorizeErrorException.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/WebSocketGlobalExceptionHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/StompCommandHandlerFactory.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/StompExceptionInterceptor.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/StompInboundInterceptor.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/exception/AbstractStompExceptionHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/exception/AuthenticateExceptionHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/exception/SubscribeExceptionHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/ChatExchangeAuthorizeHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/ConnectAuthenticateHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/DisconnectHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/HeartBeatNegotiationInterceptor.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/ConnectCommandHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/DisconnectCommandHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/StompCommandHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/StompExceptionHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/SubscribeCommandHandler.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/properties/ChatServerProperties.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/properties/MessageBrokerProperties.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/registry/ChatRoomAccessChecker.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/registry/ResourceAccessChecker.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/registry/ResourceAccessRegistry.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/authenticate/UserPrincipal.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/jwt/AccessTokenClaim.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/jwt/AccessTokenClaimKeys.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/jwt/AccessTokenProvider.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/PreAuthorizeSpELParser.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/StompMessageUtil.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/log.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/config/InfraConfig.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/config/JacksonConfig.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/config/ResourceAccessRegistryConfig.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/config/WebSocketMessageBrokerConfig.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/AuthController.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/ChatMessageController.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/StatusController.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/ChatJoinEventListener.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/ChatMessageRelayEventListener.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/SpendingShareEventListener.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/service/AuthService.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageRelayService.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/service/LastMessageIdSaveService.kt create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/service/StatusService.kt create mode 100644 pennyway-socket/src/main/resources/application.yml create mode 100644 pennyway-socket/src/main/resources/logback-spring.xml create mode 100644 pennyway-socket/src/test/java/kr/co/pennyway/socket/common/aop/AuthorizationTest.kt create mode 100644 pennyway-socket/src/test/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAopBenchmark.kt create mode 100644 pennyway-socket/src/test/resources/logback-test.xml diff --git a/.github/workflows/create-tag-and-release.yml b/.github/workflows/create-tag-and-release.yml index 0c7d48319..ea7f3049c 100644 --- a/.github/workflows/create-tag-and-release.yml +++ b/.github/workflows/create-tag-and-release.yml @@ -20,13 +20,13 @@ jobs: - name: Checkout PR uses: actions/checkout@v4 - # PR 제목으로 부터 모듈명 추출 (ex. Api, Batch, Admin, Socket) + # PR 제목으로 부터 모듈명 추출 (ex. Api, Batch, Admin, Socket, Relay, Ingore: 파이프라인 무시) - name: extract PR info id: module_prefix run: | PR_TITLE="${{ github.event.pull_request.title }}" echo "PR title : $PR_TITLE" - if [[ "$PR_TITLE" =~ ^(Api|Batch|Admin|Socket|Ignore): ]]; then + if [[ "$PR_TITLE" =~ ^(Api|Batch|Admin|Socket|Realy|Ignore): ]]; then PREFIX="${BASH_REMATCH[1]}" echo "Prefix: $PREFIX" echo "module=$PREFIX" >> $GITHUB_OUTPUT @@ -85,5 +85,21 @@ jobs: if: ${{ needs.extract-info.outputs.module == 'Batch' }} uses: ./.github/workflows/deploy-batch.yml secrets: inherit + with: + tags: ${{ needs.release.outputs.tag }} + + call-socket-deploy: + needs: [ extract-info, release ] + if: ${{ needs.extract-info.outputs.module == 'Socket' }} + uses: ./.github/workflows/deploy-socket.yml + secrets: inherit + with: + tags: ${{ needs.release.outputs.tag }} + + call-socket-relay-deploy: + needs: [ extract-info, release ] + if: ${{ needs.extract-info.outputs.module == 'Relay' }} + uses: ./.github/workflows/deploy-socket-relay.yml + secrets: inherit with: tags: ${{ needs.release.outputs.tag }} \ No newline at end of file diff --git a/.github/workflows/deploy-batch.yml b/.github/workflows/deploy-batch.yml index 8d58684ef..35ceacfe9 100644 --- a/.github/workflows/deploy-batch.yml +++ b/.github/workflows/deploy-batch.yml @@ -74,4 +74,4 @@ jobs: docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} docker system prune -a -f docker pull pennyway/pennyway-batch - docker-compose up -d \ No newline at end of file + docker-compose up -d batch \ No newline at end of file diff --git a/.github/workflows/deploy-socket-relay.yml b/.github/workflows/deploy-socket-relay.yml new file mode 100644 index 000000000..1c258cd51 --- /dev/null +++ b/.github/workflows/deploy-socket-relay.yml @@ -0,0 +1,77 @@ +name: Continuous Deployment - Socket Relay + +on: + workflow_call: + inputs: + tags: + description: '배포할 Relay 모듈 태그 정보 (Relay-v*.*.*)' + required: true + type: string + +permissions: + contents: read + +jobs: + deployment: + runs-on: ubuntu-20.04 + + steps: + # 1. Compare branch 코드 내려 받기 + - name: Checkout PR + uses: actions/checkout@v3 + with: + ref: ${{ github.event.push.base_ref }} + + # 2. 버전 정보 추출 (태그 정보에서 *.*.*만 추출) + - name: Get Version + id: get_version + run: | + RELEASE_VERSION_WITHOUT_V="$(cut -d'v' -f2 <<< ${{ inputs.tags }})" + echo "VERSION=$RELEASE_VERSION_WITHOUT_V" >> $GITHUB_OUTPUT + + # 3. 자바 환경 설정 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + # 4. FCM Admin SDK 파일 생성 + - name: Create Json + uses: jsdaniell/create-json@v1.2.2 + with: + name: ${{ secrets.FIREBASE_ADMIN_SDK_FILE }} + json: ${{ secrets.FIREBASE_ADMIN_SDK }} + dir: ${{ secrets.FIREBASE_ADMIN_SDK_DIR }} + + # 5. Build Gradle + - name: Build Gradle + run: | + chmod +x ./gradlew + ./gradlew :pennyway-socket-relay:build --parallel --stacktrace --info -x test + shell: bash + + # 6. Docker 이미지 build 및 push + - name: docker build and push + run: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker build -t pennyway/pennyway-socket-relay:${{ steps.get_version.outputs.VERSION }} ./pennyway-socket-relay + docker build -t pennyway/pennyway-socket-relay:latest ./pennyway-socket-relay + docker push pennyway/pennyway-socket-relay:${{ steps.get_version.outputs.VERSION }} + docker push pennyway/pennyway-socket-relay:latest + + # 7. AWS SSM을 통한 Run-Command (Docker 이미지 pull 후 docker-compose를 통한 실행) + - name: AWS SSM Send-Command + uses: peterkimzz/aws-ssm-send-command@master + id: ssm + with: + aws-region: ${{ secrets.AWS_REGION }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + instance-ids: ${{ secrets.AWS_DEV_INSTANCE_ID }} + working-directory: /home/ubuntu + command: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker system prune -a -f + docker pull pennyway/pennyway-socket-relay + docker-compose up -d socket-relay \ No newline at end of file diff --git a/.github/workflows/deploy-socket.yml b/.github/workflows/deploy-socket.yml new file mode 100644 index 000000000..bce395a81 --- /dev/null +++ b/.github/workflows/deploy-socket.yml @@ -0,0 +1,77 @@ +name: Continuous Deployment - Socket + +on: + workflow_call: + inputs: + tags: + description: '배포할 Socket 모듈 태그 정보 (Socket-v*.*.*)' + required: true + type: string + +permissions: + contents: read + +jobs: + deployment: + runs-on: ubuntu-20.04 + + steps: + # 1. Compare branch 코드 내려 받기 + - name: Checkout PR + uses: actions/checkout@v3 + with: + ref: ${{ github.event.push.base_ref }} + + # 2. 버전 정보 추출 (태그 정보에서 *.*.*만 추출) + - name: Get Version + id: get_version + run: | + RELEASE_VERSION_WITHOUT_V="$(cut -d'v' -f2 <<< ${{ inputs.tags }})" + echo "VERSION=$RELEASE_VERSION_WITHOUT_V" >> $GITHUB_OUTPUT + + # 3. 자바 환경 설정 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + # 4. FCM Admin SDK 파일 생성 + - name: Create Json + uses: jsdaniell/create-json@v1.2.2 + with: + name: ${{ secrets.FIREBASE_ADMIN_SDK_FILE }} + json: ${{ secrets.FIREBASE_ADMIN_SDK }} + dir: ${{ secrets.FIREBASE_ADMIN_SDK_DIR }} + + # 5. Build Gradle + - name: Build Gradle + run: | + chmod +x ./gradlew + ./gradlew :pennyway-socket:build --parallel --stacktrace --info -x test + shell: bash + + # 6. Docker 이미지 build 및 push + - name: docker build and push + run: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker build -t pennyway/pennyway-socket:${{ steps.get_version.outputs.VERSION }} ./pennyway-socket + docker build -t pennyway/pennyway-socket:latest ./pennyway-socket + docker push pennyway/pennyway-socket:${{ steps.get_version.outputs.VERSION }} + docker push pennyway/pennyway-socket:latest + + # 7. AWS SSM을 통한 Run-Command (Docker 이미지 pull 후 docker-compose를 통한 실행) + - name: AWS SSM Send-Command + uses: peterkimzz/aws-ssm-send-command@master + id: ssm + with: + aws-region: ${{ secrets.AWS_REGION }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + instance-ids: ${{ secrets.AWS_DEV_INSTANCE_ID }} + working-directory: /home/ubuntu + command: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker system prune -a -f + docker pull pennyway/pennyway-socket + docker-compose up -d socket \ No newline at end of file diff --git a/README.md b/README.md index d65281acf..8c0ab0cd8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ | v0.0.1 | 2024.03.07 | 프로젝트 기본 설명 작성 | 양재서 | | v0.0.2 | 2024.03.29 | ERD 추가, 라이브러리 버전 수정, Infra 추가 | 양재서 | | v0.0.3 | 2024.04.05 | ERD 수정, 기술 스택 추가, Infra 및 아키텍처 추가/수정 | 양재서 | +| v0.0.4 | 2024.11.27 | 멀티 모듈 아키텍처 수정, 인프라 아키텍처 수정, 기술 스택 추가 | 양재서 |
@@ -86,13 +87,13 @@ ### 2️⃣ Infrastructure Architecture
- +
### 3️⃣ Multi Module Architecture
- +
### 4️⃣ ERD @@ -121,6 +122,8 @@ - jjwt 0.12.5 - httpclient5 5.2.25.RELEASE - OpenFeign 4.0.6 +- Spring Boot Starter WebSocket 3.3.4 +- Spring Boot Starter Batch 3.3.0 ### 2️⃣ Build Tools @@ -140,6 +143,8 @@ - AWS VPC - AWS Elastic Load Balancer - AWS SNS +- AWS RDS - Docker & Docker-compose - Ngnix - GitHub Actions +- RabbitMQ diff --git a/build.gradle b/build.gradle index 1ccc1e45d..635ef125d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ buildscript { repositories { mavenCentral() + gradlePluginPortal() } } @@ -30,6 +31,7 @@ subprojects { repositories { mavenCentral() + gradlePluginPortal() } configurations { diff --git a/pennyway-app-external-api/.gitignore b/pennyway-app-external-api/.gitignore index f81dcf20a..655d28f69 100644 --- a/pennyway-app-external-api/.gitignore +++ b/pennyway-app-external-api/.gitignore @@ -42,4 +42,7 @@ bin/ .DS_Store ## Test API -src/main/java/kr/co/pennyway/api/apis/test \ No newline at end of file +src/main/java/kr/co/pennyway/api/apis/test + +## Log Files +**/logs \ No newline at end of file diff --git a/pennyway-app-external-api/README.md b/pennyway-app-external-api/README.md index aa1257d2d..08cc396fb 100644 --- a/pennyway-app-external-api/README.md +++ b/pennyway-app-external-api/README.md @@ -1,11 +1,17 @@ -## External-API 모듈 +## 📱 External-API 모듈 -### 🤝 Rule +### 🎯 핵심 역할 -- batch, worker, internal-api, external-api 등의 모듈과 묶일 수 있다. -- 사용성에 따라 다른 모든 계층에 의존성을 추가하여 사용할 수 있다. -- 웹 및 security 관련 라이브러리 의존성을 갖는다. -- Presentation Layer에 해당하는 Controller와 핵심 비즈니스 로직을 처리하는 Usecase를 포함한다. +- 외부 클라이언트와의 통신 담당 +- RESTful API 엔드포인트 제공 +- 인증/인가 처리 +- 비즈니스 유스케이스 조율 + +### 🔗 의존성 규칙 + +- domain-service 모듈 의존 +- 필요에 따라 infra/redis/rdb 모듈 직접 사용 가능 +- Spring Web, Security 의존성 포함 ### 📌 Architecture @@ -14,17 +20,23 @@ - Facade 패턴을 사용하여 Controller와 Service 계층을 분리하여 단위 테스트를 용이하게 한다. +- Controller -> UseCase -> Domain Service 흐름으로 진행한다. + 1. HTTP 요청/응답 처리 (Controller) + 2. 비즈니스 흐름 조율 (UseCase) + 3. 도메인 로직을 호출하여, 인프라 통합 서비스 비즈니스 구현 (Domain Service) + - 기능이 너무 단순하면 없을 수도 있다. ### 🏷️ Directory Structure -```agsl -pennyway-common +``` +pennyway-app-external-api ├── src │ ├── main │ │ ├── java.kr.co.pennyway │ │ │ ├── api │ │ │ │ ├── apis │ │ │ │ │ ├── auth # 기능 관심사 별로 패키지를 나누어 구성한다. +│ │ │ │ │ │ ├── api │ │ │ │ │ │ ├── controller │ │ │ │ │ │ ├── dto │ │ │ │ │ │ ├── usecase @@ -38,5 +50,5 @@ pennyway-common │ └── test ├── build.gradle ├── README.md -└── settings.gradle +└── Dockerfile ``` \ No newline at end of file diff --git a/pennyway-app-external-api/build.gradle b/pennyway-app-external-api/build.gradle index 0625ae69e..28c0591cc 100644 --- a/pennyway-app-external-api/build.gradle +++ b/pennyway-app-external-api/build.gradle @@ -14,7 +14,9 @@ repositories { dependencies { implementation project(':pennyway-common') - implementation project(':pennyway-domain') + implementation project(':pennyway-domain:domain-service') + implementation project(':pennyway-domain:domain-rdb') + implementation project(':pennyway-domain:domain-redis') implementation project(':pennyway-infra') /* Security */ diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java index 788aa335e..2c39ff719 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java @@ -11,7 +11,10 @@ public record General( String username, @Schema(description = "비밀번호", example = "pennyway1234") @NotBlank(message = "비밀번호를 입력해주세요") - String password + String password, + @Schema(description = "사용자 기기 고유 식별자", example = "AA-BBB-CCC") + @NotBlank(message = "사용자 기기 고유 식별자를 입력해주세요") + String deviceId ) { } @@ -25,7 +28,10 @@ public record Oauth( String idToken, @Schema(description = "OIDC nonce") @NotBlank(message = "OIDC nonce는 필수 입력값입니다.") - String nonce + String nonce, + @Schema(description = "사용자 기기 고유 식별자", example = "AA-BBB-CCC") + @NotBlank(message = "사용자 기기 고유 식별자를 입력해주세요") + String deviceId ) { } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java index 3ac9a7e7f..cff22e19d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -18,7 +18,7 @@ * 일반 회원가입 시엔 General, 소셜 회원가입 시엔 Oauth를 사용합니다. */ public class SignUpReq { - public record Info(String username, String name, String password, String phone, String code) { + public record Info(String username, String name, String password, String phone, String code, String deviceId) { public String password(PasswordEncoder passwordEncoder) { return passwordEncoder.encode(password); } @@ -43,7 +43,7 @@ public String password() { } public record OauthInfo(String oauthId, String idToken, String nonce, String name, String username, String phone, - String code) { + String code, String deviceId) { public User toUser() { return User.builder() .username(username) @@ -77,10 +77,13 @@ public record General( @Schema(description = "6자리 정수 인증번호", example = "123456") @NotBlank(message = "인증번호는 필수입니다.") @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") - String code + String code, + @Schema(description = "사용자 기기 고유 식별자", example = "AA-BBB-CCC") + @NotBlank(message = "사용자 기기 고유 식별자를 입력해주세요") + String deviceId ) { public Info toInfo() { - return new Info(username, name, password, phone, code); + return new Info(username, name, password, phone, code, deviceId); } } @@ -97,10 +100,13 @@ public record SyncWithOauth( @Schema(description = "6자리 정수 인증번호", example = "123456") @NotBlank(message = "인증번호는 필수입니다.") @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") - String code + String code, + @Schema(description = "사용자 기기 고유 식별자", example = "AA-BBB-CCC") + @NotBlank(message = "사용자 기기 고유 식별자를 입력해주세요") + String deviceId ) { public Info toInfo() { - return new Info(null, null, password, phone, code); + return new Info(null, null, password, phone, code, deviceId); } } @@ -130,10 +136,13 @@ public record Oauth( @Schema(description = "6자리 정수 인증번호", example = "123456") @NotBlank(message = "인증번호는 필수입니다.") @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") - String code + String code, + @Schema(description = "사용자 기기 고유 식별자", example = "AA-BBB-CCC") + @NotBlank(message = "사용자 기기 고유 식별자를 입력해주세요") + String deviceId ) { public OauthInfo toOauthInfo() { - return new OauthInfo(oauthId, idToken, nonce, name, username, phone, code); + return new OauthInfo(oauthId, idToken, nonce, name, username, phone, code, deviceId); } } @@ -155,10 +164,13 @@ public record SyncWithAuth( @Schema(description = "6자리 정수 인증번호", example = "123456") @NotBlank(message = "인증번호는 필수입니다.") @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") - String code + String code, + @Schema(description = "사용자 기기 고유 식별자", example = "AA-BBB-CCC") + @NotBlank(message = "사용자 기기 고유 식별자를 입력해주세요") + String deviceId ) { public OauthInfo toOauthInfo() { - return new OauthInfo(oauthId, idToken, nonce, null, null, phone, code); + return new OauthInfo(oauthId, idToken, nonce, null, null, phone, code, deviceId); } } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java index 52466c823..1090cd4f0 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java @@ -2,20 +2,20 @@ import kr.co.pennyway.api.common.annotation.AccessTokenStrategy; import kr.co.pennyway.api.common.annotation.RefreshTokenStrategy; -import kr.co.pennyway.api.common.security.jwt.JwtClaimsParserUtil; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim; import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys; import kr.co.pennyway.common.annotation.Helper; -import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; -import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; -import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; +import kr.co.pennyway.domain.context.account.service.ForbiddenTokenService; +import kr.co.pennyway.domain.context.account.service.RefreshTokenService; +import kr.co.pennyway.domain.domains.refresh.domain.RefreshToken; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.infra.common.exception.JwtErrorCode; import kr.co.pennyway.infra.common.exception.JwtErrorException; import kr.co.pennyway.infra.common.jwt.JwtClaims; import kr.co.pennyway.infra.common.jwt.JwtProvider; +import kr.co.pennyway.infra.common.util.JwtClaimsParserUtil; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; @@ -46,14 +46,15 @@ public JwtAuthHelper( * 사용자 정보 기반으로 access token과 refresh token을 생성하는 메서드
* refresh token은 redis에 저장된다. * - * @param user {@link User} + * @param user {@link User} : 사용자 정보 + * @param deviceId String : 사용자의 디바이스 고유 식별자 * @return {@link Jwts} */ - public Jwts createToken(User user) { + public Jwts createToken(User user, String deviceId) { String accessToken = accessTokenProvider.generateToken(AccessTokenClaim.of(user.getId(), user.getRole().getType())); - String refreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(user.getId(), user.getRole().getType())); + String refreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(user.getId(), deviceId, user.getRole().getType())); - refreshTokenService.save(RefreshToken.of(user.getId(), refreshToken, toSeconds(refreshTokenProvider.getExpiryDate(refreshToken)))); + refreshTokenService.create(RefreshToken.of(user.getId(), deviceId, refreshToken, toSeconds(refreshTokenProvider.getExpiryDate(refreshToken)))); return Jwts.of(accessToken, refreshToken); } @@ -62,11 +63,12 @@ public Pair refresh(String refreshToken) { Long userId = JwtClaimsParserUtil.getClaimsValue(claims, RefreshTokenClaimKeys.USER_ID.getValue(), Long::parseLong); String role = JwtClaimsParserUtil.getClaimsValue(claims, RefreshTokenClaimKeys.ROLE.getValue(), String.class); - log.debug("refresh token userId : {}, role : {}", userId, role); + String deviceId = JwtClaimsParserUtil.getClaimsValue(claims, RefreshTokenClaimKeys.DEVICE_ID.getValue(), String.class); + log.debug("refresh token userId : {}, deviceId: {}, role : {}", userId, deviceId, role); RefreshToken newRefreshToken; try { - newRefreshToken = refreshTokenService.refresh(userId, refreshToken, refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, role))); + newRefreshToken = refreshTokenService.refresh(userId, deviceId, refreshToken, refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, deviceId, role))); log.debug("new refresh token : {}", newRefreshToken.getToken()); } catch (IllegalArgumentException e) { throw new JwtErrorException(JwtErrorCode.EXPIRED_TOKEN); @@ -102,22 +104,23 @@ public void removeAccessTokenAndRefreshToken(Long userId, String accessToken, St } if (jwtClaims != null) { - deleteRefreshToken(userId, jwtClaims, refreshToken); + deleteRefreshToken(userId, jwtClaims); } deleteAccessToken(userId, accessToken); } - private void deleteRefreshToken(Long userId, JwtClaims jwtClaims, String refreshToken) { - Long refreshTokenUserId = Long.parseLong((String) jwtClaims.getClaims().get(RefreshTokenClaimKeys.USER_ID.getValue())); - log.info("로그아웃 요청 refresh token id : {}", refreshTokenUserId); + private void deleteRefreshToken(Long userId, JwtClaims jwtClaims) { + Long refreshTokenUserId = JwtClaimsParserUtil.getClaimsValue(jwtClaims, RefreshTokenClaimKeys.USER_ID.getValue(), Long::parseLong); + String refreshTokenDeviceId = JwtClaimsParserUtil.getClaimsValue(jwtClaims, RefreshTokenClaimKeys.DEVICE_ID.getValue(), String.class); + log.info("로그아웃 요청 refresh token userId : {}, deviceId : {}", refreshTokenUserId, refreshTokenDeviceId); if (!userId.equals(refreshTokenUserId)) { throw new JwtErrorException(JwtErrorCode.WITHOUT_OWNERSHIP_REFRESH_TOKEN); } try { - refreshTokenService.delete(refreshTokenUserId, refreshToken); + refreshTokenService.deleteAll(refreshTokenUserId); } catch (IllegalArgumentException e) { log.warn("refresh token not found. id : {}", userId); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/AuthFindService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/AuthFindService.java index e50c720f8..049404968 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/AuthFindService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/AuthFindService.java @@ -2,10 +2,10 @@ import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; +import kr.co.pennyway.domain.context.account.service.UserService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/PhoneVerificationService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/PhoneVerificationService.java index 52172b461..8b1c4845e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/PhoneVerificationService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/PhoneVerificationService.java @@ -1,71 +1,109 @@ package kr.co.pennyway.api.apis.auth.service; -import java.time.LocalDateTime; -import java.util.concurrent.ThreadLocalRandom; - -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; - import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; import kr.co.pennyway.api.common.exception.PhoneVerificationException; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; +import kr.co.pennyway.domain.context.account.service.PhoneCodeService; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; import kr.co.pennyway.infra.common.event.PushCodeEvent; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.concurrent.ThreadLocalRandom; @Slf4j @Service -@RequiredArgsConstructor public class PhoneVerificationService { - private final PhoneCodeService phoneCodeService; - private final ApplicationEventPublisher eventPublisher; - - /** - * 휴대폰 번호로 인증 코드를 발송하고 캐싱한다. (5분간 유효) - * - * @param request {@link PhoneVerificationDto.PushCodeReq} - * @param codeType {@link PhoneCodeKeyType} - * @return {@link PhoneVerificationDto.PushCodeRes} - */ - public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request, PhoneCodeKeyType codeType) { - String code = issueVerificationCode(); - LocalDateTime expiresAt = phoneCodeService.create(request.phone(), code, codeType); - - eventPublisher.publishEvent(PushCodeEvent.of(request.phone(), code)); - - return PhoneVerificationDto.PushCodeRes.of(request.phone(), LocalDateTime.now(), expiresAt); - } - - /** - * 휴대폰 번호로 인증 코드를 확인한다. - * - * @param request {@link PhoneVerificationDto.VerifyCodeReq} - * @param codeType {@link PhoneCodeKeyType} - * @return Boolean : 인증 코드가 유효한지 여부 (TRUE: 유효, 실패하는 경우 예외가 발생하므로 FALSE가 반환되지 않음) - * @throws PhoneVerificationException : 전화번호가 만료되었거나 유효하지 않은 경우(EXPIRED_OR_INVALID_PHONE), 인증 코드가 유효하지 않은 경우(IS_NOT_VALID_CODE) - */ - public Boolean isValidCode(PhoneVerificationDto.VerifyCodeReq request, PhoneCodeKeyType codeType) throws IllegalArgumentException { - String expectedCode; - try { - expectedCode = phoneCodeService.readByPhone(request.phone(), codeType); - } catch (IllegalArgumentException e) { - throw new PhoneVerificationException(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE); - } - - if (!expectedCode.equals(request.code())) - throw new PhoneVerificationException(PhoneVerificationErrorCode.IS_NOT_VALID_CODE); - - return Boolean.TRUE; - } - - private String issueVerificationCode() { - StringBuilder sb = new StringBuilder(); - - for (int i = 0; i < 6; i++) { - sb.append(ThreadLocalRandom.current().nextInt(0, 10)); - } - return sb.toString(); - } + private final PhoneCodeService phoneCodeService; + private final ApplicationEventPublisher eventPublisher; + + private final String adminPhone; + private final String adminCode; + + public PhoneVerificationService( + PhoneCodeService phoneCodeService, + ApplicationEventPublisher eventPublisher, + @Value("${pennyway.admin.phone}") String adminPhone, + @Value("${pennyway.admin.password}") String adminCode + ) { + this.phoneCodeService = phoneCodeService; + this.eventPublisher = eventPublisher; + this.adminPhone = adminPhone; + this.adminCode = adminCode; + } + + /** + * 휴대폰 번호로 인증 코드를 발송하고 캐싱한다. (5분간 유효) + * + * @param request {@link PhoneVerificationDto.PushCodeReq} + * @param codeType {@link PhoneCodeKeyType} + * @return {@link PhoneVerificationDto.PushCodeRes} + */ + public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request, PhoneCodeKeyType codeType) { + String code = issueVerificationCode(); + LocalDateTime expiresAt = phoneCodeService.create(request.phone(), code, codeType); + + eventPublisher.publishEvent(PushCodeEvent.of(request.phone(), code)); + + return PhoneVerificationDto.PushCodeRes.of(request.phone(), LocalDateTime.now(), expiresAt); + } + + /** + * 휴대폰 번호로 인증 코드를 확인한다. + * + * @param request {@link PhoneVerificationDto.VerifyCodeReq} + * @param codeType {@link PhoneCodeKeyType} + * @return Boolean : 인증 코드가 유효한지 여부 (TRUE: 유효, 실패하는 경우 예외가 발생하므로 FALSE가 반환되지 않음) + * @throws PhoneVerificationException : 전화번호가 만료되었거나 유효하지 않은 경우(EXPIRED_OR_INVALID_PHONE), 인증 코드가 유효하지 않은 경우(IS_NOT_VALID_CODE) + */ + public Boolean isValidCode(PhoneVerificationDto.VerifyCodeReq request, PhoneCodeKeyType codeType) throws IllegalArgumentException { + String expectedCode; + + if (byPassVerificationCode(request.phone(), request.code(), codeType)) { + return Boolean.TRUE; + } + + try { + expectedCode = phoneCodeService.readByPhone(request.phone(), codeType); + } catch (IllegalArgumentException e) { + throw new PhoneVerificationException(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE); + } + + if (!expectedCode.equals(request.code())) + throw new PhoneVerificationException(PhoneVerificationErrorCode.IS_NOT_VALID_CODE); + + return Boolean.TRUE; + } + + private String issueVerificationCode() { + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < 6; i++) { + sb.append(ThreadLocalRandom.current().nextInt(0, 10)); + } + return sb.toString(); + } + + private boolean byPassVerificationCode(String phone, String code, PhoneCodeKeyType codeType) { + if (codeType.equals(PhoneCodeKeyType.FIND_PASSWORD) || codeType.equals(PhoneCodeKeyType.FIND_USERNAME)) { + return Boolean.FALSE; + } + + if (isAdminPhone(phone)) { + if (!adminCode.equals(code)) + throw new PhoneVerificationException(PhoneVerificationErrorCode.IS_NOT_VALID_CODE); + log.info("관리자 전화번호로 인증되었습니다."); + + return Boolean.TRUE; + } + + return Boolean.FALSE; + } + + private boolean isAdminPhone(String phone) { + return adminPhone.equals(phone); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java index 75bf2cfee..ee5b92e8a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java @@ -2,10 +2,10 @@ import kr.co.pennyway.api.apis.auth.dto.SignUpReq; import kr.co.pennyway.api.apis.auth.dto.UserSyncDto; +import kr.co.pennyway.domain.context.account.service.UserService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java index db804abd5..bb7eae525 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java @@ -2,15 +2,15 @@ import kr.co.pennyway.api.apis.auth.dto.SignUpReq; import kr.co.pennyway.api.apis.auth.dto.UserSyncDto; +import kr.co.pennyway.domain.context.account.service.OauthService; +import kr.co.pennyway.domain.context.account.service.UserService; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; import kr.co.pennyway.domain.domains.oauth.exception.OauthException; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.oauth.type.Provider; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java index a0ca8c741..969ce5da5 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java @@ -5,9 +5,9 @@ import kr.co.pennyway.api.apis.auth.service.AuthFindService; import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; import kr.co.pennyway.common.annotation.UseCase; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; -import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.context.account.service.PhoneCodeService; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java index 6dc094a9e..0945c078c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java @@ -9,8 +9,8 @@ import kr.co.pennyway.api.apis.auth.service.UserGeneralSignService; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.common.annotation.UseCase; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; +import kr.co.pennyway.domain.context.account.service.PhoneCodeService; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -46,14 +46,14 @@ public Pair signUp(SignUpReq.Info request) { UserSyncDto userSync = checkOauthUserNotGeneralSignUp(request.phone()); User user = userGeneralSignService.saveUserWithEncryptedPassword(request, userSync); - return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); + return Pair.of(user.getId(), jwtAuthHelper.createToken(user, request.deviceId())); } @Transactional(readOnly = true) public Pair signIn(SignInReq.General request) { User user = userGeneralSignService.readUserIfValid(request.username(), request.password()); - return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); + return Pair.of(user.getId(), jwtAuthHelper.createToken(user, request.deviceId())); } public Pair refresh(String refreshToken) { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java index d71b9939e..3bbf1b45d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java @@ -10,11 +10,11 @@ import kr.co.pennyway.api.apis.auth.service.UserOauthSignService; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.common.annotation.UseCase; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; +import kr.co.pennyway.domain.context.account.service.PhoneCodeService; import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; import kr.co.pennyway.domain.domains.oauth.exception.OauthException; import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; import lombok.RequiredArgsConstructor; @@ -39,23 +39,27 @@ public Pair signIn(Provider provider, SignInReq.Oauth request) { User user = userOauthSignService.readUser(request.oauthId(), provider); - return (user != null) ? Pair.of(user.getId(), jwtAuthHelper.createToken(user)) : Pair.of(-1L, null); + return (user != null) ? Pair.of(user.getId(), jwtAuthHelper.createToken(user, request.deviceId())) : Pair.of(-1L, null); } @Transactional(readOnly = true) public PhoneVerificationDto.VerifyCodeRes verifyCode(Provider provider, PhoneVerificationDto.VerifyCodeReq request) { - Boolean isValidCode = phoneVerificationService.isValidCode(request, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + PhoneCodeKeyType type = getOauthSignUpTypeByProvider(provider); + + Boolean isValidCode = phoneVerificationService.isValidCode(request, type); UserSyncDto userSync = checkSignUpUserNotOauthByProvider(provider, request.phone()); - phoneCodeService.extendTimeToLeave(request.phone(), PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.extendTimeToLeave(request.phone(), type); return PhoneVerificationDto.VerifyCodeRes.valueOfOauth(isValidCode, userSync.isExistAccount(), userSync.username()); } @Transactional public Pair signUp(Provider provider, SignUpReq.OauthInfo request) { - phoneVerificationService.isValidCode(PhoneVerificationDto.VerifyCodeReq.from(request), PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - phoneCodeService.delete(request.phone(), PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + PhoneCodeKeyType type = getOauthSignUpTypeByProvider(provider); + + phoneVerificationService.isValidCode(PhoneVerificationDto.VerifyCodeReq.from(request), type); + phoneCodeService.delete(request.phone(), type); UserSyncDto userSync = checkSignUpUserNotOauthByProvider(provider, request.phone()); @@ -67,7 +71,7 @@ public Pair signUp(Provider provider, SignUpReq.OauthInfo request) { OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.oauthId(), request.idToken(), request.nonce()); User user = userOauthSignService.saveUser(request, userSync, provider, payload.sub()); - return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); + return Pair.of(user.getId(), jwtAuthHelper.createToken(user, request.deviceId())); } /** @@ -77,7 +81,7 @@ private UserSyncDto checkSignUpUserNotOauthByProvider(Provider provider, String UserSyncDto userSync = userOauthSignService.isSignUpAllowed(provider, phone); if (!userSync.isSignUpAllowed()) { - phoneCodeService.delete(phone, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.delete(phone, getOauthSignUpTypeByProvider(provider)); throw new OauthException(OauthErrorCode.ALREADY_SIGNUP_OAUTH); } @@ -97,4 +101,13 @@ private boolean isValidRequestScenario(UserSyncDto userSync, SignUpReq.OauthInfo private boolean isOauthSyncRequest(SignUpReq.OauthInfo request) { return request.username() != null; } + + private PhoneCodeKeyType getOauthSignUpTypeByProvider(Provider provider) { + return switch (provider) { + case KAKAO -> PhoneCodeKeyType.OAUTH_SIGN_UP_KAKAO; + case GOOGLE -> PhoneCodeKeyType.OAUTH_SIGN_UP_GOOGLE; + case APPLE -> PhoneCodeKeyType.OAUTH_SIGN_UP_APPLE; + default -> throw new IllegalArgumentException("Unexpected value: " + provider); + }; + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java index c0798d517..0c7d3d4db 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java @@ -6,15 +6,15 @@ import kr.co.pennyway.api.apis.auth.helper.JwtAuthHelper; import kr.co.pennyway.api.apis.auth.helper.OauthOidcHelper; import kr.co.pennyway.api.apis.auth.service.UserOauthSignService; -import kr.co.pennyway.api.common.security.jwt.JwtClaimsParserUtil; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys; import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.context.account.service.OauthService; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.oauth.type.Provider; import kr.co.pennyway.infra.common.jwt.JwtClaims; import kr.co.pennyway.infra.common.jwt.JwtProvider; import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; +import kr.co.pennyway.infra.common.util.JwtClaimsParserUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatApi.java new file mode 100644 index 000000000..07b6da16b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatApi.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.api.apis.chat.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.common.response.SliceResponseTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "[채팅 API]") +public interface ChatApi { + @Operation(summary = "채팅 조회", description = "채팅방의 채팅 이력을 무한스크롤 조회합니다. 결과는 최신순으로 정렬되며, `lastMessageId`를 포함하지 않은 이전 메시지들을 조회합니다.
content는 채팅방 조회의 `recentMessages` 필드와 동일합니다.") + @Parameters({ + @Parameter(name = "chatRoomId", description = "채팅방 ID", required = true, in = ParameterIn.PATH), + @Parameter(name = "lastMessageId", description = "마지막으로 읽은 메시지 ID", required = true, in = ParameterIn.QUERY), + @Parameter(name = "size", description = "조회할 채팅 수(default: 30)", example = "30", required = false, in = ParameterIn.QUERY) + }) + @ApiResponse(responseCode = "200", description = "채팅 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chats", schema = @Schema(implementation = SliceResponseTemplate.class)))) + ResponseEntity readChats( + @PathVariable("chatRoomId") Long chatRoomId, + @RequestParam(value = "lastMessageId") Long lastMessageId, + @RequestParam(value = "size", defaultValue = "30") int size + ); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatMemberApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatMemberApi.java new file mode 100644 index 000000000..4c46b8b80 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatMemberApi.java @@ -0,0 +1,98 @@ +package kr.co.pennyway.api.apis.chat.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotEmpty; +import kr.co.pennyway.api.apis.chat.dto.ChatMemberReq; +import kr.co.pennyway.api.apis.chat.dto.ChatMemberRes; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.common.annotation.ApiExceptionExplanation; +import kr.co.pennyway.api.common.annotation.ApiResponseExplanations; +import kr.co.pennyway.api.common.exception.ApiErrorCode; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.Set; + +@Tag(name = "[채팅방 멤버 API]") +public interface ChatMemberApi { + @Operation(summary = "채팅방 멤버 가입", method = "POST", description = "채팅방에 멤버로 가입한다.") + @Parameters({ + @Parameter(name = "chatRoomId", description = "채팅방 ID", required = true, in = ParameterIn.PATH), + @Parameter(name = "payload", description = "채팅방 멤버 가입 요청 DTO", required = true, in = ParameterIn.DEFAULT, schema = @Schema(implementation = ChatMemberReq.Join.class)) + }) + @ApiResponse(responseCode = "200", description = "채팅방 멤버 가입 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRoom", schema = @Schema(implementation = ChatRoomRes.Detail.class)))) + @ApiResponseExplanations(errors = { + @ApiExceptionExplanation(value = ChatRoomErrorCode.class, constant = "INVALID_PASSWORD", summary = "비밀번호가 일치하지 않음", description = "비밀번호가 일치하지 않아 채팅방 멤버 가입에 실패했습니다."), + @ApiExceptionExplanation(value = ChatRoomErrorCode.class, constant = "NOT_FOUND_CHAT_ROOM", summary = "채팅방을 찾을 수 없음", description = "채팅방을 찾을 수 없어 채팅방 멤버 가입에 실패했습니다."), + @ApiExceptionExplanation(value = ChatRoomErrorCode.class, constant = "FULL_CHAT_ROOM", summary = "채팅방이 가득 참", description = "채팅방이 가득 차서 채팅방 멤버 가입에 실패했습니다."), + @ApiExceptionExplanation(value = ChatMemberErrorCode.class, constant = "BANNED", summary = "차단된 사용자", description = "차단된 사용자로 채팅방 멤버 가입에 실패했습니다."), + @ApiExceptionExplanation(value = ChatMemberErrorCode.class, constant = "ALREADY_JOINED", summary = "이미 가입한 사용자", description = "이미 가입한 사용자로 채팅방 멤버 가입에 실패했습니다.") + }) + ResponseEntity joinChatRoom( + @PathVariable("chatRoomId") Long chatRoomId, + @Validated @RequestBody ChatMemberReq.Join payload, + @AuthenticationPrincipal SecurityUserDetails user + ); + + @Operation(summary = "채팅방 멤버 조회", method = "GET", description = "채팅방 멤버 목록을 조회한다. 오로지 요청자의 채팅방 접근 권한만을 검사하며, 요청 아이디의 채팅방 포함 여부에 대한 검사 및 응답은 포함하지 않는다.") + @Parameters({ + @Parameter(name = "chatRoomId", description = "채팅방 ID", required = true, in = ParameterIn.PATH), + @Parameter(name = "ids", description = """ + 멤버 ID 목록. 중복을 허용하며, 순서가 일관되지 않아도 된다. 단, 최대 50개까지 조회 가능하며, null을 허용하지 않는다. 값은 `[채팅방 API] 채팅방 조회`의 응답으로 얻은 `otherParticipantIds`의 값을 사용하면 된다. (주의, userId가 아님!)""" + , required = true, in = ParameterIn.QUERY, array = @ArraySchema(schema = @Schema(type = "integer"))) + }) + @ApiResponseExplanations(errors = { + @ApiExceptionExplanation(value = ApiErrorCode.class, constant = "OVERFLOW_QUERY_PARAMETER", summary = "쿼리 파라미터 오버플로우", description = "쿼리 파라미터가 최대 개수를 초과하여 채팅방 멤버 조회에 실패했습니다.") + }) + @ApiResponse(responseCode = "200", description = "채팅방 멤버 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatMembers", array = @ArraySchema(schema = @Schema(implementation = ChatMemberRes.MemberDetail.class))))) + ResponseEntity readChatMembers(@PathVariable("chatRoomId") Long chatRoomId, @Validated @NotEmpty @RequestParam("ids") Set ids); + + @Operation(summary = "채팅방 멤버 탈퇴", method = "DELETE", description = "채팅방에서 탈퇴한다. 채팅방장은 채팅 멤버가 한 명이라도 남아있으면 탈퇴할 수 없으며, 채팅방장이 탈퇴할 경우 채팅방이 삭제된다.") + @Parameter(name = "chatRoomId", description = "채팅방 ID", required = true, in = ParameterIn.PATH) + @ApiResponseExplanations(errors = { + @ApiExceptionExplanation(value = ChatMemberErrorCode.class, constant = "ADMIN_CANNOT_LEAVE", summary = "채팅방장은 탈퇴할 수 없음", description = "채팅방장은 채팅방 멤버 탈퇴에 실패했습니다.")} + ) + @ApiResponse(responseCode = "200", description = "채팅방 멤버 탈퇴 성공") + ResponseEntity leaveChatRoom(@PathVariable("chatRoomId") Long chatRoomId, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "채팅방 멤버 추방", method = "DELETE", description = "채팅방 멤버를 추방한다. 채팅방장만이 채팅방 멤버를 추방할 수 있다.") + @Parameters({ + @Parameter(name = "chatRoomId", description = "채팅방 ID", required = true, in = ParameterIn.PATH), + @Parameter(name = "chatMemberId", description = "추방할 채팅방 멤버 ID (user id가 아님)", required = true, in = ParameterIn.PATH) + }) + @ApiResponseExplanations(errors = { + @ApiExceptionExplanation(value = ChatMemberErrorCode.class, constant = "NOT_FOUND", summary = "채팅방 멤버를 찾을 수 없음", description = "채팅방 멤버를 찾을 수 없어 채팅방 멤버 추방에 실패했습니다."), + @ApiExceptionExplanation(value = ChatMemberErrorCode.class, constant = "NOT_ADMIN", summary = "권한 없음", description = "권한이 없어 채팅방 멤버 추방에 실패했습니다.") + }) + @ApiResponse(responseCode = "200", description = "채팅방 멤버 추방 성공") + ResponseEntity banChatMember(@PathVariable("chatRoomId") Long chatRoomId, @PathVariable("chatMemberId") Long chatMemberId, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "채팅방 관리자 위임", method = "PATCH", description = "채팅방 관리자를 위임한다. 채팅방장만이 채팅방 관리자를 위임할 수 있다.") + @Parameters({ + @Parameter(name = "chatRoomId", description = "채팅방 ID", required = true, in = ParameterIn.PATH), + @Parameter(name = "chatMemberId", description = "위임할 채팅방 멤버 ID (user id가 아님)", required = true, in = ParameterIn.PATH) + }) + @ApiResponseExplanations(errors = { + @ApiExceptionExplanation(value = ChatMemberErrorCode.class, constant = "NOT_FOUND", summary = "채팅방 멤버를 찾을 수 없음", description = "채팅방 멤버를 찾을 수 없어 채팅방 관리자 위임에 실패했습니다."), + @ApiExceptionExplanation(value = ChatMemberErrorCode.class, constant = "NOT_ADMIN", summary = "권한 없음", description = "권한이 없어 채팅방 관리자 위임에 실패했습니다."), + @ApiExceptionExplanation(value = ChatMemberErrorCode.class, constant = "NOT_SAME_CHAT_ROOM", summary = "다른 채팅방 멤버", description = "위임할 채팅방 멤버가 다른 채팅방 멤버여서 채팅방 관리자 위임에 실패했습니다.") + }) + @ApiResponse(responseCode = "200", description = "채팅방 관리자 위임 성공") + ResponseEntity delegateAdmin(@PathVariable("chatRoomId") Long chatRoomId, @PathVariable("chatMemberId") Long chatMemberId, @AuthenticationPrincipal SecurityUserDetails user); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatRoomApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatRoomApi.java new file mode 100644 index 000000000..a39005f19 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatRoomApi.java @@ -0,0 +1,80 @@ +package kr.co.pennyway.api.apis.chat.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomReq; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.common.annotation.ApiExceptionExplanation; +import kr.co.pennyway.api.common.annotation.ApiResponseExplanations; +import kr.co.pennyway.api.common.response.SliceResponseTemplate; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "[채팅방 API]") +public interface ChatRoomApi { + @Operation(summary = "채팅방 생성", method = "POST", description = "채팅방 생성에 성공하면 생성된 채팅방의 상세 정보를 반환한다.") + @ApiResponse(responseCode = "200", description = "채팅방 생성 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRoom", schema = @Schema(implementation = ChatRoomRes.Detail.class)))) + ResponseEntity createChatRoom(@RequestBody ChatRoomReq.Create request, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "가입한 채팅방 목록 조회", method = "GET", description = "사용자가 가입한 채팅방 목록을 조회하며, 정렬 순서는 보장하지 않는다. 최근 활성화된 채팅방의 순서를 지정할 방법에 대해 추가 개선이 필요한 API이므로, 추후 기능이 일부 수정될 수도 있다.", deprecated = true) + @Parameter(name = "summary", description = "채팅방 요약 정보 조회 여부. true로 설정하면 채팅방의 상세 정보가 chatRoomIds 필드만 반환된다. (default=false)", example = "false") + @ApiResponse(responseCode = "200", description = "가입한 채팅방 목록 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRooms", array = @ArraySchema(schema = @Schema(implementation = ChatRoomRes.Detail.class))))) + ResponseEntity getMyChatRooms(@RequestParam(name = "summary", required = false, defaultValue = "false") boolean query, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "채팅방 검색", method = "GET", description = "사용자가 가입한 채팅방 중 검색어에 일치하는 채팅방 목록을 조회한다. 검색 결과는 무한 스크롤 응답으로 반환되며, 정렬 순서는 정확도가 높은 순으로 반환된다. contents 필드는 List 타입으로, '가입한 채팅방 목록 조회' API 응답과 동일하다.") + @Parameters({ + @Parameter(name = "target", description = "검색 대상. 채팅방 제목 혹은 설명을 검색한다. 최소한 2자 이상의 문자열이어야 한다.", example = "페니웨이", required = true), + @Parameter(name = "page", description = "페이지 번호. 0부터 시작한다.", example = "0", required = true), + @Parameter(name = "size", description = "페이지 크기. 한 페이지 당 반환되는 채팅방 개수이다. 기본값으로 10개씩 반환한다."), + @Parameter(name = "query", hidden = true) + }) + @ApiResponse(responseCode = "200", description = "채팅방 검색 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRooms", schema = @Schema(implementation = SliceResponseTemplate.class)))) + ResponseEntity searchChatRooms(@Validated ChatRoomReq.SearchQuery query, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "채팅방 상세 조회", method = "GET", description = "사용자가 가입한 채팅방 중 특정 채팅방의 상세 정보를 조회한다. 채팅방의 상세 정보에는 채팅방의 참여자 목록과 최근 채팅 메시지 목록 등이 포함된다.") + @Parameter(name = "chatRoomId", description = "조회할 채팅방의 식별자", example = "1", required = true) + @ApiResponse(responseCode = "200", description = "채팅방 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRoom", schema = @Schema(implementation = ChatRoomRes.RoomWithParticipants.class)))) + ResponseEntity getChatRoom(@PathVariable("chatRoomId") Long chatRoomId, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "채팅방 관리자 모드 조회", method = "GET", description = "채팅방 정보를 관리자 모드로 조회한다. 채팅방 비밀번호를 포함하며, 채팅방 방장만이 조회 가능하다.") + @Parameter(name = "chatRoomId", description = "조회할 채팅방의 식별자", example = "1", required = true) + @ApiResponse(responseCode = "200", description = "채팅방 관리자 모드 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRoom", schema = @Schema(implementation = ChatRoomRes.AdminView.class)))) + ResponseEntity getChatRoomAdmin(@PathVariable("chatRoomId") Long chatRoomId); + + @Operation(summary = "채팅방 알림 켜기", method = "PATCH", description = "해당 채팅방의 요청자에 한해 알림을 켠다.") + @Parameter(name = "chatRoomId", description = "알림 설정을 변경할 채팅방의 식별자", example = "1", required = true) + @ApiResponse(responseCode = "200", description = "채팅방 알림 설정 변경 성공") + ResponseEntity turnOnNotification(@PathVariable("chatRoomId") Long chatRoomId, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "채팅방 알림 크기", method = "DELETE", description = "해당 채팅방의 요청자에 한해 알림을 끈다.") + @Parameter(name = "chatRoomId", description = "알림 설정을 해제할 채팅방의 식별자", example = "1", required = true) + @ApiResponse(responseCode = "200", description = "채팅방 알림 해제 성공") + ResponseEntity turnOffNotification(@PathVariable("chatRoomId") Long chatRoomId, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "채팅방 수정", method = "PUT", description = "채팅방의 정보를 수정한다. 채팅방의 정보 수정에 성공하면 수정된 채팅방의 상세 정보를 반환한다.") + @Parameter(name = "chatRoomId", description = "수정할 채팅방의 식별자", example = "1", required = true) + @ApiResponse(responseCode = "200", description = "채팅방 수정 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRoom", schema = @Schema(implementation = ChatRoomRes.Detail.class)))) + ResponseEntity updateChatRoom(@PathVariable("chatRoomId") Long chatRoomId, @Validated @RequestBody ChatRoomReq.Update request); + + @Operation(summary = "채팅방 삭제", method = "DELETE", description = "채팅방을 삭제한다. 채팅방 방장만이 가능하며, 채팅방을 삭제하면 채팅방에 참여한 모든 사용자가 채팅방에서 나가게 된다.") + @Parameter(name = "chatRoomId", description = "삭제할 채팅방의 식별자", example = "1", required = true) + @ApiResponseExplanations(errors = { + @ApiExceptionExplanation(value = ChatMemberErrorCode.class, constant = "NOT_FOUND", summary = "채팅방 멤버 정보를 찾을 수 없음"), + @ApiExceptionExplanation(value = ChatMemberErrorCode.class, constant = "NOT_ADMIN", summary = "권한 없음", description = "채팅방 방장이 아니라 채팅방을 삭제할 수 없습니다.") + }) + @ApiResponse(responseCode = "200", description = "채팅방 삭제 성공") + ResponseEntity deleteChatRoom(@PathVariable("chatRoomId") Long chatRoomId, @AuthenticationPrincipal SecurityUserDetails user); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatRoomApiV2.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatRoomApiV2.java new file mode 100644 index 000000000..9c7f4de71 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatRoomApiV2.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.api.apis.chat.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "[임시 채팅방 API]", description = "버전 관리를 위한 임시 채팅방 API, 추후 [채팅방 API]로 통합될 예정입니다.") +public interface ChatRoomApiV2 { + @Operation(summary = "가입한 채팅방 목록 조회", method = "GET", description = "사용자가 가입한 채팅방 목록을 조회하며, 정렬 순서는 보장하지 않는다. 최근 활성화된 채팅방의 순서를 지정할 방법에 대해 추가 개선이 필요한 API이므로, 추후 기능이 일부 수정될 수도 있다.") + @Parameter(name = "summary", description = "채팅방 요약 정보 조회 여부. true로 설정하면 채팅방의 상세 정보가 chatRoomIds 필드만 반환된다. (default=false)", example = "false") + @ApiResponse(responseCode = "200", description = "가입한 채팅방 목록 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRooms", array = @ArraySchema(schema = @Schema(implementation = ChatRoomRes.Detailv2.class))))) + ResponseEntity getMyChatRooms(@RequestParam(name = "summary", required = false, defaultValue = "false") boolean isSummary, @AuthenticationPrincipal SecurityUserDetails user); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatController.java new file mode 100644 index 000000000..2b773d3d9 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatController.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.api.apis.chat.controller; + +import kr.co.pennyway.api.apis.chat.api.ChatApi; +import kr.co.pennyway.api.apis.chat.usecase.ChatUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/chat-rooms/{chatRoomId}/chats") +public class ChatController implements ChatApi { + private static final String CHATS = "chats"; + + private final ChatUseCase chatUseCase; + + @Override + @GetMapping("") + @PreAuthorize("isAuthenticated() and @chatRoomManager.hasPermission(principal.userId, #chatRoomId)") + public ResponseEntity readChats( + @PathVariable("chatRoomId") Long chatRoomId, + @RequestParam(value = "lastMessageId") Long lastMessageId, + @RequestParam(value = "size", defaultValue = "30") int size + ) { + return ResponseEntity.ok(SuccessResponse.from(CHATS, chatUseCase.readChats(chatRoomId, lastMessageId, size))); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberController.java new file mode 100644 index 000000000..a12e412f6 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberController.java @@ -0,0 +1,86 @@ +package kr.co.pennyway.api.apis.chat.controller; + +import jakarta.validation.constraints.NotEmpty; +import kr.co.pennyway.api.apis.chat.api.ChatMemberApi; +import kr.co.pennyway.api.apis.chat.dto.ChatMemberReq; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.apis.chat.usecase.ChatMemberUseCase; +import kr.co.pennyway.api.common.exception.ApiErrorCode; +import kr.co.pennyway.api.common.exception.ApiErrorException; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Set; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/chat-rooms/{chatRoomId}/chat-members") +public class ChatMemberController implements ChatMemberApi { + private static final String CHAT_ROOM = "chatRoom"; + private static final String CHAT_MEMBERS = "chatMembers"; + + private final ChatMemberUseCase chatMemberUseCase; + + @Override + @PostMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity joinChatRoom( + @PathVariable("chatRoomId") Long chatRoomId, + @Validated @RequestBody ChatMemberReq.Join payload, + @AuthenticationPrincipal SecurityUserDetails user + ) { + ChatRoomRes.Detail detail = chatMemberUseCase.joinChatRoom(user.getUserId(), chatRoomId, payload.password()); + + return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOM, detail)); + } + + @Override + @GetMapping("") + @PreAuthorize("isAuthenticated() and @chatRoomManager.hasPermission(principal.userId, #chatRoomId)") + public ResponseEntity readChatMembers(@PathVariable("chatRoomId") Long chatRoomId, @Validated @NotEmpty @RequestParam("ids") Set chatMemberIds) { + if (chatMemberIds.size() > 50) { + throw new ApiErrorException(ApiErrorCode.OVERFLOW_QUERY_PARAMETER); + } + + return ResponseEntity.ok(SuccessResponse.from(CHAT_MEMBERS, chatMemberUseCase.readChatMembers(chatRoomId, chatMemberIds))); + } + + @Override + @DeleteMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity leaveChatRoom(@PathVariable("chatRoomId") Long chatRoomId, @AuthenticationPrincipal SecurityUserDetails user) { + chatMemberUseCase.leaveChatRoom(user.getUserId(), chatRoomId); + + return ResponseEntity.ok(SuccessResponse.noContent()); + } + + @Override + @DeleteMapping("/{chatMemberId}/ban") + @PreAuthorize("isAuthenticated() and @chatRoomManager.hasPermission(principal.userId, #chatRoomId)") + public ResponseEntity banChatMember(@PathVariable("chatRoomId") Long chatRoomId, @PathVariable("chatMemberId") Long chatMemberId, @AuthenticationPrincipal SecurityUserDetails user) { + chatMemberUseCase.banChatMember(user.getUserId(), chatMemberId, chatRoomId); + + return ResponseEntity.ok(SuccessResponse.noContent()); + } + + @Override + @PatchMapping("/{chatMemberId}/delegate") + @PreAuthorize("isAuthenticated()") + public ResponseEntity delegateAdmin( + @PathVariable("chatRoomId") Long chatRoomId, + @PathVariable("chatMemberId") Long chatMemberId, + @AuthenticationPrincipal SecurityUserDetails user + ) { + chatMemberUseCase.delegate(user.getUserId(), chatMemberId, chatRoomId); + + return ResponseEntity.ok(SuccessResponse.noContent()); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomController.java new file mode 100644 index 000000000..ea29e64f3 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomController.java @@ -0,0 +1,100 @@ +package kr.co.pennyway.api.apis.chat.controller; + +import kr.co.pennyway.api.apis.chat.api.ChatRoomApi; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomReq; +import kr.co.pennyway.api.apis.chat.usecase.ChatRoomUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/chat-rooms") +public class ChatRoomController implements ChatRoomApi { + private static final String CHAT_ROOM = "chatRoom"; + private static final String CHAT_ROOMS = "chatRooms"; + private final ChatRoomUseCase chatRoomUseCase; + + @Override + @PostMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity createChatRoom(@Validated @RequestBody ChatRoomReq.Create request, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOM, chatRoomUseCase.createChatRoom(request, user.getUserId()))); + } + + @Override + @GetMapping("/me") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getMyChatRooms(@RequestParam(name = "summary", required = false, defaultValue = "false") boolean isSummary, @AuthenticationPrincipal SecurityUserDetails user) { + if (isSummary) { + return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOM, chatRoomUseCase.readJoinedChatRoomIds(user.getUserId()))); + } + + return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOMS, chatRoomUseCase.getChatRooms(user.getUserId()))); + } + + @Override + @GetMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity searchChatRooms(@Validated ChatRoomReq.SearchQuery query, @AuthenticationPrincipal SecurityUserDetails user) { + Pageable pageable = Pageable.ofSize(query.size()).withPage(query.page()); + + return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOM, chatRoomUseCase.searchChatRooms(user.getUserId(), query.target(), pageable))); + } + + @Override + @GetMapping("/{chatRoomId}") + @PreAuthorize("isAuthenticated() and @chatRoomManager.hasPermission(#user.getUserId(), #chatRoomId)") + public ResponseEntity getChatRoom(@PathVariable("chatRoomId") Long chatRoomId, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOM, chatRoomUseCase.getChatRoomWithParticipants(user.getUserId(), chatRoomId))); + } + + @Override + @GetMapping("/{chatRoomId}/admin") + @PreAuthorize("isAuthenticated() and @chatRoomManager.hasAdminPermission(principal.userId, #chatRoomId)") + public ResponseEntity getChatRoomAdmin(@PathVariable("chatRoomId") Long chatRoomId) { + return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOM, chatRoomUseCase.getChatRoom(chatRoomId))); + } + + @Override + @PatchMapping("/{chatRoomId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity turnOnNotification(@PathVariable("chatRoomId") Long chatRoomId, @AuthenticationPrincipal SecurityUserDetails user) { + chatRoomUseCase.turnOnNotification(user.getUserId(), chatRoomId); + + return ResponseEntity.ok(SuccessResponse.noContent()); + } + + @Override + @DeleteMapping("/{chatRoomId}/notification") + @PreAuthorize("isAuthenticated()") + public ResponseEntity turnOffNotification(@PathVariable("chatRoomId") Long chatRoomId, @AuthenticationPrincipal SecurityUserDetails user) { + chatRoomUseCase.turnOffNotification(user.getUserId(), chatRoomId); + + return ResponseEntity.ok(SuccessResponse.noContent()); + } + + @Override + @PutMapping("/{chatRoomId}") + @PreAuthorize("isAuthenticated() and @chatRoomManager.hasAdminPermission(principal.userId, #chatRoomId)") + public ResponseEntity updateChatRoom(@PathVariable("chatRoomId") Long chatRoomId, @Validated @RequestBody ChatRoomReq.Update request) { + return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOM, chatRoomUseCase.updateChatRoom(chatRoomId, request))); + } + + @Override + @DeleteMapping("/{chatRoomId}") + @PreAuthorize("isAuthenticated() and @chatRoomManager.hasAdminPermission(principal.userId, #chatRoomId)") + public ResponseEntity deleteChatRoom(@PathVariable("chatRoomId") Long chatRoomId, @AuthenticationPrincipal SecurityUserDetails user) { + chatRoomUseCase.deleteChatRoom(user.getUserId(), chatRoomId); + + return ResponseEntity.ok(SuccessResponse.noContent()); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomControllerV2.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomControllerV2.java new file mode 100644 index 000000000..a927897c2 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomControllerV2.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.api.apis.chat.controller; + +import kr.co.pennyway.api.apis.chat.api.ChatRoomApiV2; +import kr.co.pennyway.api.apis.chat.usecase.ChatRoomUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v3/chat-rooms") +public class ChatRoomControllerV2 implements ChatRoomApiV2 { + private static final String CHAT_ROOMS = "chatRooms"; + private final ChatRoomUseCase chatRoomUseCase; + + @Override + @GetMapping("/me") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getMyChatRooms(@RequestParam(name = "summary", required = false, defaultValue = "false") boolean isSummary, @AuthenticationPrincipal SecurityUserDetails user) { + if (isSummary) { + return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOMS, chatRoomUseCase.readJoinedChatRoomIds(user.getUserId()))); + } + + return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOMS, chatRoomUseCase.getChatRoomDetails(user.getUserId()))); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberReq.java new file mode 100644 index 000000000..7ed1b3472 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberReq.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.api.apis.chat.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Pattern; + +public final class ChatMemberReq { + @Schema(title = "채팅방 멤버 가입 요청 DTO") + public static class Join { + @Schema(description = "채팅방 비밀번호. NULL을 허용한다. 비밀번호는 6자리 정수만 허용", example = "123456") + @Pattern(regexp = "^[0-9]{6}$", message = "채팅방 비밀번호는 6자리 정수여야 합니다.") + private String password; + + protected Join() { + } + + public Join(String password) { + this.password = password; + } + + // 메서드 표현 일관성을 유지하고, password를 Integer로 변환하여 반환하는 getter + public Integer password() { + return password != null ? Integer.valueOf(password) : null; + } + + // Swagger UI에서 표현하기 위한 getter + public String getPassword() { + return password; + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberRes.java new file mode 100644 index 000000000..8afe4d323 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberRes.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.api.apis.chat.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; + +import java.time.LocalDateTime; + +public final class ChatMemberRes { + @Schema(description = "채팅방 참여자 상세 정보") + public record MemberDetail( + @Schema(description = "채팅방 참여자 ID", type = "long") + Long id, + @Schema(description = "채팅방 사용자의 애플리케이션 내 고유 식별자 (userId)") + Long userId, + @Schema(description = "채팅방 참여자 이름") + String name, + @Schema(description = "채팅방 참여자 역할") + ChatMemberRole role, + @Schema(description = "채팅방 참여자 알림 설정 여부. 내 정보를 조회할 때만 포함됩니다.") + @JsonInclude(JsonInclude.Include.NON_NULL) + Boolean notifyEnabled, + @Schema(description = "채팅방 가입일") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + @Schema(description = "채팅방 참여자 프로필 이미지 URL") + String profileImageUrl + ) { + } + + @Schema(description = "채팅방 참여자 요약 정보") + public record MemberSummary( + @Schema(description = "채팅방 참여자 ID", type = "long") + Long id, + @Schema(description = "채팅방 참여자 이름") + String name + ) { + public static MemberSummary from(ChatMemberResult.Summary chatMember) { + return new MemberSummary( + chatMember.id(), + String.valueOf(chatMember.name()) + ); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRes.java new file mode 100644 index 000000000..70d20366b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRes.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.api.apis.chat.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType; +import kr.co.pennyway.domain.domains.message.type.MessageContentType; + +import java.time.LocalDateTime; + +public final class ChatRes { + @Schema(description = "채팅 메시지 상세 정보") + public record ChatDetail( + @Schema(description = "채팅방 ID", type = "long") + Long chatRoomId, + @Schema(description = "채팅 ID", type = "long") + Long chatId, + @Schema(description = "채팅 내용") + String content, + @Schema(description = "채팅 내용 타입") + MessageContentType contentType, + @Schema(description = "채팅 메시지 카테고리 타입") + MessageCategoryType categoryType, + @Schema(description = "채팅 생성일") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + @Schema(description = "채팅 보낸 사람 ID", type = "long") + Long senderId + ) { + public static ChatDetail from(ChatMessage message) { + return new ChatDetail( + message.getChatRoomId(), + message.getChatId(), + message.getContent(), + message.getContentType(), + message.getCategoryType(), + message.getCreatedAt(), + message.getSender() + ); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRoomReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRoomReq.java new file mode 100644 index 000000000..1b0fba302 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRoomReq.java @@ -0,0 +1,66 @@ +package kr.co.pennyway.api.apis.chat.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; + +public final class ChatRoomReq { + @Schema(title = "채팅방 생성 요청 DTO") + public record Create( + @NotBlank + @Size(min = 1, max = 50) + @Schema(description = "채팅방 제목. NULL 혹은 공백은 허용하지 않으며, 1~50자 이내의 문자열이어야 한다.", example = "페니웨이") + String title, + @Size(min = 1, max = 100) + @Schema(description = "채팅방 설명. NULL을 허용하며, 문자가 존재할 시 공백 허용 없이 1~100자 이내의 문자열이어야 한다.", example = "페니웨이 채팅방입니다.") + String description, + @Schema(description = "채팅방 비밀번호. NULL을 허용한다. 비밀번호는 6자리 정수만 허용", example = "123456") + @Pattern(regexp = "^[0-9]{6}$", message = "채팅방 비밀번호는 6자리 정수여야 합니다.") + String password, + @Schema(description = "채팅방 배경 이미지 URL. NULL을 허용한다.", example = "delete/chatroom/{chatroom_id}/{uuid}_{timestamp}.{ext}") + String backgroundImageUrl + ) { + public ChatRoom toEntity(long chatRoomId, String originImageUrl) { + return ChatRoom.builder() + .id(chatRoomId) + .title(title) + .description(description) + .password(password != null ? Integer.valueOf(password) : null) + .backgroundImageUrl(originImageUrl) + .build(); + } + } + + @Schema(title = "채팅방 수정 요청 DTO") + public record Update( + @Size(min = 1, max = 50) + @Schema(description = "채팅방 제목. NULL 혹은 공백은 허용하지 않으며, 1~50자 이내의 문자열이어야 한다.", example = "페니웨이") + String title, + @Size(min = 1, max = 100) + @Schema(description = "채팅방 설명. NULL을 허용하며, 문자가 존재할 시 공백 허용 없이 1~100자 이내의 문자열이어야 한다.", example = "페니웨이 채팅방입니다.") + String description, + @Schema(description = "채팅방 비밀번호. NULL을 허용한다. 비밀번호는 6자리 정수만 허용", example = "123456") + @Pattern(regexp = "^[0-9]{6}$", message = "채팅방 비밀번호는 6자리 정수여야 합니다.") + String password, + @Schema(description = "채팅방 배경 이미지 URL은 신규 등록 혹은 수정의 경우 delete/로 시작해야 하며, 기존 이미지를 유지할 경우 https://cdn.dev.pennyway.co.kr/chatroom/로 시작해야 합니다. (없으면 NULL을 허용합니다.)", example = "delete/chatroom/{chatroom_id}/{uuid}_{timestamp}.{ext}") + String backgroundImageUrl + ) { + } + + public record SearchQuery( + @NotNull(message = "검색 대상은 NULL이 될 수 없습니다.") + @Size(min = 2) + String target, + int page, + Integer size + ) { + public SearchQuery { + if (size == null) { + size = 10; + } + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRoomRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRoomRes.java new file mode 100644 index 000000000..fd0f545b2 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatRoomRes.java @@ -0,0 +1,160 @@ +package kr.co.pennyway.api.apis.chat.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.dto.ChatRoomDetail; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public final class ChatRoomRes { + /** + * 채팅방 정보를 담기 위한 DTO + * + * @param chatRoom {@link ChatRoomDetail} : 채팅방 정보 + * @param unreadMessageCount long : 읽지 않은 메시지 수 + * @param lastMessage {@link ChatRes.ChatDetail} : 가장 최근 메시지. 없을 경우 null + */ + public record Info( + ChatRoomDetail chatRoom, + long unreadMessageCount, + ChatRes.ChatDetail lastMessage + ) { + public static Info of(ChatRoomDetail chatRoom, long unreadMessageCount, ChatRes.ChatDetail recentMessage) { + return new Info(chatRoom, unreadMessageCount, recentMessage); + } + } + + @Schema(description = "채팅방 상세 정보") + public record Detail( + @Schema(description = "채팅방 ID", type = "long") + Long id, + @Schema(description = "채팅방 제목") + String title, + @Schema(description = "채팅방 설명") + String description, + @Schema(description = "채팅방 배경 이미지 URL") + String backgroundImageUrl, + @Schema(description = "채팅방 비공개 여부") + boolean isPrivate, + @Schema(description = "어드민 여부. 채팅방의 관리자라면 true, 아니라면 false") + boolean isAdmin, + @Schema(description = "채팅방 참여자 수") + int participantCount, + @Schema(description = "채팅방 개설일") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + @Schema(description = "마지막 메시지 정보. 없을 경우 null이 반환된다.") + ChatRes.ChatDetail lastMessage, + @Schema(description = "읽지 않은 메시지 수. 100 이상의 값을 가지면, 100으로 표시된다.") + long unreadMessageCount + ) { + public Detail(Long id, String title, String description, String backgroundImageUrl, boolean isPrivate, boolean isAdmin, int participantCount, LocalDateTime createdAt, ChatRes.ChatDetail lastMessage, long unreadMessageCount) { + this.id = id; + this.title = title; + this.description = Objects.toString(description, ""); + this.backgroundImageUrl = Objects.toString(backgroundImageUrl, ""); + this.isPrivate = isPrivate; + this.isAdmin = isAdmin; + this.participantCount = participantCount; + this.createdAt = createdAt; + this.lastMessage = lastMessage; + this.unreadMessageCount = (unreadMessageCount > 100) ? 100 : unreadMessageCount; + } + } + + @Schema(description = "채팅방 상세 정보 ver.2") + public record Detailv2( + @Schema(description = "채팅방 ID", type = "long") + Long id, + @Schema(description = "채팅방 제목") + String title, + @Schema(description = "채팅방 설명") + String description, + @Schema(description = "채팅방 배경 이미지 URL") + String backgroundImageUrl, + @Schema(description = "채팅방 알림 설정") + boolean isNotifyEnabled, + @Schema(description = "채팅방 비공개 여부") + boolean isPrivate, + @Schema(description = "어드민 여부. 채팅방의 관리자라면 true, 아니라면 false") + boolean isAdmin, + @Schema(description = "채팅방 참여자 수") + int participantCount, + @Schema(description = "채팅방 개설일") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + @Schema(description = "마지막 메시지 정보. 없을 경우 null이 반환된다.") + ChatRes.ChatDetail lastMessage, + @Schema(description = "읽지 않은 메시지 수. 100 이상의 값을 가지면, 100으로 표시된다.") + long unreadMessageCount + ) { + public Detailv2(Long id, String title, String description, String backgroundImageUrl, boolean isNotifyEnabled, boolean isPrivate, boolean isAdmin, int participantCount, LocalDateTime createdAt, ChatRes.ChatDetail lastMessage, long unreadMessageCount) { + this.id = id; + this.title = title; + this.description = Objects.toString(description, ""); + this.backgroundImageUrl = Objects.toString(backgroundImageUrl, ""); + this.isNotifyEnabled = isNotifyEnabled; + this.isPrivate = isPrivate; + this.isAdmin = isAdmin; + this.participantCount = participantCount; + this.createdAt = createdAt; + this.lastMessage = lastMessage; + this.unreadMessageCount = (unreadMessageCount > 100) ? 100 : unreadMessageCount; + } + } + + @Schema(description = "채팅방 정보 (어드민용)") + public record AdminView( + @Schema(description = "채팅방 ID", type = "long") + Long id, + @Schema(description = "채팅방 제목") + String title, + @Schema(description = "채팅방 설명") + String description, + @Schema(description = "채팅방 배경 이미지 URL") + String backgroundImageUrl, + @Schema(description = "비밀번호") + Integer password + ) { + public static AdminView of(ChatRoom chatRoom) { + return new AdminView( + chatRoom.getId(), + chatRoom.getTitle(), + chatRoom.getDescription(), + chatRoom.getBackgroundImageUrl(), + chatRoom.getPassword() + ); + } + } + + @Schema(description = "채팅방 요약 정보") + public record Summary( + @Schema(description = "채팅방 ID 목록. 빈 목록일 경우 빈 배열이 반환된다. 각 요소는 long 타입이다.") + Set chatRoomIds + ) { + } + + @Schema(description = "채팅방 참여자 정보 (방의 참여자 + 최근 메시지)") + @Builder + public record RoomWithParticipants( + @Schema(description = "채팅방에서 내 정보") + ChatMemberRes.MemberDetail myInfo, + @Schema(description = "최근에 채팅 메시지를 보낸 참여자의 상세 정보 목록. 내가 방장이 아니라면, 최근에 활동 내역이 없어도 방장 정보가 포함된다.") + List recentParticipants, + @Schema(description = "채팅방에서 내 정보와 최근 활동자를 제외한 참여자 ID, Name 목록") + List otherParticipants, + @Schema(description = "최근 채팅 이력. 메시지는 최신순으로 정렬되어 반환.") + List recentMessages + ) { + + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/helper/ChatMemberJoinHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/helper/ChatMemberJoinHelper.java new file mode 100644 index 000000000..e0d6e4e65 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/helper/ChatMemberJoinHelper.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.api.apis.chat.helper; + +import kr.co.pennyway.common.annotation.Helper; +import kr.co.pennyway.domain.context.chat.dto.ChatMemberJoinCommand; +import kr.co.pennyway.domain.context.chat.service.ChatMemberJoinService; +import kr.co.pennyway.domain.context.chat.service.ChatMessageService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.infra.common.event.ChatRoomJoinEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.ImmutableTriple; +import org.apache.commons.lang3.tuple.Triple; +import org.springframework.context.ApplicationEventPublisher; + +@Slf4j +@Helper +@RequiredArgsConstructor +public class ChatMemberJoinHelper { + private final ChatMemberJoinService chatMemberJoinService; + private final ChatMessageService chatMessageService; + + private final ApplicationEventPublisher eventPublisher; + + public Triple execute(Long userId, Long chatRoomId, Integer password) { + var chatMemberJoinResult = chatMemberJoinService.execute(ChatMemberJoinCommand.of(userId, chatRoomId, password)); + + Long unreadMessageCount = chatMessageService.countUnreadMessages(chatRoomId, 0L); + + eventPublisher.publishEvent(ChatRoomJoinEvent.of(chatRoomId, chatMemberJoinResult.memberName())); + + return ImmutableTriple.of(chatMemberJoinResult.chatRoom(), chatMemberJoinResult.currentMemberCount().intValue(), unreadMessageCount); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMapper.java new file mode 100644 index 000000000..44b9d327f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMapper.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.api.apis.chat.mapper; + +import kr.co.pennyway.api.apis.chat.dto.ChatRes; +import kr.co.pennyway.api.common.response.SliceResponseTemplate; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import org.springframework.data.domain.Slice; + +import java.util.List; + +@Mapper +public class ChatMapper { + public static SliceResponseTemplate toChatDetails(Slice chatMessages) { + List details = chatMessages.getContent().stream() + .map(ChatRes.ChatDetail::from) + .toList(); + + return SliceResponseTemplate.of(details, chatMessages.getPageable(), chatMessages.getNumberOfElements(), chatMessages.hasNext()); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMemberMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMemberMapper.java new file mode 100644 index 000000000..083995db0 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMemberMapper.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.api.apis.chat.mapper; + +import kr.co.pennyway.api.apis.chat.dto.ChatMemberRes; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult; + +import java.util.List; + +@Mapper +public final class ChatMemberMapper { + public static List toChatMemberResDetail(List chatMembers, String objectPrefix) { + return chatMembers.stream() + .map(chatMember -> createMemberDetail(chatMember, false, objectPrefix)) + .toList(); + } + + private static ChatMemberRes.MemberDetail createMemberDetail(ChatMemberResult.Detail chatMember, boolean isMe, String objectPrefix) { + return new ChatMemberRes.MemberDetail( + chatMember.id(), + chatMember.userId(), + chatMember.name(), + chatMember.role(), + isMe ? chatMember.notifyEnabled() : null, + chatMember.createdAt(), + chatMember.profileImageUrl() == null ? "" : objectPrefix + chatMember.profileImageUrl() + ); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatRoomMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatRoomMapper.java new file mode 100644 index 000000000..1a9e7a95f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatRoomMapper.java @@ -0,0 +1,151 @@ +package kr.co.pennyway.api.apis.chat.mapper; + +import kr.co.pennyway.api.apis.chat.dto.ChatMemberRes; +import kr.co.pennyway.api.apis.chat.dto.ChatRes; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.common.response.SliceResponseTemplate; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.dto.ChatRoomDetail; +import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +import java.util.ArrayList; +import java.util.List; + +@Mapper +public final class ChatRoomMapper { + /** + * 채팅방 상세 정보를 SliceResponseTemplate 형태로 변환한다. + * 해당 메서드는 언제나 채팅방 검색 응답으로 사용되며, 마지막 메시지 정보는 null로 설정된다. + * + * @param details + * @param pageable + * @return + */ + public static SliceResponseTemplate toChatRoomResDetails(Slice details, Pageable pageable, String objectPrefix) { + List contents = new ArrayList<>(); + for (ChatRoomDetail detail : details.getContent()) { + contents.add( + new ChatRoomRes.Detail( + detail.id(), + detail.title(), + detail.description(), + createBackGroundImageUrl(detail.backgroundImageUrl(), objectPrefix), + detail.password() != null, + detail.isAdmin(), + detail.participantCount(), + detail.createdAt(), + null, + 0 + ) + ); + } + + return SliceResponseTemplate.of(contents, pageable, contents.size(), details.hasNext()); + } + + public static List toChatRoomResDetails(List details, String objectPrefix) { + List responses = new ArrayList<>(); + + for (ChatRoomRes.Info info : details) { + responses.add( + new ChatRoomRes.Detail( + info.chatRoom().id(), + info.chatRoom().title(), + info.chatRoom().description(), + createBackGroundImageUrl(info.chatRoom().backgroundImageUrl(), objectPrefix), + info.chatRoom().password() != null, + info.chatRoom().isAdmin(), + info.chatRoom().participantCount(), + info.chatRoom().createdAt(), + info.lastMessage(), + info.unreadMessageCount() + ) + ); + } + + return responses; + } + + public static List toChatRoomResDetailsV2(List details, String objectPrefix) { + List responses = new ArrayList<>(); + + for (ChatRoomRes.Info info : details) { + responses.add( + new ChatRoomRes.Detailv2( + info.chatRoom().id(), + info.chatRoom().title(), + info.chatRoom().description(), + createBackGroundImageUrl(info.chatRoom().backgroundImageUrl(), objectPrefix), + info.chatRoom().isNotifyEnabled(), + info.chatRoom().password() != null, + info.chatRoom().isAdmin(), + info.chatRoom().participantCount(), + info.chatRoom().createdAt(), + info.lastMessage(), + info.unreadMessageCount() + ) + ); + } + + return responses; + } + + public static ChatRoomRes.Detail toChatRoomResDetail(ChatRoom chatRoom, ChatRes.ChatDetail lastMessage, boolean isAdmin, int participantCount, long unreadMessageCount, String objectPrefix) { + return new ChatRoomRes.Detail( + chatRoom.getId(), + chatRoom.getTitle(), + chatRoom.getDescription(), + createBackGroundImageUrl(chatRoom.getBackgroundImageUrl(), objectPrefix), + chatRoom.getPassword() != null, + isAdmin, + participantCount, + chatRoom.getCreatedAt(), + lastMessage, + unreadMessageCount + ); + } + + public static ChatRoomRes.RoomWithParticipants toChatRoomResRoomWithParticipants(ChatMemberResult.Detail myInfo, List recentParticipants, List otherParticipants, List chatMessages, String objectPrefix) { + List recentParticipantsRes = recentParticipants.stream() + .map(participant -> createMemberDetail(participant, false, objectPrefix)) + .toList(); + List otherParticipantsRes = otherParticipants.stream() + .map(ChatMemberRes.MemberSummary::from) + .toList(); + + List chatMessagesRes = chatMessages.stream() + .map(ChatRes.ChatDetail::from) + .toList(); + + return ChatRoomRes.RoomWithParticipants.builder() + .myInfo(createMemberDetail(myInfo, true, objectPrefix)) + .recentParticipants(recentParticipantsRes) + .otherParticipants(otherParticipantsRes) + .recentMessages(chatMessagesRes) + .build(); + } + + public static ChatRoomRes.AdminView toChatRoomResAdminView(ChatRoom chatRoom) { + return ChatRoomRes.AdminView.of(chatRoom); + } + + private static String createBackGroundImageUrl(String chatRoomBackgroundImage, String objectPrefix) { + return (chatRoomBackgroundImage == null) ? "" : objectPrefix + chatRoomBackgroundImage; + } + + private static ChatMemberRes.MemberDetail createMemberDetail(ChatMemberResult.Detail chatMember, boolean isMe, String objectPrefix) { + return new ChatMemberRes.MemberDetail( + chatMember.id(), + chatMember.userId(), + chatMember.name(), + chatMember.role(), + isMe ? chatMember.notifyEnabled() : null, + chatMember.createdAt(), + chatMember.profileImageUrl() == null ? "" : objectPrefix + chatMember.profileImageUrl() + ); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomPatchHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomPatchHelper.java new file mode 100644 index 000000000..029f0a565 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomPatchHelper.java @@ -0,0 +1,87 @@ +package kr.co.pennyway.api.apis.chat.service; + +import kr.co.pennyway.api.apis.chat.dto.ChatRoomReq; +import kr.co.pennyway.api.common.storage.AwsS3Adapter; +import kr.co.pennyway.common.annotation.Helper; +import kr.co.pennyway.domain.context.chat.dto.ChatRoomPatchCommand; +import kr.co.pennyway.domain.context.chat.service.ChatRoomPatchService; +import kr.co.pennyway.domain.context.chat.service.ChatRoomService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorException; +import kr.co.pennyway.infra.client.aws.s3.ActualIdProvider; +import kr.co.pennyway.infra.common.exception.StorageErrorCode; +import kr.co.pennyway.infra.common.exception.StorageException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Helper +@RequiredArgsConstructor +public class ChatRoomPatchHelper { + private final ChatRoomService chatRoomService; + private final ChatRoomPatchService chatRoomPatchService; + private final AwsS3Adapter awsS3Adapter; + + public ChatRoom updateChatRoom(Long chatRoomId, ChatRoomReq.Update request) { + var currentChatRoom = chatRoomService.readChatRoom(chatRoomId) + .orElseThrow(() -> new ChatRoomErrorException(ChatRoomErrorCode.NOT_FOUND_CHAT_ROOM)); + + var originImageUrl = updateImage(currentChatRoom.getBackgroundImageUrl(), request.backgroundImageUrl(), chatRoomId); + + var password = (request.password() == null) ? null : Integer.valueOf(request.password()); + + return chatRoomPatchService.execute(ChatRoomPatchCommand.of(chatRoomId, request.title(), request.description(), originImageUrl, password)); + } + + /** + * 현재 채팅방 이미지와 요청된 이미지를 비교하여, 변경이 필요한 경우 처리합니다. + * + * @param currentImageUrl String : 현재 이미지 URL + * @param requestImageUrl String : 요청된 이미지 URL + * @param chatRoomId Long : 채팅방 ID + * @return 채팅방에 저장될 이미지 URL + */ + private String updateImage(String currentImageUrl, String requestImageUrl, Long chatRoomId) { + if (currentImageUrl != null && shouldDeleteCurrentImage(requestImageUrl)) { // 현재 이미지가 있고, 다른 이미지(null 혹은 신규)로 변경하는 경우, 현재 이미지 삭제 + log.info("현재 이미지 삭제: {}", currentImageUrl); + awsS3Adapter.deleteImage(currentImageUrl); + } + + return processRequestImage(requestImageUrl, chatRoomId); + } + + private boolean shouldDeleteCurrentImage(String requestImageUrl) { + return requestImageUrl == null || requestImageUrl.startsWith("delete/"); + } + + private String processRequestImage(String requestImageUrl, Long chatRoomId) { + if (requestImageUrl == null) { + log.info("{}번 채팅방 신규 이미지 없음", chatRoomId); + + return null; + } + + if (requestImageUrl.startsWith("delete/")) { + log.info("{}번 채팅방 신규 이미지 등록 요청: {}", chatRoomId, requestImageUrl); + + return awsS3Adapter.saveImage(requestImageUrl, ActualIdProvider.createInstanceOfChatroomProfile(chatRoomId)); + } + + if (requestImageUrl.startsWith(awsS3Adapter.getObjectPrefix())) { + var originImageUrl = requestImageUrl.substring(awsS3Adapter.getObjectPrefix().length()); + + validateExistingImage(originImageUrl); + + return originImageUrl; + } + + throw new StorageException(StorageErrorCode.INVALID_IMAGE_PATH); + } + + private void validateExistingImage(String imageUrl) { + if (!awsS3Adapter.isObjectExist(imageUrl)) { + throw new StorageException(StorageErrorCode.NOT_FOUND); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSaveService.java new file mode 100644 index 000000000..f159eca65 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSaveService.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.api.apis.chat.service; + +import kr.co.pennyway.api.apis.chat.dto.ChatRoomReq; +import kr.co.pennyway.api.common.storage.AwsS3Adapter; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.chat.service.ChatMemberService; +import kr.co.pennyway.domain.context.chat.service.ChatRoomService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.infra.client.aws.s3.ActualIdProvider; +import kr.co.pennyway.infra.client.guid.IdGenerator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatRoomSaveService { + private final UserService userService; + private final ChatRoomService chatRoomService; + private final ChatMemberService chatMemberService; + + private final AwsS3Adapter awsS3Adapter; + private final IdGenerator idGenerator; + + @Transactional + public ChatRoom createChatRoom(ChatRoomReq.Create request, Long userId) { + Long chatRoomId = idGenerator.generate(); + + String originImageUrl = null; + if (request.backgroundImageUrl() != null) { + originImageUrl = awsS3Adapter.saveImage(request.backgroundImageUrl(), ActualIdProvider.createInstanceOfChatroomProfile(chatRoomId)); + } + ChatRoom chatRoom = chatRoomService.create(request.toEntity(chatRoomId, originImageUrl)); + + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + chatMemberService.createAdmin(user, chatRoom); + + return chatRoom; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchService.java new file mode 100644 index 000000000..76ed7b48f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchService.java @@ -0,0 +1,61 @@ +package kr.co.pennyway.api.apis.chat.service; + +import kr.co.pennyway.api.apis.chat.dto.ChatRes; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.domain.context.chat.service.ChatMessageService; +import kr.co.pennyway.domain.context.chat.service.ChatMessageStatusService; +import kr.co.pennyway.domain.context.chat.service.ChatRoomService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.dto.ChatRoomDetail; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorException; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatRoomSearchService { + private final ChatRoomService chatRoomService; + private final ChatMessageStatusService chatMessageStatusService; + private final ChatMessageService chatMessageService; + + /** + * 사용자 ID가 속한 채팅방 목록을 조회한다. + * + * @return 채팅방 목록. {@link ChatRoomRes.Info} 리스트 형태로 반환 + */ + @Transactional(readOnly = true) + public List readChatRooms(Long userId) { + List chatRooms = chatRoomService.readChatRoomsByUserId(userId); + List result = new ArrayList<>(); + + for (ChatRoomDetail chatRoom : chatRooms) { + Long lastReadMessageId = chatMessageStatusService.readLastReadMessageId(userId, chatRoom.id()); + ChatMessage lastMessage = chatMessageService.readRecentMessages(chatRoom.id(), 1).stream().findFirst().orElse(null); + Long unreadCount = chatMessageService.countUnreadMessages(chatRoom.id(), lastReadMessageId); + + result.add(ChatRoomRes.Info.of(chatRoom, unreadCount, lastMessage == null ? null : ChatRes.ChatDetail.from(lastMessage))); + } + + return result; + } + + @Transactional(readOnly = true) + public ChatRoom readChatRoom(Long chatRoomId) { + return chatRoomService.readChatRoom(chatRoomId).orElseThrow(() -> new ChatRoomErrorException(ChatRoomErrorCode.NOT_FOUND_CHAT_ROOM)); + } + + @Transactional(readOnly = true) + public Slice readChatRoomsBySearch(Long userId, String target, Pageable pageable) { + return chatRoomService.readChatRooms(userId, target, pageable); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomWithParticipantsSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomWithParticipantsSearchService.java new file mode 100644 index 000000000..c1abece68 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomWithParticipantsSearchService.java @@ -0,0 +1,76 @@ +package kr.co.pennyway.api.apis.chat.service; + +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.apis.chat.mapper.ChatRoomMapper; +import kr.co.pennyway.api.common.storage.AwsS3Adapter; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.chat.service.ChatMemberService; +import kr.co.pennyway.domain.context.chat.service.ChatMessageService; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatRoomWithParticipantsSearchService { + private static final int MESSAGE_LIMIT = 15; + + private final UserService userService; + private final ChatMemberService chatMemberService; + private final ChatMessageService chatMessageService; + + private final AwsS3Adapter awsS3Adapter; + + @Transactional(readOnly = true) + public ChatRoomRes.RoomWithParticipants execute(Long userId, Long chatRoomId) { + // 내 정보 조회 + User me = userService.readUser(userId) + .orElseThrow(() -> new ChatMemberErrorException(ChatMemberErrorCode.NOT_FOUND)); + + ChatMember myInfo = chatMemberService.readChatMember(userId, chatRoomId) + .orElseThrow(() -> new ChatMemberErrorException(ChatMemberErrorCode.NOT_FOUND)); + ChatMemberResult.Detail myDetail = new ChatMemberResult.Detail(myInfo.getId(), me.getName(), myInfo.getRole(), myInfo.isNotifyEnabled(), userId, myInfo.getCreatedAt(), me.getProfileImageUrl()); + + // 최근 메시지 조회 (15건) + List chatMessages = chatMessageService.readRecentMessages(chatRoomId, MESSAGE_LIMIT); + + // 최근 메시지의 발신자 조회 + Set recentParticipantIds = chatMessages.stream() + .map(ChatMessage::getSender) + .filter(sender -> !sender.equals(userId)) + .collect(Collectors.toSet()); + + // 최근 메시지의 발신자 상세 정보 조회 + List recentParticipants = new ArrayList<>( + chatMemberService.readChatMembersByUserIds(chatRoomId, recentParticipantIds) + ); + + // 내가 관리자가 아니거나, 최근 활동자에 관리자가 없다면 관리자 정보 조회 + if (!myInfo.getRole().equals(ChatMemberRole.ADMIN) && recentParticipants.stream().noneMatch(participant -> participant.role().equals(ChatMemberRole.ADMIN))) { + ChatMemberResult.Detail admin = chatMemberService.readAdmin(chatRoomId) + .orElseThrow(() -> new ChatMemberErrorException(ChatMemberErrorCode.NOT_FOUND)); + recentParticipantIds.add(admin.userId()); + recentParticipants.add(admin); + } + recentParticipantIds.add(userId); + + // 채팅방에 속한 다른 사용자 요약 정보 조회 + List otherMemberIds = chatMemberService.readChatMemberIdsByUserIdsNotIn(chatRoomId, recentParticipantIds); + + return ChatRoomMapper.toChatRoomResRoomWithParticipants(myDetail, recentParticipants, otherMemberIds, chatMessages, awsS3Adapter.getObjectPrefix()); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatSearchService.java new file mode 100644 index 000000000..70a30d317 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatSearchService.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.api.apis.chat.service; + +import kr.co.pennyway.domain.context.chat.service.ChatMessageService; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatSearchService { + private final ChatMessageService chatMessageService; + + public Slice readChats(Long chatRoomId, Long lastMessageId, int size) { + return chatMessageService.readMessageBefore(chatRoomId, lastMessageId, size); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatMemberUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatMemberUseCase.java new file mode 100644 index 000000000..1b7253bc0 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatMemberUseCase.java @@ -0,0 +1,60 @@ +package kr.co.pennyway.api.apis.chat.usecase; + +import kr.co.pennyway.api.apis.chat.dto.ChatMemberRes; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.apis.chat.helper.ChatMemberJoinHelper; +import kr.co.pennyway.api.apis.chat.mapper.ChatMemberMapper; +import kr.co.pennyway.api.apis.chat.mapper.ChatRoomMapper; +import kr.co.pennyway.api.common.storage.AwsS3Adapter; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.context.chat.dto.ChatMemberBanCommand; +import kr.co.pennyway.domain.context.chat.dto.ChatRoomAdminDelegateCommand; +import kr.co.pennyway.domain.context.chat.service.ChatMemberBanService; +import kr.co.pennyway.domain.context.chat.service.ChatMemberService; +import kr.co.pennyway.domain.context.chat.service.ChatRoomAdminDelegateService; +import kr.co.pennyway.domain.context.chat.service.ChatRoomLeaveService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Triple; + +import java.util.List; +import java.util.Set; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class ChatMemberUseCase { + private final ChatMemberJoinHelper chatMemberJoinHelper; + private final ChatRoomLeaveService chatRoomLeaveService; + private final ChatMemberBanService chatMemberBanService; + private final ChatRoomAdminDelegateService chatRoomAdminDelegateService; + + private final ChatMemberService chatMemberService; + private final AwsS3Adapter awsS3Adapter; + + public ChatRoomRes.Detail joinChatRoom(Long userId, Long chatRoomId, Integer password) { + Triple chatRoom = chatMemberJoinHelper.execute(userId, chatRoomId, password); + + return ChatRoomMapper.toChatRoomResDetail(chatRoom.getLeft(), null, false, chatRoom.getMiddle(), chatRoom.getRight(), awsS3Adapter.getObjectPrefix()); + } + + public List readChatMembers(Long chatRoomId, Set chatMemberIds) { + List chatMembers = chatMemberService.readChatMembersByMemberIds(chatRoomId, chatMemberIds); + + return ChatMemberMapper.toChatMemberResDetail(chatMembers, awsS3Adapter.getObjectPrefix()); + } + + public void leaveChatRoom(Long userId, Long chatRoomId) { + chatRoomLeaveService.execute(userId, chatRoomId); + } + + public void banChatMember(Long userId, Long targetMemberId, Long chatRoomId) { + chatMemberBanService.execute(ChatMemberBanCommand.of(userId, targetMemberId, chatRoomId)); + } + + public void delegate(Long userId, Long targetChatMemberId, Long chatRoomId) { + chatRoomAdminDelegateService.execute(ChatRoomAdminDelegateCommand.of(chatRoomId, userId, targetChatMemberId)); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatRoomUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatRoomUseCase.java new file mode 100644 index 000000000..e23c3daf3 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatRoomUseCase.java @@ -0,0 +1,100 @@ +package kr.co.pennyway.api.apis.chat.usecase; + +import kr.co.pennyway.api.apis.chat.dto.ChatRoomReq; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.apis.chat.mapper.ChatRoomMapper; +import kr.co.pennyway.api.apis.chat.service.ChatRoomPatchHelper; +import kr.co.pennyway.api.apis.chat.service.ChatRoomSaveService; +import kr.co.pennyway.api.apis.chat.service.ChatRoomSearchService; +import kr.co.pennyway.api.apis.chat.service.ChatRoomWithParticipantsSearchService; +import kr.co.pennyway.api.common.response.SliceResponseTemplate; +import kr.co.pennyway.api.common.storage.AwsS3Adapter; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.context.chat.dto.ChatRoomDeleteCommand; +import kr.co.pennyway.domain.context.chat.dto.ChatRoomToggleCommand; +import kr.co.pennyway.domain.context.chat.service.ChatMemberService; +import kr.co.pennyway.domain.context.chat.service.ChatRoomDeleteService; +import kr.co.pennyway.domain.context.chat.service.ChatRoomNotificationToggleService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.dto.ChatRoomDetail; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +import java.util.List; +import java.util.Set; + +@UseCase +@RequiredArgsConstructor +public class ChatRoomUseCase { + private final ChatRoomSaveService chatRoomSaveService; + private final ChatRoomSearchService chatRoomSearchService; + private final ChatRoomWithParticipantsSearchService chatRoomWithParticipantsSearchService; + private final ChatRoomPatchHelper chatRoomPatchHelper; + private final ChatRoomDeleteService chatRoomDeleteService; + private final ChatRoomNotificationToggleService chatRoomNotificationToggleService; + + private final ChatMemberService chatMemberService; + + private final AwsS3Adapter awsS3Adapter; + + public ChatRoomRes.Detail createChatRoom(ChatRoomReq.Create request, Long userId) { + ChatRoom chatRoom = chatRoomSaveService.createChatRoom(request, userId); + + return ChatRoomMapper.toChatRoomResDetail(chatRoom, null, true, 1, 0, awsS3Adapter.getObjectPrefix()); + } + + public ChatRoomRes.AdminView getChatRoom(Long chatRoomId) { + ChatRoom chatRoom = chatRoomSearchService.readChatRoom(chatRoomId); + + return ChatRoomMapper.toChatRoomResAdminView(chatRoom); + } + + @Deprecated(since = "2025-01-22") + public List getChatRooms(Long userId) { + List chatRooms = chatRoomSearchService.readChatRooms(userId); + + return ChatRoomMapper.toChatRoomResDetails(chatRooms, awsS3Adapter.getObjectPrefix()); + } + + public List getChatRoomDetails(Long userId) { + List chatRooms = chatRoomSearchService.readChatRooms(userId); + + return ChatRoomMapper.toChatRoomResDetailsV2(chatRooms, awsS3Adapter.getObjectPrefix()); + } + + public ChatRoomRes.RoomWithParticipants getChatRoomWithParticipants(Long userId, Long chatRoomId) { + return chatRoomWithParticipantsSearchService.execute(userId, chatRoomId); + } + + public ChatRoomRes.Summary readJoinedChatRoomIds(Long userId) { + Set chatRoomIds = chatMemberService.readChatRoomIdsByUserId(userId); + + return new ChatRoomRes.Summary(chatRoomIds); + } + + public SliceResponseTemplate searchChatRooms(Long userId, String target, Pageable pageable) { + Slice chatRooms = chatRoomSearchService.readChatRoomsBySearch(userId, target, pageable); + + return ChatRoomMapper.toChatRoomResDetails(chatRooms, pageable, awsS3Adapter.getObjectPrefix()); + } + + // 채팅방 자체의 정보 외엔 무의미한 데이터를 반환한다. + public ChatRoomRes.Detail updateChatRoom(Long chatRoomId, ChatRoomReq.Update request) { + ChatRoom chatRoom = chatRoomPatchHelper.updateChatRoom(chatRoomId, request); + + return ChatRoomMapper.toChatRoomResDetail(chatRoom, null, true, 1, 0, awsS3Adapter.getObjectPrefix()); + } + + public void turnOnNotification(Long userId, Long chatRoomId) { + chatRoomNotificationToggleService.turnOn(ChatRoomToggleCommand.of(userId, chatRoomId)); + } + + public void turnOffNotification(Long userId, Long chatRoomId) { + chatRoomNotificationToggleService.turnOff(ChatRoomToggleCommand.of(userId, chatRoomId)); + } + + public void deleteChatRoom(Long userId, Long chatRoomId) { + chatRoomDeleteService.execute(ChatRoomDeleteCommand.of(userId, chatRoomId)); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatUseCase.java new file mode 100644 index 000000000..65fbc7266 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatUseCase.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.api.apis.chat.usecase; + +import kr.co.pennyway.api.apis.chat.dto.ChatRes; +import kr.co.pennyway.api.apis.chat.mapper.ChatMapper; +import kr.co.pennyway.api.apis.chat.service.ChatSearchService; +import kr.co.pennyway.api.common.response.SliceResponseTemplate; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Slice; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class ChatUseCase { + private final ChatSearchService chatSearchService; + + public SliceResponseTemplate readChats(Long chatRoomId, Long lastMessageId, int size) { + Slice chatMessages = chatSearchService.readChats(chatRoomId, lastMessageId, size); + + return ChatMapper.toChatDetails(chatMessages); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java index c0ba48b1e..04a277bd5 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java @@ -4,16 +4,14 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.media.*; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.ledger.dto.SpendingIdsDto; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.apis.ledger.dto.SpendingShareReq; import kr.co.pennyway.api.common.annotation.ApiExceptionExplanation; import kr.co.pennyway.api.common.annotation.ApiResponseExplanations; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; @@ -109,4 +107,25 @@ public interface SpendingApi { ) })) ResponseEntity deleteSpendings(@RequestBody SpendingIdsDto spendingIds, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "지출 내역 공유", method = "GET", description = """ + 사용자의 지출 내역을 공유하고 공유된 지출 내역을 반환합니다.
+ <채팅방 공유 시> 전송할 채팅방 아이디를 누락한 경우 예외를 발생시키지만, 가입하지 않은 채팅방 아이디를 전송한 경우는 유효한 방에만 전송하고 별도의 예외를 발생시키지 않습니다. + """) + @Parameters({ + @Parameter(name = "type", description = "공유할 목적지(타입)", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(name = "채팅방 공유", value = "CHAT_ROOM") + }), + @Parameter(name = "year", description = "년도", example = "2025", required = true, in = ParameterIn.QUERY), + @Parameter(name = "month", description = "월", example = "1", required = true, in = ParameterIn.QUERY), + @Parameter(name = "day", description = "일", example = "28", required = true, in = ParameterIn.QUERY), + @Parameter(name = "chatRoomIds", description = "공유할 채팅방 ID 목록 배열 (채팅방 공유 시, null 혹은 빈 배열 허용하지 않음.)", in = ParameterIn.QUERY, array = @ArraySchema(schema = @Schema(type = "long"))), + @Parameter(name = "query", hidden = true) + }) + @ApiResponseExplanations(errors = { + @ApiExceptionExplanation(name = "전송 타입 오류", description = "유효하지 않은 목적지로 지출 내용을 공유할 수 없습니다.", value = SpendingErrorCode.class, constant = "INVALID_SHARE_TYPE"), + @ApiExceptionExplanation(name = "채팅방 공유 파라미터 누락", description = "지출 내역 공유 시 필수 파라미터가 누락되었습니다.", value = SpendingErrorCode.class, constant = "MISSING_SHARE_PARAM") + }) + @ApiResponse(responseCode = "200") + ResponseEntity shareSpending(@Validated SpendingShareReq.ShareQueryParam query, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java index 720619b9f..31e634b45 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java @@ -3,7 +3,9 @@ import kr.co.pennyway.api.apis.ledger.api.SpendingApi; import kr.co.pennyway.api.apis.ledger.dto.SpendingIdsDto; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; +import kr.co.pennyway.api.apis.ledger.dto.SpendingShareReq; import kr.co.pennyway.api.apis.ledger.usecase.SpendingUseCase; +import kr.co.pennyway.api.common.query.SpendingShareType; import kr.co.pennyway.api.common.response.SuccessResponse; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; @@ -17,6 +19,8 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; + @Slf4j @RestController @RequiredArgsConstructor @@ -79,6 +83,28 @@ public ResponseEntity deleteSpendings(@RequestBody SpendingIdsDto spendingIds return ResponseEntity.ok(SuccessResponse.noContent()); } + @Override + @GetMapping("/share") + @PreAuthorize("isAuthenticated()") + public ResponseEntity shareSpending( + @Validated SpendingShareReq.ShareQueryParam query, + @AuthenticationPrincipal SecurityUserDetails user + ) { + var date = LocalDate.of(query.year(), query.month(), query.day()); + + if (query.type().equals(SpendingShareType.CHAT_ROOM)) { + if (query.chatRoomIds() == null || query.chatRoomIds().isEmpty()) { + throw new SpendingErrorException(SpendingErrorCode.MISSING_SHARE_PARAM); + } + + spendingUseCase.shareToChatRoom(user.getUserId(), query.chatRoomIds(), date); + } else { + throw new SpendingErrorException(SpendingErrorCode.INVALID_SHARE_TYPE); + } + + return ResponseEntity.ok(SuccessResponse.noContent()); + } + /** * categoryId가 -1이면 서비스에서 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 CUSTOM이나 OTHER이 될 수 없고,
* categoryId가 -1이 아니면 사용자가 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 CUSTOM임을 확인한다. diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java index 23093cb08..dda8b6cfe 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java @@ -1,6 +1,7 @@ package kr.co.pennyway.api.apis.ledger.dto; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; import io.swagger.v3.oas.annotations.media.Schema; @@ -70,6 +71,7 @@ public Spending toEntity(User user, SpendingCustomCategory spendingCustomCategor } @Schema(hidden = true) + @JsonIgnore public boolean isCustomCategory() { return !categoryId.equals(-1L); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingShareReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingShareReq.java new file mode 100644 index 000000000..f1d704b30 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingShareReq.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.api.apis.ledger.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import kr.co.pennyway.api.common.query.SpendingShareType; + +import java.util.List; + +public class SpendingShareReq { + @Schema(description = "지출 공유 요청") + public record ShareQueryParam( + @Schema(description = "공유 타입 (대/소문자 허용)", example = "chat_room") + SpendingShareType type, + int year, + int month, + int day, + @Schema(description = "공유할 채팅방 ID 배열. 공유 타입이 chat_room인 경우 필수", example = "1") + List chatRoomIds + ) { + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/helper/SpendingChatShareHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/helper/SpendingChatShareHelper.java new file mode 100644 index 000000000..bcd892d3a --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/helper/SpendingChatShareHelper.java @@ -0,0 +1,49 @@ +package kr.co.pennyway.api.apis.ledger.helper; + +import kr.co.pennyway.api.apis.ledger.service.DailySpendingAggregateService; +import kr.co.pennyway.common.annotation.Helper; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.chat.service.ChatMemberService; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.infra.common.event.SpendingChatShareEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Helper +@RequiredArgsConstructor +public class SpendingChatShareHelper { + private final DailySpendingAggregateService dailySpendingAggregateService; + + private final UserService userService; + private final ChatMemberService chatMemberService; + + private final ApplicationEventPublisher eventPublisher; + + public void execute(Long userId, List chatRoomIds, LocalDate date) { + var user = userService.readUser(userId) + .orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + var aggregatedSpendings = dailySpendingAggregateService.execute(userId, date.getYear(), date.getMonthValue(), date.getDayOfMonth()); + var joinedChatRoomIds = chatMemberService.readChatRoomIdsByUserId(userId); + + var spendingOnDate = new ArrayList(); + for (var pair : aggregatedSpendings) { + var categoryInfo = pair.getFirst(); + var amount = pair.getSecond(); + + spendingOnDate.add(SpendingChatShareEvent.SpendingOnDate.of(categoryInfo.id(), categoryInfo.name(), categoryInfo.icon().name(), amount)); + } + + chatRoomIds.stream() + .filter(joinedChatRoomIds::contains) + .forEach(chatRoomId -> { + eventPublisher.publishEvent(new SpendingChatShareEvent(chatRoomId, user.getName(), user.getId(), date, spendingOnDate)); + }); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateService.java new file mode 100644 index 000000000..d53edd0f0 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateService.java @@ -0,0 +1,39 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; +import kr.co.pennyway.domain.domains.spending.service.SpendingRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.summingLong; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DailySpendingAggregateService { + private final SpendingRdbService spendingRdbService; + + @Transactional(readOnly = true) + public List> execute(Long userId, int year, int month, int day) { + var spendings = spendingRdbService.readSpendings(userId, year, month, day); + + return spendings.stream() + .collect( + groupingBy( + Spending::getCategory, + summingLong(Spending::getAmount) + ) + ) + .entrySet().stream() + .map(entry -> Pair.of(entry.getKey(), entry.getValue())) + .sorted((o1, o2) -> (int) (o2.getSecond() - o1.getSecond())) + .toList(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategoryDeleteService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategoryDeleteService.java index e81ad5d51..295fc25be 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategoryDeleteService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategoryDeleteService.java @@ -1,7 +1,7 @@ package kr.co.pennyway.api.apis.ledger.service; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; +import kr.co.pennyway.domain.context.finance.service.SpendingService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -11,12 +11,12 @@ @Service @RequiredArgsConstructor public class SpendingCategoryDeleteService { - private final SpendingCustomCategoryService spendingCustomCategoryService; + private final SpendingCategoryService spendingCategoryService; private final SpendingService spendingService; @Transactional public void execute(Long categoryId) { - spendingService.deleteSpendingsByCategoryIdInQuery(categoryId); - spendingCustomCategoryService.deleteSpendingCustomCategory(categoryId); + spendingService.deleteSpendingsByCategoryId(categoryId); + spendingCategoryService.deleteSpendingCustomCategory(categoryId); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySaveService.java index dbfb08081..e3e4d8e62 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySaveService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySaveService.java @@ -1,14 +1,14 @@ package kr.co.pennyway.api.apis.ledger.service; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -19,18 +19,18 @@ @RequiredArgsConstructor public class SpendingCategorySaveService { private final UserService userService; - private final SpendingCustomCategoryService spendingCustomCategoryService; + private final SpendingCategoryService spendingCategoryService; @Transactional public SpendingCustomCategory create(Long userId, String categoryName, SpendingCategory icon) { User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - return spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategory.of(categoryName, icon, user)); + return spendingCategoryService.createSpendingCustomCategory(SpendingCustomCategory.of(categoryName, icon, user)); } @Transactional public SpendingCustomCategory update(Long categoryId, String name, SpendingCategory icon) { - SpendingCustomCategory category = spendingCustomCategoryService.readSpendingCustomCategory(categoryId) + SpendingCustomCategory category = spendingCategoryService.readSpendingCustomCategory(categoryId) .orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_CUSTOM_CATEGORY)); category.update(name, icon); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySearchService.java index a27b1c2a8..c73fe7939 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySearchService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySearchService.java @@ -1,7 +1,7 @@ package kr.co.pennyway.api.apis.ledger.service; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -13,10 +13,10 @@ @Service @RequiredArgsConstructor public class SpendingCategorySearchService { - private final SpendingCustomCategoryService spendingCustomCategoryService; + private final SpendingCategoryService spendingCategoryService; @Transactional(readOnly = true) public List readSpendingCustomCategories(Long userId) { - return spendingCustomCategoryService.readSpendingCustomCategories(userId); + return spendingCategoryService.readSpendingCustomCategories(userId); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingDeleteService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingDeleteService.java index 4e38ac18c..ba221fd01 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingDeleteService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingDeleteService.java @@ -1,9 +1,9 @@ package kr.co.pennyway.api.apis.ledger.service; +import kr.co.pennyway.domain.context.finance.service.SpendingService; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -27,6 +27,6 @@ public void deleteSpending(Long spendingId) { @Transactional public void deleteSpendings(List spendingIds) { - spendingService.deleteSpendingsInQuery(spendingIds); + spendingService.deleteSpendings(spendingIds); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSaveService.java index 4b35a70ec..c856d319b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSaveService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSaveService.java @@ -1,16 +1,16 @@ package kr.co.pennyway.api.apis.ledger.service; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; +import kr.co.pennyway.domain.context.finance.service.SpendingService; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -22,7 +22,7 @@ public class SpendingSaveService { private final UserService userService; private final SpendingService spendingService; - private final SpendingCustomCategoryService spendingCustomCategoryService; + private final SpendingCategoryService spendingCategoryService; @Transactional public Spending createSpending(Long userId, SpendingReq request) { @@ -32,7 +32,7 @@ public Spending createSpending(Long userId, SpendingReq request) { if (!request.isCustomCategory()) { spending = spendingService.createSpending(request.toEntity(user)); } else { - SpendingCustomCategory customCategory = spendingCustomCategoryService.readSpendingCustomCategory(request.categoryId()) + SpendingCustomCategory customCategory = spendingCategoryService.readSpendingCustomCategory(request.categoryId()) .orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_CUSTOM_CATEGORY)); spending = spendingService.createSpending(request.toEntity(user, customCategory)); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java index f11843a0c..7cffebacc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java @@ -1,11 +1,11 @@ package kr.co.pennyway.api.apis.ledger.service; import kr.co.pennyway.api.common.query.SpendingCategoryType; +import kr.co.pennyway.domain.context.finance.service.SpendingService; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -66,7 +66,7 @@ public Slice readSpendingsByCategoryId(Long userId, Long categoryId, P @Transactional(readOnly = true) public Optional readTotalSpendingAmountByUserIdThatMonth(Long userId, LocalDate date) { - return spendingService.readTotalSpendingAmountByUserId(userId, date); + return spendingService.readTotalSpendingAmount(userId, date); } @Transactional(readOnly = true) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java index 01dcba0bb..94a07efb8 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java @@ -2,13 +2,12 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.common.query.SpendingCategoryType; -import kr.co.pennyway.api.common.security.authorization.SpendingCategoryManager; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; +import kr.co.pennyway.domain.context.finance.service.SpendingService; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,15 +19,14 @@ @RequiredArgsConstructor public class SpendingUpdateService { private final SpendingService spendingService; - private final SpendingCustomCategoryService spendingCustomCategoryService; - private final SpendingCategoryManager spendingCategoryManager; + private final SpendingCategoryService spendingCategoryService; @Transactional public Spending updateSpending(Long spendingId, SpendingReq request) { Spending spending = spendingService.readSpending(spendingId).orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING)); SpendingCustomCategory customCategory = (request.isCustomCategory()) - ? spendingCustomCategoryService.readSpendingCustomCategory(request.categoryId()).orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_CUSTOM_CATEGORY)) + ? spendingCategoryService.readSpendingCustomCategory(request.categoryId()).orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_CUSTOM_CATEGORY)) : null; spending.update(request.amount(), request.icon(), request.spendAt().atStartOfDay(), request.accountName(), request.memo(), customCategory); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountDeleteService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountDeleteService.java index db6ebd2b6..f524169e0 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountDeleteService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountDeleteService.java @@ -1,9 +1,9 @@ package kr.co.pennyway.api.apis.ledger.service; +import kr.co.pennyway.domain.context.finance.service.TargetAmountService; import kr.co.pennyway.domain.domains.target.domain.TargetAmount; import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorException; -import kr.co.pennyway.domain.domains.target.service.TargetAmountService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java index f63e12cbc..7befe5bf3 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java @@ -1,14 +1,14 @@ package kr.co.pennyway.api.apis.ledger.service; -import kr.co.pennyway.domain.common.redisson.DistributedLock; +import kr.co.pennyway.domain.common.annotation.DistributedLock; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.finance.service.TargetAmountService; import kr.co.pennyway.domain.domains.target.domain.TargetAmount; import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorException; -import kr.co.pennyway.domain.domains.target.service.TargetAmountService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSearchService.java index 1f665a7c2..249136b50 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSearchService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSearchService.java @@ -1,9 +1,9 @@ package kr.co.pennyway.api.apis.ledger.service; +import kr.co.pennyway.domain.context.finance.service.TargetAmountService; import kr.co.pennyway.domain.domains.target.domain.TargetAmount; import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorException; -import kr.co.pennyway.domain.domains.target.service.TargetAmountService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java index 9a3a1d59e..fbe5e1f53 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java @@ -2,6 +2,7 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.apis.ledger.helper.SpendingChatShareHelper; import kr.co.pennyway.api.apis.ledger.mapper.SpendingMapper; import kr.co.pennyway.api.apis.ledger.service.SpendingDeleteService; import kr.co.pennyway.api.apis.ledger.service.SpendingSaveService; @@ -13,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.List; @Slf4j @@ -24,6 +26,8 @@ public class SpendingUseCase { private final SpendingUpdateService spendingUpdateService; private final SpendingDeleteService spendingDeleteService; + private final SpendingChatShareHelper spendingChatShareHelper; + @Transactional public SpendingSearchRes.Individual createSpending(Long userId, SpendingReq request) { Spending spending = spendingSaveService.createSpending(userId, request); @@ -62,4 +66,7 @@ public void deleteSpendings(List spendingIds) { spendingDeleteService.deleteSpendings(spendingIds); } + public void shareToChatRoom(Long userId, List chatRoomIds, LocalDate date) { + spendingChatShareHelper.execute(userId, chatRoomIds, date); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java index 0d9228baf..558291d7c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java @@ -7,7 +7,7 @@ import kr.co.pennyway.api.apis.ledger.service.TargetAmountSaveService; import kr.co.pennyway.api.apis.ledger.service.TargetAmountSearchService; import kr.co.pennyway.common.annotation.UseCase; -import kr.co.pennyway.domain.common.redisson.DistributedLockPrefix; +import kr.co.pennyway.domain.common.annotation.DistributedLockPrefix; import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; import kr.co.pennyway.domain.domains.target.domain.TargetAmount; import lombok.RequiredArgsConstructor; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java index c256a165a..a3a2c1114 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java @@ -22,7 +22,7 @@ @Tag(name = "[알림 API]") public interface NotificationApi { - @Operation(summary = "수신한 알림 목록 무한 스크롤 조회") + @Operation(summary = "수신한 알림 중 확인한 알림 목록 무한 스크롤 조회") @Parameters({ @Parameter( in = ParameterIn.QUERY, @@ -56,14 +56,18 @@ public interface NotificationApi { ) ), @Parameter(name = "pageable", hidden = true)}) @ApiResponse(responseCode = "200", description = "알림 목록 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "notifications", schema = @Schema(implementation = NotificationDto.SliceRes.class)))) - ResponseEntity getNotifications( + ResponseEntity getReadNotifications( @PageableDefault(page = 0, size = 30) @SortDefault(sort = "notification.createdAt", direction = Sort.Direction.DESC) Pageable pageable, @AuthenticationPrincipal SecurityUserDetails user ); + @Operation(summary = "수신한 알림 중 미확인 알림 목록 조회") + @ApiResponse(responseCode = "200", description = "미확인 알림 목록 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "notifications", array = @ArraySchema(schema = @Schema(implementation = NotificationDto.Info.class))))) + ResponseEntity getUnreadNotifications(@AuthenticationPrincipal SecurityUserDetails user); + @Operation(summary = "수신한 알림 중 미확인 알림 존재 여부 조회") @ApiResponse(responseCode = "200", description = "미확인 알림 존재 여부 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "hasUnread", schema = @Schema(type = "boolean")))) - ResponseEntity getUnreadNotifications(@AuthenticationPrincipal SecurityUserDetails user); + ResponseEntity getHasUnreadNotification(@AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "수신한 알림 읽음 처리", description = "사용자가 수신한 알림을 읽음처리 합니다. 단, 읽음 처리할 알림의 pk는 사용자가 receiver여야 하며, 미확인 알림만 포함되어 있어야 합니다.") @ApiResponses({ diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java index 60c705b8d..c8cd2e273 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java @@ -30,17 +30,24 @@ public class NotificationController implements NotificationApi { @Override @GetMapping("") @PreAuthorize("isAuthenticated()") - public ResponseEntity getNotifications( + public ResponseEntity getReadNotifications( @PageableDefault(page = 0, size = 30) @SortDefault(sort = "notification.createdAt", direction = Sort.Direction.DESC) Pageable pageable, @AuthenticationPrincipal SecurityUserDetails user ) { - return ResponseEntity.ok(SuccessResponse.from(NOTIFICATIONS, notificationUseCase.getNotifications(user.getUserId(), pageable))); + return ResponseEntity.ok(SuccessResponse.from(NOTIFICATIONS, notificationUseCase.getReadNotifications(user.getUserId(), pageable))); } @Override @GetMapping("/unread") @PreAuthorize("isAuthenticated()") public ResponseEntity getUnreadNotifications(@AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from(NOTIFICATIONS, notificationUseCase.getUnreadNotifications(user.getUserId()))); + } + + @Override + @GetMapping("/unread/exist") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getHasUnreadNotification(@AuthenticationPrincipal SecurityUserDetails user) { return ResponseEntity.ok(SuccessResponse.from(HAS_UNREAD, notificationUseCase.hasUnreadNotification(user.getUserId()))); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/dto/NotificationDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/dto/NotificationDto.java index 4f06df4c7..21cee96f9 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/dto/NotificationDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/dto/NotificationDto.java @@ -23,6 +23,16 @@ public record ReadReq( ) { } + @Schema(title = "푸시 알림 리스트 응답") + public record ListRes( + @Schema(description = "푸시 알림 리스트") + List notifications + ) { + public static ListRes from(List notifications) { + return new ListRes(notifications); + } + } + @Schema(title = "푸시 알림 슬라이스 응답") public record SliceRes( @Schema(description = "푸시 알림 리스트") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/mapper/NotificationMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/mapper/NotificationMapper.java index 7da87bdb3..07f2b6658 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/mapper/NotificationMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/mapper/NotificationMapper.java @@ -7,9 +7,22 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import java.util.Comparator; +import java.util.List; + @Slf4j @Mapper public class NotificationMapper { + /** + * Notification 타입을 NotificationDto.Info 타입으로 변환한다. + */ + public static List toInfoList(List notifications) { + return notifications.stream() + .map(NotificationDto.Info::from) + .sorted(Comparator.comparing(NotificationDto.Info::id).reversed()) + .toList(); + } + /** * Slice 타입을 무한 스크롤 응답 형태로 변환한다. */ diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSaveService.java index a753e09a0..492ac6338 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSaveService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSaveService.java @@ -1,6 +1,6 @@ package kr.co.pennyway.api.apis.notification.service; -import kr.co.pennyway.domain.domains.notification.service.NotificationService; +import kr.co.pennyway.domain.context.alter.service.NotificationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -19,6 +19,6 @@ public class NotificationSaveService { * @param notificationIds 읽음 처리할 알림 ID 목록 */ public void updateNotificationsToRead(List notificationIds) { - notificationService.updateReadAtByIdsInBulk(notificationIds); + notificationService.updateReadAtByIds(notificationIds); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSearchService.java index ce92594b9..bc009598f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSearchService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSearchService.java @@ -1,7 +1,8 @@ package kr.co.pennyway.api.apis.notification.service; +import kr.co.pennyway.domain.context.alter.service.NotificationService; import kr.co.pennyway.domain.domains.notification.domain.Notification; -import kr.co.pennyway.domain.domains.notification.service.NotificationService; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; @@ -9,6 +10,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @Service @RequiredArgsConstructor @@ -16,8 +19,13 @@ public class NotificationSearchService { private final NotificationService notificationService; @Transactional(readOnly = true) - public Slice getNotifications(Long userId, Pageable pageable) { - return notificationService.readNotificationsSlice(userId, pageable); + public Slice getAnnounceNotifications(Long userId, Pageable pageable) { + return notificationService.readNotifications(userId, pageable, NoticeType.ANNOUNCEMENT); + } + + @Transactional(readOnly = true) + public List getAnnounceUnreadNotifications(Long userId) { + return notificationService.readUnreadNotifications(userId, NoticeType.ANNOUNCEMENT); } @Transactional(readOnly = true) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java index bd652f112..4d9af43b5 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java @@ -20,12 +20,18 @@ public class NotificationUseCase { private final NotificationSearchService notificationSearchService; private final NotificationSaveService notificationSaveService; - public NotificationDto.SliceRes getNotifications(Long userId, Pageable pageable) { - Slice notifications = notificationSearchService.getNotifications(userId, pageable); + public NotificationDto.SliceRes getReadNotifications(Long userId, Pageable pageable) { + Slice notifications = notificationSearchService.getAnnounceNotifications(userId, pageable); return NotificationMapper.toSliceRes(notifications, pageable); } + public List getUnreadNotifications(Long userId) { + List notifications = notificationSearchService.getAnnounceUnreadNotifications(userId); + + return NotificationMapper.toInfoList(notifications); + } + public boolean hasUnreadNotification(Long userId) { return notificationSearchService.isExistsUnreadNotification(userId); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/usecase/QuestionUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/usecase/QuestionUseCase.java index 3eb2ca101..81d610782 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/usecase/QuestionUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/usecase/QuestionUseCase.java @@ -3,8 +3,8 @@ import jakarta.transaction.Transactional; import kr.co.pennyway.api.apis.question.dto.QuestionReq; import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.context.support.service.QuestionService; import kr.co.pennyway.domain.domains.question.domain.Question; -import kr.co.pennyway.domain.domains.question.service.QuestionService; import kr.co.pennyway.infra.common.event.MailEvent; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/api/SocketApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/api/SocketApi.java new file mode 100644 index 000000000..afaa47bf0 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/api/SocketApi.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.api.apis.socket.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +@Tag(name = "[서비스 탐색 API]") +public interface SocketApi { + @Operation(summary = "연결 가능한 채팅 서버 정보 조회", description = "요청 헤더, 바디를 기반으로 연결 가능한 채팅 서버 정보를 조회한 후, 채팅 서버 정보를 반환합니다.") + @ApiResponse(responseCode = "200", description = "연결 가능한 채팅 서버 정보 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "url", schema = @Schema(type = "string", description = "채팅 서버 URL")))) + ResponseEntity getChatServerInfo(); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/controller/SocketController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/controller/SocketController.java new file mode 100644 index 000000000..cd28359d4 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/controller/SocketController.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.api.apis.socket.controller; + +import kr.co.pennyway.api.apis.socket.api.SocketApi; +import kr.co.pennyway.api.apis.socket.usecase.SocketUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/socket") +public class SocketController implements SocketApi { + private static final String CHAT_SERVER_URL = "chatServerUrl"; + private final SocketUseCase socketUseCase; + + @Override + @GetMapping("/chat") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getChatServerInfo() { + return ResponseEntity.ok(SuccessResponse.from(CHAT_SERVER_URL, socketUseCase.getChatServerUrl())); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/service/ChatServerSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/service/ChatServerSearchService.java new file mode 100644 index 000000000..d04456aa6 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/service/ChatServerSearchService.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.api.apis.socket.service; + +import kr.co.pennyway.infra.client.coordinator.CoordinatorService; +import kr.co.pennyway.infra.client.coordinator.WebSocket; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatServerSearchService { + private final CoordinatorService defaultCoordinatorService; + + public String getChatServerUrl() { + WebSocket.ChatServerUrl response = defaultCoordinatorService.readChatServerUrl(null, null); + + return response.url(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/usecase/SocketUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/usecase/SocketUseCase.java new file mode 100644 index 000000000..b51b0156e --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/socket/usecase/SocketUseCase.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.api.apis.socket.usecase; + +import kr.co.pennyway.api.apis.socket.service.ChatServerSearchService; +import kr.co.pennyway.common.annotation.UseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class SocketUseCase { + private final ChatServerSearchService chatServerSearchService; + + public String getChatServerUrl() { + return chatServerSearchService.getChatServerUrl(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/adapter/PresignedUrlGenerateAdapter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/adapter/PresignedUrlGenerateAdapter.java new file mode 100644 index 000000000..03dd830bf --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/adapter/PresignedUrlGenerateAdapter.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.api.apis.storage.adapter; + +import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; +import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; +import kr.co.pennyway.infra.client.aws.s3.url.properties.PresignedUrlPropertyFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.net.URI; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PresignedUrlGenerateAdapter { + private final AwsS3Provider awsS3Provider; + + public URI execute(Long userId, PresignedUrlDto.Req request) { + PresignedUrlPropertyFactory factory = PresignedUrlPropertyFactory.createInstance(request.ext(), request.type(), userId, request.chatroomId()); + + return awsS3Provider.generatedPresignedUrl(factory); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/api/StorageApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/api/StorageApi.java index 0a163e0d5..17ddfe8ba 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/api/StorageApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/api/StorageApi.java @@ -8,12 +8,15 @@ import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; +import kr.co.pennyway.api.common.annotation.ApiExceptionExplanation; +import kr.co.pennyway.api.common.annotation.ApiResponseExplanations; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.infra.common.exception.StorageErrorCode; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; @Tag(name = "[S3 이미지 저장을 위한 Presigned URL 발급 API]") @@ -21,30 +24,23 @@ public interface StorageApi { @Operation(summary = "S3 이미지 저장을 위한 Presigned URL 발급", description = "S3에 이미지를 저장하기 위한 Presigned URL을 발급합니다.") @Parameters({ @Parameter(name = "type", description = "이미지 종류", required = true, in = ParameterIn.QUERY, examples = { - @ExampleObject(value = "PROFILE"), - @ExampleObject(value = "FEED"), - @ExampleObject(value = "CHATROOM_PROFILE"), - @ExampleObject(value = "CHAT"), - @ExampleObject(value = "CHAT_PROFILE") + @ExampleObject(value = "PROFILE", name = "사용자 프로필"), + @ExampleObject(value = "FEED", name = "피드"), + @ExampleObject(value = "CHATROOM_PROFILE", name = "채팅방 프로필"), + @ExampleObject(value = "CHAT", name = "채팅"), + @ExampleObject(value = "CHAT_PROFILE", name = "채팅 프로필") }), - @Parameter(name = "ext", description = "파일 확장자", required = true, examples = { - @ExampleObject(value = "jpg"), - @ExampleObject(value = "png"), - @ExampleObject(value = "jpeg") + @Parameter(name = "ext", description = "파일 확장자", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(value = "jpg", name = "jpg"), + @ExampleObject(value = "png", name = "png"), + @ExampleObject(value = "jpeg", name = "jpeg") }), - @Parameter(name = "chatroomId", description = "채팅방 ID", example = "12345678-1234-5678-1234-567812345678"), + @Parameter(name = "chatroomId", description = "채팅방 ID", in = ParameterIn.QUERY, example = "123456789"), @Parameter(name = "request", hidden = true) }) - @ApiResponses({ - @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = PresignedUrlDto.Res.class))), - @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "필수 파라미터 누락", value = """ - { - "code": "4001", - "message": "필수 파라미터가 누락되었습니다." - } - """) - })), + @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = PresignedUrlDto.Res.class))) + @ApiResponseExplanations(errors = { + @ApiExceptionExplanation(value = StorageErrorCode.class, constant = "NOT_FOUND", name = "요청한 리소스를 찾을 수 없음") }) - ResponseEntity getPresignedUrl(@Validated PresignedUrlDto.Req req, @AuthenticationPrincipal SecurityUserDetails user); + ResponseEntity getPresignedUrl(@Validated PresignedUrlDto.Req req, BindingResult bindingResult, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/controller/StorageController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/controller/StorageController.java index 9d614370c..5491584da 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/controller/StorageController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/controller/StorageController.java @@ -3,14 +3,19 @@ import kr.co.pennyway.api.apis.storage.api.StorageApi; import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; import kr.co.pennyway.api.apis.storage.usecase.StorageUseCase; +import kr.co.pennyway.api.common.exception.CustomValidationException; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.api.common.validator.PresignedUrlDtoReqValidator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.InitBinder; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -21,10 +26,19 @@ public class StorageController implements StorageApi { private final StorageUseCase storageUseCase; + @InitBinder + protected void initBinder(WebDataBinder binder) { + binder.addValidators(new PresignedUrlDtoReqValidator()); + } + @Override @GetMapping("/presigned-url") @PreAuthorize("isAuthenticated()") - public ResponseEntity getPresignedUrl(@Validated PresignedUrlDto.Req request, @AuthenticationPrincipal SecurityUserDetails user) { + public ResponseEntity getPresignedUrl(@Validated PresignedUrlDto.Req request, BindingResult bindingResult, @AuthenticationPrincipal SecurityUserDetails user) { + if (bindingResult.hasErrors()) { + throw new CustomValidationException(bindingResult); + } + return ResponseEntity.ok(storageUseCase.getPresignedUrl(user.getUserId(), request)); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java index c1e767a15..ecd34c077 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java @@ -2,20 +2,20 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; import java.net.URI; public class PresignedUrlDto { - @Schema(title = "S3 이미지 저장을 위한 Presigned URL 발급 요청 DTO", description = "S3에 이미지를 저장하기 위한 Presigned URL을 발급 요청을 위한 DTO") public record Req( - @Schema(description = "이미지 종류", example = "PROFILE/FEED/CHATROOM_PROFILE/CHAT/CHAT_PROFILE") - @NotBlank(message = "이미지 종류는 필수입니다.") - String type, - @Schema(description = "파일 확장자", example = "jpg/png/jpeg") + @NotNull(message = "이미지 종류는 필수입니다.") + ObjectKeyType type, @NotBlank(message = "파일 확장자는 필수입니다.") + @Pattern(regexp = "^(jpg|png|jpeg)$", message = "파일 확장자는 jpg, png, jpeg 중 하나여야 합니다.") String ext, - @Schema(description = "채팅방 ID", example = "12345678-1234-5678-1234-567812345678") - String chatroomId + Long chatroomId ) { } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/usecase/StorageUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/usecase/StorageUseCase.java index 6db3b3ebb..e5a15c3c1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/usecase/StorageUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/usecase/StorageUseCase.java @@ -1,8 +1,8 @@ package kr.co.pennyway.api.apis.storage.usecase; +import kr.co.pennyway.api.apis.storage.adapter.PresignedUrlGenerateAdapter; import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; import kr.co.pennyway.common.annotation.UseCase; -import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -10,11 +10,9 @@ @UseCase @RequiredArgsConstructor public class StorageUseCase { - private final AwsS3Provider awsS3Provider; + private final PresignedUrlGenerateAdapter presignedUrlGenerateAdapter; public PresignedUrlDto.Res getPresignedUrl(Long userId, PresignedUrlDto.Req request) { - return PresignedUrlDto.Res.of( - awsS3Provider.generatedPresignedUrl(request.type(), request.ext(), userId.toString(), request.chatroomId()) - ); + return PresignedUrlDto.Res.of(presignedUrlGenerateAdapter.execute(userId, request)); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceTokenDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceTokenDto.java index 38b716046..c2482a137 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceTokenDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceTokenDto.java @@ -10,10 +10,16 @@ public class DeviceTokenDto { public record RegisterReq( @Schema(description = "디바이스 FCM 토큰", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "token은 필수입니다.") - String token + String token, + @Schema(description = "디바이스 ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "deviceId는 필수입니다.") + String deviceId, + @Schema(description = "디바이스 이름", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "deviceName은 필수입니다.") + String deviceName ) { public DeviceToken toEntity(User user) { - return DeviceToken.of(token, user); + return DeviceToken.of(token, deviceId, deviceName, user); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java index 6d50403b3..7a51ba8cc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java @@ -1,9 +1,9 @@ package kr.co.pennyway.api.apis.users.service; +import kr.co.pennyway.domain.context.account.service.DeviceTokenService; import kr.co.pennyway.domain.domains.device.domain.DeviceToken; import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; -import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -17,7 +17,7 @@ public class DeviceTokenUnregisterService { @Transactional public void execute(Long userId, String token) { - DeviceToken deviceToken = deviceTokenService.readDeviceByUserIdAndToken(userId, token).orElseThrow( + DeviceToken deviceToken = deviceTokenService.readDeviceTokenByUserIdAndToken(userId, token).orElseThrow( () -> new DeviceTokenErrorException(DeviceTokenErrorCode.NOT_FOUND_DEVICE) ); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/PasswordUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/PasswordUpdateService.java index 94ac73fba..070375fef 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/PasswordUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/PasswordUpdateService.java @@ -1,10 +1,10 @@ package kr.co.pennyway.api.apis.users.service; import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; +import kr.co.pennyway.domain.context.account.service.UserService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java index 3f99e7c5e..bbfc0798a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java @@ -1,12 +1,13 @@ package kr.co.pennyway.api.apis.users.service; -import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.context.account.service.DeviceTokenService; +import kr.co.pennyway.domain.context.account.service.OauthService; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.chat.service.ChatMemberService; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; +import kr.co.pennyway.domain.context.finance.service.SpendingService; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -27,8 +28,10 @@ public class UserDeleteService { private final OauthService oauthService; private final DeviceTokenService deviceTokenService; + private final ChatMemberService chatMemberService; + private final SpendingService spendingService; - private final SpendingCustomCategoryService spendingCustomCategoryService; + private final SpendingCategoryService spendingCategoryService; /** * 사용자와 관련한 모든 데이터를 삭제(soft delete)하는 메서드 @@ -36,19 +39,20 @@ public class UserDeleteService { * hard delete가 수행되어야 할 데이터는 삭제하지 않으며, 사용자 데이터 유지 기간이 만료될 때 DBA가 수행한다. * * @param userId - * @todo [2024-05-03] 채팅 기능이 추가되는 경우 채팅방장 탈퇴를 제한해야 하며, 추가로 삭제될 엔티티 삭제 로직을 추가해야 한다. */ @Transactional public void execute(Long userId) { if (!userService.isExistUser(userId)) throw new UserErrorException(UserErrorCode.NOT_FOUND); - // TODO: [2024-05-03] 하나라도 채팅방의 방장으로 참여하는 경우 삭제 불가능 처리 + if (chatMemberService.hasUserChatRoomOwnership(userId)) { + throw new UserErrorException(UserErrorCode.HAS_OWNERSHIP_CHAT_ROOM); + } - oauthService.deleteOauthsByUserIdInQuery(userId); - deviceTokenService.deleteDevicesByUserIdInQuery(userId); + oauthService.deleteOauth(userId); + deviceTokenService.deleteDeviceTokensByUserId(userId); - spendingService.deleteSpendingsByUserIdInQuery(userId); - spendingCustomCategoryService.deleteSpendingCustomCategoriesByUserIdInQuery(userId); + spendingService.deleteSpendingsByUserId(userId); + spendingCategoryService.deleteSpendingCustomCategoriesByUserId(userId); userService.deleteUser(userId); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileSearchService.java index 19b0f48ac..af86fd603 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileSearchService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileSearchService.java @@ -1,11 +1,11 @@ package kr.co.pennyway.api.apis.users.service; +import kr.co.pennyway.domain.context.account.service.OauthService; +import kr.co.pennyway.domain.context.account.service.UserService; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java index d331621ae..789112b05 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java @@ -2,14 +2,13 @@ import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; +import kr.co.pennyway.domain.context.account.service.PhoneCodeService; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; -import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -20,7 +19,6 @@ @RequiredArgsConstructor public class UserProfileUpdateService { private final UserService userService; - private final AwsS3Provider awsS3Provider; private final PhoneVerificationService phoneVerificationService; private final PhoneCodeService phoneCodeService; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java index 8bca7fb69..7fbbca104 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -8,11 +8,12 @@ import kr.co.pennyway.api.apis.users.service.*; import kr.co.pennyway.api.common.storage.AwsS3Adapter; import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.context.account.service.DeviceTokenRegisterService; import kr.co.pennyway.domain.domains.device.domain.DeviceToken; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; +import kr.co.pennyway.infra.client.aws.s3.ActualIdProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; @@ -36,7 +37,7 @@ public class UserAccountUseCase { @Transactional public DeviceTokenDto.RegisterRes registerDeviceToken(Long userId, DeviceTokenDto.RegisterReq request) { - DeviceToken deviceToken = deviceTokenRegisterService.execute(userId, request.token()); + DeviceToken deviceToken = deviceTokenRegisterService.execute(userId, request.deviceId(), request.deviceName(), request.token()); return DeviceTokenMapper.toRegisterRes(deviceToken); } @@ -69,7 +70,7 @@ public void updatePassword(Long userId, String oldPassword, String newPassword) } public String updateProfileImage(Long userId, UserProfileUpdateDto.ProfileImageReq request) { - String originImageUrl = awsS3Adapter.saveImage(request.profileImageUrl(), ObjectKeyType.PROFILE); + String originImageUrl = awsS3Adapter.saveImage(request.profileImageUrl(), ActualIdProvider.createInstanceOfProfile()); String oldImageUrl = userProfileUpdateService.updateProfileImage(userId, originImageUrl); if (oldImageUrl != null) { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/aop/ExternalApiLogAspect.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/aop/ExternalApiLogAspect.java index 46b1661dc..cdcbba844 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/aop/ExternalApiLogAspect.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/aop/ExternalApiLogAspect.java @@ -63,6 +63,11 @@ public void beforeRequest(JoinPoint joinPoint) { @AfterReturning(pointcut = "cut()", returning = "returnObject") public void afterResponse(JoinPoint joinPoint, Object returnObject) { ResponseEntity responseEntity = (ResponseEntity) returnObject; + + if (responseEntity == null) { + return; + } + HttpHeaders headers = responseEntity.getHeaders(); log.info("================================= Response ================================="); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingShareTypeConverter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingShareTypeConverter.java new file mode 100644 index 000000000..bb0c8503b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingShareTypeConverter.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.api.common.converter; + +import kr.co.pennyway.api.common.query.SpendingShareType; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; +import org.springframework.core.convert.converter.Converter; + +public class SpendingShareTypeConverter implements Converter { + @Override + public SpendingShareType convert(String type) { + try { + return SpendingShareType.valueOf(type.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new SpendingErrorException(SpendingErrorCode.INVALID_SHARE_TYPE); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/ApiErrorCode.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/ApiErrorCode.java new file mode 100644 index 000000000..5c552e1a1 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/ApiErrorCode.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.api.common.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ApiErrorCode implements BaseErrorCode { + // 400 Bad Request + OVERFLOW_QUERY_PARAMETER(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "쿼리 파라미터가 너무 많습니다."), + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/ApiErrorException.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/ApiErrorException.java new file mode 100644 index 000000000..1f37cae42 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/ApiErrorException.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.api.common.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class ApiErrorException extends GlobalErrorException { + private final ApiErrorCode errorCode; + + public ApiErrorException(ApiErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public String getExplainError() { + return errorCode.getExplainError(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/CustomValidationException.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/CustomValidationException.java new file mode 100644 index 000000000..bf028aaa5 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/CustomValidationException.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.api.common.exception; + +import org.springframework.validation.BindingResult; + +public class CustomValidationException extends RuntimeException { + private final BindingResult bindingResult; + + public CustomValidationException(BindingResult bindingResult) { + this.bindingResult = bindingResult; + } + + public BindingResult getBindingResult() { + return bindingResult; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingShareType.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingShareType.java new file mode 100644 index 000000000..5b57a1f75 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingShareType.java @@ -0,0 +1,11 @@ +package kr.co.pennyway.api.common.query; + +public enum SpendingShareType { + CHAT_ROOM("chat_room"); + + private final String type; + + SpendingShareType(String type) { + this.type = type; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java index ae325fdde..e5b497534 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java @@ -1,8 +1,10 @@ package kr.co.pennyway.api.common.query; import jakarta.annotation.Nonnull; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; + +import static kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType.*; public enum VerificationType { GENERAL("general", PhoneCodeKeyType.SIGN_UP), @@ -21,7 +23,11 @@ public enum VerificationType { public PhoneCodeKeyType toPhoneVerificationType(@Nonnull Provider provider) { if (this.equals(OAUTH)) { - return PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider); + return switch (provider) { + case KAKAO -> OAUTH_SIGN_UP_KAKAO; + case GOOGLE -> OAUTH_SIGN_UP_GOOGLE; + case APPLE -> OAUTH_SIGN_UP_APPLE; + }; } return phoneCodeKeyType; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/SliceResponseTemplate.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/SliceResponseTemplate.java new file mode 100644 index 000000000..25ec3cb81 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/SliceResponseTemplate.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.api.common.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.data.domain.Pageable; +import org.springframework.lang.NonNull; + +import java.util.List; + +@Schema(description = "페이징된 무한 스크롤 응답") +public record SliceResponseTemplate( + @Schema(description = "응답 컨텐츠 내용") + List contents, + @Schema(description = "현재 페이지 번호") + int currentPageNumber, + @Schema(description = "페이지 크기") + int pageSize, + @Schema(description = "전체 요소 개수") + int numberOfElements, + @Schema(description = "다음 페이지 존재 여부") + boolean hasNext +) { + public static SliceResponseTemplate of(@NonNull List contents, @NonNull Pageable pageable, int numberOfElements, boolean hasNext) { + return new SliceResponseTemplate<>(contents, pageable.getPageNumber(), pageable.getPageSize(), numberOfElements, hasNext); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java index d23a74973..50ad77ed9 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import kr.co.pennyway.api.common.exception.CustomValidationException; import kr.co.pennyway.api.common.response.ErrorResponse; import kr.co.pennyway.api.common.swagger.CustomJsonView; import kr.co.pennyway.common.exception.CausedBy; @@ -174,6 +175,21 @@ protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotV return ErrorResponse.failure(bindingResult, ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY); } + /** + * API 호출 시 객체 혹은 파라미터 데이터 값이 유효하지 않은 경우 + * + * @see CustomValidationException + */ + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + @ExceptionHandler(CustomValidationException.class) + @JsonView(CustomJsonView.Hidden.class) + protected ErrorResponse handleCustomValidationException(CustomValidationException e) { + log.warn("handleCustomValidationException: {}", e.getMessage()); + BindingResult bindingResult = e.getBindingResult(); + + return ErrorResponse.failure(bindingResult, ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY); + } + /** * API 호출 시 객체 혹은 파라미터 데이터 값이 유효하지 않은 경우 * diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/UserDetailServiceImpl.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/UserDetailServiceImpl.java index 659145ebc..98ef110a7 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/UserDetailServiceImpl.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/UserDetailServiceImpl.java @@ -1,6 +1,6 @@ package kr.co.pennyway.api.common.security.authentication; -import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.context.account.service.UserService; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.Cacheable; import org.springframework.security.core.userdetails.UserDetails; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/ChatRoomManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/ChatRoomManager.java new file mode 100644 index 000000000..cde5cf6d8 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/ChatRoomManager.java @@ -0,0 +1,48 @@ +package kr.co.pennyway.api.common.security.authorization; + +import kr.co.pennyway.domain.context.chat.service.ChatMemberService; +import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@Component("chatRoomManager") +@RequiredArgsConstructor +public class ChatRoomManager { + private final ChatMemberService chatMemberService; + + /** + * 사용자가 채팅방에 대한 접근 권한이 있는지 확인한다. + */ + @Transactional(readOnly = true) + public boolean hasPermission(Long userId, Long chatRoomId) { + return chatMemberService.isExists(chatRoomId, userId); + } + + /** + * 사용자가 채팅방과 특정 멤버에 대한 접근 권한이 있는지 확인한다. + */ + @Transactional(readOnly = true) + public boolean hasPermission(Long userId, Long chatRoomId, Long chatMemberId) { + return chatMemberService.isExists(chatRoomId, userId, chatMemberId); + } + + /** + * 사용자가 채팅방에 대한 관리자 권한이 있는지 확인한다. + */ + @Transactional(readOnly = true) + public boolean hasAdminPermission(Long userId, Long chatRoomId) { + Optional admin = chatMemberService.readAdmin(chatRoomId); + + return admin.map(detail -> detail.userId().equals(userId)).orElseGet( + () -> { + log.error("{} 채팅방에서 관리자 정보를 찾을 수 없습니다.", chatRoomId); + return false; + } + ); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/NotificationManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/NotificationManager.java index 1880e31d6..6ab3452f1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/NotificationManager.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/NotificationManager.java @@ -1,6 +1,6 @@ package kr.co.pennyway.api.common.security.authorization; -import kr.co.pennyway.domain.domains.notification.service.NotificationService; +import kr.co.pennyway.domain.context.alter.service.NotificationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java index 53cf460cd..cc4c73a33 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java @@ -1,7 +1,7 @@ package kr.co.pennyway.api.common.security.authorization; import kr.co.pennyway.api.common.query.SpendingCategoryType; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -11,11 +11,11 @@ @Component("spendingCategoryManager") @RequiredArgsConstructor public class SpendingCategoryManager { - private final SpendingCustomCategoryService spendingCustomCategoryService; + private final SpendingCategoryService spendingCategoryService; @Transactional(readOnly = true) public boolean hasPermission(Long userId, Long categoryId) { - return spendingCustomCategoryService.isExistsSpendingCustomCategory(userId, categoryId); + return spendingCategoryService.isExistsSpendingCustomCategory(userId, categoryId); } /** @@ -30,7 +30,7 @@ public boolean hasPermissionExceptMinus(Long userId, Long categoryId) { return true; } - return spendingCustomCategoryService.isExistsSpendingCustomCategory(userId, categoryId); + return spendingCategoryService.isExistsSpendingCustomCategory(userId, categoryId); } /** diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingManager.java index 67e1b4a16..ec8b8c193 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingManager.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingManager.java @@ -1,6 +1,6 @@ package kr.co.pennyway.api.common.security.authorization; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.context.finance.service.SpendingService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -26,7 +26,7 @@ public boolean hasPermission(Long userId, Long spendingId) { @Transactional(readOnly = true) public boolean hasPermissions(Long userId, List spendingIds) { - if (spendingService.countByUserIdAndIdIn(userId, spendingIds) != (long) spendingIds.size()) { + if (spendingService.countByUserIdAndSpendingIds(userId, spendingIds) != (long) spendingIds.size()) { return false; } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/TargetAmountManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/TargetAmountManager.java index f66b1e91f..4ac03e2bf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/TargetAmountManager.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/TargetAmountManager.java @@ -1,6 +1,6 @@ package kr.co.pennyway.api.common.security.authorization; -import kr.co.pennyway.domain.domains.target.service.TargetAmountService; +import kr.co.pennyway.domain.context.finance.service.TargetAmountService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java index 9166f83fb..1486f0192 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java @@ -5,7 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys; -import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; +import kr.co.pennyway.domain.context.account.service.ForbiddenTokenService; import kr.co.pennyway.infra.common.exception.JwtErrorCode; import kr.co.pennyway.infra.common.exception.JwtErrorException; import kr.co.pennyway.infra.common.jwt.JwtClaims; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java index 1d346e49c..4d7085d33 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java @@ -6,16 +6,16 @@ import java.util.Map; -import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.ROLE; -import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.USER_ID; +import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.*; @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public class RefreshTokenClaim implements JwtClaims { private final Map claims; - public static RefreshTokenClaim of(Long userId, String role) { + public static RefreshTokenClaim of(Long userId, String deviceToken, String role) { Map claims = Map.of( USER_ID.getValue(), userId.toString(), + DEVICE_ID.getValue(), deviceToken, ROLE.getValue(), role ); return new RefreshTokenClaim(claims); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java index 46a9752ad..304c50301 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java @@ -2,7 +2,8 @@ public enum RefreshTokenClaimKeys { USER_ID("id"), - ROLE("role"); + ROLE("role"), + DEVICE_ID("deviceId"); private final String value; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java index ff04b0366..ab7d612cf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java @@ -5,7 +5,6 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import kr.co.pennyway.api.common.annotation.RefreshTokenStrategy; -import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; import kr.co.pennyway.common.util.DateUtil; import kr.co.pennyway.infra.common.exception.JwtErrorCode; import kr.co.pennyway.infra.common.exception.JwtErrorException; @@ -23,8 +22,7 @@ import java.util.Date; import java.util.Map; -import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.ROLE; -import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.USER_ID; +import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.*; @Slf4j @Component @@ -57,7 +55,11 @@ public String generateToken(JwtClaims claims) { @Override public JwtClaims getJwtClaimsFromToken(String token) { Claims claims = getClaimsFromToken(token); - return AccessTokenClaim.of(Long.parseLong(claims.get(USER_ID.getValue(), String.class)), claims.get(ROLE.getValue(), String.class)); + return RefreshTokenClaim.of( + Long.parseLong(claims.get(USER_ID.getValue(), String.class)), + claims.get(DEVICE_ID.getValue(), String.class), + claims.get(ROLE.getValue(), String.class) + ); } @Override diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/storage/AwsS3Adapter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/storage/AwsS3Adapter.java index 81a3aace2..1e0daf05e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/storage/AwsS3Adapter.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/storage/AwsS3Adapter.java @@ -1,8 +1,8 @@ package kr.co.pennyway.api.common.storage; import kr.co.pennyway.common.annotation.Adapter; +import kr.co.pennyway.infra.client.aws.s3.ActualIdProvider; import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; -import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; import kr.co.pennyway.infra.common.exception.StorageErrorCode; import kr.co.pennyway.infra.common.exception.StorageException; import lombok.RequiredArgsConstructor; @@ -17,12 +17,12 @@ public class AwsS3Adapter { /** * 임시 저장 경로에서 원본 저장 경로로 사진을 복사하고, 원본이 저장된 키를 반환합니다. * - * @param deleteImageUrl 임시 저장 이미지 URL - * @param type 프로필 이미지 타입 {@link ObjectKeyType} + * @param deleteImageUrl String : 임시 저장 이미지 URL + * @param type {@link ActualIdProvider} : 실제 ID를 제공하는 클래스 * @return 프로필 이미지 원본이 저장된 key * @throws StorageException 프로필 이미지 URL이 유효하지 않을 때 */ - public String saveImage(String deleteImageUrl, ObjectKeyType type) { + public String saveImage(String deleteImageUrl, ActualIdProvider type) { if (!awsS3Provider.isObjectExist(deleteImageUrl)) { log.info("프로필 이미지 URL이 유효하지 않습니다."); throw new StorageException(StorageErrorCode.NOT_FOUND); @@ -46,6 +46,16 @@ public void deleteImage(String key) { awsS3Provider.deleteObject(key); } + /** + * Image URL에 해당하는 Object가 존재하는지 확인합니다. + */ + public boolean isObjectExist(String imageUrl) { + return awsS3Provider.isObjectExist(imageUrl); + } + + /** + * S3에 저장된 Object의 Prefix를 반환합니다. + */ public String getObjectPrefix() { return awsS3Provider.getObjectPrefix(); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/PresignedUrlDtoReqValidator.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/PresignedUrlDtoReqValidator.java new file mode 100644 index 000000000..6674e5600 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/PresignedUrlDtoReqValidator.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.api.common.validator; + +import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +/** + * {@link kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto.Req}의 유효성 검사를 담당하는 Validator + */ +@Slf4j +public class PresignedUrlDtoReqValidator implements Validator { + @Override + public boolean supports(@NonNull Class clazz) { + return PresignedUrlDto.Req.class.isAssignableFrom(clazz); + } + + @Override + public void validate(@NonNull Object target, @NonNull Errors errors) { + PresignedUrlDto.Req req = (PresignedUrlDto.Req) target; + + if (ObjectKeyType.CHAT_PROFILE.equals(req.type()) && req.chatroomId() == null) { + errors.rejectValue("chatroomId", "MISSING_CHAT_PROFILE_PARAMETER", "채팅 프로필 이미지를 위해 채팅방 ID는 필수입니다."); + } + if (ObjectKeyType.CHAT.equals(req.type()) && req.chatroomId() == null) { + errors.rejectValue("chatroomId", "MISSING_CHAT_PARAMETER", "채팅 이미지를 위해 채팅방 ID는 필수입니다."); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/DomainConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/DomainConfig.java index 4acd02935..b50523c3a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/DomainConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/DomainConfig.java @@ -1,12 +1,12 @@ package kr.co.pennyway.api.config; -import kr.co.pennyway.domain.common.importer.EnablePennywayDomainConfig; -import kr.co.pennyway.domain.common.importer.PennywayDomainConfigGroup; +import kr.co.pennyway.domain.common.importer.EnablePennywayRedisDomainConfig; +import kr.co.pennyway.domain.common.importer.PennywayRedisDomainConfigGroup; import org.springframework.context.annotation.Configuration; @Configuration -@EnablePennywayDomainConfig({ - PennywayDomainConfigGroup.REDISSON +@EnablePennywayRedisDomainConfig(value = { + PennywayRedisDomainConfigGroup.REDISSON_INFRA }) public class DomainConfig { } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java index eabed9518..39e5eed1b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java @@ -17,7 +17,10 @@ KakaoOidcProperties.class }) @EnablePennywayInfraConfig({ - PennywayInfraConfigGroup.FCM + PennywayInfraConfigGroup.FCM, + PennywayInfraConfigGroup.DISTRIBUTED_COORDINATION_CONFIG, + PennywayInfraConfigGroup.GUID_GENERATOR_CONFIG, + PennywayInfraConfigGroup.MESSAGE_BROKER_CONFIG }) public class InfraConfig { } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java index ec8ed8fcc..c011ea9b3 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java @@ -101,6 +101,28 @@ public GroupedOpenApi ledgerApi() { .build(); } + @Bean + public GroupedOpenApi socketApi() { + String[] targets = {"kr.co.pennyway.api.apis.socket"}; + + return GroupedOpenApi.builder() + .packagesToScan(targets) + .group("서비스 탐색 서비스") + .addOperationCustomizer(customizer()) + .build(); + } + + @Bean + public GroupedOpenApi chatApi() { + String[] targets = {"kr.co.pennyway.api.apis.chat"}; + + return GroupedOpenApi.builder() + .packagesToScan(targets) + .group("채팅") + .addOperationCustomizer(customizer()) + .build(); + } + @Bean public GroupedOpenApi backOfficeApi() { String[] targets = {"kr.co.pennyway.api.apis.question"}; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/CorsConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/CorsConfig.java index e0aaf5925..4775cacfc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/CorsConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/CorsConfig.java @@ -15,11 +15,11 @@ @RequiredArgsConstructor public class CorsConfig { private final ServerProperties serverProperties; - + @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of(serverProperties.getLocal(), serverProperties.getDev())); + configuration.setAllowedOriginPatterns(List.of(serverProperties.getLocal(), serverProperties.getDev())); configuration.setAllowedMethods(List.of("GET", "POST", "OPTIONS", "PUT", "PATCH", "DELETE")); configuration.setAllowedHeaders(List.of("*")); configuration.setExposedHeaders(List.of(HttpHeaders.AUTHORIZATION, HttpHeaders.SET_COOKIE)); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java index c29b83092..5fedfbadc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java @@ -21,18 +21,13 @@ import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.web.cors.CorsConfigurationSource; -import static kr.co.pennyway.api.config.security.WebSecurityUrls.AUTHENTICATED_ENDPOINTS; +import static kr.co.pennyway.api.config.security.WebSecurityUrls.*; @Configuration @EnableWebSecurity @ConditionalOnDefaultWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/v1/duplicate/**", "/actuator/health"}; - private static final String[] PUBLIC_ENDPOINTS = {"/v1/questions/**"}; - private static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**", "/v1/phone/**", "/v1/find/**"}; - private static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger",}; - private final SecurityAdapterConfig securityAdapterConfig; private final CorsConfigurationSource corsConfigurationSource; private final AccessDeniedHandler accessDeniedHandler; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityFilterConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityFilterConfig.java index fb32425a6..42bb2e2fe 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityFilterConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityFilterConfig.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import kr.co.pennyway.api.common.security.filter.JwtAuthenticationFilter; import kr.co.pennyway.api.common.security.filter.JwtExceptionFilter; -import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; +import kr.co.pennyway.domain.context.account.service.ForbiddenTokenService; import kr.co.pennyway.infra.common.jwt.JwtProvider; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/WebSecurityUrls.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/WebSecurityUrls.java index 6c7ec7406..b5b90c27b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/WebSecurityUrls.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/WebSecurityUrls.java @@ -1,9 +1,9 @@ package kr.co.pennyway.api.config.security; -public class WebSecurityUrls { - protected static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/v1/duplicate/**"}; - protected static final String[] PUBLIC_ENDPOINTS = {"/v1/questions/**"}; - protected static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**", "/v1/phone/**"}; - protected static final String[] AUTHENTICATED_ENDPOINTS = {"/v1/auth"}; - protected static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger",}; +public final class WebSecurityUrls { + public static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/v1/duplicate/**"}; + public static final String[] PUBLIC_ENDPOINTS = {"/v1/questions/**"}; + public static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**", "/v1/phone/**", "/v1/find/**"}; + public static final String[] AUTHENTICATED_ENDPOINTS = {"/v1/auth"}; + public static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger",}; } diff --git a/pennyway-app-external-api/src/main/resources/application.yml b/pennyway-app-external-api/src/main/resources/application.yml index 98ffa47a6..35b0fca07 100644 --- a/pennyway-app-external-api/src/main/resources/application.yml +++ b/pennyway-app-external-api/src/main/resources/application.yml @@ -1,8 +1,8 @@ spring: profiles: group: - local: common, domain, infra - dev: common, domain, infra + local: common, domain-service, domain-rdb, domain-redis, infra + dev: common, domain-service, domain-rdb, domain-redis, infra jwt: secret-key: @@ -13,6 +13,13 @@ jwt: access-token: ${JWT_ACCESS_EXPIRATION_TIME:1800000} # 30m (30 * 60 * 1000) refresh-token: ${JWT_REFRESH_EXPIRATION_TIME:604800000} # 7d (7 * 24 * 60 * 60 * 1000) +pennyway: + rabbitmq: + validate-connection: true + admin: + phone: ${PENNYWAY_ADMIN_PHONE:1234567890} + password: ${PENNYWAY_ADMIN_PASSWORD:1234567890} + --- spring: config: @@ -31,6 +38,10 @@ springdoc: groups: enabled: true +log: + config: + filename: app-local + --- spring: config: @@ -49,8 +60,19 @@ springdoc: groups: enabled: true +log: + config: + filename: app-dev + maxHistory: 3 + maxFileSize: 10MB + totalSizeCap: 500MB + --- spring: config: activate: - on-profile: test \ No newline at end of file + on-profile: test + +pennyway: + rabbitmq: + validate-connection: false \ No newline at end of file diff --git a/pennyway-app-external-api/src/main/resources/logback-spring.xml b/pennyway-app-external-api/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..2b213094f --- /dev/null +++ b/pennyway-app-external-api/src/main/resources/logback-spring.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + + + + + + + INFO + + + ${CONSOLE_LOG_PATTERN} + + + + + + + + ${FILE_LOG_PATTERN} + + + + true + + + + ${LOG_PATH}/%d{yyyy-MM-dd}/${LOG_FILE_NAME}.%i.log + + ${LOG_MAX_FILE_SIZE} + + ${LOG_MAX_HISTORY} + + ${LOG_TOTAL_SIZE_CAP} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java index c5ef70386..bf6feb79b 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java @@ -52,11 +52,11 @@ void setUp(WebApplicationContext webApplicationContext) { .build(); } - @DisplayName("[1] 아이디, 이름, 비밀번호, 전화번호, 인증번호 필수 입력") + @DisplayName("[1] 아이디, 이름, 비밀번호, 전화번호, 인증번호, 디바이스 아이디 필수 입력") @Test void requiredInputError() throws Exception { // given - SignUpReq.General request = new SignUpReq.General("", "", "", "", ""); + SignUpReq.General request = new SignUpReq.General("", "", "", "", "", ""); // when ResultActions resultActions = mockMvc.perform( @@ -73,6 +73,7 @@ void requiredInputError() throws Exception { .andExpect(jsonPath("$.fieldErrors.password").exists()) .andExpect(jsonPath("$.fieldErrors.phone").exists()) .andExpect(jsonPath("$.fieldErrors.code").exists()) + .andExpect(jsonPath("$.fieldErrors.deviceId").exists()) .andDo(print()); } @@ -81,7 +82,7 @@ void requiredInputError() throws Exception { void idValidError() throws Exception { // given SignUpReq.General request = new SignUpReq.General("#pennyway", "페니웨이", "pennyway1234", - "010-1234-5678", "123456"); + "010-1234-5678", "123456", "AA-BBB-CCC"); // when ResultActions resultActions = mockMvc.perform( @@ -102,7 +103,7 @@ void idValidError() throws Exception { void nameValidError() throws Exception { // given SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이12345", "pennyway1234", - "010-1234-5678", "123456"); + "010-1234-5678", "123456", "AA-BBB-CCC"); // when ResultActions resultActions = mockMvc.perform( @@ -123,7 +124,7 @@ void nameValidError() throws Exception { void passwordValidError() throws Exception { // given SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway", - "010-1234-5678", "123456"); + "010-1234-5678", "123456", "AA-BBB-CCC"); // when ResultActions resultActions = mockMvc.perform( @@ -145,7 +146,7 @@ void passwordValidError() throws Exception { void phoneValidError() throws Exception { // given SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", - "01012345673", "123456"); + "01012345673", "123456", "AA-BBB-CCC"); // when ResultActions resultActions = mockMvc.perform( @@ -166,7 +167,7 @@ void phoneValidError() throws Exception { void codeValidError() throws Exception { // given SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", - "010-1234-5678", "12345"); + "010-1234-5678", "12345", "AA-BBB-CCC"); // when ResultActions resultActions = mockMvc.perform( @@ -187,7 +188,7 @@ void codeValidError() throws Exception { void someFieldMissingError() throws Exception { // given SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", - "010-1234-5678", "123456"); + "010-1234-5678", "123456", "AA-BBB-CCC"); // when ResultActions resultActions = mockMvc.perform( @@ -210,7 +211,7 @@ void someFieldMissingError() throws Exception { void signUp() throws Exception { // given SignUpReq.General request = new SignUpReq.General("pennyway123", "페니웨이", "pennyway1234", - "010-1234-5678", "123456"); + "010-1234-5678", "123456", "AA-BBB-CCC"); ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken") .maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build(); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java index bc9ac2cdf..c7b4ff544 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java @@ -4,100 +4,107 @@ import kr.co.pennyway.api.common.security.jwt.access.AccessTokenProvider; import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim; import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenProvider; -import kr.co.pennyway.api.config.ExternalApiDBTestConfig; -import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; -import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; -import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenRepository; -import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; -import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenServiceImpl; -import kr.co.pennyway.domain.config.RedisConfig; +import kr.co.pennyway.domain.context.account.service.ForbiddenTokenService; +import kr.co.pennyway.domain.context.account.service.RefreshTokenService; +import kr.co.pennyway.domain.domains.refresh.domain.RefreshToken; import kr.co.pennyway.domain.domains.user.type.Role; import kr.co.pennyway.infra.common.exception.JwtErrorCode; import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.jwt.JwtClaims; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; +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 org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.ContextConfiguration; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.springframework.test.util.AssertionErrors.assertEquals; -import static org.springframework.test.util.AssertionErrors.assertFalse; +import static org.mockito.Mockito.verify; @Slf4j @ExtendWith(MockitoExtension.class) -@DataRedisTest(properties = "spring.config.location=classpath:application-domain.yml") -@ContextConfiguration(classes = {RedisConfig.class, JwtAuthHelper.class, RefreshTokenServiceImpl.class}) -@ActiveProfiles("test") -public class JwtAuthHelperTest extends ExternalApiDBTestConfig { - @Autowired +public class JwtAuthHelperTest { private JwtAuthHelper jwtAuthHelper; - @Autowired - private RefreshTokenService refreshTokenService; - - @Autowired - private RefreshTokenRepository refreshTokenRepository; - @MockBean + @Mock private AccessTokenProvider accessTokenProvider; - @MockBean + @Mock private RefreshTokenProvider refreshTokenProvider; - @MockBean + @Mock + private RefreshTokenService refreshTokenService; + + @Mock private ForbiddenTokenService forbiddenTokenService; + @BeforeEach + public void setUp() { + jwtAuthHelper = new JwtAuthHelper(accessTokenProvider, refreshTokenProvider, refreshTokenService, forbiddenTokenService); + } + @Test @DisplayName("사용자 아이디에 해당하는 리프레시 토큰이 존재할 시, 리프레시 토큰 갱신에 성공한다.") public void RefreshTokenRefreshSuccess() { // given - RefreshToken refreshToken = RefreshToken.builder() - .userId(1L) - .token("refreshToken") - .ttl(1000L) - .build(); - refreshTokenRepository.save(refreshToken); - given(refreshTokenProvider.getJwtClaimsFromToken(refreshToken.getToken())).willReturn(RefreshTokenClaim.of(refreshToken.getUserId(), Role.USER.getType())); - given(accessTokenProvider.generateToken(any())).willReturn("newAccessToken"); - given(refreshTokenProvider.generateToken(any())).willReturn("newRefreshToken"); + Long userId = 1L; + String deviceId = "AA-BBB-CC-DDD"; + String oldRefreshToken = "refreshToken"; + String newRefreshToken = "newRefreshToken"; + String newAccessToken = "newAccessToken"; + + JwtClaims claims = RefreshTokenClaim.of(userId, deviceId, Role.USER.getType()); + + given(refreshTokenProvider.getJwtClaimsFromToken(oldRefreshToken)).willReturn(claims); + given(accessTokenProvider.generateToken(any())).willReturn(newAccessToken); + given(refreshTokenProvider.generateToken(any())).willReturn(newRefreshToken); + given(refreshTokenService.refresh(eq(userId), eq(deviceId), eq(oldRefreshToken), eq(newRefreshToken))) + .willReturn(RefreshToken.builder() + .userId(userId) + .deviceId(deviceId) + .token(newRefreshToken) + .ttl(1000L) + .build()); // when - Pair jwts = jwtAuthHelper.refresh(refreshToken.getToken()); + Pair result = jwtAuthHelper.refresh(oldRefreshToken); // then - assertEquals("사용자 아이디가 일치하지 않습니다.", refreshToken.getUserId(), jwts.getLeft()); - assertEquals("갱신된 액세스 토큰이 일치하지 않습니다.", "newAccessToken", jwts.getRight().accessToken()); - assertEquals("리프레시 토큰이 갱신되지 않았습니다.", "newRefreshToken", jwts.getRight().refreshToken()); - log.info("갱신된 리프레시 토큰 정보 : {}", refreshTokenRepository.findById(refreshToken.getUserId()).orElse(null)); + assertEquals(userId, result.getLeft(), "사용자 아이디가 일치하지 않습니다."); + assertEquals(newAccessToken, result.getRight().accessToken(), "갱신된 액세스 토큰이 일치하지 않습니다."); + assertEquals(newRefreshToken, result.getRight().refreshToken(), "리프레시 토큰이 갱신되지 않았습니다."); + + verify(refreshTokenService).refresh(eq(userId), eq(deviceId), eq(oldRefreshToken), eq(newRefreshToken)); + verify(accessTokenProvider).generateToken(any()); + verify(refreshTokenProvider).generateToken(any()); } @Test @DisplayName("사용자 아이디에 해당하는 다른 리프레시 토큰이 저장되어 있을 시, 탈취되었다고 판단하고 토큰을 제거한 후 JwtErrorException을 발생시킨다.") public void RefreshTokenRefreshFail() { // given - RefreshToken refreshToken = RefreshToken.builder() - .userId(1L) - .token("refreshToken") - .ttl(1000L) - .build(); - refreshTokenRepository.save(refreshToken); + Long userId = 1L; + String deviceId = "AA-BBB-CC-DDD"; + String oldRefreshToken = "anotherRefreshToken"; + String newRefreshToken = "newRefreshToken"; - given(refreshTokenProvider.getJwtClaimsFromToken("anotherRefreshToken")).willReturn(RefreshTokenClaim.of(refreshToken.getUserId(), Role.USER.toString())); - given(refreshTokenProvider.generateToken(any())).willReturn("newRefreshToken"); + JwtClaims claims = RefreshTokenClaim.of(userId, deviceId, Role.USER.toString()); + given(refreshTokenProvider.getJwtClaimsFromToken(oldRefreshToken)).willReturn(claims); + given(refreshTokenProvider.generateToken(any())).willReturn(newRefreshToken); + given(refreshTokenService.refresh(eq(userId), eq(deviceId), eq(oldRefreshToken), eq(newRefreshToken))) + .willThrow(new IllegalStateException("Token taken away")); - // when - JwtErrorException jwtErrorException = assertThrows(JwtErrorException.class, () -> jwtAuthHelper.refresh("anotherRefreshToken")); + // when & then + JwtErrorException exception = assertThrows(JwtErrorException.class, () -> jwtAuthHelper.refresh(oldRefreshToken)); + assertEquals(JwtErrorCode.TAKEN_AWAY_TOKEN, exception.getErrorCode()); - // then - assertEquals("탈취 시나리오 예외가 발생하지 않았습니다.", JwtErrorCode.TAKEN_AWAY_TOKEN, jwtErrorException.getErrorCode()); - assertFalse("리프레시 토큰이 삭제되지 않았습니다.", refreshTokenRepository.existsById(refreshToken.getUserId())); + verify(refreshTokenService).refresh(eq(userId), eq(deviceId), eq(oldRefreshToken), eq(newRefreshToken)); + verify(refreshTokenProvider).generateToken(any()); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java index 30c2f5a32..ee6ffc599 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java @@ -7,14 +7,14 @@ import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; import kr.co.pennyway.api.config.fixture.UserFixture; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; +import kr.co.pennyway.domain.context.account.service.OauthService; +import kr.co.pennyway.domain.context.account.service.PhoneCodeService; +import kr.co.pennyway.domain.context.account.service.UserService; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.service.UserService; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -43,6 +43,7 @@ public class AuthControllerIntegrationTest extends ExternalApiDBTestConfig { private final String expectedPhone = "010-1234-5678"; private final String expectedCode = "123456"; + private final String expectedDeviceId = "AA-BB-CC-DD"; @Autowired private MockMvc mockMvc; @@ -224,7 +225,7 @@ void generalSignUpSuccess() throws Exception { } private ResultActions performGeneralSignUpRequest(String code) throws Exception { - SignUpReq.General request = new SignUpReq.General(UserFixture.GENERAL_USER.getUsername(), "pennyway", "dkssudgktpdy1", expectedPhone, code); + SignUpReq.General request = new SignUpReq.General(UserFixture.GENERAL_USER.getUsername(), "pennyway", "dkssudgktpdy1", expectedPhone, code, expectedDeviceId); return mockMvc.perform( post("/v1/auth/sign-up") .contentType(MediaType.APPLICATION_JSON) @@ -286,7 +287,7 @@ void syncWithOauthSignUpSuccess() throws Exception { } private ResultActions performSyncWithOauthSignUpRequest(String expectedPhone, String code) throws Exception { - SignUpReq.SyncWithOauth request = new SignUpReq.SyncWithOauth("dkssudgktpdy1", expectedPhone, code); + SignUpReq.SyncWithOauth request = new SignUpReq.SyncWithOauth("dkssudgktpdy1", expectedPhone, code, expectedDeviceId); return mockMvc.perform( post("/v1/auth/link-oauth") .contentType(MediaType.APPLICATION_JSON) diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java index af0bdc4db..f3e493f79 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java @@ -8,15 +8,15 @@ import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; +import kr.co.pennyway.domain.context.account.service.OauthService; +import kr.co.pennyway.domain.context.account.service.PhoneCodeService; +import kr.co.pennyway.domain.context.account.service.UserService; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.service.UserService; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; @@ -64,6 +64,7 @@ public class OAuthControllerIntegrationTest extends ExternalApiDBTestConfig { private final String expectedNonce = "testNonce"; private final String expectedPhone = "010-1234-5678"; private final String expectedCode = "123456"; + private final String expectedDeviceId = "testDeviceId"; @Autowired private MockMvc mockMvc; @Autowired @@ -77,6 +78,14 @@ public class OAuthControllerIntegrationTest extends ExternalApiDBTestConfig { @Autowired private OauthService oauthService; + private static PhoneCodeKeyType getOauthSignUpTypeByProvider(Provider provider) { + return switch (provider) { + case KAKAO -> PhoneCodeKeyType.OAUTH_SIGN_UP_KAKAO; + case GOOGLE -> PhoneCodeKeyType.OAUTH_SIGN_UP_GOOGLE; + case APPLE -> PhoneCodeKeyType.OAUTH_SIGN_UP_APPLE; + }; + } + /** * 일반 회원가입 유저 생성 */ @@ -226,7 +235,7 @@ void signInWithNoSignedUser() throws Exception { } private ResultActions performOauthSignIn(Provider provider, String oauthId, String idToken, String nonce) throws Exception { - SignInReq.Oauth request = new SignInReq.Oauth(oauthId, idToken, expectedNonce); + SignInReq.Oauth request = new SignInReq.Oauth(oauthId, idToken, expectedNonce, expectedDeviceId); return mockMvc.perform(post("/v1/auth/oauth/sign-in") .param("provider", provider.name()) @@ -249,7 +258,7 @@ void signUpWithGeneralSignedUser() throws Exception { User user = createGeneralSignedUser(); userService.createUser(user); - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); // when ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); @@ -276,7 +285,7 @@ void signUpWithDifferentProvider() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(Provider.KAKAO)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(Provider.KAKAO)); // when ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); @@ -298,7 +307,7 @@ void signUpWithDifferentProvider() throws Exception { void signUpWithNoSignedUser() throws Exception { // given Provider provider = Provider.KAKAO; - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); // when ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); @@ -325,7 +334,7 @@ void signUpWithSameProvider() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); // when ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); @@ -344,7 +353,7 @@ void signUpWithSameProvider() throws Exception { @DisplayName("인증 코드를 요청한 provider와 다른 provider로 인증 코드를 입력하면 404 에러가 발생한다.") void signUpWithDifferentProviderCode() throws Exception { // given - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(Provider.KAKAO)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(Provider.KAKAO)); // when ResultActions result = performOauthSignUpPhoneVerification(Provider.GOOGLE, expectedCode); @@ -365,7 +374,7 @@ void signUpWithDifferentProviderCode() throws Exception { void signUpWithInvalidCode() throws Exception { // given Provider provider = Provider.KAKAO; - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); // when ResultActions result = performOauthSignUpPhoneVerification(provider, "123457"); @@ -391,7 +400,7 @@ void signUpWithDeletedOauth() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); oauthService.deleteOauth(oauth); - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); // when ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); @@ -429,7 +438,7 @@ void signUpWithGeneralSignedUser() throws Exception { User user = createGeneralSignedUser(); userService.createUser(user); - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -459,7 +468,7 @@ void signUpWithDifferentProvider() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -484,7 +493,7 @@ void signUpWithDifferentProvider() throws Exception { void signUpWithNoSignedUser() throws Exception { // given Provider provider = Provider.KAKAO; - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -510,7 +519,7 @@ void signUpWithSameProvider() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -537,7 +546,7 @@ void signUpWithDeletedOauth() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); oauthService.deleteOauth(oauth); - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, "newOauthId", expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", "newOauthId", "email")); // when @@ -557,7 +566,7 @@ void signUpWithDeletedOauth() throws Exception { } private ResultActions performOauthSignUpAccountLinking(Provider provider, String code, String oauthId) throws Exception { - SignUpReq.SyncWithAuth request = new SignUpReq.SyncWithAuth(oauthId, expectedIdToken, expectedNonce, expectedPhone, code); + SignUpReq.SyncWithAuth request = new SignUpReq.SyncWithAuth(oauthId, expectedIdToken, expectedNonce, expectedPhone, code, expectedDeviceId); return mockMvc.perform(post("/v1/auth/oauth/link-auth") .param("provider", provider.name()) .contentType("application/json") @@ -576,7 +585,7 @@ class OauthSignUpTest { void signUpWithNoSignedUser() throws Exception { // given Provider provider = Provider.KAKAO; - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -604,7 +613,7 @@ void signUpWithGeneralSignedUser() throws Exception { User user = createGeneralSignedUser(); userService.createUser(user); - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -630,7 +639,7 @@ void signUpWithOauthSignedUser() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -645,7 +654,7 @@ void signUpWithOauthSignedUser() throws Exception { } private ResultActions performOauthSignUp(Provider provider, String code) throws Exception { - SignUpReq.Oauth request = new SignUpReq.Oauth(expectedOauthId, expectedIdToken, expectedNonce, "jayang", expectedUsername, expectedPhone, code); + SignUpReq.Oauth request = new SignUpReq.Oauth(expectedOauthId, expectedIdToken, expectedNonce, "jayang", expectedUsername, expectedPhone, code, expectedDeviceId); return mockMvc.perform(post("/v1/auth/oauth/sign-up") .param("provider", provider.name()) .contentType("application/json") diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java index 62bf9599b..b7560ecf3 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java @@ -12,15 +12,15 @@ import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; import kr.co.pennyway.api.config.fixture.UserFixture; -import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; -import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; -import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; +import kr.co.pennyway.domain.context.account.service.ForbiddenTokenService; +import kr.co.pennyway.domain.context.account.service.OauthService; +import kr.co.pennyway.domain.context.account.service.RefreshTokenService; +import kr.co.pennyway.domain.context.account.service.UserService; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.refresh.domain.RefreshToken; import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.service.UserService; import kr.co.pennyway.domain.domains.user.type.Role; import kr.co.pennyway.infra.common.exception.JwtErrorCode; import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; @@ -83,6 +83,7 @@ public class UserAuthControllerIntegrationTest extends ExternalApiDBTestConfig { class SignOut { private String expectedAccessToken; private String expectedRefreshToken; + private String expectedDeviceId; private Long userId; @BeforeEach @@ -90,15 +91,16 @@ void setUp() { User user = UserFixture.GENERAL_USER.toUser(); userService.createUser(user); userId = user.getId(); + expectedDeviceId = "AA-BBB-CC-DDD"; expectedAccessToken = accessTokenProvider.generateToken(AccessTokenClaim.of(user.getId(), Role.USER.getType())); - expectedRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(user.getId(), Role.USER.getType())); + expectedRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(user.getId(), expectedDeviceId, Role.USER.getType())); } @Test @DisplayName("Scenario #1 유효한 accessToken과 refreshToken이 있다면, accessToken은 forbiddenToken으로, refreshToken은 삭제한다.") void validAccessTokenAndValidRefreshToken() throws Exception { // given - refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + refreshTokenService.create(RefreshToken.of(userId, expectedDeviceId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); // when ResultActions result = mockMvc.perform(performSignOut() @@ -108,7 +110,7 @@ void validAccessTokenAndValidRefreshToken() throws Exception { // then result.andExpect(status().isOk()).andDo(print()); assertTrue(forbiddenTokenService.isForbidden(expectedAccessToken)); - assertThrows(IllegalArgumentException.class, () -> refreshTokenService.delete(userId, expectedRefreshToken)); + refreshTokenService.deleteAll(userId); } @Test @@ -126,9 +128,10 @@ void validAccessTokenWithoutRefreshToken() throws Exception { @DisplayName("Scenario #2-1 유효한 accessToken과 다른 사용자의 유효한 refreshToken이 있다면, 401 에러를 반환한다. accessToken이 forbidden 처리되지 않으며, 사용자와 다른 사용자의 refreshToken 정보 모두 삭제되지 않는다.") void validAccessTokenAndWithOutOwnershipRefreshToken() throws Exception { // given - String unexpectedRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(1000L, Role.USER.getType())); - refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); - refreshTokenService.save(RefreshToken.of(1000L, unexpectedRefreshToken, refreshTokenProvider.getExpiryDate(unexpectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + String otherDeviceId = "BB-CCC-DDD"; + String unexpectedRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(1000L, otherDeviceId, Role.USER.getType())); + refreshTokenService.create(RefreshToken.of(userId, expectedDeviceId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + refreshTokenService.create(RefreshToken.of(1000L, otherDeviceId, unexpectedRefreshToken, refreshTokenProvider.getExpiryDate(unexpectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); // when ResultActions result = mockMvc @@ -140,9 +143,10 @@ void validAccessTokenAndWithOutOwnershipRefreshToken() throws Exception { .andExpect(jsonPath("$.code").value(JwtErrorCode.WITHOUT_OWNERSHIP_REFRESH_TOKEN.causedBy().getCode())) .andExpect(jsonPath("$.message").value(JwtErrorCode.WITHOUT_OWNERSHIP_REFRESH_TOKEN.getExplainError())) .andDo(print()); - assertDoesNotThrow(() -> refreshTokenService.delete(userId, expectedRefreshToken)); - assertDoesNotThrow(() -> refreshTokenService.delete(1000L, unexpectedRefreshToken)); assertFalse(forbiddenTokenService.isForbidden(expectedAccessToken)); + + refreshTokenService.deleteAll(userId); + refreshTokenService.deleteAll(1000L); } @Test @@ -150,7 +154,7 @@ void validAccessTokenAndWithOutOwnershipRefreshToken() throws Exception { void validAccessTokenAndInvalidRefreshToken() throws Exception { // given long ttl = refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); - refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, ttl)); + refreshTokenService.create(RefreshToken.of(userId, expectedDeviceId, expectedRefreshToken, ttl)); // when ResultActions result = mockMvc.perform(performSignOut() @@ -163,16 +167,17 @@ void validAccessTokenAndInvalidRefreshToken() throws Exception { .andExpect(jsonPath("$.code").value(JwtErrorCode.MALFORMED_TOKEN.causedBy().getCode())) .andExpect(jsonPath("$.message").value(JwtErrorCode.MALFORMED_TOKEN.getExplainError())) .andDo(print()); - assertDoesNotThrow(() -> refreshTokenService.delete(userId, expectedRefreshToken)); assertFalse(forbiddenTokenService.isForbidden(expectedAccessToken)); + + refreshTokenService.deleteAll(userId); } @Test @DisplayName("Scenario #2-3 유효한 accessToken, 유효한 refreshToken을 가진 사용자가 refresh 하기 전의 refreshToken을 사용하는 경우, accessToken을 forbidden에 등록하고 refreshToken을 cache에서 제거한다. (refreshToken 탈취 대체 시나리오)") void validAccessTokenAndOldRefreshToken() throws Exception { // given - String oldRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, Role.USER.getType())); - refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + String oldRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, expectedDeviceId, Role.USER.getType())); + refreshTokenService.create(RefreshToken.of(userId, expectedDeviceId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); // when ResultActions result = mockMvc.perform(performSignOut() @@ -184,16 +189,16 @@ void validAccessTokenAndOldRefreshToken() throws Exception { .andExpect(status().isOk()) .andExpect(header().exists(HttpHeaders.SET_COOKIE)) .andDo(print()); - assertThrows(IllegalArgumentException.class, () -> refreshTokenService.delete(userId, oldRefreshToken)); - assertThrows(IllegalArgumentException.class, () -> refreshTokenService.delete(userId, expectedRefreshToken)); assertTrue(forbiddenTokenService.isForbidden(expectedAccessToken)); + + refreshTokenService.deleteAll(userId); } @Test @DisplayName("Scenario #3 유효하지 않은 accessToken과 유효한 refreshToken이 있다면 401 에러를 반환한다.") void invalidAccessTokenAndValidRefreshToken() throws Exception { // given - refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + refreshTokenService.create(RefreshToken.of(userId, expectedDeviceId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); // when ResultActions result = mockMvc.perform(performSignOut() @@ -335,7 +340,7 @@ void linkOauthWithDeletedOauth() throws Exception { private ResultActions performLinkOauth(Provider provider, String oauthId, User requestUser) throws Exception { UserDetails userDetails = SecurityUserDetails.from(requestUser); - SignInReq.Oauth request = new SignInReq.Oauth(oauthId, "idToken", "nonce"); + SignInReq.Oauth request = new SignInReq.Oauth(oauthId, "idToken", "nonce", "deviceId"); return mockMvc.perform(put("/v1/link-oauth") .contentType(MediaType.APPLICATION_JSON) diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/AuthFindServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/AuthFindServiceTest.java index 41a5cdf4b..e12cbeec6 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/AuthFindServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/AuthFindServiceTest.java @@ -3,9 +3,9 @@ import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.context.account.service.UserService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignServiceTest.java index d1ee07421..f86cac888 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignServiceTest.java @@ -2,10 +2,10 @@ import kr.co.pennyway.api.apis.auth.dto.UserSyncDto; import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.context.account.service.UserService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java index bfd9f4b20..67837d801 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java @@ -6,9 +6,9 @@ import kr.co.pennyway.api.apis.auth.service.UserOauthSignService; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenProvider; -import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; -import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.context.account.service.ForbiddenTokenService; +import kr.co.pennyway.domain.context.account.service.OauthService; +import kr.co.pennyway.domain.context.account.service.RefreshTokenService; import kr.co.pennyway.infra.common.exception.JwtErrorCode; import kr.co.pennyway.infra.common.exception.JwtErrorException; import kr.co.pennyway.infra.common.jwt.JwtClaims; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberBathGetControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberBathGetControllerTest.java new file mode 100644 index 000000000..6b8ac13d6 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberBathGetControllerTest.java @@ -0,0 +1,126 @@ +package kr.co.pennyway.api.apis.chat.controller; + +import kr.co.pennyway.api.apis.chat.dto.ChatMemberRes; +import kr.co.pennyway.api.apis.chat.usecase.ChatMemberUseCase; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.LongStream; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = ChatMemberController.class) +@ActiveProfiles("test") +public class ChatMemberBathGetControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private ChatMemberUseCase chatMemberUseCase; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(MockMvcRequestBuilders.get("/**").with(csrf())) + .build(); + } + + @Test + @DisplayName("채팅방 멤버 조회에 성공한다") + @WithSecurityMockUser + void successReadChatMembers() throws Exception { + // given + Long chatRoomId = 1L; + Set memberIds = Set.of(1L, 2L, 3L); + List expectedResponse = createMockMemberDetails(); + + given(chatMemberUseCase.readChatMembers(chatRoomId, memberIds)).willReturn(expectedResponse); + + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/v2/chat-rooms/{chatRoomId}/chat-members", chatRoomId) + .param("ids", "1,2,3") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.chatMembers").isArray()) + .andExpect(jsonPath("$.data.chatMembers.length()").value(3)) + .andDo(print()); + } + + @Test + @DisplayName("50개를 초과하는 멤버 ID 요청 시 실패한다") + @WithSecurityMockUser + void failReadChatMembersWhenExceedLimit() throws Exception { + // given + Long chatRoomId = 1L; + Set memberIds = LongStream.rangeClosed(1, 51) + .boxed() + .collect(Collectors.toSet()); + + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/v2/chat-rooms/{chatRoomId}/chat-members", chatRoomId) + .param("ids", memberIds.stream() + .map(String::valueOf) + .collect(Collectors.joining(","))) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + + @Test + @DisplayName("ids가 null인 경우 실패한다 <400 Bad Request>") + @WithSecurityMockUser + void failReadChatMembersWhenIdsIsNull() throws Exception { + // given + Long chatRoomId = 1L; + + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/v2/chat-rooms/{chatRoomId}/chat-members", chatRoomId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + + @Test + @DisplayName("ids가 빈 값일 경우 실패한다") + @WithSecurityMockUser + void failReadChatMembersWhenIdsIsEmpty() throws Exception { + // given + Long chatRoomId = 1L; + + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/v2/chat-rooms/{chatRoomId}/chat-members", chatRoomId) + .param("ids", "") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + + private List createMockMemberDetails() { + return List.of( + new ChatMemberRes.MemberDetail(1L, 2L, "User1", ChatMemberRole.MEMBER, null, LocalDateTime.now(), null), + new ChatMemberRes.MemberDetail(2L, 3L, "User2", ChatMemberRole.MEMBER, null, LocalDateTime.now(), null), + new ChatMemberRes.MemberDetail(3L, 4L, "User3", ChatMemberRole.MEMBER, null, LocalDateTime.now(), null) + ); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomSaveControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomSaveControllerUnitTest.java new file mode 100644 index 000000000..4b8858a7d --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomSaveControllerUnitTest.java @@ -0,0 +1,119 @@ +package kr.co.pennyway.api.apis.chat.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.chat.dto.ChatRes; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomReq; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.apis.chat.usecase.ChatRoomUseCase; +import kr.co.pennyway.api.config.fixture.ChatRoomFixture; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = ChatRoomController.class) +@ActiveProfiles("test") +public class ChatRoomSaveControllerUnitTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private ChatRoomUseCase chatRoomUseCase; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(MockMvcRequestBuilders.get("/**").with(csrf())) + .build(); + } + + @Test + @DisplayName("모두 올바른 값을 입력하면 채팅방 생성 요청에 성공한다.") + @WithSecurityMockUser + void createChatRoomSuccess() throws Exception { + // given + ChatRoom fixture = ChatRoomFixture.PRIVATE_CHAT_ROOM.toEntity(1L); + ChatRoomReq.Create request = ChatRoomFixture.PRIVATE_CHAT_ROOM.toCreateRequest(); + given(chatRoomUseCase.createChatRoom(request, 1L)).willReturn(createChatRoomResponse(fixture, null, true, 1, 10)); + + // when + ResultActions result = performPostChatRoom(request); + + // then + result.andDo(print()) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("채팅 이미지가 null이어도 채팅방 생성 요청에 성공한다.") + @WithSecurityMockUser + void createChatRoomSuccessWithNullBackgroundImageUrl() throws Exception { + // given + ChatRoom fixture = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(1L); + ChatRoomReq.Create request = ChatRoomFixture.PUBLIC_CHAT_ROOM.toCreateRequest(); + + given(chatRoomUseCase.createChatRoom(request, 1L)).willReturn(createChatRoomResponse(fixture, null, true, 1, 10)); + + // when + ResultActions result = performPostChatRoom(request); + + // then + result.andDo(print()) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("delete/로 시작하지 않는 null이 아닌 채팅 이미지 URL이 입력되면 채팅방 생성 요청에 실패한다. (422 Unprocessable Entity)") + @WithSecurityMockUser + void createChatRoomFailWithInvalidBackgroundImageUrl() throws Exception { + // given + ChatRoomReq.Create request = new ChatRoomReq.Create("페니웨이", "짱짱", "1234", "invalid"); + + // when + ResultActions result = performPostChatRoom(request); + + // then + result.andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + private ResultActions performPostChatRoom(ChatRoomReq.Create request) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders.post("/v2/chat-rooms") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + + private ChatRoomRes.Detail createChatRoomResponse(ChatRoom chatRoom, ChatRes.ChatDetail lastMessage, boolean isAdmin, int participantCount, long unreadMessageCount) { + return new ChatRoomRes.Detail( + chatRoom.getId(), + chatRoom.getTitle(), + chatRoom.getDescription(), + chatRoom.getBackgroundImageUrl(), + chatRoom.getPassword() != null, + isAdmin, + participantCount, + chatRoom.getCreatedAt(), + lastMessage, + unreadMessageCount + ); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberBatchGetIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberBatchGetIntegrationTest.java new file mode 100644 index 000000000..ba976db59 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberBatchGetIntegrationTest.java @@ -0,0 +1,109 @@ +package kr.co.pennyway.api.apis.chat.integration; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.chat.dto.ChatMemberRes; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.util.ApiTestHelper; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.ChatRoomFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.chat.service.ChatMemberService; +import kr.co.pennyway.domain.context.chat.service.ChatRoomService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Slf4j +@ExternalApiIntegrationTest +public class ChatMemberBatchGetIntegrationTest extends ExternalApiDBTestConfig { + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ChatRoomService chatRoomService; + + @Autowired + private ChatMemberService chatMemberService; + + @Autowired + private UserService userService; + + @Autowired + private JwtProvider accessTokenProvider; + + @LocalServerPort + private int port; + + private ApiTestHelper apiTestHelper; + + private User owner; + private ChatRoom chatRoom; + private ChatMember ownerMember; + + @BeforeEach + void setUp() { + apiTestHelper = new ApiTestHelper(restTemplate, objectMapper, accessTokenProvider); + + owner = userService.createUser(UserFixture.GENERAL_USER.toUser()); + chatRoom = chatRoomService.create(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(1L)); + ownerMember = chatMemberService.createAdmin(owner, chatRoom); + } + + @Test + @DisplayName("채팅방 멤버 조회를 성공한다") + void successReadChatMembers() { + // given + List members = createTestMembers(10); + List memberIds = members.stream().map(ChatMember::getId).toList(); + + // when + ResponseEntity response = apiTestHelper.callApi( + "http://localhost:" + port + "/v2/chat-rooms/{chatRoomId}/chat-members?ids={ids}", + HttpMethod.GET, + owner, + null, + new TypeReference>>>() { + }, + chatRoom.getId(), + String.join(",", memberIds.stream().map(String::valueOf).toList()) + ); + SuccessResponse>> body = (SuccessResponse>>) response.getBody(); + List payload = body.getData().get("chatMembers"); + + // then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(memberIds.size(), payload.size()); + } + + private List createTestMembers(int count) { + List createdMembers = new ArrayList<>(); + for (int i = 0; i < count; i++) { + User member = userService.createUser(UserFixture.GENERAL_USER.toUser()); + createdMembers.add(chatMemberService.createMember(member, chatRoom)); + } + return createdMembers; + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberJoinIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberJoinIntegrationTest.java new file mode 100644 index 000000000..eab1bb03d --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberJoinIntegrationTest.java @@ -0,0 +1,333 @@ +package kr.co.pennyway.api.apis.chat.integration; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.chat.dto.ChatMemberReq; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.ChatMemberFixture; +import kr.co.pennyway.api.config.fixture.ChatRoomFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.infra.client.guid.IdGenerator; +import kr.co.pennyway.infra.common.event.ChatRoomJoinEvent; +import kr.co.pennyway.infra.common.event.ChatRoomJoinEventHandler; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@Slf4j +@ExternalApiIntegrationTest +@RecordApplicationEvents +public class ChatMemberJoinIntegrationTest extends ExternalApiDBTestConfig { + private static final String BASE_URL = "/v2/chat-rooms/{chatRoomId}/chat-members"; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private ChatMemberRepository chatMemberRepository; + + @Autowired + private JwtProvider accessTokenProvider; + + @Autowired + private IdGenerator idGenerator; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ApplicationEvents events; + + @MockBean + private ChatRoomJoinEventHandler chatRoomJoinEventHandler; + + @LocalServerPort + private int port; + + private String url; + + @BeforeEach + void setUp() { + url = "http://localhost:" + port + BASE_URL; + } + + @Test + @DisplayName("Happy Path: 공개 채팅방 가입 성공") + void successJoinPublicRoom() { + // given + User admin = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(admin, chatRoom)); + + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ChatRoomJoinEvent.class); + + // when + ResponseEntity response = postJoining(user, chatRoom.getId(), new ChatMemberReq.Join(null)); + + // then + assertAll( + () -> assertEquals(HttpStatus.OK, response.getStatusCode()), + () -> assertTrue(chatMemberRepository.existsByChatRoomIdAndUserId(chatRoom.getId(), user.getId())), + () -> verify(chatRoomJoinEventHandler).handle(eventCaptor.capture()), + () -> { + ChatRoomJoinEvent capturedEvent = eventCaptor.getValue(); + assertEquals(chatRoom.getId(), capturedEvent.chatRoomId()); + assertEquals(user.getName(), capturedEvent.userName()); + } + ); + } + + @Test + @DisplayName("동시에 350명의 사용자가 가입을 시도하면 정원 초과로 인해, 299명만 가입에 성공한다") + void concurrentJoinRequests() throws InterruptedException { + // given + User admin = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(admin, chatRoom)); + + List users = IntStream.range(0, 350) + .mapToObj(i -> userRepository.save(UserFixture.GENERAL_USER.toUser())) + .toList(); + + // when + CountDownLatch latch = new CountDownLatch(users.size()); + List> futures = users.stream() + .map(user -> CompletableFuture.supplyAsync(() -> { + try { + return JoinResult.from( + postJoining(user, chatRoom.getId(), new ChatMemberReq.Join(null)) + ); + } finally { + latch.countDown(); + } + })) + .toList(); + + latch.await(); + + List results = futures.stream() + .map(CompletableFuture::join) + .toList(); + + // then + assertAll( + () -> assertEquals(299, results.stream().filter(JoinResult::isSuccess).count()), + () -> assertEquals(51, results.stream().filter(JoinResult::isFullRoomError).count()), + () -> assertEquals(300, chatMemberRepository.countByChatRoomIdAndActive(chatRoom.getId())) + ); + } + + @Test + @Disabled + @DisplayName("트랜잭션 롤백: 이벤트 발행 실패 시 가입도 롤백된다") + void rollbackWhenEventPublishFails() { + // given + User admin = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(admin, chatRoom)); + + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + + doThrow(new RuntimeException("Event publish failed")).when(chatRoomJoinEventHandler).handle(any(ChatRoomJoinEvent.class)); + + // when + ResponseEntity response = postJoining(user, chatRoom.getId(), new ChatMemberReq.Join(null)); + + // then + assertAll( + () -> assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()), + () -> assertFalse(chatMemberRepository.existsByChatRoomIdAndUserId(chatRoom.getId(), user.getId())) + ); + } + + @Test + @DisplayName("인증되지 않은 사용자는 가입할 수 없다") + void failWhenUserNotAuthenticated() { + // given + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + // when + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(new ChatMemberReq.Join(null), headers), + new ParameterizedTypeReference<>() { + }, + chatRoom.getId() + ); + + // then + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + } + + @Test + @DisplayName("비공개 채팅방 가입 시 올바른 비밀번호로 가입할 수 있다") + void successJoinPrivateRoomWithValidPassword() { + // given + User admin = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(admin, chatRoom)); + + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + Integer expectedPassword = ChatRoomFixture.PRIVATE_CHAT_ROOM.toEntity(idGenerator.generate()).getPassword(); + + // when + ResponseEntity response = postJoining(user, chatRoom.getId(), new ChatMemberReq.Join(expectedPassword.toString())); + + // then + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + @DisplayName("같은 사용자가 하나의 채팅방에 동시에 100개의 요청을 보내면, 100개의 가입 요청 중 1개만 성공한다") + void concurrentJoinRequestsFromSameUser() throws InterruptedException { + // given + User admin = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(admin, chatRoom)); + + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + + // when + CountDownLatch latch = new CountDownLatch(100); + List> futures = IntStream.range(0, 100) + .mapToObj(i -> CompletableFuture.supplyAsync(() -> { + try { + return JoinResult.from( + postJoining(user, chatRoom.getId(), new ChatMemberReq.Join(null)) + ); + } finally { + latch.countDown(); + } + })) + .toList(); + + latch.await(); + + List results = futures.stream() + .map(CompletableFuture::join) + .toList(); + + // then + assertAll( + () -> assertEquals(1, results.stream().filter(JoinResult::isSuccess).count()), + () -> assertEquals(99, results.stream().filter(JoinResult::isAlreadyJoinedError).count()), + () -> assertEquals(2, chatMemberRepository.countByChatRoomIdAndActive(chatRoom.getId())) + ); + } + + private ResponseEntity postJoining(User user, Long chatRoomId, ChatMemberReq.Join request) { + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + createHttpEntity(user, request), + Object.class, + chatRoomId + ); + + Object body = response.getBody(); + if (body == null) { + throw new IllegalStateException("예상치 못한 반환 타입입니다. : " + response); + } + + if (response.getStatusCode().is2xxSuccessful()) { + return ResponseEntity + .status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(objectMapper.convertValue(body, new TypeReference>>() { + })); + } else { + return ResponseEntity + .status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(objectMapper.convertValue(body, new TypeReference() { + })); + } + } + + private HttpEntity createHttpEntity(User user, ChatMemberReq.Join request) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessTokenProvider.generateToken(AccessTokenClaim.of(user.getId(), user.getRole().name()))); + headers.setContentType(MediaType.APPLICATION_JSON); + + return new HttpEntity<>(request, headers); + } + + @Getter + private static class JoinResult { + private final HttpStatusCode status; + private final Object body; + private final boolean isSuccess; + + private JoinResult(ResponseEntity response) { + this.status = response.getStatusCode(); + this.body = response.getBody(); + this.isSuccess = status == HttpStatus.OK; + } + + public static JoinResult from(ResponseEntity response) { + return new JoinResult(response); + } + + public boolean isFullRoomError() { + if (!isSuccess && body instanceof ErrorResponse errorResponse) { + return errorResponse.getCode().equals(ChatRoomErrorCode.FULL_CHAT_ROOM.causedBy().getCode()); + } + return false; + } + + public boolean isAlreadyJoinedError() { + if (!isSuccess && body instanceof ErrorResponse errorResponse) { + return errorResponse.getCode().equals(ChatMemberErrorCode.ALREADY_JOINED.causedBy().getCode()); + } + return false; + } + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatPaginationGetIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatPaginationGetIntegrationTest.java new file mode 100644 index 000000000..9d07aac44 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatPaginationGetIntegrationTest.java @@ -0,0 +1,257 @@ +package kr.co.pennyway.api.apis.chat.integration; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.chat.dto.ChatRes; +import kr.co.pennyway.api.common.response.SliceResponseTemplate; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.util.ApiTestHelper; +import kr.co.pennyway.api.common.util.RequestParameters; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.ChatMemberFixture; +import kr.co.pennyway.api.config.fixture.ChatRoomFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import kr.co.pennyway.domain.domains.message.domain.ChatMessageBuilder; +import kr.co.pennyway.domain.domains.message.repository.ChatMessageRepository; +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType; +import kr.co.pennyway.domain.domains.message.type.MessageContentType; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.infra.client.guid.IdGenerator; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Slf4j +@ExternalApiIntegrationTest +public class ChatPaginationGetIntegrationTest extends ExternalApiDBTestConfig { + private static final String BASE_URL = "/v2/chat-rooms/{chatRoomId}/chats"; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private ChatMemberRepository chatMemberRepository; + + @Autowired + private ChatMessageRepository chatMessageRepository; + + @Autowired + private JwtProvider accessTokenProvider; + + @Autowired + private IdGenerator idGenerator; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private RedisTemplate redisTemplate; + + private ApiTestHelper apiTestHelper; + + @BeforeEach + void setUp() { + apiTestHelper = new ApiTestHelper(restTemplate, objectMapper, accessTokenProvider); + } + + @AfterEach + void tearDown() { + Set keys = redisTemplate.keys("chatroom:*:message"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + + @Test + @DisplayName("채팅방의 이전 메시지들을 정상적으로 페이징하여 조회할 수 있다") + void successReadChats() { + // given + User user = createUser(); + ChatRoom chatRoom = createChatRoom(); + createChatMember(user, chatRoom, ChatMemberRole.ADMIN); + List messages = setupTestMessages(chatRoom.getId(), user.getId(), 50); + + // when + ResponseEntity response = performRequest(user, chatRoom.getId(), messages.get(49).getChatId(), 30); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + SliceResponseTemplate slice = extractChatDetail(response); + + assertEquals(messages.get(48).getChatId(), slice.contents().get(0).chatId(), "lastMessageId에 해당하는 메시지는 포함되지 않아야 합니다"); + assertThat(slice.contents()).hasSize(30); + assertThat(slice.hasNext()).isTrue(); + } + ); + } + + @Test + @DisplayName("채팅방 멤버가 아닌 사용자는 메시지를 조회할 수 없다") + void readChatsWithoutPermissionTest() { + // given + User nonMember = createUser(); + ChatRoom chatRoom = createChatRoom(); + + // when + ResponseEntity response = performRequest(nonMember, chatRoom.getId(), 0L, 30); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("존재하지 않는 채팅방의 메시지는 조회할 수 없다") + void readChatsFromNonExistentRoomTest() { + // given + User user = createUser(); + Long nonExistentRoomId = 9999L; + + // when + ResponseEntity response = performRequest(user, nonExistentRoomId, 0L, 30); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("마지막 페이지를 조회할 때 hasNext는 false여야 한다") + void readLastPageTest() { + // given + User user = createUser(); + ChatRoom chatRoom = createChatRoom(); + createChatMember(user, chatRoom, ChatMemberRole.ADMIN); + List messages = setupTestMessages(chatRoom.getId(), user.getId(), 10); + + // when + ResponseEntity response = performRequest(user, chatRoom.getId(), messages.get(0).getChatId(), 10); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + SliceResponseTemplate slice = extractChatDetail(response); + + assertThat(slice.contents()).hasSize(0); + assertThat(slice.hasNext()).isFalse(); + } + ); + } + + @Test + @DisplayName("메시지가 없는 채팅방을 조회하면 빈 리스트를 반환해야 한다") + void readEmptyChatsTest() { + // given + User user = createUser(); + ChatRoom chatRoom = createChatRoom(); + createChatMember(user, chatRoom, ChatMemberRole.ADMIN); + + // when + ResponseEntity response = performRequest(user, chatRoom.getId(), Long.MAX_VALUE, 30); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + SliceResponseTemplate slice = extractChatDetail(response); + + assertThat(slice.contents()).isEmpty(); + assertThat(slice.hasNext()).isFalse(); + } + ); + } + + private ResponseEntity performRequest(User user, Long chatRoomId, Long lastMessageId, int size) { + RequestParameters parameters = RequestParameters.defaultGet(BASE_URL) + .user(user) + .queryParams(RequestParameters.createQueryParams("lastMessageId", lastMessageId, "size", size)) + .uriVariables(new Object[]{chatRoomId}) + .build(); + + return apiTestHelper.callApi( + parameters, + new TypeReference>>>() { + } + ); + } + + private User createUser() { + return userRepository.save(UserFixture.GENERAL_USER.toUser()); + } + + private ChatRoom createChatRoom() { + return chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + } + + private ChatMember createChatMember(User user, ChatRoom chatRoom, ChatMemberRole role) { + return switch (role) { + case ADMIN -> chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(user, chatRoom)); + case MEMBER -> chatMemberRepository.save(ChatMemberFixture.MEMBER.toEntity(user, chatRoom)); + }; + } + + private List setupTestMessages(Long chatRoomId, Long senderId, int count) { + List messages = new ArrayList<>(); + + for (int i = 0; i < count; i++) { + ChatMessage message = ChatMessageBuilder.builder() + .chatRoomId(chatRoomId) + .chatId(idGenerator.generate()) + .content("Test message " + i) + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(senderId) + .build(); + + messages.add(chatMessageRepository.save(message)); + } + + return messages; + } + + private SliceResponseTemplate extractChatDetail(ResponseEntity response) { + SliceResponseTemplate slice = null; + + try { + SuccessResponse>> successResponse = (SuccessResponse>>) response.getBody(); + slice = successResponse.getData().get("chats"); + } catch (Exception e) { + fail("응답 데이터 추출에 실패했습니다.", e); + } + + return slice; + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatRoomCreateIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatRoomCreateIntegrationTest.java new file mode 100644 index 000000000..61b7f5cad --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatRoomCreateIntegrationTest.java @@ -0,0 +1,90 @@ +package kr.co.pennyway.api.apis.chat.integration; + +import kr.co.pennyway.api.apis.chat.dto.ChatRoomReq; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; +import kr.co.pennyway.api.common.storage.AwsS3Adapter; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.ChatRoomFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.infra.client.aws.s3.ActualIdProvider; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + + +@Slf4j +@ExternalApiIntegrationTest +public class ChatRoomCreateIntegrationTest extends ExternalApiDBTestConfig { + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private UserService userService; + + @Autowired + private JwtProvider accessTokenProvider; + + @MockBean + private AwsS3Adapter awsS3Adapter; + + @LocalServerPort + private int port; + + @Test + @DisplayName("사용자는 채팅방 생성에 성공한다.") + void success() { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + ChatRoomReq.Create request = ChatRoomFixture.PRIVATE_CHAT_ROOM.toCreateRequest(); + given(awsS3Adapter.saveImage(eq(request.backgroundImageUrl()), any(ActualIdProvider.class))).willReturn(ChatRoomFixture.getOriginImageUrl()); + given(awsS3Adapter.getObjectPrefix()).willReturn("https://cdn.test.com/"); + + // when + ResponseEntity>> response = postCreating(user, request); + ChatRoomRes.Detail detail = response.getBody().getData().get("chatRoom"); + + // then + Assertions.assertEquals(HttpStatus.OK, response.getStatusCode(), "200 OK 응답을 받아야 합니다."); + Assertions.assertEquals(request.title(), detail.title(), "생성된 채팅방의 제목이 일치해야 합니다."); + Assertions.assertEquals(request.description(), detail.description(), "생성된 채팅방의 설명이 일치해야 합니다."); + Assertions.assertEquals("https://cdn.test.com/" + ChatRoomFixture.getOriginImageUrl(), detail.backgroundImageUrl(), "생성된 채팅방의 배경 이미지 URL이 일치해야 합니다."); + Assertions.assertTrue(detail.isPrivate(), "생성된 채팅방은 비공개여야 합니다."); + } + + private ResponseEntity>> postCreating(User user, ChatRoomReq.Create request) { + return restTemplate.exchange( + "http://localhost:" + port + "/v2/chat-rooms", + HttpMethod.POST, + createHttpEntity(user, request), + new ParameterizedTypeReference<>() { + } + ); + } + + private HttpEntity createHttpEntity(User user, ChatRoomReq.Create request) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessTokenProvider.generateToken(AccessTokenClaim.of(user.getId(), user.getRole().name()))); + headers.setContentType(MediaType.APPLICATION_JSON); + + return new HttpEntity<>(request, headers); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatRoomDetailIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatRoomDetailIntegrationTest.java new file mode 100644 index 000000000..88a81ca70 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatRoomDetailIntegrationTest.java @@ -0,0 +1,238 @@ +package kr.co.pennyway.api.apis.chat.integration; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.util.ApiTestHelper; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.ChatRoomFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import kr.co.pennyway.domain.domains.message.domain.ChatMessageBuilder; +import kr.co.pennyway.domain.domains.message.repository.ChatMessageRepositoryImpl; +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType; +import kr.co.pennyway.domain.domains.message.type.MessageContentType; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.infra.client.guid.IdGenerator; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@ExternalApiIntegrationTest +public class ChatRoomDetailIntegrationTest extends ExternalApiDBTestConfig { + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private JwtProvider accessTokenProvider; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private ChatMemberRepository chatMemberRepository; + + @Autowired + private ChatMessageRepositoryImpl chatMessageRepository; + + @Autowired + private IdGenerator idGenerator; + + private ApiTestHelper apiTestHelper; + + @LocalServerPort + private int port; + + @BeforeEach + void setUp() { + apiTestHelper = new ApiTestHelper(restTemplate, objectMapper, accessTokenProvider); + } + + @AfterEach + void tearDown() { + Set keys = redisTemplate.keys("chatroom:*:message"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + + chatMemberRepository.deleteAll(); + chatRoomRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + @DisplayName("Happy Path: 사용자는 채팅방 상세 정보를 조회할 수 있다.") + void successGetChatRoomDetail() { + // given + var owner = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(1L)); + var ownerMember = chatMemberRepository.save(ChatMember.of(owner, chatRoom, ChatMemberRole.ADMIN)); + + User member = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatMember participant = chatMemberRepository.save(ChatMember.of(member, chatRoom, ChatMemberRole.MEMBER)); + + int expectedRecentParticipantCount = 1; // 나 자신은 제외 + int expectedMessageCount = 5; + + for (int i = 1; i <= 5; i++) { + chatMessageRepository.save(createTestMessage(chatRoom.getId(), (long) i, i % 2 == 0 ? owner.getId() : participant.getId())); + } + + // when + ResponseEntity response = performApi(owner, chatRoom.getId()); + + // then + assertAll( + () -> assertEquals(HttpStatus.OK, response.getStatusCode()), + () -> { + SuccessResponse> result = (SuccessResponse>) response.getBody(); + ChatRoomRes.RoomWithParticipants payload = result.getData().get("chatRoom"); + + assertNotNull(result); + assertEquals(owner.getId(), payload.myInfo().id(), "내 ID가 일치해야 한다"); + assertEquals(ownerMember.getRole(), ChatMemberRole.ADMIN, "나는 방장 권한이어야 한다"); + assertEquals(expectedRecentParticipantCount, payload.recentParticipants().size(), "최근 참여자 개수가 일치해야 한다"); + assertEquals(expectedMessageCount, payload.recentMessages().size(), "최근 메시지 개수가 일치해야 한다"); + assertTrue(payload.otherParticipants().isEmpty(), "다른 참여자가 없어야 한다"); + } + ); + } + + @Test + @DisplayName("채팅방 멤버가 아닌 사용자는 조회할 수 없다") + void failGetChatRoomDetailWhenNotMember() { + var owner = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(2L)); + var ownerMember = chatMemberRepository.save(ChatMember.of(owner, chatRoom, ChatMemberRole.ADMIN)); + + // given + User nonMember = userRepository.save(UserFixture.GENERAL_USER.toUser()); + + // when + ResponseEntity response = performApi(nonMember, chatRoom.getId()); + + // then + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode(), "채팅방 멤버가 아닌 사용자는 조회할 수 없어야 한다"); + } + + @Test + @DisplayName("최근 메시지가 없는 채팅방도 정상적으로 조회된다") + void successGetChatRoomDetailWithoutMessages() { + // given + var owner = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(3L)); + var ownerMember = chatMemberRepository.save(ChatMember.of(owner, chatRoom, ChatMemberRole.ADMIN)); + + var member = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var participant = chatMemberRepository.save(ChatMember.of(member, chatRoom, ChatMemberRole.MEMBER)); + + // when + ResponseEntity response = performApi(member, chatRoom.getId()); + + // then + SuccessResponse> result = (SuccessResponse>) response.getBody(); + ChatRoomRes.RoomWithParticipants payload = result.getData().get("chatRoom"); + + assertNotNull(result); + assertTrue(payload.recentMessages().isEmpty(), "최근 메시지가 없어야 한다"); + } + + @Test + @DisplayName("채팅방에 다수의 참여자가 있는 경우 정상적으로 조회된다") + void successGetChatRoomDetailWithManyParticipants() { + // given + var owner = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(4L)); + var ownerMember = chatMemberRepository.save(ChatMember.of(owner, chatRoom, ChatMemberRole.ADMIN)); + + int expectedParticipantCount = 10; + List participants = createMultipleParticipants(expectedParticipantCount, chatRoom); + + chatMessageRepository.save(createTestMessage(chatRoom.getId(), 1L, owner.getId())); + chatMessageRepository.save(createTestMessage(chatRoom.getId(), 2L, participants.get(0).getId())); + chatMessageRepository.save(createTestMessage(chatRoom.getId(), 3L, participants.get(1).getId())); + + // when + ResponseEntity response = performApi(owner, chatRoom.getId()); + + // then + SuccessResponse> result = (SuccessResponse>) response.getBody(); + ChatRoomRes.RoomWithParticipants payload = result.getData().get("chatRoom"); + + assertAll( + () -> assertNotNull(payload), + () -> assertEquals(payload.recentParticipants().size(), 2, "최근 참여자 개수가 일치해야 한다."), + () -> assertEquals(payload.otherParticipants().size(), expectedParticipantCount - 2, "다른 참여자 개수가 일치해야 한다.") + ); + } + + private ChatMessage createTestMessage(Long chatRoomId, Long idx, Long senderId) { + return ChatMessageBuilder.builder() + .chatRoomId(chatRoomId) + .chatId(idGenerator.generate()) + .content("Test message " + idx) + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(senderId) + .build(); + } + + private List createMultipleParticipants(int count, ChatRoom chatRoom) { + List participants = new ArrayList<>(); + + for (int i = 0; i < count; ++i) { + var user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + chatMemberRepository.save(ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER)); + participants.add(user); + } + + return participants; + } + + private ResponseEntity performApi(User user, Long roomId) { + return apiTestHelper.callApi( + "http://localhost:" + port + "/v2/chat-rooms/{roomId}", + HttpMethod.GET, + user, + null, + new TypeReference>>() { + }, + roomId + ); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomPatchHelperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomPatchHelperTest.java new file mode 100644 index 000000000..2e8d218a3 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomPatchHelperTest.java @@ -0,0 +1,136 @@ +package kr.co.pennyway.api.apis.chat.service; + +import kr.co.pennyway.api.apis.chat.dto.ChatRoomReq; +import kr.co.pennyway.api.common.storage.AwsS3Adapter; +import kr.co.pennyway.api.config.fixture.ChatRoomFixture; +import kr.co.pennyway.domain.context.chat.service.ChatRoomPatchService; +import kr.co.pennyway.domain.context.chat.service.ChatRoomService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.infra.common.exception.StorageException; +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 org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ChatRoomPatchHelperTest { + private static final Long CHATROOM_ID = 1L; + private static final String DELETE_PATH = "delete/chatroom/1/test-uuid_123.jpg"; + private static final String CONVERTED_PATH = "chatroom/1/test-uuid_123.jpg"; + private static final String ORIGIN_PATH = "chatroom/1/origin/test-uuid_321.jpg"; + + @Mock + private ChatRoomService chatRoomService; + @Mock + private ChatRoomPatchService chatRoomPatchService; + @Mock + private AwsS3Adapter awsS3Adapter; + @InjectMocks + private ChatRoomPatchHelper chatRoomPatchHelper; + + @Test + @DisplayName("이미지가 없는 채팅방에 이미지 추가") + void addNewImageToEmptyRoom() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(CHATROOM_ID); + ReflectionTestUtils.setField(chatRoom, "backgroundImageUrl", null); + + when(chatRoomService.readChatRoom(CHATROOM_ID)).thenReturn(Optional.of(chatRoom)); + when(awsS3Adapter.saveImage(eq(DELETE_PATH), any())).thenReturn(CONVERTED_PATH); + + ChatRoomReq.Update request = new ChatRoomReq.Update("title", "desc", null, DELETE_PATH); + + // when + chatRoomPatchHelper.updateChatRoom(CHATROOM_ID, request); + + // then + verify(awsS3Adapter, never()).deleteImage(anyString()); + verify(awsS3Adapter).saveImage(eq(DELETE_PATH), any()); + } + + @Test + @DisplayName("이미지가 있는 채팅방의 이미지 삭제") + void deleteExistingImage() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(CHATROOM_ID); + ReflectionTestUtils.setField(chatRoom, "backgroundImageUrl", ORIGIN_PATH); + + when(chatRoomService.readChatRoom(CHATROOM_ID)).thenReturn(Optional.of(chatRoom)); + + ChatRoomReq.Update request = new ChatRoomReq.Update("title", "desc", null, null); + + // when + chatRoomPatchHelper.updateChatRoom(CHATROOM_ID, request); + + // then + verify(awsS3Adapter).deleteImage(ORIGIN_PATH); + verify(awsS3Adapter, never()).saveImage(anyString(), any()); + } + + @Test + @DisplayName("이미지가 있는 채팅방의 이미지 변경") + void updateExistingImage() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(CHATROOM_ID); + ReflectionTestUtils.setField(chatRoom, "backgroundImageUrl", ORIGIN_PATH); + + when(chatRoomService.readChatRoom(CHATROOM_ID)).thenReturn(Optional.of(chatRoom)); + when(awsS3Adapter.saveImage(eq(DELETE_PATH), any())).thenReturn(CONVERTED_PATH); + + ChatRoomReq.Update request = new ChatRoomReq.Update("title", "desc", null, DELETE_PATH); + + // when + chatRoomPatchHelper.updateChatRoom(CHATROOM_ID, request); + + // then + verify(awsS3Adapter).deleteImage(ORIGIN_PATH); + verify(awsS3Adapter).saveImage(eq(DELETE_PATH), any()); + } + + @Test + @DisplayName("이미지가 있는 채팅방의 이미지 유지") + void keepExistingImage() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(CHATROOM_ID); + ReflectionTestUtils.setField(chatRoom, "backgroundImageUrl", ORIGIN_PATH); + + when(chatRoomService.readChatRoom(CHATROOM_ID)).thenReturn(Optional.of(chatRoom)); + when(awsS3Adapter.getObjectPrefix()).thenReturn("https://cdn.test.com/"); + when(awsS3Adapter.isObjectExist(ORIGIN_PATH)).thenReturn(true); + + ChatRoomReq.Update request = new ChatRoomReq.Update("title", "desc", null, "https://cdn.test.com/" + ORIGIN_PATH); + + // when + chatRoomPatchHelper.updateChatRoom(CHATROOM_ID, request); + + // then + verify(awsS3Adapter, never()).deleteImage(anyString()); + verify(awsS3Adapter, never()).saveImage(anyString(), any()); + verify(awsS3Adapter).isObjectExist(ORIGIN_PATH); + } + + @Test + @DisplayName("잘못된 이미지 URL 패턴으로 요청시 예외 발생") + void throwExceptionForInvalidUrlPattern() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(CHATROOM_ID); + ReflectionTestUtils.setField(chatRoom, "backgroundImageUrl", ORIGIN_PATH); + + when(chatRoomService.readChatRoom(CHATROOM_ID)).thenReturn(Optional.of(chatRoom)); + when(awsS3Adapter.getObjectPrefix()).thenReturn("https://cdn.test.com/"); + + ChatRoomReq.Update request = new ChatRoomReq.Update("title", "desc", null, "invalid/path/image.jpg"); + + // when & then + assertThrows(StorageException.class, () -> chatRoomPatchHelper.updateChatRoom(CHATROOM_ID, request)); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchServiceTest.java new file mode 100644 index 000000000..ad45518ff --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSearchServiceTest.java @@ -0,0 +1,145 @@ +package kr.co.pennyway.api.apis.chat.service; + +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.domain.context.chat.service.ChatMessageService; +import kr.co.pennyway.domain.context.chat.service.ChatMessageStatusService; +import kr.co.pennyway.domain.context.chat.service.ChatRoomService; +import kr.co.pennyway.domain.domains.chatroom.dto.ChatRoomDetail; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import kr.co.pennyway.domain.domains.message.domain.ChatMessageBuilder; +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType; +import kr.co.pennyway.domain.domains.message.type.MessageContentType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ChatRoomSearchServiceTest { + @InjectMocks + private ChatRoomSearchService chatRoomSearchService; + + @Mock + private ChatRoomService chatRoomService; + @Mock + private ChatMessageStatusService chatMessageStatusService; + @Mock + private ChatMessageService chatMessageService; + + @Test + @DisplayName("사용자의 채팅방 목록과 각 방의 읽지 않은 메시지 수, 마지막 메시지를 정상적으로 조회한다") + void successReadChatRooms() { + // given + Long userId = 1L; + List chatRooms = List.of( + new ChatRoomDetail(1L, "Room1", "", "", 123456, LocalDateTime.now(), true, 2, true), + new ChatRoomDetail(2L, "Room2", "", "", null, LocalDateTime.now(), false, 2, true) + ); + + given(chatRoomService.readChatRoomsByUserId(userId)).willReturn(chatRooms); + + // room1: 마지막으로 읽은 메시지 ID 10, 읽지 않은 메시지 5개 + ChatMessage firstRoomLastMessage = ChatMessageBuilder.builder().chatRoomId(2L).chatId(1L).content("Hello").contentType(MessageContentType.TEXT).categoryType(MessageCategoryType.NORMAL).sender(userId).build(); + + given(chatMessageStatusService.readLastReadMessageId(userId, 1L)).willReturn(10L); + given(chatMessageService.countUnreadMessages(1L, 10L)).willReturn(5L); + given(chatMessageService.readRecentMessages(1L, 1)).willReturn(List.of(firstRoomLastMessage)); + + // room2: 마지막으로 읽은 메시지 ID 20, 읽지 않은 메시지 3개 + ChatMessage secondRoomLastMessage = ChatMessageBuilder.builder().chatRoomId(2L).chatId(100L).content("jayang님이 입장하셨습니다.").contentType(MessageContentType.TEXT).categoryType(MessageCategoryType.SYSTEM).sender(userId).build(); + + given(chatMessageStatusService.readLastReadMessageId(userId, 2L)).willReturn(20L); + given(chatMessageService.countUnreadMessages(2L, 20L)).willReturn(3L); + given(chatMessageService.readRecentMessages(2L, 1)).willReturn(List.of(secondRoomLastMessage)); + + // when + List result = chatRoomSearchService.readChatRooms(userId); + + // then + assertAll( + () -> assertEquals(2, result.size(), "조회된 채팅방 목록은 2개여야 한다."), + () -> assertEquals(5L, result.get(0).unreadMessageCount(), "Room1의 읽지 않은 메시지 수는 5개여야 한다."), + () -> assertEquals(firstRoomLastMessage.getChatId(), result.get(0).lastMessage().chatId(), "Room1의 마지막 메시지는 ID가 일치해야 한다."), + () -> assertEquals(3L, result.get(1).unreadMessageCount(), "Room2의 읽지 않은 메시지 수는 3개여야 한다."), + () -> assertEquals(secondRoomLastMessage.getChatId(), result.get(1).lastMessage().chatId(), "Room2의 마지막 메시지는 ID가 일치해야 한다.") + ); + } + + @Test + @DisplayName("채팅방이 없는 경우 빈 Map을 반환한다") + void returnEmptyMapWhenNoRooms() { + // given + Long userId = 1L; + given(chatRoomService.readChatRoomsByUserId(userId)).willReturn(Collections.emptyList()); + + // when + List result = chatRoomSearchService.readChatRooms(userId); + + // then + assertTrue(result.isEmpty()); + verify(chatMessageStatusService, never()).readLastReadMessageId(eq(userId), anyLong()); + verify(chatMessageService, never()).countUnreadMessages(anyLong(), anyLong()); + verify(chatMessageService, never()).countUnreadMessages(anyLong(), anyLong()); + } + + @Test + @DisplayName("읽지 않은 메시지 수 조회 중, 모든 조회가 실패한다.") + void continueProcessingOnError() { + // given + Long userId = 1L; + List chatRooms = List.of( + new ChatRoomDetail(1L, "Room1", "", "", 123456, LocalDateTime.now(), true, 2, true), + new ChatRoomDetail(2L, "Room2", "", "", null, LocalDateTime.now(), false, 2, true) + ); + + given(chatRoomService.readChatRoomsByUserId(userId)).willReturn(chatRooms); + + // room1: 정상 처리 + given(chatMessageStatusService.readLastReadMessageId(userId, 1L)).willReturn(10L); + + // room2: 오류 발생 + given(chatMessageStatusService.readLastReadMessageId(userId, 2L)) + .willThrow(new RuntimeException("Failed to get last read message id")); + + // when - then + assertThrows(RuntimeException.class, () -> chatRoomSearchService.readChatRooms(userId)); + } + + @Test + @DisplayName("각 서비스 호출이 정해진 순서대로 실행된다") + void verifyServiceCallOrder() { + // given + Long userId = 1L; + List chatRooms = List.of( + new ChatRoomDetail(1L, "Room1", "", "", 123456, LocalDateTime.now(), true, 2, true) + ); + + InOrder inOrder = inOrder(chatRoomService, chatMessageStatusService, chatMessageService, chatMessageService); + + given(chatRoomService.readChatRoomsByUserId(userId)).willReturn(chatRooms); + given(chatMessageStatusService.readLastReadMessageId(userId, 1L)).willReturn(10L); + given(chatMessageService.readRecentMessages(1L, 1)).willReturn(Collections.emptyList()); + given(chatMessageService.countUnreadMessages(userId, 10L)).willReturn(5L); + + // when + chatRoomSearchService.readChatRooms(userId); + + // then + inOrder.verify(chatRoomService).readChatRoomsByUserId(userId); + inOrder.verify(chatMessageStatusService).readLastReadMessageId(userId, 1L); + inOrder.verify(chatMessageService).countUnreadMessages(userId, 10L); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomWithParticipantsSearchServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomWithParticipantsSearchServiceTest.java new file mode 100644 index 000000000..3ff14e531 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatRoomWithParticipantsSearchServiceTest.java @@ -0,0 +1,199 @@ +package kr.co.pennyway.api.apis.chat.service; + +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.common.storage.AwsS3Adapter; +import kr.co.pennyway.api.config.fixture.ChatMemberFixture; +import kr.co.pennyway.api.config.fixture.ChatRoomFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.chat.service.ChatMemberService; +import kr.co.pennyway.domain.context.chat.service.ChatMessageService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import kr.co.pennyway.domain.domains.message.domain.ChatMessageBuilder; +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType; +import kr.co.pennyway.domain.domains.message.type.MessageContentType; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.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.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@Slf4j +@ExtendWith(MockitoExtension.class) +public class ChatRoomWithParticipantsSearchServiceTest { + private final Long userId = 1L; + private ChatRoom chatRoom; + + @InjectMocks + private ChatRoomWithParticipantsSearchService service; + @Mock + private UserService userService; + @Mock + private ChatMemberService chatMemberService; + @Mock + private ChatMessageService chatMessageService; + @Mock + private AwsS3Adapter awsS3Adapter; + + @BeforeEach + void setUp() { + chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(1L); + } + + @Test + @DisplayName("관리자인 사용자가 채팅방 참여자 정보와 최근 메시지를 성공적으로 조회한다") + public void successToRetrieveChatRoomWithParticipantsAndRecentMessages() { + // given + ChatMember myInfo = createChatMember(userId, UserFixture.GENERAL_USER.toUser(), chatRoom, ChatMemberRole.ADMIN); + List recentMessages = createRecentMessages(); + List recentParticipants = createRecentParticipantDetails(); + List otherParticipants = createOtherParticipantSummaries(); + + given(userService.readUser(userId)).willReturn(Optional.of(UserFixture.GENERAL_USER.toUser())); + given(chatMemberService.readChatMember(userId, chatRoom.getId())).willReturn(Optional.of(myInfo)); + given(chatMessageService.readRecentMessages(eq(chatRoom.getId()), anyInt())).willReturn(recentMessages); + given(chatMemberService.readChatMembersByUserIds(eq(chatRoom.getId()), anySet())).willReturn(recentParticipants); + given(chatMemberService.readChatMemberIdsByUserIdsNotIn(eq(chatRoom.getId()), anySet())).willReturn(otherParticipants); + + // when + ChatRoomRes.RoomWithParticipants result = service.execute(userId, chatRoom.getId()); + + // then + log.debug("result: {}", result); + + assertAll( + () -> assertEquals(userId, result.myInfo().id()), + () -> assertEquals(2, result.recentParticipants().size()), + () -> assertEquals(2, result.otherParticipants().size()), + () -> assertEquals(3, result.recentMessages().size()) + ); + + // verify + verify(userService).readUser(userId); + verify(chatMemberService).readChatMember(userId, chatRoom.getId()); + verify(chatMessageService).readRecentMessages(eq(chatRoom.getId()), anyInt()); + verify(chatMemberService).readChatMembersByUserIds(eq(chatRoom.getId()), anySet()); + verify(chatMemberService).readChatMemberIdsByUserIdsNotIn(eq(chatRoom.getId()), anySet()); + verify(chatMemberService, never()).readAdmin(chatRoom.getId()); + } + + @Test + @DisplayName("일반 회원이 채팅방 참여자 정보와 최근 메시지를 조회하면 관리자 정보도 함께 조회된다") + public void memberSuccessToRetrieveChatRoomWithParticipantsIncludingAdmin() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + ChatMember myInfo = createChatMember(userId, user, chatRoom, ChatMemberRole.MEMBER); + ChatMemberResult.Detail adminDetail = new ChatMemberResult.Detail(2L, "Admin", ChatMemberRole.ADMIN, true, 2L, LocalDateTime.now(), user.getProfileImageUrl()); + List recentMessages = createRecentMessages(); + List recentParticipants = createRecentParticipantDetails(); + List otherParticipants = createOtherParticipantSummaries(); + + given(userService.readUser(userId)).willReturn(Optional.of(UserFixture.GENERAL_USER.toUser())); + given(chatMemberService.readChatMember(userId, chatRoom.getId())).willReturn(Optional.of(myInfo)); + given(chatMessageService.readRecentMessages(eq(chatRoom.getId()), eq(15))).willReturn(recentMessages); + given(chatMemberService.readChatMembersByUserIds(eq(chatRoom.getId()), anySet())).willReturn(recentParticipants); + given(chatMemberService.readAdmin(chatRoom.getId())).willReturn(Optional.of(adminDetail)); + given(chatMemberService.readChatMemberIdsByUserIdsNotIn(eq(chatRoom.getId()), anySet())).willReturn(otherParticipants); + + // when + ChatRoomRes.RoomWithParticipants result = service.execute(userId, chatRoom.getId()); + + // then + assertAll( + () -> assertEquals(userId, result.myInfo().id()), + () -> assertEquals(ChatMemberRole.MEMBER, result.myInfo().role()), + () -> assertEquals(3, result.recentParticipants().size()), // 관리자 정보가 포함되어야 함 + () -> assertTrue(result.recentParticipants().stream() + .anyMatch(member -> member.role() == ChatMemberRole.ADMIN)), + () -> assertEquals(2, result.otherParticipants().size()), + () -> assertEquals(3, result.recentMessages().size()) + ); + + // verify + verify(chatMemberService).readAdmin(chatRoom.getId()); + } + + @Test + @DisplayName("존재하지 않는 채팅방 멤버 조회 시 예외가 발생한다") + void throwExceptionWhenChatMemberNotFound() { + // given + given(userService.readUser(userId)).willReturn(Optional.of(UserFixture.GENERAL_USER.toUser())); + given(chatMemberService.readChatMember(userId, chatRoom.getId())).willReturn(Optional.empty()); + + // when + ChatMemberErrorException exception = assertThrows(ChatMemberErrorException.class, + () -> service.execute(userId, chatRoom.getId())); + + // then + assertEquals(ChatMemberErrorCode.NOT_FOUND, exception.getBaseErrorCode()); + + verify(chatMemberService).readChatMember(userId, chatRoom.getId()); + verifyNoMoreInteractions(chatMemberService, chatMessageService); + } + + private List createRecentParticipantDetails() { + return List.of( + new ChatMemberResult.Detail(2L, "User2", ChatMemberRole.MEMBER, true, 20L, LocalDateTime.now(), null), + new ChatMemberResult.Detail(3L, "User3", ChatMemberRole.MEMBER, true, 30L, LocalDateTime.now(), null) + ); + } + + private List createOtherParticipantSummaries() { + return List.of( + new ChatMemberResult.Summary(5L, "User5"), + new ChatMemberResult.Summary(6L, "User6") + ); + } + + private ChatMember createChatMember(Long userId, User user, ChatRoom chatRoom, ChatMemberRole role) { + ChatMember member; + + switch (role) { + case ADMIN -> member = ChatMemberFixture.ADMIN.toEntity(user, chatRoom); + case MEMBER -> member = ChatMemberFixture.MEMBER.toEntity(user, chatRoom); + default -> throw new IllegalArgumentException("Unexpected role: " + role); + } + + ReflectionTestUtils.setField(member, "id", userId); + return member; + } + + private List createRecentMessages() { + return List.of( + createChatMessage(3L, "Message 3", 1L), + createChatMessage(2L, "Message 2", 2L), + createChatMessage(1L, "Message 1", 3L) + ); + } + + private ChatMessage createChatMessage(Long chatId, String content, Long senderId) { + return ChatMessageBuilder.builder() + .chatRoomId(chatRoom.getId()) + .chatId(chatId) + .content(content) + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(senderId) + .build(); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryIntegrationTest.java index a47faeb32..70e890397 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryIntegrationTest.java @@ -7,13 +7,13 @@ import kr.co.pennyway.api.config.fixture.SpendingCustomCategoryFixture; import kr.co.pennyway.api.config.fixture.SpendingFixture; import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; +import kr.co.pennyway.domain.context.finance.service.SpendingService; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -42,7 +42,7 @@ public class SpendingCategoryIntegrationTest extends ExternalApiDBTestConfig { @Autowired private UserService userService; @Autowired - private SpendingCustomCategoryService spendingCustomCategoryService; + private SpendingCategoryService spendingCustomCategoryService; @Test @DisplayName("사용자 정의 지출 카테고리를 삭제하고, 삭제된 카테고리를 가지는 지출 내역 또한 삭제된다.") diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryUpdateIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryUpdateIntegrationTest.java index da51f0ae6..22ece49dd 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryUpdateIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryUpdateIntegrationTest.java @@ -8,11 +8,11 @@ import kr.co.pennyway.api.config.ExternalApiIntegrationTest; import kr.co.pennyway.api.config.fixture.SpendingCustomCategoryFixture; import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.service.UserService; import kr.co.pennyway.infra.common.jwt.JwtProvider; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.DisplayName; @@ -38,7 +38,7 @@ public class SpendingCategoryUpdateIntegrationTest extends ExternalApiDBTestConf private UserService userService; @Autowired - private SpendingCustomCategoryService spendingCustomCategoryService; + private SpendingCategoryService spendingCategoryService; @Autowired private JwtProvider accessTokenProvider; @@ -64,7 +64,7 @@ void withOutPermission() { void success() { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); - SpendingCustomCategory category = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); + SpendingCustomCategory category = spendingCategoryService.createSpendingCustomCategory(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); String expectedName = "뉴 카테고리"; // when diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java index e81d254c9..20d682da5 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java @@ -8,13 +8,13 @@ import kr.co.pennyway.api.config.ExternalApiIntegrationTest; import kr.co.pennyway.api.config.fixture.SpendingFixture; import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; +import kr.co.pennyway.domain.context.finance.service.SpendingService; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; @@ -50,7 +50,7 @@ public class SpendingControllerIntegrationTest extends ExternalApiDBTestConfig { @Autowired private SpendingService spendingService; @Autowired - private SpendingCustomCategoryService spendingCustomCategoryService; + private SpendingCategoryService spendingCategoryService; @Autowired private NamedParameterJdbcTemplate jdbcTemplate; @@ -58,7 +58,7 @@ public class SpendingControllerIntegrationTest extends ExternalApiDBTestConfig { @Order(1) @Nested @DisplayName("지출 내역 추가하기") - class CreateSpending { + class PendSpending { @Test @DisplayName("request의 categoryId가 -1인 경우, spendingCustomCategory가 null인 Spending을 생성한다.") @Transactional @@ -85,7 +85,7 @@ void createSpendingSuccess() throws Exception { void createSpendingWithCustomCategorySuccess() throws Exception { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); - SpendingCustomCategory category = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategory.of("잉여비", SpendingCategory.LIVING, user)); + SpendingCustomCategory category = spendingCategoryService.createSpendingCustomCategory(SpendingCustomCategory.of("잉여비", SpendingCategory.LIVING, user)); SpendingReq request = new SpendingReq(10000, category.getId(), SpendingCategory.CUSTOM, LocalDate.now(), "소비처", "메모"); // when diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java index d67d806b2..305046d9a 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java @@ -8,11 +8,11 @@ import kr.co.pennyway.api.config.fixture.SpendingFixture; import kr.co.pennyway.api.config.fixture.TargetAmountFixture; import kr.co.pennyway.api.config.fixture.UserFixture; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.finance.service.SpendingService; +import kr.co.pennyway.domain.context.finance.service.TargetAmountService; import kr.co.pennyway.domain.domains.target.domain.TargetAmount; -import kr.co.pennyway.domain.domains.target.service.TargetAmountService; import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateServiceTest.java new file mode 100644 index 000000000..3016f8856 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateServiceTest.java @@ -0,0 +1,78 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.repository.SpendingCustomCategoryRepository; +import kr.co.pennyway.domain.domains.spending.repository.SpendingRepository; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Slf4j +@ExternalApiIntegrationTest +public class DailySpendingAggregateServiceTest extends ExternalApiDBTestConfig { + @Autowired + private UserRepository userRepository; + + @Autowired + private SpendingRepository spendingRepository; + + @Autowired + private SpendingCustomCategoryRepository spendingCustomCategoryRepository; + + @Autowired + private DailySpendingAggregateService dailySpendingAggregateService; + + private static Spending createSpending(String accountName, LocalDateTime spendAt, SpendingCategory category, Integer amount, SpendingCustomCategory spendingCustomCategory, User user) { + return Spending.builder() + .accountName(accountName) + .spendAt(spendAt) + .category(category) + .amount(amount) + .spendingCustomCategory(spendingCustomCategory) + .user(user) + .build(); + } + + @Test + public void shouldReturnDailySpendingDescOrder() { + // given + var user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var spendingCustomCategory1 = spendingCustomCategoryRepository.save(SpendingCustomCategory.of("커스텀1", SpendingCategory.EDUCATION, user)); + var spendingCustomCategory2 = spendingCustomCategoryRepository.save(SpendingCustomCategory.of("커스텀2", SpendingCategory.FOOD, user)); + + var today = LocalDateTime.now(); + + var defaultFoodSpending1 = spendingRepository.save(createSpending("시스템 카테고리 지출1", today, SpendingCategory.FOOD, 10000, null, user)); + var defaultFoodSpending2 = spendingRepository.save(createSpending("시스템 카테고리 지출2", today, SpendingCategory.FOOD, 20000, null, user)); + var defaultEducationSpending1 = spendingRepository.save(createSpending("시스템 카테고리 지출3", today, SpendingCategory.EDUCATION, 30000, null, user)); + var defaultEducationSpending2 = spendingRepository.save(createSpending("시스템 카테고리 지출4", today, SpendingCategory.EDUCATION, 40000, null, user)); + var systemEducationSpending1 = spendingRepository.save(createSpending("커스텀 카테고리 지출1", today, SpendingCategory.CUSTOM, 50000, spendingCustomCategory1, user)); + var systemEducationSpending2 = spendingRepository.save(createSpending("커스텀 카테고리 지출2", today, SpendingCategory.CUSTOM, 60000, spendingCustomCategory1, user)); + var systemFoodSpending1 = spendingRepository.save(createSpending("커스텀 카테고리 지출3", today, SpendingCategory.CUSTOM, 70000, spendingCustomCategory2, user)); + var systemFoodSpending2 = spendingRepository.save(createSpending("커스텀 카테고리 지출4", today, SpendingCategory.CUSTOM, 80000, spendingCustomCategory2, user)); + + // when + var result = dailySpendingAggregateService.execute(user.getId(), today.getYear(), today.getMonthValue(), today.getDayOfMonth()); + + // then + assertEquals(result.get(0).getFirst(), systemFoodSpending1.getCategory()); + assertEquals(result.get(0).getSecond(), systemFoodSpending1.getAmount() + systemFoodSpending2.getAmount()); + assertEquals(result.get(1).getFirst(), systemEducationSpending1.getCategory()); + assertEquals(result.get(1).getSecond(), systemEducationSpending1.getAmount() + systemEducationSpending2.getAmount()); + assertEquals(result.get(2).getFirst(), defaultEducationSpending1.getCategory()); + assertEquals(result.get(2).getSecond(), defaultEducationSpending1.getAmount() + defaultEducationSpending2.getAmount()); + assertEquals(result.get(3).getFirst(), defaultFoodSpending1.getCategory()); + assertEquals(result.get(3).getSecond(), defaultFoodSpending1.getAmount() + defaultFoodSpending2.getAmount()); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java index b9cfbf32f..14ff777ca 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java @@ -7,12 +7,12 @@ import kr.co.pennyway.api.config.fixture.SpendingCustomCategoryFixture; import kr.co.pennyway.api.config.fixture.SpendingFixture; import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; +import kr.co.pennyway.domain.context.finance.service.SpendingService; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.extern.slf4j.Slf4j; import org.hibernate.engine.spi.SessionFactoryImplementor; import org.hibernate.engine.spi.SessionImplementor; @@ -39,7 +39,7 @@ class SpendingSearchServiceTest extends ExternalApiDBTestConfig { @Autowired private NamedParameterJdbcTemplate jdbcTemplate; @Autowired - private SpendingCustomCategoryService spendingCustomCategoryService; + private SpendingCategoryService spendingCategoryService; @PersistenceContext private EntityManager entityManager; @@ -65,7 +65,7 @@ void testReadSpendingsLazyLoading() { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); SpendingCustomCategory spendingCustomCategory = SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user); - spendingCustomCategoryService.createSpendingCustomCategory(spendingCustomCategory); + spendingCategoryService.createSpendingCustomCategory(spendingCustomCategory); SpendingFixture.bulkInsertSpending(user, 100, spendingCustomCategory.getId(), jdbcTemplate); // when diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java index 058df3b5a..dcedb8210 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java @@ -1,13 +1,12 @@ package kr.co.pennyway.api.apis.ledger.service; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; -import kr.co.pennyway.api.common.security.authorization.SpendingCategoryManager; import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; +import kr.co.pennyway.domain.context.finance.service.SpendingService; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; import lombok.extern.slf4j.Slf4j; @@ -29,11 +28,9 @@ public class SpendingUpdateServiceTest { private SpendingUpdateService spendingUpdateService; @Mock - private SpendingCustomCategoryService spendingCustomCategoryService; + private SpendingCategoryService spendingCategoryService; @Mock private SpendingService spendingService; - @Mock - private SpendingCategoryManager spendingCategoryManager; private Spending spending; private Spending spendingWithCustomCategory; @@ -45,7 +42,7 @@ public class SpendingUpdateServiceTest { @BeforeEach void setUp() { - spendingUpdateService = new SpendingUpdateService(spendingService, spendingCustomCategoryService, spendingCategoryManager); + spendingUpdateService = new SpendingUpdateService(spendingService, spendingCategoryService); request = new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), "소비처", "메모"); requestWithCustomCategory = new SpendingReq(10000, 1L, SpendingCategory.CUSTOM, LocalDate.now(), "소비처", "메모"); @@ -64,7 +61,7 @@ void testUpdateSpendingWithCustomCategoryNotFound() { // given Long spendingId = 1L; given(spendingService.readSpending(spendingId)).willReturn(Optional.of(spending)); - given(spendingCustomCategoryService.readSpendingCustomCategory(1L)).willReturn(Optional.empty()); + given(spendingCategoryService.readSpendingCustomCategory(1L)).willReturn(Optional.empty()); // when - then SpendingErrorException exception = assertThrows(SpendingErrorException.class, () -> { @@ -79,7 +76,7 @@ void testUpdateSpendingWithCustomCategory() { // given Long spendingId = 1L; given(spendingService.readSpending(spendingId)).willReturn(Optional.of(spending)); - given(spendingCustomCategoryService.readSpendingCustomCategory(1L)).willReturn(Optional.of(customCategory)); + given(spendingCategoryService.readSpendingCustomCategory(1L)).willReturn(Optional.of(customCategory)); // when - then assertDoesNotThrow(() -> spendingUpdateService.updateSpending(spendingId, requestWithCustomCategory)); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/notification/controller/GetNotificationsControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/notification/controller/GetNotificationsControllerUnitTest.java index 22c403088..55cd8d0f3 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/notification/controller/GetNotificationsControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/notification/controller/GetNotificationsControllerUnitTest.java @@ -59,7 +59,7 @@ void getNotificationsWithDefaultParameters() throws Exception { Notification notification = NotificationFixture.ANNOUNCEMENT_DAILY_SPENDING.toEntity(UserFixture.GENERAL_USER.toUser()); NotificationDto.Info info = NotificationDto.Info.from(notification); - given(notificationUseCase.getNotifications(eq(1L), any())).willReturn(NotificationDto.SliceRes.from(List.of(info), pa, numberOfElements, false)); + given(notificationUseCase.getReadNotifications(eq(1L), any())).willReturn(NotificationDto.SliceRes.from(List.of(info), pa, numberOfElements, false)); // when ResultActions result = performGetNotifications(page); @@ -82,7 +82,7 @@ void getNotificationsWithInfiniteScroll() throws Exception { NotificationDto.Info info = NotificationDto.Info.from(notification); NotificationDto.SliceRes sliceRes = NotificationDto.SliceRes.from(List.of(info), pa, numberOfElements, false); - given(notificationUseCase.getNotifications(eq(1L), any())).willReturn(sliceRes); + given(notificationUseCase.getReadNotifications(eq(1L), any())).willReturn(sliceRes); // when ResultActions result = performGetNotifications(page); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java index cfdd21eb6..6cc67f399 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java @@ -1,10 +1,8 @@ package kr.co.pennyway.api.apis.storage.controller; -import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; import kr.co.pennyway.api.apis.storage.usecase.StorageUseCase; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; -import kr.co.pennyway.infra.common.exception.StorageErrorCode; -import kr.co.pennyway.infra.common.exception.StorageException; +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,10 +15,10 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; -import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(controllers = StorageController.class) @@ -42,38 +40,104 @@ void setUp(WebApplicationContext webApplicationContext) { @Test @WithSecurityMockUser - @DisplayName("Type이 CHAT이고, ChatroomId가 NULL일 때 400 응답을 반환한다.") - void getPresignedUrlWithNullChatroomId() throws Exception { - // given - PresignedUrlDto.Req request = new PresignedUrlDto.Req("CHAT", "jpg", null); - given(storageUseCase.getPresignedUrl(1L, request)).willThrow(new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER)); + @DisplayName("jpg, png, jpeg 이외의 확장자로 요청 시 422 응답을 반환한다.") + void getPresignedUrlWithInvalidExt() throws Exception { + // when + ResultActions resultActions = getPresignedUrlRequest(ObjectKeyType.PROFILE, "gif", null); + + // then + resultActions + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithSecurityMockUser + @DisplayName("유효하지 않은 Type으로 요청 시 422 응답을 반환한다.") + void getPresignedUrlWithInvalidType() throws Exception { + // when + ResultActions resultActions = mockMvc.perform(get("/v1/storage/presigned-url") + .param("type", "INVALID") + .param("ext", "jpg")); + + // then + resultActions + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithSecurityMockUser + @DisplayName("Type이 CHAT이고, ChatroomId가 NULL일 때 422 응답을 반환한다.") + void getPresignedUrlWithNullChatroomIdForChat() throws Exception { + // when + ResultActions resultActions = getPresignedUrlRequest(ObjectKeyType.CHAT, "jpg", null); + + // then + resultActions + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithSecurityMockUser + @DisplayName("올바른 Profile 파라미터로 요청 시 200 응답을 반환한다.") + void getPresignedUrlWithValidProfileParameters() throws Exception { + // when + ResultActions resultActions = getPresignedUrlRequest(ObjectKeyType.PROFILE, "jpg", null); + + // then + resultActions.andExpect(status().isOk()); + } + + @Test + @WithSecurityMockUser + @DisplayName("올바른 Chat 파라미터로 요청 시 200 응답을 반환한다.") + void getPresignedUrlWithValidChatParameters() throws Exception { + // when + ResultActions resultActions = getPresignedUrlRequest(ObjectKeyType.CHAT, "jpg", 1L); + + // then + resultActions.andExpect(status().isOk()); + } + @Test + @WithSecurityMockUser + @DisplayName("올바른 ChatroomProfile 파라미터로 요청 시 200 응답을 반환한다.") + void getPresignedUrlWithValidChatroomProfileParameters() throws Exception { // when - ResultActions resultActions = getPresignedUrlRequest(request); + ResultActions resultActions = getPresignedUrlRequest(ObjectKeyType.CHATROOM_PROFILE, "jpg", null); // then - resultActions.andExpect(status().isBadRequest()); + resultActions.andExpect(status().isOk()); } @Test @WithSecurityMockUser - @DisplayName("Type이 CHATROOM_PROFILE이고, ChatroomId가 NULL일 때 400 응답을 반환한다.") - void getPresignedUrlWithNullChatroomIdForChatroomProfile() throws Exception { - // given - PresignedUrlDto.Req request = new PresignedUrlDto.Req("CHATROOM_PROFILE", "jpg", null); - given(storageUseCase.getPresignedUrl(1L, request)).willThrow(new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER)); + @DisplayName("올바른 ChatProfile 파라미터로 요청 시 200 응답을 반환한다.") + void getPresignedUrlWithValidChatProfileParameters() throws Exception { + // when + ResultActions resultActions = getPresignedUrlRequest(ObjectKeyType.CHAT_PROFILE, "jpg", 1L); + // then + resultActions.andExpect(status().isOk()); + } + + @Test + @WithSecurityMockUser + @DisplayName("올바른 Feed 파라미터로 요청 시 200 응답을 반환한다.") + void getPresignedUrlWithValidFeedParameters() throws Exception { // when - ResultActions resultActions = getPresignedUrlRequest(request); + ResultActions resultActions = getPresignedUrlRequest(ObjectKeyType.FEED, "jpg", null); // then - resultActions.andExpect(status().isBadRequest()); + resultActions.andExpect(status().isOk()); } - private ResultActions getPresignedUrlRequest(PresignedUrlDto.Req request) throws Exception { + private ResultActions getPresignedUrlRequest(ObjectKeyType type, String ext, Long chatroomId) throws Exception { return mockMvc.perform(get("/v1/storage/presigned-url") - .param("type", request.type()) - .param("ext", request.ext()) - .param("chatRoomId", request.chatroomId())); + .param("type", type.name()) + .param("ext", ext) + .param("chatroomId", chatroomId != null ? chatroomId.toString() : null)); } } \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/PhoneUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/PhoneUpdateServiceTest.java index 0730611b2..333c26589 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/PhoneUpdateServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/PhoneUpdateServiceTest.java @@ -2,12 +2,12 @@ import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; import kr.co.pennyway.api.config.fixture.UserFixture; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; -import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; +import kr.co.pennyway.domain.context.account.service.PhoneCodeService; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeEach; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/PasswordUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/PasswordUpdateServiceTest.java index f371bb804..e7fdba08c 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/PasswordUpdateServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/PasswordUpdateServiceTest.java @@ -1,26 +1,20 @@ package kr.co.pennyway.api.apis.users.usecase; -import com.querydsl.jpa.impl.JPAQueryFactory; import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; import kr.co.pennyway.api.apis.users.service.PasswordUpdateService; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; import kr.co.pennyway.api.config.fixture.UserFixture; -import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.context.account.service.UserService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.ContextConfiguration; import org.springframework.transaction.annotation.Transactional; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -29,10 +23,7 @@ import static org.mockito.BDDMockito.given; import static org.springframework.test.util.AssertionErrors.assertEquals; -@ExtendWith(MockitoExtension.class) -@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") -@ContextConfiguration(classes = {JpaConfig.class, PasswordUpdateService.class, UserService.class}) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ExternalApiIntegrationTest public class PasswordUpdateServiceTest extends ExternalApiDBTestConfig { @Autowired private UserService userService; @@ -43,9 +34,6 @@ public class PasswordUpdateServiceTest extends ExternalApiDBTestConfig { @MockBean private PasswordEncoderHelper passwordEncoderHelper; - @MockBean - private JPAQueryFactory queryFactory; - @Nested @DisplayName("사용자 비밀번호 검증 테스트") class VerificationPasswordTest { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserDeleteServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserDeleteServiceTest.java index a1c5bef36..5d61ae15a 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserDeleteServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserDeleteServiceTest.java @@ -2,35 +2,28 @@ import kr.co.pennyway.api.apis.users.service.UserDeleteService; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; -import kr.co.pennyway.api.config.TestJpaConfig; -import kr.co.pennyway.api.config.fixture.DeviceTokenFixture; -import kr.co.pennyway.api.config.fixture.SpendingCustomCategoryFixture; -import kr.co.pennyway.api.config.fixture.SpendingFixture; -import kr.co.pennyway.api.config.fixture.UserFixture; -import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.*; +import kr.co.pennyway.domain.context.account.service.DeviceTokenService; +import kr.co.pennyway.domain.context.account.service.OauthService; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.finance.service.SpendingCategoryService; +import kr.co.pennyway.domain.context.finance.service.SpendingService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; import kr.co.pennyway.domain.domains.device.domain.DeviceToken; -import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.oauth.type.Provider; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ContextConfiguration; import org.springframework.transaction.annotation.Transactional; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -38,11 +31,7 @@ import static org.springframework.test.util.AssertionErrors.*; @Slf4j -@ExtendWith(MockitoExtension.class) -@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") -@ContextConfiguration(classes = {JpaConfig.class, UserDeleteService.class, UserService.class, OauthService.class, DeviceTokenService.class, SpendingService.class, SpendingCustomCategoryService.class}) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Import(TestJpaConfig.class) +@ExternalApiIntegrationTest public class UserDeleteServiceTest extends ExternalApiDBTestConfig { @Autowired private UserService userService; @@ -60,7 +49,13 @@ public class UserDeleteServiceTest extends ExternalApiDBTestConfig { private SpendingService spendingService; @Autowired - private SpendingCustomCategoryService spendingCustomCategoryService; + private SpendingCategoryService spendingCategoryService; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private ChatMemberRepository chatMemberRepository; @Test @Transactional @@ -116,12 +111,12 @@ void deleteAccountWithDevices() { userService.createUser(user); DeviceToken deviceToken = DeviceTokenFixture.INIT.toDevice(user); - deviceTokenService.createDevice(deviceToken); + deviceTokenService.createDeviceToken(deviceToken); // when - then assertDoesNotThrow(() -> userDeleteService.execute(user.getId())); assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); - assertFalse("디바이스가 비활성화 있어야 한다.", deviceTokenService.readDeviceByUserIdAndToken(user.getId(), deviceToken.getToken()).get().getActivated()); + assertFalse("디바이스가 비활성화 있어야 한다.", deviceTokenService.readDeviceTokenByUserIdAndToken(user.getId(), deviceToken.getToken()).get().getActivated()); } @Test @@ -131,7 +126,7 @@ void deleteAccountWithSpending() { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); - SpendingCustomCategory category = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); + SpendingCustomCategory category = spendingCategoryService.createSpendingCustomCategory(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); Spending spending1 = spendingService.createSpending(SpendingFixture.GENERAL_SPENDING.toSpending(user)); Spending spending2 = spendingService.createSpending(SpendingFixture.CUSTOM_CATEGORY_SPENDING.toCustomCategorySpending(user, category)); @@ -141,7 +136,21 @@ void deleteAccountWithSpending() { assertDoesNotThrow(() -> userDeleteService.execute(user.getId())); assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); assertTrue("지출 정보가 삭제되어 있어야 한다.", spendingService.readSpendings(user.getId(), spending1.getSpendAt().getYear(), spending1.getSpendAt().getMonthValue()).isEmpty()); - assertTrue("지출 카테고리가 삭제되어 있어야 한다.", spendingCustomCategoryService.readSpendingCustomCategory(category.getId()).isEmpty()); + assertTrue("지출 카테고리가 삭제되어 있어야 한다.", spendingCategoryService.readSpendingCustomCategory(category.getId()).isEmpty()); + } + + @Test + @Transactional + @DisplayName("사용자가 채팅방장으로 등록된 채팅방이 하나 이상 존재하는 경우, 삭제할 수 없다.") + void deleteAccountWithOwnershipChatRoom() { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(1L)); + chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(user, chatRoom)); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userDeleteService.execute(user.getId())); + assertEquals("채팅방장으로 등록된 채팅방이 하나 이상 존재하는 경우, 삭제할 수 없다.", UserErrorCode.HAS_OWNERSHIP_CHAT_ROOM, ex.getBaseErrorCode()); } private Oauth createOauth(Provider provider, String providerId, User user) { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/ApiTestHelper.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/ApiTestHelper.java new file mode 100644 index 000000000..9e18637b3 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/ApiTestHelper.java @@ -0,0 +1,182 @@ +package kr.co.pennyway.api.common.util; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.*; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Map; + +public final class ApiTestHelper { + private final TestRestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final JwtProvider accessTokenProvider; + + public ApiTestHelper(TestRestTemplate restTemplate, ObjectMapper objectMapper, JwtProvider accessTokenProvider) { + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + this.accessTokenProvider = accessTokenProvider; + } + + /** + * API 요청을 보내고 응답을 처리하는 일반화된 메서드 + * + * @param url API 엔드포인트 URL + * @param method HTTP 메서드 + * @param user 요청하는 사용자 + * @param request 요청 바디 (없을 경우 null) + * @param successResponseType 성공 응답 타입 + * @param uriVariables URL 변수들 + * @return ResponseEntity + */ + public ResponseEntity callApi( + String url, + HttpMethod method, + User user, + T request, + TypeReference> successResponseType, + Object... uriVariables) { + + ResponseEntity response = restTemplate.exchange( + url, + method, + createHttpEntity(user, request), + Object.class, + uriVariables + ); + + Object body = response.getBody(); + if (body == null) { + throw new IllegalStateException("예상치 못한 반환 타입입니다. : " + response); + } + + return response.getStatusCode().is2xxSuccessful() + ? createSuccessResponse(response, body, successResponseType) + : createErrorResponse(response, body); + } + + /** + * API 요청을 보내고 응답을 처리하는 일반화된 메서드 + * 쿼리 파라미터를 추가할 수 있습니다. + * + * @param url API 엔드포인트 URL + * @param method HTTP 메서드 + * @param user 요청하는 사용자 + * @param request 요청 바디 (없을 경우 null) + * @param successResponseType 성공 응답 타입 + * @param queryParams 쿼리 파라미터들 + * @param uriVariables URL 변수들 + * @return ResponseEntity + */ + public ResponseEntity callApi( + String url, + HttpMethod method, + User user, + T request, + TypeReference> successResponseType, + Map queryParams, + Object... uriVariables) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(url); + if (queryParams != null) { + queryParams.forEach(builder::queryParam); + } + + ResponseEntity response = restTemplate.exchange( + builder.buildAndExpand(uriVariables).toUri(), + method, + createHttpEntity(user, request), + Object.class + ); + + Object body = response.getBody(); + if (body == null) { + throw new IllegalStateException("예상치 못한 반환 타입입니다. : " + response); + } + + return response.getStatusCode().is2xxSuccessful() + ? createSuccessResponse(response, body, successResponseType) + : createErrorResponse(response, body); + } + + /** + * API 요청을 보내고 응답을 처리하는 일반화된 메서드 + * + * @param parameters {@link RequestParameters} + * @param successResponseType 성공 응답 타입 + * @return ResponseEntity + */ + public ResponseEntity callApi( + RequestParameters parameters, + TypeReference> successResponseType) { + + ResponseEntity response = restTemplate.exchange( + parameters.createUri(), + parameters.getMethod(), + createHttpEntity(parameters.getUser(), parameters.getRequest()), + Object.class + ); + + Object body = response.getBody(); + if (body == null) { + throw new IllegalStateException("예상치 못한 반환 타입입니다. : " + response); + } + + return response.getStatusCode().is2xxSuccessful() + ? createSuccessResponse(response, body, successResponseType) + : createErrorResponse(response, body); + } + + /** + * HTTP 요청 엔티티 생성 + */ + private HttpEntity createHttpEntity(User user, T request) { + HttpHeaders headers = new HttpHeaders(); + if (user != null) { + headers.set("Authorization", "Bearer " + generateToken(user)); + } + headers.setContentType(MediaType.APPLICATION_JSON); + + return new HttpEntity<>(request, headers); + } + + /** + * 사용자 토큰 생성 + */ + private String generateToken(User user) { + return accessTokenProvider.generateToken( + AccessTokenClaim.of(user.getId(), user.getRole().name()) + ); + } + + /** + * 성공 응답 생성 + */ + private ResponseEntity> createSuccessResponse( + ResponseEntity response, + Object body, + TypeReference> successResponseType) { + return ResponseEntity + .status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(objectMapper.convertValue(body, successResponseType)); + } + + /** + * 에러 응답 생성 + */ + private ResponseEntity createErrorResponse( + ResponseEntity response, + Object body) { + return ResponseEntity + .status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(objectMapper.convertValue(body, new TypeReference() { + })); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/RequestParameters.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/RequestParameters.java new file mode 100644 index 000000000..589ab5940 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/RequestParameters.java @@ -0,0 +1,118 @@ +package kr.co.pennyway.api.common.util; + +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpMethod; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.Map; + +@Getter +@Builder +public class RequestParameters { + private final String url; + private final HttpMethod method; + private final User user; + private final Object request; + private final Map queryParams; + private final Object[] uriVariables; + + private RequestParameters(String url, HttpMethod method, User user, + Object request, Map queryParams, Object[] uriVariables) { + validateRequired(url, "URL must not be null"); + validateRequired(method, "HTTP method must not be null"); + validateRequired(user, "User must not be null"); + + this.url = url; + this.method = method; + this.user = user; + this.request = request; + this.queryParams = queryParams; + this.uriVariables = uriVariables; + } + + /** + * RequestParameters 생성을 위한 편의 메서드 + * GET 메서드를 사용하는 경우에 사용합니다. + */ + public static RequestParametersBuilder defaultGet(String url) { + return RequestParameters.builder() + .url(url) + .method(HttpMethod.GET); + } + + /** + * RequestParameters 생성을 위한 편의 메서드 + * POST 메서드를 사용하는 경우에 사용합니다. + */ + public static RequestParametersBuilder defaultPost(String url) { + return RequestParameters.builder() + .url(url) + .method(HttpMethod.POST); + } + + /** + * RequestParameters 생성을 위한 편의 메서드 + * PUT 메서드를 사용하는 경우에 사용합니다. + */ + public static RequestParametersBuilder defaultPut(String url) { + return RequestParameters.builder() + .url(url) + .method(HttpMethod.PUT); + } + + /** + * RequestParameters 생성을 위한 편의 메서드 + * DELETE 메서드를 사용하는 경우에 사용합니다. + */ + public static RequestParametersBuilder defaultDelete(String url) { + return RequestParameters.builder() + .url(url) + .method(HttpMethod.DELETE); + } + + /** + * 쿼리 파라미터 추가를 위한 편의 메서드 + * key-value 쌍으로 쿼리 파라미터를 생성합니다. + * + * @throws IllegalArgumentException key-value 쌍이 제대로 제공되지 않은 경우 + */ + public static Map createQueryParams(Object... keyValues) { + if (keyValues.length % 2 != 0) { + throw new IllegalArgumentException("Key-value pairs must be provided"); + } + + Map params = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + params.put(String.valueOf(keyValues[i]), String.valueOf(keyValues[i + 1])); + } + return params; + } + + private void validateRequired(Object value, String message) { + if (value == null) { + throw new IllegalArgumentException(message); + } + } + + /** + * URI를 생성합니다. + * 쿼리 파라미터와 URI 변수를 적용합니다. + */ + public URI createUri() { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(url); + + // 쿼리 파라미터 적용 + if (queryParams != null && !queryParams.isEmpty()) { + queryParams.forEach(builder::queryParam); + } + + // URI 변수 적용 + return (uriVariables != null && uriVariables.length > 0) + ? builder.buildAndExpand(uriVariables).toUri() + : builder.build().toUri(); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java index 5873c3dbf..b1e76d04e 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java @@ -11,7 +11,7 @@ @Testcontainers @ActiveProfiles("test") public abstract class ExternalApiDBTestConfig { - private static final String REDIS_CONTAINER_IMAGE = "redis:7.2.4-alpine"; + private static final String REDIS_CONTAINER_IMAGE = "redis:7.4"; private static final String MYSQL_CONTAINER_IMAGE = "mysql:8.0.26"; private static final RedisContainer REDIS_CONTAINER; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationProfileResolver.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationProfileResolver.java index 689c2eef8..35afd0cf0 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationProfileResolver.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationProfileResolver.java @@ -7,6 +7,6 @@ public class ExternalApiIntegrationProfileResolver implements ActiveProfilesReso @Override @NonNull public String[] resolve(@NonNull Class testClass) { - return new String[]{"common", "infra", "domain"}; + return new String[]{"common", "infra", "domain-service", "domain-rdb", "domain-redis"}; } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTestConfig.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTestConfig.java index 50d654bfe..8839220a3 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTestConfig.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTestConfig.java @@ -2,7 +2,8 @@ import kr.co.pennyway.PennywayExternalApiApplication; import kr.co.pennyway.common.PennywayCommonApplication; -import kr.co.pennyway.domain.DomainPackageLocation; +import kr.co.pennyway.domain.RedisPackageLocation; +import kr.co.pennyway.domain.domains.JpaPackageLocation; import kr.co.pennyway.infra.PennywayInfraApplication; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; @@ -12,7 +13,8 @@ basePackageClasses = { PennywayExternalApiApplication.class, PennywayInfraApplication.class, - DomainPackageLocation.class, + RedisPackageLocation.class, + JpaPackageLocation.class, PennywayCommonApplication.class } ) diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/TestJpaConfig.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/TestJpaConfig.java index 281d722b3..2e8ceef68 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/TestJpaConfig.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/TestJpaConfig.java @@ -1,11 +1,14 @@ package kr.co.pennyway.api.config; import com.querydsl.jpa.impl.JPAQueryFactory; +import com.querydsl.sql.MySQLTemplates; +import com.querydsl.sql.SQLTemplates; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.RedisTemplate; @TestConfiguration public class TestJpaConfig { @@ -17,4 +20,16 @@ public class TestJpaConfig { public JPAQueryFactory testJpaQueryFactory() { return new JPAQueryFactory(em); } + + @Bean + @ConditionalOnMissingBean + public SQLTemplates testSqlTemplates() { + return new MySQLTemplates(); + } + + @Bean + @ConditionalOnMissingBean + public RedisTemplate testRedisTemplate() { + return null; + } } \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatMemberFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatMemberFixture.java new file mode 100644 index 000000000..83570524b --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatMemberFixture.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.api.config.fixture; + +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; + +public enum ChatMemberFixture { + ADMIN(ChatMemberRole.ADMIN), + MEMBER(ChatMemberRole.MEMBER), + ; + + private final ChatMemberRole role; + + ChatMemberFixture(ChatMemberRole role) { + this.role = role; + } + + public ChatMember toEntity(User user, ChatRoom chatRoom) { + return ChatMember.of(user, chatRoom, this.role); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatRoomFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatRoomFixture.java new file mode 100644 index 000000000..a835e2567 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatRoomFixture.java @@ -0,0 +1,40 @@ +package kr.co.pennyway.api.config.fixture; + +import kr.co.pennyway.api.apis.chat.dto.ChatRoomReq; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; + +public enum ChatRoomFixture { + PRIVATE_CHAT_ROOM("페니웨이", "페니웨이 채팅방입니다.", "delete/chatroom/1/test-uuid_123.jpg", "123456"), + PUBLIC_CHAT_ROOM("페니웨이", "페니웨이 채팅방입니다.", "delete/chatroom/1/test-uuid_123.jpg", null); + + private static final String originImageUrl = "chatroom/1/test-uuid_123.jpg"; + private final String title; + private final String description; + private final String backgroundImageUrl; + private final String password; + + ChatRoomFixture(String title, String description, String backgroundImageUrl, String password) { + this.title = title; + this.description = description; + this.backgroundImageUrl = backgroundImageUrl; + this.password = password; + } + + public static String getOriginImageUrl() { + return originImageUrl; + } + + public ChatRoom toEntity(Long id) { + return ChatRoom.builder() + .id(id) + .title(title) + .description(description) + .backgroundImageUrl(backgroundImageUrl) + .password(password != null ? Integer.valueOf(password) : null) + .build(); + } + + public ChatRoomReq.Create toCreateRequest() { + return new ChatRoomReq.Create(title, description, password, backgroundImageUrl); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceTokenFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceTokenFixture.java index 6618fc5fd..5310f0c1c 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceTokenFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceTokenFixture.java @@ -5,20 +5,25 @@ import kr.co.pennyway.domain.domains.user.domain.User; public enum DeviceTokenFixture { - INIT("originToken"), - CHANGED_TOKEN("newToken"); + INIT("originToken", "AAA-BBBB-CC-DDDD-EE-FFF", "iPhone 15 Pro"), + CHANGED_TOKEN("newToken", "AAA-BBBB-CC-DDDD-EE-FFF", "iPhone 15 Pro"), + ; private final String token; + private final String deviceId; + private final String deviceName; - DeviceTokenFixture(String token) { + DeviceTokenFixture(String token, String deviceId, String deviceName) { this.token = token; + this.deviceId = deviceId; + this.deviceName = deviceName; } public DeviceToken toDevice(User user) { - return DeviceToken.of(token, user); + return DeviceToken.of(token, deviceId, deviceName, user); } public DeviceTokenDto.RegisterReq toRegisterReq() { - return new DeviceTokenDto.RegisterReq(token); + return new DeviceTokenDto.RegisterReq(token, deviceId, deviceName); } } diff --git a/pennyway-batch/build.gradle b/pennyway-batch/build.gradle index a959d5c10..26c9b1cae 100644 --- a/pennyway-batch/build.gradle +++ b/pennyway-batch/build.gradle @@ -14,9 +14,18 @@ repositories { dependencies { implementation project(':pennyway-common') - implementation project(':pennyway-domain') implementation project(':pennyway-infra') + implementation project(':pennyway-domain:domain-rdb') + implementation project(':pennyway-domain:domain-redis') implementation 'org.springframework.boot:spring-boot-starter-batch:3.3.0' testImplementation('org.springframework.batch:spring-batch-test:5.1.2') + + /* testcontainer */ + testImplementation "org.junit.jupiter:junit-jupiter:5.8.1" + testImplementation "org.testcontainers:testcontainers:1.19.7" + testImplementation "org.testcontainers:junit-jupiter:1.19.7" + testImplementation "org.testcontainers:mysql:1.19.7" + testImplementation "com.redis.testcontainers:testcontainers-redis-junit:1.6.4" + testImplementation "org.springframework.cloud:spring-cloud-contract-wiremock:4.1.2" } \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/KeyValue.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/KeyValue.java new file mode 100644 index 000000000..567875a1c --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/KeyValue.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.batch.common.dto; + +public record KeyValue( + String key, + String value +) { +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/LastMessageIdJobConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/LastMessageIdJobConfig.java new file mode 100644 index 000000000..58a24204b --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/LastMessageIdJobConfig.java @@ -0,0 +1,63 @@ +package kr.co.pennyway.batch.job; + +import kr.co.pennyway.batch.common.dto.KeyValue; +import kr.co.pennyway.batch.processor.LastMessageIdProcessor; +import kr.co.pennyway.batch.reader.LastMessageIdReader; +import kr.co.pennyway.batch.writer.LastMessageIdWriter; +import kr.co.pennyway.domain.domains.chatstatus.domain.ChatMessageStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class LastMessageIdJobConfig { + private static final int CHUNK_SIZE = 1000; + private static final String PREFIX_PATTERN = "chat:last_read:*"; + private final JobRepository jobRepository; + private final LastMessageIdProcessor processor; + private final LastMessageIdWriter writer; + private final RedisTemplate redisTemplate; + + @Bean + public Job lastMessageIdJob(PlatformTransactionManager transactionManager) { + return new JobBuilder("lastMessageIdJob", jobRepository) + .start(lastMessageIdStep(transactionManager)) + .on("FAILED") + .stopAndRestart(lastMessageIdStep(transactionManager)) + .on("*") + .end() + .end() + .build(); + } + + @Bean + @JobScope + public Step lastMessageIdStep(PlatformTransactionManager transactionManager) { + return new StepBuilder("lastMessageIdStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(lastMessageIdReader()) + .processor(processor) + .writer(writer) + .build(); + } + + @Bean + @StepScope + public LastMessageIdReader lastMessageIdReader() { + ScanOptions options = ScanOptions.scanOptions().match(PREFIX_PATTERN).count(CHUNK_SIZE).build(); + Cursor cursor = redisTemplate.scan(options); + return new LastMessageIdReader(redisTemplate, cursor); + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/LastMessageIdProcessor.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/LastMessageIdProcessor.java new file mode 100644 index 000000000..fc140bf0c --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/LastMessageIdProcessor.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.batch.processor; + +import kr.co.pennyway.batch.common.dto.KeyValue; +import kr.co.pennyway.domain.domains.chatstatus.domain.ChatMessageStatus; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class LastMessageIdProcessor implements ItemProcessor { + + @Override + public ChatMessageStatus process(KeyValue item) throws Exception { + log.debug("Processing item - key: {}, value: {}", item.key(), item.value()); + + String[] parts = item.key().split(":"); + + if (parts.length != 4) { + log.error("Invalid key format: {}", item.key()); + return null; + } + + try { + Long roomId = Long.parseLong(parts[2]); + Long userId = Long.parseLong(parts[3]); + Long messageId = Long.parseLong(item.value()); + log.debug("Parsed roomId: {}, userId: {}, messageId: {}", roomId, userId, messageId); + + return new ChatMessageStatus(userId, roomId, messageId); + } catch (NoSuchFieldError | NumberFormatException e) { + log.error("Failed to parse key: {}", item.key(), e); + return null; + } + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/LastMessageIdReader.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/LastMessageIdReader.java new file mode 100644 index 000000000..8b3d6bc44 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/LastMessageIdReader.java @@ -0,0 +1,37 @@ +package kr.co.pennyway.batch.reader; + +import kr.co.pennyway.batch.common.dto.KeyValue; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.NonTransientResourceException; +import org.springframework.batch.item.ParseException; +import org.springframework.batch.item.UnexpectedInputException; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisTemplate; + +@Slf4j +@RequiredArgsConstructor +public class LastMessageIdReader implements ItemReader { + private final RedisTemplate redisTemplate; + private final Cursor cursor; + + @Override + public KeyValue read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException { + if (!cursor.hasNext()) { + log.debug("No more keys to read cursor: {}", cursor); + return null; + } + + String key = cursor.next(); + String value = redisTemplate.opsForValue().get(key); + log.debug("Read key: {}, value: {}", key, value); + + if (value == null) { + log.warn("Value not found for key: {}", key); + return null; + } + + return new KeyValue(key, value); + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/scheduler/SpendingNotifyScheduler.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/scheduler/SpendingNotifyScheduler.java index a07982f7e..9f2e8e619 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/scheduler/SpendingNotifyScheduler.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/scheduler/SpendingNotifyScheduler.java @@ -13,6 +13,8 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; + @Slf4j @Component @RequiredArgsConstructor @@ -20,6 +22,7 @@ public class SpendingNotifyScheduler { private final JobLauncher jobLauncher; private final Job dailyNotificationJob; private final Job monthlyNotificationJob; + private final Job lastMessageIdJob; @Scheduled(cron = "0 0 20 * * ?") public void runDailyNotificationJob() { @@ -48,4 +51,18 @@ public void runMonthlyNotificationJob() { log.error("Failed to run monthlyNotificationJob", e); } } + + @Scheduled(fixedRate = 30, timeUnit = TimeUnit.MINUTES) + public void runLastMessageIdJob() { + JobParameters jobParameters = new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) + .toJobParameters(); + + try { + jobLauncher.run(lastMessageIdJob, jobParameters); + } catch (JobExecutionAlreadyRunningException | JobRestartException + | JobInstanceAlreadyCompleteException | JobParametersInvalidException e) { + log.error("Failed to run lastMessageIdJob", e); + } + } } diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/LastMessageIdWriter.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/LastMessageIdWriter.java new file mode 100644 index 000000000..6c98a554c --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/LastMessageIdWriter.java @@ -0,0 +1,44 @@ +package kr.co.pennyway.batch.writer; + +import kr.co.pennyway.domain.domains.chatstatus.domain.ChatMessageStatus; +import kr.co.pennyway.domain.domains.chatstatus.repository.ChatMessageStatusRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LastMessageIdWriter implements ItemWriter { + private final ChatMessageStatusRepository repository; + + @Override + public void write(Chunk chunk) throws Exception { + log.debug("Writing chunk size: {}", chunk.getItems().size()); + + Map> updates = chunk.getItems().stream() + .collect( + Collectors.groupingBy( + ChatMessageStatus::getUserId, + Collectors.toMap( + ChatMessageStatus::getChatRoomId, + ChatMessageStatus::getLastReadMessageId, + Long::max + ) + ) + ); + log.debug("Grouped updates: {}", updates); + + updates.forEach((userId, roomUpdates) -> + roomUpdates.forEach((roomId, messageId) -> { + log.debug("Saving - userId: {}, roomId: {}, messageId: {}", userId, roomId, messageId); + repository.saveLastReadMessageIdInBulk(userId, roomId, messageId); + }) + ); + } +} diff --git a/pennyway-batch/src/main/resources/application.yml b/pennyway-batch/src/main/resources/application.yml index 7bc8d234a..4106ef52a 100644 --- a/pennyway-batch/src/main/resources/application.yml +++ b/pennyway-batch/src/main/resources/application.yml @@ -1,8 +1,9 @@ spring: profiles: group: - local: common, domain, infra - dev: common, domain, infra + local: common, domain-rdb, domain-redis, infra + dev: common, domain-rdb, domain-redis, infra + test: common, domain-rdb, domain-redis, infra batch: job: @@ -19,12 +20,7 @@ spring: datasource: hikari: maximum-pool-size: 2 - - data: - redis: - repositories: - enabled: false - + --- spring: config: diff --git a/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchDBTestConfig.java b/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchDBTestConfig.java new file mode 100644 index 000000000..0f010e9f5 --- /dev/null +++ b/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchDBTestConfig.java @@ -0,0 +1,48 @@ +package kr.co.pennyway.batch.config; + +import com.redis.testcontainers.RedisContainer; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@ActiveProfiles("test") +public abstract class BatchDBTestConfig { + private static final String REDIS_CONTAINER_IMAGE = "redis:7.4"; + private static final String MYSQL_CONTAINER_IMAGE = "mysql:8.0.26"; + + private static final RedisContainer REDIS_CONTAINER; + private static final MySQLContainer MYSQL_CONTAINER; + + static { + REDIS_CONTAINER = + new RedisContainer(DockerImageName.parse(REDIS_CONTAINER_IMAGE)) + .withExposedPorts(6379) + .withCommand("redis-server", "--requirepass testpass") + .withReuse(true); + MYSQL_CONTAINER = + new MySQLContainer<>(DockerImageName.parse(MYSQL_CONTAINER_IMAGE)) + .withDatabaseName("pennyway") + .withUsername("root") + .withPassword("testpass") + .withCommand("--sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION") + .withInitScript("sql/schema-mysql.sql") + .withReuse(true); + + REDIS_CONTAINER.start(); + MYSQL_CONTAINER.start(); + } + + @DynamicPropertySource + public static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379))); + registry.add("spring.data.redis.password", () -> "testpass"); + registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=Asia/Seoul&characterEncoding=utf8&postfileSQL=true&logger=Slf4JLogger&rewriteBatchedStatements=true", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306))); + registry.add("spring.datasource.username", () -> "root"); + registry.add("spring.datasource.password", () -> "testpass"); + } +} diff --git a/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchIntegrationProfileResolver.java b/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchIntegrationProfileResolver.java new file mode 100644 index 000000000..d4e9e62a7 --- /dev/null +++ b/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchIntegrationProfileResolver.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.batch.config; + +import org.springframework.lang.NonNull; +import org.springframework.test.context.ActiveProfilesResolver; + +public class BatchIntegrationProfileResolver implements ActiveProfilesResolver { + @Override + @NonNull + public String[] resolve(@NonNull Class testClass) { + return new String[]{"common", "infra", "domain-rdb", "domain-redis"}; + } +} diff --git a/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchIntegrationTest.java b/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchIntegrationTest.java new file mode 100644 index 000000000..37cee5dce --- /dev/null +++ b/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchIntegrationTest.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.batch.config; + +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@SpringBatchTest +@SpringBootTest(classes = BatchIntegrationTestConfig.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles(profiles = {"test"}, resolver = BatchIntegrationProfileResolver.class) +@Documented +public @interface BatchIntegrationTest { +} diff --git a/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchIntegrationTestConfig.java b/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchIntegrationTestConfig.java new file mode 100644 index 000000000..33840eb53 --- /dev/null +++ b/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/BatchIntegrationTestConfig.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.batch.config; + +import kr.co.pennyway.PennywayBatchApplication; +import kr.co.pennyway.common.PennywayCommonApplication; +import kr.co.pennyway.domain.RedisPackageLocation; +import kr.co.pennyway.domain.domains.JpaPackageLocation; +import kr.co.pennyway.infra.PennywayInfraApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan( + basePackageClasses = { + PennywayBatchApplication.class, + PennywayInfraApplication.class, + JpaPackageLocation.class, + RedisPackageLocation.class, + PennywayCommonApplication.class + } +) +public class BatchIntegrationTestConfig { +} \ No newline at end of file diff --git a/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/TestJpaConfig.java b/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/TestJpaConfig.java new file mode 100644 index 000000000..1efcec7fd --- /dev/null +++ b/pennyway-batch/src/test/java/kr/co/pennyway/batch/config/TestJpaConfig.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.batch.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.querydsl.sql.MySQLTemplates; +import com.querydsl.sql.SQLTemplates; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.RedisTemplate; + +@TestConfiguration +public class TestJpaConfig { + @PersistenceContext + private EntityManager em; + + @Bean + @ConditionalOnMissingBean + public JPAQueryFactory testJpaQueryFactory() { + return new JPAQueryFactory(em); + } + + @Bean + @ConditionalOnMissingBean + public SQLTemplates testSqlTemplates() { + return new MySQLTemplates(); + } + + @Bean + @ConditionalOnMissingBean + public RedisTemplate testRedisTemplate() { + return null; + } +} \ No newline at end of file diff --git a/pennyway-batch/src/test/java/kr/co/pennyway/batch/integration/LastMessageIdIntegrationTest.java b/pennyway-batch/src/test/java/kr/co/pennyway/batch/integration/LastMessageIdIntegrationTest.java new file mode 100644 index 000000000..860bf9590 --- /dev/null +++ b/pennyway-batch/src/test/java/kr/co/pennyway/batch/integration/LastMessageIdIntegrationTest.java @@ -0,0 +1,221 @@ +package kr.co.pennyway.batch.integration; + +import kr.co.pennyway.batch.config.BatchDBTestConfig; +import kr.co.pennyway.batch.config.BatchIntegrationTest; +import kr.co.pennyway.domain.domains.chatstatus.domain.ChatMessageStatus; +import kr.co.pennyway.domain.domains.chatstatus.repository.ChatMessageStatusRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.*; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.JobRepositoryTestUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Slf4j +@BatchIntegrationTest +public class LastMessageIdIntegrationTest extends BatchDBTestConfig { + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + private JobRepositoryTestUtils jobRepositoryTestUtils; + + @Autowired + private Job lastMessageIdJob; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private ChatMessageStatusRepository chatMessageStatusRepository; + + private JobParameters jobParameters; + + @BeforeEach + void setUp() { + setupTestData(); + } + + @AfterEach + void tearDown() { + cleanupTestData(); + } + + @Test + @DisplayName("lastMessageId Job이 정상적으로 실행되어야 한다") + void lastMessageIdJobTest() throws Exception { + // given + Long userId1 = 1L; + Long userId2 = 2L; + Long roomId1 = 1L; + Long roomId2 = 2L; + + // when + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertEquals(ExitStatus.COMPLETED, jobExecution.getExitStatus()); + List statuses = chatMessageStatusRepository.findAllByUserIdAndChatRoomIdIn( + userId1, + Arrays.asList(roomId1, roomId2) + ); + + assertEquals(2, statuses.size()); + log.debug("사용자 1번의 데이터: {}", statuses); + + // userId1, roomId1 검증 + Optional status1 = statuses.stream() + .filter(s -> s.getChatRoomId().equals(roomId1)) + .findFirst(); + assertTrue(status1.isPresent(), "userId1, roomId1 데이터가 존재해야 합니다."); + assertEquals(100L, status1.get().getLastReadMessageId()); + + // userId1, roomId2 검증 + Optional status2 = statuses.stream() + .filter(s -> s.getChatRoomId().equals(roomId2)) + .findFirst(); + assertTrue(status2.isPresent(), "userId1, roomId2 데이터가 존재해야 합니다."); + assertEquals(200L, status2.get().getLastReadMessageId()); + + // Redis 캐시가 남아있는지 확인 + assertEquals("100", redisTemplate.opsForValue().get(formatCacheKey(userId1, roomId1))); + assertEquals("200", redisTemplate.opsForValue().get(formatCacheKey(userId1, roomId2))); + assertEquals("300", redisTemplate.opsForValue().get(formatCacheKey(userId2, roomId1))); + } + + @Test + @DisplayName("Job 실행 시 일부 데이터가 누락되어도 나머지 데이터는 정상 처리되어야 한다") + void jobWithPartialDataTest() throws Exception { + // given + Long userId = 1L; + Long roomId = 1L; + + redisTemplate.opsForValue().set(formatCacheKey(userId, roomId), "invalid_value"); // Redis에는 있지만 value가 잘못된 데이터 + redisTemplate.opsForValue().set(formatCacheKey(2L, 2L), "300"); + + // when + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertEquals(ExitStatus.COMPLETED, jobExecution.getExitStatus(), "Job이 정상 완료되어야 합니다."); + + Optional validStatus = chatMessageStatusRepository.findByUserIdAndChatRoomId(2L, 2L); + assertTrue(validStatus.isPresent(), "2번 사용자, 2번 채팅방의 lastMessageId 데이터가 존재해야 합니다."); + assertEquals(300L, validStatus.get().getLastReadMessageId()); + + Optional invalidStatus = chatMessageStatusRepository.findByUserIdAndChatRoomId(userId, roomId); + assertTrue(invalidStatus.isEmpty(), "1번 사용자, 1번 채팅방 (잘못된)lastMessageId 데이터가 존재하지 않아야 합니다."); + } + + @Test + @DisplayName("빈 데이터로 Job 실행 시 정상 완료되어야 한다") + void emptyDataJobTest() throws Exception { + // given + cleanupTestData(); + + // when + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertEquals(ExitStatus.COMPLETED, jobExecution.getExitStatus(), "Job이 정상 완료되어야 합니다."); + assertEquals(0, chatMessageStatusRepository.count()); + } + + @Test + @DisplayName("대량의 데이터도 정상적으로 처리되어야 한다") + void largeDataSetTest() throws Exception { + // given + int userCount = 100; + int roomCount = 50; + cleanupTestData(); + + // 대량의 테스트 데이터 생성 + for (int i = 1; i <= userCount; i++) { + for (int j = 1; j <= roomCount; j++) { + redisTemplate.opsForValue().set(formatCacheKey((long) i, (long) j), String.valueOf(i * 1000 + j)); + } + } + + // when + long startTime = System.currentTimeMillis(); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + long endTime = System.currentTimeMillis(); + + // then + assertEquals(ExitStatus.COMPLETED, jobExecution.getExitStatus()); + assertEquals(userCount * roomCount, chatMessageStatusRepository.count()); + log.debug("처리 시간: {}ms", endTime - startTime); + + // 샘플 데이터 검증 + Optional sampleStatus = chatMessageStatusRepository + .findByUserIdAndChatRoomId(50L, 25L); + assertTrue(sampleStatus.isPresent()); + assertEquals(50000 + 25, sampleStatus.get().getLastReadMessageId()); + } + + @Test + @DisplayName("Job이 실패하더라도 이전 처리 데이터는 유지되어야 한다") + void jobFailureTest() throws Exception { + // given + redisTemplate.opsForValue().set(formatCacheKey(1L, 1L), "100"); + redisTemplate.opsForValue().set(formatCacheKey(1L, 2L), "invalid_value"); // 실패 유발 데이터 + redisTemplate.opsForValue().set(formatCacheKey(2L, 1L), "300"); + + // when + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertEquals(ExitStatus.COMPLETED, jobExecution.getExitStatus()); + + // 정상 데이터는 저장되어 있어야 함 + Optional status1 = chatMessageStatusRepository + .findByUserIdAndChatRoomId(1L, 1L); + assertTrue(status1.isPresent()); + assertEquals(100L, status1.get().getLastReadMessageId()); + + Optional status2 = chatMessageStatusRepository + .findByUserIdAndChatRoomId(2L, 1L); + assertTrue(status2.isPresent()); + assertEquals(300L, status2.get().getLastReadMessageId()); + } + + private void setupTestData() { + jobRepositoryTestUtils.removeJobExecutions(); + jobLauncherTestUtils.setJob(lastMessageIdJob); + jobParameters = new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) + .toJobParameters(); + + // Redis 테스트 데이터 설정 + redisTemplate.opsForValue().set(formatCacheKey(1L, 1L), "100"); + redisTemplate.opsForValue().set(formatCacheKey(1L, 2L), "200"); + redisTemplate.opsForValue().set(formatCacheKey(2L, 1L), "300"); + } + + private void cleanupTestData() { + // Redis 데이터 정리 + Set keys = redisTemplate.keys("chat:last_read:*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + + // DB 데이터 정리 + chatMessageStatusRepository.deleteAll(); + } + + private String formatCacheKey(Long userId, Long chatRoomId) { + return "chat:last_read:" + chatRoomId + ":" + userId; + } +} diff --git a/pennyway-batch/src/test/java/kr/co/pennyway/batch/job/LastMessageIdJobBatchTest.java b/pennyway-batch/src/test/java/kr/co/pennyway/batch/job/LastMessageIdJobBatchTest.java new file mode 100644 index 000000000..8722e653c --- /dev/null +++ b/pennyway-batch/src/test/java/kr/co/pennyway/batch/job/LastMessageIdJobBatchTest.java @@ -0,0 +1,170 @@ +package kr.co.pennyway.batch.job; + +import kr.co.pennyway.batch.common.dto.KeyValue; +import kr.co.pennyway.batch.processor.LastMessageIdProcessor; +import kr.co.pennyway.batch.reader.LastMessageIdReader; +import kr.co.pennyway.batch.writer.LastMessageIdWriter; +import kr.co.pennyway.domain.domains.chatstatus.domain.ChatMessageStatus; +import kr.co.pennyway.domain.domains.chatstatus.repository.ChatMessageStatusRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@Slf4j +@SpringBatchTest +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +public class LastMessageIdJobBatchTest { + @Mock + private RedisTemplate redisTemplate; + + @Mock + private Cursor cursor; + + @Mock + private ValueOperations valueOperations; + + @Mock + private ChatMessageStatusRepository repository; + + private LastMessageIdReader reader; + private LastMessageIdProcessor processor; + private LastMessageIdWriter writer; + + @BeforeEach + void setUp() { + reader = new LastMessageIdReader(redisTemplate, cursor); + processor = new LastMessageIdProcessor(); + writer = new LastMessageIdWriter(repository); + } + + @Test + @DisplayName("Reader - Redis에서 키/값을 정상적으로 읽어오는지 테스트") + void readerTest() throws Exception { + // given + String key = "chat:last_read:1:2"; + String value = "100"; + + given(cursor.hasNext()).willReturn(true, false); + given(cursor.next()).willReturn(key); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(key)).willReturn(value); + + // when + KeyValue result = reader.read(); + + // then + assertNotNull(result); + assertEquals(key, result.key()); + assertEquals(value, result.value()); + assertNull(reader.read()); // 두 번째 읽기에서는 null 반환 확인 + } + + @Test + @DisplayName("Reader - value가 null인 경우 null 반환 확인") + void nullValueTest() throws Exception { + // given + String key = "chat:last_read:1:2"; + + given(cursor.hasNext()).willReturn(true); + given(cursor.next()).willReturn(key); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(key)).willReturn(null); + + // when + KeyValue result = reader.read(); + + // then + assertNull(result); + + // verify + verify(cursor).hasNext(); + verify(cursor).next(); + verify(valueOperations).get(key); + } + + @Test + @DisplayName("Reader - 더 이상 읽을 데이터가 없는 경우 테스트") + void noMoreDataTest() throws Exception { + // given + given(cursor.hasNext()).willReturn(false); + + // when + KeyValue result = reader.read(); + + // then + assertNull(result); + + // verify + verify(cursor).hasNext(); + verify(cursor, never()).next(); + verify(valueOperations, never()).get(any()); + } + + @Test + @DisplayName("Processor - 키/값을 ChatMessageStatus로 정상 변환하는지 테스트") + void processorTest() throws Exception { + // given + KeyValue item = new KeyValue("chat:last_read:1:2", "100"); + + // when + ChatMessageStatus result = processor.process(item); + + // then + assertNotNull(result); + assertEquals(2L, result.getUserId()); + assertEquals(1L, result.getChatRoomId()); + assertEquals(100L, result.getLastReadMessageId()); + } + + @Test + @DisplayName("Processor - 잘못된 형식의 키는 null을 반환하는지 테스트") + void processorInvalidKeyTest() throws Exception { + // given + KeyValue item = new KeyValue("invalid:key:format", "100"); + + // when + ChatMessageStatus result = processor.process(item); + + // then + assertNull(result); + } + + @Test + @DisplayName("Writer - 데이터를 정상적으로 저장하는지 테스트") + void writerTest() throws Exception { + // given + List items = List.of( + new ChatMessageStatus(1L, 1L, 100L), + new ChatMessageStatus(1L, 2L, 200L), + new ChatMessageStatus(2L, 1L, 300L) + ); + Chunk chunk = new Chunk<>(items); + + // when + writer.write(chunk); + + // then + verify(repository).saveLastReadMessageIdInBulk(1L, 1L, 100L); + verify(repository).saveLastReadMessageIdInBulk(1L, 2L, 200L); + verify(repository).saveLastReadMessageIdInBulk(2L, 1L, 300L); + } +} diff --git a/pennyway-batch/src/test/resources/logback-test.xml b/pennyway-batch/src/test/resources/logback-test.xml new file mode 100644 index 000000000..198192602 --- /dev/null +++ b/pennyway-batch/src/test/resources/logback-test.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/pennyway-batch/src/test/resources/sql/schema-mysql.sql b/pennyway-batch/src/test/resources/sql/schema-mysql.sql new file mode 100644 index 000000000..197ef3ff0 --- /dev/null +++ b/pennyway-batch/src/test/resources/sql/schema-mysql.sql @@ -0,0 +1,98 @@ +-- Autogenerated: do not edit this file + +CREATE TABLE BATCH_JOB_INSTANCE ( + JOB_INSTANCE_ID BIGINT NOT NULL PRIMARY KEY , + VERSION BIGINT , + JOB_NAME VARCHAR(100) NOT NULL, + JOB_KEY VARCHAR(32) NOT NULL, + constraint JOB_INST_UN unique (JOB_NAME, JOB_KEY) +) ENGINE=InnoDB; + +CREATE TABLE BATCH_JOB_EXECUTION ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY , + VERSION BIGINT , + JOB_INSTANCE_ID BIGINT NOT NULL, + CREATE_TIME DATETIME(6) NOT NULL, + START_TIME DATETIME(6) DEFAULT NULL , + END_TIME DATETIME(6) DEFAULT NULL , + STATUS VARCHAR(10) , + EXIT_CODE VARCHAR(2500) , + EXIT_MESSAGE VARCHAR(2500) , + LAST_UPDATED DATETIME(6), + constraint JOB_INST_EXEC_FK foreign key (JOB_INSTANCE_ID) + references BATCH_JOB_INSTANCE(JOB_INSTANCE_ID) +) ENGINE=InnoDB; + +CREATE TABLE BATCH_JOB_EXECUTION_PARAMS ( + JOB_EXECUTION_ID BIGINT NOT NULL , + PARAMETER_NAME VARCHAR(100) NOT NULL , + PARAMETER_TYPE VARCHAR(100) NOT NULL , + PARAMETER_VALUE VARCHAR(2500) , + IDENTIFYING CHAR(1) NOT NULL , + constraint JOB_EXEC_PARAMS_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ENGINE=InnoDB; + +CREATE TABLE BATCH_STEP_EXECUTION ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY , + VERSION BIGINT NOT NULL, + STEP_NAME VARCHAR(100) NOT NULL, + JOB_EXECUTION_ID BIGINT NOT NULL, + CREATE_TIME DATETIME(6) NOT NULL, + START_TIME DATETIME(6) DEFAULT NULL , + END_TIME DATETIME(6) DEFAULT NULL , + STATUS VARCHAR(10) , + COMMIT_COUNT BIGINT , + READ_COUNT BIGINT , + FILTER_COUNT BIGINT , + WRITE_COUNT BIGINT , + READ_SKIP_COUNT BIGINT , + WRITE_SKIP_COUNT BIGINT , + PROCESS_SKIP_COUNT BIGINT , + ROLLBACK_COUNT BIGINT , + EXIT_CODE VARCHAR(2500) , + EXIT_MESSAGE VARCHAR(2500) , + LAST_UPDATED DATETIME(6), + constraint JOB_EXEC_STEP_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ENGINE=InnoDB; + +CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT , + constraint STEP_EXEC_CTX_FK foreign key (STEP_EXECUTION_ID) + references BATCH_STEP_EXECUTION(STEP_EXECUTION_ID) +) ENGINE=InnoDB; + +CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT , + constraint JOB_EXEC_CTX_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ENGINE=InnoDB; + +CREATE TABLE BATCH_STEP_EXECUTION_SEQ ( + ID BIGINT NOT NULL, + UNIQUE_KEY CHAR(1) NOT NULL, + constraint UNIQUE_KEY_UN unique (UNIQUE_KEY) +) ENGINE=InnoDB; + +INSERT INTO BATCH_STEP_EXECUTION_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_STEP_EXECUTION_SEQ); + +CREATE TABLE BATCH_JOB_EXECUTION_SEQ ( + ID BIGINT NOT NULL, + UNIQUE_KEY CHAR(1) NOT NULL, + constraint UNIQUE_KEY_UN unique (UNIQUE_KEY) +) ENGINE=InnoDB; + +INSERT INTO BATCH_JOB_EXECUTION_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_JOB_EXECUTION_SEQ); + +CREATE TABLE BATCH_JOB_SEQ ( + ID BIGINT NOT NULL, + UNIQUE_KEY CHAR(1) NOT NULL, + constraint UNIQUE_KEY_UN unique (UNIQUE_KEY) +) ENGINE=InnoDB; + +INSERT INTO BATCH_JOB_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_JOB_SEQ); diff --git a/pennyway-common/Dockerfile b/pennyway-common/Dockerfile new file mode 100644 index 000000000..19be72911 --- /dev/null +++ b/pennyway-common/Dockerfile @@ -0,0 +1,6 @@ +FROM openjdk:17 + +WORKDIR /app +COPY build/libs/*.jar common.jar + +CMD ["java", "-jar", "common.jar"] \ No newline at end of file diff --git a/pennyway-domain/README.md b/pennyway-domain/README.md index a65f3fb62..48b43e693 100644 --- a/pennyway-domain/README.md +++ b/pennyway-domain/README.md @@ -1,48 +1,92 @@ -## Domain 모듈 +## Pennyway Domain Modules -### 🤝 Rule +### 🏛️ Architecture Overview -- 서비스 비지니스를 모른다. +
+ +
+ +도메인 모듈은 세 가지 주요 컴포넌트로 구성된다. + +- `domain-service`: 핵심 비즈니스 로직 및 도메인 간 조율 +- `domain-rdb`: MySQL/JPA 관련 구현 +- `domain-redis`: Redis 관련 구현 + +## 🤝 Convention & Rules + +### 공통 규칙 + +- 웹 관련 라이브러리 의존성 금지 +- 외부 시스템과의 직접적인 통신 금지 +- 각 모듈은 자신의 책임에 집중 + +### Domain Service Module + +- 핵심 비즈니스 로직 구현 +- 여러 도메인/저장소 간 상호작용 조율 +- `@DomainService` 어노테이션 사용 + +### Infrastructure Modules (RDB/Redis) + +- 단일 저장소 책임 원칙 +- 저장소 특화 기능 구현 +- 기본적인 유효성 검증 및 데이터 접근 로직, 불변식 검증 - 하나의 모듈은 최대 하나의 Infrastructure에 대한 책임만을 갖거나 가지지 않는다. - 도메인 모듈을 조합한 더 큰 단위의 도메인 모듈이 존재할 수 있다. -- Web 라이브러리 의존성을 갖는 것은 허용하지 않는다. -- Domain - - Java Class로 표현된 도메인 Class들 -- Repository - - 도메인 조회, 저장, 수정, 삭제 - - 시스템에서 가장 보호받아야 하고 견고해야 한다. - - 구현하려는 기능이 중심 역할이라면 도메인 모듈, 아니라면 사용하는 측에서 작성하도록 만드는 것이 좋다. -- Domain Service - - Domain의 비지니스 책임 - - Domain의 비지니스가 단순하면 생기지 않을 수도 있다. - - 트랜잭션의 단위, 요청 데이터 검증, 이벤트 발생 등의 비지니스로 사용 - - Domain 모듈의 Service는 @DomainService를 사용한다. ### 🏷️ Directory Structure -```agsl -pennyway-common -├── src -│ ├── main -│ │ ├── java.kr.co.pennyway.domain -│ │ │ ├── domains # 도메인 별로 패키지를 나누어 구성한다. -│ │ │ │ ├── entity -│ │ │ │ │ ├── domain -│ │ │ │ │ ├── exception -│ │ │ │ │ ├── repository -│ │ │ │ │ ├── service -│ │ │ │ │ └── type -│ │ │ │ └── … -│ │ │ ├── common -│ │ │ │ ├── redis # Redis Entity, Repository, Service -│ │ │ │ │ └── … -│ │ │ │ └── … -│ │ │ ├── config -│ │ │ └── DomainPackageLocation.java -│ │ └── resources -│ │ └── application-domain.yml -│ └── test -├── build.gradle -├── README.md -└── settings.gradle -``` \ No newline at end of file +``` +pennyway-domain/ +├── domain-service/ +│ ├── src/main/java/kr/co/pennyway/domain/ +│ │ ├── common/ +│ │ ├── config/ +│ │ └── context/ # 도메인 컨텍스트별 구성 +│ │ ├── chat/ +│ │ ├── account/ +│ │ └── finance/ +│ └── resources/ +│ └── application-domain-service.yml +│ +├── domain-rdb/ +│ ├── src/main/java/kr/co/pennyway/domain/ +│ │ ├── common/ +│ │ ├── config/ +│ │ └── domains/ # Entity 기반 구성 +│ │ ├── user/ +│ │ └── chat/ +│ └── resources/ +│ └── application-domain-rdb.yml +│ +└── domain-redis/ +├── src/main/java/kr/co/pennyway/domain/ +│ ├── common/ +│ ├── config/ +│ └── domains/ # Redis 모델 기반 구성 +│ ├── session/ +│ └── cache/ +└── resources/ +└── application-domain-redis.yml +``` + +## 🎯 책임 분리 가이드 + +### Domain Service가 담당하는 것 + +- 복잡한 비즈니스 규칙 +- 다중 도메인 간 조율 +- 트랜잭션 관리 + +### Infrastructure Service가 담당하는 것 + +- 단순 CRUD 연산 +- 저장소 특화 기능 (캐싱, 락 등) +- 기본적인 데이터 검증 + +## 💡 Tips + +- 새로운 기능 개발 시 도메인 책임 소재 먼저 파악하기 +- 단순 CRUD는 인프라 모듈에서 처리 +- 복잡한 비즈니스 로직은 domain-service로 +- 테스트는 각 모듈의 책임에 맞게 작성 \ No newline at end of file diff --git a/pennyway-domain/build.gradle b/pennyway-domain/build.gradle index a91450122..10f0bc91e 100644 --- a/pennyway-domain/build.gradle +++ b/pennyway-domain/build.gradle @@ -4,6 +4,9 @@ jar { enabled = true } dependencies { implementation project(':pennyway-common') + /* Jackson DataType */ + implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: '2.18.0' + /* MySQL */ implementation group: 'com.mysql', name: 'mysql-connector-j', version: '8.3.0' @@ -13,6 +16,7 @@ dependencies { /* QueryDsl */ api 'com.querydsl:querydsl-core:5.0.0' api 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + api 'com.querydsl:querydsl-sql:5.0.0' annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api:2.1.1" annotationProcessor "jakarta.persistence:jakarta.persistence-api:3.1.0" diff --git a/pennyway-domain/domain-rdb/.gitignore b/pennyway-domain/domain-rdb/.gitignore new file mode 100644 index 000000000..e47c5cb8d --- /dev/null +++ b/pennyway-domain/domain-rdb/.gitignore @@ -0,0 +1,44 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +src/main/generated/** \ No newline at end of file diff --git a/pennyway-domain/domain-rdb/build.gradle b/pennyway-domain/domain-rdb/build.gradle new file mode 100644 index 000000000..4130a3040 --- /dev/null +++ b/pennyway-domain/domain-rdb/build.gradle @@ -0,0 +1,46 @@ +bootJar { enabled = false } +jar { enabled = true } + +dependencies { + implementation project(':pennyway-common') + + /* Jackson DataType */ + implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: '2.18.0' + + /* MySQL */ + implementation group: 'com.mysql', name: 'mysql-connector-j', version: '8.3.0' + + /* JPA */ + api group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa', version: '3.2.3' + + /* QueryDsl */ + api 'com.querydsl:querydsl-core:5.0.0' + api 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + api 'com.querydsl:querydsl-sql:5.0.0' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api:2.1.1" + annotationProcessor "jakarta.persistence:jakarta.persistence-api:3.1.0" + + /* Test Containers */ + testImplementation "org.testcontainers:junit-jupiter:1.19.7" + testImplementation "org.testcontainers:testcontainers:1.19.7" + testImplementation "org.testcontainers:mysql:1.19.7" +} + +def querydslDir = 'src/main/generated' + +sourceSets { + main.java.srcDirs += [querydslDir] +} + +configurations { + querydsl.extendsFrom compileClasspath +} + +tasks.withType(JavaCompile).configureEach { + options.getGeneratedSourceOutputDirectory().set(file(querydslDir)) +} + +clean.doLast { + file(querydslDir).deleteDir() +} \ No newline at end of file diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/DomainRdbLocation.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/DomainRdbLocation.java new file mode 100644 index 000000000..0cc5248ab --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/DomainRdbLocation.java @@ -0,0 +1,4 @@ +package kr.co.pennyway.domain; + +public interface DomainRdbLocation { +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java new file mode 100644 index 000000000..09cafe044 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java @@ -0,0 +1,48 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.AttributeConverter; +import kr.co.pennyway.domain.common.util.LegacyEnumValueConvertUtil; +import lombok.Getter; +import org.springframework.util.StringUtils; + +@Getter +public class AbstractLegacyEnumAttributeConverter & LegacyCommonType> implements AttributeConverter { + /** + * 대상 Enum 클래스 {@link Class} 객체 + */ + private final Class targetEnumClass; + + /** + * nullable = false면, 변환할 값이 null로 들어왔을 때 예외를 발생시킨다.
+ * nullable = true면, 변환할 값이 null로 들어왔을 때 예외 없이 실행하며,
+ * legacy code로 변환 시엔 빈 문자열("")로 변환한다. + */ + private final boolean nullable; + + /** + * nullable = false일 때 출력할 오류 메시지에서 enum에 대한 설명을 위해 Enum의 설명적 이름을 받는다. + */ + private final String enumName; + + public AbstractLegacyEnumAttributeConverter(Class targetEnumClass, boolean nullable, String enumName) { + this.targetEnumClass = targetEnumClass; + this.nullable = nullable; + this.enumName = enumName; + } + + @Override + public String convertToDatabaseColumn(E attribute) { + if (!nullable && attribute == null) { + throw new IllegalArgumentException(String.format("%s을(를) null로 변환할 수 없습니다.", enumName)); + } + return LegacyEnumValueConvertUtil.toLegacyCode(attribute); + } + + @Override + public E convertToEntityAttribute(String dbData) { + if (!nullable && !StringUtils.hasText(dbData)) { + throw new IllegalArgumentException(String.format("%s(이)가 DB에 null 혹은 Empty로(%s) 저장되어 있습니다.", enumName, dbData)); + } + return LegacyEnumValueConvertUtil.ofLegacyCode(targetEnumClass, dbData); + } +} \ No newline at end of file diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java new file mode 100644 index 000000000..6c995aa96 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.notification.type.Announcement; + +@Converter +public class AnnouncementConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "공지 타입"; + + public AnnouncementConverter() { + super(Announcement.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/ChatMemberRoleConverter.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/ChatMemberRoleConverter.java new file mode 100644 index 000000000..17724ad2f --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/ChatMemberRoleConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; + +@Converter +public class ChatMemberRoleConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "채팅방 멤버 역할"; + + public ChatMemberRoleConverter() { + super(ChatMemberRole.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java new file mode 100644 index 000000000..3b251147e --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java @@ -0,0 +1,10 @@ +package kr.co.pennyway.domain.common.converter; + +public interface LegacyCommonType { + /** + * Legacy Super System 공통 코드를 반환한다. + * + * @return String 공통 코드 + */ + String getCode(); +} \ No newline at end of file diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java new file mode 100644 index 000000000..0653382d9 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; + +@Converter +public class NoticeTypeConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "알림 타입"; + + public NoticeTypeConverter() { + super(NoticeType.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java new file mode 100644 index 000000000..cf2f42639 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; + +@Converter +public class ProfileVisibilityConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "프로필 공개 범위"; + + public ProfileVisibilityConverter() { + super(ProfileVisibility.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java new file mode 100644 index 000000000..1ef15c1c6 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.oauth.type.Provider; + +@Converter +public class ProviderConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "제공자"; + + public ProviderConverter() { + super(Provider.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/QuestionCategoryConverter.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/QuestionCategoryConverter.java new file mode 100644 index 000000000..d40a102f3 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/QuestionCategoryConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.question.domain.QuestionCategory; + +@Converter +public class QuestionCategoryConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "문의 카테고리"; + + public QuestionCategoryConverter() { + super(QuestionCategory.class, false, ENUM_NAME); + } +} \ No newline at end of file diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java new file mode 100644 index 000000000..ee6f5da55 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.user.type.Role; + +@Converter +public class RoleConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "유저 권한"; + + public RoleConverter() { + super(Role.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java new file mode 100644 index 000000000..b9e567b88 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; + +@Converter +public class SpendingCategoryConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "지출 카테고리"; + + public SpendingCategoryConverter() { + super(SpendingCategory.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayRdbDomainConfig.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayRdbDomainConfig.java new file mode 100644 index 000000000..0eccc7377 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayRdbDomainConfig.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.domain.common.importer; + +import org.springframework.context.annotation.Import; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Import(PennywayRdbDomainConfigImportSelector.class) +public @interface EnablePennywayRdbDomainConfig { + PennywayRdbDomainConfigGroup[] value(); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRdbDomainConfig.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRdbDomainConfig.java new file mode 100644 index 000000000..d682e6bd4 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRdbDomainConfig.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.common.importer; + +/** + * Pennyway RDS Domain의 Configurations를 나타내는 Marker Interface + */ +public interface PennywayRdbDomainConfig { +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRdbDomainConfigGroup.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRdbDomainConfigGroup.java new file mode 100644 index 000000000..5ded88842 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRdbDomainConfigGroup.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.domain.common.importer; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PennywayRdbDomainConfigGroup { + ; + + private final Class configClass; +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRdbDomainConfigImportSelector.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRdbDomainConfigImportSelector.java new file mode 100644 index 000000000..f6a0f6a52 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRdbDomainConfigImportSelector.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.common.importer; + +import kr.co.pennyway.common.util.MapUtils; +import org.springframework.context.annotation.DeferredImportSelector; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.lang.NonNull; + +import java.util.Arrays; +import java.util.Map; + +public class PennywayRdbDomainConfigImportSelector implements DeferredImportSelector { + @NonNull + @Override + public String[] selectImports(@NonNull AnnotationMetadata metadata) { + return Arrays.stream(getGroups(metadata)) + .map(v -> v.getConfigClass().getName()) + .toArray(String[]::new); + } + + private PennywayRdbDomainConfigGroup[] getGroups(AnnotationMetadata metadata) { + Map attributes = metadata.getAnnotationAttributes(EnablePennywayRdbDomainConfig.class.getName()); + return (PennywayRdbDomainConfigGroup[]) MapUtils.getObject(attributes, "value", new PennywayRdbDomainConfigGroup[]{}); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java new file mode 100644 index 000000000..3ae674cca --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.common.model; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +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 +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public abstract class DateAuditable { + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java new file mode 100644 index 000000000..3198523a9 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java @@ -0,0 +1,10 @@ +package kr.co.pennyway.domain.common.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.NoRepositoryBean; + +import java.io.Serializable; + +@NoRepositoryBean +public interface ExtendedRepository extends JpaRepository, QueryDslSearchRepository { +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java new file mode 100644 index 000000000..dd6574cb8 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java @@ -0,0 +1,53 @@ +package kr.co.pennyway.domain.common.repository; + +import jakarta.persistence.EntityManager; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; +import org.springframework.lang.NonNull; + +public class ExtendedRepositoryFactory, E, ID> extends JpaRepositoryFactoryBean { + /** + * Creates a new {@link JpaRepositoryFactoryBean} for the given repository interface. + * + * @param repositoryInterface must not be {@literal null}. + */ + public ExtendedRepositoryFactory(Class repositoryInterface) { + super(repositoryInterface); + } + + @Override + @NonNull + protected RepositoryFactorySupport createRepositoryFactory(@NonNull EntityManager em) { + return new InnerRepositoryFactory(em); + } + + private static class InnerRepositoryFactory extends JpaRepositoryFactory { + private final EntityManager em; + + public InnerRepositoryFactory(EntityManager em) { + super(em); + this.em = em; + } + + @Override + @NonNull + protected RepositoryComposition.RepositoryFragments getRepositoryFragments(@NonNull RepositoryMetadata metadata) { + RepositoryComposition.RepositoryFragments fragments = super.getRepositoryFragments(metadata); + + if (QueryDslSearchRepository.class.isAssignableFrom(metadata.getRepositoryInterface())) { + var implExtendedJpa = super.instantiateClass( + QueryDslSearchRepositoryImpl.class, + this.getEntityInformation(metadata.getDomainType()), + this.em + ); + fragments = fragments.append(RepositoryComposition.RepositoryFragments.just(implExtendedJpa)); + } + + return fragments; + } + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java new file mode 100644 index 000000000..5a525ea09 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java @@ -0,0 +1,183 @@ +package kr.co.pennyway.domain.common.repository; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Predicate; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * QueryDsl을 이용한 검색 조건을 처리하는 기본적인 메서드를 선언한 인터페이스 + * + * @author YANG JAESEO + * @version 1.1 + */ +public interface QueryDslSearchRepository { + + /** + * 검색 조건에 해당하는 도메인 리스트를 조회하는 메서드 + * + * @param predicate : 검색 조건 + * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 + * @param sort : 정렬 조건 + * + * // @formatter:off + *
+     * {@code
+     * @Component
+     * class SearchService {
+     *      private final QEntity entity = QEntity.entity;
+     *      private final QEntityChild entityChild = QEntityChild.entityChild;
+     *
+     *      private Entity select() {
+     *          Predicate predicate = new BooleanBuilder();
+     *          predicate.and(entity.id.eq(1L));
+     *
+     *          QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+     *          Sort sort = Sort.by(Sort.Order.desc("entity.id"));
+     *
+     *          return searchRepository.findList(predicate, queryHandler, sort);
+     *      }
+     * }
+     * }
+     * 
+ * // @formatter:on + * + * @see Predicate + * @see QueryHandler + * @see org.springframework.data.domain.PageRequest + */ + List findList(Predicate predicate, QueryHandler queryHandler, Sort sort); + + /** + * 검색 조건에 해당하는 도메인 페이지를 조회하는 메서드 + * + * @param predicate : 검색 조건 + * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 + * @param pageable : 페이지 정보 + * + * // @formatter:off + *
+     * {@code
+     * @Component
+     * class SearchService {
+     *      private final QEntity entity = QEntity.entity;
+     *      private final QEntityChild entityChild = QEntityChild.entityChild;
+     *
+     *      private Entity select() {
+     *          Predicate predicate = new BooleanBuilder();
+     *          predicate.and(entity.id.eq(1L));
+     *
+     *          QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+     *          Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Order.desc("entity.id")));
+     *
+     *          return searchRepository.findList(predicate, queryHandler, pageable);
+     *      }
+     * }
+     * }
+     * 
+ * // @formatter:on + * + * @see Predicate + * @see QueryHandler + * @see org.springframework.data.domain.PageRequest + */ + Page findPage(Predicate predicate, QueryHandler queryHandler, Pageable pageable); + + /** + * 검색 조건에 해당하는 DTO 리스트를 조회하는 메서드
+ * bindings가 {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다.
+ * 만약 bindings가 삽입 순서를 보장하지 않을 경우, Dto는 기본 생성자와 setter 메서드를 제공해야 하며, 모든 필드의 final 키워드를 제거해야 한다. + * + * @param predicate : 검색 조건 + * @param type : 조회할 도메인(혹은 DTO) 타입 + * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드. {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입해야 한다. + * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 + * @param sort : 정렬 조건 + * + * // @formatter:off + *
+     * {@code
+     * @Component
+     * class SearchService {
+     *      private final QEntity entity = QEntity.entity;
+     *      private final QEntityChild entityChild = QEntityChild.entityChild;
+     *
+     *      private EntityDto select() {
+     *          Predicate predicate = new BooleanBuilder();
+     *          predicate.and(entity.id.eq(1L));
+     *
+     *          QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+     *          Sort sort = Sort.by(Sort.Order.desc("entity.id"));
+     *
+     *          return searchRepository.findList(predicate, EntityDto.class, this.buildBindings(), queryHandler, sort);
+     *      }
+     *
+     *      private Map> buildBindings() {
+     *          Map> bindings = new HashMap<>();
+     *
+     *          bindings.put("id", entity.id);
+     *          bindings.put("name", entity.name);
+     *
+     *          return bindings;
+     *      }
+     * }
+     * }
+     * 
+ * // @formatter:on + * + * @see Predicate + * @see QueryHandler + * @see org.springframework.data.domain.PageRequest + */ +

List

selectList(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Sort sort); + + /** + * 검색 조건에 해당하는 DTO 페이지를 조회하는 메서드 + * bindings가 {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다.
+ * 만약 bindings가 삽입 순서를 보장하지 않을 경우, Dto는 기본 생성자와 setter 메서드를 제공해야 하며, 모든 필드의 final 키워드를 제거해야 한다. + * + * @param predicate : 검색 조건 + * @param type : 조회할 도메인(혹은 DTO) 타입 + * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드. {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입해야 한다. + * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 + * @param pageable : 페이지 정보 + * + * // @formatter:off + *

+     * {@code
+     * @Component
+     * class SearchService {
+     *      private final QEntity entity = QEntity.entity;
+     *      private final QEntityChild entityChild = QEntityChild.entityChild;
+     *
+     *      private EntityDto select() {
+     *          Predicate predicate = new BooleanBuilder();
+     *          predicate.and(entity.id.eq(1L));
+     *          QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+     *          Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Order.desc("entity.id")));
+     *
+     *          return searchRepository.findPage(predicate, EntityDto.class, this.buildBindings(), queryHandler, pageable);
+     *      }
+     *
+     *      private Map> buildBindings() {
+     *          Map> bindings = new HashMap<>();
+     *          bindings.put("id", entity.id);
+     *          bindings.put("name", entity.name);
+     *          return bindings;
+     *      }
+     *  }
+     *  }
+     *  
+ * // @formatter:on + * + * @see Predicate + * @see QueryHandler + * @see org.springframework.data.domain.PageRequest + */ +

Page

selectPage(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Pageable pageable); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java new file mode 100644 index 000000000..2c751efeb --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java @@ -0,0 +1,136 @@ +package kr.co.pennyway.domain.common.repository; + +import com.querydsl.core.types.*; +import com.querydsl.core.types.dsl.EntityPathBase; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import kr.co.pennyway.domain.common.util.QueryDslUtil; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; +import org.springframework.data.querydsl.QSort; +import org.springframework.data.querydsl.SimpleEntityPathResolver; +import org.springframework.util.Assert; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class QueryDslSearchRepositoryImpl implements QueryDslSearchRepository { + private final EntityManager em; + private final JPAQueryFactory queryFactory; + private final EntityPath path; + + public QueryDslSearchRepositoryImpl(JpaEntityInformation entityInformation, EntityManager entityManager) { + this.em = entityManager; + this.queryFactory = new JPAQueryFactory(entityManager); + this.path = SimpleEntityPathResolver.INSTANCE.createPath(entityInformation.getJavaType()); + } + + public QueryDslSearchRepositoryImpl(Class type, EntityManager entityManager) { + this.em = entityManager; + this.queryFactory = new JPAQueryFactory(entityManager); + this.path = new EntityPathBase<>(type, "entity"); + } + + @Override + public List findList(Predicate predicate, QueryHandler queryHandler, Sort sort) { + return this.buildWithoutSelect(predicate, null, queryHandler, sort).select(path).fetch(); + } + + @Override + public Page findPage(Predicate predicate, QueryHandler queryHandler, Pageable pageable) { + Assert.notNull(pageable, "pageable must not be null!"); + + JPAQuery query = this.buildWithoutSelect(predicate, null, queryHandler, pageable.getSort()).select(path); + + int totalSize = query.fetch().size(); + query = query.offset(pageable.getOffset()).limit(pageable.getPageSize()); + + return new PageImpl<>(query.select(path).fetch(), pageable, totalSize); + } + + @Override + public

List

selectList(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Sort sort) { + JPAQuery query = this.buildWithoutSelect(predicate, bindings, queryHandler, sort); + + if (bindings instanceof LinkedHashMap) { + return query.select(Projections.constructor(type, bindings.values().toArray(new Expression[0]))).fetch(); + } + + return query.select(Projections.bean(type, bindings)).fetch(); + } + + @Override + public

Page

selectPage(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Pageable pageable) { + Assert.notNull(pageable, "pageable must not be null!"); + + JPAQuery query = this.buildWithoutSelect(predicate, bindings, queryHandler, pageable.getSort()).select(path); + + int totalSize = query.fetch().size(); + query = query.offset(pageable.getOffset()).limit(pageable.getPageSize()); + + if (bindings instanceof LinkedHashMap) { + return new PageImpl<>(query.select(Projections.constructor(type, bindings.values().toArray(new Expression[0]))).fetch(), pageable, totalSize); + } + + return new PageImpl<>(query.select(Projections.bean(type, bindings)).fetch(), pageable, totalSize); + } + + /** + * 파라미터를 기반으로 Querydsl의 JPAQuery를 생성하는 메서드 + */ + private JPAQuery buildWithoutSelect(Predicate predicate, Map> bindings, QueryHandler queryHandler, Sort sort) { + JPAQuery query = queryFactory.from(path); + + applyPredicate(predicate, query); + applyQueryHandler(queryHandler, query); + applySort(query, sort, bindings); + + return query; + } + + /** + * Querydsl의 JPAQuery에 Predicate를 적용하는 메서드
+ * Predicate가 null이 아닐 경우에만 적용 + */ + private void applyPredicate(Predicate predicate, JPAQuery query) { + if (predicate != null) query.where(predicate); + } + + /** + * Querydsl의 JPAQuery에 QueryHandler를 적용하는 메서드
+ * QueryHandler가 null이 아닐 경우에만 적용 + */ + private void applyQueryHandler(QueryHandler queryHandler, JPAQuery query) { + if (queryHandler != null) queryHandler.apply(query); + } + + /** + * Querydsl의 JPAQuery에 Sort를 적용하는 메서드
+ * Sort가 null이 아닐 경우에만 적용
+ * Sort가 QSort일 경우에는 OrderSpecifier를 적용하고, 그 외의 경우에는 OrderSpecifier를 생성하여 적용 + */ + private void applySort(JPAQuery query, Sort sort, Map> bindings) { + if (sort != null) { + if (sort instanceof QSort qSort) { + query.orderBy(qSort.getOrderSpecifiers().toArray(new OrderSpecifier[0])); + } else { + applySortOrders(query, sort, bindings); + } + } + } + + private void applySortOrders(JPAQuery query, Sort sort, Map> bindings) { + for (Sort.Order order : sort) { + OrderSpecifier.NullHandling queryDslNullHandling = QueryDslUtil.getQueryDslNullHandling(order); + + OrderSpecifier os = QueryDslUtil.getOrderSpecifier(order, bindings, queryDslNullHandling); + + query.orderBy(os); + } + } +} \ No newline at end of file diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java new file mode 100644 index 000000000..93af7feee --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.repository; + +import com.querydsl.jpa.impl.JPAQuery; + +/** + * QueryDsl의 명시적 조인을 위한 함수형 인터페이스 + * + * @author YANG JAESEO + */ +@FunctionalInterface +public interface QueryHandler { + JPAQuery apply(JPAQuery query); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java new file mode 100644 index 000000000..d79b46c32 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.domain.common.util; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; + +import java.util.EnumSet; + +/** + * {@link LegacyCommonType} enum을 String과 상호 변환하는 유틸리티 클래스 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class LegacyEnumValueConvertUtil { + public static & LegacyCommonType> T ofLegacyCode(Class enumClass, String code) { + if (!StringUtils.hasText(code)) return null; + return EnumSet.allOf(enumClass).stream() + .filter(e -> e.getCode().equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + String.format("enum=[%s], code=[%s]가 존재하지 않습니다.", enumClass.getName(), code))); + } + + public static & LegacyCommonType> String toLegacyCode(T enumValue) { + if (enumValue == null) return ""; + return enumValue.getCode(); + } +} \ No newline at end of file diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java new file mode 100644 index 000000000..c5b178838 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java @@ -0,0 +1,116 @@ +package kr.co.pennyway.domain.common.util; + +import com.querydsl.core.types.*; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.core.types.dsl.StringPath; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static kr.co.pennyway.domain.config.MySqlFunctionContributor.TWO_COLUMN_NATURAL_FUNCTION_NAME; + +/** + * QueryDsl의 편의 기능을 제공하는 유틸리티 클래스 + * + * @author YANG JAESEO + * @version 1.0 + */ +@Slf4j +public class QueryDslUtil { + private static final Function castToQueryDsl = nullHandling -> switch (nullHandling) { + case NATIVE -> OrderSpecifier.NullHandling.Default; + case NULLS_FIRST -> OrderSpecifier.NullHandling.NullsFirst; + case NULLS_LAST -> OrderSpecifier.NullHandling.NullsLast; + }; + + /** + * Pageable의 sort를 QueryDsl의 OrderSpecifier로 변환하는 메서드 + * + * @param sort : {@link Sort} + */ + public static List> getOrderSpecifier(Sort sort) { + List> orders = new ArrayList<>(); + + for (Sort.Order order : sort) { + OrderSpecifier.NullHandling nullHandling = castToQueryDsl.apply(order.getNullHandling()); + orders.add(getOrderSpecifier(order, nullHandling)); + } + + return orders; + } + + /** + * Sort.Order의 정보를 이용하여 OrderSpecifier.NullHandling을 반환하는 메서드 + * + * @param order : {@link Sort.Order} + * @return {@link OrderSpecifier.NullHandling} + */ + public static OrderSpecifier.NullHandling getQueryDslNullHandling(Sort.Order order) { + return castToQueryDsl.apply(order.getNullHandling()); + } + + /** + * OrderSpecifier를 생성할 때, Sort.Order의 정보를 이용하여 OrderSpecifier.NullHandling을 적용하는 메서드 + * + * @param order : {@link Sort.Order} + * @param nullHandling : {@link OrderSpecifier.NullHandling} + * @return {@link OrderSpecifier} + */ + public static OrderSpecifier getOrderSpecifier(Sort.Order order, OrderSpecifier.NullHandling nullHandling) { + Order orderBy = order.isAscending() ? Order.ASC : Order.DESC; + + return createOrderSpecifier(orderBy, Expressions.stringPath(order.getProperty()), nullHandling); + } + + /** + * Expression이 Operation이고 Operator가 ALIAS일 경우, OrderSpecifier를 생성할 때, Expression을 StringPath로 변환하여 생성한다.
+ * 그 외의 경우에는 OrderSpecifier를 생성한다. + * + * @param order : {@link Sort.Order} + * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드 정보. {@code binding}은 Map> 형태로 전달된다. + * @param queryDslNullHandling : {@link OrderSpecifier.NullHandling} + * @return {@link OrderSpecifier} + */ + public static OrderSpecifier getOrderSpecifier(Sort.Order order, Map> bindings, OrderSpecifier.NullHandling queryDslNullHandling) { + Order orderBy = order.isAscending() ? Order.ASC : Order.DESC; + + if (bindings != null && bindings.containsKey(order.getProperty())) { + Expression expression = bindings.get(order.getProperty()); + return createOrderSpecifier(orderBy, expression, queryDslNullHandling); + } else { + return createOrderSpecifier(orderBy, Expressions.stringPath(order.getProperty()), queryDslNullHandling); + } + } + + /** + * MySQL의 match_against 함수를 사용하여 한 컬럼을 비교하는 메서드 + * + * @param c1 {@link StringPath} : 비교할 첫 번째 컬럼 + * @param c2 {@link StringPath} : 비교할 두 번째 컬럼 + * @param target {@link String} : 비교할 대상 + * @return {@link NumberExpression} : match_against 함수를 사용하여 비교한 결과 측정치가 0 이상이면 true, 0 미만이면 false + */ + public static BooleanExpression matchAgainstTwoElemNaturalMode(final StringPath c1, final StringPath c2, final String target) { + if (!StringUtils.hasText(target)) { + return null; + } + + return Expressions.booleanTemplate(TWO_COLUMN_NATURAL_FUNCTION_NAME, c1, c2, target); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static OrderSpecifier createOrderSpecifier(Order orderBy, Expression expression, OrderSpecifier.NullHandling queryDslNullHandling) { + if (expression instanceof Operation && ((Operation) expression).getOperator() == Ops.ALIAS) { + return new OrderSpecifier<>(orderBy, Expressions.stringPath(((Operation) expression).getArg(1).toString()), queryDslNullHandling); + } else { + return new OrderSpecifier(orderBy, expression, queryDslNullHandling); + } + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java new file mode 100644 index 000000000..7c5d3916e --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.domain.common.util; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +import java.util.List; + +/** + * Slice를 생성하는 메서드를 제공하는 유틸리티 클래스 + * + * @author YANG JAESEO + * @version 1.0 + */ +public class SliceUtil { + /** + * List로 받은 contents를 Slice로 변환한다. + * + * @param contents : 변환할 List + * @param pageable : Pageable + * @return Slice : 변환된 Slice. 단, contents.size()가 pageable.getPageSize()보다 작을 경우 hasNext는 true이며, Slice의 size는 contents.size() - 1이다. + */ + public static Slice toSlice(List contents, Pageable pageable) { + boolean hasNext = isContentSizeGreaterThanPageSize(contents, pageable); + return new SliceImpl<>(hasNext ? subListLastContent(contents, pageable) : contents, pageable, hasNext); + } + + private static boolean isContentSizeGreaterThanPageSize(List content, Pageable pageable) { + return pageable.isPaged() && content.size() > pageable.getPageSize(); + } + + private static List subListLastContent(List content, Pageable pageable) { + return content.subList(0, pageable.getPageSize()); + } +} \ No newline at end of file diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java new file mode 100644 index 000000000..fd520160e --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.domain.config; + +import kr.co.pennyway.domain.DomainRdbLocation; +import kr.co.pennyway.domain.common.repository.ExtendedRepositoryFactory; +import kr.co.pennyway.domain.domains.JpaPackageLocation; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EnableJpaAuditing +@EntityScan(basePackageClasses = DomainRdbLocation.class) +@EnableJpaRepositories(basePackageClasses = JpaPackageLocation.class, repositoryFactoryBeanClass = ExtendedRepositoryFactory.class) +public class JpaConfig { +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/config/MySqlFunctionContributor.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/config/MySqlFunctionContributor.java new file mode 100644 index 000000000..a7a8e9236 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/config/MySqlFunctionContributor.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.config; + +import org.hibernate.boot.model.FunctionContributions; +import org.hibernate.boot.model.FunctionContributor; +import org.hibernate.query.sqm.function.SqmFunctionRegistry; +import org.hibernate.type.StandardBasicTypes; +import org.hibernate.type.spi.TypeConfiguration; + +public class MySqlFunctionContributor implements FunctionContributor { + public static final String TWO_COLUMN_NATURAL_FUNCTION_NAME = "two_column_natural"; + private static final String TWO_COLUMN_NATURAL_PATTERN = "match(?1, ?2) against(?3 in natural language mode)"; + + @Override + public void contributeFunctions(final FunctionContributions functionContributions) { + SqmFunctionRegistry registry = functionContributions.getFunctionRegistry(); + TypeConfiguration typeConfiguration = functionContributions.getTypeConfiguration(); + + registry.registerPattern(TWO_COLUMN_NATURAL_FUNCTION_NAME, TWO_COLUMN_NATURAL_PATTERN, typeConfiguration.getBasicTypeRegistry().resolve(StandardBasicTypes.DOUBLE)); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java new file mode 100644 index 000000000..5ac4f40a7 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.domain.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.querydsl.sql.MySQLTemplates; +import com.querydsl.sql.SQLTemplates; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class QueryDslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + @Primary + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } + + @Bean + public SQLTemplates sqlTemplates() { + return new MySQLTemplates(); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java new file mode 100644 index 000000000..c46f1eca0 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java @@ -0,0 +1,4 @@ +package kr.co.pennyway.domain.domains; + +public interface JpaPackageLocation { +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/domain/ChatRoom.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/domain/ChatRoom.java new file mode 100644 index 000000000..b56db8263 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/domain/ChatRoom.java @@ -0,0 +1,111 @@ +package kr.co.pennyway.domain.domains.chatroom.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.Hibernate; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "chat_room") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert +@SQLRestriction("deleted_at IS NULL") +@SQLDelete(sql = "UPDATE chat_room SET deleted_at = NOW() WHERE id = ?") +public class ChatRoom extends DateAuditable { + @Id + private Long id; + + private String title; + private String description; + private String backgroundImageUrl; + private Integer password; + + @ColumnDefault("NULL") + private LocalDateTime deletedAt; + + @OneToMany(mappedBy = "chatRoom") + @SQLRestriction("deleted_at IS NULL") + private List chatMembers = new ArrayList<>(); + + @Builder + public ChatRoom(Long id, String title, String description, String backgroundImageUrl, Integer password) { + validate(id, title, description, password); + + this.id = id; + this.title = title; + this.description = description; + this.backgroundImageUrl = backgroundImageUrl; + this.password = password; + } + + public void update(String title, String description, String backgroundImageUrl, Integer password) { + validate(title, description, password); + + this.title = title; + this.description = description; + this.backgroundImageUrl = backgroundImageUrl; + this.password = password; + } + + private void validate(Long id, String title, String description, Integer password) { + Objects.requireNonNull(id, "채팅방 ID는 null일 수 없습니다."); + + validate(title, description, password); + } + + private void validate(String title, String description, Integer password) { + if (!StringUtils.hasText(title) || title.length() > 50) { + throw new IllegalArgumentException("제목은 null이거나 빈 문자열이 될 수 없으며, 50자 이하로 제한됩니다."); + } + + if (description != null && description.length() > 100) { + throw new IllegalArgumentException("설명은 null이거나 빈 문자열이 될 수 있으며, 100자 이하로 제한됩니다."); + } + + if (password != null && password < 0 && password.toString().length() != 6) { + throw new IllegalArgumentException("비밀번호는 null이거나, 6자리 정수여야 하며, 음수는 허용하지 않습니다."); + } + } + + public boolean isPrivateRoom() { + return password != null; + } + + public boolean matchPassword(Integer password) { + return this.password.equals(password); + } + + public boolean hasOnlyAdmin() { + return Hibernate.size(chatMembers) == 1 && chatMembers.get(0).isAdmin(); + } + + @Override + public String toString() { + return "ChatRoom{" + + "id=" + id + + ", title='" + title + '\'' + + ", description='" + description + '\'' + + ", backgroundImageUrl='" + backgroundImageUrl + '\'' + + ", password=" + password + + ", deletedAt=" + deletedAt + + '}'; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/dto/ChatRoomDetail.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/dto/ChatRoomDetail.java new file mode 100644 index 000000000..2ac014cd6 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/dto/ChatRoomDetail.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.domain.domains.chatroom.dto; + +import java.time.LocalDateTime; + +public record ChatRoomDetail( + Long id, + String title, + String description, + String backgroundImageUrl, + Integer password, + LocalDateTime createdAt, + boolean isAdmin, + int participantCount, + boolean isNotifyEnabled +) { +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorCode.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorCode.java new file mode 100644 index 000000000..eaded0a8e --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorCode.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.domain.domains.chatroom.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ChatRoomErrorCode implements BaseErrorCode { + /* 400 Bad Request */ + INVALID_PASSWORD(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "비밀번호가 일치하지 않습니다."), + + /* 404 Not Found */ + NOT_FOUND_CHAT_ROOM(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "채팅방을 찾을 수 없습니다."), + + /* 409 Conflict */ + FULL_CHAT_ROOM(StatusCode.CONFLICT, ReasonCode.REQUESTED_RESPONSE_FORMAT_NOT_SUPPORTED, "채팅방 인원이 가득 찼습니다."), + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorException.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorException.java new file mode 100644 index 000000000..747a8782b --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.domain.domains.chatroom.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class ChatRoomErrorException extends GlobalErrorException { + private final ChatRoomErrorCode chatRoomErrorCode; + + public ChatRoomErrorException(ChatRoomErrorCode baseErrorCode) { + super(baseErrorCode); + this.chatRoomErrorCode = baseErrorCode; + } + + public CausedBy causedBy() { + return chatRoomErrorCode.causedBy(); + } + + public String getExplainError() { + return chatRoomErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/repository/ChatRoomCustomRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/repository/ChatRoomCustomRepository.java new file mode 100644 index 000000000..cf1f2fef9 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/repository/ChatRoomCustomRepository.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.domain.domains.chatroom.repository; + +import kr.co.pennyway.domain.domains.chatroom.dto.ChatRoomDetail; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +import java.util.List; + +public interface ChatRoomCustomRepository { + /** + * 사용자가 참여한 채팅방 목록을 조회하며, 응답은 List<{@link ChatRoomDetail}> 형태로 반환한다. + * 반환된 채팅방 목록은 정렬 순서를 보장하지 않는다. + */ + List findChatRoomsByUserId(Long userId); + + /** + * target 파라미터로 채팅방 제목, 설명과 일치하는 항목을 탐색하여 결과를 반환한다. + * 응답은 Slice<{@link ChatRoomDetail}> 형태로 반환되며, 반환된 채팅방 목록은 가장 매칭 점수가 높은 순서대로 정렬된다. + * + * @param userId 사용자 ID + * @param target 검색 대상 + * @param pageable 페이징 정보 + * @return 채팅방 목록 + */ + Slice findChatRooms(Long userId, String target, Pageable pageable); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/repository/ChatRoomCustomRepositoryImpl.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/repository/ChatRoomCustomRepositoryImpl.java new file mode 100644 index 000000000..f72e7ad84 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/repository/ChatRoomCustomRepositoryImpl.java @@ -0,0 +1,202 @@ +package kr.co.pennyway.domain.domains.chatroom.repository; + +import com.querydsl.core.Tuple; +import com.querydsl.core.types.EntityPath; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.*; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.JPQLQuery; +import com.querydsl.jpa.sql.JPASQLQuery; +import com.querydsl.sql.SQLTemplates; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import kr.co.pennyway.domain.common.util.SliceUtil; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.dto.ChatRoomDetail; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.querydsl.core.types.dsl.Expressions.stringPath; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class ChatRoomCustomRepositoryImpl implements ChatRoomCustomRepository { + private final SQLTemplates sqlTemplates; + @PersistenceContext + private final EntityManager entityManager; + + @Override + @Transactional(readOnly = true) + public List findChatRoomsByUserId(Long userId) { + JPASQLQuery jpaSqlQuery = new JPASQLQuery<>(entityManager, sqlTemplates); + + // 별칭 정의 + final StringPath MY_ROOMS = stringPath("my_rooms"); + final StringPath ROOM_STATS = stringPath("room_stats"); + final String CHAT_ROOM_ID = "chat_room_id"; + final String MEMBER_COUNT = "member_count"; + final String IS_ADMIN = "is_admin"; + final String NOTIFY_ENABLED = "notify_enabled"; + + // EntityPath 정의 + EntityPath Chat_MEMBER_ENTITY_PATH = new EntityPathBase<>(ChatMember.class, "chat_member"); + EntityPath CHAT_ROOM_ENTITY_PATH = new EntityPathBase<>(ChatRoom.class, "chat_room"); + + // 사용자가 가입한 방 필터링 서브쿼리 + JPQLQuery myRoomsQuery = JPAExpressions + .select( + Expressions.numberPath(Long.class, Chat_MEMBER_ENTITY_PATH, CHAT_ROOM_ID), + Expressions.booleanPath(Chat_MEMBER_ENTITY_PATH, "notify_enabled").as(NOTIFY_ENABLED) + ) + .from(Chat_MEMBER_ENTITY_PATH) + .where( + Expressions.numberPath(Long.class, Chat_MEMBER_ENTITY_PATH, "user_id").eq(userId), + Expressions.dateTimePath(LocalDateTime.class, Chat_MEMBER_ENTITY_PATH, "deleted_at").isNull() + ); + + // 멤버 수와 어드민 여부를 계산하는 서브쿼리 + JPQLQuery roomStatsQuery = JPAExpressions + .select( + Expressions.numberPath(Long.class, Chat_MEMBER_ENTITY_PATH, CHAT_ROOM_ID), + Expressions.numberTemplate(Long.class, "COUNT(*)", Chat_MEMBER_ENTITY_PATH).as(MEMBER_COUNT), + Expressions.booleanTemplate( + "MAX(CASE WHEN user_id = {0} AND role = '0' THEN true ELSE false END)", + userId + ).as(IS_ADMIN) + ) + .from(Chat_MEMBER_ENTITY_PATH) + .where(Expressions.dateTimePath(LocalDateTime.class, Chat_MEMBER_ENTITY_PATH, "deleted_at").isNull()) + .groupBy(Expressions.numberPath(Long.class, Chat_MEMBER_ENTITY_PATH, CHAT_ROOM_ID)); + + // 메인 쿼리 + return jpaSqlQuery + .select(Projections.constructor( + ChatRoomDetail.class, + Expressions.numberPath(Long.class, CHAT_ROOM_ENTITY_PATH, "id"), + Expressions.stringPath(CHAT_ROOM_ENTITY_PATH, "title"), + Expressions.stringPath(CHAT_ROOM_ENTITY_PATH, "description"), + Expressions.stringPath(CHAT_ROOM_ENTITY_PATH, "background_image_url"), + Expressions.numberPath(Integer.class, CHAT_ROOM_ENTITY_PATH, "password"), + Expressions.dateTemplate( + LocalDateTime.class, + "DATE_FORMAT({0}, '%Y-%m-%d %H:%i:%s')", + "createdAt" + ), + Expressions.booleanPath(ROOM_STATS, IS_ADMIN), + Expressions.numberPath(Integer.class, ROOM_STATS, MEMBER_COUNT), + Expressions.booleanPath(MY_ROOMS, NOTIFY_ENABLED) + )) + .from(CHAT_ROOM_ENTITY_PATH) + .innerJoin(myRoomsQuery, MY_ROOMS) + .on(Expressions.numberPath(Long.class, CHAT_ROOM_ENTITY_PATH, "id") + .eq(Expressions.numberPath(Long.class, MY_ROOMS, CHAT_ROOM_ID))) + .leftJoin(roomStatsQuery, ROOM_STATS) + .on(Expressions.numberPath(Long.class, CHAT_ROOM_ENTITY_PATH, "id") + .eq(Expressions.numberPath(Long.class, ROOM_STATS, CHAT_ROOM_ID))) + .where(Expressions.dateTimePath(LocalDateTime.class, CHAT_ROOM_ENTITY_PATH, "deleted_at").isNull()) + .fetch(); + } + + @Override + @Transactional(readOnly = true) + public Slice findChatRooms(Long userId, String target, Pageable pageable) { + JPASQLQuery jpaSqlQuery = new JPASQLQuery<>(entityManager, sqlTemplates); + + // 별칭 정의 + final StringPath CM = stringPath("cm"); + final StringPath CM_COUNT = stringPath("cm_count"); + + final String CHAT_ROOM_ID = "chat_room_id"; + final String MEMBER_COUNT = "member_count"; + + // EntityPath 정의 + EntityPath CHAT_MEMBER_ENTITY_PATH = new EntityPathBase<>(ChatMember.class, "chat_member"); + EntityPath CHAT_ROOM_ENTITY_PATH = new EntityPathBase<>(ChatRoom.class, "chat_room"); + + // 컬럼 경로 정의 + NumberPath CHAT_ROOM_ID_PATH = Expressions.numberPath(Long.class, CHAT_MEMBER_ENTITY_PATH, "chat_room_id"); + NumberPath USER_ID_PATH = Expressions.numberPath(Long.class, CHAT_MEMBER_ENTITY_PATH, "user_id"); + DateTimePath MEMBER_DELETED_AT_PATH = Expressions.dateTimePath(LocalDateTime.class, CHAT_MEMBER_ENTITY_PATH, "deleted_at"); + DateTimePath ROOM_DELETED_AT_PATH = Expressions.dateTimePath(LocalDateTime.class, CHAT_ROOM_ENTITY_PATH, "deleted_at"); + + // 사용자가 가입하지 않은 채팅방 필터링 서브쿼리 + JPQLQuery eligibleRoomsQuery = JPAExpressions + .select(CHAT_ROOM_ID_PATH) + .from(CHAT_MEMBER_ENTITY_PATH) + .where( + MEMBER_DELETED_AT_PATH.isNull(), + CHAT_ROOM_ID_PATH.notIn( + JPAExpressions + .select(CHAT_ROOM_ID_PATH) + .from(CHAT_MEMBER_ENTITY_PATH) + .where( + USER_ID_PATH.eq(userId), + MEMBER_DELETED_AT_PATH.isNull() + ) + ) + ); + + // 멤버 수 계산 서브쿼리 + JPQLQuery memberCountQuery = JPAExpressions + .select( + CHAT_ROOM_ID_PATH.as(CHAT_ROOM_ID), + Expressions.numberTemplate(Long.class, "count(*)", CHAT_MEMBER_ENTITY_PATH).intValue().as(MEMBER_COUNT) + ) + .from(CHAT_MEMBER_ENTITY_PATH) + .where(MEMBER_DELETED_AT_PATH.isNull()) + .groupBy(CHAT_ROOM_ID_PATH); + + // MATCH AGAINST 표현식 + BooleanExpression matchExpr = Expressions.booleanTemplate( + "MATCH({0}, {1}) AGAINST({2} IN NATURAL LANGUAGE MODE)", + Expressions.stringPath(CHAT_ROOM_ENTITY_PATH, "title"), + Expressions.stringPath(CHAT_ROOM_ENTITY_PATH, "description"), + target + ); + + // 메인 쿼리 + List results = jpaSqlQuery + .select(Projections.constructor( + ChatRoomDetail.class, + Expressions.numberPath(Long.class, CHAT_ROOM_ENTITY_PATH, "id"), + Expressions.stringPath(CHAT_ROOM_ENTITY_PATH, "title"), + Expressions.stringPath(CHAT_ROOM_ENTITY_PATH, "description"), + Expressions.stringPath(CHAT_ROOM_ENTITY_PATH, "background_image_url"), + Expressions.numberPath(Integer.class, CHAT_ROOM_ENTITY_PATH, "password"), + Expressions.dateTemplate( + LocalDateTime.class, + "DATE_FORMAT({0}, '%Y-%m-%d %H:%i:%s')", + "createdAt" + ), + Expressions.constant(false), + Expressions.numberPath(Integer.class, CM_COUNT, MEMBER_COUNT), + Expressions.constant(false) + )) + .from(CHAT_ROOM_ENTITY_PATH) + .innerJoin(eligibleRoomsQuery, CM) + .on(Expressions.numberPath(Long.class, CHAT_ROOM_ENTITY_PATH, "id") + .eq(Expressions.numberPath(Long.class, CM, CHAT_ROOM_ID))) + .leftJoin(memberCountQuery, CM_COUNT) + .on(Expressions.numberPath(Long.class, CHAT_ROOM_ENTITY_PATH, "id") + .eq(Expressions.numberPath(Long.class, CM_COUNT, CHAT_ROOM_ID))) + .where( + matchExpr, + ROOM_DELETED_AT_PATH.isNull() + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + return SliceUtil.toSlice(results, pageable); + } + +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/repository/ChatRoomRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/repository/ChatRoomRepository.java new file mode 100644 index 000000000..2297e0959 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/repository/ChatRoomRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.domains.chatroom.repository; + +import kr.co.pennyway.domain.common.repository.ExtendedRepository; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; + +public interface ChatRoomRepository extends ExtendedRepository, ChatRoomCustomRepository { +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/service/ChatRoomRdbService.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/service/ChatRoomRdbService.java new file mode 100644 index 000000000..8c862e7a1 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatroom/service/ChatRoomRdbService.java @@ -0,0 +1,51 @@ +package kr.co.pennyway.domain.domains.chatroom.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.dto.ChatRoomDetail; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatRoomRdbService { + private final ChatRoomRepository chatRoomRepository; + + @Transactional + public ChatRoom create(ChatRoom chatRoom) { + return chatRoomRepository.save(chatRoom); + } + + @Transactional(readOnly = true) + public Optional readChatRoom(Long chatRoomId) { + return chatRoomRepository.findById(chatRoomId); + } + + @Transactional(readOnly = true) + public List readChatRoomsByUserId(Long userId) { + return chatRoomRepository.findChatRoomsByUserId(userId); + } + + @Transactional(readOnly = true) + public Slice readChatRooms(Long userId, String target, Pageable pageable) { + return chatRoomRepository.findChatRooms(userId, target, pageable); + } + + @Transactional + public ChatRoom update(ChatRoom chatRoom) { + return chatRoomRepository.save(chatRoom); + } + + @Transactional + public void delete(ChatRoom chatRoom) { + chatRoomRepository.delete(chatRoom); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatstatus/domain/ChatMessageStatus.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatstatus/domain/ChatMessageStatus.java new file mode 100644 index 000000000..a3fa15f62 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatstatus/domain/ChatMessageStatus.java @@ -0,0 +1,60 @@ +package kr.co.pennyway.domain.domains.chatstatus.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "chat_message_status", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_chat_message_status", + columnNames = {"user_id", "chat_room_id"} + ) + }) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatMessageStatus { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + private Long chatRoomId; + private Long lastReadMessageId; + private LocalDateTime updatedAt; + + public ChatMessageStatus(Long userId, Long chatRoomId, Long lastReadMessageId) { + this.userId = Objects.requireNonNull(userId, "userId must not be null"); + this.chatRoomId = Objects.requireNonNull(chatRoomId, "chatRoomId must not be null"); + this.lastReadMessageId = Objects.requireNonNull(lastReadMessageId, "lastReadMessageId must not be null"); + this.updatedAt = LocalDateTime.now(); + } + + @PrePersist + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public void updateLastReadMessageId(Long messageId) { + if (this.lastReadMessageId == null || messageId > this.lastReadMessageId) { + this.lastReadMessageId = messageId; + } + } + + @Override + public String toString() { + return "ChatMessageStatus{" + + "id=" + id + + ", userId=" + userId + + ", chatRoomId=" + chatRoomId + + ", lastReadMessageId=" + lastReadMessageId + + ", updatedAt=" + updatedAt + + '}'; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusRepository.java new file mode 100644 index 000000000..75194c0cc --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusRepository.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.domain.domains.chatstatus.repository; + +import kr.co.pennyway.domain.domains.chatstatus.domain.ChatMessageStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface ChatMessageStatusRepository extends JpaRepository { + Optional findByUserIdAndChatRoomId(Long userId, Long chatRoomId); + + @Query("SELECT c FROM ChatMessageStatus c WHERE c.userId = :userId AND c.chatRoomId IN :roomIds") + List findAllByUserIdAndChatRoomIdIn(Long userId, Collection roomIds); + + @Modifying + @Query(value = """ + INSERT INTO chat_message_status (user_id, chat_room_id, last_read_message_id, updated_at) + VALUES (:userId, :roomId, :messageId, NOW()) + ON DUPLICATE KEY UPDATE + last_read_message_id = GREATEST(last_read_message_id, :messageId), + updated_at = NOW() + """, nativeQuery = true) + void saveLastReadMessageIdInBulk(Long userId, Long roomId, Long messageId); +} \ No newline at end of file diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusRdbService.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusRdbService.java new file mode 100644 index 000000000..92fa33313 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusRdbService.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.chatstatus.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.chatstatus.domain.ChatMessageStatus; +import kr.co.pennyway.domain.domains.chatstatus.repository.ChatMessageStatusRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatMessageStatusRdbService { + private final ChatMessageStatusRepository chatMessageStatusRepository; + + @Transactional(readOnly = true) + public Optional readByUserIdAndChatRoomId(Long userId, Long chatRoomId) { + return chatMessageStatusRepository.findByUserIdAndChatRoomId(userId, chatRoomId); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java new file mode 100644 index 000000000..00a3ab025 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java @@ -0,0 +1,115 @@ +package kr.co.pennyway.domain.domains.device.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "device_token") +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DeviceToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String token; + private String deviceId; + private String deviceName; + + @ColumnDefault("true") + private Boolean activated; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + private LocalDateTime lastSignedInAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private DeviceToken(String token, String deviceId, String deviceName, Boolean activated, User user) { + this.token = Objects.requireNonNull(token, "token은 null이 될 수 없습니다."); + this.deviceId = Objects.requireNonNull(deviceId, "deviceId는 null이 될 수 없습니다."); + this.deviceName = Objects.requireNonNull(deviceName, "deviceName은 null이 될 수 없습니다."); + this.activated = Objects.requireNonNull(activated, "activated는 null이 될 수 없습니다."); + this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); + this.lastSignedInAt = LocalDateTime.now(); + } + + public static DeviceToken of(String token, String deviceId, String deviceName, User user) { + return new DeviceToken(token, deviceId, deviceName, Boolean.TRUE, user); + } + + /** + * 디바이스 토큰이 활성화되었는지 확인한다. + * + * @return 토큰이 활성화 되었고, 마지막 로그인 시간이 7일 이내이면 true, 그렇지 않으면 false + */ + public Boolean isActivated() { + LocalDateTime now = LocalDateTime.now(); + + return activated && lastSignedInAt.plusDays(7).isAfter(now); + } + + /** + * 디바이스 토큰이 만료되었는지 확인한다. + * + * @return 토큰이 갱신된지 7일이 지났거나 토큰이 비활성화 되었다면 true, 그렇지 않으면 false + */ + public boolean isExpired() { + LocalDateTime now = LocalDateTime.now(); + + return !activated || lastSignedInAt.plusDays(7).isBefore(now); + } + + public void activate() { + lastSignedInAt = LocalDateTime.now(); + this.activated = Boolean.TRUE; + } + + public void deactivate() { + this.activated = Boolean.FALSE; + } + + public void updateLastSignedInAt() { + this.lastSignedInAt = LocalDateTime.now(); + } + + /** + * 토큰의 소유자를 확인하고 필요한 상태 변경을 수행합니다. + * 다른 소유자인 경우 소유자를 갱신하고, 같은 소유자인 경우 활성화만 수행합니다. + */ + public void handleOwner(User newUser, String newDeviceId) { + Objects.requireNonNull(newUser, "user는 null이 될 수 없습니다."); + Objects.requireNonNull(newDeviceId, "deviceId는 null이 될 수 없습니다."); + + if (!this.user.equals(newUser)) { + this.user = newUser; + } + + if (!this.deviceId.equals(newDeviceId)) { + this.deviceId = newDeviceId; + } + + this.activate(); + } + + @Override + public String toString() { + return "DeviceToken {" + + "id=" + id + + ", token='" + token + '\'' + + ", activated=" + activated + '}'; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java new file mode 100644 index 000000000..56bc99628 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.domain.domains.device.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum DeviceTokenErrorCode implements BaseErrorCode { + /* 404 NOT_FOUND */ + NOT_FOUND_DEVICE(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "디바이스를 찾을 수 없습니다."), + + /* 409 CONFLICT */ + DUPLICATED_DEVICE_TOKEN(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 등록된 디바이스 토큰입니다."), + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java new file mode 100644 index 000000000..2a83c00c1 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.domains.device.exception; + +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class DeviceTokenErrorException extends GlobalErrorException { + private final DeviceTokenErrorCode deviceTokenErrorCode; + + public DeviceTokenErrorException(DeviceTokenErrorCode deviceTokenErrorCode) { + super(deviceTokenErrorCode); + this.deviceTokenErrorCode = deviceTokenErrorCode; + } + + public String getExplainError() { + return deviceTokenErrorCode.getExplainError(); + } + + public String getErrorCode() { + return deviceTokenErrorCode.name(); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java new file mode 100644 index 000000000..492b9a19e --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.domain.domains.device.repository; + +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +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; + +import java.util.List; +import java.util.Optional; + +public interface DeviceTokenRepository extends JpaRepository { + @Query("SELECT d FROM DeviceToken d WHERE d.user.id = :userId AND d.token = :token") + Optional findByUser_IdAndToken(Long userId, String token); + + List findAllByUser_Id(Long userId); + + Optional findByToken(String token); + + List findAllByUser_IdAndDeviceId(Long userId, String deviceId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE DeviceToken d SET d.activated = false WHERE d.user.id = :userId") + void deleteAllByUserIdInQuery(Long userId); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenRdbService.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenRdbService.java new file mode 100644 index 000000000..e3f69b999 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenRdbService.java @@ -0,0 +1,66 @@ +package kr.co.pennyway.domain.domains.device.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; +import kr.co.pennyway.domain.domains.device.repository.DeviceTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class DeviceTokenRdbService { + private final DeviceTokenRepository deviceTokenRepository; + + /** + * @throws DeviceTokenErrorException 중복된 디바이스 토큰이 이미 존재하는 경우 + */ + @Transactional + public DeviceToken createDevice(DeviceToken deviceToken) { + try { + return deviceTokenRepository.save(deviceToken); + } catch (DataIntegrityViolationException e) { + log.error("DeviceToken 등록 중 중복 에러가 발생했습니다. deviceToken: {}", deviceToken); + + throw new DeviceTokenErrorException(DeviceTokenErrorCode.DUPLICATED_DEVICE_TOKEN); + } + } + + /** + * @return 비활성화된 디바이스 토큰 정보를 포함합니다. + */ + @Transactional(readOnly = true) + public Optional readDeviceByUserIdAndToken(Long userId, String token) { + return deviceTokenRepository.findByUser_IdAndToken(userId, token); + } + + @Transactional(readOnly = true) + public Optional readDeviceByToken(String token) { + return deviceTokenRepository.findByToken(token); + } + + @Transactional(readOnly = true) + public List readByUserIdAndDeviceId(Long userId, String deviceId) { + return deviceTokenRepository.findAllByUser_IdAndDeviceId(userId, deviceId); + } + + /** + * @return 비활성화된 디바이스 토큰 정보를 포함합니다. + */ + @Transactional(readOnly = true) + public List readAllByUserId(Long userId) { + return deviceTokenRepository.findAllByUser_Id(userId); + } + + @Transactional + public void deleteDevicesByUserIdInQuery(Long userId) { + deviceTokenRepository.deleteAllByUserIdInQuery(userId); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/domain/ChatMember.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/domain/ChatMember.java new file mode 100644 index 000000000..57aaa317c --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/domain/ChatMember.java @@ -0,0 +1,149 @@ +package kr.co.pennyway.domain.domains.member.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.ChatMemberRoleConverter; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; +import org.springframework.lang.NonNull; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "chat_member") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert +public class ChatMember extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Convert(converter = ChatMemberRoleConverter.class) + private ChatMemberRole role; + + @ColumnDefault("false") + private boolean banned; + @ColumnDefault("true") + private boolean notifyEnabled; + + @ColumnDefault("NULL") + private LocalDateTime deletedAt; + + @Column(name = "user_id") + private Long userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id") + private ChatRoom chatRoom; + + @Builder + protected ChatMember(User user, ChatRoom chatRoom, ChatMemberRole role) { + validate(user, chatRoom, role); + + this.userId = user.getId(); + this.participate(chatRoom); + this.role = role; + this.notifyEnabled = true; + } + + public static ChatMember of(User user, ChatRoom chatRoom, ChatMemberRole role) { + return ChatMember.builder() + .user(user) + .chatRoom(chatRoom) + .role(role) + .build(); + } + + private void validate(User user, ChatRoom chatRoom, ChatMemberRole role) { + Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); + Objects.requireNonNull(chatRoom, "chatRoom은 null이 될 수 없습니다."); + Objects.requireNonNull(role, "role은 null이 될 수 없습니다."); + } + + private void participate(ChatRoom chatRoom) { + if (this.chatRoom != null) { + throw new IllegalStateException("ChatMember는 이미 ChatRoom에 속해있습니다."); + } + + chatRoom.getChatMembers().add(this); + this.chatRoom = chatRoom; + } + + public void delegate(@NonNull ChatMember chatMember) { + if (chatMember == null || this.equals(chatMember)) { + throw new IllegalArgumentException("chatMember가 null이거나 자기 자신일 수 없습니다."); + } + + if (!this.getChatRoom().equals(chatMember.getChatRoom())) { + throw new ChatMemberErrorException(ChatMemberErrorCode.NOT_SAME_CHAT_ROOM); + } + + if (this.role != ChatMemberRole.ADMIN) { + throw new ChatMemberErrorException(ChatMemberErrorCode.NOT_ADMIN); + } + + this.role = ChatMemberRole.MEMBER; + chatMember.role = ChatMemberRole.ADMIN; + } + + /** + * 사용자 데이터가 삭제되었는지 확인한다. + * + * @return 삭제된 데이터가 아니면 true, 삭제된 데이터이면 false + */ + public boolean isActive() { + return deletedAt == null; + } + + public boolean isAdmin() { + return role.equals(ChatMemberRole.ADMIN); + } + + /** + * 사용자 추방된 이력이 있는 지 확인한다. + * + * @return 추방된 이력이 있으면 true, 없으면 false + */ + public boolean isBannedMember() { + return deletedAt != null && banned; + } + + public void enableNotify() { + this.notifyEnabled = true; + } + + public void disableNotify() { + this.notifyEnabled = false; + } + + public void leave() { + this.deletedAt = LocalDateTime.now(); + } + + public void ban() { + this.banned = true; + this.deletedAt = LocalDateTime.now(); + } + + @Override + public String toString() { + return "ChatMember{" + + "id=" + id + + ", banned=" + banned + + ", notifyEnabled=" + notifyEnabled + + ", deletedAt=" + deletedAt + + ", role=" + role + + '}'; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/dto/ChatMemberResult.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/dto/ChatMemberResult.java new file mode 100644 index 000000000..dd9f9bf10 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/dto/ChatMemberResult.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.domains.member.dto; + +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; + +import java.time.LocalDateTime; + +public final class ChatMemberResult { + public record Detail( + Long id, + String name, + ChatMemberRole role, + boolean notifyEnabled, + Long userId, + LocalDateTime createdAt, + String profileImageUrl + ) { + } + + public record Summary( + Long id, + String name + ) { + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorCode.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorCode.java new file mode 100644 index 000000000..9aa4a012f --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorCode.java @@ -0,0 +1,37 @@ +package kr.co.pennyway.domain.domains.member.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ChatMemberErrorCode implements BaseErrorCode { + /* 403 FORBIDDEN */ + BANNED(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "차단된 회원입니다."), + NOT_ADMIN(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_RESOURCE_NOT_ALLOWED_FOR_USER_ROLE, "관리자가 아닙니다."), + + /* 404 NOT FOUND */ + NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "회원을 찾을 수 없습니다."), + + /* 409 Conflict */ + NOT_SAME_CHAT_ROOM(StatusCode.CONFLICT, ReasonCode.REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE, "가입한 채팅방 정보가 일치하지 않습니다."), + ADMIN_CANNOT_LEAVE(StatusCode.CONFLICT, ReasonCode.REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE, "채팅방에 사용자가 남아 있다면, 채팅방 방장은 채팅방을 탈퇴할 수 없습니다."), + ALREADY_JOINED(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 가입한 회원입니다."), + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorException.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorException.java new file mode 100644 index 000000000..60ba76f2b --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.domain.domains.member.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class ChatMemberErrorException extends GlobalErrorException { + private final ChatMemberErrorCode chatMemberErrorCode; + + public ChatMemberErrorException(ChatMemberErrorCode baseErrorCode) { + super(baseErrorCode); + this.chatMemberErrorCode = baseErrorCode; + } + + public CausedBy causedBy() { + return chatMemberErrorCode.causedBy(); + } + + public String getExplainError() { + return chatMemberErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/repository/ChatMemberRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/repository/ChatMemberRepository.java new file mode 100644 index 000000000..45107e7d4 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/repository/ChatMemberRepository.java @@ -0,0 +1,52 @@ +package kr.co.pennyway.domain.domains.member.repository; + +import kr.co.pennyway.domain.common.repository.ExtendedRepository; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface ChatMemberRepository extends ExtendedRepository, CustomChatMemberRepository { + @Transactional(readOnly = true) + Set findByChatRoom_IdAndUserId(Long chatRoomId, Long userId); + + @Transactional(readOnly = true) + Optional findByChatRoom_IdAndRole(Long chatRoomId, ChatMemberRole role); + + @Transactional(readOnly = true) + Set findByChatRoom_Id(Long chatRoomId); + + @Transactional(readOnly = true) + @Query("SELECT cm FROM ChatMember cm WHERE cm.chatRoom.id = :chatRoomId AND cm.id IN :chatMemberIds") + List findByChatRoom_IdAndIdIn(Long chatRoomId, Set chatMemberIds); + + @Transactional(readOnly = true) + @Query("SELECT cm FROM ChatMember cm WHERE cm.id = :chatMemberId") + Optional findByChatMember_Id(Long chatMemberId); + + @Transactional(readOnly = true) + @Query("SELECT cm FROM ChatMember cm WHERE cm.chatRoom.id = :chatRoomId AND cm.userId = :userId AND cm.deletedAt IS NULL") + Optional findActiveChatMember(Long chatRoomId, Long userId); + + @Transactional(readOnly = true) + @Query("SELECT COUNT(*) FROM ChatMember cm WHERE cm.chatRoom.id = :chatRoomId AND cm.deletedAt IS NULL") + long countByChatRoomIdAndActive(Long chatRoomId); + + @Transactional(readOnly = true) + @Query("SELECT cm.chatRoom.id FROM ChatMember cm WHERE cm.userId = :userId AND cm.deletedAt IS NULL") + Set findChatRoomIdsByUserId(Long userId); + + @Transactional(readOnly = true) + @Query("SELECT cm.userId FROM ChatMember cm WHERE cm.chatRoom.id = :chatRoomId AND cm.deletedAt IS NULL") + Set findUserIdsByChatRoomId(Long chatRoomId); + + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE ChatMember cm SET cm.deletedAt = NOW() WHERE cm.chatRoom.id = :chatRoomId") + void deleteAllByChatRoomId(Long chatRoomId); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepository.java new file mode 100644 index 000000000..b5785e066 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepository.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.domain.domains.member.repository; + +import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult; + +import java.util.Optional; + +public interface CustomChatMemberRepository { + /** + * 채팅방에 해당 유저가 존재하는지 확인한다. + * 이 때, 삭제된 사용자 데이터는 조회하지 않는다. + */ + boolean existsByChatRoomIdAndUserId(Long chatRoomId, Long userId); + + /** + * 해당 유저가 채팅방장으로 가입한 채팅방이 존재하는지 확인한다. + */ + boolean existsOwnershipChatRoomByUserId(Long userId); + + boolean existsByChatRoomIdAndUserIdAndId(Long chatRoomId, Long userId, Long chatMemberId); + + /** + * 채팅방의 관리자 정보를 조회한다. + */ + Optional findAdminByChatRoomId(Long chatRoomId); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepositoryImpl.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepositoryImpl.java new file mode 100644 index 000000000..24b5a9125 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepositoryImpl.java @@ -0,0 +1,78 @@ +package kr.co.pennyway.domain.domains.member.repository; + +import com.querydsl.core.types.ConstantImpl; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.domains.member.domain.QChatMember; +import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class CustomChatMemberRepositoryImpl implements CustomChatMemberRepository { + private final JPAQueryFactory queryFactory; + + private final QChatMember chatMember = QChatMember.chatMember; + private final QUser user = QUser.user; + + @Override + public boolean existsByChatRoomIdAndUserId(Long chatRoomId, Long userId) { + return queryFactory.select(ConstantImpl.create(1)) + .from(chatMember) + .where(chatMember.chatRoom.id.eq(chatRoomId) + .and(chatMember.userId.eq(userId)) + .and(chatMember.deletedAt.isNull())) + .fetchFirst() != null; + } + + @Override + public boolean existsOwnershipChatRoomByUserId(Long userId) { + return queryFactory.select(ConstantImpl.create(1)) + .from(chatMember) + .where(chatMember.userId.eq(userId) + .and(chatMember.role.eq(ChatMemberRole.ADMIN)) + .and(chatMember.deletedAt.isNull())) + .fetchFirst() != null; + } + + @Override + public boolean existsByChatRoomIdAndUserIdAndId(Long chatRoomId, Long userId, Long chatMemberId) { + return queryFactory.select(ConstantImpl.create(1)) + .from(chatMember) + .where(chatMember.chatRoom.id.eq(chatRoomId) + .and(chatMember.userId.eq(userId)) + .and(chatMember.id.eq(chatMemberId)) + .and(chatMember.deletedAt.isNull())) + .fetchFirst() != null; + } + + @Override + public Optional findAdminByChatRoomId(Long chatRoomId) { + ChatMemberResult.Detail result = + queryFactory.select( + Projections.constructor( + ChatMemberResult.Detail.class, + chatMember.id, + user.name, + chatMember.role, + chatMember.notifyEnabled, + user.id, + chatMember.createdAt, + user.profileImageUrl + ) + ) + .from(chatMember) + .innerJoin(user).on(chatMember.userId.eq(user.id)) + .where(chatMember.chatRoom.id.eq(chatRoomId) + .and(chatMember.role.eq(ChatMemberRole.ADMIN)) + .and(chatMember.deletedAt.isNull())) + .fetchFirst(); + + return Optional.ofNullable(result); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/service/ChatMemberRdbService.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/service/ChatMemberRdbService.java new file mode 100644 index 000000000..3bc3f8410 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/service/ChatMemberRdbService.java @@ -0,0 +1,184 @@ +package kr.co.pennyway.domain.domains.member.service; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Predicate; +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.domain.QChatMember; +import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatMemberRdbService { + private final ChatMemberRepository chatMemberRepository; + + private final QUser qUser = QUser.user; + private final QChatMember qChatMember = QChatMember.chatMember; + + @Transactional + public ChatMember create(ChatMember chatMember) { + return chatMemberRepository.save(chatMember); + } + + @Transactional + public ChatMember createAdmin(User user, ChatRoom chatRoom) { + ChatMember chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.ADMIN); + + return chatMemberRepository.save(chatMember); + } + + @Transactional + public ChatMember createMember(User user, ChatRoom chatRoom) { + Set chatMembers = chatMemberRepository.findByChatRoom_IdAndUserId(chatRoom.getId(), user.getId()); + + if (chatMembers.stream().anyMatch(ChatMember::isActive)) { + log.warn("사용자는 이미 채팅방에 가입되어 있습니다. chatRoomId: {}, userId: {}", chatRoom.getId(), user.getId()); + throw new ChatMemberErrorException(ChatMemberErrorCode.ALREADY_JOINED); + } + + if (chatMembers.stream().anyMatch(ChatMember::isBanned)) { + log.warn("사용자는 채팅방에서 추방된 이력이 존재합니다. chatRoomId: {}, userId: {}", chatRoom.getId(), user.getId()); + throw new ChatMemberErrorException(ChatMemberErrorCode.BANNED); + } + + ChatMember chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + + return chatMemberRepository.save(chatMember); + } + + @Transactional(readOnly = true) + public Optional readChatMemberByChatMemberId(Long chatMemberId) { + return chatMemberRepository.findByChatMember_Id(chatMemberId); + } + + @Transactional(readOnly = true) + public Optional readChatMember(Long userId, Long chatRoomId) { + return chatMemberRepository.findActiveChatMember(chatRoomId, userId).stream().findFirst(); + } + + @Transactional(readOnly = true) + public Optional readAdmin(Long chatRoomId) { + return chatMemberRepository.findAdminByChatRoomId(chatRoomId); + } + + @Transactional(readOnly = true) + public Set readChatMembersByChatRoomId(Long chatRoomId) { + return chatMemberRepository.findByChatRoom_Id(chatRoomId); + } + + @Transactional(readOnly = true) + public List readChatMembersByIdIn(Long chatRoomId, Set chatMemberIds) { + QueryHandler queryHandler = query -> query.innerJoin(qUser).on(qChatMember.userId.eq(qUser.id)); + + Predicate predicate = qChatMember.chatRoom.id.eq(chatRoomId) + .and(qChatMember.id.in(chatMemberIds)) + .and(qChatMember.deletedAt.isNull()); + + Map> bindings = new LinkedHashMap<>(); + bindings.put("id", qChatMember.id); + bindings.put("name", qUser.name); + bindings.put("role", qChatMember.role); + bindings.put("notification", qChatMember.notifyEnabled); + bindings.put("userId", qUser.id); + bindings.put("createdAt", qChatMember.createdAt); + bindings.put("profileImageUrl", qUser.profileImageUrl); + + return chatMemberRepository.selectList(predicate, ChatMemberResult.Detail.class, bindings, queryHandler, null); + } + + @Transactional(readOnly = true) + public List readChatMembersByUserIdIn(Long chatRoomId, Set userIds) { + QueryHandler queryHandler = query -> query.innerJoin(qUser).on(qChatMember.userId.eq(qUser.id)); + + Predicate predicate = qChatMember.chatRoom.id.eq(chatRoomId) + .and(qUser.id.in(userIds)) + .and(qChatMember.deletedAt.isNull()); + + Map> bindings = new LinkedHashMap<>(); + bindings.put("id", qUser.id); + bindings.put("name", qUser.name); + bindings.put("role", qChatMember.role); + bindings.put("notification", qChatMember.notifyEnabled); + bindings.put("userId", qUser.id); + bindings.put("createdAt", qChatMember.createdAt); + bindings.put("profileImageUrl", qUser.profileImageUrl); + + return chatMemberRepository.selectList(predicate, ChatMemberResult.Detail.class, bindings, queryHandler, null); + } + + @Transactional(readOnly = true) + public List readChatMemberIdsByUserIdNotIn(Long chatRoomId, Set userIds) { + QueryHandler queryHandler = query -> query.innerJoin(qUser).on(qChatMember.userId.eq(qUser.id)); + + Predicate predicate = qChatMember.chatRoom.id.eq(chatRoomId) + .and(qUser.id.notIn(userIds)) + .and(qChatMember.deletedAt.isNull()); + + Map> bindings = new LinkedHashMap<>(); + bindings.put("id", qChatMember.id); + bindings.put("name", qUser.name); + + return chatMemberRepository.selectList(predicate, ChatMemberResult.Summary.class, bindings, queryHandler, null); + } + + @Transactional(readOnly = true) + public Set readChatRoomIdsByUserId(Long userId) { + return chatMemberRepository.findChatRoomIdsByUserId(userId); + } + + @Transactional(readOnly = true) + public Set readUserIdsByChatRoomId(Long chatRoomId) { + return chatMemberRepository.findUserIdsByChatRoomId(chatRoomId); + } + + /** + * 채팅방에 해당 유저가 존재하는지 확인한다. + * 이 때, 삭제된 사용자 데이터는 조회하지 않는다. + */ + @Transactional(readOnly = true) + public boolean isExists(Long chatRoomId, Long userId) { + return chatMemberRepository.existsByChatRoomIdAndUserId(chatRoomId, userId); + } + + /** + * 삭제된 사용자 데이터는 조회하지 않는다. + */ + @Transactional(readOnly = true) + public boolean isExists(Long chatRoomId, Long userId, Long chatMemberId) { + return chatMemberRepository.existsByChatRoomIdAndUserIdAndId(chatRoomId, userId, chatMemberId); + } + + @Transactional(readOnly = true) + public boolean hasUserChatRoomOwnership(Long userId) { + return chatMemberRepository.existsOwnershipChatRoomByUserId(userId); + } + + @Transactional(readOnly = true) + public long countActiveMembers(Long chatRoomId) { + return chatMemberRepository.countByChatRoomIdAndActive(chatRoomId); + } + + @Transactional + public void update(ChatMember chatMember) { + chatMemberRepository.save(chatMember); + } + + @Transactional + public void deleteAllByChatRoomId(Long chatRoomId) { + chatMemberRepository.deleteAllByChatRoomId(chatRoomId); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/type/ChatMemberRole.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/type/ChatMemberRole.java new file mode 100644 index 000000000..e5dbcd565 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/member/type/ChatMemberRole.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.domain.domains.member.type; + +import com.fasterxml.jackson.annotation.JsonValue; +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ChatMemberRole implements LegacyCommonType { + ADMIN("0", "ADMIN"), + MEMBER("1", "MEMBER");; + + private final String code; + private final String type; + + @Override + public String getCode() { + return code; + } + + @JsonValue + public String getType() { + return type; + } + + @Override + public String toString() { + return type; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java new file mode 100644 index 000000000..49034994b --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java @@ -0,0 +1,138 @@ +package kr.co.pennyway.domain.domains.notification.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.AnnouncementConverter; +import kr.co.pennyway.domain.common.converter.NoticeTypeConverter; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "notification") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private LocalDateTime readAt; + @Convert(converter = NoticeTypeConverter.class) + private NoticeType type; + @Convert(converter = AnnouncementConverter.class) + private Announcement announcement; // 공지 종류 + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender") + private User sender; + private String senderName; + + private Long toId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver") + private User receiver; + private String receiverName; + + private Notification(NoticeType type, Announcement announcement, User sender, String senderName, Long toId, User receiver, String receiverName) { + this.type = Objects.requireNonNull(type); + this.announcement = Objects.requireNonNull(announcement); + this.sender = (!type.equals(NoticeType.ANNOUNCEMENT)) ? Objects.requireNonNull(sender) : sender; + this.senderName = (!type.equals(NoticeType.ANNOUNCEMENT)) ? Objects.requireNonNull(senderName) : senderName; + this.toId = toId; + this.receiver = Objects.requireNonNull(receiver); + this.receiverName = Objects.requireNonNull(receiverName); + } + + @Override + public String toString() { + return "Notification{" + + "id=" + id + + ", readAt=" + readAt + + ", type=" + type + + ", announcement=" + announcement + + ", senderName='" + senderName + '\'' + + ", toId=" + toId + + ", receiverName='" + receiverName + '\'' + + '}'; + } + + /** + * 공지 제목을 생성한다. + *
+ * 이 메서드는 내부적으로 알림 타입의 종류에 따라 공지 제목을 포맷팅한다. + * + * @apiNote 이 메서드는 {@link NoticeType#ANNOUNCEMENT} 타입에 대해서만 동작한다. 다른 타입의 알림을 포맷팅해야 하는 경우 해당 메서드를 확장해야 한다. + */ + public String createFormattedTitle() { + if (!type.equals(NoticeType.ANNOUNCEMENT)) { + return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함. + } + + return formatAnnouncementTitle(); + } + + private String formatAnnouncementTitle() { + if (announcement.equals(Announcement.MONTHLY_TARGET_AMOUNT)) { + return announcement.createFormattedTitle(String.valueOf(getCreatedAt().getMonthValue())); + } + + return announcement.createFormattedTitle(receiverName); + } + + /** + * 공지 내용을 생성한다. + *
+ * 이 메서드는 내부적으로 알림 타입의 종류에 따라 공지 내용을 포맷팅한다. + * + * @apiNote 이 메서드는 {@link NoticeType#ANNOUNCEMENT} 타입에 대해서만 동작한다. 다른 타입의 알림을 포맷팅해야 하는 경우 해당 메서드를 확장해야 한다. + */ + public String createFormattedContent() { + if (!type.equals(NoticeType.ANNOUNCEMENT)) { + return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함. + } + + return announcement.createFormattedContent(receiverName); + } + + public static class Builder { + private final NoticeType type; + private final Announcement announcement; + private final User receiver; + private final String receiverName; + + private User sender; + private String senderName; + + private Long toId; + + public Builder(NoticeType type, Announcement announcement, User receiver) { + this.type = type; + this.announcement = announcement; + this.receiver = receiver; + this.receiverName = receiver.getName(); + } + + public Builder sender(User sender) { + this.sender = sender; + this.senderName = sender.getName(); + return this; + } + + public Builder toId(Long toId) { + this.toId = toId; + return this; + } + + public Notification build() { + return new Notification(type, announcement, sender, senderName, toId, receiver, receiverName); + } + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java new file mode 100644 index 000000000..e85b146b2 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.domains.notification.type.Announcement; + +import java.util.List; + +public interface NotificationCustomRepository { + boolean existsUnreadNotification(Long userId); + + /** + * 사용자들에게 정기 지출 등록 알림을 저장한다. (발송이 아님) + * 만약 이미 전송하려는 데이터가 년-월-일에 해당하는 생성일을 가지고 있고, 그 알림의 announcement 타입까지 같다면 저장하지 않는다. + * + *

+     * {@code
+     * INSERT INTO notification(type, announcement, created_at, updated_at, receiver, receiver_name)
+     * SELECT ?, ?, NOW(), NOW(), u.id, u.name
+     * FROM user u
+     * WHERE u.id IN (?)
+     * AND NOT EXISTS (
+     * 	SELECT n.receiver
+     * 	FROM notification n
+     * 	WHERE n.receiver = u.id
+     *     AND n.created_at >= CURDATE()
+     *     AND n.created_at < CURDATE() + INTERVAL 1 DAY
+     * 	AND n.type = '0'
+     * 	AND n.announcement = 1
+     * );
+     * }
+     * 
+ * + * @param userIds : 등록할 사용자 아이디 목록 + * @param announcement : 공지 타입 {@link Announcement} + */ + void saveDailySpendingAnnounceInBulk(List userIds, Announcement announcement); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java new file mode 100644 index 000000000..7e20aa278 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java @@ -0,0 +1,93 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.domains.notification.domain.QNotification; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class NotificationCustomRepositoryImpl implements NotificationCustomRepository { + private final JPAQueryFactory queryFactory; + private final JdbcTemplate jdbcTemplate; + + private final QNotification notification = QNotification.notification; + + private final int BATCH_SIZE = 1000; + + @Override + public boolean existsUnreadNotification(Long userId) { + return queryFactory + .select(notification.id) + .from(notification) + .where(notification.receiver.id.eq(userId) + .and(notification.readAt.isNull())) + .fetchFirst() != null; + } + + @Override + public void saveDailySpendingAnnounceInBulk(List userIds, Announcement announcement) { + int batchCount = 0; + List subItems = new ArrayList<>(); + + for (int i = 0; i < userIds.size(); ++i) { + subItems.add(userIds.get(i)); + + if ((i + 1) % BATCH_SIZE == 0) { + batchCount = batchInsert(batchCount, subItems, NoticeType.ANNOUNCEMENT, announcement); + } + } + + if (!subItems.isEmpty()) { + batchInsert(batchCount, subItems, NoticeType.ANNOUNCEMENT, announcement); + } + + log.info("Notification saved. announcement: {}, count: {}", announcement, userIds.size()); + } + + private int batchInsert(int batchCount, List userIds, NoticeType noticeType, Announcement announcement) { + String sql = "INSERT INTO notification(id, read_at, type, announcement, created_at, updated_at, receiver, receiver_name) " + + "SELECT NULL, NULL, ?, ?, NOW(), NOW(), u.id, u.name " + + "FROM user u " + + "WHERE u.id IN (?) " + + "AND NOT EXISTS ( " + + " SELECT n.receiver " + + " FROM notification n " + + " WHERE n.receiver = u.id " + + " AND n.created_at >= CURDATE() " + + " AND n.created_at < CURDATE() + INTERVAL 1 DAY " + + " AND n.type = ? " + + " AND n.announcement = ? " + + ")"; + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ps.setString(1, noticeType.getCode()); + ps.setString(2, announcement.getCode()); + ps.setLong(3, userIds.get(i)); + ps.setString(4, noticeType.getCode()); + ps.setString(5, announcement.getCode()); + } + + @Override + public int getBatchSize() { + return userIds.size(); + } + }); + + userIds.clear(); + return ++batchCount; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java new file mode 100644 index 000000000..ad7217f11 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.common.repository.ExtendedRepository; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +public interface NotificationRepository extends ExtendedRepository, NotificationCustomRepository { + @Modifying(clearAutomatically = true) + @Transactional + @Query("update Notification n set n.readAt = current_timestamp where n.id in ?1") + void updateReadAtByIdsInBulk(List notificationIds); + + @Transactional(readOnly = true) + @Query("select count(n) from Notification n where n.receiver.id = ?1 and n.id in ?2 and n.readAt is null") + long countUnreadNotificationsByIds(Long userId, List notificationIds); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationRdbService.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationRdbService.java new file mode 100644 index 000000000..26e675594 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationRdbService.java @@ -0,0 +1,66 @@ +package kr.co.pennyway.domain.domains.notification.service; + +import com.querydsl.core.types.Predicate; +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.common.util.SliceUtil; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.domain.QNotification; +import kr.co.pennyway.domain.domains.notification.repository.NotificationRepository; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class NotificationRdbService { + private final NotificationRepository notificationRepository; + + private final QNotification notification = QNotification.notification; + + @Transactional(readOnly = true) + public Slice readNotificationsSlice(Long userId, Pageable pageable, NoticeType noticeType) { + Predicate predicate = notification.receiver.id.eq(userId) + .and(notification.readAt.isNotNull()) + .and(notification.type.eq(noticeType)); + + QueryHandler queryHandler = query -> query + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1); + + Sort sort = pageable.getSort(); + + return SliceUtil.toSlice(notificationRepository.findList(predicate, queryHandler, sort), pageable); + } + + @Transactional(readOnly = true) + public List readUnreadNotifications(Long userId, NoticeType noticeType) { + Predicate predicate = notification.receiver.id.eq(userId) + .and(notification.readAt.isNull()) + .and(notification.type.eq(noticeType)); + + return notificationRepository.findList(predicate, null, null); + } + + @Transactional(readOnly = true) + public boolean isExistsUnreadNotification(Long userId) { + return notificationRepository.existsUnreadNotification(userId); + } + + @Transactional(readOnly = true) + public long countUnreadNotifications(Long userId, List notificationIds) { + return notificationRepository.countUnreadNotificationsByIds(userId, notificationIds); + } + + @Transactional + public void updateReadAtByIdsInBulk(List notificationIds) { + notificationRepository.updateReadAtByIdsInBulk(notificationIds); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java new file mode 100644 index 000000000..30fd87df9 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java @@ -0,0 +1,70 @@ +package kr.co.pennyway.domain.domains.notification.type; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.Getter; +import org.springframework.util.StringUtils; + +@Getter +public enum Announcement implements LegacyCommonType { + NOT_ANNOUNCE("0", "", ""), + + // 정기 지출 알림 + DAILY_SPENDING("1", "%s님, 3분 카레보다 빨리 끝나요!", "많은 친구들이 소비 기록에 참여하고 있어요👀"), + MONTHLY_TARGET_AMOUNT("2", "%s월의 첫 시작! 두구두구..🥁", "%s님의 이번 달 목표 소비 금액은?"); + + private final String code; + private final String title; + private final String content; + + Announcement(String code, String title, String content) { + this.code = code; + this.title = title; + this.content = content; + } + + /** + * 수신자의 이름을 받아서 공지 제목을 생성한다. + *
+ * 만약 해당 타입의 제목에서 % 문자가 없다면 그대로 반환한다. + * + * @param name 수신자의 이름 + * @return 포맷팅된 공지 제목 + */ + public String createFormattedTitle(String name) { + validateName(name); + + if (this.title.indexOf("%") == -1) { + return this.title; + } + + return String.format(title, name); + } + + /** + * 수신자의 이름을 받아서 공지 내용을 생성한다. + *
+ * 만약 해당 타입의 내용에서 % 문자가 없다면 그대로 반환한다. + * + * @param name 수신자의 이름 + * @return 포맷팅된 공지 내용 + */ + public String createFormattedContent(String name) { + validateName(name); + + if (this.content.indexOf("%") == -1) { + return this.content; + } + + return String.format(content, name); + } + + private void validateName(String name) { + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name must not be empty"); + } + + if (this == NOT_ANNOUNCE) { + throw new IllegalArgumentException("NOT_ANNOUNCE type is not allowed"); + } + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java new file mode 100644 index 000000000..05bc2646b --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java @@ -0,0 +1,32 @@ +package kr.co.pennyway.domain.domains.notification.type; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.Getter; + +/** + * 알림 종류를 정의하기 위한 타입 + * + *

+ * 알림 타입은 [도메인]_[액션]_[FROM]_[TO?] 형태로 정의한다. + * 각 알림 타입에 대한 이름, 제목, 내용 형식을 지정하며, 알림 타입에 따라 내용을 생성하는 기능을 제공한다. + *

+ * + * @author YANG JAESEO + * @since 2024-07-04 + */ +@Getter +public enum NoticeType implements LegacyCommonType { + ANNOUNCEMENT("0", "%s", "%s"); // 공지 사항은 별도 제목을 설정하여 사용한다. + + private final String code; + private final String title; + private final String contentFormat; + private final String navigablePlaceholders = "{%s_%d}"; + private final String plainTextPlaceholders = "%s"; + + NoticeType(String code, String title, String contentFormat) { + this.code = code; + this.title = title; + this.contentFormat = contentFormat; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java new file mode 100644 index 000000000..0a566a20d --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java @@ -0,0 +1,81 @@ +package kr.co.pennyway.domain.domains.oauth.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.ProviderConverter; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.SQLDelete; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "oauth") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +@DynamicInsert +@SQLDelete(sql = "UPDATE oauth SET deleted_at = NOW() WHERE id = ?") +public class Oauth { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Convert(converter = ProviderConverter.class) + private Provider provider; + private String oauthId; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @ColumnDefault("NULL") + private LocalDateTime deletedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Builder(access = AccessLevel.PRIVATE) + private Oauth(Provider provider, String oauthId, User user) { + if (!StringUtils.hasText(oauthId)) { + throw new IllegalArgumentException("oauthId는 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.provider = Objects.requireNonNull(provider, "provider는 null이 될 수 없습니다."); + this.oauthId = oauthId; + this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); + } + + public static Oauth of(Provider provider, String oauthId, User user) { + return Oauth.builder() + .provider(provider) + .oauthId(oauthId) + .user(user) + .build(); + } + + public boolean isDeleted() { + return deletedAt != null; + } + + @Override + public String toString() { + return "Oauth{" + + "id=" + id + + ", provider=" + provider + + ", oauthId='" + oauthId + '\'' + + ", createdAt=" + createdAt + + ", deletedAt=" + deletedAt + + '}'; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java new file mode 100644 index 000000000..2c2657149 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java @@ -0,0 +1,43 @@ +package kr.co.pennyway.domain.domains.oauth.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum OauthErrorCode implements BaseErrorCode { + /* 400 Bad Request */ + INVALID_OAUTH_SYNC_REQUEST(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "Oauth 동기화 요청이 잘못되었습니다."), + + /* 401 Unauthorized */ + NOT_MATCHED_OAUTH_ID(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "OAuth ID가 일치하지 않습니다."), + + /* 404 Not Found */ + NOT_FOUND_OAUTH(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "해당 제공자로 가입된 이력을 찾을 수 없습니다."), + + /* 409 Conflict */ + CANNOT_UNLINK_OAUTH(StatusCode.CONFLICT, ReasonCode.REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE, "해당 제공자로만 가입된 사용자는 연동을 해제할 수 없습니다."), + ALREADY_USED_OAUTH(StatusCode.CONFLICT, ReasonCode.REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE, "이미 다른 계정에서 사용 중인 계정입니다."), + ALREADY_SIGNUP_OAUTH(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 해당 제공자로 가입된 사용자입니다."), + + /* 422 Unprocessable Entity */ + INVALID_PROVIDER(StatusCode.UNPROCESSABLE_CONTENT, ReasonCode.TYPE_MISMATCH_ERROR_IN_REQUEST_BODY, "유효하지 않은 제공자입니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java new file mode 100644 index 000000000..2276a4478 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.oauth.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class OauthException extends GlobalErrorException { + private final OauthErrorCode errorCode; + + public OauthException(OauthErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public String getExplainError() { + return errorCode.getExplainError(); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java new file mode 100644 index 000000000..5012fbeaa --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.domain.domains.oauth.repository; + +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +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; + +import java.util.Optional; +import java.util.Set; + +public interface OauthRepository extends JpaRepository { + Optional findByOauthIdAndProviderAndDeletedAtIsNull(String oauthId, Provider provider); + + Optional findByUser_IdAndProviderAndDeletedAtIsNull(Long userId, Provider provider); + + Set findAllByUser_Id(Long userId); + + boolean existsByUser_IdAndProviderAndDeletedAtIsNull(Long userId, Provider provider); + + boolean existsByOauthIdAndProviderAndDeletedAtIsNull(String oauthId, Provider provider); + + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE Oauth o SET o.deletedAt = NOW() WHERE o.user.id = :userId AND o.deletedAt IS NULL") + void deleteAllByUser_IdAndDeletedAtNullInQuery(Long userId); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthRdbService.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthRdbService.java new file mode 100644 index 000000000..7086a8833 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthRdbService.java @@ -0,0 +1,66 @@ +package kr.co.pennyway.domain.domains.oauth.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.repository.OauthRepository; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.Set; + +@DomainService +@RequiredArgsConstructor +public class OauthRdbService { + private final OauthRepository oauthRepository; + + @Transactional + public Oauth createOauth(Oauth oauth) { + return oauthRepository.save(oauth); + } + + @Transactional + public Optional readOauth(Long id) { + return oauthRepository.findById(id); + } + + /** + * oauthId와 provider로 Oauth를 조회한다. 이 때, deletedAt이 null인 Oauth만 조회한다. + */ + @Transactional(readOnly = true) + public Optional readOauthByOauthIdAndProvider(String oauthId, Provider provider) { + return oauthRepository.findByOauthIdAndProviderAndDeletedAtIsNull(oauthId, provider); + } + + @Transactional(readOnly = true) + public Set readOauthsByUserId(Long userId) { + return oauthRepository.findAllByUser_Id(userId); + } + + /** + * userId와 provider로 Oauth가 존재하는지 확인한다. 이 때, deletedAt이 null인 Oauth만 조회한다. + */ + @Transactional(readOnly = true) + public boolean isExistOauthByUserIdAndProvider(Long userId, Provider provider) { + return oauthRepository.existsByUser_IdAndProviderAndDeletedAtIsNull(userId, provider); + } + + /** + * oauthId와 provider로 Oauth가 존재하는지 확인한다. 이 때, deletedAt이 null인 Oauth만 조회한다. + */ + @Transactional(readOnly = true) + public boolean isExistOauthByOauthIdAndProvider(String oauthId, Provider provider) { + return oauthRepository.existsByOauthIdAndProviderAndDeletedAtIsNull(oauthId, provider); + } + + @Transactional + public void deleteOauth(Oauth oauth) { + oauthRepository.delete(oauth); + } + + @Transactional + public void deleteOauthsByUserIdInQuery(Long userId) { + oauthRepository.deleteAllByUser_IdAndDeletedAtNullInQuery(userId); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java new file mode 100644 index 000000000..e405919ae --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.domain.domains.oauth.type; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum Provider implements LegacyCommonType { + KAKAO("1", "카카오"), + GOOGLE("2", "구글"), + APPLE("3", "애플"); + + private final String code; + private final String type; + + @JsonCreator + public Provider fromString(String type) { + return valueOf(type.toUpperCase()); + } + + @Override + public String getCode() { + return code; + } + + @JsonValue + public String getType() { + return type; + } + + @Override + public String toString() { + return name().toLowerCase(); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java new file mode 100644 index 000000000..6f369708d --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.domain.domains.question.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.QuestionCategoryConverter; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "question") +@SQLDelete(sql = "UPDATE question SET deleted_at = NOW() WHERE id = ?") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class Question { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) + private String email; + @Convert(converter = QuestionCategoryConverter.class) + @Column(nullable = false) + private QuestionCategory category; + private String content; + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + private LocalDateTime deletedAt; + + @Builder + private Question(String email, QuestionCategory category, String content) { + if (!StringUtils.hasText(email)) { + throw new IllegalArgumentException("email은 null이거나 빈 문자열이 될 수 없습니다."); + } else if (!StringUtils.hasText(content)) { + throw new IllegalArgumentException("content는 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.email = email; + this.category = Objects.requireNonNull(category, "category는 null이 될 수 없습니다."); + this.content = content; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java new file mode 100644 index 000000000..6fabf68b1 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.domains.question.domain; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public enum QuestionCategory implements LegacyCommonType { + UTILIZATION("1", "이용 관련"), + BUG_REPORT("2", "오류 신고"), + SUGGESTION("3", "서비스 제안"), + ETC("4", "기타"); + + private final String code; + private final String title; +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java new file mode 100644 index 000000000..6b20844a1 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.domain.domains.question.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum QuestionErrorCode implements BaseErrorCode { + INTERNAL_MAIL_ERROR(StatusCode.INTERNAL_SERVER_ERROR, ReasonCode.UNEXPECTED_ERROR, "메일 발송에 실패했습니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java new file mode 100644 index 000000000..3eb89c218 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.domains.question.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import lombok.Getter; + +public class QuestionErrorException extends GlobalErrorException { + private final QuestionErrorCode questionErrorCode; + + public QuestionErrorException(QuestionErrorCode questionErrorCode) { + super(questionErrorCode); + this.questionErrorCode = questionErrorCode; + } + + public CausedBy causedBy() { + return questionErrorCode.causedBy(); + } + + public String getExplainError() { + return questionErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java new file mode 100644 index 000000000..d50f22cdd --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.domains.question.repository; + +import kr.co.pennyway.domain.domains.question.domain.Question; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface QuestionRepository extends JpaRepository { +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionRdbService.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionRdbService.java new file mode 100644 index 000000000..53e6096f0 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionRdbService.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.domains.question.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.question.domain.Question; +import kr.co.pennyway.domain.domains.question.repository.QuestionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +@DomainService +@RequiredArgsConstructor +public class QuestionRdbService { + private final QuestionRepository questionRepository; + + @Transactional + public void createQuestion(Question question) { + questionRepository.save(question); + } + +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java new file mode 100644 index 000000000..bdad51667 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java @@ -0,0 +1,108 @@ +package kr.co.pennyway.domain.domains.spending.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.SpendingCategoryConverter; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "spending") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLRestriction("deleted_at IS NULL") +@SQLDelete(sql = "UPDATE spending SET deleted_at = NOW() WHERE id = ?") +public class Spending extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Integer amount; + @Convert(converter = SpendingCategoryConverter.class) + private SpendingCategory category; + private LocalDateTime spendAt; + private String accountName; + private String memo; + private LocalDateTime deletedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + /* category가 OTHER일 경우 spendingCustomCategory를 참조 */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "spending_custom_category_id") + private SpendingCustomCategory spendingCustomCategory; + + @Builder + private Spending(Integer amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, User user, SpendingCustomCategory spendingCustomCategory) { + if (spendingCustomCategory == null && (category.equals(SpendingCategory.CUSTOM) || category.equals(SpendingCategory.OTHER))) { + throw new IllegalArgumentException("서비스 제공 아이콘을 등록할 때는 CUSTOM, OHTER 아이콘을 사용할 수 없습니다."); + } else if (spendingCustomCategory != null && !category.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("사용자 정의 아이콘을 등록할 때는 CUSTOM 아이콘이어야 합니다."); + } + + this.amount = Objects.requireNonNull(amount, "amount는 null이 될 수 없습니다."); + this.category = Objects.requireNonNull(category, "category는 null이 될 수 없습니다."); + this.spendAt = Objects.requireNonNull(spendAt, "spendAt는 null이 될 수 없습니다."); + this.accountName = accountName; + this.memo = memo; + this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); + this.spendingCustomCategory = spendingCustomCategory; + } + + public int getDay() { + return spendAt.getDayOfMonth(); + } + + /** + * 지출 내역의 소비 카테고리를 조회하는 메서드
+ * SpendingCategory가 OTHER일 경우 SpendingCustomCategory를 정보를 조회하여 반환한다. + * + * @return {@link CategoryInfo} + */ + public CategoryInfo getCategory() { + if (this.category.equals(SpendingCategory.CUSTOM)) { + SpendingCustomCategory category = getSpendingCustomCategory(); + return CategoryInfo.of(category.getId(), category.getName(), category.getIcon()); + } + + return CategoryInfo.of(-1L, this.category.getType(), this.category); + } + + public void update(Integer amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, SpendingCustomCategory spendingCustomCategory) { + if (spendingCustomCategory == null && (category.equals(SpendingCategory.CUSTOM) || category.equals(SpendingCategory.OTHER))) { + throw new IllegalArgumentException("서비스 제공 아이콘을 등록할 때는 CUSTOM, OHTER 아이콘을 사용할 수 없습니다."); + } else if (spendingCustomCategory != null && !category.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("사용자 정의 아이콘을 등록할 때는 CUSTOM 아이콘이어야 합니다."); + } + + this.amount = Objects.requireNonNull(amount, "amount는 null이 될 수 없습니다."); + this.category = Objects.requireNonNull(category, "category는 null이 될 수 없습니다."); + this.spendAt = Objects.requireNonNull(spendAt, "spendAt는 null이 될 수 없습니다."); + this.accountName = accountName; + this.memo = memo; + this.spendingCustomCategory = spendingCustomCategory; + } + + @Override + public String toString() { + return "Spending{" + + "id=" + id + + ", amount=" + amount + + ", category=" + category + + ", spendAt=" + spendAt + + ", accountName='" + accountName + '\'' + + ", memo='" + memo + "'}"; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java new file mode 100644 index 000000000..083f6a3c3 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java @@ -0,0 +1,67 @@ +package kr.co.pennyway.domain.domains.spending.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.SpendingCategoryConverter; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "spending_custom_category") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLRestriction("deleted_at IS NULL") +@SQLDelete(sql = "UPDATE spending_custom_category SET deleted_at = NOW() WHERE id = ?") +public class SpendingCustomCategory extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + @Convert(converter = SpendingCategoryConverter.class) + private SpendingCategory icon; + private LocalDateTime deletedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private SpendingCustomCategory(String name, SpendingCategory icon, User user) { + if (icon.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."); + } + + this.name = name; + this.icon = icon; + this.user = user; + } + + public static SpendingCustomCategory of(String name, SpendingCategory icon, User user) { + return new SpendingCustomCategory(name, icon, user); + } + + public void update(String name, SpendingCategory icon) { + if (icon.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."); + } + + this.name = name; + this.icon = icon; + } + + @Override + public String toString() { + return "SpendingCustomCategory{" + + "id=" + id + + ", name='" + name + '\'' + + ", icon=" + icon + + ", deletedAt=" + deletedAt + '}'; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java new file mode 100644 index 000000000..9934bb592 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.domain.domains.spending.dto; + +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import org.springframework.util.StringUtils; + +import java.util.Objects; + +/** + * 지출 카테고리 정보를 담은 DTO + * + * @param isCustom boolean : 사용자 정의 카테고리 여부 + * @param id Long : 카테고리 ID. 사용자 정의 카테고리가 아니라면 -1, 사용자 정의 카테고리라면 0 이상의 값을 갖는다. + * @param name String : 카테고리 이름 + * @param icon String : 카테고리 아이콘 + */ +public record CategoryInfo( + boolean isCustom, + Long id, + String name, + SpendingCategory icon +) { + public CategoryInfo { + Objects.requireNonNull(id, "id는 null일 수 없습니다."); + Objects.requireNonNull(icon, "icon은 null일 수 없습니다."); + + if (isCustom && id < 0 || !isCustom && id != -1) { + throw new IllegalArgumentException("isCustom이 " + isCustom + "일 때 id는 " + (isCustom ? "0 이상" : "-1") + "이어야 합니다."); + } + + if (isCustom && icon.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("사용자 정의 카테고리는 OTHER가 될 수 없습니다."); + } + + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name은 null이거나 빈 문자열일 수 없습니다."); + } + } + + public static CategoryInfo of(Long id, String name, SpendingCategory icon) { + return new CategoryInfo(!id.equals(-1L), id, name, icon); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java new file mode 100644 index 000000000..a21c4504d --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.domain.domains.spending.dto; + +import java.time.YearMonth; + +/** + * 사용자의 해당 년/월 총 지출 금액을 담는 DTO + */ +public record TotalSpendingAmount( + int year, + int month, + long totalSpending +) { + public TotalSpendingAmount(int year, int month, long totalSpending) { + this.year = year; + this.month = month; + this.totalSpending = totalSpending; + } + + /** + * YearMonth 객체로 변환하는 메서드 + * + * @return 해당 년/월을 나타내는 YearMonth 객체 + */ + public YearMonth getYearMonth() { + return YearMonth.of(year, month); + } + +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java new file mode 100644 index 000000000..6cfb30c2c --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java @@ -0,0 +1,38 @@ +package kr.co.pennyway.domain.domains.spending.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SpendingErrorCode implements BaseErrorCode { + /* 400 Bad Request */ + INVALID_ICON(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "CUSTOM 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."), + INVALID_ICON_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "icon의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), + INVALID_TYPE_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "type의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), + INVALID_CATEGORY_TYPE(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "존재하지 않는 카테고리 타입입니다."), + INVALID_SHARE_TYPE(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "부적절한 공유 타입입니다."), + MISSING_SHARE_PARAM(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "지출 내역 공유 시 필수 파라미터가 누락되었습니다."), + + /* 404 Not Found */ + NOT_FOUND_SPENDING(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 지출 내역입니다."), + NOT_FOUND_CUSTOM_CATEGORY(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 커스텀 카테고리입니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java new file mode 100644 index 000000000..74eb92be4 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.spending.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class SpendingErrorException extends GlobalErrorException { + private final SpendingErrorCode errorCode; + + public SpendingErrorException(SpendingErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public String getExplainError() { + return errorCode.getExplainError(); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java new file mode 100644 index 000000000..7632740da --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.spending.repository; + +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +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; + +import java.util.List; + +public interface SpendingCustomCategoryRepository extends JpaRepository { + @Transactional(readOnly = true) + boolean existsByIdAndUser_Id(Long id, Long userId); + + @Transactional(readOnly = true) + List findAllByUser_Id(Long userId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE SpendingCustomCategory s SET s.deletedAt = NOW() WHERE s.user.id = :userId") + void deleteAllByUserIdInQuery(Long userId); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java new file mode 100644 index 000000000..c0c5d7619 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.domain.domains.spending.repository; + +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; + +import java.util.List; +import java.util.Optional; + +public interface SpendingCustomRepository { + Optional findTotalSpendingAmountByUserId(Long userId, int year, int month); + + List findByYearAndMonth(Long userId, int year, int month); + + List findByYearAndMonthAndDay(Long userId, int year, int month, int day); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java new file mode 100644 index 000000000..89b05dfbc --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java @@ -0,0 +1,78 @@ +package kr.co.pennyway.domain.domains.spending.repository; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.common.util.QueryDslUtil; +import kr.co.pennyway.domain.domains.spending.domain.QSpending; +import kr.co.pennyway.domain.domains.spending.domain.QSpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class SpendingCustomRepositoryImpl implements SpendingCustomRepository { + private final JPAQueryFactory queryFactory; + + private final QUser user = QUser.user; + private final QSpending spending = QSpending.spending; + private final QSpendingCustomCategory spendingCustomCategory = QSpendingCustomCategory.spendingCustomCategory; + + @Override + public Optional findTotalSpendingAmountByUserId(Long userId, int year, int month) { + TotalSpendingAmount result = queryFactory.select( + Projections.constructor( + TotalSpendingAmount.class, + spending.spendAt.year().intValue(), + spending.spendAt.month().intValue(), + spending.amount.sum().longValue() + ) + ).from(user) + .leftJoin(spending).on(user.id.eq(spending.user.id)) + .where(user.id.eq(userId) + .and(spending.spendAt.year().eq(year)) + .and(spending.spendAt.month().eq(month))) + .groupBy(spending.spendAt.year(), spending.spendAt.month()) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public List findByYearAndMonth(Long userId, int year, int month) { + Sort sort = Sort.by(Sort.Order.desc("spendAt")); + List> orderSpecifiers = QueryDslUtil.getOrderSpecifier(sort); + + return queryFactory.selectFrom(spending) + .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin() + .where(spending.spendAt.year().eq(year) + .and(spending.spendAt.month().eq(month)) + .and(spending.user.id.eq(userId)) + ) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .fetch(); + } + + @Override + public List findByYearAndMonthAndDay(Long userId, int year, int month, int day) { + Sort sort = Sort.by(Sort.Order.desc("spendAt")); + + return queryFactory.selectFrom(spending) + .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin() + .where(spending.spendAt.year().eq(year) + .and(spending.spendAt.month().eq(month)) + .and(spending.spendAt.dayOfMonth().eq(day)) + .and(spending.user.id.eq(userId)) + ) + .fetch(); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java new file mode 100644 index 000000000..f9e65322b --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java @@ -0,0 +1,59 @@ +package kr.co.pennyway.domain.domains.spending.repository; + +import kr.co.pennyway.domain.common.repository.ExtendedRepository; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +public interface SpendingRepository extends ExtendedRepository, SpendingCustomRepository { + @Transactional(readOnly = true) + boolean existsByIdAndUser_Id(Long id, Long userId); + + @Transactional(readOnly = true) + int countByUser_IdAndSpendingCustomCategory_Id(Long userId, Long categoryId); + + @Transactional(readOnly = true) + int countByUser_IdAndCategory(Long userId, SpendingCategory spendingCategory); + + @Transactional(readOnly = true) + long countByUserIdAndIdIn(Long userId, List spendingIds); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId, s.category = :custom WHERE s.category = :fromCategory") + void updateCategoryByCustomCategoryInQuery(SpendingCategory fromCategory, Long toCategoryId, SpendingCategory custom); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.category = :toCategory WHERE s.category = :fromCategory") + void updateCategoryByCategoryInQuery(SpendingCategory fromCategory, SpendingCategory toCategory); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId WHERE s.spendingCustomCategory.id = :fromCategoryId") + void updateCustomCategoryByCustomCategoryInQuery(Long fromCategoryId, Long toCategoryId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.spendingCustomCategory = null, s.category = :toCategory WHERE s.spendingCustomCategory.id = :fromCategoryId") + void updateCustomCategoryByCategoryInQuery(Long fromCategoryId, SpendingCategory toCategory); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.deletedAt = NOW() where s.id IN :spendingIds") + void deleteAllByIdAndDeletedAtNullInQuery(List spendingIds); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.user.id = :userId") + void deleteAllByUserIdInQuery(Long userId); + + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.spendingCustomCategory.id = :categoryId") + void deleteAllByCategoryIdAndDeletedAtNullInQuery(Long categoryId); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryRdbService.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryRdbService.java new file mode 100644 index 000000000..039045ac6 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryRdbService.java @@ -0,0 +1,48 @@ +package kr.co.pennyway.domain.domains.spending.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.repository.SpendingCustomCategoryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class SpendingCustomCategoryRdbService { + private final SpendingCustomCategoryRepository spendingCustomCategoryRepository; + + @Transactional + public SpendingCustomCategory createSpendingCustomCategory(SpendingCustomCategory spendingCustomCategory) { + return spendingCustomCategoryRepository.save(spendingCustomCategory); + } + + @Transactional(readOnly = true) + public Optional readSpendingCustomCategory(Long id) { + return spendingCustomCategoryRepository.findById(id); + } + + @Transactional(readOnly = true) + public List readSpendingCustomCategories(Long userId) { + return spendingCustomCategoryRepository.findAllByUser_Id(userId); + } + + @Transactional(readOnly = true) + public boolean isExistsSpendingCustomCategory(Long userId, Long categoryId) { + return spendingCustomCategoryRepository.existsByIdAndUser_Id(categoryId, userId); + } + + @Transactional + public void deleteSpendingCustomCategory(Long categoryId) { + spendingCustomCategoryRepository.deleteById(categoryId); + } + + @Transactional + public void deleteSpendingCustomCategoriesByUserIdInQuery(Long userId) { + spendingCustomCategoryRepository.deleteAllByUserIdInQuery(userId); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingRdbService.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingRdbService.java new file mode 100644 index 000000000..5f6eb2df5 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingRdbService.java @@ -0,0 +1,182 @@ +package kr.co.pennyway.domain.domains.spending.service; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Predicate; +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.common.util.SliceUtil; +import kr.co.pennyway.domain.domains.spending.domain.QSpending; +import kr.co.pennyway.domain.domains.spending.domain.QSpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import kr.co.pennyway.domain.domains.spending.repository.SpendingRepository; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class SpendingRdbService { + private final SpendingRepository spendingRepository; + + private final QUser user = QUser.user; + private final QSpending spending = QSpending.spending; + private final QSpendingCustomCategory spendingCustomCategory = QSpendingCustomCategory.spendingCustomCategory; + + @Transactional + public Spending createSpending(Spending spending) { + return spendingRepository.save(spending); + } + + @Transactional(readOnly = true) + public Optional readSpending(Long spendingId) { + return spendingRepository.findById(spendingId); + } + + @Transactional(readOnly = true) + public List readSpendings(Long userId, int year, int month) { + return spendingRepository.findByYearAndMonth(userId, year, month); + } + + @Transactional(readOnly = true) + public List readSpendings(Long userId, int year, int month, int day) { + return spendingRepository.findByYearAndMonthAndDay(userId, year, month, day); + } + + @Transactional(readOnly = true) + public int readSpendingTotalCountByCategoryId(Long userId, Long categoryId) { + return spendingRepository.countByUser_IdAndSpendingCustomCategory_Id(userId, categoryId); + } + + @Transactional(readOnly = true) + public int readSpendingTotalCountByCategory(Long userId, SpendingCategory spendingCategory) { + return spendingRepository.countByUser_IdAndCategory(userId, spendingCategory); + } + + /** + * 사용자 정의 카테고리 ID로 지출 내역 리스트를 조회한다. + * + * @return 지출 내역 리스트를 {@link Slice}에 담아서 반환한다. + */ + @Transactional(readOnly = true) + public Slice readSpendingsSliceByCategoryId(Long userId, Long categoryId, Pageable pageable) { + Predicate predicate = spending.user.id.eq(userId).and(spendingCustomCategory.id.eq(categoryId)); + + QueryHandler queryHandler = query -> query + .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin() + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1); + + Sort sort = pageable.getSort(); + + return SliceUtil.toSlice(spendingRepository.findList(predicate, queryHandler, sort), pageable); + } + + /** + * 시스템 제공 카테고리 code로 지출 내역 리스트를 조회한다. + * + * @return 지출 내역 리스트를 {@link Slice}에 담아서 반환한다. + */ + @Transactional(readOnly = true) + public Slice readSpendingsSliceByCategory(Long userId, SpendingCategory spendingCategory, Pageable pageable) { + if (spendingCategory.equals(SpendingCategory.CUSTOM) || spendingCategory.equals(SpendingCategory.OTHER)) { + throw new IllegalArgumentException("지출 카테고리가 시스템 제공 카테고리가 아닙니다."); + } + + Predicate predicate = spending.user.id.eq(userId).and(spending.category.eq(spendingCategory)); + + QueryHandler queryHandler = query -> query + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1); + + Sort sort = pageable.getSort(); + + return SliceUtil.toSlice(spendingRepository.findList(predicate, queryHandler, sort), pageable); + } + + @Transactional(readOnly = true) + public Optional readTotalSpendingAmountByUserId(Long userId, LocalDate date) { + return spendingRepository.findTotalSpendingAmountByUserId(userId, date.getYear(), date.getMonthValue()); + } + + @Transactional(readOnly = true) + public List readTotalSpendingsAmountByUserId(Long userId) { + Predicate predicate = user.id.eq(userId); + + QueryHandler queryHandler = query -> query.leftJoin(spending).on(user.id.eq(spending.user.id)) + .groupBy(spending.spendAt.year(), spending.spendAt.month()); + + Sort sort = Sort.by(Sort.Order.desc("year(spendAt)"), Sort.Order.desc("month(spendAt)")); + + Map> bindings = new LinkedHashMap<>(); + bindings.put("year", spending.spendAt.year().intValue()); + bindings.put("month", spending.spendAt.month().intValue()); + bindings.put("totalSpending", spending.amount.sum().longValue()); + + return spendingRepository.selectList(predicate, TotalSpendingAmount.class, bindings, queryHandler, sort); + } + + @Transactional(readOnly = true) + public boolean isExistsSpending(Long userId, Long spendingId) { + return spendingRepository.existsByIdAndUser_Id(spendingId, userId); + } + + @Transactional(readOnly = true) + public long countByUserIdAndIdIn(Long userId, List spendingIds) { + return spendingRepository.countByUserIdAndIdIn(userId, spendingIds); + } + + @Transactional + public void updateCategoryByCustomCategory(SpendingCategory fromCategory, Long toId) { + SpendingCategory custom = SpendingCategory.CUSTOM; + spendingRepository.updateCategoryByCustomCategoryInQuery(fromCategory, toId, custom); + } + + @Transactional + public void updateCategoryByCategory(SpendingCategory fromCategory, SpendingCategory toCategory) { + + spendingRepository.updateCategoryByCategoryInQuery(fromCategory, toCategory); + } + + @Transactional + public void updateCustomCategoryByCustomCategory(Long fromId, Long toId) { + spendingRepository.updateCustomCategoryByCustomCategoryInQuery(fromId, toId); + } + + @Transactional + public void updateCustomCategoryByCategory(Long fromId, SpendingCategory toCategory) { + spendingRepository.updateCustomCategoryByCategoryInQuery(fromId, toCategory); + } + + @Transactional + public void deleteSpending(Spending spending) { + spendingRepository.delete(spending); + } + + @Transactional + public void deleteSpendingsInQuery(List spendingIds) { + spendingRepository.deleteAllByIdAndDeletedAtNullInQuery(spendingIds); + } + + @Transactional + public void deleteSpendingsByUserIdInQuery(Long userId) { + spendingRepository.deleteAllByUserIdInQuery(userId); + } + + @Transactional + public void deleteSpendingsByCategoryIdInQuery(Long categoryId) { + spendingRepository.deleteAllByCategoryIdAndDeletedAtNullInQuery(categoryId); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java new file mode 100644 index 000000000..aa5ad5431 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.domain.domains.spending.type; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.stream.Stream; + +@Getter +@RequiredArgsConstructor +public enum SpendingCategory implements LegacyCommonType { + CUSTOM("0", "사용자 정의"), + FOOD("1", "식비"), + TRANSPORTATION("2", "교통"), + BEAUTY_OR_FASHION("3", "뷰티/패션"), + CONVENIENCE_STORE("4", "편의점/마트"), + EDUCATION("5", "교육"), + LIVING("6", "생활"), + HEALTH("7", "건강"), + HOBBY("8", "취미/여가"), + TRAVEL("9", "여행/숙박"), + ALCOHOL_OR_ENTERTAINMENT("10", "술/유흥"), + MEMBERSHIP_OR_FAMILY_EVENT("11", "회비/경조사"), + OTHER("12", "기타"); + + private final String code; + private final String type; + + public static SpendingCategory fromCode(String code) { + return Stream.of(values()) + .filter(v -> v.getCode().equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 카테고리 코드입니다.")); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java new file mode 100644 index 000000000..8576bdf77 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java @@ -0,0 +1,72 @@ +package kr.co.pennyway.domain.domains.target.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; + +import java.time.YearMonth; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "target_amount") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE target_amount SET amount = -1, is_read = 1 WHERE id = ?") +public class TargetAmount extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private int amount; + private boolean isRead; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private TargetAmount(int amount, User user) { + this.amount = amount; + this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); + this.isRead = false; + } + + /** + * @param amount 목표 금액은 null을 허용하지 않는다. + * @param user 사용자는 null을 허용하지 않는다. + * @throws NullPointerException amount가 null이거나 user가 null일 때 + */ + public static TargetAmount of(int amount, User user) { + return new TargetAmount(amount, user); + } + + /** + * @param amount 변경할 목표 금액은 null을 허용하지 않는다. + */ + public void updateAmount(int amount) { + this.amount = amount; + this.isRead = true; + } + + public boolean isAllocatedAmount() { + return this.amount >= 0; + } + + /** + * 해당 TargetAmount가 당월 데이터인지 확인한다. + * + * @return 당월 데이터라면 true, 아니라면 false + */ + public boolean isThatMonth() { + YearMonth yearMonth = YearMonth.now(); + return this.getCreatedAt().getYear() == yearMonth.getYear() && this.getCreatedAt().getMonth() == yearMonth.getMonth(); + } + + @Override + public String toString() { + return "TargetAmount(id=" + this.getId() + ", amount=" + this.getAmount() + ", year = " + this.getCreatedAt().getYear() + ", month = " + this.getCreatedAt().getMonthValue() + ")"; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java new file mode 100644 index 000000000..2a85e2351 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.domain.domains.target.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum TargetAmountErrorCode implements BaseErrorCode { + /* 400 BAD_REQUEST */ + INVALID_TARGET_AMOUNT_DATE(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "당월 목표 금액에 대한 요청이 아닙니다."), + + /* 404 NOT_FOUND */ + NOT_FOUND_TARGET_AMOUNT(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "해당 월의 목표 금액이 존재하지 않습니다."), + + /* 409 Conflict */ + ALREADY_EXIST_TARGET_AMOUNT(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 해당 월의 목표 금액 데이터가 존재합니다."), + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java new file mode 100644 index 000000000..669ab9b96 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.domain.domains.target.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class TargetAmountErrorException extends GlobalErrorException { + private final TargetAmountErrorCode targetAmountErrorCode; + + public TargetAmountErrorException(TargetAmountErrorCode targetAmountErrorCode) { + super(targetAmountErrorCode); + this.targetAmountErrorCode = targetAmountErrorCode; + } + + public CausedBy causedBy() { + return targetAmountErrorCode.causedBy(); + } + + public String getExplainError() { + return targetAmountErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java new file mode 100644 index 000000000..b97857b6f --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.domain.domains.target.repository; + +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; + +import java.time.LocalDate; +import java.util.Optional; + +public interface TargetAmountCustomRepository { + Optional findRecentOneByUserId(Long userId); + + boolean existsByUserIdThatMonth(Long userId, LocalDate date); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java new file mode 100644 index 000000000..f3803491a --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java @@ -0,0 +1,49 @@ +package kr.co.pennyway.domain.domains.target.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.domains.target.domain.QTargetAmount; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.Optional; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class TargetAmountCustomRepositoryImpl implements TargetAmountCustomRepository { + private final JPAQueryFactory queryFactory; + + private final QUser user = QUser.user; + private final QTargetAmount targetAmount = QTargetAmount.targetAmount; + + /** + * 사용자의 가장 최근 목표 금액을 조회한다. + * + * @return 최근 목표 금액이 존재하지 않을 경우 Optional.empty()를 반환하며, 당월 목표 금액 정보일 수도 있다. + */ + @Override + public Optional findRecentOneByUserId(Long userId) { + TargetAmount result = queryFactory.selectFrom(targetAmount) + .innerJoin(user).on(targetAmount.user.id.eq(user.id)) + .where(user.id.eq(userId) + .and(targetAmount.amount.gt(-1))) + .orderBy(targetAmount.createdAt.desc()) + .fetchFirst(); + + return Optional.ofNullable(result); + } + + @Override + public boolean existsByUserIdThatMonth(Long userId, LocalDate date) { + return queryFactory.selectOne().from(targetAmount) + .innerJoin(user).on(targetAmount.user.id.eq(user.id)) + .where(user.id.eq(userId) + .and(targetAmount.createdAt.year().eq(date.getYear())) + .and(targetAmount.createdAt.month().eq(date.getMonthValue()))) + .fetchFirst() != null; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java new file mode 100644 index 000000000..1a3405763 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.target.repository; + +import kr.co.pennyway.domain.common.repository.ExtendedRepository; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface TargetAmountRepository extends ExtendedRepository, TargetAmountCustomRepository { + @Transactional(readOnly = true) + @Query("SELECT ta FROM TargetAmount ta WHERE ta.user.id = :userId AND YEAR(ta.createdAt) = YEAR(:date) AND MONTH(ta.createdAt) = MONTH(:date)") + Optional findByUserIdThatMonth(Long userId, LocalDate date); + + @Transactional(readOnly = true) + List findByUser_Id(Long userId); + + @Transactional(readOnly = true) + boolean existsByIdAndUser_Id(Long id, Long userId); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountRdbService.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountRdbService.java new file mode 100644 index 000000000..d2161543a --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountRdbService.java @@ -0,0 +1,60 @@ +package kr.co.pennyway.domain.domains.target.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.repository.TargetAmountRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class TargetAmountRdbService { + private final TargetAmountRepository targetAmountRepository; + + @Transactional + public TargetAmount createTargetAmount(TargetAmount targetAmount) { + return targetAmountRepository.save(targetAmount); + } + + @Transactional(readOnly = true) + public Optional readTargetAmount(Long id) { + return targetAmountRepository.findById(id); + } + + @Transactional(readOnly = true) + public Optional readTargetAmountThatMonth(Long userId, LocalDate date) { + return targetAmountRepository.findByUserIdThatMonth(userId, date); + } + + @Transactional(readOnly = true) + public List readTargetAmountsByUserId(Long userId) { + return targetAmountRepository.findByUser_Id(userId); + } + + @Transactional(readOnly = true) + public Optional readRecentTargetAmount(Long userId) { + return targetAmountRepository.findRecentOneByUserId(userId); + } + + @Transactional(readOnly = true) + public boolean isExistsTargetAmountThatMonth(Long userId, LocalDate date) { + return targetAmountRepository.existsByUserIdThatMonth(userId, date); + } + + @Transactional(readOnly = true) + public boolean isExistsTargetAmountByIdAndUserId(Long id, Long userId) { + return targetAmountRepository.existsByIdAndUser_Id(id, userId); + } + + + @Transactional + public void deleteTargetAmount(TargetAmount targetAmount) { + targetAmountRepository.delete(targetAmount); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java new file mode 100644 index 000000000..373a4fe78 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java @@ -0,0 +1,61 @@ +package kr.co.pennyway.domain.domains.user.domain; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert +@ToString(of = {"accountBookNotify", "feedNotify", "chatNotify"}) +public class NotifySetting { + @ColumnDefault("true") + private boolean accountBookNotify; + @ColumnDefault("true") + private boolean feedNotify; + @ColumnDefault("true") + private boolean chatNotify; + + @Builder + private NotifySetting(boolean accountBookNotify, boolean feedNotify, boolean chatNotify) { + this.accountBookNotify = accountBookNotify; + this.feedNotify = feedNotify; + this.chatNotify = chatNotify; + } + + public static NotifySetting of(boolean accountBookNotify, boolean feedNotify, boolean chatNotify) { + return NotifySetting.builder() + .accountBookNotify(accountBookNotify) + .feedNotify(feedNotify) + .chatNotify(chatNotify) + .build(); + } + + public void updateNotifySetting(NotifyType notifyType, boolean flag) { + switch (notifyType) { + case ACCOUNT_BOOK -> this.accountBookNotify = flag; + case FEED -> this.feedNotify = flag; + case CHAT -> this.chatNotify = flag; + } + } + + public boolean isAccountBookNotify() { + return accountBookNotify; + } + + public boolean isFeedNotify() { + return feedNotify; + } + + public boolean isChatNotify() { + return chatNotify; + } + + public enum NotifyType { + ACCOUNT_BOOK, FEED, CHAT + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java new file mode 100644 index 000000000..6bf99ffe3 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -0,0 +1,126 @@ +package kr.co.pennyway.domain.domains.user.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.ProfileVisibilityConverter; +import kr.co.pennyway.domain.common.converter.RoleConverter; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "user") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert +@SQLRestriction("deleted_at IS NULL") +@SQLDelete(sql = "UPDATE user SET deleted_at = NOW() WHERE id = ?") +public class User extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String username; + private String name; + @ColumnDefault("NULL") + private String password; + @ColumnDefault("NULL") + private LocalDateTime passwordUpdatedAt; + @ColumnDefault("NULL") + private String profileImageUrl; + private String phone; + @Convert(converter = RoleConverter.class) + private Role role; + @Convert(converter = ProfileVisibilityConverter.class) + private ProfileVisibility profileVisibility; + @ColumnDefault("false") + private boolean locked; + @Embedded + private NotifySetting notifySetting; + @ColumnDefault("NULL") + private LocalDateTime deletedAt; + + @Builder + private User(String username, String name, String password, LocalDateTime passwordUpdatedAt, String profileImageUrl, String phone, Role role, + ProfileVisibility profileVisibility, NotifySetting notifySetting, boolean locked) { + if (!StringUtils.hasText(username)) { + throw new IllegalArgumentException("username은 null이거나 빈 문자열이 될 수 없습니다."); + } else if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name은 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.username = username; + this.name = name; + this.password = password; + this.passwordUpdatedAt = passwordUpdatedAt; + this.profileImageUrl = profileImageUrl; + this.phone = Objects.requireNonNull(phone, "phone은 null이 될 수 없습니다."); + this.role = Objects.requireNonNull(role, "role은 null이 될 수 없습니다."); + this.profileVisibility = Objects.requireNonNull(profileVisibility, "profileVisibility는 null이 될 수 없습니다."); + this.notifySetting = Objects.requireNonNull(notifySetting, "notifySetting은 null이 될 수 없습니다."); + this.locked = locked; + } + + public void updatePassword(String password) { + if (!StringUtils.hasText(password)) { + throw new IllegalArgumentException("password는 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.password = password; + this.passwordUpdatedAt = LocalDateTime.now(); + } + + public void updateName(String name) { + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name은 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.name = name; + } + + public void updateUsername(String username) { + if (!StringUtils.hasText(username)) { + throw new IllegalArgumentException("username은 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.username = username; + } + + public void updateProfileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + } + + public void updatePhone(String phone) { + this.phone = phone; + } + + public boolean isGeneralSignedUpUser() { + return password != null; + } + + public boolean isLocked() { + return locked; + } + + @Override + public String toString() { + return "User{" + + "id=" + id + + ", username='" + username + '\'' + + ", name='" + name + '\'' + + ", role=" + role + + ", deletedAt=" + deletedAt + + '}'; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java new file mode 100644 index 000000000..cacb5f055 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.domain.domains.user.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum UserErrorCode implements BaseErrorCode { + /* 400 BAD_REQUEST */ + NOT_MATCHED_PASSWORD(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "비밀번호가 일치하지 않습니다."), + PASSWORD_NOT_CHANGED(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "현재 비밀번호와 동일한 비밀번호로 변경할 수 없습니다."), + + /* 401 UNAUTHORIZED */ + INVALID_USERNAME_OR_PASSWORD(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "유효하지 않은 아이디 또는 비밀번호입니다."), + + /* 403 FORBIDDEN */ + ALREADY_WITHDRAWAL(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "이미 탈퇴한 유저입니다."), + DO_NOT_GENERAL_SIGNED_UP(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "일반 회원가입 계정이 아닙니다."), + + /* 404 NOT_FOUND */ + NOT_ALLOCATED_PROFILE_IMAGE(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "프로필 이미지가 할당되지 않았습니다."), + + /* 409 Conflict */ + ALREADY_SIGNUP(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 회원가입한 유저입니다."), + ALREADY_EXIST_USERNAME(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 존재하는 아이디입니다."), + ALREADY_EXIST_PHONE(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 존재하는 휴대폰 번호입니다."), + HAS_OWNERSHIP_CHAT_ROOM(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "채팅방의 방장으로 참여하고 있는 경우 삭제할 수 없습니다."), + + /* 404 NOT_FOUND */ + NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "유저를 찾을 수 없습니다."), + + /* 422 UNPROCESSABLE_ENTITY */ + INVALID_NOTIFY_TYPE(StatusCode.UNPROCESSABLE_CONTENT, ReasonCode.TYPE_MISMATCH_ERROR_IN_REQUEST_BODY, "유효하지 않은 알림 타입입니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java new file mode 100644 index 000000000..e0f352ed4 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.domain.domains.user.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class UserErrorException extends GlobalErrorException { + private final UserErrorCode userErrorCode; + + public UserErrorException(UserErrorCode userErrorCode) { + super(userErrorCode); + this.userErrorCode = userErrorCode; + } + + public CausedBy causedBy() { + return userErrorCode.causedBy(); + } + + public String getExplainError() { + return userErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java new file mode 100644 index 000000000..8db6e9c33 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.domains.user.repository; + +import kr.co.pennyway.domain.common.repository.ExtendedRepository; +import kr.co.pennyway.domain.domains.user.domain.User; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +public interface UserRepository extends ExtendedRepository { + Optional findByPhone(String phone); + + Optional findByUsername(String username); + + boolean existsByUsername(String username); + + boolean existsByPhone(String phone); + + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE User u SET u.deletedAt = NOW() WHERE u.id = :userId") + void deleteByIdInQuery(Long userId); +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/service/UserRdbService.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/service/UserRdbService.java new file mode 100644 index 000000000..bb222fe20 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/service/UserRdbService.java @@ -0,0 +1,60 @@ +package kr.co.pennyway.domain.domains.user.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@DomainService +@RequiredArgsConstructor +public class UserRdbService { + private final UserRepository userRepository; + + @Transactional + public User createUser(User user) { + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public Optional readUser(Long id) { + return userRepository.findById(id); + } + + @Transactional(readOnly = true) + public Optional readUserByPhone(String phone) { + return userRepository.findByPhone(phone); + } + + @Transactional(readOnly = true) + public Optional readUserByUsername(String username) { + return userRepository.findByUsername(username); + } + + @Transactional(readOnly = true) + public boolean isExistUser(Long id) { + return userRepository.existsById(id); + } + + @Transactional(readOnly = true) + public boolean isExistUsername(String username) { + return userRepository.existsByUsername(username); + } + + @Transactional(readOnly = true) + public boolean isExistPhone(String phone) { + return userRepository.existsByPhone(phone); + } + + @Transactional + public void deleteUser(User user) { + userRepository.delete(user); + } + + @Transactional + public void deleteUser(Long userId) { + userRepository.deleteByIdInQuery(userId); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java new file mode 100644 index 000000000..3d4e505b9 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.domain.domains.user.type; + +import com.fasterxml.jackson.annotation.JsonValue; +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ProfileVisibility implements LegacyCommonType { + PUBLIC("0", "전체 공개"), + FRIEND("1", "친구 공개"), + PRIVATE("2", "비공개"); + + private final String code; + private final String type; + + @Override + public String getCode() { + return code; + } + + public String getType() { + return type; + } + + @JsonValue + public String createJson() { + return name(); + } + + @Override + public String toString() { + return type; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java new file mode 100644 index 000000000..1d46744f6 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.domain.domains.user.type; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.toMap; + +@RequiredArgsConstructor +public enum Role implements LegacyCommonType { + ADMIN("0", "ROLE_ADMIN"), + USER("1", "ROLE_USER"); + + private static final Map stringToEnum = + Stream.of(values()).collect(toMap(Object::toString, e -> e)); + private final String code; + private final String type; + + @JsonCreator + public static Role fromString(String type) { + return stringToEnum.get(type.toUpperCase()); + } + + @Override + public String getCode() { + return code; + } + + @JsonValue + public String getType() { + return type; + } + + @Override + public String toString() { + return type; + } +} diff --git a/pennyway-domain/domain-rdb/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor b/pennyway-domain/domain-rdb/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor new file mode 100644 index 000000000..62c62ff9b --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor @@ -0,0 +1 @@ +kr.co.pennyway.domain.config.MySqlFunctionContributor \ No newline at end of file diff --git a/pennyway-domain/domain-rdb/src/main/resources/application-domain-rdb.yml b/pennyway-domain/domain-rdb/src/main/resources/application-domain-rdb.yml new file mode 100644 index 000000000..6ca9dece2 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/main/resources/application-domain-rdb.yml @@ -0,0 +1,79 @@ +spring: + profiles: + group: + local: common + dev: common + + datasource: + url: ${DB_URL:jdbc:mysql://localhost:3300/pennyway?serverTimezone=Asia/Seoul&characterEncoding=utf8&postfileSQL=true&logger=Slf4JLogger&rewriteBatchedStatements=true} + username: ${DB_USER_NAME:root} + password: ${DB_PASSWORD:password} + driver-class-name: com.mysql.cj.jdbc.Driver + +--- +spring: + config: + activate: + on-profile: local + + jpa: + database: MySQL + open-in-view: false + generate-ddl: false + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + +logging: + level: + ROOT: INFO + org.hibernate: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + org.hibernate.sql: debug + org.hibernate.type: trace + com.zaxxer.hikari.HikariConfig: DEBUG + org.springframework.orm: TRACE + org.springframework.transaction: TRACE + com.zaxxer.hikari: TRACE + com.mysql.cj.jdbc: TRACE + +--- +spring: + config: + activate: + on-profile: dev + + jpa: + database: MySQL + open-in-view: false + generate-ddl: false + hibernate: + ddl-auto: none + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + +--- +spring: + config: + activate: + on-profile: test + + jpa: + database: MySQL + open-in-view: false + generate-ddl: true + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + +logging: + level: + org.springframework.jdbc: debug \ No newline at end of file diff --git a/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/common/fixture/ChatRoomFixture.java b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/common/fixture/ChatRoomFixture.java new file mode 100644 index 000000000..1457a44e0 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/common/fixture/ChatRoomFixture.java @@ -0,0 +1,40 @@ +package kr.co.pennyway.domain.common.fixture; + +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; + +public enum ChatRoomFixture { + PRIVATE_CHAT_ROOM("페니웨이", "페니웨이 채팅방입니다.", "delete/chatroom/1/fsdflasdfa_12121210.jpg", "123456"), + PUBLIC_CHAT_ROOM("페니웨이", "페니웨이 채팅방입니다.", "delete/chatroom/1/fsdflasdfa_12121210.jpg", null); + + private final String title; + private final String description; + private final String backgroundImageUrl; + private final String password; + + ChatRoomFixture(String title, String description, String backgroundImageUrl, String password) { + this.title = title; + this.description = description; + this.backgroundImageUrl = backgroundImageUrl; + this.password = password; + } + + public ChatRoom toEntity() { + return ChatRoom.builder() + .id(1L) + .title(title) + .description(description) + .backgroundImageUrl(backgroundImageUrl) + .password(password != null ? Integer.valueOf(password) : null) + .build(); + } + + public ChatRoom toEntityWithId(Long id) { + return ChatRoom.builder() + .id(id) + .title(title) + .description(description) + .backgroundImageUrl(backgroundImageUrl) + .password(password != null ? Integer.valueOf(password) : null) + .build(); + } +} diff --git a/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/common/fixture/UserFixture.java b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/common/fixture/UserFixture.java new file mode 100644 index 000000000..9396efca4 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/common/fixture/UserFixture.java @@ -0,0 +1,66 @@ +package kr.co.pennyway.domain.common.fixture; + +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.Getter; +import org.springframework.test.util.ReflectionTestUtils; + +@Getter +public enum UserFixture { + GENERAL_USER(1L, "jayang", "dkssudgktpdy1", "Yang", "010-1111-1111", Role.USER, ProfileVisibility.PUBLIC, NotifySetting.of(true, true, true), false), + OAUTH_USER(2L, "only._.o", null, "Only", "010-2222-2222", Role.USER, ProfileVisibility.PUBLIC, NotifySetting.of(true, true, true), false), + ; + + private final Long id; + private final String username; + private final String password; + private final String name; + private final String phone; + private final Role role; + private final ProfileVisibility profileVisibility; + private final NotifySetting notifySetting; + private final Boolean locked; + + UserFixture(Long id, String username, String password, String name, String phone, Role role, ProfileVisibility profileVisibility, NotifySetting notifySetting, Boolean locked) { + this.id = id; + this.username = username; + this.password = password; + this.name = name; + this.phone = phone; + this.role = role; + this.profileVisibility = profileVisibility; + this.notifySetting = notifySetting; + this.locked = locked; + } + + public User toUser() { + return User.builder() + .username(username) + .password(password) + .name(name) + .phone(phone) + .role(role) + .profileVisibility(profileVisibility) + .notifySetting(notifySetting) + .locked(locked) + .build(); + } + + public User toUserWithCustomSetting(Long id, String username, String name, NotifySetting notifySetting) { + User user = User.builder() + .username(username) + .password(password) + .name(name) + .phone(phone) + .role(role) + .profileVisibility(profileVisibility) + .notifySetting(notifySetting) + .locked(locked) + .build(); + ReflectionTestUtils.setField(user, "id", id); + + return user; + } +} \ No newline at end of file diff --git a/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java new file mode 100644 index 000000000..876505dd9 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.domain.config; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +public class ContainerMySqlTestConfig { + private static final String MYSQL_CONTAINER_IMAGE = "mysql:8.0.26"; + + private static final MySQLContainer MYSQL_CONTAINER; + + static { + MYSQL_CONTAINER = + new MySQLContainer<>(DockerImageName.parse(MYSQL_CONTAINER_IMAGE)) + .withDatabaseName("pennyway") + .withUsername("root") + .withPassword("testpass") + .withCommand("--sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION") + .withReuse(true); + + MYSQL_CONTAINER.start(); + } + + @DynamicPropertySource + public static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=UTC&characterEncoding=utf8", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306))); + registry.add("spring.datasource.username", () -> "root"); + registry.add("spring.datasource.password", () -> "testpass"); + } +} diff --git a/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/config/JpaTestConfig.java b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/config/JpaTestConfig.java new file mode 100644 index 000000000..ed4c7aae0 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/config/JpaTestConfig.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.domain.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.querydsl.sql.MySQLTemplates; +import com.querydsl.sql.SQLTemplates; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class JpaTestConfig { + @PersistenceContext + private EntityManager em; + + @Bean + @ConditionalOnMissingBean + public JPAQueryFactory testJpaQueryFactory() { + return new JPAQueryFactory(em); + } + + @Bean + @ConditionalOnMissingBean + public SQLTemplates testSqlTemplates() { + return new MySQLTemplates(); + } +} diff --git a/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java new file mode 100644 index 000000000..2ee862030 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java @@ -0,0 +1,190 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.springframework.test.util.AssertionErrors.*; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = JpaConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(JpaTestConfig.class) +@ActiveProfiles("test") +public class NotificationRepositoryUnitTest extends ContainerMySqlTestConfig { + @Autowired + private UserRepository userRepository; + @Autowired + private NotificationRepository notificationRepository; + + @Test + @Transactional + @DisplayName("여러 사용자에게 일일 소비 알림을 저장할 수 있다.") + public void saveDailySpendingAnnounceInBulk() { + // given + User user1 = userRepository.save(createUser("jayang")); + User user2 = userRepository.save(createUser("mock")); + User user3 = userRepository.save(createUser("test")); + + // when + notificationRepository.saveDailySpendingAnnounceInBulk( + List.of(user1.getId(), user2.getId(), user3.getId()), + Announcement.DAILY_SPENDING + ); + + // then + notificationRepository.findAll().forEach(notification -> { + log.info("notification: {}", notification); + assertEquals("알림 타입이 일일 소비 알림이어야 한다.", Announcement.DAILY_SPENDING, notification.getAnnouncement()); + }); + } + + @Test + @Transactional + @DisplayName("이미 당일에 알림을 받은 사용자에게 데이터가 중복 저장되지 않아야 한다.") + public void notSaveDuplicateNotification() { + // given + User user1 = userRepository.save(createUser("jayang")); + User user2 = userRepository.save(createUser("mock")); + + Notification notification = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user1) + .build(); + notificationRepository.save(notification); + + // when + notificationRepository.saveDailySpendingAnnounceInBulk( + List.of(user1.getId(), user2.getId()), + Announcement.DAILY_SPENDING + ); + + // then + List notifications = notificationRepository.findAll(); + log.debug("notifications: {}", notifications); + assertEquals("알림이 중복 저장되지 않아야 한다.", 2, notifications.size()); + } + + @Test + @DisplayName("사용자의 여러 알림을 읽음 처리할 수 있다.") + void updateReadAtSuccessfully() { + // given + User user = userRepository.save(createUser("jayang")); + + List notifications = notificationRepository.saveAll(List.of( + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(), + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(), + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build())); + + // when + notificationRepository.updateReadAtByIdsInBulk(notifications.stream().map(Notification::getId).toList()); + + // then + notificationRepository.findAll().forEach(notification -> { + log.info("notification: {}", notification); + assertNotNull("알림이 읽음 처리 되어야 한다.", notification.getReadAt()); + }); + } + + @Test + @DisplayName("사용자의 읽지 않은 알림 개수를 조회할 수 있다.") + void countUnreadNotificationsByIds() { + // given + User user = userRepository.save(createUser("jayang")); + + List notifications = notificationRepository.saveAll(List.of( + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(), + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(), + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build())); + List ids = notifications.stream().map(Notification::getId).toList(); + + notificationRepository.updateReadAtByIdsInBulk(List.of(ids.get(1))); + + // when + long count = notificationRepository.countUnreadNotificationsByIds( + user.getId(), + notifications.stream().map(Notification::getId).toList() + ); + + // then + assertEquals("읽지 않은 알림 개수가 2개여야 한다.", 2L, count); + } + + @Test + @DisplayName("사용자의 읽지 않은 알림이 존재하면 true를 반환한다.") + void existsTopByReceiver_IdAndReadAtIsNull() { + // given + User user = userRepository.save(createUser("jayang")); + + Notification notification1 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + Notification notification2 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + Notification notification3 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + + ReflectionTestUtils.setField(notification1, "readAt", LocalDateTime.now()); + ReflectionTestUtils.setField(notification2, "readAt", LocalDateTime.now()); + + notificationRepository.saveAll(List.of(notification1, notification2, notification3)); + + // when + boolean exists = notificationRepository.existsUnreadNotification(user.getId()); + + // then + assertTrue("읽지 않은 알림이 존재하면 true를 반환해야 한다.", exists); + } + + @Test + @DisplayName("사용자의 읽지 않은 알림이 존재하지 않으면 false를 반환한다.") + void notExistsTopByReceiver_IdAndReadAtIsNull() { + // given + User user = userRepository.save(createUser("jayang")); + + Notification notification1 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + Notification notification2 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + Notification notification3 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + + ReflectionTestUtils.setField(notification1, "readAt", LocalDateTime.now()); + ReflectionTestUtils.setField(notification2, "readAt", LocalDateTime.now()); + ReflectionTestUtils.setField(notification3, "readAt", LocalDateTime.now()); + + notificationRepository.saveAll(List.of(notification1, notification2, notification3)); + + // when + boolean exists = notificationRepository.existsUnreadNotification(user.getId()); + + // then + assertFalse("읽지 않은 알림이 존재하지 않으면 false를 반환해야 한다.", exists); + } + + private User createUser(String name) { + return User.builder() + .username("test") + .name(name) + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } +} diff --git a/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java new file mode 100644 index 000000000..a6621725e --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java @@ -0,0 +1,124 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.service.NotificationRdbService; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertTrue; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = {JpaConfig.class, NotificationRdbService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(JpaTestConfig.class) +@ActiveProfiles("test") +public class ReadNotificationsSliceUnitTest extends ContainerMySqlTestConfig { + @Autowired + private UserRepository userRepository; + + @Autowired + private NotificationRdbService notificationService; + + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @Test + @Transactional + @DisplayName("특정 사용자의 알림 목록을 슬라이스로 조회하며, 결과는 최신순으로 정렬되어야 한다.") + public void readNotificationsSliceSorted() { + // given + User user = userRepository.save(createUser("jayang")); + Pageable pa = PageRequest.of(0, 5, Sort.by(Sort.Order.desc("notification.createdAt"))); + + List notifications = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Notification notification = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + ReflectionTestUtils.setField(notification, "readAt", LocalDateTime.now()); + notifications.add(notification); + } + bulkInsertNotifications(notifications); + + // when + Slice result = notificationService.readNotificationsSlice(user.getId(), pa, NoticeType.ANNOUNCEMENT); + + // then + assertEquals("Slice 데이터 개수는 5개여야 한다.", 5, result.getNumberOfElements()); + assertTrue("hasNext()는 true여야 한다.", result.hasNext()); + for (int i = 0; i < result.getNumberOfElements() - 1; i++) { + Notification current = result.getContent().get(i); + Notification next = result.getContent().get(i + 1); + log.debug("current: {}, next: {}", current.getCreatedAt(), next.getCreatedAt()); + log.debug("notification: {}", current); + assert current.getCreatedAt().isAfter(next.getCreatedAt()); + } + } + + private User createUser(String name) { + return User.builder() + .username("test") + .name(name) + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } + + private void bulkInsertNotifications(List notifications) { + String sql = String.format(""" + INSERT INTO `%s` (type, announcement, created_at, updated_at, receiver, receiver_name, read_at) + VALUES (:type, :announcement, :createdAt, :updatedAt, :receiver, :receiverName, :readAt); + """, "notification"); + + LocalDateTime date = LocalDateTime.now(); + SqlParameterSource[] params = new SqlParameterSource[notifications.size()]; + + for (int i = 0; i < notifications.size(); i++) { + Notification notification = notifications.get(i); + params[i] = new MapSqlParameterSource() + .addValue("type", notification.getType().getCode()) + .addValue("announcement", notification.getAnnouncement().getCode()) + .addValue("createdAt", date) + .addValue("updatedAt", date) + .addValue("receiver", notification.getReceiver().getId()) + .addValue("receiverName", notification.getReceiverName()) + .addValue("readAt", notification.getReadAt()); + date = date.minusDays(1); + } + + jdbcTemplate.batchUpdate(sql, params); + } +} diff --git a/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java new file mode 100644 index 000000000..dce4f1821 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java @@ -0,0 +1,79 @@ +package kr.co.pennyway.domain.domains.oauth.repository; + +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = JpaConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(JpaTestConfig.class) +@ActiveProfiles("test") +public class OauthRepositoryTest extends ContainerMySqlTestConfig { + @Autowired + private UserRepository userRepository; + + @Autowired + private OauthRepository oauthRepository; + + private User user; + + @Test + @DisplayName("soft delete된 다른 user_id를 가지면서, 같은 oauth_id, provider를 갖는 정보가 존재해도, 하나의 결과만을 반환한다.") + @Transactional + public void test() { + // given + User user = createUser(); + Oauth oauth = Oauth.of(Provider.KAKAO, "oauth_id", user); + + User newUser = createUser(); + Oauth newOauth = Oauth.of(Provider.KAKAO, "oauth_id", newUser); + + // when (소셜 회원가입 ⇾ 회원 탈퇴 ⇾ 동일 정보 소셜 회원가입 ⇾ 조회 성공) + userRepository.save(user); + oauthRepository.save(oauth); + log.debug("user: {}, oauth: {}", user, oauth); + + userRepository.delete(user); + oauthRepository.delete(oauth); + + userRepository.save(newUser); + oauthRepository.save(newOauth); + log.debug("newUser: {}, newOauth: {}", newUser, newOauth); + + // then + assertDoesNotThrow(() -> oauthRepository.findByOauthIdAndProviderAndDeletedAtIsNull(newOauth.getOauthId(), newOauth.getProvider())); + } + + private User createUser() { + return User.builder() + .username("test") + .name("pannyway") + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } +} diff --git a/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java new file mode 100644 index 000000000..b01021323 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java @@ -0,0 +1,124 @@ +package kr.co.pennyway.domain.domains.target.repository; + +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = JpaConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Import(JpaTestConfig.class) +public class RecentTargetAmountSearchTest extends ContainerMySqlTestConfig { + private final Collection mockTargetAmounts = List.of( + MockTargetAmount.of(10000, true, LocalDateTime.of(2021, 1, 1, 0, 0, 0), LocalDateTime.of(2021, 1, 1, 0, 0, 0)), + MockTargetAmount.of(-1, false, LocalDateTime.of(2022, 3, 1, 0, 0, 0), LocalDateTime.of(2022, 3, 1, 0, 0, 0)), + MockTargetAmount.of(20000, true, LocalDateTime.of(2022, 5, 1, 0, 0, 0), LocalDateTime.of(2022, 5, 1, 0, 0, 0)), + MockTargetAmount.of(30000, true, LocalDateTime.of(2023, 7, 1, 0, 0, 0), LocalDateTime.of(2023, 7, 1, 0, 0, 0)), + MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)), + MockTargetAmount.of(-1, true, LocalDateTime.of(2024, 2, 1, 0, 0, 0), LocalDateTime.of(2024, 2, 1, 0, 0, 0)) + ); + private final Collection mockTargetAmountsMinus = List.of( + MockTargetAmount.of(-1, true, LocalDateTime.of(2022, 3, 1, 0, 0, 0), LocalDateTime.of(2022, 3, 1, 0, 0, 0)), + MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)), + MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)), + MockTargetAmount.of(-1, true, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)) + ); + @Autowired + private UserRepository userRepository; + @Autowired + private TargetAmountRepository targetAmountRepository; + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @Test + @DisplayName("사용자의 가장 최근 목표 금액을 조회할 수 있다.") + @Transactional + public void 가장_최근_사용자_목표_금액_조회() { + // given + User user = userRepository.save(createUser()); + bulkInsertTargetAmount(user, mockTargetAmounts); + + // when - then + targetAmountRepository.findRecentOneByUserId(user.getId()) + .ifPresentOrElse( + targetAmount -> assertEquals(targetAmount.getAmount(), 30000), + () -> Assertions.fail("최근 목표 금액이 존재하지 않습니다.") + ); + } + + @Test + @DisplayName("사용자의 가장 최근 목표 금액이 존재하지 않으면 Optional.empty()를 반환한다.") + @Transactional + public void 가장_최근_사용자_목표_금액_미존재() { + // given + User user = userRepository.save(createUser()); + bulkInsertTargetAmount(user, mockTargetAmountsMinus); + + // when - then + targetAmountRepository.findRecentOneByUserId(user.getId()) + .ifPresentOrElse( + targetAmount -> Assertions.fail("최근 목표 금액이 존재합니다."), + () -> log.info("최근 목표 금액이 존재하지 않습니다.") + ); + } + + private void bulkInsertTargetAmount(User user, Collection targetAmounts) { + String sql = String.format(""" + INSERT INTO `%s` (amount, is_read, user_id, created_at, updated_at) + VALUES (:amount, true, :userId, :createdAt, :updatedAt) + """, "target_amount"); + SqlParameterSource[] params = targetAmounts.stream() + .map(mockTargetAmount -> new MapSqlParameterSource() + .addValue("amount", mockTargetAmount.amount) + .addValue("userId", user.getId()) + .addValue("createdAt", mockTargetAmount.createdAt) + .addValue("updatedAt", mockTargetAmount.updatedAt)) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + private User createUser() { + return User.builder() + .username("test") + .name("pannyway") + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } + + private record MockTargetAmount(int amount, boolean isRead, LocalDateTime createdAt, LocalDateTime updatedAt) { + public static MockTargetAmount of(int amount, boolean isRead, LocalDateTime createdAt, LocalDateTime updatedAt) { + return new MockTargetAmount(amount, isRead, createdAt, updatedAt); + } + } +} diff --git a/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java new file mode 100644 index 000000000..db95e67b3 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java @@ -0,0 +1,297 @@ +package kr.co.pennyway.domain.domains.user.repository; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Predicate; +import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.domain.QOauth; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +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.data.domain.Sort; +import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +import static java.time.LocalDateTime.now; +import static org.springframework.test.util.AssertionErrors.*; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = {JpaConfig.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Import(JpaTestConfig.class) +public class UserExtendedRepositoryTest extends ContainerMySqlTestConfig { + private static final String USER_TABLE = "user"; + private static final String OAUTH_TABLE = "oauth"; + + private final QUser qUser = QUser.user; + private final QOauth qOauth = QOauth.oauth; + + @Autowired + private UserRepository userRepository; + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + List users = getRandomUsers(); + bulkInsertUser(users); + + users = userRepository.findAll(); + + List oauths = getRandomOauths(users); + bulkInsertOauth(oauths); + } + + @Test + @DisplayName(""" + Entity findList 테스트: 이름이 양재서고, 일반 회원가입 이력이 존재하면서, lock이 걸려있지 않은 사용자 정보를 조회한다. + 이때, 결과는 id 내림차순으로 정렬한다. + """) + @Transactional + public void findList() { + // given + Predicate predicate = qUser.name.eq("양재서") + .and(qUser.password.isNotNull()) + .and(qUser.locked.isFalse()); + + QueryHandler queryHandler = null; // queryHandler는 사용하지 않으므로 null로 설정 + + Sort sort = Sort.by(Sort.Order.desc("id")); + + // when + List users = userRepository.findList(predicate, queryHandler, sort); + + // then + Long maxValue = 100000L; + for (User user : users) { + log.info("user: {}", user); + + assertTrue("id는 내림차순 정렬되어야 한다.", user.getId() <= maxValue); + assertTrue("일반 회원가입 이력이 존재해야 한다.", user.isGeneralSignedUpUser()); + assertFalse("lock이 걸려있지 않아야 한다.", user.isLocked()); + + maxValue = user.getId(); + } + } + + @Test + @DisplayName(""" + Entity findPage 테스트: 이름이 양재서고, Kakao로 가입한 Oauth 정보를 조회한다. + 단, 결과는 처음 5개만 조회하며, id 내림차순으로 정렬한다. + """) + @Transactional + public void findPage() { + // given + Predicate predicate = qUser.name.eq("양재서") + .and(qOauth.provider.eq(Provider.KAKAO)); + + QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id)); + Sort sort = Sort.by(Sort.Order.desc("user.id")); + + int pageNumber = 0, pageSize = 5; + Pageable pageable = PageRequest.of(pageNumber, pageSize, sort); + + // when + Page users = userRepository.findPage(predicate, queryHandler, pageable); + + // then + assertEquals("users의 크기는 5여야 한다.", 5, users.getSize()); + Long maxValue = 100000L; + for (User user : users.getContent()) { + log.debug("user: {}", user); + assertTrue("id는 내림차순 정렬되어야 한다.", user.getId() <= maxValue); + assertEquals("이름이 양재서여야 한다.", "양재서", user.getName()); + maxValue = user.getId(); + } + } + + @Test + @DisplayName(""" + Dto selectList 테스트: 사용자 이름이 양재서인 사용자의 username, name, phone 그리고 연동된 Oauth 정보를 조회한다. + LinkedHashMap을 사용하여 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다. + """) + @Transactional + public void selectListUseLinkedHashMap() { + // given + Predicate predicate = qUser.name.eq("양재서"); + + QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id)); + Sort sort = null; + + Map> bindings = new LinkedHashMap<>(); + + bindings.put("userId", qUser.id); + bindings.put("username", qUser.username); + bindings.put("name", qUser.name); + bindings.put("phone", qUser.phone); + bindings.put("oauthId", qOauth.id); + bindings.put("provider", qOauth.provider); + + // when + List userAndOauthInfos = userRepository.selectList(predicate, UserAndOauthInfo.class, bindings, queryHandler, sort); + + // then + userAndOauthInfos.forEach(userAndOauthInfo -> { + log.debug("userAndOauthInfo: {}", userAndOauthInfo); + assertEquals("이름이 양재서인 사용자만 조회되어야 한다.", "양재서", userAndOauthInfo.name()); + assertEquals("provider는 KAKAO여야 한다.", Provider.KAKAO, userAndOauthInfo.provider()); + }); + } + + @Test + @DisplayName(""" + Dto selectList 테스트: 사용자 이름이 양재서인 사용자의 username, name, phone 그리고 연동된 Oauth 정보를 조회한다. + HashMap을 사용하더라도 Dto의 setter를 명시하고 final 키워드를 제거하면 결과를 조회할 수 있다. + """) + @Transactional + public void selectListUseHashMap() { + // given + Predicate predicate = qUser.name.eq("양재서"); + + QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id)); + Sort sort = null; + + Map> bindings = new HashMap<>(); + + bindings.put("userId", qUser.id); + bindings.put("username", qUser.username); + bindings.put("name", qUser.name); + bindings.put("phone", qUser.phone); + bindings.put("oauthId", qOauth.id); + bindings.put("provider", qOauth.provider); + + // when + List userAndOauthInfos = userRepository.selectList(predicate, UserAndOauthInfoNotImmutable.class, bindings, queryHandler, sort); + + // then + userAndOauthInfos.forEach(userAndOauthInfo -> { + log.debug("userAndOauthInfo: {}", userAndOauthInfo); + assertEquals("이름이 양재서인 사용자만 조회되어야 한다.", "양재서", userAndOauthInfo.getName()); + assertEquals("provider는 KAKAO여야 한다.", Provider.KAKAO, userAndOauthInfo.getProvider()); + }); + } + + private List getRandomUsers() { + List users = new ArrayList<>(100); + List name = List.of("양재서", "이진우", "안성윤", "최희진", "아우신얀", "강병준", "이의찬", "이수민", "이주원"); + + for (int i = 0; i < 100; ++i) { + User user = User.builder() + .username("jayang" + i) + .name(name.get(i % name.size())) + .password((i % 2 == 0) ? null : "password" + i) + .passwordUpdatedAt((i % 2 == 0) ? null : now()) + .profileVisibility(ProfileVisibility.PUBLIC) + .phone("010-1111-1" + String.format("%03d", i)) + .role(Role.USER) + .locked((i % 10 == 0)) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + + users.add(user); + } + + return users; + } + + private List getRandomOauths(Collection users) { + List oauths = new ArrayList<>(users.size()); + + for (User user : users) { + Oauth oauth = Oauth.of(Provider.KAKAO, "providerId" + user.getId(), user); + oauths.add(oauth); + } + + return oauths; + } + + private void bulkInsertUser(Collection users) { + String sql = String.format(""" + INSERT INTO `%s` (username, name, password, password_updated_at, profile_image_url, phone, role, profile_visibility, locked, created_at, updated_at, account_book_notify, feed_notify, chat_notify, deleted_at) + VALUES (:username, :name, :password, :passwordUpdatedAt, :profileImageUrl, :phone, '1', '0', :locked, now(), now(), 1, 1, 1, :deletedAt) + """, USER_TABLE); + SqlParameterSource[] params = users.stream() + .map(BeanPropertySqlParameterSource::new) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + private void bulkInsertOauth(Collection oauths) { + String sql = String.format(""" + INSERT INTO `%s` (provider, oauth_id, user_id, created_at, deleted_at) + VALUES (1, :oauthId, :user.id, now(), NULL) + """, OAUTH_TABLE); + SqlParameterSource[] params = oauths.stream() + .map(BeanPropertySqlParameterSource::new) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + public record UserAndOauthInfo(Long userId, String username, String name, String phone, Long oauthId, + Provider provider) { + @Override + public String toString() { + return "UserAndOauthInfo{" + + "userId=" + userId + + ", username='" + username + '\'' + + ", name='" + name + '\'' + + ", phone='" + phone + '\'' + + ", oauthId=" + oauthId + + ", provider=" + provider + + '}'; + } + } + + @Setter + @Getter + public static class UserAndOauthInfoNotImmutable { + private Long userId; + private String username; + private String name; + private String phone; + private Long oauthId; + private Provider provider; + + public UserAndOauthInfoNotImmutable() { + } + + @Override + public String toString() { + return "UserAndOauthInfoNotImmutable{" + + "userId=" + userId + + ", username='" + username + '\'' + + ", name='" + name + '\'' + + ", phone='" + phone + '\'' + + ", oauthId=" + oauthId + + ", provider=" + provider + + '}'; + } + } +} diff --git a/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java new file mode 100644 index 000000000..35c008204 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java @@ -0,0 +1,124 @@ +package kr.co.pennyway.domain.domains.user.repository; + +import jakarta.persistence.EntityManager; +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserRdbService; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.util.AssertionErrors.*; + +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") +@ContextConfiguration(classes = {JpaConfig.class, UserRdbService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(JpaTestConfig.class) +@ActiveProfiles("test") +public class UserSoftDeleteTest extends ContainerMySqlTestConfig { + @Autowired + private UserRdbService userService; + + @Autowired + private EntityManager em; + + private User user; + + @BeforeEach + public void setUp() { + user = User.builder() + .username("test") + .name("pannyway") + .password("test") + .phone("01012345678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } + + @Test + @DisplayName("[명제] em.createNativeQuery를 사용해도 영속성 컨텍스트에 저장된 엔티티를 조회할 수 있다.") + @Transactional + public void findByEntityMangerUsingNativeQuery() { + // given + User savedUser = userService.createUser(user); + Long userId = savedUser.getId(); + + // when + Object foundUser = em.createNativeQuery("SELECT * FROM user WHERE id = ?", User.class) + .setParameter(1, userId) + .getSingleResult(); + + // then + assertNotNull("foundUser는 nll이 아니어야 한다.", foundUser); + assertEquals("동등성 보장에 성공해야 한다.", savedUser, foundUser); + assertTrue("동일성 보장에 성공해야 한다.", savedUser == foundUser); + System.out.println("foundUser = " + foundUser); + } + + @Test + @DisplayName("유저가 삭제되면 deletedAt이 업데이트된다.") + @Transactional + public void deleteUser() { + // given + User savedUser = userService.createUser(user); + Long userId = savedUser.getId(); + + // when + userService.deleteUser(savedUser); + Object deletedUser = em.createNativeQuery("SELECT * FROM user WHERE id = ?", User.class) + .setParameter(1, userId) + .getSingleResult(); + + // then + assertNotNull("유저가 삭제되면 deletedAt이 업데이트된다. ", ((User) deletedUser).getDeletedAt()); + System.out.println("deletedUser = " + deletedUser); + } + + @Test + @DisplayName("유저가 삭제되면 findBy와 existsBy로 조회할 수 없다.") + @Transactional + public void deleteUserAndFindById() { + // given + User savedUser = userService.createUser(user); + Long userId = savedUser.getId(); + + // when + userService.deleteUser(savedUser); + + // then + assertFalse("유저가 삭제되면 existsById로 조회할 수 없다. ", userService.isExistUser(userId)); + assertNull("유저가 삭제되면 findById로 조회할 수 없다. ", userService.readUser(userId).orElse(null)); + System.out.println("after delete: savedUser = " + savedUser); + } + + @Test + @DisplayName("유저가 삭제되지 않으면 findById로 조회할 수 있다.") + @Transactional + public void findUserNotDeleted() { + // given + User savedUser = userService.createUser(user); + Long userId = savedUser.getId(); + + // when + User foundUser = userService.readUser(userId).orElse(null); + + // then + assertNotNull("foundUser는 null이 아니어야 한다.", foundUser); + assertEquals("foundUser는 savedUser와 같아야 한다.", savedUser, foundUser); + System.out.println("foundUser = " + foundUser); + } +} diff --git a/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/member/service/ChatMemberCreateServiceTest.java b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/member/service/ChatMemberCreateServiceTest.java new file mode 100644 index 000000000..0c9148135 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/member/service/ChatMemberCreateServiceTest.java @@ -0,0 +1,110 @@ +package kr.co.pennyway.domain.member.service; + +import kr.co.pennyway.domain.common.fixture.ChatRoomFixture; +import kr.co.pennyway.domain.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Assertions; +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 org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Set; + +import static org.junit.Assert.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@Slf4j +@ExtendWith(MockitoExtension.class) +public class ChatMemberCreateServiceTest { + @Mock + private ChatMemberRepository chatMemberRepository; + private ChatMemberRdbService chatMemberService; + + private User user; + private ChatRoom chatRoom; + + @BeforeEach + void setUp() { + chatMemberService = new ChatMemberRdbService(chatMemberRepository); + user = UserFixture.GENERAL_USER.toUser(); + chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(); + } + + @Test + @DisplayName("이미 가입한 회원은 가입에 실패한다.") + void createMemberWhenAlreadyExist() { + // given + ChatMember chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + given(chatMemberRepository.findByChatRoom_IdAndUserId(chatRoom.getId(), user.getId())).willReturn(Set.of(chatMember)); + + // when + ChatMemberErrorException exception = assertThrows(ChatMemberErrorException.class, () -> chatMemberService.createMember(user, chatRoom)); + + // then + assertEquals(ChatMemberErrorCode.ALREADY_JOINED, exception.getBaseErrorCode(), "에러 코드는 ALREADY_JOINED 여야 한다."); + } + + @Test + @DisplayName("추방 당한 이력이 있는 회원은 가입에 실패한다.") + void createMemberWhenBanned() { + // given + ChatMember chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + chatMember.ban(); + given(chatMemberRepository.findByChatRoom_IdAndUserId(chatRoom.getId(), user.getId())).willReturn(Set.of(chatMember)); + + // when + ChatMemberErrorException exception = assertThrows(ChatMemberErrorException.class, () -> chatMemberService.createMember(user, chatRoom)); + + // then + assertEquals(ChatMemberErrorCode.BANNED, exception.getBaseErrorCode(), "에러 코드는 BANNED 여야 한다."); + } + + @Test + @DisplayName("가입한 이력이 없는 사용자는 가입에 성공한다.") + void createMemberWhenNotExist() { + + // given + given(chatMemberRepository.findByChatRoom_IdAndUserId(chatRoom.getId(), user.getId())).willReturn(Set.of()); + + ChatMember chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + given(chatMemberRepository.save(any(ChatMember.class))).willReturn(chatMember); + + // when + ChatMember result = chatMemberService.createMember(user, chatRoom); + + // then + Assertions.assertNotNull(result); + } + + @Test + @DisplayName("탈퇴한 이력이 있지만, 사유가 추방이 아니라면 가입에 성공한다.") + void createMemberWhenWithdrawn() { + // given + ChatMember original = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + ReflectionTestUtils.setField(original, "deletedAt", LocalDateTime.now()); + + ChatMember chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + given(chatMemberRepository.save(any(ChatMember.class))).willReturn(chatMember); + + // when + ChatMember result = chatMemberService.createMember(user, chatRoom); + + // then + Assertions.assertNotNull(result); + } +} diff --git a/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/member/service/ChatMemberNameSearchTest.java b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/member/service/ChatMemberNameSearchTest.java new file mode 100644 index 000000000..89fcae316 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/java/kr/co/pennyway/domain/member/service/ChatMemberNameSearchTest.java @@ -0,0 +1,133 @@ +package kr.co.pennyway.domain.member.service; + +import kr.co.pennyway.domain.common.fixture.ChatRoomFixture; +import kr.co.pennyway.domain.common.fixture.UserFixture; +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = {JpaConfig.class, ChatMemberRdbService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Import({JpaTestConfig.class}) +public class ChatMemberNameSearchTest extends ContainerMySqlTestConfig { + @Autowired + private ChatMemberRdbService chatMemberRdbService; + + @Autowired + private ChatMemberRepository chatMemberRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Test + @Transactional + @DisplayName("채팅방 멤버 단일 조회에 성공한다.") + public void successReadChatMember() { + // given + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity()); + ChatMember chatMember = chatMemberRepository.save(ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER)); + + // when + Optional result = chatMemberRdbService.readChatMember(user.getId(), chatRoom.getId()); + + // then + log.debug("result: {}", result); + assertNotNull(result.get()); + assertEquals(chatMember.getId(), result.get().getId()); + } + + @Test + @Transactional + @DisplayName("채팅방 관리자 조회에 성공한다.") + public void successReadAdmin() { + // given + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity()); + ChatMember chatMember = chatMemberRepository.save(ChatMember.of(user, chatRoom, ChatMemberRole.ADMIN)); + + // when + Optional result = chatMemberRdbService.readAdmin(chatRoom.getId()); + + // then + log.debug("result: {}", result); + assertNotNull(result.get()); + assertEquals(chatMember.getId(), result.get().id()); + } + + @Test + @Transactional + @DisplayName("멤버 아이디 리스트로 멤버 조회에 성공한다.") + public void successReadChatMembersByIdIn() { + // given + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity()); + ChatMember chatMember1 = chatMemberRepository.save(ChatMember.of(user, chatRoom, ChatMemberRole.ADMIN)); + + User user2 = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatMember chatMember2 = chatMemberRepository.save(ChatMember.of(user2, chatRoom, ChatMemberRole.MEMBER)); + + Set chatMemberIds = Set.of(chatMember1.getId(), chatMember2.getId()); + + // when + List result = chatMemberRdbService.readChatMembersByIdIn(chatRoom.getId(), chatMemberIds); + + // then + log.debug("result: {}", result); + assertEquals(2, result.size()); + } + + @Test + @Transactional + @DisplayName("사용자 아이디 리스트로 멤버 조회에 성공한다.") + public void successReadChatMembersByUserIds() { + // given + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity()); + ChatMember chatMember1 = chatMemberRepository.save(ChatMember.of(user, chatRoom, ChatMemberRole.ADMIN)); + + User user2 = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatMember chatMember2 = chatMemberRepository.save(ChatMember.of(user2, chatRoom, ChatMemberRole.MEMBER)); + + Set userIds = Set.of(user.getId(), user2.getId()); + + // when + List result = chatMemberRdbService.readChatMembersByUserIdIn(chatRoom.getId(), userIds); + + // then + log.debug("result: {}", result); + assertEquals(2, result.size()); + } +} diff --git a/pennyway-domain/domain-rdb/src/test/resources/logback-test.xml b/pennyway-domain/domain-rdb/src/test/resources/logback-test.xml new file mode 100644 index 000000000..198192602 --- /dev/null +++ b/pennyway-domain/domain-rdb/src/test/resources/logback-test.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/pennyway-domain/domain-redis/.gitignore b/pennyway-domain/domain-redis/.gitignore new file mode 100644 index 000000000..b63da4551 --- /dev/null +++ b/pennyway-domain/domain-redis/.gitignore @@ -0,0 +1,42 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/pennyway-domain/domain-redis/build.gradle b/pennyway-domain/domain-redis/build.gradle new file mode 100644 index 000000000..fbb252457 --- /dev/null +++ b/pennyway-domain/domain-redis/build.gradle @@ -0,0 +1,23 @@ +bootJar { enabled = false } +jar { enabled = true } + +dependencies { + implementation project(':pennyway-common') + + /* AOP */ + implementation 'org.springframework.boot:spring-boot-starter-aop' + + /* Jackson DataType */ + implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: '2.18.0' + + /* Redis */ + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + /* Redission */ + implementation 'org.redisson:redisson-spring-boot-starter:3.30.0' + + /* Test Containers */ + testImplementation "org.testcontainers:junit-jupiter:1.19.7" + testImplementation "org.testcontainers:testcontainers:1.19.7" + testImplementation "com.redis.testcontainers:testcontainers-redis-junit:1.6.4" +} \ No newline at end of file diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/RedisPackageLocation.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/RedisPackageLocation.java new file mode 100644 index 000000000..fe215996d --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/RedisPackageLocation.java @@ -0,0 +1,4 @@ +package kr.co.pennyway.domain; + +public interface RedisPackageLocation { +} \ No newline at end of file diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DistributedLock.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DistributedLock.java new file mode 100644 index 000000000..f6a13e644 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DistributedLock.java @@ -0,0 +1,40 @@ +package kr.co.pennyway.domain.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedLock { + /** + * Lock 이름 + */ + String key(); + + /** + * Lock 유지 시간 (초) + */ + TimeUnit timeUnit() default TimeUnit.SECONDS; + + /** + * Lock 유지 시간 (DEFAULT: 10초) + * LOCK 획득을 위해 waitTime만큼 대기한다. + */ + long waitTime() default 10L; + + /** + * Lock 임대 시간 (DEFAULT: 5초) + * LOCK 획득 이후 leaseTime이 지나면 LOCK을 해제한다. + */ + long leaseTime() default 5L; + + /** + * 동일한 트랜잭션에서 Lock을 획득할지 여부 (DEFAULT: true)
+ * - true : Propagation.REQUIRES_NEW 전파 방식을 사용하여 새로운 트랜잭션에서 Lock을 획득한다.
+ * - false : Propagation.MANDATORY 전파 방식을 사용하여 동일한 트랜잭션에서 Lock을 획득한다. + */ + boolean needNewTransaction() default true; +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DistributedLockPrefix.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DistributedLockPrefix.java new file mode 100644 index 000000000..02b38825a --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DistributedLockPrefix.java @@ -0,0 +1,8 @@ +package kr.co.pennyway.domain.common.annotation; + +/** + * 분산 락을 위한 prefix + */ +public class DistributedLockPrefix { + public static final String TARGET_AMOUNT_USER = "TargetAmount_User_"; +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java new file mode 100644 index 000000000..11783e25a --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("domainRedisCacheManager") +public @interface DomainRedisCacheManager { +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java new file mode 100644 index 000000000..12c232a3b --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("redisCacheConnectionFactory") +public @interface DomainRedisConnectionFactory { +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java new file mode 100644 index 000000000..5e8a4edf3 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("domainRedisTemplate") +public @interface DomainRedisTemplate { +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java new file mode 100644 index 000000000..05bdd3e42 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.common.aop; + +import org.aspectj.lang.ProceedingJoinPoint; + +public interface CallTransaction { + Object proceed(ProceedingJoinPoint joinPoint) throws Throwable; +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java new file mode 100644 index 000000000..77c7ed50b --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.aop; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CallTransactionFactory { + private final RedissonCallNewTransaction redissonCallNewTransaction; + private final RedissonCallSameTransaction redissonCallSameTransaction; + + public CallTransaction getCallTransaction(boolean isNewTransaction) { + return isNewTransaction ? redissonCallNewTransaction : redissonCallSameTransaction; + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java new file mode 100644 index 000000000..21d3995cc --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java @@ -0,0 +1,56 @@ +package kr.co.pennyway.domain.common.aop; + +import kr.co.pennyway.domain.common.annotation.DistributedLock; +import kr.co.pennyway.domain.common.util.CustomSpringELParser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; + +import java.lang.reflect.Method; + +/** + * {@link DistributedLock} 어노테이션을 사용한 메소드에 대한 분산 락 처리를 위한 AOP + */ +@Slf4j +@Aspect +@RequiredArgsConstructor +public class DistributedLockAspect { + private static final String REDISSON_LOCK_PREFIX = "LOCK:"; + + private final RedissonClient redissonClient; + private final CallTransactionFactory callTransactionFactory; + + @Around("@annotation(kr.co.pennyway.domain.common.annotation.DistributedLock)") + public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + DistributedLock distributedLock = method.getAnnotation(DistributedLock.class); + + String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key()); + RLock rLock = redissonClient.getLock(key); + + try { + boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); + if (!available) { + return false; + } + log.info("{} : Redisson Lock 진입 : {} {}", Thread.currentThread().getId(), method.getName(), key); + + return callTransactionFactory.getCallTransaction(distributedLock.needNewTransaction()).proceed(joinPoint); + } catch (InterruptedException e) { + throw new InterruptedException("Failed to acquire lock: " + key); + } finally { + try { + log.info("{} : Redisson Lock 해제 : {} {}", Thread.currentThread().getId(), method.getName(), key); + rLock.unlock(); + } catch (IllegalMonitorStateException ignored) { + log.error("Redisson lock is already unlocked: {} {}", method.getName(), key); + } + } + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java new file mode 100644 index 000000000..ba28b682e --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.common.aop; + +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +public class RedissonCallNewTransaction implements CallTransaction { + /** + * 다른 트랜잭션이 실행 중인 경우에도 새로운 트랜잭션을 생성하여 이 메서드를 실행한다. + * 동시성 환경에서 데이터 정합성을 보장하기 위해 트랜잭션 커밋 이후 락이 해제된다. + */ + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java new file mode 100644 index 000000000..af6148eea --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.common.aop; + +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +public class RedissonCallSameTransaction implements CallTransaction { + /** + * 기존 트랜잭션 내에서 이 메서드를 실행하며, 새로운 트랜잭션을 생성하지 않는다. + * 트랜잭션이 활성화되어 있지 않으면 예외를 발생시킨다. + */ + @Override + @Transactional(propagation = Propagation.MANDATORY, timeout = 2) + public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayRedisDomainConfig.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayRedisDomainConfig.java new file mode 100644 index 000000000..7fb335bfa --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayRedisDomainConfig.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.domain.common.importer; + +import org.springframework.context.annotation.Import; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Import(PennywayRedisDomainConfigImportSelector.class) +public @interface EnablePennywayRedisDomainConfig { + PennywayRedisDomainConfigGroup[] value(); +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRedisDomainConfig.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRedisDomainConfig.java new file mode 100644 index 000000000..214dbd37a --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRedisDomainConfig.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.common.importer; + +/** + * Pennyway RDS Domain의 Configurations를 나타내는 Marker Interface + */ +public interface PennywayRedisDomainConfig { +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRedisDomainConfigGroup.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRedisDomainConfigGroup.java new file mode 100644 index 000000000..7fe877a1f --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRedisDomainConfigGroup.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.importer; + +import kr.co.pennyway.domain.config.RedissonConfig; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PennywayRedisDomainConfigGroup { + REDISSON_INFRA(RedissonConfig.class); + + private final Class configClass; +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRedisDomainConfigImportSelector.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRedisDomainConfigImportSelector.java new file mode 100644 index 000000000..5528b39ea --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/importer/PennywayRedisDomainConfigImportSelector.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.common.importer; + +import kr.co.pennyway.common.util.MapUtils; +import org.springframework.context.annotation.DeferredImportSelector; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.lang.NonNull; + +import java.util.Arrays; +import java.util.Map; + +public class PennywayRedisDomainConfigImportSelector implements DeferredImportSelector { + @NonNull + @Override + public String[] selectImports(@NonNull AnnotationMetadata metadata) { + return Arrays.stream(getGroups(metadata)) + .map(v -> v.getConfigClass().getName()) + .toArray(String[]::new); + } + + private PennywayRedisDomainConfigGroup[] getGroups(AnnotationMetadata metadata) { + Map attributes = metadata.getAnnotationAttributes(EnablePennywayRedisDomainConfig.class.getName()); + return (PennywayRedisDomainConfigGroup[]) MapUtils.getObject(attributes, "value", new PennywayRedisDomainConfigGroup[]{}); + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java new file mode 100644 index 000000000..e202b5ac5 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.domain.common.util; + +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +/** + * Spring Expression Language (SpEL)을 사용한 커스텀 EL 파서 + */ +public class CustomSpringELParser { + /** + * SpEL을 사용하여 동적으로 값을 평가한다. + * + * @param parameterNames : 메서드 파라미터 이름 + * @param args : 메서드 파라미터 값 + * @param key : SpEL 표현식 + * @return : 평가된 값 + */ + public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) { + ExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + // 메서드 파라미터 이름과 값을 SpEL 컨텍스트에 변수로 설정 + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + return parser.parseExpression(key).getValue(context, Object.class); + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/config/LettuceConfig.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/config/LettuceConfig.java new file mode 100644 index 000000000..a9456cdd7 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/config/LettuceConfig.java @@ -0,0 +1,88 @@ +package kr.co.pennyway.domain.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.domain.RedisPackageLocation; +import kr.co.pennyway.domain.common.annotation.DomainRedisCacheManager; +import kr.co.pennyway.domain.common.annotation.DomainRedisConnectionFactory; +import kr.co.pennyway.domain.common.annotation.DomainRedisTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import java.time.Duration; + +@Configuration +@EnableRedisRepositories(basePackageClasses = RedisPackageLocation.class) +@EnableTransactionManagement +public class LettuceConfig { + private final String host; + private final int port; + private final String password; + + public LettuceConfig( + @Value("${spring.data.redis.host}") String host, + @Value("${spring.data.redis.port}") int port, + @Value("${spring.data.redis.password}") String password + ) { + this.host = host; + this.port = port; + this.password = password; + } + + @Bean + @DomainRedisConnectionFactory + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); + config.setPassword(password); + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder().build(); + + LettuceConnectionFactory factory = new LettuceConnectionFactory(config, clientConfig); + factory.start(); + + return factory; + } + + @Bean + @Primary + @DomainRedisTemplate + public RedisTemplate stringKeyRedisTemplate(ObjectMapper redisObjectMapper) { + RedisTemplate template = new RedisTemplate<>(); + + template.setConnectionFactory(redisConnectionFactory()); + + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper)); + return template; + } + + @Bean + @DomainRedisCacheManager + public RedisCacheManager redisCacheManager(@DomainRedisConnectionFactory RedisConnectionFactory cf) { + RedisCacheConfiguration redisCacheConfiguration = + RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new StringRedisSerializer())) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer())) + .entryTtl(Duration.ofHours(1L)); + + return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf) + .cacheDefaults(redisCacheConfiguration) + .build(); + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java new file mode 100644 index 000000000..3bee28490 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedisConfig { + @Bean + public ObjectMapper redisObjectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true); + objectMapper.registerModule(new JavaTimeModule()); + + return objectMapper; + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java new file mode 100644 index 000000000..75f1ddf96 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java @@ -0,0 +1,58 @@ +package kr.co.pennyway.domain.config; + +import kr.co.pennyway.domain.common.aop.CallTransactionFactory; +import kr.co.pennyway.domain.common.aop.DistributedLockAspect; +import kr.co.pennyway.domain.common.aop.RedissonCallNewTransaction; +import kr.co.pennyway.domain.common.aop.RedissonCallSameTransaction; +import kr.co.pennyway.domain.common.importer.PennywayRedisDomainConfig; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; + +public class RedissonConfig implements PennywayRedisDomainConfig { + private static final String REDISSON_HOST_PREFIX = "redis://"; + private final String host; + private final int port; + private final String password; + + public RedissonConfig( + @Value("${spring.data.redis.host}") String host, + @Value("${spring.data.redis.port}") int port, + @Value("${spring.data.redis.password}") String password + ) { + this.host = host; + this.port = port; + this.password = password; + } + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress(REDISSON_HOST_PREFIX + host + ":" + port) + .setPassword(password); + return Redisson.create(config); + } + + @Bean + public RedissonCallNewTransaction redissonCallNewTransaction() { + return new RedissonCallNewTransaction(); + } + + @Bean + public RedissonCallSameTransaction redissonCallSameTransaction() { + return new RedissonCallSameTransaction(); + } + + @Bean + public CallTransactionFactory callTransactionFactory(RedissonCallNewTransaction redissonCallNewTransaction, RedissonCallSameTransaction redissonCallSameTransaction) { + return new CallTransactionFactory(redissonCallNewTransaction, redissonCallSameTransaction); + } + + @Bean + public DistributedLockAspect distributedLockAspect(RedissonClient redissonClient, CallTransactionFactory callTransactionFactory) { + return new DistributedLockAspect(redissonClient, callTransactionFactory); + } +} \ No newline at end of file diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheRepository.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheRepository.java new file mode 100644 index 000000000..a080ceba4 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheRepository.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.domains.chatstatus.repository; + +import java.util.Optional; + +public interface ChatMessageStatusCacheRepository { + /** + * 캐시 데이터에서 마지막으로 읽은 메시지 ID를 조회합니다. + */ + Optional findLastReadMessageId(Long userId, Long chatRoomId); + + /** + * 캐시 데이터에 마지막으로 읽은 메시지 ID를 저장합니다. + */ + void saveLastReadMessageId(Long userId, Long chatRoomId, Long messageId); + + /** + * 캐시 데이터를 삭제합니다. + */ + void deleteLastReadMessageId(Long userId, Long chatRoomId); +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheRepositoryImpl.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheRepositoryImpl.java new file mode 100644 index 000000000..48b9f0ec3 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/chatstatus/repository/ChatMessageStatusCacheRepositoryImpl.java @@ -0,0 +1,51 @@ +package kr.co.pennyway.domain.domains.chatstatus.repository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.util.Optional; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class ChatMessageStatusCacheRepositoryImpl implements ChatMessageStatusCacheRepository { + private static final String CACHE_KEY_PREFIX = "chat:last_read:"; + private static final Duration CACHE_TTL = Duration.ofHours(1); + + private final RedisTemplate redisTemplate; + + @Override + public Optional findLastReadMessageId(Long userId, Long chatRoomId) { + String value = redisTemplate.opsForValue().get(formatCacheKey(userId, chatRoomId)); + return Optional.ofNullable(value).map(Long::parseLong); + } + + @Override + public void saveLastReadMessageId(Long userId, Long chatRoomId, Long messageId) { + try { + String key = formatCacheKey(userId, chatRoomId); + String currentValue = redisTemplate.opsForValue().get(key); + + if (currentValue != null && Long.parseLong(currentValue) >= messageId) { + return; + } + + redisTemplate.opsForValue().set(key, messageId.toString()); + redisTemplate.expire(key, CACHE_TTL); + } catch (Exception e) { + log.error("Failed to cache message status: userId={}, roomId={}, messageId={}", userId, chatRoomId, messageId, e); + } + } + + @Override + public void deleteLastReadMessageId(Long userId, Long chatRoomId) { + redisTemplate.delete(formatCacheKey(userId, chatRoomId)); + } + + private String formatCacheKey(Long userId, Long chatRoomId) { + return CACHE_KEY_PREFIX + chatRoomId + ":" + userId; + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusRedisService.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusRedisService.java new file mode 100644 index 000000000..c2d258513 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/chatstatus/service/ChatMessageStatusRedisService.java @@ -0,0 +1,37 @@ +package kr.co.pennyway.domain.domains.chatstatus.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.chatstatus.repository.ChatMessageStatusCacheRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatMessageStatusRedisService { + private final ChatMessageStatusCacheRepository chatMessageStatusCacheRepository; + + public Optional readLastReadMessageId(Long userId, Long chatRoomId) { + return chatMessageStatusCacheRepository.findLastReadMessageId(userId, chatRoomId); + } + + public void saveLastReadMessageId(Long userId, Long chatRoomId, Long messageId) { + validateInputs(userId, chatRoomId, messageId); + + chatMessageStatusCacheRepository.saveLastReadMessageId(userId, chatRoomId, messageId); + } + + private void validateInputs(Long userId, Long chatRoomId, Long lastReadMessageId) { + if (userId == null || userId <= 0) { + throw new IllegalArgumentException("Invalid userId: " + userId); + } + if (chatRoomId == null || chatRoomId <= 0) { + throw new IllegalArgumentException("Invalid chatRoomId: " + chatRoomId); + } + if (lastReadMessageId == null || lastReadMessageId <= 0) { + throw new IllegalArgumentException("Invalid lastReadMessageId: " + lastReadMessageId); + } + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/forbidden/domain/ForbiddenToken.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/forbidden/domain/ForbiddenToken.java new file mode 100644 index 000000000..058f95a86 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/forbidden/domain/ForbiddenToken.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.domain.domains.forbidden.domain; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash("forbiddenToken") +public class ForbiddenToken { + @Id + private final String accessToken; + private final Long userId; + @TimeToLive + private final long ttl; + + @Builder + private ForbiddenToken(String accessToken, Long userId, long ttl) { + this.accessToken = accessToken; + this.userId = userId; + this.ttl = ttl; + } + + public static ForbiddenToken of(String accessToken, Long userId, long ttl) { + return new ForbiddenToken(accessToken, userId, ttl); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ForbiddenToken that)) return false; + return accessToken.equals(that.accessToken) && userId.equals(that.userId); + } + + @Override + public int hashCode() { + int result = accessToken.hashCode(); + result = ((1 << 5) - 1) * result + userId.hashCode(); + return result; + } +} \ No newline at end of file diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/forbidden/repository/ForbiddenTokenRepository.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/forbidden/repository/ForbiddenTokenRepository.java new file mode 100644 index 000000000..63892ddca --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/forbidden/repository/ForbiddenTokenRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.domains.forbidden.repository; + +import kr.co.pennyway.domain.domains.forbidden.domain.ForbiddenToken; +import org.springframework.data.repository.CrudRepository; + +public interface ForbiddenTokenRepository extends CrudRepository { +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/forbidden/service/ForbiddenTokenRedisService.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/forbidden/service/ForbiddenTokenRedisService.java new file mode 100644 index 000000000..87ab9bb13 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/forbidden/service/ForbiddenTokenRedisService.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.domain.domains.forbidden.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.forbidden.domain.ForbiddenToken; +import kr.co.pennyway.domain.domains.forbidden.repository.ForbiddenTokenRepository; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Slf4j +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@DomainService +public class ForbiddenTokenRedisService { + private final ForbiddenTokenRepository forbiddenTokenRepository; + + /** + * 토큰을 블랙 리스트에 등록합니다. + * + * @param accessToken String : 블랙 리스트에 등록할 액세스 토큰 + * @param userId Long : 블랙 리스트에 등록할 유저 아이디 + * @param expiresAt LocalDateTime : 블랙 리스트에 등록할 토큰의 만료 시간 (등록할 access token의 만료시간을 추출한 값) + */ + public void createForbiddenToken(String accessToken, Long userId, LocalDateTime expiresAt) { + final LocalDateTime now = LocalDateTime.now(); + final long timeToLive = Duration.between(now, expiresAt).toSeconds(); + + log.info("forbidden token ttl : {}", timeToLive); + + ForbiddenToken forbiddenToken = ForbiddenToken.of(accessToken, userId, timeToLive); + forbiddenTokenRepository.save(forbiddenToken); + log.info("forbidden token registered. about User : {}", forbiddenToken.getUserId()); + } + + /** + * 토큰이 블랙 리스트에 등록되어 있는지 확인합니다. + * + * @return : 블랙 리스트에 등록되어 있으면 true, 아니면 false + */ + public boolean isForbidden(String accessToken) { + return forbiddenTokenRepository.existsById(accessToken); + } +} \ No newline at end of file diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/domain/ChatMessage.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/domain/ChatMessage.java new file mode 100644 index 000000000..6f2d7445d --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/domain/ChatMessage.java @@ -0,0 +1,54 @@ +package kr.co.pennyway.domain.domains.message.domain; + +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType; +import kr.co.pennyway.domain.domains.message.type.MessageContentType; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.redis.core.RedisHash; + +import java.time.LocalDateTime; + +/** + * 채팅 메시지를 표현하는 클래스입니다. + * Redis에 저장되는 채팅 메시지의 기본 단위입니다. + */ +@Getter +@RedisHash(value = "chatroom") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatMessage { + private Long chatRoomId; + private Long chatId; + private String content; + private MessageContentType contentType; + private MessageCategoryType categoryType; + private LocalDateTime createdAt; + private LocalDateTime deletedAt; + private Long sender; + + protected ChatMessage(ChatMessageBuilder builder) { + this.chatRoomId = builder.getChatRoomId(); + this.chatId = builder.getChatId(); + this.content = builder.getContent(); + this.contentType = builder.getContentType(); + this.categoryType = builder.getCategoryType(); + this.createdAt = LocalDateTime.now(); + this.deletedAt = null; + this.sender = builder.getSender(); + } + + + @Override + public String toString() { + return "ChatMessage{" + + "chatRoomId='" + chatRoomId + '\'' + + ", chatId='" + chatId + '\'' + + ", content='" + content + '\'' + + ", contentType='" + contentType + '\'' + + ", categoryType='" + categoryType + '\'' + + ", createdAt='" + createdAt + '\'' + + ", deletedAt='" + deletedAt + '\'' + + ", sender='" + sender + '\'' + + '}'; + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/domain/ChatMessageBuilder.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/domain/ChatMessageBuilder.java new file mode 100644 index 000000000..d8f066bc5 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/domain/ChatMessageBuilder.java @@ -0,0 +1,229 @@ +package kr.co.pennyway.domain.domains.message.domain; + +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType; +import kr.co.pennyway.domain.domains.message.type.MessageContentType; +import org.springframework.lang.NonNull; + +import java.util.Objects; + +/** + * 채팅 메시지 생성을 위한 Step Builder입니다. + * 필수 필드들을 순차적으로 설정하도록 강제하여 객체 생성의 안정성을 보장합니다. + * + *

사용 예시: + *

+ * ChatMessage message = ChatMessage.builder()
+ *     .chatRoomId(123L)
+ *     .chatId(456L)
+ *     .content("Hello")
+ *     .contentType(MessageContentType.TEXT)
+ *     .categoryType(MessageCategoryType.NORMAL)
+ *     .sender(789L)
+ *     .build();
+ * 
+ */ +public final class ChatMessageBuilder { + private Long chatRoomId; + private Long chatId; + private String content; + private MessageContentType contentType; + private MessageCategoryType categoryType; + private long sender; + + private ChatMessageBuilder() { + } + + /** + * ChatMessage 빌더의 시작점입니다. + * + * @return 채팅방 ID 설정 단계 + */ + public static ChatRoomIdStep builder() { + return new Steps(); + } + + Long getChatRoomId() { + return chatRoomId; + } + + Long getChatId() { + return chatId; + } + + String getContent() { + return content; + } + + MessageContentType getContentType() { + return contentType; + } + + MessageCategoryType getCategoryType() { + return categoryType; + } + + long getSender() { + return sender; + } + + /** + * 채팅방 ID 설정 단계입니다. + * 채팅 메시지가 속한 채팅방의 ID를 지정합니다. + */ + public interface ChatRoomIdStep { + /** + * 채팅방 ID를 설정합니다. + * + * @param chatRoomId 채팅방 ID + * @return 채팅 메시지 ID 설정 단계 + * @throws NullPointerException chatRoomId가 null인 경우 + */ + ChatIdStep chatRoomId(Long chatRoomId); + } + + /** + * 채팅 메시지 ID 설정 단계입니다. + * 개별 채팅 메시지를 식별하기 위한 ID를 지정합니다. + */ + public interface ChatIdStep { + /** + * 채팅 메시지 ID를 설정합니다. + * + * @param chatId 채팅 메시지 ID + * @return 메시지 내용 설정 단계 + * @throws NullPointerException chatId가 null인 경우 + */ + ContentStep chatId(Long chatId); + } + + /** + * 메시지 내용 설정 단계입니다. + * 채팅 메시지의 실제 내용을 지정합니다. + */ + public interface ContentStep { + /** + * 메시지 내용을 설정합니다. + * + * @param content 메시지 내용 + * @return 메시지 타입 설정 단계 + * @throws NullPointerException content가 null인 경우 + * @throws IllegalArgumentException content가 5000자를 초과하는 경우 + */ + ContentTypeStep content(String content); + } + + /** + * 메시지 타입 설정 단계입니다. + * 메시지의 형식(텍스트, 이미지, 파일 등)을 지정합니다. + */ + public interface ContentTypeStep { + /** + * 메시지 타입을 설정합니다. + * + * @param contentType 메시지 타입 + * @return 메시지 카테고리 설정 단계 + * @throws NullPointerException contentType이 null인 경우 + */ + CategoryTypeStep contentType(MessageContentType contentType); + } + + /** + * 메시지 카테고리 설정 단계입니다. + * 메시지의 종류(일반, 시스템 등)를 지정합니다. + */ + public interface CategoryTypeStep { + /** + * 메시지 카테고리를 설정합니다. + * + * @param categoryType 메시지 카테고리 + * @return 발신자 설정 단계 + * @throws NullPointerException categoryType이 null인 경우 + */ + SenderStep categoryType(MessageCategoryType categoryType); + } + + /** + * 발신자 설정 단계입니다. + * 메시지를 보낸 사용자의 ID를 지정합니다. + */ + public interface SenderStep { + /** + * 발신자 ID를 설정합니다. + * + * @param sender 발신자 ID + * @return 빌드 단계 + */ + BuildStep sender(Long sender); + } + + /** + * 최종 빌드 단계입니다. + * 모든 필수 필드가 설정된 후 ChatMessage 객체를 생성합니다. + */ + public interface BuildStep { + /** + * 설정된 값들을 사용하여 ChatMessage 객체를 생성합니다. + * + * @return 생성된 ChatMessage 객체 + */ + ChatMessage build(); + } + + private static class Steps implements + ChatRoomIdStep, + ChatIdStep, + ContentStep, + ContentTypeStep, + CategoryTypeStep, + SenderStep, + BuildStep { + + private final ChatMessageBuilder builder = new ChatMessageBuilder(); + + @Override + public ChatIdStep chatRoomId(@NonNull final Long chatRoomId) { + builder.chatRoomId = Objects.requireNonNull(chatRoomId, "chatRoomId must not be null"); + return this; + } + + @Override + public ContentStep chatId(@NonNull final Long chatId) { + builder.chatId = Objects.requireNonNull(chatId, "chatId must not be null"); + return this; + } + + @Override + public ContentTypeStep content(@NonNull final String content) { + builder.content = Objects.requireNonNull(content, "content must not be null"); + + if (content.length() > 5000) { + throw new IllegalArgumentException("content length must be less than or equal to 5000"); + } + + return this; + } + + @Override + public CategoryTypeStep contentType(@NonNull final MessageContentType contentType) { + builder.contentType = Objects.requireNonNull(contentType, "contentType must not be null"); + return this; + } + + @Override + public SenderStep categoryType(@NonNull final MessageCategoryType categoryType) { + builder.categoryType = Objects.requireNonNull(categoryType, "categoryType must not be null"); + return this; + } + + @Override + public BuildStep sender(@NonNull final Long sender) { + builder.sender = sender; + return this; + } + + @Override + public ChatMessage build() { + return new ChatMessage(builder); + } + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/repository/ChatMessageRepository.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/repository/ChatMessageRepository.java new file mode 100644 index 000000000..4aa53c989 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/repository/ChatMessageRepository.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.domain.domains.message.repository; + +import com.fasterxml.jackson.core.JsonProcessingException; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import org.springframework.data.domain.SliceImpl; + +import java.util.List; + +public interface ChatMessageRepository { + /** + * 채팅 메시지를 Redis에 저장합니다. + * 메시지는 JSON 형태로 직렬화되어 저장되며, TSID를 score로 사용하여 정렬됩니다. + * + * @param message {@link ChatMessage}: 저장할 채팅 메시지 + * @throws JsonProcessingException JSON 직렬화에 실패한 경우 + */ + ChatMessage save(ChatMessage message); + + /** + * 채팅방의 최근 메시지를 조회합니다. + * 메시지는 시간 순으로 정렬되어 반환됩니다. + * + * @param roomId Long: 채팅방 ID + * @param limit int: 조회할 메시지 개수 + * @return 최근 메시지 목록 + */ + List findRecentMessages(Long roomId, int limit); + + /** + * 특정 메시지 ID 이전의 메시지들을 페이징하여 조회합니다. + * TSID를 기준으로 정렬된 결과를 반환하며, lastMessageId에 해당하는 메시지는 포함되지 않습니다. + * 만약, lastMessageId에 해당하는 메시지가 필요한 경우 인자는 lastMessageId + 1로 설정해야 합니다. + * + * @param roomId Long: 채팅방 ID + * @param lastMessageId Long: 마지막으로 조회한 메시지의 TSID + * @param size int: 조회할 메시지 개수 + * @return 페이징된 메시지 목록과 다음 페이지 존재 여부 + */ + SliceImpl findMessagesBefore(Long roomId, Long lastMessageId, int size); + + /** + * 사용자가 마지막으로 읽은 메시지 이후의 안 읽은 메시지 개수를 조회합니다. + * + * @param roomId 채팅방 ID + * @param lastReadMessageId 사용자가 마지막으로 읽은 메시지의 TSID. 이 값이 0일 경우 모든 메시지 개수를 조회합니다. + * @return 안 읽은 메시지 개수 + * @throws IllegalArgumentException lastReadMessageId가 null이거나 음수인 경우 + */ + Long countUnreadMessages(Long roomId, Long lastReadMessageId); +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/repository/ChatMessageRepositoryImpl.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/repository/ChatMessageRepositoryImpl.java new file mode 100644 index 000000000..aadf7227d --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/repository/ChatMessageRepositoryImpl.java @@ -0,0 +1,142 @@ +package kr.co.pennyway.domain.domains.message.repository; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Range; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.redis.connection.Limit; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class ChatMessageRepositoryImpl implements ChatMessageRepository { + private static final int COUNTER_DIGITS = 4; + private static final String SEPARATOR = "|"; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public ChatMessage save(ChatMessage message) { + try { + String messageJson = objectMapper.writeValueAsString(message); + String chatRoomKey = getChatRoomKey(message.getChatRoomId()); + + String tsidKey = formatTsidKey(message.getChatId()); + + redisTemplate.opsForZSet().add(chatRoomKey, tsidKey + SEPARATOR + messageJson, 0); + } catch (JsonProcessingException e) { + log.error("Failed to save chat message: {}", message, e); + throw new RuntimeException("Failed to save chat message", e); + } + + return message; + } + + @Override + public List findRecentMessages(Long roomId, int limit) { + String chatRoomKey = getChatRoomKey(roomId); + + Set messageJsonSet = redisTemplate.opsForZSet().reverseRangeByLex(chatRoomKey, Range.unbounded(), Limit.limit().count(limit)); + + return convertToMessages(messageJsonSet); + } + + @Override + public SliceImpl findMessagesBefore(Long roomId, Long lastMessageId, int size) { + String chatRoomKey = getChatRoomKey(roomId); + String tsidKey = formatTsidKey(lastMessageId); + + Set messageJsonSet = redisTemplate.opsForZSet().reverseRangeByLex( + chatRoomKey, + Range.of(Range.Bound.unbounded(), Range.Bound.exclusive(tsidKey)), + Limit.limit().count(size + 1) + ); + List messages = convertToMessages(messageJsonSet); + + boolean hasNext = messages.size() > size; + + if (hasNext) { + messages = messages.subList(0, size); + } + + return new SliceImpl<>(messages, PageRequest.of(0, size), hasNext); + } + + @Override + public Long countUnreadMessages(Long roomId, Long lastReadMessageId) { + if (lastReadMessageId == null || lastReadMessageId < 0) { + throw new IllegalArgumentException("lastReadMessageId must not be null"); + } + + if (lastReadMessageId == 0L) { + return redisTemplate.opsForZSet().zCard(getChatRoomKey(roomId)); + } + + String chatRoomKey = getChatRoomKey(roomId); + String tsidKey = formatTsidKey(lastReadMessageId); + + Long totalCount = redisTemplate.opsForZSet().lexCount(chatRoomKey, Range.of(Range.Bound.inclusive(tsidKey), Range.Bound.unbounded())); + + return totalCount > 0 ? totalCount - 1 : 0; + } + + /** + * JSON 문자열 집합을 ChatMessage 객체 리스트로 변환합니다. + * 변환 실패한 메시지는 무시됩니다. + * + * @param messageJsonSet JSON 문자열 집합 + * @return 변환된 ChatMessage 객체 리스트 + */ + private List convertToMessages(Set messageJsonSet) { + if (messageJsonSet == null || messageJsonSet.isEmpty()) { + return Collections.emptyList(); + } + + return messageJsonSet.stream() + .map(value -> { + try { + String json = value.substring(value.indexOf(SEPARATOR) + 1); + return objectMapper.readValue(json, ChatMessage.class); + } catch (JsonProcessingException e) { + log.error("Failed to parse chat message JSON: {}", value, e); + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + } + + /** + * 채팅방의 조회용 Redis key를 생성합니다. + * + * @param roomId 채팅방 ID + * @return 생성된 Redis key + */ + private String getChatRoomKey(Long roomId) { + return "chatroom:" + roomId + ":message"; + } + + /** + * TSID를 lexicographical sorting이 가능한 형태의 문자열로 변환 + * format: {timestamp부분:16진수}:{counter부분:4자리} + */ + private String formatTsidKey(long tsid) { + String tsidStr = String.valueOf(tsid); + + String timestamp = tsidStr.substring(0, tsidStr.length() - COUNTER_DIGITS); + String counter = tsidStr.substring(tsidStr.length() - COUNTER_DIGITS); + + return timestamp + ":" + counter; + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/service/ChatMessageRedisService.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/service/ChatMessageRedisService.java new file mode 100644 index 000000000..670a7700b --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/service/ChatMessageRedisService.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.domain.domains.message.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import kr.co.pennyway.domain.domains.message.repository.ChatMessageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Slice; + +import java.util.List; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatMessageRedisService { + private final ChatMessageRepository chatMessageRepository; + + public ChatMessage create(ChatMessage chatMessage) { + return chatMessageRepository.save(chatMessage); + } + + public List readRecentMessages(Long roomId, int limit) { + return chatMessageRepository.findRecentMessages(roomId, limit); + } + + public Slice readMessagesBefore(Long roomId, Long lastMessageId, int size) { + return chatMessageRepository.findMessagesBefore(roomId, lastMessageId, size); + } + + public Long countUnreadMessages(Long roomId, Long lastReadMessageId) { + return chatMessageRepository.countUnreadMessages(roomId, lastReadMessageId); + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/type/MessageCategoryType.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/type/MessageCategoryType.java new file mode 100644 index 000000000..971e8cd8a --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/type/MessageCategoryType.java @@ -0,0 +1,32 @@ +package kr.co.pennyway.domain.domains.message.type; + +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.stream.Stream; + +@RequiredArgsConstructor +public enum MessageCategoryType { + NORMAL("0", "NORMAL"), + SYSTEM("1", "SYSTEM"), + SHARE("2", "SHARE"); + + private static final Map stringToEnum = Stream.of(values()).collect(java.util.stream.Collectors.toMap(Object::toString, e -> e)); + private final String code; + private final String type; + + @JsonCreator + public static MessageCategoryType fromString(String type) { + return stringToEnum.get(type.toUpperCase()); + } + + public String getCode() { + return null; + } + + @Override + public String toString() { + return type; + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/type/MessageContentType.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/type/MessageContentType.java new file mode 100644 index 000000000..5b14c2aae --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/type/MessageContentType.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.domain.domains.message.type; + +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.stream.Stream; + +@RequiredArgsConstructor +public enum MessageContentType { + TEXT("0", "TEXT"), + IMAGE("1", "IMAGE"), + VIDEO("2", "VIDEO"), + FILE("3", "FILE"); + + private static final Map stringToEnum = Stream.of(values()).collect(java.util.stream.Collectors.toMap(Object::toString, e -> e)); + private final String code; + private final String type; + + @JsonCreator + public static MessageContentType fromString(String type) { + return stringToEnum.get(type.toUpperCase()); + } + + public String getCode() { + return code; + } + + @Override + public String toString() { + return type; + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/phone/repository/PhoneCodeRepository.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/phone/repository/PhoneCodeRepository.java new file mode 100644 index 000000000..88a353f4d --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/phone/repository/PhoneCodeRepository.java @@ -0,0 +1,37 @@ +package kr.co.pennyway.domain.domains.phone.repository; + +import kr.co.pennyway.domain.common.annotation.DomainRedisTemplate; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; + +@Repository +public class PhoneCodeRepository { + private final RedisTemplate redisTemplate; + + public PhoneCodeRepository(@DomainRedisTemplate RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public LocalDateTime save(String phone, String code, PhoneCodeKeyType codeType) { + LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(5); + redisTemplate.opsForValue().set(codeType.getPrefix() + ":" + phone, code, Duration.between(LocalDateTime.now(), expiresAt)); + return expiresAt; + } + + public String findCodeByPhone(String phone, PhoneCodeKeyType codeType) throws NullPointerException { + return Objects.requireNonNull(redisTemplate.opsForValue().get(codeType.getPrefix() + ":" + phone)).toString(); + } + + public void extendTimeToLeave(String phone, PhoneCodeKeyType codeType) { + redisTemplate.expire(codeType.getPrefix() + ":" + phone, Duration.ofMinutes(5)); + } + + public void delete(String phone, PhoneCodeKeyType codeType) { + redisTemplate.opsForValue().getAndDelete(codeType.getPrefix() + ":" + phone); + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/phone/service/PhoneCodeRedisService.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/phone/service/PhoneCodeRedisService.java new file mode 100644 index 000000000..b35217f56 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/phone/service/PhoneCodeRedisService.java @@ -0,0 +1,67 @@ +package kr.co.pennyway.domain.domains.phone.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.phone.repository.PhoneCodeRepository; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class PhoneCodeRedisService { + private final PhoneCodeRepository phoneCodeRepository; + + /** + * 휴대폰 번호와 코드를 저장한다. (5분간 유효) + *
+ * redis에 저장되는 key는 codeType:phone, value는 code이다. + * + * @param phone String : 휴대폰 번호 + * @param code String : 6자리 정수 코드 + * @param codeType {@link PhoneCodeKeyType} : 코드 타입 + * @return LocalDateTime : 만료 시간 + */ + public LocalDateTime create(String phone, String code, PhoneCodeKeyType codeType) { + return phoneCodeRepository.save(phone, code, codeType); + } + + /** + * 휴대폰 번호로 저장된 코드를 조회한다. + * + * @param phone String : 휴대폰 번호 + * @param codeType {@link PhoneCodeKeyType} : 코드 타입 + * @return String : 6자리 정수 코드 + * @throws IllegalArgumentException : 코드가 없을 경우 + */ + public String readByPhone(String phone, PhoneCodeKeyType codeType) throws IllegalArgumentException { + try { + return phoneCodeRepository.findCodeByPhone(phone, codeType); + } catch (NullPointerException e) { + log.error("{}:{}에 해당하는 키가 존재하지 않습니다.", phone, codeType); + throw new IllegalArgumentException(e); + } + } + + /** + * 휴대폰 번호로 저장된 데이터의 ttl을 5분으로 연장(롤백)한다. + * + * @param phone String : 휴대폰 번호 + * @param codeType {@link PhoneCodeKeyType} : 코드 타입 + */ + public void extendTimeToLeave(String phone, PhoneCodeKeyType codeType) { + phoneCodeRepository.extendTimeToLeave(phone, codeType); + } + + /** + * 휴대폰 번호로 저장된 코드를 삭제한다. + * + * @param phone String : 휴대폰 번호 + * @param codeType {@link PhoneCodeKeyType} : 코드 타입 + */ + public void delete(String phone, PhoneCodeKeyType codeType) { + phoneCodeRepository.delete(phone, codeType); + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/phone/type/PhoneCodeKeyType.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/phone/type/PhoneCodeKeyType.java new file mode 100644 index 000000000..6abadd859 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/phone/type/PhoneCodeKeyType.java @@ -0,0 +1,18 @@ +package kr.co.pennyway.domain.domains.phone.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PhoneCodeKeyType { + SIGN_UP("signUp"), + OAUTH_SIGN_UP_KAKAO("oauthSignUp:kakao"), + OAUTH_SIGN_UP_GOOGLE("oauthSignUp:google"), + OAUTH_SIGN_UP_APPLE("oauthSignUp:apple"), + FIND_USERNAME("username"), + FIND_PASSWORD("password"), + PHONE("phone"); + + private final String prefix; +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/domain/RefreshToken.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/domain/RefreshToken.java new file mode 100644 index 000000000..7d5abd428 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/domain/RefreshToken.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.domain.domains.refresh.domain; + +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +@RedisHash("refreshToken") +@Getter +@ToString(of = {"userId", "token", "ttl"}) +@EqualsAndHashCode(of = {"userId", "token"}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken { + @Id + private String id; + private Long userId; + private String deviceId; + private long ttl; + private String token; + + @Builder + private RefreshToken(Long userId, String deviceId, String token, long ttl) { + this.id = createId(userId, deviceId); + this.userId = userId; + this.deviceId = deviceId; + this.token = token; + this.ttl = ttl; + } + + public static RefreshToken of(Long userId, String deviceId, String token, long ttl) { + return RefreshToken.builder() + .userId(userId) + .deviceId(deviceId) + .token(token) + .ttl(ttl) + .build(); + } + + public static String createId(Long userId, String deviceId) { + return userId + ":" + deviceId; + } + + public void rotation(String token) { + this.token = token; + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/repository/RefreshTokenCustomRepository.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/repository/RefreshTokenCustomRepository.java new file mode 100644 index 000000000..bfce8112f --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/repository/RefreshTokenCustomRepository.java @@ -0,0 +1,5 @@ +package kr.co.pennyway.domain.domains.refresh.repository; + +public interface RefreshTokenCustomRepository { + void deleteAllByUserId(Long userId); +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/repository/RefreshTokenCustomRepositoryImpl.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/repository/RefreshTokenCustomRepositoryImpl.java new file mode 100644 index 000000000..8d868e714 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/repository/RefreshTokenCustomRepositoryImpl.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.domain.domains.refresh.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Set; + +@Repository +@RequiredArgsConstructor +public class RefreshTokenCustomRepositoryImpl implements RefreshTokenCustomRepository { + private final RedisTemplate redisTemplate; + + @Override + public void deleteAllByUserId(Long userId) { + String pattern = "refreshToken:" + userId + ":*"; + Set keys = redisTemplate.keys(pattern); + + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/repository/RefreshTokenRepository.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/repository/RefreshTokenRepository.java new file mode 100644 index 000000000..775cbd1f2 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/repository/RefreshTokenRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.domains.refresh.repository; + +import kr.co.pennyway.domain.domains.refresh.domain.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository, RefreshTokenCustomRepository { +} \ No newline at end of file diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/service/RefreshTokenRedisService.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/service/RefreshTokenRedisService.java new file mode 100644 index 000000000..1966eb02f --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/service/RefreshTokenRedisService.java @@ -0,0 +1,32 @@ +package kr.co.pennyway.domain.domains.refresh.service; + +import kr.co.pennyway.domain.domains.refresh.domain.RefreshToken; + +public interface RefreshTokenRedisService { + /** + * refresh token을 redis에 저장한다. + * + * @param refreshToken : {@link RefreshToken} + */ + void save(RefreshToken refreshToken); + + /** + * 사용자가 보낸 refresh token으로 기존 refresh token과 비교 검증 후, 새로운 refresh token으로 저장한다. + * + * @param userId : 토큰 주인 pk + * @param deviceId : 토큰 발급한 디바이스 + * @param oldRefreshToken : 사용자가 보낸 refresh token + * @param newRefreshToken : 교체할 refresh token + * @return {@link RefreshToken} + * @throws IllegalArgumentException : userId에 해당하는 refresh token이 없을 경우 + * @throws IllegalStateException : 요청한 토큰과 저장된 토큰이 다르다면 토큰이 탈취되었다고 판단하여 값 삭제 + */ + RefreshToken refresh(Long userId, String deviceId, String oldRefreshToken, String newRefreshToken) throws IllegalArgumentException, IllegalStateException; + + /** + * 사용자에게 할당된 모든 Device의 refresh token을 삭제한다. + * + * @param userId : 토큰 주인 pk + */ + void deleteAll(Long userId); +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/service/RefreshTokenRedisServiceImpl.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/service/RefreshTokenRedisServiceImpl.java new file mode 100644 index 000000000..dfd99561b --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/refresh/service/RefreshTokenRedisServiceImpl.java @@ -0,0 +1,70 @@ +package kr.co.pennyway.domain.domains.refresh.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.refresh.domain.RefreshToken; +import kr.co.pennyway.domain.domains.refresh.repository.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class RefreshTokenRedisServiceImpl implements RefreshTokenRedisService { + private final RefreshTokenRepository refreshTokenRepository; + + @Override + public void save(RefreshToken refreshToken) { + refreshTokenRepository.save(refreshToken); + log.debug("리프레시 토큰 저장 : {}", refreshToken); + } + + @Override + public RefreshToken refresh(Long userId, String deviceId, String oldRefreshToken, String newRefreshToken) throws IllegalArgumentException, IllegalStateException { + RefreshToken refreshToken = findOrElseThrow(userId, deviceId); + + validateToken(oldRefreshToken, refreshToken); + + refreshToken.rotation(newRefreshToken); + refreshTokenRepository.save(refreshToken); + + log.info("사용자 {}의 리프레시 토큰 갱신", userId); + return refreshToken; + } + + @Override + public void deleteAll(Long userId) { + refreshTokenRepository.deleteAllByUserId(userId); + log.info("사용자 {}의 리프레시 토큰 삭제", userId); + } + + private RefreshToken findOrElseThrow(Long userId, String deviceId) { + return refreshTokenRepository.findById(RefreshToken.createId(userId, deviceId)) + .orElseThrow(() -> new IllegalArgumentException("refresh token not found")); + } + + /** + * @param requestRefreshToken String : 사용자가 보낸 refresh token + * @param expectedRefreshToken String : Redis에 저장된 refresh token + * @throws IllegalStateException : 요청한 토큰과 저장된 토큰이 다르다면 토큰이 탈취되었다고 판단하여 값 삭제 + */ + private void validateToken(String requestRefreshToken, RefreshToken expectedRefreshToken) throws IllegalStateException { + if (isTakenAway(requestRefreshToken, expectedRefreshToken.getToken())) { + log.warn("리프레시 토큰 불일치(탈취). expected : {}, actual : {}", requestRefreshToken, expectedRefreshToken.getToken()); + refreshTokenRepository.deleteAllByUserId(expectedRefreshToken.getUserId()); + log.info("사용자 {}의 리프레시 토큰 삭제", expectedRefreshToken.getUserId()); + + throw new IllegalStateException("refresh token mismatched"); + } + } + + /** + * 토큰 탈취 여부 확인 + * + * @param requestRefreshToken String : 사용자가 보낸 refresh token + * @param expectedRefreshToken String : Redis에 저장된 refresh token + * @return boolean : 탈취되었다면 true, 아니면 false + */ + private boolean isTakenAway(String requestRefreshToken, String expectedRefreshToken) { + return !requestRefreshToken.equals(expectedRefreshToken); + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/domain/UserSession.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/domain/UserSession.java new file mode 100644 index 000000000..db0236978 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/domain/UserSession.java @@ -0,0 +1,173 @@ +package kr.co.pennyway.domain.domains.session.domain; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.co.pennyway.domain.domains.session.type.UserStatus; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; + +public class UserSession implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private Long userId; + private String deviceId; + private String deviceName; + private UserStatus status; + private Long currentChatRoomId; + private LocalDateTime lastActiveAt; + @JsonIgnore + private int hashCode; + + @JsonCreator + private UserSession( + @JsonProperty("userId") Long userId, + @JsonProperty("deviceId") String deviceId, + @JsonProperty("deviceName") String deviceName, + @JsonProperty("status") UserStatus status, + @JsonProperty("currentChatRoomId") Long currentChatRoomId, + @JsonProperty("lastActiveAt") LocalDateTime lastActiveAt + ) { + validate(userId, deviceId, deviceName, status, lastActiveAt); + + this.userId = userId; + this.deviceId = deviceId; + this.deviceName = deviceName; + this.status = status; + this.currentChatRoomId = currentChatRoomId; + this.lastActiveAt = lastActiveAt; + } + + /** + * 새로운 사용자 세션을 생성한다. + * 사용자의 상태는 ACTIVE_APP이며, 채팅방 관련 뷰룰 보고 있지 않음을 전제로 한다. + * 마지막 활동 시간은 현재 시간으로 설정된다. + */ + public static UserSession of(Long userId, String deviceId, String deviceName) { + return new UserSession(userId, deviceId, deviceName, UserStatus.ACTIVE_APP, -1L, LocalDateTime.now()); + } + + public Long getUserId() { + return userId; + } + + public String getDeviceId() { + return deviceId; + } + + public String getDeviceName() { + return deviceName; + } + + public UserStatus getStatus() { + return status; + } + + /** + * 사용자가 보고 있는 채팅방 ID를 반환한다. + * + * @return 사용자가 보고 있는 채팅방 ID. 채팅방을 보고 있지 않을 경우 -1을 반환한다. + */ + public Long getCurrentChatRoomId() { + if (!this.status.equals(UserStatus.ACTIVE_CHAT_ROOM)) { + return -1L; + } + + return currentChatRoomId; + } + + public LocalDateTime getLastActiveAt() { + return lastActiveAt; + } + + public void updateStatus(UserStatus status, Long currentChatRoomId) { + validate(userId, deviceId, deviceName, status, lastActiveAt); + + if (status.equals(UserStatus.ACTIVE_CHAT_ROOM) && (currentChatRoomId == null || currentChatRoomId <= 0)) { + throw new IllegalArgumentException("ACTIVE_CHAT_ROOM 상태에서 채팅방 ID는 null 혹은 0을 포함한 음수를 허용하지 않습니다."); + } + + this.status = status; + this.currentChatRoomId = currentChatRoomId; + updateLastActiveAt(); + } + + /** + * 사용자의 마지막 활동 시간을 현재 시간으로 갱신한다. + */ + public void updateLastActiveAt() { + this.lastActiveAt = LocalDateTime.now(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UserSession that = (UserSession) o; + return userId.equals(that.userId) && deviceId.equals(that.deviceId) && Objects.equals(currentChatRoomId, that.currentChatRoomId) && lastActiveAt.equals(that.lastActiveAt); + } + + @Override + public int hashCode() { + if (hashCode != -1) { + return hashCode; + } + + int result = userId.hashCode(); + result = 31 * result + deviceId.hashCode(); + result = 31 * result + lastActiveAt.hashCode(); + return hashCode = result; + } + + @Serial + private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException { + s.defaultReadObject(); + + // 가변 요소를 방어적으로 복사 + this.userId = Long.valueOf(userId); + this.deviceId = String.copyValueOf(deviceId.toCharArray()); + this.deviceName = String.copyValueOf(deviceName.toCharArray()); + this.status = UserStatus.valueOf(status.name()); + this.lastActiveAt = LocalDateTime.of(lastActiveAt.toLocalDate(), lastActiveAt.toLocalTime()); + this.currentChatRoomId = this.currentChatRoomId == null ? -1L : currentChatRoomId; + + // 불변식을 만족하는지 검사한다. + validate(userId, deviceId, deviceName, status, lastActiveAt); + } + + private void validate(Long userId, String deviceId, String deviceName, UserStatus status, LocalDateTime lastActiveAt) { + if (userId == null) { + throw new IllegalStateException("userId는 null일 수 없습니다."); + } + if (deviceId == null) { + throw new IllegalStateException("deviceId는 null일 수 없습니다."); + } + if (deviceName == null) { + throw new IllegalStateException("deviceName은 null일 수 없습니다."); + } + if (status == null) { + throw new IllegalStateException("status는 null일 수 없습니다."); + } + if (lastActiveAt == null) { + throw new IllegalStateException("lastActiveAt은 null일 수 없습니다."); + } + } + + @Override + public String toString() { + return "UserSession{" + + "userId='" + userId + '\'' + + ", deviceId='" + deviceId + '\'' + + ", deviceName='" + deviceName + '\'' + + ", status='" + status + '\'' + + ", currentChatRoomId='" + currentChatRoomId + '\'' + + ", lastActiveAt='" + lastActiveAt + '\'' + + '}'; + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/repository/SessionLuaScripts.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/repository/SessionLuaScripts.java new file mode 100644 index 000000000..2362e65f2 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/repository/SessionLuaScripts.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.domain.domains.session.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.script.RedisScript; + +import java.util.List; + +@RequiredArgsConstructor +public enum SessionLuaScripts { + SAVE( + "redis.call('HSET', KEYS[1], ARGV[1], ARGV[2]) " + + "return redis.call('HEXPIRE', KEYS[1], ARGV[3], 'FIELDS', '1', ARGV[1])", + List.class + ), + FIND( + "return redis.call('HGET', KEYS[1], ARGV[1])", + String.class + ), + FIND_ALL( + "return redis.call('HGETALL', KEYS[1])", + List.class + ), + GET_TTL( + "return redis.call('HTTL', KEYS[1], 'FIELDS', '1', ARGV[1])", + Long.class + ), + EXISTS( + "return redis.call('HEXISTS', KEYS[1], ARGV[1])", + Boolean.class + ), + RESET_TTL( + "return redis.call('HEXPIRE', KEYS[1], ARGV[2], 'FIELDS', '1', ARGV[1])", + Long.class + ), + DELETE( + "return redis.call('HDEL', KEYS[1], ARGV[1])", + Long.class + ); + + private final String script; + private final Class returnType; + + public RedisScript getScript() { + return RedisScript.of(script, (Class) returnType); + } + + public Class getReturnType() { + return (Class) returnType; + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/repository/UserSessionRepository.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/repository/UserSessionRepository.java new file mode 100644 index 000000000..638359186 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/repository/UserSessionRepository.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.session.repository; + +import kr.co.pennyway.domain.domains.session.domain.UserSession; + +import java.util.Map; +import java.util.Optional; + +public interface UserSessionRepository { + void save(Long userId, String hashKey, UserSession value); + + Optional findUserSession(Long userId, String hashKey); + + Map findAllUserSessions(Long userId); + + Long getSessionTtl(Long userId, String hashKey); + + boolean exists(Long userId, String hashKey); + + void resetSessionTtl(Long userId, String hashKey); + + void delete(Long userId, String hashKey); +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/repository/UserSessionRepositoryImpl.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/repository/UserSessionRepositoryImpl.java new file mode 100644 index 000000000..aae8ffaae --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/repository/UserSessionRepositoryImpl.java @@ -0,0 +1,112 @@ +package kr.co.pennyway.domain.domains.session.repository; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.domain.common.annotation.DomainRedisTemplate; +import kr.co.pennyway.domain.domains.session.domain.UserSession; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Repository +public class UserSessionRepositoryImpl implements UserSessionRepository { + private static final long ttlSeconds = 60 * 60 * 24 * 7; // 1주일 (초) + private final ObjectMapper objectMapper; + private final RedisTemplate redisTemplate; + + public UserSessionRepositoryImpl(@DomainRedisTemplate RedisTemplate redisTemplate, ObjectMapper objectMapper) { + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + } + + @Override + public void save(Long userId, String hashKey, UserSession value) { + executeScript(SessionLuaScripts.SAVE, userId, hashKey, serialize(value), ttlSeconds); + } + + @Override + public Optional findUserSession(Long userId, String hashKey) { + Object result = executeScript(SessionLuaScripts.FIND, userId, hashKey); + + return Optional.ofNullable(deserialize(result)); + } + + @Override + public Map findAllUserSessions(Long userId) { + List result = executeScript(SessionLuaScripts.FIND_ALL, userId); + + return deserializeMap(result); + } + + @Override + public Long getSessionTtl(Long userId, String hashKey) { + return executeScript(SessionLuaScripts.GET_TTL, userId, hashKey); + } + + @Override + public boolean exists(Long userId, String hashKey) { + return executeScript(SessionLuaScripts.EXISTS, userId, hashKey); + } + + @Override + public void resetSessionTtl(Long userId, String hashKey) { + executeScript(SessionLuaScripts.RESET_TTL, userId, hashKey, ttlSeconds); + } + + @Override + public void delete(Long userId, String hashKey) { + executeScript(SessionLuaScripts.DELETE, userId, hashKey); + } + + private String createKey(Long userId) { + return "user:" + userId; + } + + private T executeScript(SessionLuaScripts script, Long userId, Object... args) { + try { + return redisTemplate.execute( + script.getScript(), + List.of(createKey(userId)), + args + ); + } catch (Exception e) { + log.error("Error executing Redis script: {}", script.name(), e); + throw new RuntimeException("Failed to execute Redis operation", e); + } + } + + private String serialize(UserSession value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + log.error("Error serializing UserSession", e); + throw new RuntimeException("Failed to serialize UserSession", e); + } + } + + private UserSession deserialize(Object value) { + if (value == null) return null; + try { + return objectMapper.readValue((String) value, UserSession.class); + } catch (JsonProcessingException e) { + log.error("Error deserializing UserSession", e); + throw new RuntimeException("Failed to deserialize UserSession", e); + } + } + + private Map deserializeMap(List entries) { + Map result = new ConcurrentHashMap<>(); + for (int i = 0; i < entries.size(); i += 2) { + String key = (String) entries.get(i); + UserSession value = deserialize(entries.get(i + 1)); + result.put(key, value); + } + return result; + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/service/UserSessionRedisService.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/service/UserSessionRedisService.java new file mode 100644 index 000000000..3aae3c4e7 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/service/UserSessionRedisService.java @@ -0,0 +1,83 @@ +package kr.co.pennyway.domain.domains.session.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.session.domain.UserSession; +import kr.co.pennyway.domain.domains.session.repository.UserSessionRepository; +import kr.co.pennyway.domain.domains.session.type.UserStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class UserSessionRedisService { + private final UserSessionRepository userSessionRepository; + + public void create(Long userId, String deviceId, UserSession value) { + userSessionRepository.save(userId, deviceId, value); + } + + public Optional read(Long userId, String deviceId) { + return userSessionRepository.findUserSession(userId, deviceId); + } + + public Map readAll(Long userId) { + return userSessionRepository.findAllUserSessions(userId); + } + + public boolean isExists(Long userId, String deviceId) { + return userSessionRepository.exists(userId, deviceId); + } + + /** + * 사용자 세션의 상태를 변경합니다. + * {@link UserStatus#ACTIVE_CHAT_ROOM} 이외에 사용자 세션의 상태를 변경할 때 사용합니다. + * 사용자 세션의 chatRoomId는 -1로 설정됩니다. + * + * @throws IllegalArgumentException 사용자 세션 정보를 찾을 수 없을 때 발생합니다.fix + */ + public UserSession updateUserStatus(Long userId, String deviceId, UserStatus status) { + return updateUserStatus(userId, deviceId, -1L, status); + } + + /** + * 사용자 세션의 상태를 변경합니다. + * {@link UserStatus#ACTIVE_CHAT_ROOM}로 변경할 때 사용되며, chatRoomId는 null 혹은 0을 포함한 음수를 허용하지 않습니다. + * + * @throws IllegalArgumentException chatRoomId가 null 혹은 0을 포함한 음수일 때 발생합니다. 사용자 세션 정보를 찾을 수 없을 때도 발생합니다. + */ + public UserSession updateUserStatus(Long userId, String deviceId, Long chatRoomId) { + return updateUserStatus(userId, deviceId, chatRoomId, UserStatus.ACTIVE_CHAT_ROOM); + } + + private UserSession updateUserStatus(Long userId, String deviceId, Long chatRoomId, UserStatus status) { + UserSession userSession = userSessionRepository.findUserSession(userId, deviceId) + .orElseThrow(() -> new IllegalArgumentException("사용자 세션을 찾을 수 없습니다.")); + + userSession.updateStatus(status, chatRoomId); + userSessionRepository.save(userId, deviceId, userSession); + userSessionRepository.resetSessionTtl(userId, deviceId); + + return userSession; + } + + public Long getSessionTtl(Long userId, String deviceId) { + return userSessionRepository.getSessionTtl(userId, deviceId); + } + + public void resetSessionTtl(Long userId, String deviceId) { + UserSession userSession = userSessionRepository.findUserSession(userId, deviceId) + .orElseThrow(() -> new IllegalArgumentException("사용자 세션을 찾을 수 없습니다.")); + + userSession.updateLastActiveAt(); + userSessionRepository.save(userId, deviceId, userSession); + userSessionRepository.resetSessionTtl(userId, deviceId); + } + + public void delete(Long userId, String deviceId) { + userSessionRepository.delete(userId, deviceId); + } +} diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/type/UserStatus.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/type/UserStatus.java new file mode 100644 index 000000000..8574fd631 --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/session/type/UserStatus.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.domain.domains.session.type; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum UserStatus { + ACTIVE_APP("1", "앱 활성화"), + ACTIVE_CHAT_ROOM_LIST("2", "채팅방 리스트 뷰"), + ACTIVE_CHAT_ROOM("3", "채팅방 뷰"), + BACKGROUND("4", "백그라운드"), + INACTIVE("5", "비활성화"), + ; + + private final String code; + private final String type; + + public String getCode() { + return code; + } + + @Override + public String toString() { + return type; + } +} diff --git a/pennyway-domain/domain-redis/src/main/resources/application-domain-redis.yml b/pennyway-domain/domain-redis/src/main/resources/application-domain-redis.yml new file mode 100644 index 000000000..2fcc23b3f --- /dev/null +++ b/pennyway-domain/domain-redis/src/main/resources/application-domain-redis.yml @@ -0,0 +1,44 @@ +spring: + profiles: + group: + local: common + dev: common + + data.redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + + autoconfigure: + exclude: + - org.redisson.spring.starter.RedissonAutoConfigurationV2 + +--- +spring: + config: + activate: + on-profile: local + +logging: + level: + ROOT: INFO + org.hibernate: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + org.hibernate.sql: debug + org.hibernate.type: trace + com.zaxxer.hikari.HikariConfig: DEBUG + org.springframework.orm: TRACE + org.springframework.transaction: TRACE + com.zaxxer.hikari: TRACE + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: test \ No newline at end of file diff --git a/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/config/ContainerRedisTestConfig.java b/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/config/ContainerRedisTestConfig.java new file mode 100644 index 000000000..40f71715a --- /dev/null +++ b/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/config/ContainerRedisTestConfig.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.config; + +import org.junit.jupiter.api.DisplayName; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +@DisplayName("Container Redis 설정") +public abstract class ContainerRedisTestConfig { + private static final String REDIS_CONTAINER_NAME = "redis:7.4"; + private static final GenericContainer REDIS_CONTAINER; + + static { + REDIS_CONTAINER = + new GenericContainer<>(DockerImageName.parse(REDIS_CONTAINER_NAME)) + .withExposedPorts(6379) + .withCommand("redis-server", "--requirepass testpass") + .withReuse(true); + + REDIS_CONTAINER.start(); + } + + @DynamicPropertySource + public static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379))); + registry.add("spring.data.redis.password", () -> "testpass"); + } +} diff --git a/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/config/RedisDataTestConfig.java b/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/config/RedisDataTestConfig.java new file mode 100644 index 000000000..e326ef998 --- /dev/null +++ b/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/config/RedisDataTestConfig.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.RedisTemplate; + +@TestConfiguration +public class RedisDataTestConfig { + @Bean + @ConditionalOnMissingBean + public RedisTemplate testRedisTemplate() { + return null; + } +} diff --git a/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/message/ChatMessageRepositoryImplTest.java b/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/message/ChatMessageRepositoryImplTest.java new file mode 100644 index 000000000..601d09b0b --- /dev/null +++ b/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/message/ChatMessageRepositoryImplTest.java @@ -0,0 +1,382 @@ +package kr.co.pennyway.domains.message; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.config.ContainerRedisTestConfig; +import kr.co.pennyway.config.RedisDataTestConfig; +import kr.co.pennyway.domain.config.LettuceConfig; +import kr.co.pennyway.domain.config.RedisConfig; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import kr.co.pennyway.domain.domains.message.domain.ChatMessageBuilder; +import kr.co.pennyway.domain.domains.message.repository.ChatMessageRepositoryImpl; +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType; +import kr.co.pennyway.domain.domains.message.type.MessageContentType; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Slice; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@ContextConfiguration(classes = {RedisConfig.class, LettuceConfig.class}) +@DataRedisTest(properties = "spring.config.location=classpath:application-domain-redis.yml") +@Import({ChatMessageRepositoryImpl.class, RedisDataTestConfig.class}) +@ActiveProfiles("test") +public class ChatMessageRepositoryImplTest extends ContainerRedisTestConfig { + private static final long CUSTOM_EPOCH = 1577836800000L; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private ObjectMapper objectMapper; + + private ChatMessageRepositoryImpl chatMessageRepositoryImpl; + + private ChatMessage chatMessage; + + @BeforeEach + void setUp() { + chatMessageRepositoryImpl = new ChatMessageRepositoryImpl(redisTemplate, objectMapper); + chatMessage = ChatMessageBuilder.builder() + .chatRoomId(1L) + .chatId(createChatId(1)) + .content("Hello") + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(1L) + .build(); + } + + @Test + @DisplayName("Happy Path: 채팅 메시지 저장에 성공한다") + void successSaveChatMessage() { + // when + chatMessageRepositoryImpl.save(chatMessage); + + // then + List messages = chatMessageRepositoryImpl.findRecentMessages(1L, 1); + assertFalse(messages.isEmpty(), "저장된 메시지는 조회할 수 있어야 합니다"); + } + + @Test + @DisplayName("최근 메시지를 지정한 개수만큼 조회한다") + void successFindRecentMessages() { + // given + saveMessagesInOrder(1L, 5); + + // when + List messages = chatMessageRepositoryImpl.findRecentMessages(1L, 3); + + // then + assertAll( + () -> assertEquals(3, messages.size(), "요청한 개수만큼 메시지가 조회되어야 합니다"), + () -> assertEquals("Message 5", messages.get(0).getContent(), "최신 메시지가 먼저 조회되어야 합니다"), + () -> assertEquals("Message 4", messages.get(1).getContent()), + () -> assertEquals("Message 3", messages.get(2).getContent()) + ); + } + + @Test + @DisplayName("특정 메시지 이전의 메시지들을 페이징하여 조회한다") + void successFindMessagesAfter() { + // given + List messages = saveMessagesInOrder(1L, 10); + + // when + Slice messageSlice = chatMessageRepositoryImpl.findMessagesBefore(1L, messages.get(7).getChatId(), 2); + + // then + assertAll( + () -> assertEquals(2, messageSlice.getContent().size(), "요청한 크기만큼 메시지가 조회되어야 합니다"), + () -> assertEquals("Message 7", messageSlice.getContent().get(0).getContent()), + () -> assertEquals("Message 6", messageSlice.getContent().get(1).getContent()), + () -> assertTrue(messageSlice.hasNext(), "남은 메시지가 더 존재해야 합니다.") + ); + } + + @Test + @DisplayName("Enum 타입들이 올바르게 저장 및 조회된다") + void successSaveAndFindEnumTypes() { + // given + chatMessageRepositoryImpl.save(chatMessage); + + // when + List messages = chatMessageRepositoryImpl.findRecentMessages(1L, 1); + ChatMessage foundMessage = messages.get(0); + + // then + assertAll( + () -> assertEquals(MessageContentType.TEXT, foundMessage.getContentType(), + "contentType이 올바르게 저장/조회되어야 합니다"), + () -> assertEquals(MessageCategoryType.NORMAL, foundMessage.getCategoryType(), + "categoryType이 올바르게 저장/조회되어야 합니다") + ); + } + + @Test + @DisplayName("안 읽은 메시지 개수를 정확히 계산한다") + void successCountUnreadMessages() { + // given + List messages = saveMessagesInOrder(1L, 5); + + // when + Long unreadCount = chatMessageRepositoryImpl.countUnreadMessages(1L, messages.get(2).getChatId()); + + // then + assertEquals(2L, unreadCount, "마지막으로 읽은 메시지(ID: 3) 이후의 메시지 개수(4, 5)가 반환되어야 합니다"); + } + + @Test + @DisplayName("메시지 내용이 5000자를 초과하면 저장 시 예외가 발생한다") + void throwExceptionWhenContentExceeds5000Characters() { + // given + String longContent = "a".repeat(5001); + + // when & then + assertThrows(IllegalArgumentException.class, + () -> ChatMessageBuilder.builder() + .chatRoomId(1L) + .chatId(1L) + .content(longContent) + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(1L) + .build(), + "메시지 내용이 5000자를 초과하면 예외가 발생해야 합니다"); + } + + @Test + @DisplayName("BVA: 첫 페이지(가장 최근 메시지)부터 정상적으로 조회된다") + void successFindFirstPage() { + // given + saveMessagesInOrder(1L, 5); + + // when + Slice messageSlice = chatMessageRepositoryImpl.findMessagesBefore(1L, Long.MAX_VALUE, 2); + + // then + assertAll( + () -> assertEquals(2, messageSlice.getContent().size()), + () -> assertEquals("Message 5", messageSlice.getContent().get(0).getContent()), + () -> assertEquals("Message 4", messageSlice.getContent().get(1).getContent()), + () -> assertTrue(messageSlice.hasNext()) + ); + } + + @Test + @DisplayName("BVA: 마지막 페이지(가장 오래된 메시지)까지 정상적으로 조회된다") + void successFindLastPage() { + // given + List messages = saveMessagesInOrder(1L, 5); + + // when + Slice messageSlice = chatMessageRepositoryImpl.findMessagesBefore(1L, messages.get(1).getChatId(), 2); + + // then + assertAll( + () -> assertEquals(1, messageSlice.getContent().size()), + () -> assertEquals("Message 1", messageSlice.getContent().get(0).getContent()), + () -> assertFalse(messageSlice.hasNext()) + ); + } + + @Test + @DisplayName("여러 채팅방의 메시지가 서로 영향을 주지 않는다") + void successMultipleRoomMessages() { + // given + saveMessagesInOrder(1L, 3); // room 1 + saveMessagesInOrder(2L, 3); // room 2 + + // when + List room1Messages = chatMessageRepositoryImpl.findRecentMessages(1L, 5); + List room2Messages = chatMessageRepositoryImpl.findRecentMessages(2L, 5); + + // then + assertAll( + () -> assertEquals(3, room1Messages.size()), + () -> assertEquals(3, room2Messages.size()), + () -> assertTrue(room1Messages.stream().allMatch(msg -> msg.getChatRoomId().equals(1L))), + () -> assertTrue(room2Messages.stream().allMatch(msg -> msg.getChatRoomId().equals(2L))) + ); + } + + @Test + @DisplayName("존재하지 않는 채팅방 조회 시 빈 목록을 반환한다") + void returnEmptyForNonExistingRoom() { + // when + List messages = chatMessageRepositoryImpl.findRecentMessages(999L, 10); + + // then + assertTrue(messages.isEmpty()); + } + + @Test + @DisplayName("존재하지 않는 메시지 ID로 페이징 조회 시 빈 Slice를 반환한다") + void returnEmptySliceForNonExistingMessage() { + // when + Slice messageSlice = chatMessageRepositoryImpl.findMessagesBefore(1L, createChatId(999), 10); + + // then + assertAll( + () -> assertTrue(messageSlice.getContent().isEmpty()), + () -> assertFalse(messageSlice.hasNext()) + ); + } + + @Test + @DisplayName("요청한 크기가 전체 메시지 수보다 큰 경우에도 정상 동작한다") + void successWithLargePageSize() { + // given + saveMessagesInOrder(1L, 3); + + // when + List messages = chatMessageRepositoryImpl.findRecentMessages(1L, 10); + + // then + assertEquals(3, messages.size()); + } + + @Test + @DisplayName("동일한 시간에 생성된 메시지도 TSID 순서대로 정렬된다") + void successSortingWithSameTimestamp() { + // given + int messageCount = 3; + LocalDateTime now = LocalDateTime.now(); + for (long i = 1; i <= messageCount; i++) { + ChatMessage message = ChatMessageBuilder.builder() + .chatRoomId(1L) + .chatId(createChatId(i)) + .content("Message " + i) + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(1L) + .build(); + ReflectionTestUtils.setField(message, "createdAt", now); + + chatMessageRepositoryImpl.save(message); + } + + // when + List messages = chatMessageRepositoryImpl.findRecentMessages(1L, 3); + + // then + assertAll( + () -> assertEquals(3, messages.size()), + () -> assertEquals("Message 3", messages.get(0).getContent()), + () -> assertEquals("Message 2", messages.get(1).getContent()), + () -> assertEquals("Message 1", messages.get(2).getContent()) + ); + } + + @Test + @DisplayName("같은 밀리초에 생성된 메시지들 중 ID의 차이가 10의 자리 수 이내인 경우에도 조회에 성공한다.") + void successSortingWithCloseIds() { + // given + long timestamp = (System.currentTimeMillis() - CUSTOM_EPOCH); + int gap = 5; + + List messages = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + ChatMessage message = ChatMessageBuilder.builder() + .chatRoomId(1L) + .chatId(Long.parseLong(timestamp + String.format("%07d", i + gap))) // 5씩 차이나는 ID + .content("Message " + (i + 1)) + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(1L) + .build(); + ReflectionTestUtils.setField(message, "createdAt", LocalDateTime.now()); + messages.add(chatMessageRepositoryImpl.save(message)); + } + + // when + Slice messageSlice = chatMessageRepositoryImpl.findMessagesBefore( + 1L, + messages.get(1).getChatId(), // 2번째 메시지 ID + 1 + ); + + // then + assertAll( + () -> assertEquals(1, messageSlice.getContent().size(), "정확히 1개의 메시지가 조회되어야 합니다"), + () -> assertEquals("Message 1", messageSlice.getContent().get(0).getContent(), + "가장 첫 번째 메시지가 조회되어야 합니다"), + () -> assertFalse(messageSlice.hasNext(), "더 이전 메시지가 없어야 합니다") + ); + } + + @Test + @DisplayName("같은 밀리초에 생성된 메시지들 중 ID의 차이가 10의 자리 수 이내인 경우에도 읽지 않은 메시지 개수를 정확히 계산한다.") + void successCountUnreadMessagesWithCloseIds() { + // given + long timestamp = (System.currentTimeMillis() - CUSTOM_EPOCH); + int gap = 5; + + List messages = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + ChatMessage message = ChatMessageBuilder.builder() + .chatRoomId(1L) + .chatId(Long.parseLong(timestamp + String.format("%07d", i + gap))) // 5씩 차이나는 ID + .content("Message " + (i + 1)) + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(1L) + .build(); + ReflectionTestUtils.setField(message, "createdAt", LocalDateTime.now()); + log.info("message: {}", message); + messages.add(chatMessageRepositoryImpl.save(message)); + } + + // when + Long unreadCount = chatMessageRepositoryImpl.countUnreadMessages(1L, messages.get(8).getChatId()); + + // then + assertEquals(1L, unreadCount, "마지막으로 읽은 메시지(ID: 3) 이후의 메시지 개수(7)가 반환되어야 합니다"); + } + + private List saveMessagesInOrder(Long roomId, int messageCount) { + List messages = new ArrayList<>(); + + for (long i = 1; i <= messageCount; i++) { + ChatMessage message = ChatMessageBuilder.builder() + .chatRoomId(roomId) + .chatId(createChatId(i)) + .content("Message " + i) + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(1L) + .build(); + messages.add(chatMessageRepositoryImpl.save(message)); + } + + return messages; + } + + @AfterEach + void tearDown() { + Set keys = redisTemplate.keys("chatroom:*:message"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + + private long createChatId(long i) { + long timestamp = (System.currentTimeMillis() - CUSTOM_EPOCH); + return Long.parseLong(timestamp + String.format("%07d", i)); + } +} diff --git a/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/phone/PhoneValidationDaoTest.java b/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/phone/PhoneValidationDaoTest.java new file mode 100644 index 000000000..f46de96aa --- /dev/null +++ b/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/phone/PhoneValidationDaoTest.java @@ -0,0 +1,91 @@ +package kr.co.pennyway.domains.phone; + +import kr.co.pennyway.config.ContainerRedisTestConfig; +import kr.co.pennyway.domain.config.LettuceConfig; +import kr.co.pennyway.domain.config.RedisConfig; +import kr.co.pennyway.domain.domains.phone.repository.PhoneCodeRepository; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@Slf4j +@ContextConfiguration(classes = {RedisConfig.class, LettuceConfig.class}) +@DataRedisTest(properties = "spring.config.location=classpath:application-domain-redis.yml") +@Import({PhoneCodeRepository.class}) +@ActiveProfiles("test") +public class PhoneValidationDaoTest extends ContainerRedisTestConfig { + @Autowired + private PhoneCodeRepository phoneCodeRepository; + private String phone; + private String code; + private PhoneCodeKeyType codeType; + + @BeforeEach + void setUp() { + phone = "01012345678"; + code = "123456"; + codeType = PhoneCodeKeyType.SIGN_UP; + } + + @AfterEach + void tearDown() { + phoneCodeRepository.delete(phone, codeType); + } + + @Test + @DisplayName("Redis에 데이터를 저장하면 {'codeType:phone':code}로 데이터가 저장된다.") + void codeSaveTest() { + // given + phoneCodeRepository.save(phone, code, codeType); + + // when + String savedCode = phoneCodeRepository.findCodeByPhone(phone, codeType); + + // then + assertEquals(code, savedCode); + System.out.println("savedCode = " + savedCode); + } + + @Test + @DisplayName("Redis에 'codeType:phone'에 해당하는 값이 없으면 NullPointerException이 발생한다.") + void codeReadError() { + // given + phoneCodeRepository.delete(phone, codeType); + String wrongPhone = "01087654321"; + + // when - then + assertThrows(NullPointerException.class, () -> phoneCodeRepository.findCodeByPhone(wrongPhone, codeType)); + } + + @Test + @DisplayName("Redis에 저장된 데이터를 삭제하면 해당 데이터가 삭제된다.") + void codeRemoveTest() { + // given + phoneCodeRepository.save(phone, code, codeType); + + // when + phoneCodeRepository.delete(phone, codeType); + + // then + assertThrows(NullPointerException.class, () -> phoneCodeRepository.findCodeByPhone(phone, codeType)); + } + + @Test + @DisplayName("저장되지 않은 데이터를 삭제해도 에러가 발생하지 않는다.") + void codeRemoveError() { + // when - thengi + assertThrows(NullPointerException.class, () -> phoneCodeRepository.findCodeByPhone(phone, codeType)); + phoneCodeRepository.delete(phone, codeType); + } +} diff --git a/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/refresh/RefreshTokenServiceIntegrationTest.java b/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/refresh/RefreshTokenServiceIntegrationTest.java new file mode 100644 index 000000000..0c15a3bfb --- /dev/null +++ b/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/refresh/RefreshTokenServiceIntegrationTest.java @@ -0,0 +1,132 @@ +package kr.co.pennyway.domains.refresh; + +import kr.co.pennyway.config.ContainerRedisTestConfig; +import kr.co.pennyway.domain.config.LettuceConfig; +import kr.co.pennyway.domain.config.RedisConfig; +import kr.co.pennyway.domain.domains.refresh.domain.RefreshToken; +import kr.co.pennyway.domain.domains.refresh.repository.RefreshTokenRepository; +import kr.co.pennyway.domain.domains.refresh.service.RefreshTokenRedisService; +import kr.co.pennyway.domain.domains.refresh.service.RefreshTokenRedisServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertFalse; + +@Slf4j +@DataRedisTest(properties = "spring.config.location=classpath:application-domain-redis.yml") +@ContextConfiguration(classes = {RedisConfig.class, LettuceConfig.class, RefreshTokenRedisServiceImpl.class}) +@ActiveProfiles("test") +public class RefreshTokenServiceIntegrationTest extends ContainerRedisTestConfig { + @Autowired + private RefreshTokenRepository refreshTokenRepository; + private RefreshTokenRedisService refreshTokenService; + + @BeforeEach + void setUp() { + this.refreshTokenService = new RefreshTokenRedisServiceImpl(refreshTokenRepository); + } + + @Test + @DisplayName("리프레시 토큰 저장 테스트") + void saveTest() { + // given + RefreshToken refreshToken = RefreshToken.builder() + .userId(1L) + .deviceId("AA-BBB-CC-DDD") + .token("refreshToken") + .ttl(1000L) + .build(); + + // when + refreshTokenService.save(refreshToken); + + // then + RefreshToken savedRefreshToken = refreshTokenRepository.findById(refreshToken.getId()).orElse(null); + assertEquals("저장된 리프레시 토큰이 일치하지 않습니다.", refreshToken, savedRefreshToken); + log.info("저장된 리프레시 토큰 정보 : {}", savedRefreshToken); + } + + @Test + @DisplayName("리프레시 토큰 갱신 테스트") + void refreshTest() { + // given + RefreshToken refreshToken = RefreshToken.builder() + .userId(1L) + .deviceId("AA-BBB-CC-DDD") + .token("refreshToken") + .ttl(1000L) + .build(); + refreshTokenService.save(refreshToken); + + // when + refreshTokenService.refresh(refreshToken.getUserId(), refreshToken.getDeviceId(), refreshToken.getToken(), "newRefreshToken"); + + // then + RefreshToken savedRefreshToken = refreshTokenRepository.findById(refreshToken.getId()).orElse(null); + assertEquals("갱신된 리프레시 토큰이 일치하지 않습니다.", "newRefreshToken", savedRefreshToken.getToken()); + log.info("갱신된 리프레시 토큰 정보 : {}", savedRefreshToken); + } + + @Test + @DisplayName("요청한 리프레시 토큰과 저장된 리프레시 토큰이 다를 경우 토큰이 탈취되었다고 판단하여 값 삭제") + void validateTokenTest() { + // given + RefreshToken refreshToken = RefreshToken.builder() + .userId(1L) + .deviceId("AA-BBB-CC-DDD") + .token("refreshToken") + .ttl(1000L) + .build(); + refreshTokenService.save(refreshToken); + + // when + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> refreshTokenService.refresh(refreshToken.getUserId(), refreshToken.getDeviceId(), "anotherRefreshToken", "newRefreshToken")); + + // then + assertEquals("리프레시 토큰이 탈취되었을 때 예외가 발생해야 합니다.", "refresh token mismatched", exception.getMessage()); + assertFalse("리프레시 토큰이 탈취되었을 때 저장된 리프레시 토큰이 삭제되어야 합니다.", refreshTokenRepository.existsById(refreshToken.getId())); + } + + @Test + @DisplayName("사용자에게 할당된 모든 Device의 리프레시 토큰 삭제 테스트") + void deleteAllTest() { + // given + RefreshToken refreshToken1 = RefreshToken.builder() + .userId(1L) + .deviceId("AA-BBB-CC-DDD") + .token("refreshToken1") + .ttl(1000L) + .build(); + RefreshToken refreshToken2 = RefreshToken.builder() + .userId(1L) + .deviceId("AA-BBB-CC-EEE") + .token("refreshToken2") + .ttl(1000L) + .build(); + refreshTokenService.save(refreshToken1); + refreshTokenService.save(refreshToken2); + + // when + refreshTokenService.deleteAll(refreshToken1.getUserId()); + + // then + assertFalse("사용자에게 할당된 모든 Device의 리프레시 토큰이 삭제되어야 합니다.", refreshTokenRepository.existsById(refreshToken1.getId())); + assertFalse("사용자에게 할당된 모든 Device의 리프레시 토큰이 삭제되어야 합니다.", refreshTokenRepository.existsById(refreshToken2.getId())); + } + + @Test + @DisplayName("userId에 해당하는 리프레시 토큰이 없어도, 삭제 수행에서 예외가 발생하지 않아야 합니다.") + void deleteAllWithoutRefreshTokenTest() { + // when - then + assertDoesNotThrow(() -> refreshTokenService.deleteAll(1L)); + } +} diff --git a/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/session/UserSessionCustomRepositoryTest.java b/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/session/UserSessionCustomRepositoryTest.java new file mode 100644 index 000000000..6a6e681bb --- /dev/null +++ b/pennyway-domain/domain-redis/src/test/java/kr/co/pennyway/domains/session/UserSessionCustomRepositoryTest.java @@ -0,0 +1,182 @@ +package kr.co.pennyway.domains.session; + +import com.fasterxml.jackson.core.JsonProcessingException; +import kr.co.pennyway.config.ContainerRedisTestConfig; +import kr.co.pennyway.domain.config.LettuceConfig; +import kr.co.pennyway.domain.config.RedisConfig; +import kr.co.pennyway.domain.domains.session.domain.UserSession; +import kr.co.pennyway.domain.domains.session.repository.UserSessionRepository; +import kr.co.pennyway.domain.domains.session.repository.UserSessionRepositoryImpl; +import kr.co.pennyway.domain.domains.session.type.UserStatus; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@DisplayName("사용자 세션 Redis 저장소 테스트") +@ContextConfiguration(classes = {RedisConfig.class, LettuceConfig.class}) +@DataRedisTest(properties = "spring.config.location=classpath:application-domain-redis.yml") +@Import({UserSessionRepositoryImpl.class}) +@ActiveProfiles("test") +public class UserSessionCustomRepositoryTest extends ContainerRedisTestConfig { + @Autowired + private UserSessionRepository userSessionRepository; + + private Long userId; + private String deviceId; + private String deviceName; + private UserSession userSession; + + @BeforeEach + void setUp() { + userId = 1L; + deviceId = "123456789"; + deviceName = "TestDevice"; + userSession = UserSession.of(userId, deviceId, deviceName); + } + + @Test + @DisplayName("사용자 세션 저장 및 조회 테스트") + void saveAndFindUserSessionTest() throws JsonProcessingException { + // given + log.debug("userSession: {}", userSession); + userSessionRepository.save(userId, deviceId, userSession); + + // when + Optional foundSession = userSessionRepository.findUserSession(userId, deviceId); + + // then + log.debug("foundSession: {}", foundSession); + assertTrue(foundSession.isPresent()); + assertEquals(deviceName, foundSession.get().getDeviceName()); + assertEquals(UserStatus.ACTIVE_APP, foundSession.get().getStatus()); + } + + @Test + @DisplayName("임의의 사용자의 모든 세션 조회 테스트") + void findAllUserSessionsTest() throws JsonProcessingException { + // given + String deviceId2 = "987654321"; + String deviceName2 = "TestDevice2"; + UserSession userSession2 = UserSession.of(userId, deviceId2, deviceName2); + + userSessionRepository.save(userId, deviceId, userSession); + userSessionRepository.save(userId, deviceId2, userSession2); + + // when + Map allSessions = userSessionRepository.findAllUserSessions(userId); + + // then + log.debug("allSessions: {}", allSessions); + assertThat(allSessions).hasSize(2); + assertTrue(allSessions.containsKey(deviceId)); + assertTrue(allSessions.containsKey(deviceId2)); + } + + @Test + @DisplayName("세션 TTL 조회 및 업데이트 테스트") + void sessionTtlTest() throws Exception { + // given + userSessionRepository.save(userId, deviceId, userSession); + + // when + Thread.sleep(1000); // 1초 대기 + Long initialTtl = userSessionRepository.getSessionTtl(userId, deviceId); + userSessionRepository.resetSessionTtl(userId, deviceId); // 세션 초기화 + Long updatedTtl = userSessionRepository.getSessionTtl(userId, deviceId); + + // then + log.debug("initialTtl: {}, updatedTtl: {}", initialTtl, updatedTtl); + assertNotNull(initialTtl); + assertNotNull(updatedTtl); + assertTrue(updatedTtl > initialTtl); // 약간의 오차 허용 + } + + @Test + @DisplayName("사용자 세션 존재 여부 조회 테스트") + void existsTest() { + // Given + userSessionRepository.save(userId, deviceId, userSession); + + // When + boolean exists = userSessionRepository.exists(userId, deviceId); + + // Then + assertTrue(exists); + + // When + boolean notExists = userSessionRepository.exists(userId, "nonExistentDevice"); + + // Then + assertFalse(notExists); + } + + @Test + @DisplayName("사용자 세션 삭제 테스트") + void deleteUserSessionTest() throws JsonProcessingException { + // given + userSessionRepository.save(userId, deviceId, userSession); + + // when + userSessionRepository.delete(userId, deviceId); + + // then + Optional deletedSession = userSessionRepository.findUserSession(userId, deviceId); + assertFalse(deletedSession.isPresent()); + } + + @Test + @DisplayName("사용자 세션 상태 업데이트 테스트 (채팅방으로 이동)") + void updateUserSessionStatusTest() { + // given + userSessionRepository.save(userId, deviceId, userSession); + + // when + UserSession updatedSession = userSessionRepository.findUserSession(userId, deviceId).get(); + updatedSession.updateStatus(UserStatus.ACTIVE_CHAT_ROOM, 123L); + userSessionRepository.save(userId, deviceId, updatedSession); + + // then + log.debug("updatedSession: {} to {}", userSession, updatedSession); + UserSession foundSession = userSessionRepository.findUserSession(userId, deviceId).get(); + assertEquals(UserStatus.ACTIVE_CHAT_ROOM, foundSession.getStatus()); + assertEquals(123L, foundSession.getCurrentChatRoomId()); + } + + @Test + @DisplayName("사용자 세션 마지막 활동 시간 업데이트 테스트") + void updateLastActiveAtTest() throws Exception { + // given + userSessionRepository.save(userId, deviceId, userSession); + LocalDateTime initialLastActiveAt = userSession.getLastActiveAt(); + + // when + Thread.sleep(1000); // 1초 대기 + UserSession updatedSession = userSessionRepository.findUserSession(userId, deviceId).get(); + updatedSession.updateLastActiveAt(); + userSessionRepository.save(userId, deviceId, updatedSession); + + // then + UserSession foundSession = userSessionRepository.findUserSession(userId, deviceId).get(); + assertTrue(foundSession.getLastActiveAt().isAfter(initialLastActiveAt)); + } + + @AfterEach + void tearDown() { + userSessionRepository.delete(userId, deviceId); + } +} diff --git a/pennyway-domain/domain-redis/src/test/resources/logback-test.xml b/pennyway-domain/domain-redis/src/test/resources/logback-test.xml new file mode 100644 index 000000000..198192602 --- /dev/null +++ b/pennyway-domain/domain-redis/src/test/resources/logback-test.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/pennyway-domain/domain-service/.gitignore b/pennyway-domain/domain-service/.gitignore new file mode 100644 index 000000000..b63da4551 --- /dev/null +++ b/pennyway-domain/domain-service/.gitignore @@ -0,0 +1,42 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/pennyway-domain/domain-service/build.gradle b/pennyway-domain/domain-service/build.gradle new file mode 100644 index 000000000..d88677941 --- /dev/null +++ b/pennyway-domain/domain-service/build.gradle @@ -0,0 +1,14 @@ +bootJar { enabled = false } +jar { enabled = true } + +dependencies { + implementation project(':pennyway-common') + implementation project(':pennyway-domain:domain-rdb') + implementation project(':pennyway-domain:domain-redis') + + /* Test Containers */ + testImplementation "org.testcontainers:junit-jupiter:1.19.7" + testImplementation "org.testcontainers:testcontainers:1.19.7" + testImplementation "org.testcontainers:mysql:1.19.7" + testImplementation "com.redis.testcontainers:testcontainers-redis-junit:1.6.4" +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java new file mode 100644 index 000000000..4867a3cf3 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.domain.common.importer; + +import org.springframework.context.annotation.Import; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Import(PennywayDomainConfigImportSelector.class) +public @interface EnablePennywayDomainConfig { + PennywayDomainConfigGroup[] value(); +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java new file mode 100644 index 000000000..794a315ec --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.common.importer; + +/** + * Pennyway RDS Domain의 Configurations를 나타내는 Marker Interface + */ +public interface PennywayDomainConfig { +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java new file mode 100644 index 000000000..d5fe70698 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.importer; + +import kr.co.pennyway.domain.config.RedissonDomainConfig; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PennywayDomainConfigGroup { + REDISSON_DOMAIN(RedissonDomainConfig.class); + + private final Class configClass; +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java new file mode 100644 index 000000000..01add00bf --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.common.importer; + +import kr.co.pennyway.common.util.MapUtils; +import org.springframework.context.annotation.DeferredImportSelector; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.lang.NonNull; + +import java.util.Arrays; +import java.util.Map; + +public class PennywayDomainConfigImportSelector implements DeferredImportSelector { + @NonNull + @Override + public String[] selectImports(@NonNull AnnotationMetadata metadata) { + return Arrays.stream(getGroups(metadata)) + .map(v -> v.getConfigClass().getName()) + .toArray(String[]::new); + } + + private PennywayDomainConfigGroup[] getGroups(AnnotationMetadata metadata) { + Map attributes = metadata.getAnnotationAttributes(EnablePennywayDomainConfig.class.getName()); + return (PennywayDomainConfigGroup[]) MapUtils.getObject(attributes, "value", new PennywayDomainConfigGroup[]{}); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/config/RedissonDomainConfig.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/config/RedissonDomainConfig.java new file mode 100644 index 000000000..23c9492e7 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/config/RedissonDomainConfig.java @@ -0,0 +1,9 @@ +package kr.co.pennyway.domain.config; + +import kr.co.pennyway.domain.common.importer.EnablePennywayRedisDomainConfig; +import kr.co.pennyway.domain.common.importer.PennywayDomainConfig; +import kr.co.pennyway.domain.common.importer.PennywayRedisDomainConfigGroup; + +@EnablePennywayRedisDomainConfig(value = PennywayRedisDomainConfigGroup.REDISSON_INFRA) +public class RedissonDomainConfig implements PennywayDomainConfig { +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/collection/DeviceTokenRegisterCollection.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/collection/DeviceTokenRegisterCollection.java new file mode 100644 index 000000000..d2237d0a7 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/collection/DeviceTokenRegisterCollection.java @@ -0,0 +1,107 @@ +package kr.co.pennyway.domain.context.account.collection; + +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; + +import java.util.List; + +@Slf4j +public class DeviceTokenRegisterCollection { + private final List userDeviceTokens; + private DeviceToken deviceToken; + + public DeviceTokenRegisterCollection() { + this.deviceToken = null; + this.userDeviceTokens = List.of(); + } + + public DeviceTokenRegisterCollection(DeviceToken deviceToken) { + this.deviceToken = deviceToken; + this.userDeviceTokens = List.of(); + } + + /** + * @param userDeviceTokens : + */ + public DeviceTokenRegisterCollection(DeviceToken deviceToken, List userDeviceTokens) { + this.deviceToken = deviceToken; + this.userDeviceTokens = userDeviceTokens; + } + + /** + * 사용자의 디바이스 토큰을 생성하거나 갱신한다. + * + *
+     * [비즈니스 규칙]
+     * - 같은 {userId, deviceId}에 대해 새로운 토큰이 발급될 수 있지만, 활성화된 토큰은 하나여야 합니다.
+     * - {deviceId, token} 조합은 시스템 전체에서 유일해야 합니다.
+     * - device token이 이미 등록된 경우, 소유자 정보를 갱신하고 마지막 로그인 시간을 갱신한다.
+     * - device token이 등록되지 않은 경우, 새로운 device token을 생성한다.
+     * 
+ * + * @param owner User : 사용자 정보 + * @param deviceId String: 디바이스 식별자 + * @param deviceName String: 디바이스 이름 + * @param token String: 디바이스 토큰 + * @return {@link DeviceToken} 사용자의 기기로 등록된 Device 정보 + * @throws UserErrorException {@link UserErrorCode#NOT_FOUND} : 사용자 파라미터가 null인 경우 + * @throws DeviceTokenErrorException {@link DeviceTokenErrorCode#DUPLICATED_DEVICE_TOKEN} : 이미 등록된 활성화 디바이스 토큰의 deviceId와 다른 deviceId가 들어온 경우 + */ + public DeviceToken register(@NonNull User owner, @NonNull String deviceId, @NonNull String deviceName, @NonNull String token) { + DeviceToken existingDeviceToken = this.getDeviceTokenByToken(token); + + return (existingDeviceToken != null) + ? this.updateDevice(owner, deviceId, existingDeviceToken) + : this.createDevice(owner, deviceId, deviceName, token); + } + + private DeviceToken getDeviceTokenByToken(String token) { + if (this.deviceToken != null && this.deviceToken.getToken().equals(token)) { + return this.deviceToken; + } + + return null; + } + + private DeviceToken updateDevice(User user, String deviceId, DeviceToken originalDeviceToken) { + if (isDuplicatedDeviceToken(deviceId, originalDeviceToken)) { + log.error("활성화된 토큰을 다른 디바이스에서 사용 중입니다."); + throw new DeviceTokenErrorException(DeviceTokenErrorCode.DUPLICATED_DEVICE_TOKEN); + } + + originalDeviceToken.handleOwner(user, deviceId); + log.info("디바이스 토큰이 갱신되었습니다. deviceId: {}, token: {}", deviceId, originalDeviceToken.getToken()); + + return originalDeviceToken; + } + + private boolean isDuplicatedDeviceToken(String deviceId, DeviceToken originalDeviceToken) { + return !originalDeviceToken.getDeviceId().equals(deviceId) && originalDeviceToken.isActivated(); + } + + private DeviceToken createDevice(User user, String deviceId, String deviceName, String token) { + this.deviceToken = DeviceToken.of(token, deviceId, deviceName, user); + log.info("새로운 디바이스 토큰이 생성되었습니다. deviceId: {}, token: {}", deviceId, token); + + deactivateExistingTokens(); + + return this.deviceToken; + } + + /** + * 특정 사용자의 디바이스에 대한 기존 활성 토큰들을 비활성화합니다. + * 새로운 토큰 등록 시 호출되어 하나의 디바이스에 하나의 활성 토큰만 존재하도록 보장합니다. + */ + private void deactivateExistingTokens() { + userDeviceTokens.stream() + .filter(token -> token.getDeviceId().equals(deviceToken.getDeviceId())) + .filter(DeviceToken::isActivated) + .forEach(DeviceToken::deactivate); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/DeviceTokenRegisterService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/DeviceTokenRegisterService.java new file mode 100644 index 000000000..d8ee1c3eb --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/DeviceTokenRegisterService.java @@ -0,0 +1,51 @@ +package kr.co.pennyway.domain.context.account.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.context.account.collection.DeviceTokenRegisterCollection; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenRdbService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class DeviceTokenRegisterService { + private final UserRdbService userRdbService; + private final DeviceTokenRdbService deviceTokenRdbService; + + /** + * 사용자의 디바이스 토큰을 생성하거나 갱신한다. + * + * @param userId 사용자 식별자 + * @param deviceId 디바이스 식별자 + * @param deviceName 디바이스 이름 + * @param deviceToken 디바이스 토큰 + * @return {@link DeviceToken} 사용자의 기기로 등록된 Device 정보 + */ + @Transactional + public DeviceToken execute(Long userId, String deviceId, String deviceName, String deviceToken) { + User user = userRdbService.readUser(userId) + .orElseThrow(() -> { + log.error("디바이스 토큰을 등록할 사용자 정보가 없습니다."); + return new UserErrorException(UserErrorCode.NOT_FOUND); + }); + + DeviceToken existingDeviceToken = deviceTokenRdbService.readDeviceByToken(deviceToken).orElse(null); + List userDeviceTokens = deviceTokenRdbService.readByUserIdAndDeviceId(userId, deviceId); + + DeviceToken newDeviceToken = new DeviceTokenRegisterCollection( + existingDeviceToken, + userDeviceTokens + ).register(user, deviceId, deviceName, deviceToken); + + return deviceTokenRdbService.createDevice(newDeviceToken); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/DeviceTokenService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/DeviceTokenService.java new file mode 100644 index 000000000..5a78c74e6 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/DeviceTokenService.java @@ -0,0 +1,44 @@ +package kr.co.pennyway.domain.context.account.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class DeviceTokenService { + private final DeviceTokenRdbService deviceTokenRdbService; + + @Transactional + public DeviceToken createDeviceToken(DeviceToken deviceToken) { + return deviceTokenRdbService.createDevice(deviceToken); + } + + /** + * @return 비활성화된 디바이스 토큰 정보를 포함합니다. + */ + @Transactional(readOnly = true) + public Optional readDeviceTokenByUserIdAndToken(Long userId, String token) { + return deviceTokenRdbService.readDeviceByUserIdAndToken(userId, token); + } + + /** + * @return 비활성화된 디바이스 토큰 정보를 포함합니다. + */ + @Transactional(readOnly = true) + public List readAllByUserId(Long userId) { + return deviceTokenRdbService.readAllByUserId(userId); + } + + @Transactional + public void deleteDeviceTokensByUserId(Long userId) { + deviceTokenRdbService.deleteDevicesByUserIdInQuery(userId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/ForbiddenTokenService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/ForbiddenTokenService.java new file mode 100644 index 000000000..994e94393 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/ForbiddenTokenService.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.context.account.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.forbidden.service.ForbiddenTokenRedisService; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; + +@Slf4j +@DomainService +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class ForbiddenTokenService { + private final ForbiddenTokenRedisService forbiddenTokenRedisService; + + public void createForbiddenToken(String accessToken, Long userId, LocalDateTime expiresAt) { + forbiddenTokenRedisService.createForbiddenToken(accessToken, userId, expiresAt); + } + + public boolean isForbidden(String accessToken) { + return forbiddenTokenRedisService.isForbidden(accessToken); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/OauthService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/OauthService.java new file mode 100644 index 000000000..55d236967 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/OauthService.java @@ -0,0 +1,60 @@ +package kr.co.pennyway.domain.context.account.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.service.OauthRdbService; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.Set; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class OauthService { + private final OauthRdbService oauthRdbService; + + @Transactional + public Oauth createOauth(Oauth oauth) { + return oauthRdbService.createOauth(oauth); + } + + @Transactional(readOnly = true) + public Optional readOauth(Long id) { + return oauthRdbService.readOauth(id); + } + + // @Todo: Provider 파라미터를 제거하고, 선택 로직을 내부에서 처리 + @Transactional(readOnly = true) + public Optional readOauthByOauthIdAndProvider(String oauthId, Provider provider) { + return oauthRdbService.readOauthByOauthIdAndProvider(oauthId, provider); + } + + @Transactional(readOnly = true) + public Set readOauthsByUserId(Long userId) { + return oauthRdbService.readOauthsByUserId(userId); + } + + @Transactional(readOnly = true) + public boolean isExistOauthByUserIdAndProvider(Long userId, Provider provider) { + return oauthRdbService.isExistOauthByUserIdAndProvider(userId, provider); + } + + @Transactional(readOnly = true) + public boolean isExistOauthByOauthIdAndProvider(String oauthId, Provider provider) { + return oauthRdbService.isExistOauthByOauthIdAndProvider(oauthId, provider); + } + + @Transactional + public void deleteOauth(Oauth oauth) { + oauthRdbService.deleteOauth(oauth); + } + + @Transactional + public void deleteOauth(Long oauthId) { + oauthRdbService.deleteOauthsByUserIdInQuery(oauthId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/PhoneCodeService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/PhoneCodeService.java new file mode 100644 index 000000000..628381782 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/PhoneCodeService.java @@ -0,0 +1,32 @@ +package kr.co.pennyway.domain.context.account.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.phone.service.PhoneCodeRedisService; +import kr.co.pennyway.domain.domains.phone.type.PhoneCodeKeyType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class PhoneCodeService { + private final PhoneCodeRedisService phoneCodeRedisService; + + public LocalDateTime create(String phone, String code, PhoneCodeKeyType codeType) { + return phoneCodeRedisService.create(phone, code, codeType); + } + + public String readByPhone(String phone, PhoneCodeKeyType codeKeyType) { + return phoneCodeRedisService.readByPhone(phone, codeKeyType); + } + + public void extendTimeToLeave(String phone, PhoneCodeKeyType codeType) { + phoneCodeRedisService.extendTimeToLeave(phone, codeType); + } + + public void delete(String phone, PhoneCodeKeyType codeType) { + phoneCodeRedisService.delete(phone, codeType); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/RefreshTokenService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/RefreshTokenService.java new file mode 100644 index 000000000..c13fce9dd --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/RefreshTokenService.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.domain.context.account.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.refresh.domain.RefreshToken; +import kr.co.pennyway.domain.domains.refresh.service.RefreshTokenRedisService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class RefreshTokenService { + private final RefreshTokenRedisService refreshTokenRedisService; + + public void create(RefreshToken refreshToken) { + refreshTokenRedisService.save(refreshToken); + } + + public RefreshToken refresh(Long userId, String deviceId, String oldRefreshToken, String newRefreshToken) { + return refreshTokenRedisService.refresh(userId, deviceId, oldRefreshToken, newRefreshToken); + } + + public void deleteAll(Long userId) { + refreshTokenRedisService.deleteAll(userId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/UserService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/UserService.java new file mode 100644 index 000000000..4f35a16bc --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/UserService.java @@ -0,0 +1,62 @@ +package kr.co.pennyway.domain.context.account.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class UserService { + private final UserRdbService userRdbService; + + @Transactional + public User createUser(User user) { + return userRdbService.createUser(user); + } + + @Transactional(readOnly = true) + public Optional readUser(Long id) { + return userRdbService.readUser(id); + } + + @Transactional(readOnly = true) + public Optional readUserByPhone(String phone) { + return userRdbService.readUserByPhone(phone); + } + + @Transactional(readOnly = true) + public Optional readUserByUsername(String username) { + return userRdbService.readUserByUsername(username); + } + + @Transactional(readOnly = true) + public boolean isExistUser(Long id) { + return userRdbService.isExistUser(id); + } + + @Transactional(readOnly = true) + public boolean isExistUsername(String username) { + return userRdbService.isExistUsername(username); + } + + @Transactional(readOnly = true) + public boolean isExistPhone(String phone) { + return userRdbService.isExistPhone(phone); + } + + @Transactional + public void deleteUser(User user) { + userRdbService.deleteUser(user); + } + + @Transactional + public void deleteUser(Long userId) { + userRdbService.deleteUser(userId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/UserSessionService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/UserSessionService.java new file mode 100644 index 000000000..45f02a6af --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/account/service/UserSessionService.java @@ -0,0 +1,46 @@ +package kr.co.pennyway.domain.context.account.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.session.domain.UserSession; +import kr.co.pennyway.domain.domains.session.service.UserSessionRedisService; +import kr.co.pennyway.domain.domains.session.type.UserStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class UserSessionService { + private final UserSessionRedisService userSessionRedisService; + + public void create(Long userId, String deviceId, UserSession value) { + userSessionRedisService.create(userId, deviceId, value); + } + + public Optional read(Long userId, String deviceId) { + return userSessionRedisService.read(userId, deviceId); + } + + public Map readAll(Long userId) { + return userSessionRedisService.readAll(userId); + } + + public boolean isExists(Long userId, String deviceId) { + return userSessionRedisService.isExists(userId, deviceId); + } + + public UserSession updateUserStatus(Long userId, String deviceId, UserStatus status) { + return userSessionRedisService.updateUserStatus(userId, deviceId, status); + } + + public UserSession updateUserStatus(Long userId, String deviceId, Long chatRoomId) { + return userSessionRedisService.updateUserStatus(userId, deviceId, chatRoomId); + } + + public Long getSessionTtl(Long userId, String deviceId) { + return userSessionRedisService.getSessionTtl(userId, deviceId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/alter/service/NotificationService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/alter/service/NotificationService.java new file mode 100644 index 000000000..117cb5864 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/alter/service/NotificationService.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.domain.context.alter.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.service.NotificationRdbService; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class NotificationService { + private final NotificationRdbService notificationRdbService; + + @Transactional(readOnly = true) + public Slice readNotifications(Long userId, Pageable pageable, NoticeType noticeType) { + return notificationRdbService.readNotificationsSlice(userId, pageable, noticeType); + } + + @Transactional(readOnly = true) + public List readUnreadNotifications(Long userId, NoticeType noticeType) { + return notificationRdbService.readUnreadNotifications(userId, noticeType); + } + + @Transactional(readOnly = true) + public boolean isExistsUnreadNotification(Long userId) { + return notificationRdbService.isExistsUnreadNotification(userId); + } + + @Transactional(readOnly = true) + public long countUnreadNotifications(Long userId, List notificationIds) { + return notificationRdbService.countUnreadNotifications(userId, notificationIds); + } + + @Transactional + public void updateReadAtByIds(List notificationIds) { + notificationRdbService.updateReadAtByIdsInBulk(notificationIds); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/collection/ChatMemberJoinOperation.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/collection/ChatMemberJoinOperation.java new file mode 100644 index 000000000..41fed2666 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/collection/ChatMemberJoinOperation.java @@ -0,0 +1,77 @@ +package kr.co.pennyway.domain.context.chat.collection; + +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorException; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; + +import java.util.Collection; + +@Slf4j +public class ChatMemberJoinOperation { + private static final long MAX_MEMBER_COUNT = 300; + + private final User user; + private final ChatRoom chatRoom; + private final Collection chatMembers; + + public ChatMemberJoinOperation(@NonNull User user, @NonNull ChatRoom chatRoom, @NonNull Collection chatMembers) { + this.user = user; + this.chatRoom = chatRoom; + this.chatMembers = chatMembers; + } + + /** + * 사용자가 채팅방에 참여하는 도메인 비즈니스 로직을 처리한다. + * + *
+     * [비즈니스 규칙]
+     * - 채팅방에 이미 참여한 사용자는 다시 참여할 수 없다.
+     * - 채팅방이 가득 찼을 경우, 채팅방에 참여할 수 없다.
+     * - 비밀번호가 일치하지 않는 경우, 채팅방에 참여할 수 없다.
+     * - 참여한 사용자는 일반 멤버 권한을 부여받는다.
+     * 
+ * + * @return ChatMember : 채팅방에 참여한 사용자 정보 + * @throws ChatMemberErrorException {@link ChatMemberErrorCode#ALREADY_JOINED} : 이미 채팅방에 참여한 사용자가 다시 참여하는 경우 + * @throws ChatRoomErrorException {@link ChatRoomErrorCode#FULL_CHAT_ROOM} : 채팅방이 가득 찬 경우 + * @throws ChatRoomErrorException {@link ChatRoomErrorCode#INVALID_PASSWORD} : 비밀번호가 일치하지 않는 경우 + */ + public ChatMember execute(Integer password) { + if (isAlreadyJoined()) { + log.warn("이미 채팅방에 참여한 사용자입니다. chatRoomId: {}, userId: {}", chatRoom.getId(), user.getId()); + throw new ChatMemberErrorException(ChatMemberErrorCode.ALREADY_JOINED); + } + + if (isFullRoom()) { + log.warn("채팅방이 가득 찼습니다. chatRoomId: {}", chatRoom.getId()); + throw new ChatRoomErrorException(ChatRoomErrorCode.FULL_CHAT_ROOM); + } + + if (matchPassword(password)) { + log.warn("채팅방 비밀번호가 일치하지 않습니다. chatRoomId: {}", chatRoom.getId()); + throw new ChatRoomErrorException(ChatRoomErrorCode.INVALID_PASSWORD); + } + + return ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + } + + private boolean isAlreadyJoined() { + return chatMembers.stream() + .anyMatch(member -> member.getUserId().equals(user.getId())); + } + + private boolean isFullRoom() { + return chatMembers.size() >= MAX_MEMBER_COUNT; + } + + private boolean matchPassword(Integer password) { + return chatRoom.isPrivateRoom() && !chatRoom.matchPassword(password); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomAdminDelegateOperation.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomAdminDelegateOperation.java new file mode 100644 index 000000000..b2d066487 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomAdminDelegateOperation.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.context.chat.collection; + +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; + +import java.util.Objects; + +@Slf4j +public class ChatRoomAdminDelegateOperation { + private final ChatMember chatAdmin; + private final ChatMember chatMember; + + public ChatRoomAdminDelegateOperation(@NonNull ChatMember chatAdmin, @NonNull ChatMember chatMember) { + this.chatAdmin = Objects.requireNonNull(chatAdmin); + this.chatMember = Objects.requireNonNull(chatMember); + } + + public void execute() { + chatAdmin.delegate(chatMember); + + log.info("방장 권한 위임: {} -> {}", chatAdmin, chatMember); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomLeaveCollection.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomLeaveCollection.java new file mode 100644 index 000000000..ccadb13c1 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomLeaveCollection.java @@ -0,0 +1,63 @@ +package kr.co.pennyway.domain.context.chat.collection; + +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ChatRoomLeaveCollection { + private final ChatMember chatMember; + + public ChatRoomLeaveCollection(ChatMember chatMember) { + this.chatMember = chatMember; + } + + /** + * 채팅방 멤버의 퇴장을 처리합니다. + * + *
+     * [비즈니스 규칙]
+     * - 방장(admin)과 일반 멤버는 서로 다른 퇴장 규칙을 따릅니다.
+     * - 방장은 자신만 채팅방에 남아있는 경우에만 퇴장할 수 있습니다.
+     * - 방장이 퇴장하면 채팅방도 함께 삭제됩니다.
+     * - 일반 멤버는 언제든지 퇴장할 수 있습니다.
+     * 
+ * + * @return ChatRoomLeaveResult 퇴장 처리 결과 (채팅방 삭제 여부 포함) + * @throws ChatMemberErrorException {@link ChatMemberErrorCode#ADMIN_CANNOT_LEAVE} : 다른 멤버가 있는 상태에서 방장이 퇴장을 시도하는 경우 + */ + public ChatRoomLeaveResult leave() { + if (!chatMember.isAdmin()) { + return handleMemberLeave(); + } + + return handleAdminLeave(); + } + + private ChatRoomLeaveResult handleAdminLeave() { + ChatRoom chatRoom = chatMember.getChatRoom(); + if (!chatRoom.hasOnlyAdmin()) { + log.warn("채팅방에 사용자가 남아 있다면, 채팅방 방장은 채팅방을 탈퇴할 수 없습니다. chatRoomId: {}, chatMemberId: {}", + chatRoom.getId(), chatMember.getId()); + throw new ChatMemberErrorException(ChatMemberErrorCode.ADMIN_CANNOT_LEAVE); + } + + chatMember.leave(); + log.info("채팅방 방장이 채팅방을 탈퇴합니다. chatRoom: {}, chatMember: {}", chatRoom, chatMember); + return new ChatRoomLeaveResult(chatMember, true); + } + + private ChatRoomLeaveResult handleMemberLeave() { + chatMember.leave(); + log.info("채팅방 멤버가 채팅방을 탈퇴합니다. chatRoom: {}, chatMember: {}", chatMember.getChatRoom(), chatMember); + return new ChatRoomLeaveResult(chatMember, false); + } + + public record ChatRoomLeaveResult( + ChatMember chatMember, + boolean shouldDeleteChatRoom + ) { + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatMemberBanCommand.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatMemberBanCommand.java new file mode 100644 index 000000000..0af4a4c51 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatMemberBanCommand.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.domain.context.chat.dto; + +public record ChatMemberBanCommand( + Long userId, + Long targetMemberId, + Long chatRoomId +) { + public ChatMemberBanCommand { + if (userId == null) { + throw new IllegalArgumentException("userId must not be null"); + } + if (targetMemberId == null) { + throw new IllegalArgumentException("targetMemberId must not be null"); + } + if (chatRoomId == null) { + throw new IllegalArgumentException("chatRoomId must not be null"); + } + } + + public static ChatMemberBanCommand of(Long adminId, Long targetMemberId, Long chatRoomId) { + return new ChatMemberBanCommand(adminId, targetMemberId, chatRoomId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatMemberJoinCommand.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatMemberJoinCommand.java new file mode 100644 index 000000000..b1479fe17 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatMemberJoinCommand.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.context.chat.dto; + +import org.springframework.lang.NonNull; + +public record ChatMemberJoinCommand( + @NonNull Long userId, + @NonNull Long chatRoomId, + Integer password +) { + public static ChatMemberJoinCommand of(@NonNull Long userId, @NonNull Long chatRoomId, Integer password) { + return new ChatMemberJoinCommand(userId, chatRoomId, password); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatMemberJoinResult.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatMemberJoinResult.java new file mode 100644 index 000000000..5c7157b40 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatMemberJoinResult.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.context.chat.dto; + +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; + +public record ChatMemberJoinResult( + ChatRoom chatRoom, + String memberName, + Long currentMemberCount +) { + public ChatMemberJoinResult(ChatRoom chatRoom, String memberName, Long currentMemberCount) { + this.chatRoom = chatRoom; + this.memberName = memberName; + this.currentMemberCount = currentMemberCount; + } + + public static ChatMemberJoinResult of(ChatRoom chatRoom, String memberName, Long currentMemberCount) { + return new ChatMemberJoinResult(chatRoom, memberName, currentMemberCount); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatPushNotificationContext.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatPushNotificationContext.java new file mode 100644 index 000000000..a6ac2c9f2 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatPushNotificationContext.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.context.chat.dto; + +import java.util.List; + +public record ChatPushNotificationContext( + String senderName, + String senderImageUrl, + List deviceTokens +) { + public static ChatPushNotificationContext of(String senderName, String senderImageUrl, List deviceTokens) { + return new ChatPushNotificationContext(senderName, senderImageUrl, deviceTokens); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomAdminDelegateCommand.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomAdminDelegateCommand.java new file mode 100644 index 000000000..b3033f4de --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomAdminDelegateCommand.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.domain.context.chat.dto; + +public record ChatRoomAdminDelegateCommand( + Long chatRoomId, + Long chatAdminUserId, + Long targetChatMemberId +) { + public ChatRoomAdminDelegateCommand { + if (chatRoomId == null) { + throw new IllegalArgumentException("chatRoomId must not be null"); + } + + if (chatAdminUserId == null) { + throw new IllegalArgumentException("chatAdminUserId must not be null"); + } + + if (targetChatMemberId == null) { + throw new IllegalArgumentException("targetChatMemberId must not be null"); + } + } + + public static ChatRoomAdminDelegateCommand of(Long chatRoomId, Long chatAdminUserId, Long targetChatMemberId) { + return new ChatRoomAdminDelegateCommand(chatRoomId, chatAdminUserId, targetChatMemberId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomDeleteCommand.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomDeleteCommand.java new file mode 100644 index 000000000..c6d354cb5 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomDeleteCommand.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.context.chat.dto; + +public record ChatRoomDeleteCommand( + Long userId, + Long chatRoomId +) { + public ChatRoomDeleteCommand { + if (userId == null) { + throw new IllegalArgumentException("userId must not be null"); + } + if (chatRoomId == null) { + throw new IllegalArgumentException("chatRoomId must not be null"); + } + } + + public static ChatRoomDeleteCommand of(Long userId, Long chatRoomId) { + return new ChatRoomDeleteCommand(userId, chatRoomId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomPatchCommand.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomPatchCommand.java new file mode 100644 index 000000000..b31b2fae9 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomPatchCommand.java @@ -0,0 +1,37 @@ +package kr.co.pennyway.domain.context.chat.dto; + +import org.springframework.util.StringUtils; + +public record ChatRoomPatchCommand( + Long chatRoomId, + String title, + String description, + String backgroundImageUrl, + Integer password +) { + public ChatRoomPatchCommand { + if (chatRoomId == null) { + throw new IllegalArgumentException("채팅방 ID는 NULL이 될 수 없습니다."); + } + + if (!StringUtils.hasText(title) && title.length() >= 50) { + throw new IllegalArgumentException("채팅방 제목은 NULL 혹은 공백을 허용하지 않으며, 1~50자 이내의 문자열이어야 합니다."); + } + + if (description != null && description.length() >= 100) { + throw new IllegalArgumentException("채팅방 설명은 NULL 혹은, 문자가 존재할 시 공백 허용 없이 1~100자 이내의 문자열이어야 합니다."); + } + + if (password != null && password.toString().length() != 6) { + throw new IllegalArgumentException("채팅방 비밀번호는 Null 혹은, 6자리 정수여야 합니다."); + } + + if (backgroundImageUrl != null && !backgroundImageUrl.startsWith("chatroom/")) { + throw new IllegalArgumentException("채팅방 배경 이미지 URL은 'chatroom/' 으로 시작해야 합니다."); + } + } + + public static ChatRoomPatchCommand of(Long chatRoomId, String title, String description, String backgroundImageUrl, Integer password) { + return new ChatRoomPatchCommand(chatRoomId, title, description, backgroundImageUrl, password); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomToggleCommand.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomToggleCommand.java new file mode 100644 index 000000000..7527267a5 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/dto/ChatRoomToggleCommand.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.context.chat.dto; + +public record ChatRoomToggleCommand( + Long userId, + Long chatRoomId +) { + public ChatRoomToggleCommand { + if (userId == null) { + throw new IllegalArgumentException("userId must not be null"); + } + if (chatRoomId == null) { + throw new IllegalArgumentException("chatRoomId must not be null"); + } + } + + public static ChatRoomToggleCommand of(Long userId, Long chatRoomId) { + return new ChatRoomToggleCommand(userId, chatRoomId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMemberBanService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMemberBanService.java new file mode 100644 index 000000000..a4d6cf8ef --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMemberBanService.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.context.chat.dto.ChatMemberBanCommand; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatMemberBanService { + + private final ChatMemberRdbService chatMemberRdbService; + + @Transactional + public void execute(ChatMemberBanCommand command) { + ChatMember admin = chatMemberRdbService.readChatMember(command.userId(), command.chatRoomId()) + .orElseThrow(() -> new ChatMemberErrorException(ChatMemberErrorCode.NOT_FOUND)); + + if (!admin.isAdmin()) { + throw new ChatMemberErrorException(ChatMemberErrorCode.NOT_ADMIN); + } + + ChatMember targetMember = chatMemberRdbService.readChatMemberByChatMemberId(command.targetMemberId()) + .orElseThrow(() -> new ChatMemberErrorException(ChatMemberErrorCode.NOT_FOUND)); + + targetMember.ban(); + + chatMemberRdbService.update(targetMember); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMemberJoinService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMemberJoinService.java new file mode 100644 index 000000000..94fe182e1 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMemberJoinService.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.common.annotation.DistributedLock; +import kr.co.pennyway.domain.context.chat.collection.ChatMemberJoinOperation; +import kr.co.pennyway.domain.context.chat.dto.ChatMemberJoinCommand; +import kr.co.pennyway.domain.context.chat.dto.ChatMemberJoinResult; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorException; +import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomRdbService; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.stream.Collectors; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatMemberJoinService { + private final UserRdbService userRdbService; + private final ChatRoomRdbService chatRoomRdbService; + private final ChatMemberRdbService chatMemberRdbService; + + @DistributedLock(key = "'chat-room-join-' + #command.chatRoomId()", leaseTime = 10L) + public ChatMemberJoinResult execute(ChatMemberJoinCommand command) { + var user = userRdbService.readUser(command.userId()).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + var chatRoom = chatRoomRdbService.readChatRoom(command.chatRoomId()).orElseThrow(() -> new ChatRoomErrorException(ChatRoomErrorCode.NOT_FOUND_CHAT_ROOM)); + var chatMembers = chatMemberRdbService.readChatMembersByChatRoomId(command.chatRoomId()) + .stream().filter(chatMember -> chatMember.isActive() && !chatMember.isBanned()) + .collect(Collectors.toSet()); + + var newChatMember = new ChatMemberJoinOperation(user, chatRoom, chatMembers) + .execute(command.password()); + chatMemberRdbService.create(newChatMember); + + return ChatMemberJoinResult.of(chatRoom, user.getName(), (long) chatMembers.size() + 1); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMemberService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMemberService.java new file mode 100644 index 000000000..ee89464d2 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMemberService.java @@ -0,0 +1,94 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatMemberService { + private final ChatMemberRdbService chatMemberRdbService; + + @Transactional + public ChatMember createAdmin(User user, ChatRoom chatRoom) { + return chatMemberRdbService.createAdmin(user, chatRoom); + } + + @Transactional + public ChatMember createMember(User user, ChatRoom chatRoom) { + return chatMemberRdbService.createMember(user, chatRoom); + } + + @Transactional(readOnly = true) + public Optional readAdmin(Long chatRoomId) { + return chatMemberRdbService.readAdmin(chatRoomId); + } + + @Transactional(readOnly = true) + public Optional readChatMember(Long userId, Long chatMemberId) { + return chatMemberRdbService.readChatMember(userId, chatMemberId); + } + + @Transactional(readOnly = true) + public List readChatMembersByMemberIds(Long chatRoomId, Set chatMemberIds) { + return chatMemberRdbService.readChatMembersByIdIn(chatRoomId, chatMemberIds); + } + + @Transactional(readOnly = true) + public List readChatMembersByUserIds(Long chatRoomId, Set userIds) { + return chatMemberRdbService.readChatMembersByUserIdIn(chatRoomId, userIds); + } + + @Transactional(readOnly = true) + public List readChatMemberIdsByUserIdsNotIn(Long chatRoomId, Set userIds) { + return chatMemberRdbService.readChatMemberIdsByUserIdNotIn(chatRoomId, userIds); + } + + @Transactional(readOnly = true) + public Set readChatRoomIdsByUserId(Long userId) { + return chatMemberRdbService.readChatRoomIdsByUserId(userId); + } + + @Transactional(readOnly = true) + public Set readUserIdsByChatRoomId(Long chatRoomId) { + return chatMemberRdbService.readUserIdsByChatRoomId(chatRoomId); + } + + /** + * 채팅방에 해당 유저가 존재하는지 확인한다. + * 이 때, 삭제된 사용자 데이터는 조회하지 않는다. + */ + @Transactional(readOnly = true) + public boolean isExists(Long chatRoomId, Long userId) { + return chatMemberRdbService.isExists(chatRoomId, userId); + } + + /** + * 삭제된 사용자 데이터는 조회하지 않는다. + */ + @Transactional(readOnly = true) + public boolean isExists(Long chatRoomId, Long userId, Long chatMemberId) { + return chatMemberRdbService.isExists(chatRoomId, userId, chatMemberId); + } + + @Transactional(readOnly = true) + public boolean hasUserChatRoomOwnership(Long userId) { + return chatMemberRdbService.hasUserChatRoomOwnership(userId); + } + + @Transactional(readOnly = true) + public long countActiveMembers(Long chatRoomId) { + return chatMemberRdbService.countActiveMembers(chatRoomId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMessageService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMessageService.java new file mode 100644 index 000000000..6d016f7f8 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMessageService.java @@ -0,0 +1,57 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import kr.co.pennyway.domain.domains.message.service.ChatMessageRedisService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Slice; + +import java.util.List; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatMessageService { + private final ChatMessageRedisService chatMessageRedisService; + + public ChatMessage create(ChatMessage chatMessage) { + return chatMessageRedisService.create(chatMessage); + } + + /** + * 채팅방의 최근 메시지를 조회합니다. + * + * @param roomId Long: 채팅방 ID + * @param limit int: 조회할 메시지 개수 + * @return 최근 시간 순으로 정렬된 최근 메시지 목록 + */ + public List readRecentMessages(Long roomId, int limit) { + return chatMessageRedisService.readRecentMessages(roomId, limit); + } + + /** + * 특정 메시지 ID 이전의 메시지들을 페이징하여 조회합니다. + * 최근 시간 기준으로 정렬된 결과를 반환하며, lastMessageId에 해당하는 메시지는 포함되지 않습니다. + * 만약, lastMessageId에 해당하는 메시지가 필요한 경우 인자는 lastMessageId + 1로 설정해야 합니다. + * + * @param roomId Long: 채팅방 ID + * @param lastMessageId Long: 마지막으로 조회한 메시지의 TSID + * @param size int: 조회할 메시지 개수 + * @return 페이징된 메시지 목록 + */ + public Slice readMessageBefore(Long roomId, Long lastMessageId, int size) { + return chatMessageRedisService.readMessagesBefore(roomId, lastMessageId, size); + } + + /** + * 사용자가 마지막으로 읽은 메시지 이후의 안 읽은 메시지 개수를 조회합니다. + * + * @param roomId Long: 채팅방 ID + * @param lastReadMessageId Long: 사용자가 마지막으로 읽은 메시지의 TSID + * @return Long: 안 읽은 메시지 개수 + */ + public Long countUnreadMessages(Long roomId, Long lastReadMessageId) { + return chatMessageRedisService.countUnreadMessages(roomId, lastReadMessageId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMessageStatusService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMessageStatusService.java new file mode 100644 index 000000000..5b9322c8d --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatMessageStatusService.java @@ -0,0 +1,43 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.chatstatus.service.ChatMessageStatusRdbService; +import kr.co.pennyway.domain.domains.chatstatus.service.ChatMessageStatusRedisService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatMessageStatusService { + private final ChatMessageStatusRdbService rdbService; + private final ChatMessageStatusRedisService redisService; + + /** + * 마지막으로 읽은 메시지 ID를 저장합니다. + * + * @throws IllegalArgumentException 사용자 ID, 채팅방 ID, 메시지 ID가 null이거나 0보다 작을 경우 + */ + public void saveLastReadMessageId(Long userId, Long roomId, Long messageId) { + redisService.saveLastReadMessageId(userId, roomId, messageId); + } + + /** + * 마지막으로 읽은 메시지 ID를 조회합니다. + * + * @return 마지막으로 읽은 메시지 ID가 없을 경우 0을 반환합니다. + */ + @Transactional(readOnly = true) + public Long readLastReadMessageId(Long userId, Long chatRoomId) { + return redisService.readLastReadMessageId(userId, chatRoomId) + .orElseGet(() -> rdbService + .readByUserIdAndChatRoomId(userId, chatRoomId) + .map(status -> { + Long lastReadId = status.getLastReadMessageId(); + redisService.saveLastReadMessageId(userId, chatRoomId, lastReadId); + return lastReadId; + }) + .orElse(0L)); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatNotificationCoordinatorService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatNotificationCoordinatorService.java new file mode 100644 index 000000000..03ea3cd1c --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatNotificationCoordinatorService.java @@ -0,0 +1,154 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.context.chat.dto.ChatPushNotificationContext; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenRdbService; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import kr.co.pennyway.domain.domains.session.domain.UserSession; +import kr.co.pennyway.domain.domains.session.service.UserSessionRedisService; +import kr.co.pennyway.domain.domains.session.type.UserStatus; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatNotificationCoordinatorService { + private final UserRdbService userRdbService; + private final ChatMemberRdbService chatMemberRdbService; + private final DeviceTokenRdbService deviceTokenRdbService; + + private final UserSessionRedisService userSessionRedisService; + + /** + * 채팅방에 참여 중인 사용자들 중에서 푸시 알림을 받아야 하는 사용자들을 판별합니다. + *
+     * [판별 기준]
+     * - 전송자는 푸시 알림을 받지 않습니다.
+     * - 채팅방에 참여 중인 사용자 중에서 채팅방 리스트 뷰를 보고 있지 않는 사용자들만 필터링합니다.
+     * - 사용자 세션 중 하나라도 해당 채팅방 뷰를 보고 있는 경우, 해당 사용자의 전체 세션을 제외합니다.
+     * - 채팅방에 참여 중인 사용자 중에서 채팅 알림을 받지 않는 사용자들은 제외합니다.
+     * - 채팅방에 참여 중인 사용자 중에서 채팅방의 알림을 받지 않는 사용자들은 제외합니다.
+     * 
+ * + * @param senderId Long 전송자 아이디. Must not be null. + * @param chatRoomId Long 채팅방 아이디. Must not be null. + * @return {@link ChatPushNotificationContext} 전송자와 푸시 알림을 받아야 하는 사용자들의 정보를 담은 컨텍스트 + * @throws IllegalArgumentException 전송자 정보를 찾을 수 없을 때 발생합니다. + */ + @Transactional(readOnly = true) + public ChatPushNotificationContext determineRecipients(Long senderId, Long chatRoomId) { + User sender = userRdbService.readUser(senderId).orElseThrow(() -> new IllegalArgumentException("전송자 정보를 찾을 수 없습니다.")); + + Map> participants = getUserSessionGroupByUserId(senderId, chatRoomId); + + Set targets = filterNotificationEnabledUserSessions(participants, chatRoomId); + + List deviceTokens = getDeviceTokens(targets); + + return ChatPushNotificationContext.of(sender.getName(), sender.getProfileImageUrl(), deviceTokens); + } + + /** + *
+     * [STEP]
+     * 1. 채팅방에 참여 중인 사용자 세션들을 가져옴 (사용자 별로 여러 세션이 존재할 수 있음)
+     * 2. 사용자 세션 중에서 전송자는 제외하고, 채팅방에 참여 중 혹은 채팅방 리스트 뷰를 보고 있지 않은 사용자들만 필터링
+     * 3. 사용자 세션을 사용자 아이디 별로 그룹핑
+     * 4. 사용자 세션 중 하나라도 해당 채팅방에 참여 중인 경우, 해당 사용자의 전체 세션 제외
+     * 
+ * + * @return 사용자 아이디 별로 사용자 세션들을 그룹핑한 맵 + */ + private Map> getUserSessionGroupByUserId(Long senderId, Long chatRoomId) { + Set userIds = chatMemberRdbService.readUserIdsByChatRoomId(chatRoomId); + + List> userSessions = userIds.stream() + .filter(userId -> !userId.equals(senderId)) + .map(userSessionRedisService::readAll) + .toList(); + + Map> sessions = userSessions.stream() + .flatMap(userSessionMap -> userSessionMap.entrySet().stream()) + .filter(entry -> isTargetStatus(entry, chatRoomId)) + .collect(Collectors.groupingBy(entry -> entry.getValue().getUserId(), Collectors.mapping(Map.Entry::getValue, Collectors.toSet()))); + + sessions.entrySet().removeIf(entry -> entry.getValue().stream().anyMatch(userSession -> isExistsViewingChatRoom(Map.entry(entry.getKey(), userSession), chatRoomId))); + + return sessions; + } + + /** + * 사용자 세션의 상태가 푸시 알림을 받아야 하는 상태인지 판별합니다. + * + * @return '채팅방 리스트 뷰'를 보고 있지 않은 경우 false를 반환합니다. + */ + private boolean isTargetStatus(Map.Entry entry, Long chatRoomId) { + return !(UserStatus.ACTIVE_CHAT_ROOM_LIST.equals(entry.getValue().getStatus())); + } + + /** + * chatRoomId에 해당하는 채팅방을 보고 있는 사용자 세션이 존재하는지 판별합니다. + */ + private boolean isExistsViewingChatRoom(Map.Entry entry, Long chatRoomId) { + return UserStatus.ACTIVE_CHAT_ROOM.equals(entry.getValue().getStatus()) && chatRoomId.equals(entry.getValue().getCurrentChatRoomId()); + } + + /** + *
+     * [STEP]
+     * 1. 사용자 아이디로 채팅 알림 off 여부 판단. 만약 false면, 해당 사용자는 모두 제외
+     * 2. 사용자 아이디로 채팅방의 알림 off 여부 판단. 만약 false면, 해당 사용자는 모두 제외
+     * 3. 사용자 아이디로 디바이스 토큰을 가져옴
+     * 
+ * + * @return 푸시 알림을 받아야 하는 사용자 세션들 + */ + private Set filterNotificationEnabledUserSessions(Map> participants, Long chatRoomId) { + return participants.entrySet().stream() + .filter(entry -> isChatNotifyEnabled(entry.getKey())) // N개 쿼리 발생 + .filter(entry -> isChatRoomNotifyEnabled(entry.getKey(), chatRoomId)) // N개 쿼리 발생 + .flatMap(entry -> entry.getValue().stream()) + .collect(Collectors.toUnmodifiableSet()); + } + + private boolean isChatNotifyEnabled(Long userId) { + Optional user = userRdbService.readUser(userId); + + return user.isPresent() && user.get().getNotifySetting().isChatNotify(); + } + + private boolean isChatRoomNotifyEnabled(Long userId, Long chatRoomId) { + Optional chatMember = chatMemberRdbService.readChatMember(userId, chatRoomId); + + return chatMember.isPresent() && chatMember.get().isNotifyEnabled(); + } + + /** + * 사용자 세션들 중에서 기기별 활성화된 디바이스 토큰들을 가져옵니다. + * + * @return 활성화된 디바이스 토큰들 + */ + private List getDeviceTokens(Iterable targets) { + List deviceTokens = new ArrayList<>(); + + for (UserSession target : targets) { + deviceTokenRdbService.readAllByUserId(target.getUserId()).stream() + .filter(DeviceToken::isActivated) + .filter(deviceToken -> deviceToken.getDeviceId().equals(target.getDeviceId())) + .findFirst() + .map(DeviceToken::getToken) + .ifPresent(deviceTokens::add); + } + + return deviceTokens; + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomAdminDelegateService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomAdminDelegateService.java new file mode 100644 index 000000000..f64d6e704 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomAdminDelegateService.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.common.annotation.DistributedLock; +import kr.co.pennyway.domain.context.chat.collection.ChatRoomAdminDelegateOperation; +import kr.co.pennyway.domain.context.chat.dto.ChatRoomAdminDelegateCommand; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatRoomAdminDelegateService { + private final ChatMemberRdbService chatMemberRdbService; + + @Transactional + @DistributedLock(key = "'chat-room-admin-delegate-' + #command.chatRoomId()") + public void execute(ChatRoomAdminDelegateCommand command) { + var chatAdmin = chatMemberRdbService.readChatMember(command.chatAdminUserId(), command.chatRoomId()) + .filter(ChatMember::isActive) + .orElseThrow(() -> new ChatMemberErrorException(ChatMemberErrorCode.NOT_FOUND)); + + var targetMember = chatMemberRdbService.readChatMemberByChatMemberId(command.targetChatMemberId()) + .filter(ChatMember::isActive) + .orElseThrow(() -> new ChatMemberErrorException(ChatMemberErrorCode.NOT_FOUND)); + + new ChatRoomAdminDelegateOperation(chatAdmin, targetMember).execute(); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomDeleteService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomDeleteService.java new file mode 100644 index 000000000..4b5c32f25 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomDeleteService.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.context.chat.dto.ChatRoomDeleteCommand; +import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomRdbService; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatRoomDeleteService { + private final ChatMemberRdbService chatMemberRdbService; + private final ChatRoomRdbService chatRoomRdbService; + + @Transactional + public void execute(ChatRoomDeleteCommand command) { + var admin = chatMemberRdbService.readChatMember(command.userId(), command.chatRoomId()) + .orElseThrow(() -> new ChatMemberErrorException(ChatMemberErrorCode.NOT_FOUND)); + + if (!admin.isAdmin()) throw new ChatMemberErrorException(ChatMemberErrorCode.NOT_ADMIN); + + var chatRoom = admin.getChatRoom(); + + chatMemberRdbService.deleteAllByChatRoomId(chatRoom.getId()); + chatRoomRdbService.delete(chatRoom); + + log.info("채팅방이 삭제되었습니다. chatRoom: {}", chatRoom); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomLeaveService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomLeaveService.java new file mode 100644 index 000000000..376478ad5 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomLeaveService.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.context.chat.collection.ChatRoomLeaveCollection; +import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomRdbService; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatRoomLeaveService { + private final ChatMemberRdbService chatMemberRdbService; + private final ChatRoomRdbService chatRoomRdbService; + + @Transactional + public void execute(Long userId, Long chatRoomId) { + ChatMember chatMember = chatMemberRdbService.readChatMember(userId, chatRoomId) + .orElseThrow(() -> { + log.warn("{}번 방에서 {}번 사용자의 채팅방 멤버 정보를를 찾을 수 없습니다.", chatRoomId, userId); + return new ChatMemberErrorException(ChatMemberErrorCode.NOT_FOUND); + }); + + var result = new ChatRoomLeaveCollection(chatMember).leave(); + + chatMemberRdbService.update(result.chatMember()); + if (result.shouldDeleteChatRoom()) { + chatRoomRdbService.delete(chatMember.getChatRoom()); + } + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomNotificationToggleService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomNotificationToggleService.java new file mode 100644 index 000000000..3d4370075 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomNotificationToggleService.java @@ -0,0 +1,37 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.context.chat.dto.ChatRoomToggleCommand; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatRoomNotificationToggleService { + private final ChatMemberRdbService chatMemberRdbService; + + @Transactional + public void turnOn(ChatRoomToggleCommand command) { + var chatMember = chatMemberRdbService.readChatMember(command.userId(), command.chatRoomId()) + .orElseThrow(() -> new ChatMemberErrorException(ChatMemberErrorCode.NOT_FOUND)); + + chatMember.enableNotify(); + + log.info("{}님이 {} 채팅방의 알림을 켰습니다.", chatMember.getId(), command.chatRoomId()); + } + + @Transactional + public void turnOff(ChatRoomToggleCommand command) { + var chatMember = chatMemberRdbService.readChatMember(command.userId(), command.chatRoomId()) + .orElseThrow(() -> new ChatMemberErrorException(ChatMemberErrorCode.NOT_FOUND)); + + chatMember.disableNotify(); + + log.info("{}님이 {} 채팅방의 알림을 껐습니다.", chatMember.getId(), command.chatRoomId()); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomPatchService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomPatchService.java new file mode 100644 index 000000000..bc125010d --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomPatchService.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.context.chat.dto.ChatRoomPatchCommand; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorException; +import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatRoomPatchService { + private final ChatRoomRdbService chatRoomRdbService; + + @Transactional + public ChatRoom execute(ChatRoomPatchCommand command) { + ChatRoom chatRoom = chatRoomRdbService.readChatRoom(command.chatRoomId()) + .orElseThrow(() -> new ChatRoomErrorException(ChatRoomErrorCode.NOT_FOUND_CHAT_ROOM)); + + chatRoom.update(command.title(), command.description(), command.backgroundImageUrl(), command.password()); + return chatRoomRdbService.update(chatRoom); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomService.java new file mode 100644 index 000000000..701b95079 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/chat/service/ChatRoomService.java @@ -0,0 +1,41 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.dto.ChatRoomDetail; +import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class ChatRoomService { + private final ChatRoomRdbService chatRoomRdbService; + + @Transactional + public ChatRoom create(ChatRoom chatRoom) { + return chatRoomRdbService.create(chatRoom); + } + + @Transactional(readOnly = true) + public Optional readChatRoom(Long chatRoomId) { + return chatRoomRdbService.readChatRoom(chatRoomId); + } + + @Transactional(readOnly = true) + public List readChatRoomsByUserId(Long userId) { + return chatRoomRdbService.readChatRoomsByUserId(userId); + } + + @Transactional(readOnly = true) + public Slice readChatRooms(Long userId, String target, Pageable pageable) { + return chatRoomRdbService.readChatRooms(userId, target, pageable); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/finance/service/SpendingCategoryService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/finance/service/SpendingCategoryService.java new file mode 100644 index 000000000..885332467 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/finance/service/SpendingCategoryService.java @@ -0,0 +1,48 @@ +package kr.co.pennyway.domain.context.finance.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class SpendingCategoryService { + private final SpendingCustomCategoryRdbService spendingCustomCategoryRdbService; + + @Transactional + public SpendingCustomCategory createSpendingCustomCategory(SpendingCustomCategory spendingCustomCategory) { + return spendingCustomCategoryRdbService.createSpendingCustomCategory(spendingCustomCategory); + } + + @Transactional(readOnly = true) + public Optional readSpendingCustomCategory(Long id) { + return spendingCustomCategoryRdbService.readSpendingCustomCategory(id); + } + + @Transactional(readOnly = true) + public List readSpendingCustomCategories(Long userId) { + return spendingCustomCategoryRdbService.readSpendingCustomCategories(userId); + } + + @Transactional(readOnly = true) + public boolean isExistsSpendingCustomCategory(Long userId, Long categoryId) { + return spendingCustomCategoryRdbService.isExistsSpendingCustomCategory(userId, categoryId); + } + + @Transactional + public void deleteSpendingCustomCategory(Long categoryId) { + spendingCustomCategoryRdbService.deleteSpendingCustomCategory(categoryId); + } + + @Transactional + public void deleteSpendingCustomCategoriesByUserId(Long userId) { + spendingCustomCategoryRdbService.deleteSpendingCustomCategoriesByUserIdInQuery(userId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/finance/service/SpendingService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/finance/service/SpendingService.java new file mode 100644 index 000000000..d95bef90b --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/finance/service/SpendingService.java @@ -0,0 +1,118 @@ +package kr.co.pennyway.domain.context.finance.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import kr.co.pennyway.domain.domains.spending.service.SpendingRdbService; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class SpendingService { + private final SpendingRdbService spendingRdbService; + + @Transactional + public Spending createSpending(Spending spending) { + return spendingRdbService.createSpending(spending); + } + + @Transactional(readOnly = true) + public Optional readSpending(Long spendingId) { + return spendingRdbService.readSpending(spendingId); + } + + @Transactional(readOnly = true) + public List readSpendings(Long userId, int year, int month) { + return spendingRdbService.readSpendings(userId, year, month); + } + + @Transactional(readOnly = true) + public int readSpendingTotalCountByCategoryId(Long userId, Long categoryId) { + return spendingRdbService.readSpendingTotalCountByCategoryId(userId, categoryId); + } + + @Transactional(readOnly = true) + public int readSpendingTotalCountByCategory(Long userId, SpendingCategory spendingCategory) { + return spendingRdbService.readSpendingTotalCountByCategory(userId, spendingCategory); + } + + @Transactional(readOnly = true) + public Slice readSpendingsSliceByCategoryId(Long userId, Long categoryId, Pageable pageable) { + return spendingRdbService.readSpendingsSliceByCategoryId(userId, categoryId, pageable); + } + + @Transactional(readOnly = true) + public Slice readSpendingsSliceByCategory(Long userId, SpendingCategory spendingCategory, Pageable pageable) { + return spendingRdbService.readSpendingsSliceByCategory(userId, spendingCategory, pageable); + } + + @Transactional(readOnly = true) + public Optional readTotalSpendingAmount(Long userId, LocalDate date) { + return spendingRdbService.readTotalSpendingAmountByUserId(userId, date); + } + + @Transactional(readOnly = true) + public List readTotalSpendingsAmountByUserId(Long userId) { + return spendingRdbService.readTotalSpendingsAmountByUserId(userId); + } + + @Transactional(readOnly = true) + public boolean isExistsSpending(Long userId, Long spendingId) { + return spendingRdbService.isExistsSpending(userId, spendingId); + } + + @Transactional(readOnly = true) + public long countByUserIdAndSpendingIds(Long userId, List spendingIds) { + return spendingRdbService.countByUserIdAndIdIn(userId, spendingIds); + } + + @Transactional + public void updateCategoryByCustomCategory(SpendingCategory fromCategory, Long toId) { + spendingRdbService.updateCategoryByCustomCategory(fromCategory, toId); + } + + @Transactional + public void updateCategoryByCategory(SpendingCategory fromCategory, SpendingCategory toCategory) { + spendingRdbService.updateCategoryByCategory(fromCategory, toCategory); + } + + @Transactional + public void updateCustomCategoryByCustomCategory(Long fromId, Long toId) { + spendingRdbService.updateCustomCategoryByCustomCategory(fromId, toId); + } + + @Transactional + public void updateCustomCategoryByCategory(Long fromId, SpendingCategory toCategory) { + spendingRdbService.updateCustomCategoryByCategory(fromId, toCategory); + } + + @Transactional + public void deleteSpending(Spending spending) { + spendingRdbService.deleteSpending(spending); + } + + @Transactional + public void deleteSpendings(List spendingIds) { + spendingRdbService.deleteSpendingsInQuery(spendingIds); + } + + @Transactional + public void deleteSpendingsByUserId(Long userId) { + spendingRdbService.deleteSpendingsByUserIdInQuery(userId); + } + + @Transactional + public void deleteSpendingsByCategoryId(Long categoryId) { + spendingRdbService.deleteSpendingsByCategoryIdInQuery(categoryId); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/finance/service/TargetAmountService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/finance/service/TargetAmountService.java new file mode 100644 index 000000000..2bf5ef835 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/finance/service/TargetAmountService.java @@ -0,0 +1,59 @@ +package kr.co.pennyway.domain.context.finance.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.service.TargetAmountRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class TargetAmountService { + private final TargetAmountRdbService targetAmountRdbService; + + @Transactional + public TargetAmount createTargetAmount(TargetAmount targetAmount) { + return targetAmountRdbService.createTargetAmount(targetAmount); + } + + @Transactional(readOnly = true) + public Optional readTargetAmount(Long id) { + return targetAmountRdbService.readTargetAmount(id); + } + + @Transactional(readOnly = true) + public Optional readTargetAmountThatMonth(Long userId, LocalDate date) { + return targetAmountRdbService.readTargetAmountThatMonth(userId, date); + } + + @Transactional(readOnly = true) + public List readTargetAmountsByUserId(Long userId) { + return targetAmountRdbService.readTargetAmountsByUserId(userId); + } + + @Transactional(readOnly = true) + public Optional readRecentTargetAmount(Long userId) { + return targetAmountRdbService.readRecentTargetAmount(userId); + } + + @Transactional(readOnly = true) + public boolean isExistsTargetAmountThatMonth(Long userId, LocalDate date) { + return targetAmountRdbService.isExistsTargetAmountThatMonth(userId, date); + } + + @Transactional(readOnly = true) + public boolean isExistsTargetAmountByIdAndUserId(Long id, Long userId) { + return targetAmountRdbService.isExistsTargetAmountByIdAndUserId(id, userId); + } + + @Transactional + public void deleteTargetAmount(TargetAmount targetAmount) { + targetAmountRdbService.deleteTargetAmount(targetAmount); + } +} diff --git a/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/support/service/QuestionService.java b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/support/service/QuestionService.java new file mode 100644 index 000000000..e514fbbf4 --- /dev/null +++ b/pennyway-domain/domain-service/src/main/java/kr/co/pennyway/domain/context/support/service/QuestionService.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.context.support.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.question.domain.Question; +import kr.co.pennyway.domain.domains.question.service.QuestionRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class QuestionService { + private final QuestionRdbService questionRdbService; + + @Transactional + public void createQuestion(Question question) { + questionRdbService.createQuestion(question); + } +} diff --git a/pennyway-domain/domain-service/src/main/resources/application-domain-service.yml b/pennyway-domain/domain-service/src/main/resources/application-domain-service.yml new file mode 100644 index 000000000..3756eb29b --- /dev/null +++ b/pennyway-domain/domain-service/src/main/resources/application-domain-service.yml @@ -0,0 +1,23 @@ +spring: + profiles: + group: + local: common, domain-rds, domain-redis + dev: common, domain-rds, domain-redis + +--- +spring: + config: + activate: + on-profile: local + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: test \ No newline at end of file diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/config/DomainServiceIntegrationProfileResolver.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/config/DomainServiceIntegrationProfileResolver.java new file mode 100644 index 000000000..cdd3a1f9d --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/config/DomainServiceIntegrationProfileResolver.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.domain.config; + +import org.springframework.lang.NonNull; +import org.springframework.test.context.ActiveProfilesResolver; + +public class DomainServiceIntegrationProfileResolver implements ActiveProfilesResolver { + @Override + @NonNull + public String[] resolve(@NonNull Class testClass) { + return new String[]{"test", "common", "infra", "domain-rdb", "domain-redis"}; + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/config/DomainServiceTestInfraConfig.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/config/DomainServiceTestInfraConfig.java new file mode 100644 index 000000000..c71a3b850 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/config/DomainServiceTestInfraConfig.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.domain.config; + +import com.redis.testcontainers.RedisContainer; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +public abstract class DomainServiceTestInfraConfig { + private static final String REDIS_CONTAINER_IMAGE = "redis:7.4"; + private static final String MYSQL_CONTAINER_IMAGE = "mysql:8.0.26"; + + private static final RedisContainer REDIS_CONTAINER; + private static final MySQLContainer MYSQL_CONTAINER; + + static { + REDIS_CONTAINER = + new RedisContainer(DockerImageName.parse(REDIS_CONTAINER_IMAGE)) + .withExposedPorts(6379) + .withCommand("redis-server", "--requirepass testpass") + .withReuse(true); + MYSQL_CONTAINER = + new MySQLContainer<>(DockerImageName.parse(MYSQL_CONTAINER_IMAGE)) + .withDatabaseName("pennyway") + .withUsername("root") + .withPassword("testpass") + .withCommand("--sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION") + .withReuse(true); + + REDIS_CONTAINER.start(); + MYSQL_CONTAINER.start(); + } + + @DynamicPropertySource + public static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379))); + registry.add("spring.data.redis.password", () -> "testpass"); + registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=Asia/Seoul&characterEncoding=utf8&postfileSQL=true&logger=Slf4JLogger&rewriteBatchedStatements=true", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306))); + registry.add("spring.datasource.username", () -> "root"); + registry.add("spring.datasource.password", () -> "testpass"); + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/config/JpaTestConfig.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/config/JpaTestConfig.java new file mode 100644 index 000000000..da6585354 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/config/JpaTestConfig.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.domain.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.querydsl.sql.MySQLTemplates; +import com.querydsl.sql.SQLTemplates; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@TestConfiguration +@EnableJpaAuditing +public class JpaTestConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } + + @Bean + public SQLTemplates sqlTemplates() { + return new MySQLTemplates(); + } +} \ No newline at end of file diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/account/integration/DeviceTokenRegisterServiceIntegrationTest.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/account/integration/DeviceTokenRegisterServiceIntegrationTest.java new file mode 100644 index 000000000..37b3c5277 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/account/integration/DeviceTokenRegisterServiceIntegrationTest.java @@ -0,0 +1,176 @@ +package kr.co.pennyway.domain.context.account.integration; + +import kr.co.pennyway.domain.common.repository.ExtendedRepositoryFactory; +import kr.co.pennyway.domain.config.DomainServiceIntegrationProfileResolver; +import kr.co.pennyway.domain.config.DomainServiceTestInfraConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.context.account.service.DeviceTokenRegisterService; +import kr.co.pennyway.domain.context.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; +import kr.co.pennyway.domain.domains.device.repository.DeviceTokenRepository; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenRdbService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.service.UserRdbService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@EnableAutoConfiguration +@SpringBootTest(classes = {DeviceTokenRegisterService.class, UserRdbService.class, DeviceTokenRdbService.class}) +@EntityScan(basePackageClasses = {User.class, DeviceToken.class}) +@EnableJpaRepositories(basePackageClasses = {UserRepository.class, DeviceTokenRepository.class}, repositoryFactoryBeanClass = ExtendedRepositoryFactory.class) +@ActiveProfiles(resolver = DomainServiceIntegrationProfileResolver.class) +@Import(value = {JpaTestConfig.class}) +public class DeviceTokenRegisterServiceIntegrationTest extends DomainServiceTestInfraConfig { + @Autowired + private DeviceTokenRegisterService deviceTokenRegisterService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DeviceTokenRepository deviceTokenRepository; + + private User savedUser; + + @BeforeEach + void setUp() { + savedUser = userRepository.save(UserFixture.GENERAL_USER.toUser()); + } + + @AfterEach + void tearDown() { + deviceTokenRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + @DisplayName("새로운 토큰 등록 요청 시 디바이스 토큰이 정상적으로 저장됩니다") + void when_registering_new_token_then_save_successfully() { + // given + String deviceId = "newDevice"; + String token = "newToken"; + + // when + DeviceToken result = deviceTokenRegisterService.execute(savedUser.getId(), deviceId, "Android", token); + + // then + // 1. 반환된 결과 검증 + assertEquals(token, result.getToken()); + assertEquals(deviceId, result.getDeviceId()); + assertEquals(savedUser.getId(), result.getUser().getId()); + assertTrue(result.isActivated()); + + // 2. 실제 DB 저장 여부 검증 + DeviceToken savedToken = deviceTokenRepository.findById(result.getId()) + .orElseThrow(() -> new IllegalStateException("저장된 토큰을 찾을 수 없습니다.")); + + assertEquals(token, savedToken.getToken()); + assertEquals(deviceId, savedToken.getDeviceId()); + assertEquals(savedUser.getId(), savedToken.getUser().getId()); + assertTrue(savedToken.isActivated()); + } + + @Test + @DisplayName("사용자가 동일한 디바이스에 새로운 토큰을 등록하면 기존 토큰이 비활성화됩니다") + void when_registering_new_token_for_same_device_then_deactivate_existing_token() { + // given + String deviceId = "device1"; + DeviceToken firstToken = deviceTokenRepository.save(DeviceToken.of("token1", deviceId, "Android", savedUser)); + + // when + DeviceToken secondToken = deviceTokenRegisterService.execute(savedUser.getId(), deviceId, "Android", "token2"); + + // then + assertTrue(secondToken.isActivated()); + assertEquals(deviceId, secondToken.getDeviceId()); + + // DB에 실제로 저장되었는지 확인 + List savedTokens = deviceTokenRepository.findAllByUser_Id(savedUser.getId()); + assertEquals(2, savedTokens.size()); + assertEquals(1L, savedTokens.stream().filter(DeviceToken::isActivated).count(), "활성화된 토큰은 1개여야 합니다"); + } + + @Test + @DisplayName("활성화된 토큰을 다른 디바이스에서 사용하려고 하면 예외가 발생합니다") + void when_using_active_token_on_different_device_then_throw_exception() { + // given + String token = "token1"; + deviceTokenRepository.save(DeviceToken.of(token, "device1", "iPhone", savedUser)); + + // when & then + DeviceTokenErrorException exception = assertThrowsExactly( + DeviceTokenErrorException.class, + () -> deviceTokenRegisterService.execute(savedUser.getId(), "device2", "iPhone", token) + ); + assertEquals(DeviceTokenErrorCode.DUPLICATED_DEVICE_TOKEN, exception.getBaseErrorCode()); + } + + @Test + @DisplayName("같은 deviceId, token / 다른 사용자 갱신 요청이라면, 디바이스 토큰의 소유권이 다른 사용자에게 이전됩니다") + void shouldTransferTokenOwnership() { + // given + User anotherUser = userRepository.save(UserFixture.GENERAL_USER.toUser()); + + String deviceId = "device1"; + String token = "token1"; + + DeviceToken firstUserToken = deviceTokenRepository.save(DeviceToken.of(token, deviceId, "Android", savedUser)); + + // when + DeviceToken secondUserToken = deviceTokenRegisterService.execute(anotherUser.getId(), deviceId, "Android", token); + + // then + assertEquals(firstUserToken.getId(), secondUserToken.getId()); + assertEquals(anotherUser.getId(), secondUserToken.getUser().getId()); + assertTrue(secondUserToken.isActivated()); + + List firstUserTokens = deviceTokenRepository.findAllByUser_Id(savedUser.getId()); + List secondUserTokens = deviceTokenRepository.findAllByUser_Id(anotherUser.getId()); + + assertTrue(firstUserTokens.isEmpty(), "첫 번째 사용자의 토큰이 없어야 합니다"); + assertEquals(1, secondUserTokens.size(), "두 번째 사용자의 토큰이 1개 있어야 합니다"); + } + + @Test + @DisplayName("사용자는 여러 기기에 서로 다른 토큰을 등록할 수 있습니다") + void when_user_registers_multiple_devices_then_allow_different_tokens() { + // given + String device1Token = "token1"; + String device2Token = "token2"; + + // when + DeviceToken result1 = deviceTokenRegisterService.execute(savedUser.getId(), "device1", "Android", device1Token); + DeviceToken result2 = deviceTokenRegisterService.execute(savedUser.getId(), "device2", "iPhone", device2Token); + + // then + assertTrue(result1.isActivated()); + assertTrue(result2.isActivated()); + assertNotEquals(result1.getDeviceId(), result2.getDeviceId()); + assertNotEquals(result1.getToken(), result2.getToken()); + + // DB에 실제로 저장되었는지 확인 + List savedTokens = deviceTokenRepository.findAllByUser_Id(savedUser.getId()); + assertEquals(2, savedTokens.size()); + assertTrue(savedTokens.stream().allMatch(DeviceToken::isActivated)); + + deviceTokenRepository.deleteAll(savedTokens); + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/account/service/DeviceTokenRegisterServiceTest.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/account/service/DeviceTokenRegisterServiceTest.java new file mode 100644 index 000000000..6ecc4618a --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/account/service/DeviceTokenRegisterServiceTest.java @@ -0,0 +1,158 @@ +package kr.co.pennyway.domain.context.account.service; + +import kr.co.pennyway.domain.context.account.collection.DeviceTokenRegisterCollection; +import kr.co.pennyway.domain.context.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; +import kr.co.pennyway.domain.domains.user.domain.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class DeviceTokenRegisterServiceTest { + @Test + @DisplayName("새로운 토큰 등록 시 올바른 정보로 생성됩니다") + void when_user_has_no_token_should_create_new_token() { + // given + DeviceTokenRegisterCollection deviceTokenRegisterCollection = new DeviceTokenRegisterCollection(); + + User user = UserFixture.GENERAL_USER.toUserWithCustomSetting(1L, "jayang", "Yang", UserFixture.GENERAL_USER.getNotifySetting()); + String expectedToken = "token1"; + String expectedDeviceId = "재서의 까리한 플립"; + String expectedDeviceName = "Galaxy Flip 6"; + + // when + DeviceToken actual = deviceTokenRegisterCollection.register(user, expectedDeviceId, expectedDeviceName, expectedToken); + + // then + assertEquals(expectedToken, actual.getToken()); + assertEquals(expectedDeviceId, actual.getDeviceId()); + assertEquals(expectedDeviceName, actual.getDeviceName()); + assertEquals(user, actual.getUser()); + } + + @Test + @DisplayName("이미 소유 중인 토큰인 경우 마지막 로그인 날짜만 갱신합니다") + void when_token_exists_should_update_last_signed_at() { + // given + User owner = UserFixture.GENERAL_USER.toUser(); + String token = "token1"; + String deviceId = "device1"; + DeviceToken existingToken = DeviceToken.of(token, deviceId, "Android", owner); + DeviceTokenRegisterCollection collection = new DeviceTokenRegisterCollection(existingToken); + + // when + DeviceToken actual = collection.register(owner, deviceId, "Android", token); + + // then + assertEquals(existingToken, actual); + assertEquals(owner, actual.getUser()); + assertTrue(actual.isActivated()); + } + + @Test + @DisplayName("동일한 deviceToken이 이미 비활성화된 상태로 등록되어 있다면, 소유자와 마지막 로그인 시간을 변경하고 토큰을 활성화한다.") + void when_token_exists_should_update_owner_and_last_signed_at() { + // given + User originalOwner = UserFixture.GENERAL_USER.toUserWithCustomSetting(1L, "jayang", "Yang", UserFixture.GENERAL_USER.getNotifySetting()); + DeviceToken existingToken = DeviceToken.of("token1", "device1", "Android", originalOwner); + existingToken.deactivate(); + DeviceTokenRegisterCollection deviceTokenRegisterCollection = new DeviceTokenRegisterCollection(existingToken); + + User newOwner = UserFixture.GENERAL_USER.toUserWithCustomSetting(2L, "another", "User", UserFixture.GENERAL_USER.getNotifySetting()); + String expectedToken = "token1"; + String expectedDeviceId = "device1"; + String expectedDeviceName = "Android"; + + // when + DeviceToken actual = deviceTokenRegisterCollection.register(newOwner, expectedDeviceId, expectedDeviceName, expectedToken); + + // then + assertTrue(actual.isActivated()); + assertEquals(expectedToken, actual.getToken()); + assertEquals(expectedDeviceId, actual.getDeviceId()); + assertEquals(expectedDeviceName, actual.getDeviceName()); + assertEquals(newOwner, actual.getUser()); + } + + @Test + @DisplayName("새로운 토큰 등록 시, 디바이스의 기존 활성 토큰들은 비활성화되고, 새로 등록된 토큰이 반환됩니다") + void when_registering_new_token_should_deactivate_existing_token_for_same_device() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + DeviceToken existingToken = DeviceToken.of("oldToken", "device1", "Android", user); + List userTokens = List.of(existingToken); + + String expectedToken = "newToken"; + + DeviceTokenRegisterCollection collection = new DeviceTokenRegisterCollection(null, userTokens); + + // when + DeviceToken actual = collection.register(user, "device1", "Android", expectedToken); + + // then + assertFalse(existingToken.isActivated()); + assertTrue(actual.isActivated()); + assertEquals(expectedToken, actual.getToken()); + } + + + @Test + @DisplayName("다른 디바이스에서 이미 사용 중인 활성화 토큰으로 등록을 시도하면 예외가 발생합니다") + void should_throw_duplicate_exception_when_token_exists_for_different_device_id() { + // given + User owner = UserFixture.GENERAL_USER.toUserWithCustomSetting(1L, "jayang", "Yang", UserFixture.GENERAL_USER.getNotifySetting()); + User hacker = UserFixture.GENERAL_USER.toUserWithCustomSetting(2L, "another", "User", UserFixture.GENERAL_USER.getNotifySetting()); + + String token = "token1"; + DeviceToken existingToken = DeviceToken.of(token, "token1", "Android", owner); + + DeviceTokenRegisterCollection deviceTokenRegisterCollection = new DeviceTokenRegisterCollection(existingToken); + + // when & then + assertThrows(DeviceTokenErrorException.class, () -> deviceTokenRegisterCollection.register(hacker, "HACKER_DEVICE_ID", "Android", token)); + } + + @Test + @DisplayName("비활성화된 토큰은 다른 디바이스에서도 재사용할 수 있습니다") + void when_token_deactivated_should_allow_reuse_on_different_device() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + String token = "token1"; + String newDeviceId = "newDeviceId"; + + DeviceToken existingToken = DeviceToken.of(token, "old-device", "Android", user); + existingToken.deactivate(); + DeviceTokenRegisterCollection collection = new DeviceTokenRegisterCollection(existingToken); + + // when + DeviceToken actual = collection.register(user, newDeviceId, "Android", token); + + // then + assertEquals(existingToken, actual); + assertEquals(newDeviceId, actual.getDeviceId()); + assertTrue(actual.isActivated()); + } + + @Test + @DisplayName("같은 사용자가 같은 디바이스에 대해 새로운 토큰을 등록하면 기존 토큰은 비활성화됩니다") + void when_registering_different_token_for_same_device_should_deactivate_existing() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + DeviceToken existingToken = DeviceToken.of("oldToken", "device1", "Android", user); + List userTokens = List.of(existingToken); + + DeviceTokenRegisterCollection collection = new DeviceTokenRegisterCollection(null, userTokens); + + // when + DeviceToken result = collection.register(user, "device1", "Android", "newToken"); + + // then + assertFalse(existingToken.isActivated()); + assertTrue(result.isActivated()); + assertEquals("newToken", result.getToken()); + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/collection/ChatMemberJoinOperationTest.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/collection/ChatMemberJoinOperationTest.java new file mode 100644 index 000000000..6f58f1fb3 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/collection/ChatMemberJoinOperationTest.java @@ -0,0 +1,130 @@ +package kr.co.pennyway.domain.context.chat.collection; + +import kr.co.pennyway.domain.context.common.fixture.ChatRoomFixture; +import kr.co.pennyway.domain.context.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorException; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ChatMemberJoinOperationTest { + private static final long MAX_MEMBER_COUNT = 300L; + + @Test + @DisplayName("이미 채팅방에 참여한 사용자가 다시 참여할 때 가입에 실패한다") + void failWhenAlreadyJoined() { + // given + var user = createUser(1L); + var chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(); + var chatMembers = List.of(ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER)); + + var operation = new ChatMemberJoinOperation(user, chatRoom, chatMembers); + + // when & then + assertThatThrownBy(() -> operation.execute(chatRoom.getPassword())) + .isInstanceOf(ChatMemberErrorException.class) + .hasFieldOrPropertyWithValue("baseErrorCode", ChatMemberErrorCode.ALREADY_JOINED); + } + + @Test + @DisplayName("채팅방이 가득 찼을 때 (정원 300명) 가입에 실패한다") + void failWhenChatRoomIsFull() { + // given + var user = createUser(1L); + var chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(); + var chatMembers = IntStream.range(0, 300) + .mapToObj(i -> createChatMember(i + 2L, chatRoom)) // userId 2~301 + .collect(Collectors.toSet()); + + var operation = new ChatMemberJoinOperation(user, chatRoom, chatMembers); + + // when & then + assertThatThrownBy(() -> operation.execute(chatRoom.getPassword())) + .isInstanceOf(ChatRoomErrorException.class) + .hasFieldOrPropertyWithValue("baseErrorCode", ChatRoomErrorCode.FULL_CHAT_ROOM); + } + + @Test + @DisplayName("비공개 채팅방의 비밀번호가 일치하지 않을 때 가입에 실패한다") + void failWhenPasswordIsNotMatch() { + // given + var user = createUser(1L); + var chatRoom = ChatRoomFixture.PRIVATE_CHAT_ROOM.toEntity(); + var chatMembers = new HashSet(); + + var operation = new ChatMemberJoinOperation(user, chatRoom, chatMembers); + + // when & then + assertThatThrownBy(() -> operation.execute(235676)) + .isInstanceOf(ChatRoomErrorException.class) + .hasFieldOrPropertyWithValue("baseErrorCode", ChatRoomErrorCode.INVALID_PASSWORD); + } + + @Test + @DisplayName("채팅방 수용 인원이 남아있고, 공개 채팅방이라면 비밀번호 검증을 수행하지 않는다") + void successWhenChatRoomIsNotFullAndPublic() { + // given + var user = createUser(1L); + var chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(); + var chatMembers = new HashSet(); + + var operation = new ChatMemberJoinOperation(user, chatRoom, chatMembers); + + // when + ChatMember result = operation.execute(chatRoom.getPassword()); + + // then + assertAll( + () -> assertEquals(user.getId(), result.getUserId()), + () -> assertEquals(chatRoom, result.getChatRoom()), + () -> assertEquals(ChatMemberRole.MEMBER, result.getRole()) + ); + } + + @Test + @DisplayName("채팅방 수용 인원이 남아있고, 비공개 채팅방이라면 비밀번호 검증을 수행한다") + void successWhenChatRoomIsNotFullAndPrivate() { + // given + var user = createUser(1L); + var chatRoom = ChatRoomFixture.PRIVATE_CHAT_ROOM.toEntity(); + var chatMembers = new HashSet(); + + var operation = new ChatMemberJoinOperation(user, chatRoom, chatMembers); + + // when + ChatMember result = operation.execute(chatRoom.getPassword()); + + // then + assertAll( + () -> assertEquals(user.getId(), result.getUserId()), + () -> assertEquals(chatRoom, result.getChatRoom()), + () -> assertEquals(ChatMemberRole.MEMBER, result.getRole()) + ); + } + + private User createUser(Long userId) { + var user = UserFixture.GENERAL_USER.toUser(); + ReflectionTestUtils.setField(user, "id", userId); + return user; + } + + private ChatMember createChatMember(Long userId, ChatRoom chatRoom) { + return ChatMember.of(createUser(userId), chatRoom, ChatMemberRole.MEMBER); + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomAdminDelegateOperationTest.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomAdminDelegateOperationTest.java new file mode 100644 index 000000000..0da0b84f9 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomAdminDelegateOperationTest.java @@ -0,0 +1,96 @@ +package kr.co.pennyway.domain.context.chat.collection; + +import kr.co.pennyway.domain.context.common.fixture.ChatRoomFixture; +import kr.co.pennyway.domain.context.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ChatRoomAdminDelegateOperationTest { + + @Test + @DisplayName("방장은 다른 멤버에게 방장 권한을 위임할 수 있다") + void shouldDelegateAdmin() { + // given + var chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(); + + var chatAdmin = createChatMember(1L, 1L, ChatMemberRole.ADMIN, chatRoom); + var chatMember = createChatMember(2L, 2L, ChatMemberRole.MEMBER, chatRoom); + + var operation = new ChatRoomAdminDelegateOperation(chatAdmin, chatMember); + + // when + operation.execute(); + + // then + assertEquals(ChatMemberRole.ADMIN, chatMember.getRole()); + assertEquals(ChatMemberRole.MEMBER, chatAdmin.getRole()); + } + + @Test + @DisplayName("방장이 아닌 멤버는 방장 권한을 위임할 수 없다") + void shouldNotDelegateAdmin() { + // given + var chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(); + + var chatAdmin = createChatMember(1L, 1L, ChatMemberRole.MEMBER, chatRoom); + var chatMember = createChatMember(2L, 2L, ChatMemberRole.MEMBER, chatRoom); + + var operation = new ChatRoomAdminDelegateOperation(chatAdmin, chatMember); + + // when & then + assertThatThrownBy(operation::execute) + .isInstanceOf(ChatMemberErrorException.class) + .hasFieldOrPropertyWithValue("chatMemberErrorCode", ChatMemberErrorCode.NOT_ADMIN); + } + + @Test + @DisplayName("방장은 자기자신에게 방장 권한을 위임할 수 없다") + void shouldNotDelegateAdminToSelf() { + // given + var chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(); + var chatAdmin = createChatMember(1L, 1L, ChatMemberRole.ADMIN, chatRoom); + + var operation = new ChatRoomAdminDelegateOperation(chatAdmin, chatAdmin); + + // when & then + assertThatThrownBy(operation::execute) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("다른 채팅방의 멤버에게 방장 권한을 위임할 수 없다") + void shouldNotDelegateAdminToMemberInOtherChatRoom() { + // given + var chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L); + var otherChatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(2L); + + var chatAdmin = createChatMember(1L, 1L, ChatMemberRole.ADMIN, chatRoom); + var chatMember = createChatMember(2L, 2L, ChatMemberRole.MEMBER, otherChatRoom); + + var operation = new ChatRoomAdminDelegateOperation(chatAdmin, chatMember); + + // when & then + assertThatThrownBy(operation::execute) + .isInstanceOf(ChatMemberErrorException.class) + .hasFieldOrPropertyWithValue("chatMemberErrorCode", ChatMemberErrorCode.NOT_SAME_CHAT_ROOM); + } + + private ChatMember createChatMember(Long userId, Long chatMemberId, ChatMemberRole role, ChatRoom chatRoom) { + var user = UserFixture.GENERAL_USER.toUser(); + ReflectionTestUtils.setField(user, "id", userId); + + var chatMember = ChatMember.of(user, chatRoom, role); + ReflectionTestUtils.setField(chatMember, "id", chatMemberId); + + return chatMember; + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomLeaveCollectionTest.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomLeaveCollectionTest.java new file mode 100644 index 000000000..fc59302d0 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/collection/ChatRoomLeaveCollectionTest.java @@ -0,0 +1,76 @@ +package kr.co.pennyway.domain.context.chat.collection; + +import kr.co.pennyway.domain.context.common.fixture.ChatRoomFixture; +import kr.co.pennyway.domain.context.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +public class ChatRoomLeaveCollectionTest { + @Test + @DisplayName("일반 멤버는 언제든지 퇴장할 수 있다") + void normalMemberShouldBeAbleToLeaveAnytime() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(); + ChatMember normalMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + + ChatRoomLeaveCollection collection = new ChatRoomLeaveCollection(normalMember); + + // when + ChatRoomLeaveCollection.ChatRoomLeaveResult result = collection.leave(); + + // then + assertEquals(normalMember, result.chatMember()); + assertFalse(result.shouldDeleteChatRoom()); + assertNotNull(normalMember.getDeletedAt()); + } + + @Test + @DisplayName("방장이 혼자 남은 경우 퇴장할 수 있고, 채팅방이 삭제되어야 한다") + void adminShouldBeAbleToLeaveWhenAloneAndRoomShouldBeDeleted() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(); + ChatMember adminMember = ChatMember.of(user, chatRoom, ChatMemberRole.ADMIN); + + ChatRoomLeaveCollection collection = new ChatRoomLeaveCollection(adminMember); + + // when + ChatRoomLeaveCollection.ChatRoomLeaveResult result = collection.leave(); + + // then + assertEquals(adminMember, result.chatMember()); + assertTrue(result.shouldDeleteChatRoom()); + assertNotNull(adminMember.getDeletedAt()); + } + + @Test + @DisplayName("다른 멤버가 있는 경우 방장은 퇴장할 수 없다") + void adminShouldNotBeAbleToLeaveWhenOtherMembersExist() { + // given + User admin = UserFixture.GENERAL_USER.toUser(); + User normal = UserFixture.GENERAL_USER.toUser(); + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(); + ChatMember adminMember = ChatMember.of(admin, chatRoom, ChatMemberRole.ADMIN); + ChatMember normalMember = ChatMember.of(normal, chatRoom, ChatMemberRole.MEMBER); + + ChatRoomLeaveCollection collection = new ChatRoomLeaveCollection(adminMember); + + // when + assertThatThrownBy(collection::leave) + .isInstanceOf(ChatMemberErrorException.class) + .hasFieldOrPropertyWithValue("chatMemberErrorCode", ChatMemberErrorCode.ADMIN_CANNOT_LEAVE); + + // then + assertNull(adminMember.getDeletedAt()); + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatMemberJoinServiceIntegrationTest.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatMemberJoinServiceIntegrationTest.java new file mode 100644 index 000000000..0aa817779 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatMemberJoinServiceIntegrationTest.java @@ -0,0 +1,137 @@ +package kr.co.pennyway.domain.context.chat.integration; + +import jakarta.transaction.Transactional; +import kr.co.pennyway.domain.common.repository.ExtendedRepositoryFactory; +import kr.co.pennyway.domain.config.DomainServiceIntegrationProfileResolver; +import kr.co.pennyway.domain.config.DomainServiceTestInfraConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.context.chat.dto.ChatMemberJoinCommand; +import kr.co.pennyway.domain.context.chat.dto.ChatMemberJoinResult; +import kr.co.pennyway.domain.context.chat.service.ChatMemberJoinService; +import kr.co.pennyway.domain.context.common.fixture.ChatRoomFixture; +import kr.co.pennyway.domain.context.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorException; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; +import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomRdbService; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.service.UserRdbService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@EnableAutoConfiguration +@SpringBootTest(classes = { + ChatMemberJoinService.class, + UserRdbService.class, + ChatRoomRdbService.class, + ChatMemberRdbService.class +}) +@EntityScan(basePackageClasses = {User.class, ChatRoom.class, ChatMember.class}) +@EnableJpaRepositories(basePackageClasses = { + UserRepository.class, + ChatRoomRepository.class, + ChatMemberRepository.class +}, repositoryFactoryBeanClass = ExtendedRepositoryFactory.class) +@ActiveProfiles(resolver = DomainServiceIntegrationProfileResolver.class) +@Import(JpaTestConfig.class) +public class ChatMemberJoinServiceIntegrationTest extends DomainServiceTestInfraConfig { + @Autowired + private ChatMemberJoinService sut; + + @Autowired + private ChatMemberRepository chatMemberRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private UserRepository userRepository; + + @Test + @Transactional + @DisplayName("채팅방 가입 성공") + void successJoinRoom() { + // given + var user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity()); + var command = ChatMemberJoinCommand.of(user.getId(), chatRoom.getId(), null); + + // when + ChatMemberJoinResult result = sut.execute(command); + + // then + assertAll( + () -> assertEquals(chatRoom, result.chatRoom()), + () -> assertEquals(user.getName(), result.memberName()), + () -> assertEquals(1L, result.currentMemberCount()), + () -> assertTrue(chatMemberRepository.existsByChatRoomIdAndUserId(chatRoom.getId(), user.getId())) + ); + } + + @Test + @Transactional + @DisplayName("이미 가입된 사용자는 재가입할 수 없다") + void failWhenAlreadyJoined() { + // given + var user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity()); + chatMemberRepository.save(ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER)); + + var command = ChatMemberJoinCommand.of(user.getId(), chatRoom.getId(), null); + + // when & then + assertThatThrownBy(() -> sut.execute(command)) + .isInstanceOf(ChatMemberErrorException.class) + .hasFieldOrPropertyWithValue("baseErrorCode", ChatMemberErrorCode.ALREADY_JOINED); + } + + @Test + @Transactional + @DisplayName("채팅방 가입시 채팅방이 존재하지 않으면 가입할 수 없다") + void failWhenChatRoomNotFound() { + // given + var user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var command = ChatMemberJoinCommand.of(user.getId(), 1L, null); + + // when & then + assertThatThrownBy(() -> sut.execute(command)) + .isInstanceOf(ChatRoomErrorException.class) + .hasFieldOrPropertyWithValue("baseErrorCode", ChatRoomErrorCode.NOT_FOUND_CHAT_ROOM); + } + + @Test + @Transactional + @DisplayName("채팅방 가입시 사용자가 존재하지 않으면 가입할 수 없다") + void failWhenUserNotFound() { + // given + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity()); + var command = ChatMemberJoinCommand.of(1L, chatRoom.getId(), null); + + // when & then + assertThatThrownBy(() -> sut.execute(command)) + .isInstanceOf(UserErrorException.class) + .hasFieldOrPropertyWithValue("baseErrorCode", UserErrorCode.NOT_FOUND); + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomAdminDelegateServiceIntegrationTest.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomAdminDelegateServiceIntegrationTest.java new file mode 100644 index 000000000..cb61010de --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomAdminDelegateServiceIntegrationTest.java @@ -0,0 +1,77 @@ +package kr.co.pennyway.domain.context.chat.integration; + +import kr.co.pennyway.domain.common.repository.ExtendedRepositoryFactory; +import kr.co.pennyway.domain.config.DomainServiceIntegrationProfileResolver; +import kr.co.pennyway.domain.config.DomainServiceTestInfraConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.context.chat.dto.ChatRoomAdminDelegateCommand; +import kr.co.pennyway.domain.context.chat.service.ChatRoomAdminDelegateService; +import kr.co.pennyway.domain.context.common.fixture.ChatRoomFixture; +import kr.co.pennyway.domain.context.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; +import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomRdbService; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Slf4j +@EnableAutoConfiguration +@SpringBootTest(classes = {ChatRoomAdminDelegateService.class, ChatMemberRdbService.class, ChatRoomRdbService.class}) +@EntityScan(basePackageClasses = {User.class, ChatRoom.class, ChatMember.class}) +@EnableJpaRepositories( + basePackageClasses = {UserRepository.class, ChatRoomRepository.class, ChatMemberRepository.class}, + repositoryFactoryBeanClass = ExtendedRepositoryFactory.class +) +@ActiveProfiles(resolver = DomainServiceIntegrationProfileResolver.class) +@Import(value = {JpaTestConfig.class}) +public class ChatRoomAdminDelegateServiceIntegrationTest extends DomainServiceTestInfraConfig { + @Autowired + private ChatRoomAdminDelegateService sut; + + @Autowired + private ChatMemberRepository chatMemberRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private UserRepository userRepository; + + @Test + @Transactional + void shouldDelegateAdmin() { + // given + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L)); + var chatAdmin = createChatMember(ChatMemberRole.ADMIN, chatRoom); + var chatMember = createChatMember(ChatMemberRole.MEMBER, chatRoom); + + // when + sut.execute(ChatRoomAdminDelegateCommand.of(chatRoom.getId(), chatAdmin.getUserId(), chatMember.getId())); + + // then + assertEquals(ChatMemberRole.ADMIN, chatMember.getRole()); + assertEquals(ChatMemberRole.MEMBER, chatAdmin.getRole()); + } + + private ChatMember createChatMember(ChatMemberRole role, ChatRoom chatRoom) { + var user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + + return chatMemberRepository.save(ChatMember.of(user, chatRoom, role)); + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomDeleteServiceIntegrationTest.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomDeleteServiceIntegrationTest.java new file mode 100644 index 000000000..05db83ddc --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomDeleteServiceIntegrationTest.java @@ -0,0 +1,151 @@ +package kr.co.pennyway.domain.context.chat.integration; + +import kr.co.pennyway.domain.common.repository.ExtendedRepositoryFactory; +import kr.co.pennyway.domain.config.DomainServiceIntegrationProfileResolver; +import kr.co.pennyway.domain.config.DomainServiceTestInfraConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.context.chat.dto.ChatRoomDeleteCommand; +import kr.co.pennyway.domain.context.chat.service.ChatRoomDeleteService; +import kr.co.pennyway.domain.context.common.fixture.ChatRoomFixture; +import kr.co.pennyway.domain.context.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; +import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomRdbService; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Slf4j +@EnableAutoConfiguration +@SpringBootTest(classes = {ChatRoomDeleteService.class, ChatMemberRdbService.class, ChatRoomRdbService.class}) +@EntityScan(basePackageClasses = {User.class, ChatRoom.class, ChatMember.class}) +@EnableJpaRepositories( + basePackageClasses = {UserRepository.class, ChatRoomRepository.class, ChatMemberRepository.class}, + repositoryFactoryBeanClass = ExtendedRepositoryFactory.class +) +@ActiveProfiles(resolver = DomainServiceIntegrationProfileResolver.class) +@Import(value = {JpaTestConfig.class}) +public class ChatRoomDeleteServiceIntegrationTest extends DomainServiceTestInfraConfig { + @Autowired + private ChatRoomDeleteService chatRoomDeleteService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ChatMemberRepository chatMemberRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @AfterEach + void tearDown() { + chatMemberRepository.deleteAll(); + chatRoomRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + @DisplayName("관리자는 채팅방을 삭제할 수 있다.") + void shouldChatRoomDeletedWhenAdminExecute() { + // given + var user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L)); + var admin = chatMemberRepository.save(ChatMember.of(user, chatRoom, ChatMemberRole.ADMIN)); + + ChatRoomDeleteCommand command = ChatRoomDeleteCommand.of(user.getId(), chatRoom.getId()); + + // when + chatRoomDeleteService.execute(command); + + // then + var chatMembers = chatMemberRepository.findAll(); + assertThat(chatMembers).hasSize(1); + assertTrue(chatMembers.stream().noneMatch(ChatMember::isActive)); + assertThat(chatRoomRepository.findById(chatRoom.getId())).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 멤버가 채팅방 삭제를 시도하면 예외가 발생한다.") + void shouldThrowExceptionWhenNonExistMemberExecute() { + // given + var user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(2L)); + + ChatRoomDeleteCommand command = ChatRoomDeleteCommand.of(user.getId(), chatRoom.getId()); + + // when & then + assertThatThrownBy(() -> chatRoomDeleteService.execute(command)) + .isInstanceOf(ChatMemberErrorException.class); + } + + @Test + @DisplayName("일반 사용자는 채팅방을 삭제할 수 없다.") + void shouldThrowExceptionWhenGeneralUserExecute() { + // given + var user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(3L)); + chatMemberRepository.save(ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER)); + + ChatRoomDeleteCommand command = ChatRoomDeleteCommand.of(user.getId(), chatRoom.getId()); + + // when & then + assertThatThrownBy(() -> chatRoomDeleteService.execute(command)) + .isInstanceOf(ChatMemberErrorException.class); + } + + @Test + @DisplayName("채팅방에 속한 모든 멤버가 삭제된다.") + void shouldAllChatMembersDeletedWhenExecute() { + // given + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(4L)); + var users = createUsers(10); + var admin = chatMemberRepository.save(ChatMember.of(users.get(0), chatRoom, ChatMemberRole.ADMIN)); + var members = createGeneralChatMembers(users.subList(1, users.size()), chatRoom); + + ChatRoomDeleteCommand command = ChatRoomDeleteCommand.of(admin.getUserId(), chatRoom.getId()); + + // when + chatRoomDeleteService.execute(command); + + // then + var chatMembers = chatMemberRepository.findAll(); + assertThat(chatMembers).hasSize(10); + assertTrue(chatMembers.stream().noneMatch(ChatMember::isActive)); + assertThat(chatRoomRepository.findById(chatRoom.getId())).isEmpty(); + } + + private List createUsers(int count) { + return IntStream.range(0, count) + .mapToObj(i -> userRepository.save(UserFixture.GENERAL_USER.toUser())) + .toList(); + } + + private List createGeneralChatMembers(List users, ChatRoom chatRoom) { + return users.stream() + .map(user -> chatMemberRepository.save(ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER))) + .toList(); + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomLeaveServiceIntegrationTest.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomLeaveServiceIntegrationTest.java new file mode 100644 index 000000000..383e7b632 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomLeaveServiceIntegrationTest.java @@ -0,0 +1,143 @@ +package kr.co.pennyway.domain.context.chat.integration; + +import kr.co.pennyway.domain.common.repository.ExtendedRepositoryFactory; +import kr.co.pennyway.domain.config.DomainServiceIntegrationProfileResolver; +import kr.co.pennyway.domain.config.DomainServiceTestInfraConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.context.chat.service.ChatRoomLeaveService; +import kr.co.pennyway.domain.context.common.fixture.ChatRoomFixture; +import kr.co.pennyway.domain.context.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; +import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomRdbService; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@EnableAutoConfiguration +@SpringBootTest(classes = {ChatRoomLeaveService.class, ChatMemberRdbService.class, ChatRoomRdbService.class}) +@EntityScan(basePackageClasses = {User.class, ChatRoom.class, ChatMember.class}) +@EnableJpaRepositories( + basePackageClasses = {UserRepository.class, ChatRoomRepository.class, ChatMemberRepository.class}, + repositoryFactoryBeanClass = ExtendedRepositoryFactory.class +) +@ActiveProfiles(resolver = DomainServiceIntegrationProfileResolver.class) +@Import(value = {JpaTestConfig.class}) +public class ChatRoomLeaveServiceIntegrationTest extends DomainServiceTestInfraConfig { + @Autowired + private ChatRoomLeaveService chatRoomLeaveService; + + @Autowired + private ChatMemberRepository chatMemberRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private UserRepository userRepository; + + @Test + @DisplayName("일반 멤버가 채팅방을 나가면 멤버는 삭제되고 채팅방은 유지된다") + void whenNormalMemberLeaves_thenMemberIsDeletedAndRoomRemains() { + // given + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L)); + ChatMember normalMember = chatMemberRepository.save(ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER)); + + // when + chatRoomLeaveService.execute(user.getId(), chatRoom.getId()); + + // then + ChatMember deletedMember = chatMemberRepository.findById(normalMember.getId()).orElseThrow(); + ChatRoom remainingRoom = chatRoomRepository.findById(chatRoom.getId()).orElseThrow(); + + assertNotNull(deletedMember.getDeletedAt()); + assertNull(remainingRoom.getDeletedAt()); + } + + @Test + @DisplayName("방장이 혼자 있는 채팅방을 나가면 방장과 채팅방 모두 삭제된다") + void whenLastAdminLeaves_thenBothMemberAndRoomAreDeleted() { + // given + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(2L)); + ChatMember adminMember = chatMemberRepository.save(ChatMember.of(user, chatRoom, ChatMemberRole.ADMIN)); + + // when + chatRoomLeaveService.execute(user.getId(), chatRoom.getId()); + + // then + ChatMember deletedMember = chatMemberRepository.findById(adminMember.getId()).orElseThrow(); + Optional deletedRoom = chatRoomRepository.findById(chatRoom.getId()); // 조회 조건에서 삭제된 데이터는 조회되지 않음 + + assertNotNull(deletedMember.getDeletedAt()); + assertTrue(deletedRoom.isEmpty()); + } + + @Test + @DisplayName("다른 멤버가 있는 채팅방에서 방장이 나가려고 하면 예외가 발생한다") + void whenAdminLeavesWithOtherMembers_thenThrowsException() { + // given + User adminUser = userRepository.save(UserFixture.GENERAL_USER.toUser()); + User normalUser = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(3L)); + + ChatMember adminMember = chatMemberRepository.save(ChatMember.of(adminUser, chatRoom, ChatMemberRole.ADMIN)); + chatMemberRepository.save(ChatMember.of(normalUser, chatRoom, ChatMemberRole.MEMBER)); + + // when & then + assertThatThrownBy(() -> chatRoomLeaveService.execute(adminUser.getId(), chatRoom.getId())) + .isInstanceOf(ChatMemberErrorException.class) + .hasFieldOrPropertyWithValue("chatMemberErrorCode", ChatMemberErrorCode.ADMIN_CANNOT_LEAVE); + + // 상태가 변경되지 않았는지 확인 + ChatMember unchangedMember = chatMemberRepository.findById(adminMember.getId()).orElseThrow(); + ChatRoom unchangedRoom = chatRoomRepository.findById(chatRoom.getId()).orElseThrow(); + assertNull(unchangedMember.getDeletedAt()); + assertNull(unchangedRoom.getDeletedAt()); + } + + @Test + @Transactional + @DisplayName("방장 이외의 멤버가 가입하고 나갔을 때, 남아있는 채팅방 멤버는 한 명이이므로 방장이 나갈 수 있다") + void whenNormalMemberJoinsAndLeaves_thenRemainingMemberShouldBeOne() { + // given + var adminUser = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var normalUser = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(4L)); + + var adminMember = chatMemberRepository.save(ChatMember.of(adminUser, chatRoom, ChatMemberRole.ADMIN)); + var normalMember = ChatMember.of(normalUser, chatRoom, ChatMemberRole.MEMBER); + normalMember.leave(); + normalMember = chatMemberRepository.save(normalMember); + + log.info("chatRoom: {}", chatRoom); + log.info("adminMember: {}", adminMember); + log.info("normalMember: {}", normalMember); + + // when & then + assertDoesNotThrow(() -> chatRoomLeaveService.execute(adminUser.getId(), chatRoom.getId())); + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomNotificationToggleServiceIntegrationTest.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomNotificationToggleServiceIntegrationTest.java new file mode 100644 index 000000000..8ecb70042 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/integration/ChatRoomNotificationToggleServiceIntegrationTest.java @@ -0,0 +1,91 @@ +package kr.co.pennyway.domain.context.chat.integration; + +import kr.co.pennyway.domain.common.repository.ExtendedRepositoryFactory; +import kr.co.pennyway.domain.config.DomainServiceIntegrationProfileResolver; +import kr.co.pennyway.domain.config.DomainServiceTestInfraConfig; +import kr.co.pennyway.domain.config.JpaTestConfig; +import kr.co.pennyway.domain.context.chat.dto.ChatRoomToggleCommand; +import kr.co.pennyway.domain.context.chat.service.ChatRoomNotificationToggleService; +import kr.co.pennyway.domain.context.common.fixture.ChatRoomFixture; +import kr.co.pennyway.domain.context.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Slf4j +@EnableAutoConfiguration +@SpringBootTest(classes = {ChatRoomNotificationToggleService.class, ChatMemberRdbService.class}) +@EntityScan(basePackageClasses = {User.class, ChatRoom.class, ChatMember.class}) +@EnableJpaRepositories( + basePackageClasses = {UserRepository.class, ChatRoomRepository.class, ChatMemberRepository.class}, + repositoryFactoryBeanClass = ExtendedRepositoryFactory.class +) +@ActiveProfiles(resolver = DomainServiceIntegrationProfileResolver.class) +@Import(value = {JpaTestConfig.class}) +public class ChatRoomNotificationToggleServiceIntegrationTest extends DomainServiceTestInfraConfig { + @Autowired + private ChatRoomNotificationToggleService sut; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private ChatMemberRepository chatMemberRepository; + + @Test + @DisplayName("채팅방 알림을 켜면 알림이 켜진다.") + void shouldTurnOnWhenChatMemberExists() { + // given + var user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L)); + var chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + chatMember.disableNotify(); + chatMember = chatMemberRepository.save(chatMember); + + // when + sut.turnOn(ChatRoomToggleCommand.of(user.getId(), chatRoom.getId())); + + // then + var updatedChatMember = chatMemberRepository.findById(chatMember.getId()).get(); + assertTrue(updatedChatMember.isNotifyEnabled()); + } + + @Test + @DisplayName("채팅방 알림을 끄면 알림이 꺼진다.") + void shouldTurnOffWhenChatMemberExists() { + // given + var user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(2L)); + var chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + chatMember.enableNotify(); + chatMember = chatMemberRepository.save(chatMember); + + // when + sut.turnOff(ChatRoomToggleCommand.of(user.getId(), chatRoom.getId())); + + // then + var updatedChatMember = chatMemberRepository.findById(chatMember.getId()).get(); + assertFalse(updatedChatMember.isNotifyEnabled()); + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/service/ChatMessageStatusServiceTest.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/service/ChatMessageStatusServiceTest.java new file mode 100644 index 000000000..6522318b6 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/service/ChatMessageStatusServiceTest.java @@ -0,0 +1,145 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.domain.domains.chatstatus.domain.ChatMessageStatus; +import kr.co.pennyway.domain.domains.chatstatus.service.ChatMessageStatusRdbService; +import kr.co.pennyway.domain.domains.chatstatus.service.ChatMessageStatusRedisService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class ChatMessageStatusServiceTest { + @InjectMocks + private ChatMessageStatusService chatMessageStatusService; + + @Mock + private ChatMessageStatusRdbService rdbService; + + @Mock + private ChatMessageStatusRedisService redisService; + + private static Stream provideInvalidInputs() { + return Stream.of( + Arguments.of(null, 1L, 1L), + Arguments.of(1L, null, 1L), + Arguments.of(1L, 1L, null), + Arguments.of(-1L, 1L, 1L), + Arguments.of(1L, -1L, 1L), + Arguments.of(1L, 1L, -1L) + ); + } + + @Test + @DisplayName("캐시에서 마지막 읽은 메시지 ID를 조회한다") + void getLastReadMessageIdFromCache() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + Long messageId = 100L; + + given(redisService.readLastReadMessageId(userId, chatRoomId)).willReturn(Optional.of(messageId)); + + // when + Long result = chatMessageStatusService.readLastReadMessageId(userId, chatRoomId); + + // then + assertEquals(messageId, result); + verify(redisService).readLastReadMessageId(userId, chatRoomId); + verifyNoInteractions(rdbService); + } + + @Test + @DisplayName("캐시 미스 시 DB에서 조회하고 캐시를 갱신한다") + void getLastReadMessageIdFromDB() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + Long messageId = 100L; + + ChatMessageStatus status = new ChatMessageStatus(userId, chatRoomId, messageId); + + given(redisService.readLastReadMessageId(userId, chatRoomId)).willReturn(Optional.empty()); + given(rdbService.readByUserIdAndChatRoomId(userId, chatRoomId)).willReturn(Optional.of(status)); + + // when + Long result = chatMessageStatusService.readLastReadMessageId(userId, chatRoomId); + + // then + assertEquals(messageId, result); + verify(redisService).readLastReadMessageId(userId, chatRoomId); + verify(rdbService).readByUserIdAndChatRoomId(userId, chatRoomId); + verify(redisService).saveLastReadMessageId(userId, chatRoomId, messageId); + } + + @Test + @DisplayName("DB에도 데이터가 없는 경우 0을 반환한다") + void returnZeroWhenNoDataExists() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + + given(redisService.readLastReadMessageId(userId, chatRoomId)).willReturn(Optional.empty()); + given(rdbService.readByUserIdAndChatRoomId(userId, chatRoomId)).willReturn(Optional.empty()); + + // when + Long result = chatMessageStatusService.readLastReadMessageId(userId, chatRoomId); + + // then + assertEquals(0L, result); + verify(redisService).readLastReadMessageId(userId, chatRoomId); + verify(rdbService).readByUserIdAndChatRoomId(userId, chatRoomId); + verifyNoMoreInteractions(redisService, rdbService); + } + + @Test + @DisplayName("새 메시지 저장 시 정상적으로 처리된다") + void saveNewMessageStatus() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + Long messageId = 100L; + + // when + chatMessageStatusService.saveLastReadMessageId(userId, chatRoomId, messageId); + + // then + verify(redisService).saveLastReadMessageId(userId, chatRoomId, messageId); + verifyNoInteractions(rdbService); + } + + @Test + @DisplayName("cache repository에서 예외 발생 시 적절히 처리된다 (현재는 예외를 던짐)") + void handleRepositoryException() { + // given + Long userId = 1L; + Long chatRoomId = 1L; + + given(redisService.readLastReadMessageId(userId, chatRoomId)) + .willThrow(new RuntimeException("Redis connection failed")); + + // when - then + assertThrows(RuntimeException.class, () -> + chatMessageStatusService.readLastReadMessageId(userId, chatRoomId) + ); + } + + @ParameterizedTest + @MethodSource("provideInvalidInputs") + @DisplayName("잘못된 입력값이 들어올 경우 적절히 처리된다") + void handleInvalidInputs(Long userId, Long chatRoomId, Long messageId) { + assertDoesNotThrow(() -> chatMessageStatusService.saveLastReadMessageId(userId, chatRoomId, messageId)); + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/service/ChatNotificationCoordinatorServiceUnitTest.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/service/ChatNotificationCoordinatorServiceUnitTest.java new file mode 100644 index 000000000..296f43688 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/chat/service/ChatNotificationCoordinatorServiceUnitTest.java @@ -0,0 +1,1093 @@ +package kr.co.pennyway.domain.context.chat.service; + +import kr.co.pennyway.domain.context.chat.dto.ChatPushNotificationContext; +import kr.co.pennyway.domain.context.common.fixture.ChatRoomFixture; +import kr.co.pennyway.domain.context.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenRdbService; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.service.ChatMemberRdbService; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.session.domain.UserSession; +import kr.co.pennyway.domain.domains.session.service.UserSessionRedisService; +import kr.co.pennyway.domain.domains.session.type.UserStatus; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserRdbService; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@Slf4j +@ExtendWith(MockitoExtension.class) +public class ChatNotificationCoordinatorServiceUnitTest { + @Mock + private UserRdbService userService; + + @Mock + private ChatMemberRdbService chatMemberService; + + @Mock + private DeviceTokenRdbService deviceTokenService; + + @Mock + private UserSessionRedisService userSessionService; + + private ChatNotificationCoordinatorService service; + + @BeforeEach + public void setUp() { + service = new ChatNotificationCoordinatorService(userService, chatMemberService, deviceTokenService, userSessionService); + } + + @Test + @DisplayName("Happy Path: 채팅방 참여자 중 푸시 알림을 받을 수 있는 사용자가 있는 경우 정상적으로 처리된다.") + public void determineRecipientsSuccessfully() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L); + + ChatNotificationTestFlow.DeviceTokenInfo senderToken = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken(1L, "token1", "deviceId1", "iPhone Pro 15"); + ChatNotificationTestFlow.DeviceTokenInfo recipientToken = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken(2L, "token2", "deviceId2", "iPhone SE"); + + ChatNotificationTestFlow.init(userService, chatMemberService, userSessionService, deviceTokenService) + .givenSender(1L) + .withNotifyEnabled() + .withChatRoomNotifyEnabled() + .inChatRoom(chatRoom) + .withStatus(UserStatus.ACTIVE_CHAT_ROOM, 1L) + .withDeviceTokens(List.of(senderToken)) + .and() + .givenRecipient(2L) + .withNotifyEnabled() + .withChatRoomNotifyEnabled() + .inChatRoom(chatRoom) + .withSessionStatuses(Map.of( + recipientToken.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_APP) + )) + .withDeviceTokens(List.of(recipientToken)) + .and() + .whenMocking(); + + // when + ChatPushNotificationContext context = service.determineRecipients(1L, 1L); + + // then + assertThat(context.deviceTokens()).contains(recipientToken.token()); + } + + @Test + @DisplayName("전송자 정보가 없는 경우 IllegalArgumentException가 발생한다.") + public void notFoundSender() { + // given + given(userService.readUser(anyLong())).willReturn(Optional.empty()); + + // when - then + assertThrows(IllegalArgumentException.class, () -> service.determineRecipients(1L, 1L)); + + verify(chatMemberService, never()).readUserIdsByChatRoomId(anyLong()); + } + + @Test + @DisplayName("전송자는 푸시 알림 대상에서 제외된다.") + public void excludeSender() { + // given + Long chatRoomId = 1L; + + User sender = UserFixture.GENERAL_USER.toUserWithCustomSetting(1L, "sender", "발신자", NotifySetting.of(true, true, true)); + given(userService.readUser(anyLong())).willReturn(Optional.of(sender)); + DeviceToken deviceToken = DeviceToken.of("token1", "deviceId1", "iPhone Pro 15", sender); + UserSession senderSession = UserSession.of(sender.getId(), deviceToken.getDeviceId(), deviceToken.getDeviceName()); + senderSession.updateStatus(UserStatus.ACTIVE_CHAT_ROOM, chatRoomId); + + given(chatMemberService.readUserIdsByChatRoomId(anyLong())).willReturn(new HashSet<>(List.of(sender.getId()))); + + // when + ChatPushNotificationContext context = service.determineRecipients(sender.getId(), chatRoomId); + + // then + assertThat(context.deviceTokens()).isEmpty(); + + verify(userSessionService, never()).readAll(anyLong()); + } + + @Test + @DisplayName("채팅방 알림이 비활성화된 사용자의 모든 디바이스 토큰은 제외된다.") + public void excludeUserWithDisabledChatRoomNotification() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L); + ChatNotificationTestFlow.DeviceTokenInfo recipientToken1 = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken(2L, "token2", "deviceId2", "iPhone SE"); + ChatNotificationTestFlow.DeviceTokenInfo recipientToken2 = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken(3L, "token3", "deviceId3", "Galaxy S21"); + + ChatNotificationTestFlow.init(userService, chatMemberService, userSessionService, deviceTokenService) + .givenSender(1L) + .withNotifyEnabled() + .inChatRoom(chatRoom) + .and() + .givenRecipient(2L) + .withNotifyDisabled() // 채팅 알림 비활성화 + .withChatRoomNotifyEnabled() + .withDeviceTokens(List.of(recipientToken1, recipientToken2)) + .withSessionStatuses(Map.of( + recipientToken1.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_APP), + recipientToken2.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_APP) + )) + .inChatRoom(chatRoom) + .and() + .whenMocking(); + + // when + ChatPushNotificationContext context = service.determineRecipients(1L, chatRoom.getId()); + + // then + assertThat(context.deviceTokens()).isEmpty(); + } + + @Test + @DisplayName("채팅 알림이 비활성화된 사용자의 모든 디바이스 토큰은 제외된다.") + public void excludeUserWithDisabledChatNotification() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L); + ChatNotificationTestFlow.DeviceTokenInfo recipientToken1 = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken(2L, "token2", "deviceId2", "iPhone SE"); + ChatNotificationTestFlow.DeviceTokenInfo recipientToken2 = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken(3L, "token3", "deviceId3", "Galaxy S21"); + + ChatNotificationTestFlow.init(userService, chatMemberService, userSessionService, deviceTokenService) + .givenSender(1L) + .withNotifyEnabled() + .inChatRoom(chatRoom) + .and() + .givenRecipient(2L) + .withNotifyEnabled() + .withChatRoomNotifyDisabled() // 채팅 알림 비활성화 + .withDeviceTokens(List.of(recipientToken1, recipientToken2)) + .withSessionStatuses(Map.of( + recipientToken1.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_APP), + recipientToken2.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_APP) + )) + .inChatRoom(chatRoom) + .and() + .whenMocking(); + + // when + ChatPushNotificationContext context = service.determineRecipients(1L, chatRoom.getId()); + + // then + assertThat(context.deviceTokens()).isEmpty(); + } + + @Test + @DisplayName("같은 채팅방 혹은 채팅방 리스트 뷰를 보고 있는 사용자는 대상에서 제외된다.") + public void excludeUserViewingChatRoom() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L); + ChatRoom otherChatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(2L); + + ChatNotificationTestFlow.DeviceTokenInfo recipientToken1 = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken(2L, "token2", "deviceId2", "iPhone SE"); + ChatNotificationTestFlow.DeviceTokenInfo recipientToken2 = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken(3L, "token3", "deviceId3", "Galaxy S21"); + ChatNotificationTestFlow.DeviceTokenInfo recipientToken3 = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken(4L, "token4", "deviceId4", "Galaxy Flip 6"); + + ChatNotificationTestFlow.init(userService, chatMemberService, userSessionService, deviceTokenService) + .givenSender(1L) + .withNotifyEnabled() + .inChatRoom(chatRoom) + .and() + .givenRecipient(2L) // 다른 채팅방에 참여 중 + .withNotifyEnabled() + .withChatRoomNotifyEnabled() + .withDeviceTokens(List.of(recipientToken1)) + .withSessionStatuses(Map.of( + recipientToken1.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_CHAT_ROOM, otherChatRoom.getId()) + )) + .inChatRoom(otherChatRoom) + .and() + .givenRecipient(3L) // 채팅방 리스트 뷰 + .withNotifyEnabled() + .withChatRoomNotifyEnabled() + .withDeviceTokens(List.of(recipientToken2)) + .withSessionStatuses(Map.of( + recipientToken2.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_CHAT_ROOM_LIST) + )) + .inChatRoom(chatRoom) + .and() + .givenRecipient(4L) // 같은 채팅방 참여 중 + .withNotifyEnabled() + .withChatRoomNotifyEnabled() + .withDeviceTokens(List.of(recipientToken3)) + .withSessionStatuses(Map.of( + recipientToken3.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_CHAT_ROOM, chatRoom.getId()) + )) + .inChatRoom(chatRoom) + .and() + .whenMocking(); + + // when + ChatPushNotificationContext context = service.determineRecipients(1L, chatRoom.getId()); + + // then + assertThat(context.deviceTokens()).contains(recipientToken1.token()); + } + + @Test + @DisplayName("사용자 세션 중 하나라도 해당 채팅방을 보고 있을 경우, 사용자의 모든 디바이스 토큰은 제외된다.") + public void excludeUserViewingChatRoomAtLeastOne() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L); + ChatNotificationTestFlow.DeviceTokenInfo recipientToken1 = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken(2L, "token2", "deviceId2", "iPhone SE"); + ChatNotificationTestFlow.DeviceTokenInfo recipientToken2 = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken(3L, "token3", "deviceId3", "Galaxy S21"); + + ChatNotificationTestFlow.init(userService, chatMemberService, userSessionService, deviceTokenService) + .givenSender(1L) + .withNotifyEnabled() + .inChatRoom(chatRoom) + .and() + .givenRecipient(2L) + .withNotifyEnabled() + .withChatRoomNotifyEnabled() + .withDeviceTokens(List.of(recipientToken1, recipientToken2)) + .withSessionStatuses(Map.of( + recipientToken1.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_APP), + recipientToken2.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_CHAT_ROOM, chatRoom.getId()) + )) + .inChatRoom(chatRoom) + .and() + .whenMocking(); + + // when + ChatPushNotificationContext context = service.determineRecipients(1L, chatRoom.getId()); + + // then + assertThat(context.deviceTokens()).isEmpty(); + } + + @Test + @DisplayName("경계 테스트: 채팅방에 참여한 사용자가 없는 경우 빈 디바이스 토큰 리스트를 반환한다.") + public void noParticipantsInChatRoom() { + // given + User sender = UserFixture.GENERAL_USER.toUser(); + given(userService.readUser(anyLong())).willReturn(Optional.of(sender)); + given(chatMemberService.readUserIdsByChatRoomId(anyLong())).willReturn(Collections.emptySet()); + + // when + ChatPushNotificationContext context = service.determineRecipients(1L, 1L); + + // then + assertThat(context.deviceTokens()).isEmpty(); + } + + @Test + @DisplayName("비활성화된 디바이스 토큰은 푸시 알림 대상에서 제외된다") + public void excludeInactiveDeviceTokens() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L); + ChatNotificationTestFlow.DeviceTokenInfo activeToken = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken(2L, "token2", "deviceId2", "iPhone SE"); + ChatNotificationTestFlow.DeviceTokenInfo inactiveToken = ChatNotificationTestFlow.DeviceTokenInfo.createDeactivateToken(3L, "token3", "deviceId3", "iPad Pro"); + + ChatNotificationTestFlow.init(userService, chatMemberService, userSessionService, deviceTokenService) + .givenSender(1L) + .withNotifyEnabled() + .inChatRoom(chatRoom) + .and() + .givenRecipient(2L) + .withNotifyEnabled() + .withChatRoomNotifyEnabled() + .withDeviceTokens(List.of(activeToken, inactiveToken)) + .withSessionStatuses(Map.of( + activeToken.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_APP), + inactiveToken.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_APP) + )) + .inChatRoom(chatRoom) + .and() + .whenMocking(); + + // when + ChatPushNotificationContext context = service.determineRecipients(1L, chatRoom.getId()); + + // then + assertThat(context.deviceTokens()).containsExactly(activeToken.token()); + } + + @Test + @DisplayName("사용자가 매우 많은 디바이스를 가지고 있는 경우에도 정상적으로 처리된다") + public void handleUserWithManyDevices() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L); + List manyDevices = IntStream.range(0, 10) + .mapToObj(i -> ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken( + (long) (i + 2), + "token" + (i + 2), + "deviceId" + (i + 2), + "Device " + (i + 2) + )) + .collect(Collectors.toList()); + + Map deviceStatuses = manyDevices.stream() + .collect(Collectors.toMap( + ChatNotificationTestFlow.DeviceTokenInfo::deviceId, + deviceToken -> ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_APP) + )); + + ChatNotificationTestFlow.init(userService, chatMemberService, userSessionService, deviceTokenService) + .givenSender(1L) + .withNotifyEnabled() + .inChatRoom(chatRoom) + .and() + .givenRecipient(2L) + .withNotifyEnabled() + .withChatRoomNotifyEnabled() + .withDeviceTokens(manyDevices) + .withSessionStatuses(deviceStatuses) + .inChatRoom(chatRoom) + .and() + .whenMocking(); + + // when + ChatPushNotificationContext context = service.determineRecipients(1L, chatRoom.getId()); + + // then + assertThat(context.deviceTokens()) + .hasSize(10) + .containsExactlyInAnyOrderElementsOf( + manyDevices.stream().map(ChatNotificationTestFlow.DeviceTokenInfo::token).collect(Collectors.toList()) + ); + } + + @Test + @DisplayName("채팅방에 매우 많은 사용자가 있는 경우에도 정상적으로 처리된다") + public void handleChatRoomWithManyUsers() { + // given + ChatRoom chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntityWithId(1L); + ChatNotificationTestFlow flow = ChatNotificationTestFlow.init( + userService, chatMemberService, userSessionService, deviceTokenService + ) + .givenSender(1L) + .withNotifyEnabled() + .inChatRoom(chatRoom) + .and(); + + // 100명의 사용자 추가 + IntStream.range(0, 100).forEach(i -> { + ChatNotificationTestFlow.DeviceTokenInfo token = ChatNotificationTestFlow.DeviceTokenInfo.createActivateToken( + (long) (i + 2), + "token" + (i + 2), + "deviceId" + (i + 2), + "Device " + (i + 2) + ); + + flow.givenRecipient((long) (i + 2)) + .withNotifyEnabled() + .withChatRoomNotifyEnabled() + .withDeviceTokens(List.of(token)) + .withSessionStatuses(Map.of( + token.deviceId(), ChatNotificationTestFlow.SessionStatus.of(UserStatus.ACTIVE_APP) + )) + .inChatRoom(chatRoom) + .and(); + }); + + flow.whenMocking(); + + // when + ChatPushNotificationContext context = service.determineRecipients(1L, chatRoom.getId()); + + // then + assertThat(context.deviceTokens()).hasSize(100); + } +} + +/** + * 채팅 알림 기능에 대한 테스트를 쉽게 작성할 수 있도록 도와주는 테스트 유틸리티 클래스입니다. + * BDD 스타일의 플루언트 인터페이스를 제공하여 테스트 시나리오를 쉽게 구성할 수 있습니다. + * + *

기본 사용 예시: + *

{@code
+ * @Test
+ * void testExample() {
+ *     ChatNotificationTestFlow.init(userService, chatMemberService, userSessionService, deviceTokenService)
+ *         .defaultScenario()  // 기본 시나리오 설정
+ *         .whenMocking();     // 모킹 설정
+ *
+ *     // when
+ *     ChatPushNotificationContext result = service.determineRecipients(1L, 1L);
+ *
+ *     // then
+ *     assertThat(result.deviceTokens()).hasSize(1);
+ * }
+ * }
+ * + *

커스텀 시나리오 예시: + *

{@code
+ * ChatNotificationTestFlow.init(...)
+ *     .givenSender(1L)           // 발신자 설정
+ *         .withNotifyEnabled()    // 알림 활성화 상태
+ *         .and()
+ *     .givenRecipient(2L)        // 수신자 설정
+ *         .withNotifyEnabled()    // 알림 활성화
+ *         .withActiveAppStatus()  // 앱 사용 중 상태
+ *     .whenMocking();
+ * }
+ */ +@Slf4j +@TestComponent +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +class ChatNotificationTestFlow { + private final UserRdbService userService; + private final ChatMemberRdbService chatMemberService; + private final UserSessionRedisService userSessionService; + private final DeviceTokenRdbService deviceTokenService; + + private final Long DEFAULT_SENDER_ID = 1L; + private final Long DEFAULT_CHAT_ROOM_ID = 1L; + private final Long DEFAULT_RECIPIENT_ID = 2L; + private final Set recipientIds = new HashSet<>(); + private final Map recipients = new HashMap<>(); + private final Map chatMembers = new HashMap<>(); + private final Map> sessions = new HashMap<>(); + private final Map> deviceTokens = new HashMap<>(); + private final Map chatRooms = new HashMap<>(); // 채팅방 ID를 키로 사용 + private Long senderId = DEFAULT_SENDER_ID; + private Long chatRoomId = DEFAULT_CHAT_ROOM_ID; + private User sender; + + /** + * ChatNotificationTestFlow 인스턴스를 생성하고 초기화합니다. + * 모든 필수 서비스 의존성을 주입받습니다. + * + * @param userService 사용자 서비스 + * @param chatMemberService 채팅방 멤버 서비스 + * @param userSessionService 사용자 세션 서비스 + * @param deviceTokenService 디바이스 토큰 서비스 + * @return {@link ChatNotificationTestFlow} + */ + public static ChatNotificationTestFlow init( + UserRdbService userService, + ChatMemberRdbService chatMemberService, + UserSessionRedisService userSessionService, + DeviceTokenRdbService deviceTokenService + ) { + return new ChatNotificationTestFlow(userService, chatMemberService, userSessionService, deviceTokenService); + } + + /** + * 가장 일반적인 테스트 시나리오를 설정합니다. + * 기본 설정: + *
    + *
  • 발신자(ID: 1): 알림 활성화 상태
  • + *
  • 수신자(ID: 2): 알림 활성화 상태, 앱 사용 중
  • + *
+ * + * @return {@link ChatNotificationTestFlow} + */ + public ChatNotificationTestFlow defaultScenario() { + givenSender(DEFAULT_SENDER_ID) + .withNotifyEnabled() + .and() + .givenRecipient(DEFAULT_RECIPIENT_ID) + .withNotifyEnabled() + .withStatus(UserStatus.ACTIVE_CHAT_ROOM, DEFAULT_CHAT_ROOM_ID); + + return this; + } + + /** + * 테스트할 발신자를 설정합니다. + * 발신자의 상태는 반환된 SenderBuilder를 통해 추가로 구성할 수 있습니다. + * + * @param senderId 발신자 ID + * @return {@link SenderBuilder} 인스턴스 + */ + public SenderBuilder.NotificationSettingStep givenSender(Long senderId) { + this.senderId = senderId; + return new SenderBuilder().new SenderBuilderImpl(this, senderId); + } + + /** + * 테스트할 수신자를 설정합니다. + * 수신자의 상태는 반환된 RecipientBuilder를 통해 추가로 구성할 수 있습니다. + * + * @param recipientId 수신자 ID + * @return {@link RecipientBuilder} 인스턴스 + */ + public RecipientBuilder.NotificationSettingStep givenRecipient(Long recipientId) { + this.recipientIds.add(recipientId); + return new RecipientBuilder().new RecipientBuilderImpl(this, recipientId); + } + + /** + * 설정된 시나리오에 따라 모든 필요한 모킹을 수행합니다. + * 이 메서드는 시나리오 구성의 마지막 단계로 호출되어야 합니다. + * + * @return {@link ChatNotificationTestFlow} + */ + public ChatNotificationTestFlow whenMocking() { + printScenarioPreCondition(); + + // 기본 모킹 설정 (언제나 수행) + // 발신자 정보 반환 + given(userService.readUser(senderId)).willReturn(Optional.ofNullable(sender)); + + // 모든 수신자 ID 반환 + given(chatMemberService.readUserIdsByChatRoomId(chatRoomId)).willReturn(recipientIds); + + // 사용자별 세션 정보 반환 + sessions.forEach((userId, userSessions) -> { + if (userId.equals(senderId)) { + return; + } + + log.debug("User ID: {}, Sessions: {}", userId, userSessions); + given(userSessionService.readAll(userId)).willReturn(userSessions.stream() + .collect(Collectors.toMap(UserSession::getDeviceId, session -> session))); + }); + + // 4. 수신자의 채팅방 알림 설정과 디바이스 토큰은 필요한 경우에만 모킹 + recipients.forEach((userId, recipient) -> { + // 발신자는 제외 + if (userId.equals(senderId)) { + return; + } + + if (!isRequireMoking(userId)) { + return; + } + + given(userService.readUser(userId)).willReturn(Optional.ofNullable(recipient)); + + // 채팅 알림 설정이 비활성화된 사용자는 제외 + if (!recipient.getNotifySetting().isChatNotify()) { + return; + } + + ChatMember chatMember = chatMembers.get(userId); + given(chatMemberService.readChatMember(userId, chatRoomId)).willReturn(Optional.of(chatMember)); + + // 채팅방 알림 설정이 비활성화된 사용자는 제외 + if (!chatMember.isNotifyEnabled()) { + return; + } + + List tokens = deviceTokens.get(userId); + if (tokens != null && !tokens.isEmpty()) { + log.debug("User ID: {}, Device Tokens: {}", userId, tokens); + given(deviceTokenService.readAllByUserId(userId)).willReturn(tokens); + } + }); + + return this; + } + + // 사용자 세션 중 하나라도 해당 채팅방을 보고 있거나, 모든 세션이 모킹 대상이 아닐 경우 + private boolean isRequireMoking(Long userId) { + boolean flag = false; + + for (UserSession session : sessions.get(userId)) { + if (UserStatus.ACTIVE_CHAT_ROOM.equals(session.getStatus()) && chatRoomId.equals(session.getCurrentChatRoomId())) { + flag = false; + break; + } + + if (!UserStatus.ACTIVE_CHAT_ROOM_LIST.equals(session.getStatus())) { + flag = true; + } + } + + return flag; + } + + private void printScenarioPreCondition() { + log.debug("Scenario Pre-Condition"); + log.debug("Sender: {}", sender); + log.debug("Recipients: {}", recipients); + log.debug("Chat Members: {}", chatMembers); + log.debug("Sessions: {}", sessions); + log.debug("Device Tokens: {}", deviceTokens); + log.debug("Chat Rooms: {}", chatRooms); + } + + public record DeviceTokenInfo(Long id, String token, String deviceId, String deviceName, Boolean activated) { + public static DeviceTokenInfo createActivateToken(Long id, String token, String deviceId, String deviceName) { + return new DeviceTokenInfo(id, token, deviceId, deviceName, true); + } + + public static DeviceTokenInfo createDeactivateToken(Long id, String token, String deviceId, String deviceName) { + return new DeviceTokenInfo(id, token, deviceId, deviceName, false); + } + } + + /** + * 세션 상태 정보를 담는 레코드 + */ + public record SessionStatus(UserStatus status, Long chatRoomId) { + public static SessionStatus of(UserStatus status) { + return new SessionStatus(status, null); + } + + public static SessionStatus of(UserStatus status, Long chatRoomId) { + return new SessionStatus(status, chatRoomId); + } + } + + /** + * 발신자의 상태를 설정하기 위한 빌더 클래스입니다. + */ + private class SenderBuilder { + public interface NotificationSettingStep { + /** + * 발신자의 채팅 알림을 활성화 상태로 설정합니다. + * + * @return {@link ConfigurationStep} + */ + ConfigurationStep withNotifyEnabled(); + + /** + * 발신자의 채팅 알림을 비활성화 상태로 설정합니다. + * + * @return {@link ConfigurationStep} + */ + ConfigurationStep withNotifyDisabled(); + } + + public interface ConfigurationStep { + /** + * 발신자의 채팅방 알림을 활성화 상태로 설정합니다. + * + * @return {@link ConfigurationStep} + */ + ConfigurationStep withChatRoomNotifyEnabled(); + + /** + * 발신자의 채팅방 알림을 비활성화 상태로 설정합니다. + * + * @return {@link ConfigurationStep} + */ + ConfigurationStep withChatRoomNotifyDisabled(); + + /** + * 발신자의 세션 상태를 설정합니다. + * 채팅방 뷰 상태를 설정하고 싶다면, {@link #withStatus(UserStatus, Long)} 메서드를 사용하세요. + * + * @param status 사용자 세션 상태 + * @return {@link ConfigurationStep} + */ + ConfigurationStep withStatus(UserStatus status); + + /** + * 발신자의 채팅방 뷰 세션 상태를 설정합니다. + * + * @param status 사용자 세션 상태 + * @param chatRoomId 채팅방 ID + * @return {@link ConfigurationStep} + */ + ConfigurationStep withStatus(UserStatus status, Long chatRoomId); + + /** + * 발신자의 디바이스 토큰을 설정합니다. + * + * @param tokenInfos 설정할 디바이스 토큰 정보 목록 + * @return {@link ConfigurationStep} + */ + ConfigurationStep withDeviceTokens(List tokenInfos); + + /** + * 사용자가 속한 채팅방을 설정합니다. + * + * @param chatRoom 채팅방 정보 + * @return ConfigurationStep + */ + ConfigurationStep inChatRoom(ChatRoom chatRoom); + + /** + * 설정한 정보를 저장하고, 다음 설정을 위한 빌더 인스턴스를 반환합니다. + * + * @return {@link ChatNotificationTestFlow} + */ + ChatNotificationTestFlow and(); + } + + private class SenderBuilderImpl implements NotificationSettingStep, ConfigurationStep { + private final ChatNotificationTestFlow flow; + private final Long senderId; + private final List deviceTokens = new ArrayList<>(); + private User sender; + private UserStatus status = UserStatus.ACTIVE_APP; + private Long activeChatRoomId; + private boolean notifyEnabled = true; + private ChatRoom chatRoom; + + private SenderBuilderImpl(ChatNotificationTestFlow flow, Long senderId) { + this.flow = flow; + this.senderId = senderId; + } + + @Override + public ConfigurationStep withNotifyEnabled() { + this.sender = UserFixture.GENERAL_USER.toUserWithCustomSetting( + senderId, "sender", "발신자", + NotifySetting.of(true, true, true) + ); + return this; + } + + @Override + public ConfigurationStep withNotifyDisabled() { + this.sender = UserFixture.GENERAL_USER.toUserWithCustomSetting( + senderId, "sender", "발신자", + NotifySetting.of(true, true, false) + ); + return this; + } + + @Override + public ConfigurationStep withChatRoomNotifyEnabled() { + this.notifyEnabled = true; + return this; + } + + @Override + public ConfigurationStep withChatRoomNotifyDisabled() { + this.notifyEnabled = false; + return this; + } + + @Override + public ConfigurationStep withStatus(UserStatus status) { + this.status = status; + this.activeChatRoomId = null; + return this; + } + + @Override + public ConfigurationStep withStatus(UserStatus status, Long chatRoomId) { + this.status = status; + this.activeChatRoomId = chatRoomId; + return this; + } + + @Override + public ConfigurationStep withDeviceTokens(List deviceTokens) { + deviceTokens.forEach(deviceToken -> { + DeviceToken token = DeviceToken.of( + deviceToken.token(), + deviceToken.deviceId(), + deviceToken.deviceName(), + sender + ); + ReflectionTestUtils.setField(token, "id", deviceToken.id()); + + this.deviceTokens.add(token); + }); + return this; + } + + @Override + public ConfigurationStep inChatRoom(ChatRoom chatRoom) { + this.chatRoom = chatRoom; + flow.chatRooms.putIfAbsent(chatRoom.getId(), chatRoom); + return this; + } + + @Override + public ChatNotificationTestFlow and() { + if (sender == null) { + withNotifyEnabled(); + } + + flow.sender = sender; + + if (!deviceTokens.isEmpty()) { + flow.deviceTokens.put(senderId, new ArrayList<>(deviceTokens)); + + List sessions = deviceTokens.stream() + .map(token -> { + UserSession session = UserSession.of( + senderId, + token.getDeviceId(), + token.getDeviceName() + ); + session.updateStatus(status, activeChatRoomId); + return session; + }) + .collect(Collectors.toList()); + + flow.sessions.put(senderId, sessions); + } + + // ChatMember 정보 저장 시 설정된 채팅방 사용 + ChatRoom targetChatRoom = chatRoom != null ? chatRoom : flow.chatRooms.get(flow.chatRoomId); + ChatMember chatMember = ChatMember.of( + sender, + targetChatRoom, + ChatMemberRole.MEMBER + ); + ReflectionTestUtils.setField(chatMember, "id", senderId); + + if (!notifyEnabled) { + chatMember.disableNotify(); + } + flow.chatMembers.put(senderId, chatMember); + + return flow; + } + } + } + + /** + * 수신자의 상태를 설정하기 위한 빌더 클래스입니다. + */ + private class RecipientBuilder { + public interface NotificationSettingStep { + /** + * 수신자의 채팅 알림을 활성화 상태로 설정합니다. + * + * @return {@link RecipientBuilder} + */ + ConfigurationStep withNotifyEnabled(); + + /** + * 수신자의 채팅 알림을 비활성화 상태로 설정합니다. + * + * @return {@link RecipientBuilder} + */ + ConfigurationStep withNotifyDisabled(); + } + + public interface ConfigurationStep { + /** + * 수신자의 채팅방 알림을 활성화 상태로 설정합니다. + */ + ConfigurationStep withChatRoomNotifyEnabled(); + + /** + * 수신자의 채팅방 알림을 비활성화 상태로 설정합니다. + */ + ConfigurationStep withChatRoomNotifyDisabled(); + + /** + * 수신자의 세션 상태를 모두 동일하게 설정합니다. + * 채팅방 뷰 상태를 설정하고 싶다면, {@link #withStatus(UserStatus, Long)} 메서드를 사용하세요. + * + * @return {@link RecipientBuilder} + */ + ConfigurationStep withStatus(UserStatus status); + + /** + * 수신자의 채팅방 뷰 세션 상태를 모두 동일하게 설정합니다. + * + * @return {@link RecipientBuilder} + */ + ConfigurationStep withStatus(UserStatus status, Long chatRoomId); + + /** + * 수신자의 디바이스 토큰을 설정합니다. + * 이 메서드는 기존 디바이스 토큰을 모두 제거하고 새로 설정합니다. + *

+ * 반드시 #withNotifyEnabled() 혹은 #withNotifyDisabled() 메서드를 통해 알림 설정을 먼저 해야 합니다. + * + * @param deviceTokens 설정할 디바이스 토큰 정보 목록 + * @return {@link RecipientBuilder} + */ + ConfigurationStep withDeviceTokens(List deviceTokens); + + /** + * 사용자가 속한 채팅방을 설정합니다. + * + * @param chatRoom 채팅방 정보 + * @return ConfigurationStep + */ + ConfigurationStep inChatRoom(ChatRoom chatRoom); + + /** + * 수신자의 디바이스별 세션 상태를 설정합니다. + *

+ * 예시: + *

+             * .withSessionStatuses(Map.of(
+             *     "deviceId1", new SessionStatus(UserStatus.ACTIVE_CHAT_ROOM, chatRoom.getId()),
+             *     "deviceId2", new SessionStatus(UserStatus.ACTIVE_APP, null)
+             * ))
+             * 
+ * + * @param deviceStatuses 디바이스ID를 키로, 세션 상태 정보를 값으로 하는 Map + * @return {@link ConfigurationStep} + */ + ConfigurationStep withSessionStatuses(Map deviceStatuses); + + /** + * 설정이 완료되었으며, 다음 설정을 위해 {@link ChatNotificationTestFlow}로 돌아갑니다. + * + * @return {@link ChatNotificationTestFlow} + */ + ChatNotificationTestFlow and(); + } + + private class RecipientBuilderImpl implements NotificationSettingStep, ConfigurationStep { + private final ChatNotificationTestFlow flow; + private final Long recipientId; + private final List deviceTokens = new ArrayList<>(); + private Map sessionStatuses = new HashMap<>(); + private User recipient; + private UserStatus status = UserStatus.ACTIVE_APP; + private Long activeChatRoomId; + private boolean notifyEnabled = true; + private ChatRoom chatRoom; + + private RecipientBuilderImpl(ChatNotificationTestFlow flow, Long recipientId) { + this.flow = flow; + this.recipientId = recipientId; + } + + @Override + public ConfigurationStep withNotifyEnabled() { + this.recipient = UserFixture.GENERAL_USER.toUserWithCustomSetting( + recipientId, + "recipient" + recipientId, + "수신자" + recipientId, + NotifySetting.of(true, true, true) + ); + return this; + } + + @Override + public ConfigurationStep withNotifyDisabled() { + this.recipient = UserFixture.GENERAL_USER.toUserWithCustomSetting( + recipientId, + "recipient" + recipientId, + "수신자" + recipientId, + NotifySetting.of(true, true, false) + ); + return this; + } + + @Override + public ConfigurationStep withChatRoomNotifyEnabled() { + this.notifyEnabled = true; + return this; + } + + @Override + public ConfigurationStep withChatRoomNotifyDisabled() { + this.notifyEnabled = false; + return this; + } + + @Override + public ConfigurationStep withStatus(UserStatus status) { + this.status = status; + this.activeChatRoomId = null; + + return this; + } + + @Override + public ConfigurationStep withStatus(UserStatus status, Long chatRoomId) { + this.status = status; + this.activeChatRoomId = chatRoomId; + + return this; + } + + @Override + public ConfigurationStep withDeviceTokens(List deviceTokens) { + deviceTokens.forEach(deviceToken -> { + DeviceToken token = DeviceToken.of( + deviceToken.token(), + deviceToken.deviceId(), + deviceToken.deviceName(), + recipient + ); + ReflectionTestUtils.setField(token, "id", deviceToken.id()); + + if (!deviceToken.activated()) { + token.deactivate(); + } + + this.deviceTokens.add(token); + }); + + return this; + } + + @Override + public ConfigurationStep withSessionStatuses(Map deviceStatuses) { + this.sessionStatuses = new HashMap<>(deviceStatuses); + return this; + } + + @Override + public ConfigurationStep inChatRoom(ChatRoom chatRoom) { + this.chatRoom = chatRoom; + flow.chatRooms.putIfAbsent(chatRoom.getId(), chatRoom); + return this; + } + + @Override + public ChatNotificationTestFlow and() { + if (recipient == null) { + withNotifyEnabled(); + } + + flow.recipientIds.add(recipientId); + flow.recipients.put(recipientId, recipient); + + if (!deviceTokens.isEmpty()) { + flow.deviceTokens.put(recipientId, new ArrayList<>(deviceTokens)); + + List sessions = deviceTokens.stream() + .map(token -> { + UserSession session = UserSession.of( + recipientId, + token.getDeviceId(), + token.getDeviceName() + ); + + // 디바이스별 상태 설정 + SessionStatus sessionStatus = sessionStatuses.getOrDefault( + token.getDeviceId(), + new SessionStatus(UserStatus.ACTIVE_APP, null) // 기본값 + ); + session.updateStatus(sessionStatus.status(), sessionStatus.chatRoomId()); + + return session; + }) + .collect(Collectors.toList()); + + flow.sessions.put(recipientId, sessions); + } + + // ChatMember 정보 저장 시 설정된 채팅방 사용 + ChatRoom targetChatRoom = chatRoom != null ? chatRoom : flow.chatRooms.get(flow.chatRoomId); + ChatMember chatMember = ChatMember.of( + recipient, + targetChatRoom, + ChatMemberRole.MEMBER + ); + ReflectionTestUtils.setField(chatMember, "id", recipientId); + + if (!notifyEnabled) { + chatMember.disableNotify(); + } + flow.chatMembers.put(recipientId, chatMember); + + return flow; + } + } + } +} \ No newline at end of file diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/common/fixture/ChatRoomFixture.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/common/fixture/ChatRoomFixture.java new file mode 100644 index 000000000..48b773368 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/common/fixture/ChatRoomFixture.java @@ -0,0 +1,40 @@ +package kr.co.pennyway.domain.context.common.fixture; + +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; + +public enum ChatRoomFixture { + PRIVATE_CHAT_ROOM("페니웨이", "페니웨이 채팅방입니다.", "delete/chatroom/1/fsdflasdfa_12121210.jpg", "123456"), + PUBLIC_CHAT_ROOM("페니웨이", "페니웨이 채팅방입니다.", "delete/chatroom/1/fsdflasdfa_12121210.jpg", null); + + private final String title; + private final String description; + private final String backgroundImageUrl; + private final String password; + + ChatRoomFixture(String title, String description, String backgroundImageUrl, String password) { + this.title = title; + this.description = description; + this.backgroundImageUrl = backgroundImageUrl; + this.password = password; + } + + public ChatRoom toEntity() { + return ChatRoom.builder() + .id(1L) + .title(title) + .description(description) + .backgroundImageUrl(backgroundImageUrl) + .password(password != null ? Integer.valueOf(password) : null) + .build(); + } + + public ChatRoom toEntityWithId(Long id) { + return ChatRoom.builder() + .id(id) + .title(title) + .description(description) + .backgroundImageUrl(backgroundImageUrl) + .password(password != null ? Integer.valueOf(password) : null) + .build(); + } +} diff --git a/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/common/fixture/UserFixture.java b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/common/fixture/UserFixture.java new file mode 100644 index 000000000..fbd2ebc54 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/java/kr/co/pennyway/domain/context/common/fixture/UserFixture.java @@ -0,0 +1,66 @@ +package kr.co.pennyway.domain.context.common.fixture; + +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.Getter; +import org.springframework.test.util.ReflectionTestUtils; + +@Getter +public enum UserFixture { + GENERAL_USER(1L, "jayang", "dkssudgktpdy1", "Yang", "010-1111-1111", Role.USER, ProfileVisibility.PUBLIC, NotifySetting.of(true, true, true), false), + OAUTH_USER(2L, "only._.o", null, "Only", "010-2222-2222", Role.USER, ProfileVisibility.PUBLIC, NotifySetting.of(true, true, true), false), + ; + + private final Long id; + private final String username; + private final String password; + private final String name; + private final String phone; + private final Role role; + private final ProfileVisibility profileVisibility; + private final NotifySetting notifySetting; + private final Boolean locked; + + UserFixture(Long id, String username, String password, String name, String phone, Role role, ProfileVisibility profileVisibility, NotifySetting notifySetting, Boolean locked) { + this.id = id; + this.username = username; + this.password = password; + this.name = name; + this.phone = phone; + this.role = role; + this.profileVisibility = profileVisibility; + this.notifySetting = notifySetting; + this.locked = locked; + } + + public User toUser() { + return User.builder() + .username(username) + .password(password) + .name(name) + .phone(phone) + .role(role) + .profileVisibility(profileVisibility) + .notifySetting(notifySetting) + .locked(locked) + .build(); + } + + public User toUserWithCustomSetting(Long id, String username, String name, NotifySetting notifySetting) { + User user = User.builder() + .username(username) + .password(password) + .name(name) + .phone(phone) + .role(role) + .profileVisibility(profileVisibility) + .notifySetting(notifySetting) + .locked(locked) + .build(); + ReflectionTestUtils.setField(user, "id", id); + + return user; + } +} \ No newline at end of file diff --git a/pennyway-domain/domain-service/src/test/resources/logback-test.xml b/pennyway-domain/domain-service/src/test/resources/logback-test.xml new file mode 100644 index 000000000..198192602 --- /dev/null +++ b/pennyway-domain/domain-service/src/test/resources/logback-test.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/pennyway-infra/build.gradle b/pennyway-infra/build.gradle index 390e57f7c..f20c017ae 100644 --- a/pennyway-infra/build.gradle +++ b/pennyway-infra/build.gradle @@ -4,6 +4,9 @@ jar { enabled = true } dependencies { implementation project(':pennyway-common') + /* Jackson DataType */ + implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: '2.18.0' + /* jwt */ api group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.5' @@ -33,4 +36,9 @@ dependencies { /* firebase */ implementation 'com.google.firebase:firebase-admin:9.2.0' + /* RabbitMQ */ + implementation group: 'org.springframework.boot', name: 'spring-boot-starter-amqp', version: '3.3.4' + + /* TSID Generator */ + implementation 'com.github.f4b6a3:tsid-creator:5.2.6' } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ActualIdProvider.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ActualIdProvider.java new file mode 100644 index 000000000..5381ccc7c --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ActualIdProvider.java @@ -0,0 +1,76 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.HashMap; +import java.util.Map; + +/** + * 임시 저장 URL에서 임의로 설정된 ID를 실제 ID로 변경하기 위한 정보를 제공하는 클래스 + */ +public final class ActualIdProvider { + private final ObjectKeyType type; + private final Map actualIds; + + private ActualIdProvider(ObjectKeyType type, Map actualIds) { + this.type = type; + this.actualIds = actualIds; + } + + /** + * 프로필 이미지 URL을 생성하기 위한 ActualIdProvider 인스턴스를 생성합니다. + */ + public static ActualIdProvider createInstanceOfProfile() { + return createEmptyInstance(ObjectKeyType.PROFILE); + } + + /** + * 피드 이미지 URL을 생성하기 위한 ActualIdProvider 인스턴스를 생성합니다. + * + * @param feedId 실제 피드 ID + */ + public static ActualIdProvider createInstanceOfFeed(Long feedId) { + Map ids = new HashMap<>(); + ids.put("feed_id", feedId.toString()); + return new ActualIdProvider(ObjectKeyType.FEED, ids); + } + + /** + * 채팅방 프로필 이미지 URL을 생성하기 위한 ActualIdProvider 인스턴스를 생성합니다. + * + * @param chatroomId 실제 채팅방 ID + */ + public static ActualIdProvider createInstanceOfChatroomProfile(Long chatroomId) { + Map ids = new HashMap<>(); + ids.put("chatroom_id", chatroomId.toString()); + return new ActualIdProvider(ObjectKeyType.CHATROOM_PROFILE, ids); + } + + /** + * 채팅 이미지 URL을 생성하기 위한 ActualIdProvider 인스턴스를 생성합니다. + * + * @param chatId 실제 채팅 ID + */ + public static ActualIdProvider createInstanceOfChat(Long chatId) { + Map ids = new HashMap<>(); + ids.put("chat_id", chatId.toString()); + return new ActualIdProvider(ObjectKeyType.CHAT, ids); + } + + /** + * 채팅방 사용자 프로필 이미지 URL을 생성하기 위한 ActualIdProvider 인스턴스를 생성합니다. + */ + public static ActualIdProvider createInstanceOfChatProfile() { + return createEmptyInstance(ObjectKeyType.CHAT_PROFILE); + } + + private static ActualIdProvider createEmptyInstance(ObjectKeyType type) { + return new ActualIdProvider(type, new HashMap<>()); + } + + public Map getActualIds() { + return actualIds; + } + + public ObjectKeyType getType() { + return type; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java index 570ce270a..ba0b32601 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java @@ -1,5 +1,7 @@ package kr.co.pennyway.infra.client.aws.s3; +import kr.co.pennyway.infra.client.aws.s3.url.generator.UrlGenerator; +import kr.co.pennyway.infra.client.aws.s3.url.properties.PresignedUrlPropertyFactory; import kr.co.pennyway.infra.common.exception.StorageErrorCode; import kr.co.pennyway.infra.common.exception.StorageException; import kr.co.pennyway.infra.config.AwsS3Config; @@ -13,15 +15,11 @@ import java.net.URI; import java.time.Duration; -import java.util.Map; -import java.util.Set; @Slf4j @Component @RequiredArgsConstructor public class AwsS3Provider { - private static final Set extensionSet = Set.of("jpg", "png", "jpeg"); - private final AwsS3Config awsS3Config; private final S3Presigner s3Presigner; private final S3Client s3Client; @@ -29,22 +27,14 @@ public class AwsS3Provider { /** * type에 해당하는 확장자를 가진 파일을 S3에 저장하기 위한 Presigned URL을 생성한다. * - * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) - * @param ext : 파일 확장자 (jpg, png, jpeg) - * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE - * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE + * @param factory {@link PresignedUrlPropertyFactory} : Presigned URL 생성을 위한 Property Factory * @return Presigned URL - * @throws Exception */ - public URI generatedPresignedUrl(String type, String ext, String userId, String chatroomId) { + public URI generatedPresignedUrl(PresignedUrlPropertyFactory factory) { try { - if (!extensionSet.contains(ext)) { - throw new StorageException(StorageErrorCode.INVALID_EXTENSION); - } - PutObjectRequest putObjectRequest = PutObjectRequest.builder() .bucket(awsS3Config.getBucketName()) - .key(generateObjectKey(type, ext, userId, chatroomId)) + .key(generateObjectKey(factory)) .build(); PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(r -> r.putObjectRequest(putObjectRequest) @@ -60,38 +50,11 @@ public URI generatedPresignedUrl(String type, String ext, String userId, String /** * type에 해당하는 ObjectKeyTemplate을 적용하여 ObjectKey(S3에 저장하기 위한 정적 파일의 경로 및 이름)를 생성한다. * - * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) - * @param ext : 파일 확장자 (jpg, png, jpeg) - * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE - * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE + * @param factory {@link PresignedUrlPropertyFactory} : Presigned URL 생성을 위한 Property Factory * @return ObjectKey */ - private String generateObjectKey(String type, String ext, String userId, String chatroomId) { - ObjectKeyTemplate objectKeyTemplate = new ObjectKeyTemplate(ObjectKeyType.valueOf(type).getDeleteTemplate()); - Map variables = generateObjectKeyVariables(type, ext, userId, chatroomId); - String objectKey = objectKeyTemplate.apply(variables); - return objectKey; - - } - - /** - * ObjectKey에 사용될 변수들을 Template에 적용하기 위한 Map에 담아 반환한다. - * - * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) - * @param ext : 파일 확장자 (jpg, png, jpeg) - * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE - * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE - */ - private Map generateObjectKeyVariables(String type, String ext, String userId, String chatroomId) { - ObjectKeyType objectType; - try { - objectType = ObjectKeyType.valueOf(type); - } catch (IllegalArgumentException e) { - throw new StorageException(StorageErrorCode.INVALID_TYPE); - } - - UrlGenerator urlGenerator = UrlGeneratorFactory.getUrlGenerator(objectType); - return urlGenerator.generate(type, ext, userId, chatroomId); + private String generateObjectKey(PresignedUrlPropertyFactory factory) { + return UrlGenerator.createDeleteUrl(factory.getProperty()); } /** @@ -120,12 +83,12 @@ public boolean isObjectExist(String key) { /** * S3에 저장된 파일을 복사한다. * - * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) - * @param sourceKey : 복사할 파일의 키 + * @param type {@link ActualIdProvider} : 실제 ID를 제공하는 클래스 + * @param sourceKey String : 복사할 파일의 키 * @return 복사된 파일의 키 */ - public String copyObject(ObjectKeyType type, String sourceKey) { - String originKey = type.convertDeleteKeyToOriginKey(sourceKey); + public String copyObject(ActualIdProvider type, String sourceKey) { + String originKey = UrlGenerator.convertDeleteToOriginUrl(type, sourceKey); try { CopyObjectRequest copyObjRequest = CopyObjectRequest.builder() diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java index 8a698fb5d..bb440e2fc 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java @@ -1,38 +1,30 @@ package kr.co.pennyway.infra.client.aws.s3; -import lombok.RequiredArgsConstructor; +import java.util.Arrays; +import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -@RequiredArgsConstructor public enum ObjectKeyType { - PROFILE("1", "PROFILE", "delete/profile/{userId}/{uuid}_{timestamp}.{ext}", "profile/{userId}/origin/{uuid}_{timestamp}.{ext}"), - FEED("2", "FEED", "delete/feed/{feed_id}/{uuid}_{timestamp}.{ext}", "feed/{feed_id}/origin/{uuid}_{timestamp}.{ext}"), + PROFILE("1", "PROFILE", "delete/profile/{user_id}/{uuid}_{timestamp}.{ext}", "profile/{user_id}/origin/{uuid}_{timestamp}.{ext}"), + FEED("2", "FEED", "delete/feed/{feed_id}/{uuid}_{timestamp}.{ext}", "feed/{feed_id}/origin/{uuid}_{timestamp}.{ext}", "feed_id"), CHATROOM_PROFILE("3", "CHATROOM_PROFILE", "delete/chatroom/{chatroom_id}/{uuid}_{timestamp}.{ext}", - "chatroom/{chatroom_id}/origin/{uuid}_{timestamp}.{ext}"), + "chatroom/{chatroom_id}/origin/{uuid}_{timestamp}.{ext}", "chatroom_id"), CHAT("4", "CHAT", "delete/chatroom/{chatroom_id}/chat/{chat_id}/{uuid}_{timestamp}.{ext}", - "chatroom/{chatroom_id}/chat/{chat_id}/origin/{uuid}_{timestamp}.{ext}"), - CHAT_PROFILE("5", "CHAT_PROFILE", "delete/chatroom/{chatroom_id}/chat_profile/{userId}/{uuid}_{timestamp}.{ext}", - "chatroom/{chatroom_id}/chat_profile/{userId}/origin/{uuid}_{timestamp}.{ext}"); + "chatroom/{chatroom_id}/chat/{chat_id}/origin/{uuid}_{timestamp}.{ext}", "chat_id"), + CHAT_PROFILE("5", "CHAT_PROFILE", "delete/chatroom/{chatroom_id}/chat_profile/{user_id}/{uuid}_{timestamp}.{ext}", + "chatroom/{chatroom_id}/chat_profile/{user_id}/origin/{uuid}_{timestamp}.{ext}"); private final String code; private final String type; private final String deleteTemplate; private final String originTemplate; - - public static String convertDeleteKeyToOriginKey(String deleteKey, Pattern pattern, String originTemplate) { - Matcher matcher = pattern.matcher(deleteKey); - - if (matcher.matches()) { - String originKey = originTemplate; - for (int i = 1; i <= matcher.groupCount(); i++) { - originKey = originKey.replaceFirst("\\{[^}]+\\}", matcher.group(i)); - } - return originKey; - } - - throw new IllegalArgumentException("No matching ObjectKeyType for deleteKey: " + deleteKey); + private final String[] requiredExchangeParams; + + ObjectKeyType(String code, String type, String deleteTemplate, String originTemplate, String... requiredExchangeParams) { + this.code = code; + this.type = type; + this.deleteTemplate = deleteTemplate; + this.originTemplate = originTemplate; + this.requiredExchangeParams = requiredExchangeParams; } public String getCode() { @@ -43,6 +35,10 @@ public String getType() { return type; } + public List getRequiredParams() { + return Arrays.asList(requiredExchangeParams); + } + public String getDeleteTemplate() { return deleteTemplate; } @@ -50,16 +46,4 @@ public String getDeleteTemplate() { public String getOriginTemplate() { return originTemplate; } - - public String convertDeleteKeyToOriginKey(String deleteKey) { - Pattern pattern = switch (this) { - case PROFILE -> ObjectKeyPattern.PROFILE_PATTERN; - case FEED -> ObjectKeyPattern.FEED_PATTERN; - case CHATROOM_PROFILE -> ObjectKeyPattern.CHATROOM_PROFILE_PATTERN; - case CHAT -> ObjectKeyPattern.CHAT_PATTERN; - case CHAT_PROFILE -> ObjectKeyPattern.CHAT_PROFILE_PATTERN; - default -> throw new IllegalArgumentException("Unknown ObjectKeyType: " + this); - }; - return convertDeleteKeyToOriginKey(deleteKey, pattern, this.originTemplate); - } } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/generator/UrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/generator/UrlGenerator.java new file mode 100644 index 000000000..bdf3083e0 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/generator/UrlGenerator.java @@ -0,0 +1,92 @@ +package kr.co.pennyway.infra.client.aws.s3.url.generator; + +import kr.co.pennyway.infra.client.aws.s3.ActualIdProvider; +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; +import kr.co.pennyway.infra.client.aws.s3.url.properties.PresignedUrlProperty; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class UrlGenerator { + private static final EnumMap DELETE_PATTERNS = new EnumMap<>(ObjectKeyType.class); + private static final EnumMap> VARIABLE_NAMES = new EnumMap<>(ObjectKeyType.class); + + static { + for (ObjectKeyType type : ObjectKeyType.values()) { + DELETE_PATTERNS.put(type, createRegexPattern(type.getDeleteTemplate())); + VARIABLE_NAMES.put(type, extractVariableNames(type.getDeleteTemplate())); + } + } + + /** + * S3에 임시 업로드할 파일의 URL을 생성한다. + * + * @param property {@link PresignedUrlProperty}: Presigned URL 생성을 위한 Property + * @return Presigned URL + */ + public static String createDeleteUrl(PresignedUrlProperty property) { + return applyTemplate(property.type().getDeleteTemplate(), property.variables()); + } + + /** + * 임시 경로에서 실제 경로로 파일을 이동시키기 위한 URL을 생성한다. + * + * @param type {@link ActualIdProvider} + * @param deleteUrl 임시 경로의 URL + * @return Presigned URL + */ + public static String convertDeleteToOriginUrl(ActualIdProvider type, String deleteUrl) { + Map variables = extractVariables(type.getType(), deleteUrl); + + for (String requiredParam : type.getType().getRequiredParams()) { + if (!type.getActualIds().containsKey(requiredParam)) { + throw new IllegalArgumentException("Missing required parameter: " + requiredParam); + } + variables.put(requiredParam, type.getActualIds().get(requiredParam)); + } + + return applyTemplate(type.getType().getOriginTemplate(), variables); + } + + private static String applyTemplate(String template, Map variables) { + String result = template; + for (Map.Entry entry : variables.entrySet()) { + result = result.replace("{" + entry.getKey() + "}", entry.getValue()); + } + return result; + } + + private static Map extractVariables(ObjectKeyType type, String url) { + Map variables = new HashMap<>(); + Matcher matcher = DELETE_PATTERNS.get(type).matcher(url); + + if (matcher.matches()) { + List variableNames = VARIABLE_NAMES.get(type); + for (int i = 0; i < variableNames.size(); i++) { + variables.put(variableNames.get(i), matcher.group(i + 1)); + } + } else { + throw new IllegalArgumentException("URL이 패턴과 일치하지 않습니다. URL: " + url); + } + + return variables; + } + + private static Pattern createRegexPattern(String template) { + String regex = template.replaceAll("\\{[^}]+\\}", "([^/]+)") + .replace("/", "\\/") + .replace(".", "\\."); + return Pattern.compile(regex); + } + + private static List extractVariableNames(String template) { + List variableNames = new ArrayList<>(); + Pattern variablePattern = Pattern.compile("\\{([^}]+)\\}"); + Matcher matcher = variablePattern.matcher(template); + while (matcher.find()) { + variableNames.add(matcher.group(1)); + } + return variableNames; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/BaseUrlProperty.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/BaseUrlProperty.java new file mode 100644 index 000000000..24fab8030 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/BaseUrlProperty.java @@ -0,0 +1,46 @@ +package kr.co.pennyway.infra.client.aws.s3.url.properties; + +import kr.co.pennyway.common.util.UUIDUtil; +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; + +import java.util.Set; + +public abstract class BaseUrlProperty implements PresignedUrlProperty { + private static final Set extensionSet = Set.of("jpg", "png", "jpeg"); + + protected final String imageId; + protected final String timestamp; + protected final String ext; + protected final ObjectKeyType type; + + protected BaseUrlProperty(String ext, ObjectKeyType type) { + if (!extensionSet.contains(ext)) { + throw new IllegalArgumentException("지원하지 않는 확장자입니다."); + } + + this.imageId = UUIDUtil.generateUUID(); + this.timestamp = String.valueOf(System.currentTimeMillis()); + this.ext = ext; + this.type = type; + } + + @Override + public String imageId() { + return imageId; + } + + @Override + public String timestamp() { + return timestamp; + } + + @Override + public String ext() { + return ext; + } + + @Override + public ObjectKeyType type() { + return type; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ChatProfileUrlProperty.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ChatProfileUrlProperty.java new file mode 100644 index 000000000..f31b2b8f2 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ChatProfileUrlProperty.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.infra.client.aws.s3.url.properties; + +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; + +import java.util.Map; +import java.util.Objects; + +public class ChatProfileUrlProperty extends BaseUrlProperty { + private final Long userId; + private final Long chatroomId; + + public ChatProfileUrlProperty(Long userId, Long chatroomId, String ext) { + super(ext, ObjectKeyType.CHAT_PROFILE); + this.userId = Objects.requireNonNull(userId, "유저 아이디는 필수입니다."); + this.chatroomId = chatroomId; + } + + @Override + public Map variables() { + return Map.of( + "user_id", userId.toString(), + "chatroom_id", chatroomId.toString(), + "uuid", imageId, + "timestamp", timestamp, + "ext", ext + ); + } +} \ No newline at end of file diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ChatRoomProfileUrlProperty.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ChatRoomProfileUrlProperty.java new file mode 100644 index 000000000..8d72f0a1f --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ChatRoomProfileUrlProperty.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.infra.client.aws.s3.url.properties; + +import kr.co.pennyway.common.util.UUIDUtil; +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; + +import java.util.Map; + +public class ChatRoomProfileUrlProperty extends BaseUrlProperty { + private final String chatroomId; + + public ChatRoomProfileUrlProperty(String ext) { + super(ext, ObjectKeyType.CHATROOM_PROFILE); + this.chatroomId = UUIDUtil.generateUUID(); + } + + @Override + public Map variables() { + return Map.of( + "chatroom_id", chatroomId, + "uuid", imageId, + "timestamp", timestamp, + "ext", ext + ); + } +} \ No newline at end of file diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ChatUrlProperty.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ChatUrlProperty.java new file mode 100644 index 000000000..df3a19918 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ChatUrlProperty.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.infra.client.aws.s3.url.properties; + +import kr.co.pennyway.common.util.UUIDUtil; +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; + +import java.util.Map; +import java.util.Objects; + +public class ChatUrlProperty extends BaseUrlProperty { + private final Long chatroomId; + private final String chatId; + + public ChatUrlProperty(Long chatroomId, String ext) { + super(ext, ObjectKeyType.CHAT); + this.chatroomId = Objects.requireNonNull(chatroomId, "채팅방 아이디는 필수입니다."); + this.chatId = UUIDUtil.generateUUID(); + } + + @Override + public Map variables() { + return Map.of( + "chatroom_id", chatroomId.toString(), + "chat_id", chatId, + "uuid", imageId, + "timestamp", timestamp, + "ext", ext + ); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/FeedUrlProperty.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/FeedUrlProperty.java new file mode 100644 index 000000000..c55123d50 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/FeedUrlProperty.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.infra.client.aws.s3.url.properties; + +import kr.co.pennyway.common.util.UUIDUtil; +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; + +import java.util.Map; + +public final class FeedUrlProperty extends BaseUrlProperty { + private final String feedId; + + public FeedUrlProperty(String ext) { + super(ext, ObjectKeyType.FEED); + this.feedId = UUIDUtil.generateUUID(); + } + + @Override + public Map variables() { + return Map.of( + "feed_id", feedId, + "uuid", imageId, + "timestamp", timestamp, + "ext", ext + ); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/PresignedUrlProperty.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/PresignedUrlProperty.java new file mode 100644 index 000000000..d9733aea7 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/PresignedUrlProperty.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.infra.client.aws.s3.url.properties; + +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; + +import java.util.Map; + +public interface PresignedUrlProperty { + String imageId(); + + String timestamp(); + + String ext(); + + ObjectKeyType type(); + + /** + * Presigned URL 생성을 위한 변수들을 반환한다. + * key는 각 변수명을 lowerCamelCase로 변환한 것이다. + */ + Map variables(); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/PresignedUrlPropertyFactory.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/PresignedUrlPropertyFactory.java new file mode 100644 index 000000000..328a19502 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/PresignedUrlPropertyFactory.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.infra.client.aws.s3.url.properties; + +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; + +public class PresignedUrlPropertyFactory { + private final PresignedUrlProperty property; + + private PresignedUrlPropertyFactory(Long userId, String ext, ObjectKeyType type, Long chatRoomId) { + this.property = switch (type) { + case PROFILE -> new ProfileUrlProperty(userId, ext); + case CHATROOM_PROFILE -> new ChatRoomProfileUrlProperty(ext); + case CHAT_PROFILE -> new ChatProfileUrlProperty(userId, chatRoomId, ext); + case CHAT -> new ChatUrlProperty(chatRoomId, ext); + case FEED -> new FeedUrlProperty(ext); + }; + } + + public static PresignedUrlPropertyFactory createInstance(String ext, ObjectKeyType type, Long userId, Long chatRoomId) { + return new PresignedUrlPropertyFactory(userId, ext, type, chatRoomId); + } + + public PresignedUrlProperty getProperty() { + return property; + } +} + diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ProfileUrlProperty.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ProfileUrlProperty.java new file mode 100644 index 000000000..f0122aaac --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/url/properties/ProfileUrlProperty.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.infra.client.aws.s3.url.properties; + +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; + +import java.util.Map; +import java.util.Objects; + +public final class ProfileUrlProperty extends BaseUrlProperty { + private final Long userId; + + public ProfileUrlProperty(Long userId, String ext) { + super(ext, ObjectKeyType.PROFILE); + this.userId = Objects.requireNonNull(userId, "유저 아이디는 필수입니다."); + } + + @Override + public Map variables() { + return Map.of( + "user_id", userId.toString(), + "uuid", imageId, + "timestamp", timestamp, + "ext", ext + ); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/broker/MessageBrokerAdapter.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/broker/MessageBrokerAdapter.java new file mode 100644 index 000000000..bc753d5da --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/broker/MessageBrokerAdapter.java @@ -0,0 +1,41 @@ +package kr.co.pennyway.infra.client.broker; + +import org.springframework.amqp.rabbit.core.RabbitMessagingTemplate; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.core.MessagePostProcessor; + +import java.util.Map; + +public class MessageBrokerAdapter { + private final RabbitMessagingTemplate rabbitMessagingTemplate; + + public MessageBrokerAdapter(RabbitMessagingTemplate rabbitMessagingTemplate) { + this.rabbitMessagingTemplate = rabbitMessagingTemplate; + } + + public void send(String exchange, String routingKey, Message message) throws MessagingException { + rabbitMessagingTemplate.send(exchange, routingKey, message); + } + + public void convertAndSend(String exchange, String routingKey, Object payload) throws MessagingException { + rabbitMessagingTemplate.convertAndSend(exchange, routingKey, payload); + } + + public void convertAndSend(String exchange, String routingKey, Object payload, + @Nullable Map headers) throws MessagingException { + rabbitMessagingTemplate.convertAndSend(exchange, routingKey, payload, headers); + } + + public void convertAndSend(String exchange, String routingKey, Object payload, + @Nullable MessagePostProcessor postProcessor) throws MessagingException { + rabbitMessagingTemplate.convertAndSend(exchange, routingKey, payload, postProcessor); + } + + public void convertAndSend(String exchange, String routingKey, Object payload, + @Nullable Map headers, @Nullable MessagePostProcessor postProcessor) + throws MessagingException { + rabbitMessagingTemplate.convertAndSend(exchange, routingKey, payload, headers, postProcessor); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/coordinator/CoordinatorService.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/coordinator/CoordinatorService.java new file mode 100644 index 000000000..2cb0dc57c --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/coordinator/CoordinatorService.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.infra.client.coordinator; + +import java.util.Map; + +public interface CoordinatorService { + /** + * 채팅 서버에 연결하려는 클라이언트에게 유효한 채팅 서버의 URL을 반환합니다. + * 이 메서드는 다양한 방식으로 구현될 수 있습니다. + * 예를 들어, 단일 채팅 서버 환경에서는 고정된 채팅 서버 URL을 반환할 수 있습니다. + * 분산 채팅 서버 환경이라면, 분산 코디네이션과 같은 서비스를 통해 사용자의 지리적 위치, 채팅 서버의 부하 상태 등을 고려하여 적절한 채팅 서버 URL을 반환할 수 있습니다. + */ + WebSocket.ChatServerUrl readChatServerUrl(Map headers, Object payload); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/coordinator/DefaultCoordinatorService.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/coordinator/DefaultCoordinatorService.java new file mode 100644 index 000000000..9d0cf0405 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/coordinator/DefaultCoordinatorService.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.infra.client.coordinator; + +import java.util.Map; + +/** + * 이 클래스는 단일 채팅 서버 환경에서 사용할 수 있는 기본적인 {@link CoordinatorService} 구현체입니다. + * 미리 정의된 채팅 서버 URL을 반환합니다. + */ +public class DefaultCoordinatorService implements CoordinatorService { + private final String chatServerUrl; + + public DefaultCoordinatorService(String chatServerUrl) { + this.chatServerUrl = chatServerUrl; + } + + @Override + public WebSocket.ChatServerUrl readChatServerUrl(Map headers, Object payload) { + return WebSocket.ChatServerUrl.of(chatServerUrl); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/coordinator/WebSocket.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/coordinator/WebSocket.java new file mode 100644 index 000000000..149a8f945 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/coordinator/WebSocket.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.infra.client.coordinator; + +import java.util.Objects; + +public final class WebSocket { + public record ChatServerUrl(String url) { + public ChatServerUrl { + Objects.requireNonNull(url, "url must not be null"); + } + + public static ChatServerUrl of(String url) { + return new ChatServerUrl(url); + } + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/guid/IdGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/guid/IdGenerator.java new file mode 100644 index 000000000..8da0942d8 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/guid/IdGenerator.java @@ -0,0 +1,10 @@ +package kr.co.pennyway.infra.client.guid; + +/** + * Global Unique Identifier 생성 인터페이스 + *

+ * IdGenerator는 Integer, Long 또는 String과 같은 Wrapper 타입으로만 구현해야 한다. + */ +public interface IdGenerator { + T generate(); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/guid/TsidGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/guid/TsidGenerator.java new file mode 100644 index 000000000..a5dc11458 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/guid/TsidGenerator.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.infra.client.guid; + +import com.github.f4b6a3.tsid.TsidCreator; + +/** + * Time-Sorted ID 생성 클래스 + */ +public class TsidGenerator implements IdGenerator { + + /** + * TSID 알고리즘 기반의 timestamp(42bit) + node(8bit) + counter(14bit)로 구성되는 64bit 정수를 반환한다. + * 생성된 ID는 ms당 16,383개의 Unique Id를 생성할 수 있으며, 정렬 순서는 생성 순서와 동일하다. + * + * @see GUID별 성능 지표 + */ + @Override + public Long generate() { + return TsidCreator.getTsid256().toLong(); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEvent.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEvent.java new file mode 100644 index 000000000..6162eebf7 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEvent.java @@ -0,0 +1,10 @@ +package kr.co.pennyway.infra.common.event; + +public record ChatRoomJoinEvent( + Long chatRoomId, + String userName +) { + public static ChatRoomJoinEvent of(Long chatRoomId, String userName) { + return new ChatRoomJoinEvent(chatRoomId, userName); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEventHandler.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEventHandler.java new file mode 100644 index 000000000..9aaa89f9b --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEventHandler.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.infra.common.event; + +import kr.co.pennyway.infra.client.broker.MessageBrokerAdapter; +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties; +import kr.co.pennyway.infra.common.properties.ChatJoinEventExchangeProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.scheduling.annotation.Async; + +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +public class ChatRoomJoinEventHandler { + private final MessageBrokerAdapter messageBrokerAdapter; + private final ChatExchangeProperties chatExchangeProperties; + private final ChatJoinEventExchangeProperties chatJoinEventExchangeProperties; + + @Async + @EventListener + public void handle(ChatRoomJoinEvent event) { + log.debug("handle: {}", event); + + Message message = MessageBuilder.createMessage(event, new MessageHeaders(Map.of())); + + messageBrokerAdapter.send( + chatExchangeProperties.getExchange(), + chatJoinEventExchangeProperties.getRoutingKey(), + message + ); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/FcmNotificationEventHandler.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/FcmNotificationEventHandler.java index 5bc54f1e7..8462b579f 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/FcmNotificationEventHandler.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/FcmNotificationEventHandler.java @@ -21,7 +21,7 @@ public class FcmNotificationEventHandler implements NotificationEventHandler { @Override @TransactionalEventListener public void handleEvent(NotificationEvent event) { - log.debug("handleEvent: {}", event); + log.info("handleEvent: {}", event); ApiFuture response = fcmManager.sendMessage(event); if (response == null) { diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/NotificationEvent.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/NotificationEvent.java index e36f1fcce..8145190b7 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/NotificationEvent.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/NotificationEvent.java @@ -3,9 +3,11 @@ import com.google.firebase.messaging.Message; import com.google.firebase.messaging.MulticastMessage; import com.google.firebase.messaging.Notification; +import org.springframework.util.Assert; import org.springframework.util.StringUtils; import java.util.List; +import java.util.Map; /** * FCM 푸시 알림에 필요한 정보를 담은 Event 클래스 @@ -16,7 +18,8 @@ public record NotificationEvent( String title, String content, List deviceTokens, - String imageUrl + String imageUrl, + Map data ) { public NotificationEvent { if (!StringUtils.hasText(title)) { @@ -31,7 +34,18 @@ public record NotificationEvent( } public static NotificationEvent of(String title, String content, List deviceTokens, String imageUrl) { - return new NotificationEvent(title, content, deviceTokens, imageUrl); + return new NotificationEvent(title, content, deviceTokens, imageUrl, null); + } + + /** + * 추가 데이터를 포함하는 NotificationEvent를 생성한다. + * + * @param data : null을 허용하지 않으며, 반드시 null인 key, 혹은 value가 존재하지 않아야 한다. + */ + public static NotificationEvent of(String title, String content, List deviceTokens, String imageUrl, Map data) { + Assert.notNull(data, "추가 데이터는 null이 아니어야 합니다."); + + return new NotificationEvent(title, content, deviceTokens, imageUrl, data); } public int deviceTokensSize() { @@ -42,14 +56,26 @@ public int deviceTokensSize() { * 단일 메시지를 전송하기 위한 Message.Builder를 생성한다. */ public Message.Builder buildSingleMessage() { - return Message.builder().setNotification(toNotification()).setToken(deviceTokens.get(0)); + Message.Builder builder = Message.builder().setNotification(toNotification()).setToken(deviceTokens.get(0)); + + if (data != null) { + builder.putAllData(data); + } + + return builder; } /** * 다중 메시지를 전송하기 위한 MulticastMessage.Builder를 생성한다. */ public MulticastMessage.Builder buildMulticastMessage() { - return MulticastMessage.builder().setNotification(toNotification()).addAllTokens(deviceTokens); + MulticastMessage.Builder builder = MulticastMessage.builder().setNotification(toNotification()).addAllTokens(deviceTokens); + + if (data != null) { + builder.putAllData(data); + } + + return builder; } private Notification toNotification() { diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEvent.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEvent.java new file mode 100644 index 000000000..c3f2951a8 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEvent.java @@ -0,0 +1,53 @@ +package kr.co.pennyway.infra.common.event; + +import org.springframework.util.StringUtils; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; + +public record SpendingChatShareEvent( + Long chatRoomId, + String name, + Long senderId, + LocalDate date, + List spendingOnDates +) { + public SpendingChatShareEvent { + Objects.requireNonNull(chatRoomId, "chatRoomId는 null일 수 없습니다."); + Objects.requireNonNull(name, "name은 null일 수 없습니다."); + Objects.requireNonNull(senderId, "senderId는 null일 수 없습니다."); + Objects.requireNonNull(date, "date는 null일 수 없습니다."); + Objects.requireNonNull(spendingOnDates, "spendingOnDates는 null일 수 없습니다."); + } + + public record SpendingOnDate( + boolean isCustom, + Long categoryId, + String name, + String icon, + Long amount + ) { + public SpendingOnDate { + Objects.requireNonNull(categoryId, "categoryId는 null일 수 없습니다."); + Objects.requireNonNull(icon, "icon은 null일 수 없습니다."); + Objects.requireNonNull(amount, "amount는 null일 수 없습니다."); + + if (isCustom && categoryId < 0 || !isCustom && categoryId != -1) { + throw new IllegalArgumentException("isCustom이 " + isCustom + "일 때 categoryId는 " + (isCustom ? "0 이상" : "-1") + "이어야 합니다."); + } + + if (isCustom && icon.equals("CUSTOM")) { + throw new IllegalArgumentException("사용자 정의 카테고리는 OTHER가 될 수 없습니다."); + } + + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name은 null이거나 빈 문자열일 수 없습니다."); + } + } + + public static SpendingOnDate of(Long categoryId, String name, String icon, Long amount) { + return new SpendingOnDate(!categoryId.equals(-1L), categoryId, name, icon, amount); + } + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEventHandler.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEventHandler.java new file mode 100644 index 000000000..4619e999c --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEventHandler.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.infra.common.event; + +import kr.co.pennyway.infra.client.broker.MessageBrokerAdapter; +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties; +import kr.co.pennyway.infra.common.properties.SpendingChatShareExchangeProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.scheduling.annotation.Async; + +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +public class SpendingChatShareEventHandler { + private final MessageBrokerAdapter messageBrokerAdapter; + private final ChatExchangeProperties chatExchangeProperties; + private final SpendingChatShareExchangeProperties spendingChatShareExchangeProperties; + + @Async + @EventListener + public void handle(SpendingChatShareEvent event) { + log.debug("handle: {}", event); + + var headers = new MessageHeaders(Map.of("Content-Type", "application/json")); + var message = MessageBuilder.createMessage(event, headers); + + messageBrokerAdapter.send( + chatExchangeProperties.getExchange(), + spendingChatShareExchangeProperties.getRoutingKey(), + message + ); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageErrorCode.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageErrorCode.java index 5d5879cc5..fdaa88c40 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageErrorCode.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageErrorCode.java @@ -10,26 +10,30 @@ @Getter @RequiredArgsConstructor public enum StorageErrorCode implements BaseErrorCode { - // 400 Bad Request - MISSING_REQUIRED_PARAMETER(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "필수 파라미터가 누락되었습니다."), - INVALID_EXTENSION(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "지원하지 않는 확장자입니다."), - INVALID_TYPE(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "지원하지 않는 타입입니다."), - INVALID_FILE(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "올바르지 않은 파일입니다."), + // 400 Bad Request + MISSING_REQUIRED_PARAMETER(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "필수 파라미터가 누락되었습니다."), + INVALID_EXTENSION(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "지원하지 않는 확장자입니다."), + INVALID_TYPE(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "지원하지 않는 타입입니다."), + INVALID_FILE(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "올바르지 않은 파일입니다."), - // 404 Not Found - NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."); + // 404 Not Found + NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."), - private final StatusCode statusCode; - private final ReasonCode reasonCode; - private final String message; + // 422 Unprocessable Entity + INVALID_IMAGE_PATH(StatusCode.UNPROCESSABLE_CONTENT, ReasonCode.MALFORMED_PARAMETER, "올바르지 않은 이미지 경로입니다."), + ; - @Override - public CausedBy causedBy() { - return CausedBy.of(statusCode, reasonCode); - } + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; - @Override - public String getExplainError() throws NoSuchFieldError { - return message; - } + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigGroup.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigGroup.java index 028b82c79..508f6332c 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigGroup.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigGroup.java @@ -1,13 +1,19 @@ package kr.co.pennyway.infra.common.importer; +import kr.co.pennyway.infra.config.DistributedCoordinationConfig; import kr.co.pennyway.infra.config.FcmConfig; +import kr.co.pennyway.infra.config.GuidConfig; +import kr.co.pennyway.infra.config.MessageBrokerConfig; import lombok.Getter; import lombok.RequiredArgsConstructor; @Getter @RequiredArgsConstructor public enum PennywayInfraConfigGroup { - FCM(FcmConfig.class); + FCM(FcmConfig.class), + DISTRIBUTED_COORDINATION_CONFIG(DistributedCoordinationConfig.class), + MESSAGE_BROKER_CONFIG(MessageBrokerConfig.class), + GUID_GENERATOR_CONFIG(GuidConfig.class); private final Class configClass; } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ChatExchangeProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ChatExchangeProperties.java new file mode 100644 index 000000000..2b0865f56 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ChatExchangeProperties.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.infra.common.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "pennyway.rabbitmq.chat") +public class ChatExchangeProperties { + private final String exchange; + private final String queue; + private final String routingKey; + + @Override + public String toString() { + return "ChatExchangeProperties{" + + "exchange='" + exchange + '\'' + + ", queue='" + queue + '\'' + + ", routingKey='" + routingKey + '\'' + + '}'; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ChatJoinEventExchangeProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ChatJoinEventExchangeProperties.java new file mode 100644 index 000000000..abca425d4 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ChatJoinEventExchangeProperties.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.infra.common.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "pennyway.rabbitmq.chat-join-event") +public class ChatJoinEventExchangeProperties { + private final String queue; + private final String routingKey; + + @Override + public String toString() { + return "ChatJoinEventExchangeProperties{" + + "queue='" + queue + '\'' + + ", routingKey='" + routingKey + '\'' + + '}'; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/RabbitMqProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/RabbitMqProperties.java new file mode 100644 index 000000000..bbf6fdb0e --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/RabbitMqProperties.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.infra.common.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "spring.rabbitmq") +public class RabbitMqProperties { + private final String host; + private final int port; + private final String username; + private final String password; + private final String virtualHost; + private final int requestedHeartbeat; + + @Override + public String toString() { + return "RabbitMqProperties{" + + "host='" + host + '\'' + + ", port=" + port + + ", username='" + username + '\'' + + ", password='" + password + '\'' + + ", virtualHost='" + virtualHost + '\'' + + ", requestedHeartbeat=" + requestedHeartbeat + + '}'; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/SpendingChatShareExchangeProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/SpendingChatShareExchangeProperties.java new file mode 100644 index 000000000..242c42ec0 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/SpendingChatShareExchangeProperties.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.infra.common.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "pennyway.rabbitmq.spending-chat-share") +public class SpendingChatShareExchangeProperties { + private final String queue; + private final String routingKey; + + @Override + public String toString() { + return "SpendingChatShareExchangeProperties{" + + "queue='" + queue + '\'' + + ", routingKey='" + routingKey + '\'' + + '}'; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/util/JwtClaimsParserUtil.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/util/JwtClaimsParserUtil.java new file mode 100644 index 000000000..48e97bcbe --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/util/JwtClaimsParserUtil.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.infra.common.util; + +import kr.co.pennyway.infra.common.jwt.JwtClaims; + +import java.util.function.Function; + +public class JwtClaimsParserUtil { + /** + * JwtClaims에서 key에 해당하는 값을 반환하는 메서드 + * + * @return key에 해당하는 값이 없거나, 타입이 일치하지 않을 경우 null을 반환한다. + */ + @SuppressWarnings("unchecked") + public static T getClaimsValue(JwtClaims claims, String key, Class type) { + Object value = claims.getClaims().get(key); + if (value != null && type.isAssignableFrom(value.getClass())) { + return (T) value; + } + return null; + } + + /** + * JwtClaims에서 valueConverter를 이용하여 key에 해당하는 값을 반환하는 메서드 + * + * @param valueConverter : String 타입의 값을 T 타입으로 변환하는 함수 + * @return key에 해당하는 값이 없을 경우 null을 반환한다. + */ + public static T getClaimsValue(JwtClaims claims, String key, Function valueConverter) { + Object value = claims.getClaims().get(key); + if (value != null) { + return valueConverter.apply((String) value); + } + return null; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/DistributedCoordinationConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/DistributedCoordinationConfig.java new file mode 100644 index 000000000..dcbb9ee71 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/DistributedCoordinationConfig.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.infra.config; + +import kr.co.pennyway.infra.client.coordinator.CoordinatorService; +import kr.co.pennyway.infra.client.coordinator.DefaultCoordinatorService; +import kr.co.pennyway.infra.common.importer.PennywayInfraConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; + +public class DistributedCoordinationConfig implements PennywayInfraConfig { + private final String chatServerUrl; + + public DistributedCoordinationConfig(@Value("${distributed-coordination.chat-server.url}") String chatServerUrl) { + this.chatServerUrl = chatServerUrl; + } + + @Bean + public CoordinatorService defaultCoordinatorService() { + return new DefaultCoordinatorService(chatServerUrl); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/GuidConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/GuidConfig.java new file mode 100644 index 000000000..9db09a165 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/GuidConfig.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.infra.config; + +import kr.co.pennyway.infra.client.guid.IdGenerator; +import kr.co.pennyway.infra.client.guid.TsidGenerator; +import kr.co.pennyway.infra.common.importer.PennywayInfraConfig; +import org.springframework.context.annotation.Bean; + +public class GuidConfig implements PennywayInfraConfig { + @Bean + public IdGenerator idGenerator() { + return new TsidGenerator(); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java new file mode 100644 index 000000000..69961bb20 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java @@ -0,0 +1,175 @@ +package kr.co.pennyway.infra.config; + + +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import kr.co.pennyway.infra.client.broker.MessageBrokerAdapter; +import kr.co.pennyway.infra.common.event.ChatRoomJoinEventHandler; +import kr.co.pennyway.infra.common.event.SpendingChatShareEventHandler; +import kr.co.pennyway.infra.common.importer.PennywayInfraConfig; +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties; +import kr.co.pennyway.infra.common.properties.ChatJoinEventExchangeProperties; +import kr.co.pennyway.infra.common.properties.RabbitMqProperties; +import kr.co.pennyway.infra.common.properties.SpendingChatShareExchangeProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.rabbit.annotation.EnableRabbit; +import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; +import org.springframework.amqp.rabbit.connection.CachingConnectionFactory; +import org.springframework.amqp.rabbit.connection.Connection; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitMessagingTemplate; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +@Slf4j +@EnableRabbit +@RequiredArgsConstructor +@EnableConfigurationProperties({ChatExchangeProperties.class, ChatJoinEventExchangeProperties.class, RabbitMqProperties.class, SpendingChatShareExchangeProperties.class}) +public class MessageBrokerConfig implements PennywayInfraConfig { + private final RabbitMqProperties rabbitMqProperties; + private final ChatExchangeProperties chatExchangeProperties; + private final ChatJoinEventExchangeProperties chatJoinEventExchangeProperties; + private final SpendingChatShareExchangeProperties spendingChatShareExchangeProperties; + + @Bean + public TopicExchange chatExchange() { + return new TopicExchange(chatExchangeProperties.getExchange()); + } + + @Bean + public Queue chatQueue() { + return new Queue(chatExchangeProperties.getQueue(), true); + } + + @Bean + public Queue chatJoinEventQueue(ChatJoinEventExchangeProperties chatJoinEventExchangeProperties) { + return new Queue(chatJoinEventExchangeProperties.getQueue(), true); + } + + @Bean + public Queue spendingChatShareQueue(SpendingChatShareExchangeProperties spendingChatShareExchangeProperties) { + return new Queue(spendingChatShareExchangeProperties.getQueue(), true); + } + + @Bean + public Binding chatBinding(Queue chatQueue, TopicExchange chatExchange) { + return BindingBuilder + .bind(chatQueue) + .to(chatExchange) + .with(chatExchangeProperties.getRoutingKey()); + } + + @Bean + public Binding chatJoinEventBinding(Queue chatJoinEventQueue, TopicExchange chatExchange) { + return BindingBuilder + .bind(chatJoinEventQueue) + .to(chatExchange) + .with(chatJoinEventExchangeProperties.getRoutingKey()); + } + + @Bean + public Binding spendingShareEventBinding(Queue spendingChatShareQueue, TopicExchange chatExchange) { + return BindingBuilder + .bind(spendingChatShareQueue) + .to(chatExchange) + .with(spendingChatShareExchangeProperties.getRoutingKey()); + } + + @Bean + public Module dateTimeModule() { + return new JavaTimeModule(); + } + + @Bean + public MessageConverter messageConverter(Module dateTimeModule) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true); + objectMapper.registerModule(dateTimeModule); + + return new Jackson2JsonMessageConverter(objectMapper); + } + + @Bean + @Primary + public ConnectionFactory createConnectionFactory() { + CachingConnectionFactory factory = new CachingConnectionFactory(); + + factory.setHost(rabbitMqProperties.getHost()); + factory.setUsername(rabbitMqProperties.getUsername()); + factory.setPassword(rabbitMqProperties.getPassword()); + factory.setPort(rabbitMqProperties.getPort()); + factory.setVirtualHost(rabbitMqProperties.getVirtualHost()); + factory.setRequestedHeartBeat(rabbitMqProperties.getRequestedHeartbeat()); + + return factory; + } + + @Bean + @ConditionalOnProperty(prefix = "pennyway.rabbitmq", name = "validate-connection", havingValue = "true", matchIfMissing = false) + ApplicationRunner connectionFactoryRunner(ConnectionFactory cf) { + return args -> { + try (Connection conn = cf.createConnection()) { + log.info("RabbitMQ connection validated"); + } catch (Exception e) { + log.error("Failed to validate RabbitMQ connection", e); + throw e; + } + }; + } + + @Bean + @ConditionalOnProperty(prefix = "pennyway.rabbitmq", name = "chat-join-event-listener", havingValue = "true", matchIfMissing = false) + public SimpleRabbitListenerContainerFactory simpleRabbitListenerContainerFactory(ConnectionFactory connectionFactory, MessageConverter messageConverter) { + SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); + factory.setConnectionFactory(connectionFactory); + factory.setMessageConverter(messageConverter); + + factory.setConcurrentConsumers(2); + factory.setMaxConcurrentConsumers(10); + + factory.setErrorHandler(t -> log.error("An error occurred in the listener", t)); + factory.setAutoStartup(true); + + return factory; + } + + @Bean + public RabbitTemplate customRabbitTemplate(ConnectionFactory connectionFactory, MessageConverter messageConverter) { + RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); + rabbitTemplate.setMessageConverter(messageConverter); + return rabbitTemplate; + } + + @Bean + public RabbitMessagingTemplate customRabbitMessagingTemplate(RabbitTemplate rabbitTemplate) { + return new RabbitMessagingTemplate(rabbitTemplate); + } + + @Bean + public MessageBrokerAdapter messageBrokerAdapter(RabbitMessagingTemplate rabbitMessagingTemplate) { + return new MessageBrokerAdapter(rabbitMessagingTemplate); + } + + @Bean + public ChatRoomJoinEventHandler chatRoomJoinEventHandler(MessageBrokerAdapter messageBrokerAdapter, ChatExchangeProperties chatExchangeProperties, ChatJoinEventExchangeProperties chatJoinEventExchangeProperties) { + return new ChatRoomJoinEventHandler(messageBrokerAdapter, chatExchangeProperties, chatJoinEventExchangeProperties); + } + + @Bean + public SpendingChatShareEventHandler spendingChatShareEventHandler(MessageBrokerAdapter messageBrokerAdapter, ChatExchangeProperties chatExchangeProperties, SpendingChatShareExchangeProperties spendingChatShareExchangeProperties) { + return new SpendingChatShareEventHandler(messageBrokerAdapter, chatExchangeProperties, spendingChatShareExchangeProperties); + } +} diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index ae7690dc9..4b33bc6f4 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -28,6 +28,14 @@ spring: cloudfront: domain: ${AWS_CLOUDFRONT_DOMAIN:https://cdn.cloudfront.net} + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:guest} + password: ${RABBITMQ_PASSWORD:guest} + virtual-host: ${RABBITMQ_VIRTUAL_HOST:/} + requested-heartbeat: ${RABBITMQ_REQUESTED_HEARTBEAT:20} + app: question-address: ${ADMIN_ADDRESS:team.collabu@gmail.com} mail: @@ -47,6 +55,18 @@ pennyway: local: ${PENNYWAY_DOMAIN_LOCAL:127.0.0.1:8080} dev: ${PENNYWAY_DOMAIN_DEV:127.0.0.1:8080} + rabbitmq: + chat: + queue: ${RABBITMQ_CHAT_QUEUE:chat.queue} + exchange: ${RABBITMQ_CHAT_EXCHANGE:chat.exchange} + routing-key: ${RABBITMQ_CHAT_ROUTING:chat.room.*} + chat-join-event: + queue: ${RABBITMQ_CHAT_JOIN_QUEUE:chat.join.queue} + routing-key: ${RABBITMQ_CHAT_JOIN_ROUTING:chat.join.*} + spending-chat-share: + queue: ${RABBITMQ_SPENDING_CHAT_QUEUE:spending.chat.queue} + routing-key: ${RABBITMQ_SPENDING_CHAT_EXCHANGE:chat.share.spending.*} + oauth2: client: provider: @@ -61,6 +81,10 @@ oauth2: jwks-uri: ${APPLE_JWKS_URI:http://localhost} secret: ${APPLE_CLIENT_SECRET:pennyway-jayang-was} +distributed-coordination: + chat-server: + url: ${CHAT_SERVER_URL:ws://localhost:8000/chat} + --- spring: config: diff --git a/pennyway-infra/src/test/java/kr/co/infra/client/aws/s3/url/generator/UrlGeneratorTest.java b/pennyway-infra/src/test/java/kr/co/infra/client/aws/s3/url/generator/UrlGeneratorTest.java new file mode 100644 index 000000000..be48bcc4d --- /dev/null +++ b/pennyway-infra/src/test/java/kr/co/infra/client/aws/s3/url/generator/UrlGeneratorTest.java @@ -0,0 +1,186 @@ +package kr.co.infra.client.aws.s3.url.generator; + +import kr.co.pennyway.infra.client.aws.s3.ActualIdProvider; +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; +import kr.co.pennyway.infra.client.aws.s3.url.generator.UrlGenerator; +import kr.co.pennyway.infra.client.aws.s3.url.properties.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; + +public class UrlGeneratorTest { + private final Logger log = Logger.getLogger(UrlGeneratorTest.class.getName()); + + @Test + @DisplayName("프로필 타입에 대한 임시 저장 URL을 생성한다.") + void createDeleteUrlProfile() { + PresignedUrlProperty property = new ProfileUrlProperty(1L, "jpg"); + + String result = UrlGenerator.createDeleteUrl(property); + + log.info("deleteUrl: " + result); + assertTrue(result.matches("delete/profile/1/[a-f0-9-]+_\\d+\\.jpg")); + } + + @Test + @DisplayName("채팅 타입에 대한 임시 저장 URL을 생성한다.") + void createDeleteUrlChat() { + PresignedUrlProperty property = new ProfileUrlProperty(1L, "jpg"); + + String result = UrlGenerator.createDeleteUrl(property); + + log.info("deleteUrl: " + result); + assertTrue(result.matches("delete/profile/1/[a-f0-9-]+_\\d+\\.jpg")); + } + + @Test + @DisplayName("채팅 프로필 타입에 대한 임시 저장 URL을 생성한다.") + void createDeleteUrlChatProfile() { + PresignedUrlProperty property = new ChatProfileUrlProperty(500L, 600L, "jpg"); + + String result = UrlGenerator.createDeleteUrl(property); + + log.info("deleteUrl: " + result); + assertTrue(result.matches("delete/chatroom/600/chat_profile/500/[a-f0-9-]+_\\d+\\.jpg")); + } + + @Test + @DisplayName("피드 타입에 대한 임시 저장 URL을 생성한다.") + void createDeleteUrlFeed() { + PresignedUrlProperty property = new FeedUrlProperty("jpg"); + + String result = UrlGenerator.createDeleteUrl(property); + + log.info("deleteUrl: " + result); + assertTrue(result.matches("delete/feed/[a-f0-9-]+/[a-f0-9-]+_\\d+\\.jpg")); + } + + @Test + @DisplayName("채팅방 프로필 타입에 대한 임시 저장 URL을 생성한다.") + void createDeleteUrlChatRoomProfile() { + PresignedUrlProperty property = new ChatRoomProfileUrlProperty("png"); + + String result = UrlGenerator.createDeleteUrl(property); + + log.info("deleteUrl: " + result); + assertTrue(result.matches("delete/chatroom/[a-f0-9-]+/[a-f0-9-]+_\\d+\\.png")); + } + + @Test + @DisplayName("프로필 타입에 대한 임시 저장 URL을 원본 URL로 변환한다.") + void convertDeleteToOriginUrlProfile() { + PresignedUrlProperty property = new ProfileUrlProperty(1L, "jpg"); + String deleteUrl = UrlGenerator.createDeleteUrl(property); + ActualIdProvider idProvider = ActualIdProvider.createInstanceOfProfile(); + String originUrl = UrlGenerator.convertDeleteToOriginUrl(idProvider, deleteUrl); + + log.info("deleteUrl: " + deleteUrl + " -> originUrl: " + originUrl); + assertTrue(originUrl.matches("profile/1/origin/[a-f0-9-]+_\\d+\\.jpg")); + } + + @Test + @DisplayName("채팅 타입에 대한 임시 저장 URL을 원본 URL로 변환한다.") + void convertDeleteToOriginUrlChat() { + PresignedUrlProperty property = new ChatUrlProperty(300L, "jpg"); + String deleteUrl = UrlGenerator.createDeleteUrl(property); + ActualIdProvider idProvider = ActualIdProvider.createInstanceOfChat(1000L); + String originUrl = UrlGenerator.convertDeleteToOriginUrl(idProvider, deleteUrl); + + log.info("deleteUrl: " + deleteUrl + " -> originUrl: " + originUrl); + assertTrue(originUrl.matches("chatroom/300/chat/1000/origin/[a-f0-9-]+_\\d+\\.jpg")); + } + + @Test + @DisplayName("채팅 프로필 타입에 대한 임시 저장 URL을 원본 URL로 변환한다.") + void convertDeleteToOriginUrlChatProfile() { + PresignedUrlProperty property = new ChatProfileUrlProperty(500L, 600L, "jpg"); + String deleteUrl = UrlGenerator.createDeleteUrl(property); + ActualIdProvider idProvider = ActualIdProvider.createInstanceOfChatProfile(); + String originUrl = UrlGenerator.convertDeleteToOriginUrl(idProvider, deleteUrl); + + log.info("deleteUrl: " + deleteUrl + " -> originUrl: " + originUrl); + assertTrue(originUrl.matches("chatroom/600/chat_profile/500/origin/[a-f0-9-]+_\\d+\\.jpg")); + } + + @Test + @DisplayName("피드 타입에 대한 임시 저장 URL을 원본 URL로 변환한다.") + void convertDeleteToOriginUrlFeed() { + PresignedUrlProperty property = new FeedUrlProperty("jpg"); + String deleteUrl = UrlGenerator.createDeleteUrl(property); + ActualIdProvider idProvider = ActualIdProvider.createInstanceOfFeed(1000L); + String originUrl = UrlGenerator.convertDeleteToOriginUrl(idProvider, deleteUrl); + + log.info("deleteUrl: " + deleteUrl + " -> originUrl: " + originUrl); + assertTrue(originUrl.matches("feed/1000/origin/[a-f0-9-]+_\\d+\\.jpg")); + } + + @Test + @DisplayName("채팅방 프로필 타입에 대한 임시 저장 URL을 원본 URL로 변환한다.") + void convertDeleteToOriginUrlChatRoomProfile() { + PresignedUrlProperty property = new ChatRoomProfileUrlProperty("png"); + String deleteUrl = UrlGenerator.createDeleteUrl(property); + ActualIdProvider idProvider = ActualIdProvider.createInstanceOfChatroomProfile(2000L); + String originUrl = UrlGenerator.convertDeleteToOriginUrl(idProvider, deleteUrl); + + log.info("deleteUrl: " + deleteUrl + " -> originUrl: " + originUrl); + assertTrue(originUrl.matches("chatroom/2000/origin/[a-f0-9-]+_\\d+\\.png")); + } + + @Test + @DisplayName("잘못된 URL 형식의 임시 저장 URL을 원본 URL로 변환하려고 시도하면 예외를 던진다.") + void convertDeleteToOriginUrlInvalidUrl() { + String invalidUrl = "invalid/url/format"; + ActualIdProvider idProvider = ActualIdProvider.createInstanceOfProfile(); + assertThrows(IllegalArgumentException.class, () -> + UrlGenerator.convertDeleteToOriginUrl(idProvider, invalidUrl)); + } + + @ParameterizedTest + @EnumSource(ObjectKeyType.class) + @DisplayName("모든 타입에 대해 임시 저장 URL을 원본 URL로 변환한다.") + void convertDeleteToOriginUrlAllTypes(ObjectKeyType type) { + PresignedUrlProperty property = createDummyProperty(type); + String deleteUrl = UrlGenerator.createDeleteUrl(property); + ActualIdProvider idProvider = createDummyActualIdProvider(type); + String originUrl = UrlGenerator.convertDeleteToOriginUrl(idProvider, deleteUrl); + + assertTrue(originUrl.contains("origin")); + assertFalse(originUrl.contains("delete")); + + switch (type) { + case PROFILE -> assertTrue(originUrl.matches("profile/\\d+/origin/[a-f0-9-]+_\\d+\\.(jpg|png|jpeg)")); + case FEED -> assertTrue(originUrl.matches("feed/\\d+/origin/[a-f0-9-]+_\\d+\\.(jpg|png|jpeg)")); + case CHATROOM_PROFILE -> + assertTrue(originUrl.matches("chatroom/\\d+/origin/[a-f0-9-]+_\\d+\\.(jpg|png|jpeg)")); + case CHAT -> + assertTrue(originUrl.matches("chatroom/\\d+/chat/\\d+/origin/[a-f0-9-]+_\\d+\\.(jpg|png|jpeg)")); + case CHAT_PROFILE -> + assertTrue(originUrl.matches("chatroom/\\d+/chat_profile/\\d+/origin/[a-f0-9-]+_\\d+\\.(jpg|png|jpeg)")); + } + } + + private PresignedUrlProperty createDummyProperty(ObjectKeyType type) { + return switch (type) { + case PROFILE -> new ProfileUrlProperty(1L, "jpg"); + case FEED -> new FeedUrlProperty("png"); + case CHATROOM_PROFILE -> new ChatRoomProfileUrlProperty("jpeg"); + case CHAT -> new ChatUrlProperty(300L, "jpg"); + case CHAT_PROFILE -> new ChatProfileUrlProperty(500L, 600L, "png"); + }; + } + + private ActualIdProvider createDummyActualIdProvider(ObjectKeyType type) { + return switch (type) { + case PROFILE -> ActualIdProvider.createInstanceOfProfile(); + case FEED -> ActualIdProvider.createInstanceOfFeed(100L); + case CHATROOM_PROFILE -> ActualIdProvider.createInstanceOfChatroomProfile(200L); + case CHAT -> ActualIdProvider.createInstanceOfChat(400L); + case CHAT_PROFILE -> ActualIdProvider.createInstanceOfChatProfile(); + }; + } +} diff --git a/pennyway-infra/src/test/java/kr/co/infra/client/aws/s3/url/properties/PresignedUrlPropertyFactoryTest.java b/pennyway-infra/src/test/java/kr/co/infra/client/aws/s3/url/properties/PresignedUrlPropertyFactoryTest.java new file mode 100644 index 000000000..03c5fedfd --- /dev/null +++ b/pennyway-infra/src/test/java/kr/co/infra/client/aws/s3/url/properties/PresignedUrlPropertyFactoryTest.java @@ -0,0 +1,92 @@ +package kr.co.infra.client.aws.s3.url.properties; + +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; +import kr.co.pennyway.infra.client.aws.s3.url.properties.ChatUrlProperty; +import kr.co.pennyway.infra.client.aws.s3.url.properties.FeedUrlProperty; +import kr.co.pennyway.infra.client.aws.s3.url.properties.PresignedUrlProperty; +import kr.co.pennyway.infra.client.aws.s3.url.properties.PresignedUrlPropertyFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class PresignedUrlPropertyFactoryTest { + @ParameterizedTest + @EnumSource(ObjectKeyType.class) + @DisplayName("모든 타입에 대해 PresignedUrlProperty를 생성한다.") + void createPropertyAllTypes(ObjectKeyType type) { + PresignedUrlPropertyFactory factory = createValidFactory(type); + PresignedUrlProperty property = factory.getProperty(); + + assertNotNull(property); + assertEquals(type, property.type()); + assertNotNull(property.imageId()); + assertNotNull(property.timestamp()); + assertEquals("jpg", property.ext()); + } + + @Test + @DisplayName("피드 타입에 대해 PresignedUrlProperty를 생성한다.") + void createPropertyProfile() { + PresignedUrlPropertyFactory factory = PresignedUrlPropertyFactory.createInstance("jpg", ObjectKeyType.FEED, 1L, null); + + PresignedUrlProperty property = factory.getProperty(); + assertTrue(property instanceof FeedUrlProperty); + assertNotNull(ReflectionTestUtils.getField(property, "feedId")); + } + + @Test + @DisplayName("채팅 타입에 대해 PresignedUrlProperty를 생성한다.") + void createPropertyChat() { + PresignedUrlPropertyFactory factory = PresignedUrlPropertyFactory.createInstance("png", ObjectKeyType.CHAT, 1L, 100L); + + PresignedUrlProperty property = factory.getProperty(); + assertTrue(property instanceof ChatUrlProperty); + assertEquals(100L, ReflectionTestUtils.getField(property, "chatroomId")); + assertNotNull(ReflectionTestUtils.getField(property, "chatId")); + } + + @Test + @DisplayName("잘못된 확장자로 생성 시 예외를 던진다.") + void createPropertyInvalidExtension() { + assertThrows(IllegalArgumentException.class, () -> + PresignedUrlPropertyFactory.createInstance("gif", ObjectKeyType.PROFILE, 1L, null)); + } + + @Test + @DisplayName("필수 파라미터가 누락된 경우 예외를 던진다.") + void createPropertyMissingRequiredParameter() { + assertThrows(NullPointerException.class, () -> + PresignedUrlPropertyFactory.createInstance("jpg", ObjectKeyType.PROFILE, null, null)); + } + + @Test + @DisplayName("PresignedUrlProperty는 자신의 변수들을 반환할 수 있다.") + void createPropertyVariables() { + PresignedUrlPropertyFactory factory = PresignedUrlPropertyFactory.createInstance("jpg", ObjectKeyType.PROFILE, 1L, null); + + PresignedUrlProperty property = factory.getProperty(); + Map variables = property.variables(); + + assertNotNull(variables); + assertEquals("1", variables.get("user_id")); + assertNotNull(variables.get("uuid")); + assertNotNull(variables.get("timestamp")); + assertEquals("jpg", variables.get("ext")); + } + + private PresignedUrlPropertyFactory createValidFactory(ObjectKeyType type) { + return switch (type) { + case PROFILE -> PresignedUrlPropertyFactory.createInstance("jpg", type, 1L, null); + case FEED -> PresignedUrlPropertyFactory.createInstance("jpg", type, null, null); + case CHATROOM_PROFILE -> PresignedUrlPropertyFactory.createInstance("jpg", type, null, null); + case CHAT -> PresignedUrlPropertyFactory.createInstance("jpg", type, null, 100L); + case CHAT_PROFILE -> PresignedUrlPropertyFactory.createInstance("jpg", type, 1L, 100L); + }; + } +} diff --git a/pennyway-socket-relay/.gitignore b/pennyway-socket-relay/.gitignore new file mode 100644 index 000000000..b63da4551 --- /dev/null +++ b/pennyway-socket-relay/.gitignore @@ -0,0 +1,42 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/pennyway-socket-relay/Dockerfile b/pennyway-socket-relay/Dockerfile new file mode 100644 index 000000000..c9bf20fb6 --- /dev/null +++ b/pennyway-socket-relay/Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:17 +ARG JAR_FILE=./build/libs/*.jar +COPY ${JAR_FILE} app.jar + +ARG PROFILE=dev +ENV PROFILE=${PROFILE} + +ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=${PROFILE}","-Djava.security.egd=file:/dev/./urandom","-Duser.timezone=Asia/Seoul"] \ No newline at end of file diff --git a/pennyway-socket-relay/README.md b/pennyway-socket-relay/README.md new file mode 100644 index 000000000..63e3d3419 --- /dev/null +++ b/pennyway-socket-relay/README.md @@ -0,0 +1,31 @@ +## Socket 모듈 + +### 🤝 Rule + +- 사용성에 따라 모든 계층에 의존성을 추가하여 사용할 수 있다. +- STOMP 기반의 WebSocket 의존성을 갖는다. + +### 🏷️ Directory Structure + +> 명확히 정해진 규칙은 없지만, 이유가 없다면 아래 규칙을 따른다. + +``` +pennyway-socket-relay +├── src +│ ├── main +│ │ ├── java.kr.co.pennyway +│ │ │ ├── socket +│ │ │ │ ├── chat +│ │ │ │ │ ├── consumer +│ │ │ │ │ ├── service +│ │ │ │ │ └── … +│ │ │ │ ├── common +│ │ │ │ ├── config +│ │ │ │ └── PennywaySocketRelayApplication.java +│ │ └── resources +│ │ └── application.yml +│ └── test +├── build.gradle +├── README.md +└── Dockerfile +``` \ No newline at end of file diff --git a/pennyway-socket-relay/build.gradle b/pennyway-socket-relay/build.gradle new file mode 100644 index 000000000..52988a376 --- /dev/null +++ b/pennyway-socket-relay/build.gradle @@ -0,0 +1,19 @@ +plugins { + id 'java' +} + +bootJar { enabled = true } +jar { enabled = false } + +group = 'kr.co.pennyway.relay' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':pennyway-common') + implementation project(':pennyway-domain') + implementation project(':pennyway-infra') +} \ No newline at end of file diff --git a/pennyway-socket-relay/src/main/java/kr/co/pennyway/PennywaySocketRelayApplication.java b/pennyway-socket-relay/src/main/java/kr/co/pennyway/PennywaySocketRelayApplication.java new file mode 100644 index 000000000..dc9f1f5e2 --- /dev/null +++ b/pennyway-socket-relay/src/main/java/kr/co/pennyway/PennywaySocketRelayApplication.java @@ -0,0 +1,19 @@ +package kr.co.pennyway; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import java.util.TimeZone; + +@SpringBootApplication +public class PennywaySocketRelayApplication { + public static void main(String[] args) { + SpringApplication.run(PennywaySocketRelayApplication.class, args); + } + + @PostConstruct + public void init() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } +} \ No newline at end of file diff --git a/pennyway-socket-relay/src/main/resources/application.yml b/pennyway-socket-relay/src/main/resources/application.yml new file mode 100644 index 000000000..37b946c67 --- /dev/null +++ b/pennyway-socket-relay/src/main/resources/application.yml @@ -0,0 +1,23 @@ +spring: + profiles: + group: + local: common, domain, infra + dev: common, domain, infra + +--- +spring: + config: + activate: + on-profile: local + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: test \ No newline at end of file diff --git a/pennyway-socket/.gitignore b/pennyway-socket/.gitignore new file mode 100644 index 000000000..250038022 --- /dev/null +++ b/pennyway-socket/.gitignore @@ -0,0 +1,49 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +### Test ### +src/main/resources/compose.yml +src/main/resources/static/ + +## Log files ## +**/logs \ No newline at end of file diff --git a/pennyway-socket/Dockerfile b/pennyway-socket/Dockerfile new file mode 100644 index 000000000..c9bf20fb6 --- /dev/null +++ b/pennyway-socket/Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:17 +ARG JAR_FILE=./build/libs/*.jar +COPY ${JAR_FILE} app.jar + +ARG PROFILE=dev +ENV PROFILE=${PROFILE} + +ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=${PROFILE}","-Djava.security.egd=file:/dev/./urandom","-Duser.timezone=Asia/Seoul"] \ No newline at end of file diff --git a/pennyway-socket/README.md b/pennyway-socket/README.md new file mode 100644 index 000000000..64d124b1a --- /dev/null +++ b/pennyway-socket/README.md @@ -0,0 +1,37 @@ +## 🔌 Socket 모듈 + +### 🎯 핵심 역할 + +- 실시간 양방향 통신 제공 +- STOMP 기반 WebSocket 처리 +- 분산 환경 고려한 설계 + +### ⚡ 주요 특징 + +- Redis Pub/Sub 활용한 세션 관리 +- 무상태(Stateless) 설계로 수평 확장 용이 +- 실시간 이벤트 처리에 최적화 + +### 🏷️ Directory Structure + +> 명확히 정해진 규칙은 없지만, 이유가 없다면 아래 규칙을 따른다. + +``` +pennyway-socket +├── src +│ ├── main +│ │ ├── java.kr.co.pennyway +│ │ │ ├── socket +│ │ │ │ ├── controller +│ │ │ │ ├── service +│ │ │ │ ├── relay # 추후 분리 용이성을 위해 구분 +│ │ │ │ ├── common +│ │ │ │ ├── config +│ │ │ │ └── PennywaySocketApplication.java +│ │ └── resources +│ │ └── application.yml +│ └── test +├── build.gradle +├── README.md +└── Dockerfile +``` \ No newline at end of file diff --git a/pennyway-socket/build.gradle b/pennyway-socket/build.gradle new file mode 100644 index 000000000..d6550fed5 --- /dev/null +++ b/pennyway-socket/build.gradle @@ -0,0 +1,48 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id 'java' + id 'org.jetbrains.kotlin.jvm' version '1.8.21' + id 'org.jetbrains.kotlin.plugin.spring' version '1.8.21' +} + +bootJar { enabled = true } +jar { enabled = false } + +group = 'kr.co' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':pennyway-common') + implementation project(':pennyway-domain:domain-service') + implementation project(':pennyway-domain:domain-rdb') + implementation project(':pennyway-domain:domain-redis') + implementation project(':pennyway-infra') + + /* Web Socket */ + implementation group: 'org.springframework.boot', name: 'spring-boot-starter-websocket', version: '3.3.4' + + /* Reactor Netty */ + implementation group: 'org.springframework.boot', name: 'spring-boot-starter-reactor-netty', version: '3.3.4' + + /* RabbitMQ (for listener) */ + implementation group: 'org.springframework.boot', name: 'spring-boot-starter-amqp', version: '3.3.4' + + /* jackson */ + implementation group: 'org.openapitools', name: 'jackson-databind-nullable', version: '0.2.6' + + implementation 'org.springframework.boot:spring-boot-starter-validation:3.2.3' + implementation "org.jetbrains.kotlin:kotlin-reflect:1.9.22" +} + +tasks.withType(KotlinCompile) { + kotlinOptions { + freeCompilerArgs = ['-Xjsr305=strict'] + javaParameters = true + jvmTarget = '17' + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/PennywaySocketApplication.kt b/pennyway-socket/src/main/java/kr/co/pennyway/PennywaySocketApplication.kt new file mode 100644 index 000000000..e7d846a6f --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/PennywaySocketApplication.kt @@ -0,0 +1,18 @@ +package kr.co.pennyway; + +import jakarta.annotation.PostConstruct +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import java.util.* + +@SpringBootApplication +class PennywaySocketApplication { + @PostConstruct + fun setDefaultTimeZone() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) + } +} + +fun main(args: Array) { + runApplication(*args) +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/command/SendMessageCommand.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/command/SendMessageCommand.java new file mode 100644 index 000000000..22c01928b --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/command/SendMessageCommand.java @@ -0,0 +1,97 @@ +package kr.co.pennyway.socket.command; + +import jakarta.annotation.Nullable; +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType; +import kr.co.pennyway.domain.domains.message.type.MessageContentType; +import kr.co.pennyway.socket.common.constants.SystemMessageConstants; + +import java.util.Map; + +/** + * 채팅 메시지 전송을 위한 Command 클래스 + */ +public record SendMessageCommand( + long chatRoomId, + String content, + MessageContentType contentType, + MessageCategoryType categoryType, + long senderId, + String senderName, + Map messageIdHeader, + @Nullable Map headers +) { + public SendMessageCommand { + if (chatRoomId <= 0) { + throw new IllegalArgumentException("채팅방 아이디는 0 혹은 음수일 수 없습니다."); + } + if (content.length() > 5000) { + throw new IllegalArgumentException("메시지 내용은 5000자를 초과할 수 없습니다."); + } + if (contentType == null) { + throw new IllegalArgumentException("메시지 타입은 NULL일 수 없습니다."); + } + if (categoryType == null) { + throw new IllegalArgumentException("메시지 카테고리는 NULL일 수 없습니다."); + } + if (senderId < 0) { + throw new IllegalArgumentException("발신자 아이디는 음수일 수 없습니다."); + } + } + + /** + * 시스템 메시지를 생성합니다. + * + * @param chatRoomId long : 채팅방 아이디 + * @param content String : 메시지 내용 + * @return {@link MessageContentType#TEXT}, {@link MessageCategoryType#SYSTEM}, {@link SystemMessageConstants#SYSTEM_SENDER_ID}로 생성된 SendMessageCommand + */ + public static SendMessageCommand createSystemMessage(long chatRoomId, String content) { + return new SendMessageCommand( + chatRoomId, + content, + MessageContentType.TEXT, + MessageCategoryType.SYSTEM, + SystemMessageConstants.SYSTEM_SENDER_ID, + null, + null, + null + ); + } + + /** + * 사용자 메시지를 생성합니다. + * + * @param chatRoomId long : 채팅방 아이디 + * @param content String : 메시지 내용 + * @param contentType {@link MessageContentType} : 메시지 타입 + * @param senderId long : 발신자 아이디 + * @param senderName String : 발신자 이름 + * @param messageIdHeader Map : `x-message-id` 헤더. null일 경우 성공 메시지를 반환하지 않음. + * @return {@link MessageCategoryType#NORMAL}로 생성된 SendMessageCommand + */ + public static SendMessageCommand createUserMessage(long chatRoomId, String content, MessageContentType contentType, long senderId, String senderName, Map messageIdHeader) { + return new SendMessageCommand( + chatRoomId, + content, + contentType, + MessageCategoryType.NORMAL, + senderId, + senderName, + messageIdHeader, + null + ); + } + + public static SendMessageCommand createMessage(long chatRoomId, String content, MessageContentType contentType, MessageCategoryType categoryType, long senderId, String senderName, Map messageIdHeader, @Nullable Map headers) { + return new SendMessageCommand( + chatRoomId, + content, + contentType, + categoryType, + senderId, + senderName, + messageIdHeader, + headers + ); + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/annotation/PreAuthorize.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/annotation/PreAuthorize.java new file mode 100644 index 000000000..9d125a44d --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/annotation/PreAuthorize.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.socket.common.annotation; + +import java.lang.annotation.*; + +/** + * WebSocket Controller에 대한 인증 및 인가를 지정하는 어노테이션. + * 이 어노테이션이 붙은 메서드는 {@link PreAuthorizeAspect}에 의해 처리됩니다. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface PreAuthorize { + /** + * 인증/인가를 위한 SpEL 표현식. + * 이 표현식은 {@link PreAuthorizeSpELParser}에 의해 평가됩니다. + * 평가를 위해서 메서드 파라미터로 반드시 {@link java.security.Principal}이 포함되어야 합니다. + * + * @return 평가할 SpEL 표현식 + */ + String value(); +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAspect.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAspect.kt new file mode 100644 index 000000000..5a2f11b60 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAspect.kt @@ -0,0 +1,91 @@ +package kr.co.pennyway.socket.common.aop; + +import kr.co.pennyway.socket.common.annotation.PreAuthorize +import kr.co.pennyway.socket.common.exception.PreAuthorizeErrorCode +import kr.co.pennyway.socket.common.exception.PreAuthorizeErrorException +import kr.co.pennyway.socket.common.util.PreAuthorizeSpELParser +import kr.co.pennyway.socket.common.util.logger +import org.aspectj.lang.ProceedingJoinPoint +import org.aspectj.lang.annotation.Around +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.reflect.MethodSignature +import org.springframework.context.ApplicationContext +import org.springframework.stereotype.Component +import java.lang.reflect.Method +import java.security.Principal + +@Aspect +@Component +class PreAuthorizeAspect(private val applicationContext: ApplicationContext) { + /** + * {@link PreAuthorize} 어노테이션이 붙은 메서드를 가로채고 인증/인가를 수행합니다. + * + * @param joinPoint 가로챈 메서드의 실행 지점 + * @return 인증/인가가 성공하면 원래 메서드의 실행 결과, 실패하면 UnauthorizedResponse + * @throws Throwable 메서드 실행 중 발생한 예외 + */ + @Around("@annotation(kr.co.pennyway.socket.common.annotation.PreAuthorize)") + fun execute(joinPoint: ProceedingJoinPoint): Any? { + val methodSignature = joinPoint.signature as? MethodSignature + ?: throw IllegalStateException("PreAuthorize는 메서드에만 적용할 수 있습니다") + + val method = methodSignature.method + validateAccess(method, joinPoint) + + return joinPoint.proceed() + } + + private fun validateAccess(method: Method, joinPoint: ProceedingJoinPoint) { + val preAuthorize = method.requireAnnotation() + val principal = joinPoint.args.findPrincipal() + + evaluateAccess( + principal = principal, + preAuthorize = preAuthorize, + method = method, + args = joinPoint.args + ) + } + + private fun evaluateAccess( + principal: Principal?, + preAuthorize: PreAuthorize, + method: Method, + args: Array + ) = PreAuthorizeSpELParser + .evaluate( + expression = preAuthorize.value, + method = method, + args = args, + applicationContext = applicationContext + ) + .also { result -> handleEvaluationResult(result, principal) } + + private fun handleEvaluationResult( + result: PreAuthorizeSpELParser.EvaluationResult, + principal: Principal? + ) = when (result) { + is PreAuthorizeSpELParser.EvaluationResult.Permitted -> Unit + is PreAuthorizeSpELParser.EvaluationResult.Denied.Unauthenticated -> { + log.warn("인증 실패: {}", principal) + throw PreAuthorizeErrorException(PreAuthorizeErrorCode.UNAUTHENTICATED) + } + + is PreAuthorizeSpELParser.EvaluationResult.Denied.Unauthorized -> { + log.warn("인가 실패: {}", principal) + throw PreAuthorizeErrorException(PreAuthorizeErrorCode.FORBIDDEN) + } + } + + private companion object { + inline fun Method.requireAnnotation(): T = + getAnnotation(T::class.java) + ?: throw IllegalStateException("Required annotation ${T::class.simpleName} not found") + + fun Array.findPrincipal(): Principal? = asSequence() + .filterIsInstance() + .firstOrNull() + + val log = logger() + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizer.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizer.kt new file mode 100644 index 000000000..668bad0bb --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizer.kt @@ -0,0 +1,147 @@ +package kr.co.pennyway.socket.common.aop + +import kr.co.pennyway.socket.common.exception.PreAuthorizeErrorCode +import kr.co.pennyway.socket.common.exception.PreAuthorizeErrorException +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import kr.co.pennyway.socket.common.util.logger +import org.springframework.context.ApplicationContext +import org.springframework.stereotype.Component +import java.lang.reflect.ParameterizedType +import java.security.Principal +import kotlin.reflect.KClass +import kotlin.reflect.full.memberFunctions +import kotlin.reflect.javaType + +@Component +class PreAuthorizer( + _preAuthorizeAdvice: PreAuthorizeAdvice +) { + init { + preAuthorizeAdvice = _preAuthorizeAdvice + } + + companion object { + private lateinit var preAuthorizeAdvice: PreAuthorizeAdvice + private val log = logger() + + /** + * 모든 요청을 허용합니다. + */ + fun permitAll(function: () -> T): T = function.invoke() + + /** + * 사용자의 인증 여부만 검증합니다. + * @param principal 사용자 정보 + * @throws 인증되지 않은 사용자인 경우 {@link PreAuthorizeErrorException} 예외를 발생시킵니다. + */ + fun authenticate( + principal: Principal, + function: () -> T + ): T { + when (isAuthenticated(principal)) { + true -> return function.invoke() + false -> { + log.warn("인증 실패: {}", principal) + throw PreAuthorizeErrorException(PreAuthorizeErrorCode.UNAUTHENTICATED) + } + } + } + + /** + * 사용자의 인가 여부만 검증합니다. + * @param serviceClass 서비스 클래스 + * @param args 메서드 참조에 필요한 인자 + * @throws 인가되지 않은 사용자인 경우 {@link PreAuthorizeErrorException} 예외를 발생시킵니다. + */ + fun authorize( + serviceClass: KClass, + methodName: String, + vararg args: Any?, + function: () -> R + ): R where T : Any { + return preAuthorizeAdvice.run( + serviceClass = serviceClass, + methodName = methodName, + function = function, + args = args + ) + } + + /** + * 사용자의 인증 및 인가 여부를 검증합니다. + * @param principal 사용자 정보 + * @param methodReference 인가 검증을 위한 메서드 참조 + * @param args 메서드 참조에 필요한 인자 + * @throws 인증되지 않은 사용자인 경우 {@link PreAuthorizeErrorException} 예외를 발생시킵니다. + * @throws 인가되지 않은 사용자인 경우 {@link PreAuthorizeErrorException} 예외를 발생시킵니다. + */ + fun authorize( + principal: Principal, + serviceClass: KClass, + methodName: String, + vararg args: Any?, + function: () -> R + ): R where T : Any { + when (isAuthenticated(principal)) { + true -> return preAuthorizeAdvice.run( + serviceClass = serviceClass, + methodName = methodName, + function = function, + args = args + ) + + false -> { + log.warn("인증 실패: {}", principal) + throw PreAuthorizeErrorException(PreAuthorizeErrorCode.UNAUTHENTICATED) + } + } + } + + /** + * 현재 사용자가 인증되어 있는지 확인합니다. + * @return principal 초기화되지 않았거나, 인증된 상태라면 true, 그렇지 않으면 false + */ + private fun isAuthenticated(principal: Principal?): Boolean = when (principal) { + is UserPrincipal -> principal.isAuthenticated() + else -> throw IllegalArgumentException("Principal must be UserPrincipal") + } + } + + @Component + class PreAuthorizeAdvice(private val applicationContext: ApplicationContext) { + @OptIn(ExperimentalStdlibApi::class) + fun run( + serviceClass: KClass, + methodName: String, + vararg args: Any?, + function: () -> R + ): R where T : Any { + val manager = applicationContext.getBean(serviceClass.java) + + val method = serviceClass.memberFunctions.find { it.name == methodName } + ?: throw NoSuchMethodException("$methodName not found in ${serviceClass.qualifiedName}") + val parameterTypes = method.parameters.drop(1).map { it.type.javaType } + + val javaMethod = serviceClass.java.getDeclaredMethod( + methodName, + *parameterTypes.map { + when (it) { + is Class<*> -> it // 이미 Class 객체라면 그대로 사용 + is ParameterizedType -> it.rawType as Class<*> // 제네릭 타입이라면 raw type 사용 + else -> throw IllegalStateException("Unsupported type: $it") + } + }.toTypedArray() + ) + + val result = javaMethod.invoke(manager, *args) as? Boolean + ?: throw IllegalArgumentException("Method must return Boolean") + + if (!result) { + log.warn("인가 실패: {}", args) + throw PreAuthorizeErrorException(PreAuthorizeErrorCode.FORBIDDEN) + } + + return function.invoke() + } + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/StompNativeHeaderFields.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/StompNativeHeaderFields.java new file mode 100644 index 000000000..7ffcd6410 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/StompNativeHeaderFields.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.socket.common.constants; + +public enum StompNativeHeaderFields { + DEVICE_ID("device-id"), + DEVICE_NAME("device-name"), + ; + + private final String value; + + StompNativeHeaderFields(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageConstants.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageConstants.java new file mode 100644 index 000000000..5bd733721 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageConstants.java @@ -0,0 +1,9 @@ +package kr.co.pennyway.socket.common.constants; + +public final class SystemMessageConstants { + public static final long SYSTEM_SENDER_ID = 0L; + + private SystemMessageConstants() { + throw new UnsupportedOperationException("Constants class cannot be instantiated"); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageTemplate.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageTemplate.java new file mode 100644 index 000000000..c76d4e4c6 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/constants/SystemMessageTemplate.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.socket.common.constants; + +/** + * 채팅방 가입, 퇴장 등 시스템 메시지 템플릿 클래스 + */ +public enum SystemMessageTemplate { + JOIN_MESSAGE_FORMAT("%s님이 입장하셨습니다."), + ; + + private final String value; + + SystemMessageTemplate(String value) { + this.value = value; + } + + /** + * 사용자 이름을 받아 시스템 메시지로 변환한다. + * + * @param userName String: 사용자 이름 + * @return 포맷팅된 시스템 메시지 + */ + public String convertToMessage(String userName) { + return String.format(value, userName); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/dto/ChatMessageDto.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/dto/ChatMessageDto.java new file mode 100644 index 000000000..512cc9fe8 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/dto/ChatMessageDto.java @@ -0,0 +1,47 @@ +package kr.co.pennyway.socket.common.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import kr.co.pennyway.domain.domains.message.domain.ChatMessage; +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType; +import kr.co.pennyway.domain.domains.message.type.MessageContentType; + +import java.time.LocalDateTime; + +public final class ChatMessageDto { + public record Request( + @NotNull(message = "메시지 내용은 null을 허용하지 않습니다.") + @Size(min = 1, max = 1000, message = "메시지 내용은 1자 이상 1000자 이하로 입력해주세요.") + String content, + @NotNull(message = "메시지 타입은 null을 허용하지 않습니다.") + MessageContentType contentType + ) { + } + + public record Response( + Long chatRoomId, + Long chatId, + String content, + MessageContentType contentType, + MessageCategoryType categoryType, + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + Long senderId + ) { + public static Response from(ChatMessage message) { + return new Response( + message.getChatRoomId(), + message.getChatId(), + message.getContent(), + message.getContentType(), + message.getCategoryType(), + message.getCreatedAt(), + message.getSender() + ); + } + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/dto/ServerSideMessage.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/dto/ServerSideMessage.java new file mode 100644 index 000000000..825e316fe --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/dto/ServerSideMessage.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.socket.common.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.Objects; + +public record ServerSideMessage( + @JsonInclude(JsonInclude.Include.NON_NULL) + String code, + String reason +) { + public ServerSideMessage { + Objects.requireNonNull(reason, "reason must not be null"); + } + + public static ServerSideMessage of(String reason) { + return new ServerSideMessage(null, reason); + } + + public static ServerSideMessage of(String code, String reason) { + return new ServerSideMessage(code, reason); + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/dto/StatusMessage.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/dto/StatusMessage.java new file mode 100644 index 000000000..80147d599 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/dto/StatusMessage.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.socket.common.dto; + +import kr.co.pennyway.domain.domains.session.type.UserStatus; +import kr.co.pennyway.socket.common.exception.MessageErrorCode; +import kr.co.pennyway.socket.common.exception.MessageErrorException; + +import java.util.Objects; + +public record StatusMessage( + UserStatus status, + Long chatRoomId +) { + public StatusMessage { + if (Objects.isNull(status)) { + throw new MessageErrorException(MessageErrorCode.MALFORMED_MESSAGE_BODY); + } + + if (status.equals(UserStatus.ACTIVE_CHAT_ROOM) && Objects.isNull(chatRoomId)) { + throw new MessageErrorException(MessageErrorCode.MALFORMED_MESSAGE_BODY); + } + } + + public boolean isChatRoomStatus() { + return status.equals(UserStatus.ACTIVE_CHAT_ROOM); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/event/ReceiptEvent.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/event/ReceiptEvent.java new file mode 100644 index 000000000..c177da069 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/event/ReceiptEvent.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.socket.common.event; + +import kr.co.pennyway.socket.common.dto.ServerSideMessage; +import org.springframework.context.ApplicationEvent; +import org.springframework.messaging.Message; + +public class ReceiptEvent extends ApplicationEvent { + private final Message message; + + private ReceiptEvent(Message message) { + super(message); + this.message = message; + } + + public static ReceiptEvent of(Message message) { + return new ReceiptEvent<>(message); + } + + public Message getMessage() { + return message; + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/event/ReceiptEventHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/event/ReceiptEventHandler.java new file mode 100644 index 000000000..d2746de31 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/event/ReceiptEventHandler.java @@ -0,0 +1,47 @@ +package kr.co.pennyway.socket.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.socket.common.dto.ServerSideMessage; +import kr.co.pennyway.socket.common.util.StompMessageUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.AbstractSubscribableChannel; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReceiptEventHandler { + private final ObjectMapper objectMapper; + private final AbstractSubscribableChannel clientOutboundChannel; + + @Async + @EventListener + public void handle(ReceiptEvent event) { + log.debug("handle: {}", event); + + Message message = event.getMessage(); + StompHeaderAccessor accessor = StompHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + Message payload = StompMessageUtil.createMessage(accessor, message.getPayload(), objectMapper); + + sendReceiptMessage(clientOutboundChannel, accessor, payload.getPayload()); + } + + private void sendReceiptMessage(AbstractSubscribableChannel clientOutboundChannel, StompHeaderAccessor accessor, byte[] payload) { + if (accessor != null && accessor.getReceipt() != null) { + accessor.setHeader("stompCommand", StompCommand.RECEIPT); + accessor.setReceiptId(accessor.getReceipt()); + + Message receiptMessage = MessageBuilder.createMessage(payload, accessor.getMessageHeaders()); + + clientOutboundChannel.send(receiptMessage); + } + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/InterceptorErrorCode.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/InterceptorErrorCode.java new file mode 100644 index 000000000..68d625d59 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/InterceptorErrorCode.java @@ -0,0 +1,54 @@ +package kr.co.pennyway.socket.common.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import org.springframework.messaging.simp.stomp.StompCommand; + +public enum InterceptorErrorCode implements BaseErrorCode { + // 400 + INVALID_DESTINATION(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "유효하지 않은 목적지입니다", StompCommand.SEND, StompCommand.SUBSCRIBE, StompCommand.UNSUBSCRIBE), + INAVLID_HEADER(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST_SYNTAX, "유효하지 않은 헤더입니다", StompCommand.CONNECT), + + // 403 + UNAUTHORIZED_TO_SUBSCRIBE(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "해당 주제에 대한 구독 권한이 없습니다", StompCommand.SUBSCRIBE, StompCommand.UNSUBSCRIBE), + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + private final StompCommand[] commands; + + InterceptorErrorCode(StatusCode statusCode, ReasonCode reasonCode, String message, StompCommand... commands) { + this.statusCode = statusCode; + this.reasonCode = reasonCode; + this.message = message; + this.commands = commands; + } + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } + + /** + * StompCommand가 ErrorCode에서 지원하는 명령어인지 확인하는 편의용 메서드 + * + * @param command {@link StompCommand} + * @return 해당 ErrorCode에서 지원하는 명령어라면 true, 아니라면 false + */ + public boolean isSupportCommand(StompCommand command) { + for (StompCommand c : commands) { + if (c.equals(command)) { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/InterceptorErrorException.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/InterceptorErrorException.java new file mode 100644 index 000000000..b46f47c3c --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/InterceptorErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.socket.common.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class InterceptorErrorException extends GlobalErrorException { + private final InterceptorErrorCode errorCode; + + public InterceptorErrorException(InterceptorErrorCode baseErrorCode) { + super(baseErrorCode); + this.errorCode = baseErrorCode; + } + + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public InterceptorErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/MessageErrorCode.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/MessageErrorCode.java new file mode 100644 index 000000000..5da3e6e31 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/MessageErrorCode.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.socket.common.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; + +public enum MessageErrorCode implements BaseErrorCode { + MALFORMED_MESSAGE_BODY(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_REQUEST_BODY, "잘못된 메시지 형식입니다"), + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + MessageErrorCode(StatusCode statusCode, ReasonCode reasonCode, String message) { + this.statusCode = statusCode; + this.reasonCode = reasonCode; + this.message = message; + } + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/MessageErrorException.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/MessageErrorException.java new file mode 100644 index 000000000..54bd5736c --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/MessageErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.socket.common.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class MessageErrorException extends GlobalErrorException { + private final MessageErrorCode errorCode; + + public MessageErrorException(MessageErrorCode baseErrorCode) { + super(baseErrorCode); + this.errorCode = baseErrorCode; + } + + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public MessageErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/PreAuthorizeErrorCode.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/PreAuthorizeErrorCode.java new file mode 100644 index 000000000..3d0e48a96 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/PreAuthorizeErrorCode.java @@ -0,0 +1,32 @@ +package kr.co.pennyway.socket.common.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import static kr.co.pennyway.common.exception.ReasonCode.*; + +@Getter +@RequiredArgsConstructor +public enum PreAuthorizeErrorCode implements BaseErrorCode { + NOT_ANONYMOUS(StatusCode.UNAUTHORIZED, MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "인증되지 않은 사용자입니다."), + UNAUTHENTICATED(StatusCode.UNAUTHORIZED, EXPIRED_OR_REVOKED_TOKEN, "인증되지 않은 사용자입니다."), + FORBIDDEN(StatusCode.FORBIDDEN, ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "접근 권한이 없습니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/PreAuthorizeErrorException.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/PreAuthorizeErrorException.java new file mode 100644 index 000000000..3101260ad --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/PreAuthorizeErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.socket.common.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class PreAuthorizeErrorException extends GlobalErrorException { + private final PreAuthorizeErrorCode errorCode; + + public PreAuthorizeErrorException(PreAuthorizeErrorCode preAuthorizeErrorCode) { + super(preAuthorizeErrorCode); + this.errorCode = preAuthorizeErrorCode; + } + + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public PreAuthorizeErrorCode getErrorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/WebSocketGlobalExceptionHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/WebSocketGlobalExceptionHandler.java new file mode 100644 index 000000000..4cfcb05d0 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/exception/WebSocketGlobalExceptionHandler.java @@ -0,0 +1,82 @@ +package kr.co.pennyway.socket.common.exception; + +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import kr.co.pennyway.common.exception.GlobalErrorException; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import kr.co.pennyway.socket.common.dto.ServerSideMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.security.Principal; + +import static kr.co.pennyway.common.exception.ReasonCode.TYPE_MISMATCH_ERROR_IN_REQUEST_BODY; + +/** + * 비지니스 예외를 처리하는 전역 핸들러. + */ +@Slf4j +@ControllerAdvice +@RequiredArgsConstructor +public class WebSocketGlobalExceptionHandler { + private static final String ERROR_DESTINATION = "/queue/errors"; + + private final SimpMessagingTemplate template; + + @MessageExceptionHandler(GlobalErrorException.class) + public void handleGlobalErrorException(Principal principal, StompHeaderAccessor accessor, GlobalErrorException e) { + ServerSideMessage serverSideMessage = ServerSideMessage.of(e.causedBy().getCode(), e.getBaseErrorCode().getExplainError()); + log.warn("handleGlobalErrorException: {}", serverSideMessage); + + sendErrorMessage(principal, serverSideMessage, accessor); + } + + @MessageExceptionHandler(MethodArgumentTypeMismatchException.class) + public void handleMethodArgumentTypeMismatchException(Principal principal, StompHeaderAccessor accessor, MethodArgumentTypeMismatchException e) { + String code = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + TYPE_MISMATCH_ERROR_IN_REQUEST_BODY.getCode()); + ServerSideMessage serverSideMessage = ServerSideMessage.of(code, e.getMessage()); + log.warn("handleMethodArgumentTypeMismatchException: {}", serverSideMessage); + + sendErrorMessage(principal, serverSideMessage, accessor); + } + + @MessageExceptionHandler(HttpMessageNotReadableException.class) + public void handleHttpMessageNotReadableException(Principal principal, StompHeaderAccessor accessor, HttpMessageNotReadableException e) { + String code, message; + if (e.getCause() instanceof MismatchedInputException mismatchedInputException) { + code = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + TYPE_MISMATCH_ERROR_IN_REQUEST_BODY.getCode()); + message = mismatchedInputException.getPath().get(0).getFieldName() + " 필드의 값이 유효하지 않습니다."; + } else { + code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.MALFORMED_REQUEST_BODY.getCode()); + message = e.getMessage(); + } + + ServerSideMessage serverSideMessage = ServerSideMessage.of(code, message); + log.warn("handleHttpMessageNotReadableException: {}", serverSideMessage); + + sendErrorMessage(principal, serverSideMessage, accessor); + } + + @MessageExceptionHandler(Exception.class) + public void handleException(Principal principal, StompHeaderAccessor accessor, Exception e) { + ServerSideMessage serverSideMessage = ServerSideMessage.of("5000", e.getMessage()); + log.error("handleException: {}", serverSideMessage); + + sendErrorMessage(principal, serverSideMessage, accessor); + } + + private void sendErrorMessage(Principal principal, ServerSideMessage serverSideMessage, StompHeaderAccessor accessor) { + if (principal == null) { + log.warn("예외 메시지를 반환할 사용자가 없습니다."); + return; + } + + template.convertAndSendToUser(principal.getName(), ERROR_DESTINATION, serverSideMessage, accessor.getMessageHeaders()); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/StompCommandHandlerFactory.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/StompCommandHandlerFactory.java new file mode 100644 index 000000000..a6fe5fcf1 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/StompCommandHandlerFactory.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.socket.common.interceptor; + +import kr.co.pennyway.socket.common.interceptor.marker.StompCommandHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.stereotype.Component; + +import java.util.*; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StompCommandHandlerFactory { + private final Map> handlers = new EnumMap<>(StompCommand.class); + + @Autowired + public StompCommandHandlerFactory(List allHandlers) { + allHandlers.forEach(this::registerHandler); + log.info("StompCommandHandlerFactory: handlers={}", handlers); + } + + private void registerHandler(StompCommandHandler handler) { + Arrays.stream(StompCommand.values()) + .filter(handler::isSupport) + .forEach(command -> { + handlers.computeIfAbsent(command, k -> new ArrayList<>()).add(handler); + log.info("Registered handler {} for command {}", handler.getClass().getSimpleName(), command); + }); + } + + public List getHandlers(StompCommand command) { + return handlers.getOrDefault(command, List.of()); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/StompExceptionInterceptor.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/StompExceptionInterceptor.java new file mode 100644 index 000000000..fd9fc74b3 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/StompExceptionInterceptor.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.socket.common.interceptor; + +import kr.co.pennyway.socket.common.interceptor.marker.StompExceptionHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StompExceptionInterceptor extends StompSubProtocolErrorHandler { + private final List interceptors; + + @Override + @Nullable + public Message handleClientMessageProcessingError(@Nullable Message clientMessage, Throwable ex) { + Throwable cause = ex.getCause(); + + for (StompExceptionHandler interceptor : interceptors) { + if (interceptor.canHandle(cause)) { + log.warn("STOMP client message processing throw({}) catch handler {}", cause.getMessage(), interceptor); + return interceptor.handle(clientMessage, cause); + } + } + + log.error("STOMP client message processing error: {}", ex.getMessage()); + return super.handleClientMessageProcessingError(clientMessage, ex); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/StompInboundInterceptor.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/StompInboundInterceptor.java new file mode 100644 index 000000000..a12a715eb --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/StompInboundInterceptor.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.socket.common.interceptor; + +import kr.co.pennyway.socket.common.interceptor.marker.StompCommandHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StompInboundInterceptor implements ChannelInterceptor { + private final StompCommandHandlerFactory stompCommandHandlerFactory; + + @Override + public Message preSend(@NonNull Message message, @NonNull MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (accessor != null && accessor.getCommand() != null) { + log.info("[StompInboundInterceptor] command={}", accessor.getCommand()); + + for (StompCommandHandler handler : stompCommandHandlerFactory.getHandlers(accessor.getCommand())) { + handler.handle(message, accessor); + } + } + + return message; + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/exception/AbstractStompExceptionHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/exception/AbstractStompExceptionHandler.java new file mode 100644 index 000000000..627e39453 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/exception/AbstractStompExceptionHandler.java @@ -0,0 +1,68 @@ +package kr.co.pennyway.socket.common.interceptor.handler.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.socket.common.dto.ServerSideMessage; +import kr.co.pennyway.socket.common.interceptor.marker.StompExceptionHandler; +import kr.co.pennyway.socket.common.util.StompMessageUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; + +/** + * STOMP 예외 처리를 위한 추상 기본 클래스. + * 이 클래스는 공통적인 예외 처리 로직을 제공하며, 구체적인 예외 처리 동작은 하위 클래스에서 구현합니다. + */ +@Slf4j +public abstract class AbstractStompExceptionHandler implements StompExceptionHandler { + protected final ObjectMapper objectMapper; + + public AbstractStompExceptionHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public Message handle(Message clientMessage, Throwable cause) { + if (isNullReturnRequired(clientMessage)) { + return null; + } + + StompHeaderAccessor accessor = StompHeaderAccessor.create(getStompCommand()); + accessor.setLeaveMutable(true); + extractClientHeaderAccessor(clientMessage, accessor); + ServerSideMessage payload = getServerSideMessage(cause); + + if (payload != null) { + accessor.setMessage(payload.code()); + } + + accessor.setImmutable(); + return StompMessageUtil.createMessage(accessor, payload, objectMapper); + } + + /** + * 클라이언트 메시지의 유효성을 검사합니다. + * 기본 구현은 항상 false를 반환합니다. 필요한 경우 하위 클래스에서 재정의할 수 있습니다. + * + * @param clientMessage 클라이언트로부터 받은 원본 메시지 + * @return null을 반환해야 한다면 true, 그렇지 않다면 false + */ + protected boolean isNullReturnRequired(Message clientMessage) { + return false; + } + + /** + * 이 핸들러가 사용할 STOMP 명령을 반환합니다. + * + * @return STOMP 명령 + */ + protected abstract StompCommand getStompCommand(); + + /** + * 주어진 예외를 기반으로 {@link ServerSideMessage}를 생성합니다. + * + * @param cause 발생한 예외 + * @return 생성된 ServerSideMessage + */ + protected abstract ServerSideMessage getServerSideMessage(Throwable cause); +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/exception/AuthenticateExceptionHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/exception/AuthenticateExceptionHandler.java new file mode 100644 index 000000000..bd39bda89 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/exception/AuthenticateExceptionHandler.java @@ -0,0 +1,37 @@ +package kr.co.pennyway.socket.common.interceptor.handler.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.util.JwtErrorCodeUtil; +import kr.co.pennyway.socket.common.dto.ServerSideMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class AuthenticateExceptionHandler extends AbstractStompExceptionHandler { + public AuthenticateExceptionHandler(ObjectMapper objectMapper) { + super(objectMapper); + } + + @Override + public boolean canHandle(Throwable cause) { + return cause instanceof JwtErrorException; + } + + @Override + protected StompCommand getStompCommand() { + return StompCommand.ERROR; + } + + @Override + protected ServerSideMessage getServerSideMessage(Throwable cause) { + JwtErrorException ex = (JwtErrorException) cause; + ex = JwtErrorCodeUtil.determineAuthErrorException(ex); + + log.warn("[인증 예외] {}", ex.getErrorCode().getMessage()); + + return ServerSideMessage.of(ex.getErrorCode().getExplainError()); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/exception/SubscribeExceptionHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/exception/SubscribeExceptionHandler.java new file mode 100644 index 000000000..3c6680e23 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/exception/SubscribeExceptionHandler.java @@ -0,0 +1,54 @@ +package kr.co.pennyway.socket.common.interceptor.handler.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.socket.common.dto.ServerSideMessage; +import kr.co.pennyway.socket.common.exception.InterceptorErrorException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class SubscribeExceptionHandler extends AbstractStompExceptionHandler { + public SubscribeExceptionHandler(ObjectMapper objectMapper) { + super(objectMapper); + } + + @Override + public boolean canHandle(Throwable cause) { + if (cause instanceof InterceptorErrorException ex) { + return ex.getErrorCode().isSupportCommand(StompCommand.SUBSCRIBE); + } + return false; + } + + @Override + protected StompCommand getStompCommand() { + return StompCommand.RECEIPT; + } + + @Override + protected ServerSideMessage getServerSideMessage(Throwable cause) { + InterceptorErrorException ex = (InterceptorErrorException) cause; + return ServerSideMessage.of(ex.causedBy().getCode(), ex.getErrorCode().getExplainError()); + } + + @Override + protected boolean isNullReturnRequired(Message clientMessage) { + if (clientMessage == null) { + log.warn("receipt header가 존재하지 않습니다. clientMessage={}", clientMessage); + return true; + } + + StompHeaderAccessor accessor = StompHeaderAccessor.getAccessor(clientMessage, StompHeaderAccessor.class); + + if (accessor == null || accessor.getReceipt() == null) { + log.warn("receipt header가 존재하지 않습니다. accessor={}", accessor); + return true; + } + + return false; + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/ChatExchangeAuthorizeHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/ChatExchangeAuthorizeHandler.java new file mode 100644 index 000000000..9beb987e4 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/ChatExchangeAuthorizeHandler.java @@ -0,0 +1,65 @@ +package kr.co.pennyway.socket.common.interceptor.handler.inbound; + +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties; +import kr.co.pennyway.socket.common.exception.InterceptorErrorCode; +import kr.co.pennyway.socket.common.exception.InterceptorErrorException; +import kr.co.pennyway.socket.common.interceptor.marker.SubscribeCommandHandler; +import kr.co.pennyway.socket.common.properties.MessageBrokerProperties; +import kr.co.pennyway.socket.common.registry.ResourceAccessRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@EnableConfigurationProperties({ChatExchangeProperties.class, MessageBrokerProperties.class}) +public class ChatExchangeAuthorizeHandler implements SubscribeCommandHandler { + private static final String REQUEST_EXCHANGE_PREFIX = "/sub/"; + private static final String CONVERTED_EXCHANGE_PREFIX = "/exchange/"; + + private final ChatExchangeProperties chatExchangeProperties; + private final MessageBrokerProperties messageBrokerProperties; + + private final ResourceAccessRegistry resourceAccessRegistry; + + @Override + public boolean isSupport(StompCommand command) { + return StompCommand.SUBSCRIBE.equals(command); + } + + @Override + public void handle(Message message, StompHeaderAccessor accessor) { + String destination = accessor.getDestination(); + + // private exchange에 대해서는 bypass + if (destination != null && destination.startsWith(messageBrokerProperties.getUserPrefix() + "/")) { + log.info("[Exchange 권한 검사] User {}에 대한 {} 권한 검사 통과 (bypass)", accessor.getUser().getName(), destination); + return; + } + + if (resourceAccessRegistry.getChecker(destination).hasPermission(destination, accessor.getUser())) { + log.info("[Exchange 권한 검사] User {}에 대한 {} 권한 검사 통과", accessor.getUser().getName(), destination); + String convertedDestination = convertDestination(destination); + accessor.setDestination(convertedDestination); + } else { + log.warn("[Exchange 권한 검사] User {}에 대한 {} 권한 검사 실패", accessor.getUser().getName(), destination); + throw new InterceptorErrorException(InterceptorErrorCode.UNAUTHORIZED_TO_SUBSCRIBE); + } + } + + private String convertDestination(String destination) { + if (destination == null || !destination.startsWith(REQUEST_EXCHANGE_PREFIX)) { + throw new InterceptorErrorException(InterceptorErrorCode.INVALID_DESTINATION); + } + + String convertedDestination = destination.replace(REQUEST_EXCHANGE_PREFIX, CONVERTED_EXCHANGE_PREFIX + chatExchangeProperties.getExchange() + "/"); + log.debug("[Exchange 변환 핸들러] destination={}, convertedDestination={}", destination, convertedDestination); + + return convertedDestination; + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/ConnectAuthenticateHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/ConnectAuthenticateHandler.java new file mode 100644 index 000000000..7bc963fce --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/ConnectAuthenticateHandler.java @@ -0,0 +1,109 @@ +package kr.co.pennyway.socket.common.interceptor.handler.inbound; + +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.account.service.UserSessionService; +import kr.co.pennyway.domain.domains.session.domain.UserSession; +import kr.co.pennyway.domain.domains.session.type.UserStatus; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.jwt.AuthConstants; +import kr.co.pennyway.infra.common.jwt.JwtClaims; +import kr.co.pennyway.infra.common.util.JwtClaimsParserUtil; +import kr.co.pennyway.socket.common.constants.StompNativeHeaderFields; +import kr.co.pennyway.socket.common.exception.InterceptorErrorCode; +import kr.co.pennyway.socket.common.exception.InterceptorErrorException; +import kr.co.pennyway.socket.common.interceptor.marker.ConnectCommandHandler; +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal; +import kr.co.pennyway.socket.common.security.jwt.AccessTokenClaimKeys; +import kr.co.pennyway.socket.common.security.jwt.AccessTokenProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ConnectAuthenticateHandler implements ConnectCommandHandler { + private final AccessTokenProvider accessTokenProvider; + private final UserService userService; + private final UserSessionService userSessionService; + + @Override + public boolean isSupport(StompCommand command) { + return StompCommand.CONNECT.equals(command); + } + + @Override + public void handle(Message message, StompHeaderAccessor accessor) { + String accessToken = extractAccessToken(accessor); + + JwtClaims claims = accessTokenProvider.getJwtClaimsFromToken(accessToken); + Long userId = JwtClaimsParserUtil.getClaimsValue(claims, AccessTokenClaimKeys.USER_ID.getValue(), Long::parseLong); + LocalDateTime expiresDate = accessTokenProvider.getExpiryDate(accessToken); + + existsHeader(accessor); + + UserPrincipal principal = (UserPrincipal) authenticateUser(accessor, userId, expiresDate); + activateUserSession(principal); + } + + private String extractAccessToken(StompHeaderAccessor accessor) { + String authorization = accessor.getFirstNativeHeader(AuthConstants.AUTHORIZATION.getValue()); + + if ((authorization == null || !authorization.startsWith(AuthConstants.TOKEN_TYPE.getValue()))) { + log.warn("[인증 핸들러] 헤더에 Authorization이 없거나 Bearer 토큰이 아닙니다."); + throw new JwtErrorException(JwtErrorCode.EMPTY_ACCESS_TOKEN); + } + + return authorization.substring(7); + } + + private void existsHeader(StompHeaderAccessor accessor) { + List headerNames = List.of( + StompNativeHeaderFields.DEVICE_ID.getValue(), + StompNativeHeaderFields.DEVICE_NAME.getValue() + ); + + for (String headerName : headerNames) { + if (!accessor.containsNativeHeader(headerName)) { + log.warn("[인증 핸들러] 헤더에 {}가 없습니다.", headerName); + throw new InterceptorErrorException(InterceptorErrorCode.INAVLID_HEADER); + } + } + } + + private Principal authenticateUser(StompHeaderAccessor accessor, Long userId, LocalDateTime expiresDate) { + String deviceId = accessor.getFirstNativeHeader(StompNativeHeaderFields.DEVICE_ID.getValue()); + String deviceName = accessor.getFirstNativeHeader(StompNativeHeaderFields.DEVICE_NAME.getValue()); + + User user = userService.readUser(userId) + .orElseThrow(() -> new JwtErrorException(JwtErrorCode.MALFORMED_TOKEN)); + Principal principal = UserPrincipal.of(user, expiresDate, deviceId, deviceName); + + log.info("[인증 핸들러] 사용자 인증 완료: {}", principal); + + accessor.setUser(principal); + + return principal; + } + + private void activateUserSession(UserPrincipal principal) { + if (userSessionService.isExists(principal.getUserId(), principal.getDeviceId())) { + log.info("[인증 핸들러] 사용자 세션을 갱신합니다. userId: {}, deviceId: {}", principal.getUserId(), principal.getDeviceId()); + userSessionService.updateUserStatus(principal.getUserId(), principal.getDeviceId(), UserStatus.ACTIVE_APP); + log.info("[인증 핸들러] 사용자 세션을 갱신했습니다. userId: {}, deviceId: {}", principal.getUserId(), principal.getDeviceId()); + } else { + log.info("[인증 핸들러] 사용자 세션을 생성합니다. userId: {}, deviceId: {}", principal.getUserId(), principal.getDeviceId()); + userSessionService.create(principal.getUserId(), principal.getDeviceId(), UserSession.of(principal.getUserId(), principal.getDeviceId(), principal.getDeviceName())); + log.info("[인증 핸들러] 사용자 세션을 생성했습니다. userId: {}, deviceId: {}", principal.getUserId(), principal.getDeviceId()); + } + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/DisconnectHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/DisconnectHandler.java new file mode 100644 index 000000000..a57648327 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/DisconnectHandler.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.socket.common.interceptor.handler.inbound; + +import kr.co.pennyway.domain.context.account.service.UserSessionService; +import kr.co.pennyway.domain.domains.session.type.UserStatus; +import kr.co.pennyway.socket.common.interceptor.marker.DisconnectCommandHandler; +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DisconnectHandler implements DisconnectCommandHandler { + private final UserSessionService userSessionService; + + @Override + public boolean isSupport(StompCommand command) { + return StompCommand.DISCONNECT.equals(command); + } + + @Override + public void handle(Message message, StompHeaderAccessor accessor) { + UserPrincipal principal = (UserPrincipal) accessor.getUser(); + + userSessionService.updateUserStatus(principal.getUserId(), principal.getDeviceId(), UserStatus.INACTIVE); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/HeartBeatNegotiationInterceptor.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/HeartBeatNegotiationInterceptor.java new file mode 100644 index 000000000..76000ede0 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/handler/inbound/HeartBeatNegotiationInterceptor.java @@ -0,0 +1,55 @@ +package kr.co.pennyway.socket.common.interceptor.handler.inbound; + +import kr.co.pennyway.socket.common.interceptor.marker.ConnectCommandHandler; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class HeartBeatNegotiationInterceptor implements ConnectCommandHandler { + private static final String HEART_BEAT_HEADER = "heart-beat"; + + private static final long SERVER_HEARTBEAT_SEND = 25000; // sx + private static final long SERVER_HEARTBEAT_RECEIVE = 25000; // sy + + @Override + public boolean isSupport(StompCommand command) { + return StompCommand.CONNECT.equals(command); + } + + @Override + public void handle(Message message, StompHeaderAccessor accessor) { + String heartbeat = accessor.getFirstNativeHeader(HEART_BEAT_HEADER); + + long clientToServer = SERVER_HEARTBEAT_RECEIVE; + long serverToClient = SERVER_HEARTBEAT_SEND; + + if (heartbeat == null || heartbeat.equals("0,0")) { + log.debug("Client attempted connection without heart-beat. Enforcing server's heart-beat policy: {},{}", + SERVER_HEARTBEAT_SEND, SERVER_HEARTBEAT_RECEIVE); + } + + if (heartbeat != null) { + String[] parts = heartbeat.split(","); + + if (parts.length == 2) { + long cx = Long.parseLong(parts[0]); + long cy = Long.parseLong(parts[1]); + + clientToServer = (cx != 0) ? Math.max(cx, SERVER_HEARTBEAT_RECEIVE) : SERVER_HEARTBEAT_RECEIVE; + serverToClient = (cy != 0) ? Math.max(SERVER_HEARTBEAT_SEND, cy) : SERVER_HEARTBEAT_SEND; + + log.debug("Heart-beat negotiation - Client wants: {}, Server wants: {}", + heartbeat, SERVER_HEARTBEAT_SEND + "," + SERVER_HEARTBEAT_RECEIVE); + } + } + + log.info("Negotiated heart-beat - Client to Server: {}, Server to Client: {}", + clientToServer, serverToClient); + + accessor.setNativeHeader(HEART_BEAT_HEADER, clientToServer + "," + serverToClient); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/ConnectCommandHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/ConnectCommandHandler.java new file mode 100644 index 000000000..320196ec6 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/ConnectCommandHandler.java @@ -0,0 +1,4 @@ +package kr.co.pennyway.socket.common.interceptor.marker; + +public interface ConnectCommandHandler extends StompCommandHandler { +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/DisconnectCommandHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/DisconnectCommandHandler.java new file mode 100644 index 000000000..ffd372d51 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/DisconnectCommandHandler.java @@ -0,0 +1,4 @@ +package kr.co.pennyway.socket.common.interceptor.marker; + +public interface DisconnectCommandHandler extends StompCommandHandler { +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/StompCommandHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/StompCommandHandler.java new file mode 100644 index 000000000..eef112053 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/StompCommandHandler.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.socket.common.interceptor.marker; + +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; + +/** + * STOMP 명령어 핸들러 인터페이스 + */ +public interface StompCommandHandler { + /** + * 해당 핸들러가 지원하는 명령어인지 확인한다. + * + * @param command {@link StompCommand} 명령어 + */ + boolean isSupport(StompCommand command); + + void handle(Message message, StompHeaderAccessor accessor); +} + diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/StompExceptionHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/StompExceptionHandler.java new file mode 100644 index 000000000..2b2456147 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/StompExceptionHandler.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.socket.common.interceptor.marker; + +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageHeaderAccessor; + +/** + * STOMP 인터셉터에서 발생한 예외를 처리하기 위한 인터페이스 + */ +public interface StompExceptionHandler { + /** + * 해당 예외를 처리할 수 있는지 여부를 반환하는 메서드 + * + * @return true: 해당 예외를 처리할 수 있음, false: 해당 예외를 처리할 수 없음 + */ + boolean canHandle(Throwable cause); + + /** + * 예외를 처리하는 메서드. + * WebSocket 프로토콜에 의해 ERROR 커맨드를 사용하면, client와의 연결을 반드시 끊어야 한다. + * 이를 원치 않는 경우, {@link StompCommand#ERROR}를 사용하여 Accessor를 설정해서는 안 된다. + * + * @param clientMessage {@link Message}: client로부터 받은 메시지 + * @param cause Throwable: 발생한 예외 + * @return {@link Message}: client에게 보낼 최종 메시지 + */ + @Nullable + Message handle(@Nullable Message clientMessage, Throwable cause); + + /** + * 클라이언트 메시지에서 필요한 헤더 정보를 추출하여 새로운 StompHeaderAccessor에 설정합니다. + * 기본적으로 receiptId를 추출합니다. 하위 클래스에서 필요에 따라 재정의할 수 있습니다. + * + * @param clientMessage 클라이언트로부터 받은 원본 메시지 (null일 수 있음) + * @param accessor 새로 생성된 StompHeaderAccessor + */ + default void extractClientHeaderAccessor(Message clientMessage, StompHeaderAccessor accessor) { + if (clientMessage != null) { + StompHeaderAccessor clientHeaderAccessor = MessageHeaderAccessor.getAccessor(clientMessage, StompHeaderAccessor.class); + if (clientHeaderAccessor != null) { + String receiptId = clientHeaderAccessor.getReceipt(); + if (receiptId != null) { + accessor.setReceiptId(receiptId); + } + } + } + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/SubscribeCommandHandler.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/SubscribeCommandHandler.java new file mode 100644 index 000000000..23b0d88b1 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/interceptor/marker/SubscribeCommandHandler.java @@ -0,0 +1,4 @@ +package kr.co.pennyway.socket.common.interceptor.marker; + +public interface SubscribeCommandHandler extends StompCommandHandler { +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/properties/ChatServerProperties.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/properties/ChatServerProperties.java new file mode 100644 index 000000000..5baf0b0d4 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/properties/ChatServerProperties.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.socket.common.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "pennyway.socket.chat") +public class ChatServerProperties { + private final String endpoint; + private final List allowedOriginPatterns; + + @Override + public String toString() { + return "ChatServerProperties{" + + "endpoint='" + endpoint + '\'' + + ", allowedOriginPatterns=" + allowedOriginPatterns + + '}'; + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/properties/MessageBrokerProperties.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/properties/MessageBrokerProperties.java new file mode 100644 index 000000000..758bb2431 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/properties/MessageBrokerProperties.java @@ -0,0 +1,37 @@ +package kr.co.pennyway.socket.common.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "message-broker.external") +public class MessageBrokerProperties { + private final String host; + private final int port; + private final String systemId; + private final String systemPassword; + private final String clientId; + private final String clientPassword; + private final String userPrefix; + private final String publishExchange; + private final int heartbeatSendInterval; + private final int heartbeatReceiveInterval; + + @Override + public String toString() { + return "MessageBrokerProperties{" + + "host='" + host + '\'' + + ", port=" + port + + ", systemId='" + systemId + '\'' + + ", systemPassword='" + systemPassword + '\'' + + ", clientId='" + clientId + '\'' + + ", clientPassword='" + clientPassword + '\'' + + ", userPrefix='" + userPrefix + '\'' + + ", publishExchange='" + publishExchange + '\'' + + ", heartbeatSendInterval=" + heartbeatSendInterval + + ", heartbeatReceiveInterval=" + heartbeatReceiveInterval + + '}'; + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/registry/ChatRoomAccessChecker.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/registry/ChatRoomAccessChecker.java new file mode 100644 index 000000000..7d1f46067 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/registry/ChatRoomAccessChecker.java @@ -0,0 +1,39 @@ +package kr.co.pennyway.socket.common.registry; + +import kr.co.pennyway.domain.context.chat.service.ChatMemberService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.security.Principal; + +@Slf4j +@Component("chatRoomAccessChecker") +@RequiredArgsConstructor +public class ChatRoomAccessChecker implements ResourceAccessChecker { + private final ChatMemberService chatMemberService; + + @Override + public boolean hasPermission(String path, Principal principal) { + return isChatRoomAccessPermit(getChatRoomId(path), principal); + } + + public boolean hasPermission(Long chatRoomId, Principal principal) { + return isChatRoomAccessPermit(chatRoomId, principal); + } + + /** + * path에서 chatRoomId를 추출한다. + * + * @param path : {@code /sub/chat.room.{roomId} 포맷} + * @return chatRoomId + */ + private Long getChatRoomId(String path) { + String[] split = path.split("\\."); + return Long.parseLong(split[split.length - 1]); + } + + private boolean isChatRoomAccessPermit(Long chatRoomId, Principal principal) { + return chatMemberService.isExists(chatRoomId, Long.parseLong(principal.getName())); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/registry/ResourceAccessChecker.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/registry/ResourceAccessChecker.java new file mode 100644 index 000000000..ccd84d72f --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/registry/ResourceAccessChecker.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.socket.common.registry; + +import java.security.Principal; + +/** + * 리소스 접근 권한을 확인하는 인터페이스 + */ +public interface ResourceAccessChecker { + /** + * 리소스에 대한 접근 권한을 확인한다. + * + * @param path : 요청 경로 + * @param principal : 요청자 + * @return 접근 권한 여부 + */ + boolean hasPermission(String path, Principal principal); +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/registry/ResourceAccessRegistry.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/registry/ResourceAccessRegistry.java new file mode 100644 index 000000000..bbb470f2e --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/registry/ResourceAccessRegistry.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.socket.common.registry; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 리소스 접근 권한 체커를 관리하는 레지스트리 + * path에 대한 checker를 내부적으로 관리한다. + */ +public final class ResourceAccessRegistry { + private final Map checkers = new HashMap<>(); + + public ResourceAccessRegistry() { + } + + public void registerChecker(final String pathPattern, final ResourceAccessChecker checker) { + checkers.put(Pattern.compile(pathPattern), checker); + } + + /** + * path에 대한 체커를 반환한다. + * + * @param path : 요청 경로 + * @return ResourceAccessChecker : path에 대한 체커 + * @throws IllegalArgumentException : 해당 경로에 대한 체커가 없는 경우 + */ + public ResourceAccessChecker getChecker(final String path) { + return checkers.entrySet().stream() + .filter(entry -> entry.getKey().matcher(path).matches()) + .map(Map.Entry::getValue) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("해당 경로에 대한 체커가 없습니다. path = " + path)); + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/authenticate/UserPrincipal.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/authenticate/UserPrincipal.kt new file mode 100644 index 000000000..0891b6ef4 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/authenticate/UserPrincipal.kt @@ -0,0 +1,48 @@ +package kr.co.pennyway.socket.common.security.authenticate; + +import kr.co.pennyway.domain.domains.user.domain.User +import kr.co.pennyway.domain.domains.user.type.Role +import java.security.Principal +import java.time.LocalDateTime + +data class UserPrincipal( + val userId: Long, + private var _name: String, + var username: String, + var role: Role, + var isChatNotify: Boolean, + var expiresAt: LocalDateTime, + var deviceId: String, + var deviceName: String +) : Principal { + fun isAuthenticated(): Boolean = !isExpired() + + fun updateExpiresAt(newExpiresAt: LocalDateTime) { + this.expiresAt = newExpiresAt + } + + override fun getName(): String = userId.toString() + + fun getDefaultName(): String = _name + + private fun isExpired(): Boolean = LocalDateTime.now().isAfter(expiresAt) + + companion object { + @JvmStatic + fun of( + user: User, + expiresAt: LocalDateTime, + deviceId: String, + deviceName: String + ): UserPrincipal = UserPrincipal( + userId = user.id, + _name = user.name, + username = user.username, + role = user.role, + isChatNotify = user.notifySetting.isChatNotify, + expiresAt = expiresAt, + deviceId = deviceId, + deviceName = deviceName + ) + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/jwt/AccessTokenClaim.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/jwt/AccessTokenClaim.java new file mode 100644 index 000000000..a47ceecd3 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/jwt/AccessTokenClaim.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.socket.common.security.jwt; + +import kr.co.pennyway.infra.common.jwt.JwtClaims; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.util.Map; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class AccessTokenClaim implements JwtClaims { + private final Map claims; + + public static AccessTokenClaim of(Long userId, String role) { + Map claims = Map.of( + AccessTokenClaimKeys.USER_ID.getValue(), userId.toString(), + AccessTokenClaimKeys.ROLE.getValue(), role + ); + return new AccessTokenClaim(claims); + } + + @Override + public Map getClaims() { + return claims; + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/jwt/AccessTokenClaimKeys.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/jwt/AccessTokenClaimKeys.java new file mode 100644 index 000000000..d9d2fa6b8 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/jwt/AccessTokenClaimKeys.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.socket.common.security.jwt; + +public enum AccessTokenClaimKeys { + USER_ID("id"), + ROLE("role"); + + private final String value; + + AccessTokenClaimKeys(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/jwt/AccessTokenProvider.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/jwt/AccessTokenProvider.java new file mode 100644 index 000000000..9094dbf8e --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/jwt/AccessTokenProvider.java @@ -0,0 +1,93 @@ +package kr.co.pennyway.socket.common.security.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import kr.co.pennyway.common.util.DateUtil; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.jwt.JwtClaims; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import kr.co.pennyway.infra.common.util.JwtErrorCodeUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +import static kr.co.pennyway.socket.common.security.jwt.AccessTokenClaimKeys.ROLE; +import static kr.co.pennyway.socket.common.security.jwt.AccessTokenClaimKeys.USER_ID; + +@Slf4j +@Primary +@Component +public class AccessTokenProvider implements JwtProvider { + private final SecretKey secretKey; + + public AccessTokenProvider( + @Value("${jwt.secret-key.access-token}") String jwtSecretKey + ) { + final byte[] secretKeyBytes = Base64.getDecoder().decode(jwtSecretKey); + this.secretKey = Keys.hmacShaKeyFor(secretKeyBytes); + } + + @Override + public String generateToken(JwtClaims claims) { + throw new UnsupportedOperationException("채팅 서버에서는 AccessToken을 생성하지 않습니다."); + } + + @Override + public JwtClaims getJwtClaimsFromToken(String token) { + Claims claims = getClaimsFromToken(token); + return AccessTokenClaim.of(Long.parseLong(claims.get(USER_ID.getValue(), String.class)), claims.get(ROLE.getValue(), String.class)); + } + + @Override + public LocalDateTime getExpiryDate(String token) { + Claims claims = getClaimsFromToken(token); + return DateUtil.toLocalDateTime(claims.getExpiration()); + } + + @Override + public boolean isTokenExpired(String token) { + try { + Claims claims = getClaimsFromToken(token); + return claims.getExpiration().before(new Date()); + } catch (JwtErrorException e) { + if (JwtErrorCode.EXPIRED_TOKEN.equals(e.getErrorCode())) return true; + throw e; + } + } + + @Override + public Claims getClaimsFromToken(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (JwtException e) { + final JwtErrorCode errorCode = JwtErrorCodeUtil.determineErrorCode(e, JwtErrorCode.FAILED_AUTHENTICATION); + + log.warn("Error code : {}, Error - {}, {}", errorCode, e.getClass(), e.getMessage()); + throw new JwtErrorException(errorCode); + } + } + + private Map createHeader() { + return Map.of("typ", "JWT", + "alg", "HS256", + "regDate", System.currentTimeMillis()); + } + + private Date createExpireDate(final Date now, long expirationTime) { + return new Date(now.getTime() + expirationTime); + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/PreAuthorizeSpELParser.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/PreAuthorizeSpELParser.kt new file mode 100644 index 000000000..5dd428651 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/PreAuthorizeSpELParser.kt @@ -0,0 +1,129 @@ +package kr.co.pennyway.socket.common.util; + +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import org.springframework.context.ApplicationContext +import org.springframework.context.expression.BeanFactoryResolver +import org.springframework.expression.spel.standard.SpelExpressionParser +import org.springframework.expression.spel.support.StandardEvaluationContext +import java.lang.reflect.Method +import java.security.Principal + +/** + * WebSocket 인증 및 인가를 위한 Spring Expression Language (SpEL) 파서. + * 이 클래스는 WebSocket 연결에서 사용되는 다양한 인증/인가 함수를 제공하고, + * SpEL 표현식을 평가하는 기능을 제공합니다. + * + * @author YANG JAESEO + * @version 1.1.0 + * @since 2024.12.25 + */ +object PreAuthorizeSpELParser { + private val parser = SpelExpressionParser() + private val context = StandardEvaluationContext().apply { + initializeContext() + } + + sealed interface EvaluationResult { + object Permitted : EvaluationResult + sealed interface Denied : EvaluationResult { + object Unauthenticated : Denied + object Unauthorized : Denied + } + } + + private fun StandardEvaluationContext.initializeContext() = apply { + SpELFunction.values().forEach { function -> registerSpELFunction(function) } + } + + private fun StandardEvaluationContext.registerSpELFunction(function: SpELFunction) { + runCatching { + PreAuthorizeSpELParser::class.java + .getDeclaredMethod(function.methodName, *function.parameterTypes) + .let { method -> registerFunction(function.level, method) } + }.onFailure { e -> + throw RuntimeException("Error registering SpEL function: ${function.level}", e) + } + } + + /** + * 주어진 SpEL 표현식을 평가합니다. + */ + @Synchronized + fun evaluate( + expression: String, + method: Method, + args: Array, + applicationContext: ApplicationContext + ): EvaluationResult = context.run { + setupContext(method, args, applicationContext) + evaluateExpression(expression) + } + + /** + * SpEL 평가를 위해, 사용자의 Principal 객체와 메서드의 인자들을 EvaluationContext에 추가합니다. + */ + private fun setupContext( + method: Method, + args: Array, + applicationContext: ApplicationContext + ) { + with(context) { + context.setBeanResolver(BeanFactoryResolver(applicationContext)) + + method.parameters.forEachIndexed { index, parameter -> + setVariable(parameter.name, args[index]) + } + } + } + + private fun StandardEvaluationContext.evaluateExpression( + expression: String + ): EvaluationResult { + val isAuthenticationRequired = expression.contains(SpELFunction.IS_AUTHENTICATED.level) + + val authenticationResult = when { + isAuthenticationRequired -> evaluateAuthentication() + else -> true + } + + val authorizationResult = evaluateAuthorization(expression) + + return when { + authenticationResult.not() -> EvaluationResult.Denied.Unauthenticated + authorizationResult.not() -> EvaluationResult.Denied.Unauthorized + else -> EvaluationResult.Permitted + } + } + + private fun StandardEvaluationContext.evaluateAuthentication(): Boolean = + parser.parseExpression("#isAuthenticated(#principal)") + .getValue(this, Boolean::class.java) ?: false + + private fun StandardEvaluationContext.evaluateAuthorization(expression: String): Boolean = + parser.parseExpression(expression) + .getValue(this, Boolean::class.java) ?: false + + /** + * 모든 사용자에게 접근을 허용합니다. + */ + @JvmStatic + fun permitAll(): Boolean = true + + /** + * 주어진 Principal이 인증된 사용자인지 확인합니다. + */ + @JvmStatic + fun isAuthenticated(principal: Principal): Boolean = when (principal) { + is UserPrincipal -> principal.isAuthenticated() + else -> false + } + + enum class SpELFunction( + val level: String, + val methodName: String, + vararg val parameterTypes: Class<*> + ) { + PERMIT_ALL("permitAll", "permitAll"), + IS_AUTHENTICATED("isAuthenticated", "isAuthenticated", Principal::class.java); + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/StompMessageUtil.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/StompMessageUtil.kt new file mode 100644 index 000000000..768c429e8 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/StompMessageUtil.kt @@ -0,0 +1,46 @@ +package kr.co.pennyway.socket.common.util; + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.pennyway.socket.common.dto.ServerSideMessage +import org.springframework.messaging.Message +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.messaging.support.MessageBuilder + +/** + * STOMP 메시지 처리를 위한 유틸리티 클래스. + * 이 클래스는 STOMP 헤더 액세서 생성 및 메시지 생성과 관련된 공통 기능을 제공합니다. + */ +object StompMessageUtil { + private val log = logger() + private val EMPTY_PAYLOAD = ByteArray(0) + + /** + * StompHeaderAccessor와 페이로드를 사용하여 STOMP 메시지를 생성합니다. + * + * @param accessor StompHeaderAccessor + * @param payload ServerSideMessage 메시지 페이로드 (null일 수 있음) + * @param objectMapper Jackson ObjectMapper + * @return 생성된 STOMP 메시지 + */ + @JvmStatic + fun createMessage( + accessor: StompHeaderAccessor, + payload: ServerSideMessage?, + objectMapper: ObjectMapper + ): Message = payload?.let { nonNullPayload -> + runCatching { + objectMapper.writeValueAsBytes(nonNullPayload) + }.fold( + onSuccess = { bytes -> + MessageBuilder.createMessage(bytes, accessor.messageHeaders) + }, + onFailure = { e -> + log.error("Error serializing payload", e) + createEmptyMessage(accessor) + } + ) + } ?: createEmptyMessage(accessor) + + private fun createEmptyMessage(accessor: StompHeaderAccessor): Message = + MessageBuilder.createMessage(EMPTY_PAYLOAD, accessor.messageHeaders) +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/log.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/log.kt new file mode 100644 index 000000000..9eff54c4c --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/log.kt @@ -0,0 +1,5 @@ +package kr.co.pennyway.socket.common.util + +import org.slf4j.LoggerFactory + +inline fun T.logger() = LoggerFactory.getLogger(T::class.java)!! diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/config/InfraConfig.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/config/InfraConfig.java new file mode 100644 index 000000000..7875c6652 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/config/InfraConfig.java @@ -0,0 +1,14 @@ +package kr.co.pennyway.socket.config; + +import kr.co.pennyway.infra.common.importer.EnablePennywayInfraConfig; +import kr.co.pennyway.infra.common.importer.PennywayInfraConfigGroup; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnablePennywayInfraConfig({ + PennywayInfraConfigGroup.MESSAGE_BROKER_CONFIG, + PennywayInfraConfigGroup.GUID_GENERATOR_CONFIG, + PennywayInfraConfigGroup.FCM +}) +public class InfraConfig { +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/config/JacksonConfig.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/config/JacksonConfig.kt new file mode 100644 index 000000000..193dafac9 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/config/JacksonConfig.kt @@ -0,0 +1,18 @@ +package kr.co.pennyway.socket.config; + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration; + +@Configuration +class JacksonConfig { + @Bean + fun objectMapper(): ObjectMapper { + return ObjectMapper().apply { + registerModule(JavaTimeModule()) + disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + } + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/config/ResourceAccessRegistryConfig.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/config/ResourceAccessRegistryConfig.java new file mode 100644 index 000000000..6d0efe536 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/config/ResourceAccessRegistryConfig.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.socket.config; + +import kr.co.pennyway.socket.common.registry.ChatRoomAccessChecker; +import kr.co.pennyway.socket.common.registry.ResourceAccessRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class ResourceAccessRegistryConfig { + private final ChatRoomAccessChecker chatRoomChecker; + + @Bean + public ResourceAccessRegistry configureResourceAccess() { + ResourceAccessRegistry registry = new ResourceAccessRegistry(); + + registry.registerChecker("^/sub/chat\\.room\\.\\d+$", chatRoomChecker); + + return registry; + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/config/WebSocketMessageBrokerConfig.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/config/WebSocketMessageBrokerConfig.java new file mode 100644 index 000000000..4b8f743d6 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/config/WebSocketMessageBrokerConfig.java @@ -0,0 +1,90 @@ +package kr.co.pennyway.socket.config; + +import kr.co.pennyway.socket.common.interceptor.StompExceptionInterceptor; +import kr.co.pennyway.socket.common.interceptor.StompInboundInterceptor; +import kr.co.pennyway.socket.common.properties.ChatServerProperties; +import kr.co.pennyway.socket.common.properties.MessageBrokerProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.NonNull; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.simp.stomp.StompReactorNettyCodec; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.tcp.reactor.ReactorNettyTcpClient; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import reactor.netty.tcp.TcpClient; + +@Slf4j +@Configuration +@RequiredArgsConstructor +@EnableWebSocketMessageBroker +@EnableConfigurationProperties({ChatServerProperties.class, MessageBrokerProperties.class}) +public class WebSocketMessageBrokerConfig implements WebSocketMessageBrokerConfigurer { + private final ChatServerProperties chatServerProperties; + private final MessageBrokerProperties messageBrokerProperties; + + private final StompInboundInterceptor stompInboundInterceptor; + private final StompExceptionInterceptor stompExceptionInterceptor; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint(chatServerProperties.getEndpoint()) + .setAllowedOriginPatterns(chatServerProperties.getAllowedOriginPatterns().toArray(new String[0])); + + registry.setErrorHandler(stompExceptionInterceptor); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableStompBrokerRelay("/queue", "/topic", "/exchange", "/amq/queue") + .setAutoStartup(true) + .setTcpClient(createTcpClient()) + .setSystemLogin(messageBrokerProperties.getSystemId()) + .setSystemPasscode(messageBrokerProperties.getSystemPassword()) + .setClientLogin(messageBrokerProperties.getClientId()) + .setClientPasscode(messageBrokerProperties.getClientPassword()) + .setRelayHost(messageBrokerProperties.getHost()) + .setRelayPort(messageBrokerProperties.getPort()) + .setSystemHeartbeatSendInterval(messageBrokerProperties.getHeartbeatSendInterval()) + .setSystemHeartbeatReceiveInterval(messageBrokerProperties.getHeartbeatReceiveInterval()); + + config.setUserDestinationPrefix(messageBrokerProperties.getUserPrefix()); + config.setPathMatcher(new AntPathMatcher(".")); + config.setApplicationDestinationPrefixes(messageBrokerProperties.getPublishExchange()); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompInboundInterceptor); + } + + @Override + public void configureClientOutboundChannel(ChannelRegistration registration) { + registration.interceptors(new ChannelInterceptor() { + @Override + public Message preSend(@NonNull Message message, @NonNull MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + log.debug("Outbound message: {}", accessor); + return message; + } + }); + } + + private ReactorNettyTcpClient createTcpClient() { + TcpClient tcpClient = TcpClient + .create() + .host(messageBrokerProperties.getHost()) + .port(messageBrokerProperties.getPort()); + + return new ReactorNettyTcpClient<>(tcpClient, new StompReactorNettyCodec()); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/AuthController.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/AuthController.kt new file mode 100644 index 000000000..0632af427 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/AuthController.kt @@ -0,0 +1,42 @@ +package kr.co.pennyway.socket.controller; + +import kr.co.pennyway.infra.common.exception.JwtErrorCode +import kr.co.pennyway.infra.common.exception.JwtErrorException +import kr.co.pennyway.socket.common.annotation.PreAuthorize +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import kr.co.pennyway.socket.common.util.logger +import kr.co.pennyway.socket.service.AuthService +import org.springframework.messaging.handler.annotation.Header +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.stereotype.Controller +import java.security.Principal + +@Controller +class AuthController(private val authService: AuthService) { + private val log = logger() + + @MessageMapping("auth.refresh") + @PreAuthorize("#principal instanceof T(kr.co.pennyway.socket.common.security.authenticate.UserPrincipal)") + fun refreshPrincipal( + @Header("Authorization") authorization: String?, + principal: Principal, + accessor: StompHeaderAccessor + ) { + val token = authorization + ?.takeIf { it.startsWith("Bearer ") } + ?.substring(7) + ?: run { + log.warn("Authorization header is null or invalid") + throw JwtErrorException(JwtErrorCode.EMPTY_ACCESS_TOKEN) + } + + val userPrincipal = principal as? UserPrincipal + ?: run { + log.warn("Principal is not an instance of UserPrincipal") + throw IllegalArgumentException("Principal must be UserPrincipal") + } + + authService.refreshPrincipal(token, userPrincipal, accessor) + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/ChatMessageController.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/ChatMessageController.kt new file mode 100644 index 000000000..bb3919fef --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/ChatMessageController.kt @@ -0,0 +1,54 @@ +package kr.co.pennyway.socket.controller; + +import kr.co.pennyway.socket.command.SendMessageCommand +import kr.co.pennyway.socket.common.annotation.PreAuthorize +import kr.co.pennyway.socket.common.dto.ChatMessageDto +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import kr.co.pennyway.socket.service.ChatMessageSendService +import kr.co.pennyway.socket.service.LastMessageIdSaveService +import org.springframework.messaging.handler.annotation.DestinationVariable +import org.springframework.messaging.handler.annotation.Header +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.stereotype.Controller +import org.springframework.validation.annotation.Validated + +@Controller +class ChatMessageController( + private val chatMessageSendService: ChatMessageSendService, + private val lastMessageIdSaveService: LastMessageIdSaveService +) { + companion object { + private const val CHAT_MESSAGE_PATH = "chat.message.{chatRoomId}" + private const val READ_MESSAGE_PATH = "chat.message.{chatRoomId}.read.{lastReadMessageId}" + } + + @MessageMapping(CHAT_MESSAGE_PATH) + @PreAuthorize("#isAuthenticated(#principal) and @chatRoomAccessChecker.hasPermission(#chatRoomId, #principal)") + fun sendMessage( + @DestinationVariable chatRoomId: Long, + @Validated payload: ChatMessageDto.Request, + principal: UserPrincipal, + @Header("x-message-id") messageId: String? + ) { + chatMessageSendService.execute( + SendMessageCommand.createUserMessage( + chatRoomId, + payload.content(), + payload.contentType(), + principal.userId, + principal.name, + messageId?.let { mapOf("x-message-id" to it) } + ) + ) + } + + @MessageMapping(READ_MESSAGE_PATH) + @PreAuthorize("#isAuthenticated(#principal) and @chatRoomAccessChecker.hasPermission(#chatRoomId, #principal)") + fun readMessage( + @DestinationVariable("chatRoomId") @Validated chatRoomId: Long, + @DestinationVariable("lastReadMessageId") @Validated lastReadMessageId: Long, + principal: UserPrincipal + ) { + lastMessageIdSaveService.execute(principal.userId, chatRoomId, lastReadMessageId) + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/StatusController.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/StatusController.kt new file mode 100644 index 000000000..6de5e5363 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/StatusController.kt @@ -0,0 +1,18 @@ +package kr.co.pennyway.socket.controller; + +import kr.co.pennyway.socket.common.annotation.PreAuthorize +import kr.co.pennyway.socket.common.dto.StatusMessage +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import kr.co.pennyway.socket.service.StatusService +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.stereotype.Controller + +@Controller +class StatusController(private val statusService: StatusService) { + @MessageMapping("status.me") + @PreAuthorize("#isAuthenticated(#principal)") + fun updateStatus(principal: UserPrincipal, message: StatusMessage, accessor: StompHeaderAccessor) { + statusService.updateStatus(principal.userId, principal.deviceId, message, accessor); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/ChatJoinEventListener.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/ChatJoinEventListener.java new file mode 100644 index 000000000..fc35f0257 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/ChatJoinEventListener.java @@ -0,0 +1,39 @@ +package kr.co.pennyway.socket.relay; + +import kr.co.pennyway.infra.common.event.ChatRoomJoinEvent; +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties; +import kr.co.pennyway.socket.command.SendMessageCommand; +import kr.co.pennyway.socket.common.constants.SystemMessageTemplate; +import kr.co.pennyway.socket.service.ChatMessageSendService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.Exchange; +import org.springframework.amqp.rabbit.annotation.Queue; +import org.springframework.amqp.rabbit.annotation.QueueBinding; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@EnableConfigurationProperties({ChatExchangeProperties.class}) +public class ChatJoinEventListener { + private final ChatMessageSendService chatMessageSendService; + + @RabbitListener( + containerFactory = "simpleRabbitListenerContainerFactory", + bindings = @QueueBinding( + value = @Queue("${pennyway.rabbitmq.chat-join-event.queue}"), + exchange = @Exchange(value = "${pennyway.rabbitmq.chat.exchange}", type = "topic"), + key = "${pennyway.rabbitmq.chat-join-event.routing-key}" + ) + ) + public void handleJoinEvent(ChatRoomJoinEvent event) { + log.debug("handleJoinEvent: {}", event); + + chatMessageSendService.execute( + SendMessageCommand.createSystemMessage(event.chatRoomId(), SystemMessageTemplate.JOIN_MESSAGE_FORMAT.convertToMessage(event.userName())) + ); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/ChatMessageRelayEventListener.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/ChatMessageRelayEventListener.java new file mode 100644 index 000000000..45114987a --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/ChatMessageRelayEventListener.java @@ -0,0 +1,38 @@ +package kr.co.pennyway.socket.relay; + +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType; +import kr.co.pennyway.socket.common.dto.ChatMessageDto; +import kr.co.pennyway.socket.service.ChatMessageRelayService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.Exchange; +import org.springframework.amqp.rabbit.annotation.Queue; +import org.springframework.amqp.rabbit.annotation.QueueBinding; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatMessageRelayEventListener { + private final ChatMessageRelayService chatMessageRelayService; + + @RabbitListener( + containerFactory = "simpleRabbitListenerContainerFactory", + bindings = @QueueBinding( + value = @Queue("${pennyway.rabbitmq.chat.queue}"), + exchange = @Exchange(value = "${pennyway.rabbitmq.chat.exchange}", type = "topic"), + key = "${pennyway.rabbitmq.chat.routing-key}" + ), + concurrency = "1" + ) + public void handleSendEvent(ChatMessageDto.Response event) { + log.info("ChatMessageSendEventListener.handleSendEvent: {}", event); + + if (MessageCategoryType.SYSTEM.equals(event.categoryType())) { + return; + } + + chatMessageRelayService.execute(event.senderId(), event.chatRoomId(), event.content()); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/SpendingShareEventListener.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/SpendingShareEventListener.kt new file mode 100644 index 000000000..542a75a0c --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/SpendingShareEventListener.kt @@ -0,0 +1,71 @@ +package kr.co.pennyway.socket.relay; + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType +import kr.co.pennyway.domain.domains.message.type.MessageContentType +import kr.co.pennyway.infra.common.event.SpendingChatShareEvent +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties +import kr.co.pennyway.socket.command.SendMessageCommand +import kr.co.pennyway.socket.common.util.logger +import kr.co.pennyway.socket.service.ChatMessageSendService +import lombok.extern.slf4j.Slf4j +import org.springframework.amqp.rabbit.annotation.Exchange +import org.springframework.amqp.rabbit.annotation.Queue +import org.springframework.amqp.rabbit.annotation.QueueBinding +import org.springframework.amqp.rabbit.annotation.RabbitListener +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.stereotype.Component +import java.time.LocalDate + +@Slf4j +@Component +@EnableConfigurationProperties(ChatExchangeProperties::class) +class SpendingShareEventListener( + private val chatMessageSendService: ChatMessageSendService, + private val objectMapper: ObjectMapper +) { + private companion object { + private val log = logger() + } + + private data class Payload( + val date: LocalDate, + val spendingOnDates: List + ) + + @RabbitListener( + containerFactory = "simpleRabbitListenerContainerFactory", + bindings = [QueueBinding( + value = Queue("\${pennyway.rabbitmq.spending-chat-share.queue}"), + exchange = Exchange(value = "\${pennyway.rabbitmq.chat.exchange}", type = "topic"), + key = ["\${pennyway.rabbitmq.spending-chat-share.routing-key}"] + )] + ) + fun handle(event: SpendingChatShareEvent) { + log.debug("handle: {}", event) + + convertToJson(Payload(event.date(), event.spendingOnDates())) + .getOrNull() + ?.let { payload -> + chatMessageSendService.execute( + SendMessageCommand.createMessage( + event.chatRoomId(), + payload, + MessageContentType.TEXT, + MessageCategoryType.SHARE, + event.senderId(), + event.name(), + null, + mapOf("Content-Type" to "application/json") + ) + ) + } + } + + private fun convertToJson(payload: Payload): Result = + runCatching { + objectMapper.writeValueAsString(payload) + }.onFailure { + log.error("Failed to serialize spendingOnDates", it) + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/AuthService.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/AuthService.kt new file mode 100644 index 000000000..d3a3fae9a --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/AuthService.kt @@ -0,0 +1,49 @@ +package kr.co.pennyway.socket.service; + +import kr.co.pennyway.infra.common.exception.JwtErrorException +import kr.co.pennyway.socket.common.dto.ServerSideMessage +import kr.co.pennyway.socket.common.event.ReceiptEvent +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import kr.co.pennyway.socket.common.security.jwt.AccessTokenProvider +import kr.co.pennyway.socket.common.util.logger +import org.springframework.context.ApplicationEventPublisher +import org.springframework.messaging.Message +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.messaging.support.MessageBuilder +import org.springframework.stereotype.Service + +@Service +class AuthService( + private val accessTokenProvider: AccessTokenProvider, + private val eventPublisher: ApplicationEventPublisher +) { + private val log = logger() + + fun refreshPrincipal( + token: String, + principal: UserPrincipal, + accessor: StompHeaderAccessor + ) { + val message = try { + val expiresAt = accessTokenProvider.getExpiryDate(token) + principal.updateExpiresAt(expiresAt) + + createMessage("2000", "토큰 갱신 성공", accessor) + } catch (e: JwtErrorException) { + log.warn("refresh failed: {}", e.errorCode.explainError) + + createMessage(code = e.causedBy().code, message = e.errorCode.explainError, accessor = accessor) + } + + eventPublisher.publishEvent(ReceiptEvent.of(message)) + } + + private fun createMessage( + code: String, + message: String, + accessor: StompHeaderAccessor + ): Message { + val payload = ServerSideMessage.of(code, message) + return MessageBuilder.createMessage(payload, accessor.messageHeaders) + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageRelayService.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageRelayService.kt new file mode 100644 index 000000000..1ccee80bd --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageRelayService.kt @@ -0,0 +1,40 @@ +package kr.co.pennyway.socket.service; + +import kr.co.pennyway.domain.context.chat.service.ChatNotificationCoordinatorService +import kr.co.pennyway.infra.common.event.NotificationEvent +import kr.co.pennyway.socket.common.util.logger +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ChatMessageRelayService( + private val eventPublisher: ApplicationEventPublisher, + private val chatNotificationCoordinatorService: ChatNotificationCoordinatorService +) { + private val log = logger() + + /** + * 채팅방, 채팅 리스트 뷰를 보고 있지 않은 사용자들에게만 푸시 알림을 전송합니다. + * + * @param senderId Long 전송자 아이디 + * @param chatRoomId Long 채팅방 아이디 + * @param content String 채팅 내용 + * @apiNote push notification 전송 실패에 대한 재시도를 수행하고 있지 않습니다. + */ + @Transactional + fun execute(senderId: Long, chatRoomId: Long, content: String) { + chatNotificationCoordinatorService.determineRecipients(senderId, chatRoomId) + .also { log.info("채팅 메시지 알림 전송 컨텍스트: {}", it) } + .let { context -> + NotificationEvent.of( + context.senderName(), + content, + context.deviceTokens(), + context.senderImageUrl(), + mapOf("chatRoomId" to chatRoomId.toString()) + ) + } + .let { eventPublisher.publishEvent(it) } + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.kt new file mode 100644 index 000000000..28c2ba441 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.kt @@ -0,0 +1,59 @@ +package kr.co.pennyway.socket.service; + +import kr.co.pennyway.domain.context.chat.service.ChatMessageService +import kr.co.pennyway.domain.domains.message.domain.ChatMessageBuilder +import kr.co.pennyway.infra.client.broker.MessageBrokerAdapter +import kr.co.pennyway.infra.client.guid.IdGenerator +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties +import kr.co.pennyway.socket.command.SendMessageCommand +import kr.co.pennyway.socket.common.dto.ChatMessageDto +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.stereotype.Component + +@Component +@EnableConfigurationProperties(ChatExchangeProperties::class) +class ChatMessageSendService( + private val chatMessageService: ChatMessageService, + private val messageBrokerAdapter: MessageBrokerAdapter, + private val idGenerator: IdGenerator, + private val chatExchangeProperties: ChatExchangeProperties, + private val simpMessagingTemplate: SimpMessagingTemplate +) { + /** + * 채팅 메시지를 전송한다. + * + * @param command SendMessageCommand : 채팅 메시지 전송을 위한 Command + */ + fun execute(command: SendMessageCommand) { + val message = command.toChatMessage(command) + .let { chatMessageService.create(it) } + + with(chatExchangeProperties) { + messageBrokerAdapter.convertAndSend( + exchange, + "chat.room.${command.chatRoomId}", + ChatMessageDto.Response.from(message), + command.headers + ) + } + + command.senderName?.takeIf { it.isNotBlank() }?.let { senderName -> + simpMessagingTemplate.convertAndSendToUser( + senderName, + "/queue/success", + Unit, + command.messageIdHeader() + ) + } + } + + private fun SendMessageCommand.toChatMessage(command: SendMessageCommand) = ChatMessageBuilder.builder() + .chatRoomId(command.chatRoomId()) + .chatId(idGenerator.generate()) + .content(command.content()) + .contentType(command.contentType()) + .categoryType(command.categoryType()) + .sender(command.senderId()) + .build() +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/LastMessageIdSaveService.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/LastMessageIdSaveService.kt new file mode 100644 index 000000000..a2256babc --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/LastMessageIdSaveService.kt @@ -0,0 +1,11 @@ +package kr.co.pennyway.socket.service; + +import kr.co.pennyway.domain.context.chat.service.ChatMessageStatusService +import org.springframework.stereotype.Service + +@Service +class LastMessageIdSaveService(private val chatMessageStatusService: ChatMessageStatusService) { + fun execute(userId: Long, chatRoomId: Long, lastReadMessageId: Long) { + chatMessageStatusService.saveLastReadMessageId(userId, chatRoomId, lastReadMessageId) + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/StatusService.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/StatusService.kt new file mode 100644 index 000000000..11002855c --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/StatusService.kt @@ -0,0 +1,36 @@ +package kr.co.pennyway.socket.service; + +import kr.co.pennyway.domain.context.account.service.UserSessionService +import kr.co.pennyway.socket.common.dto.ServerSideMessage +import kr.co.pennyway.socket.common.dto.StatusMessage +import kr.co.pennyway.socket.common.event.ReceiptEvent +import kr.co.pennyway.socket.common.util.logger +import org.springframework.context.ApplicationEventPublisher +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.messaging.support.MessageBuilder +import org.springframework.stereotype.Service + +@Service +class StatusService( + private val userSessionService: UserSessionService, + private val publisher: ApplicationEventPublisher +) { + private val log = logger() + + fun updateStatus( + userId: Long, + deviceId: String, + message: StatusMessage, + accessor: StompHeaderAccessor + ) = when (message.isChatRoomStatus) { + true -> userSessionService.updateUserStatus(userId, deviceId, message.chatRoomId()) + false -> userSessionService.updateUserStatus(userId, deviceId, message.status()) + } + .also { session -> log.info("사용자 상태 변경: {}", session) } + .run { + ServerSideMessage.of("2000", "OK") + .let { MessageBuilder.createMessage(it, accessor.messageHeaders) } + .let { ReceiptEvent.of(it) } + .let { publisher.publishEvent(it) } + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/resources/application.yml b/pennyway-socket/src/main/resources/application.yml new file mode 100644 index 000000000..673445b56 --- /dev/null +++ b/pennyway-socket/src/main/resources/application.yml @@ -0,0 +1,63 @@ +spring: + profiles: + group: + local: common, domain-service, domain-rdb, domain-redis, infra + dev: common, domain-service, domain-rdb, domain-redis, infra + +server: + port: ${SOCKET_SERVER_PORT:8081} + +pennyway: + socket: + chat: + endpoint: ${SOCKET_CHAT_ENDPOINT:/ws} + allowed-origin-patterns: ${ALLOWED_ORIGIN_PATTERNS:*} + rabbitmq: + validate-connection: true + chat-join-event-listener: true + +message-broker: + external: + host: ${MESSAGE_BROKER_HOST:localhost} + port: ${MESSAGE_BROKER_PORT:5672} + system-id: ${MESSAGE_BROKER_SYSTEM_ID:guest} + system-password: ${MESSAGE_BROKER_SYSTEM_PASSWORD:guest} + client-id: ${MESSAGE_BROKER_CLIENT_ID:guest} + client-password: ${MESSAGE_BROKER_CLIENT_PASSWORD:guest} + user-prefix: ${MESSAGE_BROKER_USER_PREFIX:/usr} + publish-exchange: ${MESSAGE_BROKER_PUBLISH_EXCHANGE:/topic} + heartbeat-send-interval: ${MESSAGE_BROKER_HEARTBEAT_SEND_INTERVAL:20000} + heartbeat-receive-interval: ${MESSAGE_BROKER_HEARTBEAT_RECEIVE_INTERVAL:20000} + +jwt: + secret-key: + access-token: ${JWT_ACCESS_SECRET_KEY:exampleSecretKeyForPennywaySystemAccessSecretKeyTestForPadding} + +--- +spring: + config: + activate: + on-profile: local + +log: + config: + filename: socket-local + +--- +spring: + config: + activate: + on-profile: dev + +log: + config: + filename: socket-dev + maxHistory: 3 + maxFileSize: 10MB + totalSizeCap: 500MB + +--- +spring: + config: + activate: + on-profile: test \ No newline at end of file diff --git a/pennyway-socket/src/main/resources/logback-spring.xml b/pennyway-socket/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..cd61a4812 --- /dev/null +++ b/pennyway-socket/src/main/resources/logback-spring.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + + + + + + + INFO + + + ${CONSOLE_LOG_PATTERN} + + + + + + + ${FILE_LOG_PATTERN} + + + + true + + + + ${LOG_PATH}/%d{yyyy-MM-dd}/${LOG_FILE_NAME}.%i.log + + ${LOG_MAX_FILE_SIZE} + + ${LOG_MAX_HISTORY} + + ${LOG_TOTAL_SIZE_CAP} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pennyway-socket/src/test/java/kr/co/pennyway/socket/common/aop/AuthorizationTest.kt b/pennyway-socket/src/test/java/kr/co/pennyway/socket/common/aop/AuthorizationTest.kt new file mode 100644 index 000000000..90e6f5231 --- /dev/null +++ b/pennyway-socket/src/test/java/kr/co/pennyway/socket/common/aop/AuthorizationTest.kt @@ -0,0 +1,154 @@ +package kr.co.pennyway.socket.common.aop + +import kr.co.pennyway.domain.domains.user.domain.NotifySetting +import kr.co.pennyway.domain.domains.user.domain.User +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility +import kr.co.pennyway.domain.domains.user.type.Role +import kr.co.pennyway.socket.common.aop.PreAuthorizer.Companion.authenticate +import kr.co.pennyway.socket.common.aop.PreAuthorizer.Companion.authorize +import kr.co.pennyway.socket.common.aop.PreAuthorizer.Companion.permitAll +import kr.co.pennyway.socket.common.exception.PreAuthorizeErrorCode +import kr.co.pennyway.socket.common.exception.PreAuthorizeErrorException +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestComponent +import org.springframework.context.ApplicationContext +import org.springframework.test.util.ReflectionTestUtils +import java.time.LocalDateTime + +@SpringBootTest(classes = [PreAuthorizer::class, PreAuthorizer.PreAuthorizeAdvice::class, ApplicationContext::class, MockManager::class]) +class AuthorizationTest { + @Autowired + private lateinit var preAuthorizerAdvice: PreAuthorizer.PreAuthorizeAdvice + + @Autowired + private val applicationContext: ApplicationContext? = null + + @Test + fun `permitAll을 호출하면 언제나 정상적으로 진행된다`() { + // when + val result = permitAll { "some result" } + + // then + assertEquals("some result", result) + } + + @Test + fun `인증된 사용자는 정상적으로 진행된다`() { + // given + val (user, userPrincipal) = createValidFixture() + + // when + val result = authenticate(principal = userPrincipal) { + "some result" + } + + // then + assertEquals("some result", result) + } + + @Test + fun `인증되지 않은 사용자는 UNAUTHENTICATED 예외가 발생한다`() { + // given + val (user, userPrincipal) = createExpiredFixture() + + // when & then + assertThrows { + authenticate(principal = userPrincipal) {} + }.also { e -> + assertEquals(PreAuthorizeErrorCode.UNAUTHENTICATED, e.errorCode) + } + } + + @Test + fun `인가된 사용자는 정상적으로 진행된다`() { + val result = authorize(MockManager::class, MockManager::execute.name) { + "some result" + } + + // then + assertEquals("some result", result) + } + + @Test + fun `인가되지 않은 사용자는 FORBIDDEN 예외를 반환한다`() { + // when & then + assertThrows { + authorize(MockManager::class, MockManager::executeFail.name) {} + }.also { e -> + assertEquals(PreAuthorizeErrorCode.FORBIDDEN, e.errorCode) + } + } + + @Test + fun `인증되었으나, 1번 사용자가 아니라면 FORBIDDEN 예외를 반환한다`() { + // given + val (user, userPrincipal) = createValidFixture() + + // when & then + assertThrows { + authorize(userPrincipal, MockManager::class, MockManager::hasPermission.name, 2L) {} + }.also { e -> + assertEquals(PreAuthorizeErrorCode.FORBIDDEN, e.errorCode) + } + } + + private fun createValidFixture(): Pair { + val user = createUser() + val userPrincipal = UserPrincipal.of(user, LocalDateTime.now().plusMinutes(30), "deviceId", "deviceName") + + return user and userPrincipal + } + + private fun createExpiredFixture(): Pair { + val user = createUser() + val userPrincipal = UserPrincipal.of(user, LocalDateTime.now().minusHours(1), "deviceId", "deviceName") + + return user and userPrincipal + } + + private fun createUser(): User { + val user = User.builder() + .name("test") + .username("jayang") + .notifySetting(NotifySetting.of(true, true, true)) + .role(Role.USER) + .password("password") + .phone("010-1234-5678") + .profileVisibility(ProfileVisibility.PUBLIC) + .build() + + ReflectionTestUtils.setField(user, "id", 1L) + + return user + } + + data class Pair( + val first: A, + val second: B + ) + + private infix fun A.and(value: B) = Pair(this, value) +} + +@TestComponent +class MockManager { + fun execute(): Boolean { + println("인가된 사용자입니다.") + return true + } + + fun executeFail(): Boolean { + println("인가되지 않은 사용자입니다.") + return false + } + + fun hasPermission(userId: Long): Boolean { + println("userId: $userId") + return userId == 1L + } +} \ No newline at end of file diff --git a/pennyway-socket/src/test/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAopBenchmark.kt b/pennyway-socket/src/test/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAopBenchmark.kt new file mode 100644 index 000000000..8094f4e89 --- /dev/null +++ b/pennyway-socket/src/test/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAopBenchmark.kt @@ -0,0 +1,337 @@ +package kr.co.pennyway.socket.common.aop; + +import kr.co.pennyway.domain.domains.user.domain.NotifySetting +import kr.co.pennyway.domain.domains.user.domain.User +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility +import kr.co.pennyway.domain.domains.user.type.Role +import kr.co.pennyway.socket.common.annotation.PreAuthorize +import kr.co.pennyway.socket.common.aop.PreAuthorizer.Companion.authenticate +import kr.co.pennyway.socket.common.aop.PreAuthorizer.Companion.authorize +import kr.co.pennyway.socket.common.exception.PreAuthorizeErrorException +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import lombok.extern.slf4j.Slf4j +import org.junit.jupiter.api.* +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestComponent +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.data.projection.SpelAwareProxyProjectionFactory +import org.springframework.test.util.ReflectionTestUtils +import java.security.Principal +import java.time.LocalDateTime + +@Disabled +@Slf4j +@SpringBootTest( + classes = [ + PreAuthorizer::class, + PreAuthorizer.PreAuthorizeAdvice::class, + PreAuthorizeAspect::class, + AuthorizationBenchmark.BenchmarkConfig::class, + SpelAwareProxyProjectionFactory::class + ] +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AuthorizationBenchmark { + companion object { + private const val WARMUP_ITERATIONS = 1000 + private const val TEST_ITERATIONS = 10000 + + private const val WARMUP_BATCH_SIZE = 10 + private const val WARMUP_BATCH_COUNT = 10 + } + + @TestConfiguration + @EnableAspectJAutoProxy + class BenchmarkConfig { + @Bean + fun mockManager() = MockManager() + + @Bean + fun testService() = TestService() + } + + @Autowired + private lateinit var testService: TestService + + private lateinit var validPrincipal: UserPrincipal + private lateinit var expiredPrincipal: UserPrincipal + + @BeforeAll + fun setup() { + // 테스트용 Principal 생성 + val user = createUser() + validPrincipal = UserPrincipal.of(user, LocalDateTime.now().plusMinutes(30), "deviceId", "deviceName") + expiredPrincipal = UserPrincipal.of(user, LocalDateTime.now().minusHours(1), "deviceId", "deviceName") + + // 워밍업 + repeat(WARMUP_ITERATIONS) { + // 각 시나리오별 워밍업 호출 + authenticate(validPrincipal) { true } + authorize(MockManager::class, "hasPermission", 1L) { true } + authorize(MockManager::class, "hasComplexPermission", 1L, "READ", true) { true } + } + + repeat(WARMUP_ITERATIONS) { + runCatching { + testService.simpleAuthCheck(validPrincipal) + testService.simplePermissionCheck(1L) + testService.complexPermissionCheck(1L, "READ", true) + } + } + } + + @Test + fun `웜업 성능 비교`() { + // AOP 방식 웜업 성능 측정 + prepareForWarmupTest() + measureWarmup("Spring AOP 방식") { + testService.simpleAuthCheck(validPrincipal) + testService.simplePermissionCheck(1L) + testService.complexPermissionCheck(1L, "READ", true) + } + + // Reflection 방식 웜업 성능 측정 + prepareForWarmupTest() + measureWarmup("Reflection 방식") { + authenticate(validPrincipal) { true } + authorize(MockManager::class, "hasPermission", 1L) { true } + authorize(MockManager::class, "hasComplexPermission", 1L, "READ", true) { true } + } + } + + @Test + fun `벤치마크 - 단순 인증 체크`() { + measureTime("Spring AOP 단순 인증 체크") { + testService.simpleAuthCheck(validPrincipal) + } + + measureTime("단순 인증 체크") { + authenticate(validPrincipal) { true } + } + } + + @Test + fun `벤치마크 - 만료된 인증 체크`() { + measureTime("Spring AOP 만료된 인증 체크") { + assertThrows { + testService.simpleAuthCheck(expiredPrincipal) + } + } + + measureTime("만료된 인증 체크") { + assertThrows { + authenticate(expiredPrincipal) { true } + } + } + } + + @Test + fun `벤치마크 - 단순 인가 체크 (성공)`() { + measureTime("Spring AOP 단순 인가 체크 (성공)") { + testService.simplePermissionCheck(1L) + } + + measureTime("단순 인가 체크 (성공)") { + authorize(MockManager::class, "hasPermission", 1L) { true } + } + } + + @Test + fun `벤치마크 - 단순 인가 체크 (실패)`() { + measureTime("Spring AOP 단순 인가 체크 (실패)") { + assertThrows { + testService.simplePermissionCheck(2L) + } + } + + measureTime("단순 인가 체크 (실패)") { + assertThrows { + authorize(MockManager::class, "hasPermission", 2L) { true } + } + } + } + + @Test + fun `벤치마크 - 복잡한 인가 체크 (성공)`() { + measureTime("Spring AOP 복잡한 인가 체크 (성공)") { + testService.complexPermissionCheck(1L, "READ", true) + } + + measureTime("복잡한 인가 체크 (성공)") { + authorize( + MockManager::class, + "hasComplexPermission", + 1L, "READ", true + ) { true } + } + } + + @Test + fun `벤치마크 - 복잡한 인가 체크 (실패)`() { + measureTime("Spring AOP 복잡한 인가 체크 (실패)") { + assertThrows { + testService.complexPermissionCheck(2L, "WRITE", false) + } + } + + measureTime("복잡한 인가 체크 (실패)") { + assertThrows { + authorize( + MockManager::class, + "hasComplexPermission", + 2L, "WRITE", false + ) { true } + } + } + } + + @Test + fun `벤치마크 - 인증 인가 복합 체크 (성공)`() { + measureTime("Spring AOP 인증 인가 복합 체크 (성공)") { + testService.compositeCheck(validPrincipal, 1L) + } + + measureTime("인증 인가 복합 체크 (성공)") { + authorize( + validPrincipal, + MockManager::class, + "hasPermission", + 1L + ) { true } + } + } + + @Test + fun `벤치마크 - 인증 인가 복합 체크 (인증 실패)`() { + measureTime("Spring AOP 인증 인가 복합 체크 (인증 실패)") { + assertThrows { + testService.compositeCheck(expiredPrincipal, 1L) + } + } + + measureTime("인증 인가 복합 체크 (인증 실패)") { + assertThrows { + authorize( + expiredPrincipal, + MockManager::class, + "hasPermission", + 1L + ) { true } + } + } + } + + @Test + fun `벤치마크 - 인증 인가 복합 체크 (인가 실패)`() { + measureTime("Spring AOP 인증 인가 복합 체크 (인가 실패)") { + assertThrows { + testService.compositeCheck(validPrincipal, 2L) + } + } + + measureTime("인증 인가 복합 체크 (인가 실패)") { + assertThrows { + authorize( + validPrincipal, + MockManager::class, + "hasPermission", + 2L + ) { true } + } + } + } + + private fun measureTime(testName: String, block: () -> Unit) { + val times = mutableListOf() + + repeat(TEST_ITERATIONS) { + val start = System.nanoTime() + block() + val end = System.nanoTime() + times.add(end - start) + } + + val avgTime = times.average() + val p95Time = times.sorted()[((TEST_ITERATIONS * 0.95).toInt())] + + println( + """ + |테스트: $testName + |평균 실행 시간: ${avgTime / 1000} μs + |95th percentile: ${p95Time / 1000} μs + |=================================== + """.trimMargin() + ) + } + + private fun prepareForWarmupTest() { + System.gc() + Thread.sleep(1000) // GC 완료 대기 + } + + private fun measureWarmup(testName: String, block: () -> Unit) { + val batches = mutableListOf() + + repeat(WARMUP_BATCH_COUNT) { batchIndex -> + val batchTimes = mutableListOf() + + repeat(WARMUP_BATCH_SIZE) { + val start = System.nanoTime() + block() + val end = System.nanoTime() + batchTimes.add(end - start) + } + + batches.add(batchTimes.average().toLong()) + + println( + """ + |$testName - 배치 ${batchIndex + 1} + |평균 실행 시간: ${batches.last() / 1000} μs + |누적 평균 시간: ${batches.average() / 1000} μs + |================= + """.trimMargin() + ) + } + } + + private fun createUser(): User { + val user = User.builder() + .name("test") + .username("jayang") + .notifySetting(NotifySetting.of(true, true, true)) + .role(Role.USER) + .password("password") + .phone("010-1234-5678") + .profileVisibility(ProfileVisibility.PUBLIC) + .build() + + ReflectionTestUtils.setField(user, "id", 1L) + return user + } + + @TestComponent("mockManager") + class MockManager { + fun hasPermission(userId: Long): Boolean = userId == 1L + fun hasComplexPermission(userId: Long, action: String, enabled: Boolean): Boolean = + userId == 1L && action == "READ" && enabled + } + + @TestComponent + class TestService { + @PreAuthorize("#isAuthenticated(#principal)") + fun simpleAuthCheck(principal: Principal): Boolean = true + + @PreAuthorize("@mockManager.hasPermission(#userId)") + fun simplePermissionCheck(userId: Long): Boolean = true + + @PreAuthorize("@mockManager.hasComplexPermission(#userId, #action, #enabled)") + fun complexPermissionCheck(userId: Long, action: String, enabled: Boolean): Boolean = true + + @PreAuthorize("#isAuthenticated(#principal) and @mockManager.hasPermission(#userId)") + fun compositeCheck(principal: Principal, userId: Long): Boolean = true + } +} \ No newline at end of file diff --git a/pennyway-socket/src/test/resources/logback-test.xml b/pennyway-socket/src/test/resources/logback-test.xml new file mode 100644 index 000000000..198192602 --- /dev/null +++ b/pennyway-socket/src/test/resources/logback-test.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 69f415d8f..338aa6c12 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,4 +5,12 @@ include 'pennyway-batch' include 'pennyway-domain' include 'pennyway-infra' include 'pennyway-common' +include 'pennyway-socket' +include 'pennyway-socket-relay' +include 'pennyway-domain:domain-redis' +findProject(':pennyway-domain:domain-redis')?.name = 'domain-redis' +include 'pennyway-domain:domain-rdb' +findProject(':pennyway-domain:domain-rdb')?.name = 'domain-rdb' +include 'pennyway-domain:domain-service' +findProject(':pennyway-domain:domain-service')?.name = 'domain-service'