From 277a4f17120cefb875e56e11dedbe60c98cf6545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=B1=84=EB=A6=B0=20=28Bryn=29?= <67696767+cofls6581@users.noreply.github.com> Date: Tue, 4 Jul 2023 23:19:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20v0.1=20=EA=B0=9C=EB=B0=9C=20=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat/4_ci_setting (#6) * Feat/4 add project setting (#5) * feat: multimodule 구조 변경 web, api 모듈 통합해 api 모듈 하나로 구성 (api 서버) 전체 api, domain, infra로 모듈 구성 * feat: 플러그인 추가 및 수정 all open 플러그인 추가 플러그인 모듈에 맞춰 위치 조정 * feat: code owners 설정 추가 * ci default 적용 - ci default 적용 * feat: ci 스크립트 이름 변경 - ci 스크립트 이름 변경 --------- Co-authored-by: 이채린 (Bryn) <67696767+cofls6581@users.noreply.github.com> * chore: gradle.yml 제거 - 중복으로 들어간 스크립트 파일 제거 * Feat/8 kotest mockk 추가 (#9) * style: 설정 선언방식 변경 - tasks 로 묶어서 변경 - include 하나의 괄호에 설정 * feat: kotest, mockk 추가 - kotest, mockk 추가 * Feate/10 swagger setting (#11) * style: 설정 선언방식 변경 - tasks 로 묶어서 변경 - include 하나의 괄호에 설정 * feat: kotest, mockk 추가 - kotest, mockk 추가 * feat: swagger 추가 - swagger 추가 * Feat/12 querydsl (#13) * ci: ci 스크립트 파일명 수정 - ci 스크립트 파일명 수정 - build -> 명시적으로 스크립트 변경 (develop 에만 반영되어 main 맞춰놓음) * feat: querydsl 연동 - querydsl 연동 (플러그인 추가, config 파일 추가 등) * feat: querydsl 어노테이션 변경 - component-> repository --------- Co-authored-by: taesan * refactor: ktlint plugin 적용 (#15) * refactor: ktlint plugin 적용 - ktlint 적용되지 않아 plugins에 추가 함 * refactor: 기존 코드 ktlintFormat 일괄 적용 - 기존 코드 ktlintFormat 일괄 적용 * feat: cd 세팅 (#17) - 깃헙액션즈+도커 사용 * Refactor/18 change package name (#19) * refactor: 서비스명으로 패키지명 변경 - muckpot으로 변경 * refactor: cicd 스크립트 바뀐 패키지명 적용 - muckpot으로 루트 패키지명 변경 반영 * feat : BaseTimeEntity 생성 (#21) * feat: .env 설정 (#24) * feat: .env 설정 - .env 설정 후 기존 민감정보 값 매핑 - .env파일 .gitignore에 추가 - dev 프로필 cicd 스크립트에 .env 동기화 로직 추가 * feat: dev 프로필 설정 * bug: dev port 환경변수 변경 * Feat/22 create entity (#26) * feat: .env 설정 - .env 설정 후 기존 민감정보 값 매핑 - .env파일 .gitignore에 추가 - dev 프로필 cicd 스크립트에 .env 동기화 로직 추가 * fix: yml 파일 수정 - api모듈 profile 필요 없어서 제거 (active 설정만 하면 됨) - domain모듈 application.yml 필요 없어서 제거 - 로컬 h2 설정 mysql로 변경 함 * feat: noArg, 테스트 컨테이너 추가 - Entity 정의시 noArg 필요하여 플러그인 추가 - 테스트 컨테이터 구축 * feat: Entity 추가 및 기본 테스트 - Entity 추가 및 기본 테스트 * refactor: mariadb 변경 - mariadb 변경 * test: @SpringBootTest 제거 - 모듈이 변경되면서 의미없어 제거 * fix: ignore 에 mariadb volume 추가 - ignore 에 mariadb volume 추가 * refactor: 어노테이션 중복 Retention 선언 제거 - @DataJpaTest에 포함되어 제거 * docs: db 서비스 이름변경 - db 서비스 이름변경 * feat: image_url, state 필드 추가 - user.image_url 추가 - 모든 Entity 에 state 추가 - soft delete 위함 * refactor: OSIV false 설정 - 실행시 경고 떠서 false 설정 * refactor: dev db rds 변경 - dev db rds 변경 - env file에 RDS 관련 변수 추가 --------- Co-authored-by: bryn * feat: 공통 응답 포맷, ExceptionHandler 처리 (#28) - 공통 응답 포맷 추가 - ExceptionHandler 처리 * Feat/27 test fix (#29) * chore: DB 저장 테스트 가능하도록 수정 - DB 저장 테스트 가능하도록 수정 * chore: request 받도록 수정 - request 받도록 수정 * feat : 30 elasticache 연동 (#31) * feat: elasticache 연동 - elasticache 연동 - redis test code 작성 - local, dev redis 설정 분리 * refactor: 필요없는 코드 제거 * refactor: redisConfig 코드 리팩토링 * fix: 프로필 못잡는 에러 수정 * feat: logback 설정 추가 (#33) * feat: logback 설정 추가 - logback 설정 추가 - logback 테스트를 위한 로깅 추가 * chore: dev append 추가 - dev 설정에 log파일 appender 추가 * fix: 이전 버전으로 잘못올려서 최신화 - 이전 버전으로 잘못올려서 최신화 * feat: 32 백로그 파일 volume mount 설정 (#34) * fix: ec2 서버에 로그파일 생성되지 않아 옵션 추가 - 기존에 로그파일이 도커 컨테이너 내부에 생성되어 -v 옵션 추가 * chore: 개행 한칸 삭제 - 개행 한칸 삭제 * feat: 35 login 기능 구현 (#38) * feat: Security 추가 및 설정 - Security 추가 및 설정 * feat: Jwt 발급 및 검증 구현 - Jwt 발급 및 검증 구현 * fix: 서브카테고리, 이미지url nullable 변경 - 서브카테고리, 이미지url nullable 변경 * feat: 로그인 기능 구현 - 로그인 기능 구현 * feat: secret key 환경변수 로 관리 - secret key 환경변수 로 관리 * feat: 인가 처리를 위한 Security 객체 정의 - 인가 처리를 위한 Security 객체 정의 * feat: 인증 및 인가 실패시 예외 처리 - 인증 및 인가 실패시 예외 처리 - 인증되지 않은 경우에도 체크가 필요하여 EntryPoint 구현 - AccessDeniedHandler 는 인증이 안되었으면 타지않음. * feat: refresh 토큰 redis 저장 구현 - key: email, value: refresh 토큰 * feat: 공통 ControllerAdvice 추가 - 공통 ControllerAdvice 추가 * chore: 성공 message 제외 - 성공 message 제외 * chore: swagger 샘플 추가 - swagger 샘플 추가 * feat: 테스트 환경 구성 추가 - QueryDSL 테스트 위한 TestConfiguration 추가 - 통합 테스트 위해 api 모듈에서 local 수행되도록 설정 * ci: ci/cd 스크립트에 docker-compose 실행로직 추가 - ci/cd 스크립트에 docker-compose 실행로직 추가 * fix: docker-compose 에서 환경변수 제거 - docker-compose 에서 환경변수 제거 * feat: permitAll 옵션 추가 - 테스트 환경에서 인증처리 제외시킬 수 있는 옵션 - 배포 시 false 유지되어야 함 * chore: 디버깅 레벨 조정 - 최초 로그인 할 때 Exception 메시지 출력되어 info 변경 - 예외 확인은 debug 레벨로 추가 * fix: 비로그인 유저 가능 api 추가 (#39) - 회원가입, 이메일 인증, 이메일 전송 * feat: 37 회원가입 이메일 인증 구현 (#40) * feat: 이메일 인증 요청 API 초안 - google smtp 연동 - 인증 번호 난수 생성 - redis 서버에 인증 번호 저장 - JavaMailSender를 통한 이메일 전송 * feat: 발신자 이메일 변경 * feat: 잔존한 유효 인증 요청 처리 추가 - 기존 인증 코드 redis에서 삭제 후 새 인증코드 발급 * feat: MethodArgumentNotValidException 에러 핸들링 * style: 컨벤션 통일 - 패키징 구조 맞추기 - 스웨게 응답 예시 추가 - 중복된 에러 처리 하나로 통합 * feat: 이메일 인증 검증 API - 이메일 인증 검증 API 개발 - 스웨거 응답 예시 추가 - 이메일 인증 요청 API 응답값 추가 * refactor: 204 http code 바디 제거 * refactor: 204 request nullable 처리 변경 * fix: test property 에러 해결 * fix: 비교 연산 및 소나클라우드 보안 에러 수정 * style: 주석 정리 * refactor: 코드 개선 - 매직 넘버 상수 처리 - apply 적용 - it 키워드 사용 * fix: apply 함수 롤백 * refactor: apply 함수 적용 * feat: 41 먹팟 등록 api 구현 (#42) * feat: 먹팟 글 생성 api 구현 및 테스트 - 먹팟 글 생성 api 구현 및 테스트 * feat: board entity 변경 - nullable 명시 - locationType 제거 (회사에서, 식당에서) - maxApply 초기값 2 적용 - 상수 별도 파일로 분리 * chore: RegexPattern 상수로 분리 - 기존 이넘에서 상수로 분리 * feat: 로그인 정보 접근 위한 util 추가 - 로그인 정보 접근 위한 util 추가 * fix: 상수 사용되는곳 변경 - 상수 사용되는곳 변경 및 테스트 간단한 수정 * feat: 요청 null 또는 포맷이 이상한 경우 공통 처리 - 값이 없는 경우: 값이 필요합니다. - 포맷이 이상한 경우: 유효하지 않은 포맷입니다. * chore: 응답 타입 명시 및 ktlintFormat 적용 - 응답 타입 명시 및 ktlintFormat 적용 * fix: 소나큐브 실패 처리 - cause: "PASSWORD" detected here, make sure this is not a hard-coded credential. * fix: 소나큐브 실패 처리 2 - cause: "PASSWORD" detected here, make sure this is not a hard-coded credential. * fix: 스웨거 이름 변경 - 먹팟 생성 -> 먹팟 글 생성 * fix: 나이 nullable, 주소 상세 trim - 나이 nullable, 주소 상세 trim * refactor: 강결합 코드 분리 - as-is: Service 안에서 Util 클래스 사용 - 테스트 할때마다 Util 신경써야 함.. - to-be: 인증 정보를 Controller에서 전달 * refactor: 44 board refactor (#45) * refactor: board entity 변경 - meetingDate, meetingTime을 LocalDateTime 하나로 관리 - 먹팟 참가 시 maxApply 되면 상태 변경 * refactor: board entity 변경에 따른 먹팟 생성 request 수정 - meetingTime 타입 LocalTime 변경 - currentApply 1로 저장되도록 변경 * chore: ExceptionHandler 로깅 추가 * fix: 로그레벨 error로 변경 - info에서 error로 변경 * feat:43 먹팟 조회 api 구현 (#46) * feat: 무한스크롤 dto 정의 * feat: 시간 정책 util 추가 - 오늘, 내일 계산 - 경과 시간 계산 * feat: boardId 리스트로 참여자 목록 조회 쿼리 구현 - 먼저 참여한 순서대로 정렬(생성일자) * feat: 무한 스크롤 적용한 모든 먹팟글 조회 쿼리 구현 - lastId, countPerScroll 기준으로 조회 * feat: 만료시간 체크 함수 추가 * feat: 먹팟 상태 2개로 변경 및 한글이름 추가 * feat: 먹팟 조회 API 구현 및 테스트 * fix: if문 when으로 변경 - sonarcloud: Merge chained "if" statements into a single "when" statement. * fix: 소나큐브.. 중복 라인 제거 - sonarcloud: Duplicated Lines (%) on New Code * fix: 소나큐브.. 중복 라인 더 제거 ... - sonarcloud: Duplicated Lines (%) on New Code * fix: 마지막 페이지에서 lastId null 되지 않도록 변경 - 마지막 페이지에서 lastId null 되지 않도록 변경 * fix: 전체 Exception 핸들링 추가 - 전체 Exception 핸들링 추가 * feat: 유저 프로필 조회 api 구현 (#48) - 로그인 유저: 200 + 유저 정보 - 비 로그인 유저: 204 * feat: 회원가입 api 생성 (#49) * feat: 회원가입 api 생성 - 회원가입 validation 처리 - 테스트 코드 작성 - 변경된 SwaggerConstant 적용 - 변경된 예외처리 적용 * refactor: 정규식 상수 적용 * refactor: 직군 대분류 에러 처리 리팩토링 * bug: 51 cors 설정 변경 및 로그인 유저 회원가입 및 로그인 요청 조건 수정 (#52) * fix: cors 설정 변경 - allowedOrigins에 * 값을 포함하는 경우 allowCredentials가 true로 설정되어 있더라도 CORS 정책을 준수할 수 없다고 함. * fix: 와일드카드 사용하지 않도록 변경 - 와일드카드 사용하지 않도록 변경 * fix: 로그인 유저 재요청 조건 수정 - 잘못 반영되어있어 수정 * fix: 로그인 유저는 로그인, 회원가입 요청할 수 없도록 변경 - 로그인 유저는 로그인, 회원가입 요청할 수 없도록 변경 * feat: 50 먹팟 상세조회 구현 (#53) * chore: 에러 메시지 변경 * chore: Location nullable 제거 * chore: enum 한글 명 korNm으로 통일 * chore: 로그인 유저정보 없습니다 레벨 debug 변경 - 너무 자주 호출되어 info -> debug 낮춤 * refactor: 유저 프로필 조회시 SecurityContextHolder 보도록 변경 - as-is: cookie 에서 고객정보 찾아옴. - to-be: SecurityContextHolder에서 찾아옴. * fix: formatMeetingTime 함수 변경 및 시간 패턴 상수 이동 - 시간 패턴 모두 RegexPatternConstant로 이동 - pattern을 받아서 처리하는 함수로 Util 함수 통합 * fix: 참여자 목록 조회시 메인 카테고리, isWriter 추가 - 상세에서 함께 사용하기 위해 추가 함. - null 인 경우 모두 응답에 포함되지 않도록 함 * fix: meetingTime 필드 명 변경 - 상세에서 meetingTime, meetingDate 따로내려주어 구분하기 위해 변경 * fix: jobGroupMain 한글 명으로 응답 하도록 변경 * feat: 먹팟 상세조회 구현 - 상세 조회 시 조회수 증가 - 참여목록 로그인 유저 및 조직장 조건에 따른 정렬 적용 * chore: 스웨거 응답 예시 수정 * fix: nullable 제거 및 함수 명 변경 * feat : 55 redisson distributed lock (#59) * refactor: 상수 파일들 패키징 * chore: setting redisson config * feat: 분산락 커스텀 어노테이션 생성 * feat: 분산락 트랜잭션 설정 * feat: 분산락 aop 설정 * feat: 도메인 모듈 의존성 추가 * test: 분산락 기반 동시성 테스트 * test: 쓰레드풀 100 테스트 * test: 쓰레드풀 500 테스트 * fix: 소나클라우드 에러 해결 * test: 동시성 테스트 분산락 적용 - 스래드풀 개수 조정 - 분산락 어노테이션 적용 * refactor: redis 관련 설정 통합 * refactor: 에러 스택트레이스 출력하도록 변경 * refactor: 분산락 구조 리팩토링 - api 모듈:어노테이션, aop, 어노테이션으로 분산락 테스트 - infra 모듈:redis config, new transaction * refactor: 필요없는 코드 제거 * feat: 54 먹팟 수정 api 구현 (#57) * fix: Board.user nullable 제거 * feat: 먹팟 글 수정 api 구현 - 먹팟 글 수정 api 구현 * fix: 중복 예외 메시지 상수 처리 - NotBlank 기본 메시지가 달라서 ci도 실패하는듯하여, 직접 명시 함 * chore: ktlint 적용 * feat: 56 먹팟 참가 신청 api 구현 (#60) * refactor: 상수 파일들 패키징 * chore: setting redisson config * feat: 분산락 커스텀 어노테이션 생성 * feat: 분산락 트랜잭션 설정 * feat: 분산락 aop 설정 * feat: 도메인 모듈 의존성 추가 * test: 분산락 기반 동시성 테스트 * test: 쓰레드풀 100 테스트 * test: 쓰레드풀 500 테스트 * fix: 소나클라우드 에러 해결 * test: 동시성 테스트 분산락 적용 - 스래드풀 개수 조정 - 분산락 어노테이션 적용 * refactor: redis 관련 설정 통합 * refactor: 에러 스택트레이스 출력하도록 변경 * refactor: 필요없는 파일 제거 * refactor: 스레드풀 개수 조정 * feat: 먹팟 참가 신청 api -분산락 적용 -participant table insert -muckpot_user.current_apply update -참가 신청 조건 validation * test: 먹팟 참가 신청 api 테스트 코드 * refactor: 201 응답꼴 api 명세 맞춰 수정 * refactor: 필요없는 의존성 제거 * feat:58 먹팟 글 삭제 api 구현 (#61) * refactor: 모든 Entity Soft Delete 적용 - user 유니크 키 제거 - participant 복합키 제거 * feat: 먹팟 삭제 api 구현 - 먹팟 삭제시 참가목록 제거 - 자신의 글만 삭제 가능 * refactor: repository 트랜잭션 추가 - 추가 및 readOnly 옵션 명시 * fix: 함수이름 통일 * fix: JpaRepository 조회쿼리 트랜잭션 명시 제거 * feat: 먹팟 글 상태변경 API 구현 (#63) feat: 먹팟 글 상태변경 API 구현 * refactor: 65 로그인 유지하기 만료시간 지정 (#66) * refactor: 로그인 유지하기 만료시간 지정 - as-is: 만료시간 없음 - to-be: 토큰 별 1일, 30일 기한 지정 * chore: 유효성 검사 메시지 변경 * refactor: 쿠키도 만료시간 지정 * feat : 67 먹팟 참가 신청 취소 api 구현 (#68) * feat: 먹팟 참가 신청 취소 api 생성 -participant table soft delete -muckpot_user.current_apply update -validation 처리 * test: 먹팟 참가 신청 취소 api 테스트 코드 작성 * refactor: 테스트 코드 리팩토링 * refactor: 참가신청취소 함수명 변경 * feat: 64 로그아웃 api 구현 (#69) * refactor: JwtCookieUtil 변경 - 이름 수정 및 함수 변경 * refactor: 상수 파일 분리 - URL 관련 상수 따로 분리 * refactor: 인증 예외처리 클래스 이름 및 패키지 변경 - filter 하위로 변경 * refactor: value에 이상한 값이 들어가서 설정 추가 - \xac\xed\x00\x05t\x00\x03key 저장 됨 - see: https://stackoverflow.com/questions/31608394/get-set-value-from-redis-using-redistemplate * feat: 로그아웃 기능 구현 - 토큰 관련 쿠키삭제 - accessToken 블랙리스트 추가 - refreshToken 제거 * test: 로그아웃 테스트 - accessToken 블랙리스트 추가 - refreshToken 제거 * refactor: api/common 하위의 util, constant 클래스들 패키지 추가 * refactor: testFixtures 적용 - 기존에 api, domain 각각 있던 Fixture 통합 * chore: url 변경 * feat : 70 JWT 재발급 API 구현 (#72) * feat: JWT 재발급 api 구현 - access 토큰 재생성 - refresh 토큰 재생성 - refresh 토큰 만료 시간 이전 refresh 토큰 만료시간과 동일 - access 토큰 만료 시간 로그인 유지 여부에 따라 설정 * feat: JWT 재발급 필터 인증에서 제외 * refactor: 로그인 유지 여부 기능 함수로 분리 * refactor: getTokenExpirationDuration 함수 재사용 * refactor: 중복 로직 제거 * feat: redis에 refresh 토큰 존재 검증 추가 * feat: jwt 재발급시 만료된 access 토큰 검증 * feat: jwt 재발급시 검증 추가 * fix: jwt 디코딩 함수 변경 - verify -> decode * api 서브 도메인 적용 관련 cicd 수정 * refactor: 76 토큰 만료시간 및 먹팟 상세 리펙토링 (#77) * refactor: 먹팟 상세조회 개선 - 자신의 글은 조회수 증가 x - 나이조건 선택하지 않은 경우 null 응답 * refactor: jwt 만료기한 변경 - access-token 모두 1시간으로 변경 - refresh-token 각각 30, 180 변경 * test: 비로그인 유저 테스트 추가 - 비로그인 유저도 조회수가 증가한다. * refactor: 먹팟 상세 이전, 다음 글로 이동하기위한 id 응답 추가 - 이전, 다음 글로 이동하기위한 id 응답 추가 * refactor: Board 유효성 검사 추가 - 만날시간은 현재시간 이후로만 가능하도록 추가 * refactor: 로그인 유지 판별 함수 제거 - access 토큰 만료기간 통일에 따른 필요없는 코드 제거 --------- Co-authored-by: bryn * refactor:71 testcontainer total (#73) * refactor: 통합 테스트 환경 컨테이너 적용 - 테스트 컨테이너 의존성 root 프로젝트로 변경 - api 모듈 테스트 프로퍼티 변경 - api 모듈 테스트 컨테이너 설정 * refactor: ci/cd 에서 docker-compse 실행로직 제거 * test: ci 실패 테스트 * refactor: ci, cd 스크립트 중복 flow 제거 - build 에서 ktlintcheck, test 모두 수행되고 있음 * test: ci ktlint 실패 테스트 * test: 실패 테스트 복구 * chore: 클래스 이름 변경 * refactor: 테스트 컨테이너 모듈 별 분리 - testFixtures 활용 - root 프로젝트에서 테스트컨테이너 관련 의존성 제거 - 설정 시 의존성 참조를 위해 testFixturesImplementation 추가 * refactor: 모듈 별 설정 분리 * feat: 먹팟 글 상제 조회 응답 추가 (#79) - useAge 값 추가 - 로그인한 유저 나이, 비로그인시 null * refactor:80 이메일 인증 요청시 중복 이메일 검사 및 예외 메시지 변경 (#81) * refactor: 인증 메일 받기 단계에서 중복이메일 검사 추가 * refactor: 예외 메시지 피그마 기준으로 변경 * feat:74 미팅 시간이 현재시간 이전인 먹팟 상태변경 (#82) * feat: 현재시간 이전의 먹팟은 Done 처리 - 10-22시, 00시 15분마다 수행, 하루 56회 * refactor: 이전, 이후 아이디 조회시 DONE 포함 - 이전, 이후 아이디 조회시 DONE 포함 * feat: 83 CORS ORIGIN 추가 (#84) * feat: PROD CORS ORIGIN 추가 - ORIGIN 관리 env로 분리 * feat: PROD 다른 URL도 추가 * refactor:85 패스워드 유효성 검사 변경 (#86) * feat: PROD CORS ORIGIN 추가 - ORIGIN 관리 env로 분리 * feat: PROD 다른 URL도 추가 * refactor: 회원가입, 로그인시 pw 유효성 검사 변경 - 특수문자 포함가능 - 최소 영어1, 숫자1 포함되어야 함 * refactor: constant package 선언위치 수정 - 이전에 패키지 변경 후 선언문 반영이 안되어있어서, 변경 * refactor: cookie 발급시 도메인 지정 (#88) - 하위 도메인 접근이 가능하도록 변경 - 로컬호스트에서 접근 할 수 있도록 허용 * fix: 87 로컬 접근 시 쿠키 발급 로직 변경 (#89) * fix: 로컬 접근시 쿠키 발급 로직 변경 - Host 헤더로는 localhost를 구분할 수 없음. * fix: port 제거 후 startsWith 로 구분 - localhost:8080 에서도 쿠키 발급 위함 * refactor:90 상세 조회시 이전, 이후아이디 불일치 개선 (#91) * fix: 로컬 접근시 쿠키 발급 로직 변경 - Host 헤더로는 localhost를 구분할 수 없음. * fix: port 제거 후 startsWith 로 구분 - localhost:8080 에서도 쿠키 발급 위함 * fix: 상세, 이전 아이디 조회조건 변경 * fix: 상세, 이전 아이디 조회조건 쿼리 수정 --------- Co-authored-by: mountain --- .github/workflows/ci.yml | 22 +- .github/workflows/cicd.yml | 65 ++++ .gitignore | 8 +- build.gradle.kts | 12 + docker-compose.yml | 25 ++ muckpot-api/Dockerfile | 12 + muckpot-api/build.gradle.kts | 14 + .../kotlin/com/yapp/muckpot/Application.kt | 17 + .../muckpot/common/constants/JwtConstant.kt | 12 + .../common/constants/SwaggerConstant.kt | 120 +++++++ .../muckpot/common/constants/UrlConstant.kt | 9 + .../constants/ValidationMessageConstant.kt | 9 + .../common/dto/CursorPaginationRequest.kt | 16 + .../common/dto/CursorPaginationResponse.kt | 12 + .../common/redisson/DistributedLock.kt | 13 + .../common/redisson/DistributedLockAop.kt | 62 ++++ .../common/security/AuthenticationUser.kt | 25 ++ .../yapp/muckpot/common/utils/CookieUtil.kt | 53 ++++ .../muckpot/common/utils/RandomCodeUtil.kt | 21 ++ .../common/utils/ResponseEntityUtil.kt | 19 ++ .../muckpot/common/utils/ResponseWriter.kt | 33 ++ .../common/utils/SecurityContextHolderUtil.kt | 20 ++ .../com/yapp/muckpot/config/SecurityConfig.kt | 121 +++++++ .../com/yapp/muckpot/config/SwaggerConfig.kt | 36 +++ .../com/yapp/muckpot/config/TomcatConfig.kt | 15 + .../board/controller/BoardController.kt | 207 ++++++++++++ .../controller/dto/MuckpotCreateRequest.kt | 85 +++++ .../controller/dto/MuckpotCreateResponse.kt | 5 + .../controller/dto/MuckpotDetailResponse.kt | 95 ++++++ .../controller/dto/MuckpotReadResponse.kt | 56 ++++ .../controller/dto/MuckpotUpdateRequest.kt | 85 +++++ .../domains/board/service/BoardScheduler.kt | 26 ++ .../domains/board/service/BoardService.kt | 145 +++++++++ .../domains/user/controller/UserController.kt | 181 +++++++++++ .../user/controller/dto/EmailAuthResponse.kt | 5 + .../user/controller/dto/LoginRequest.kt | 25 ++ .../controller/dto/SendEmailAuthRequest.kt | 14 + .../user/controller/dto/SignUpRequest.kt | 66 ++++ .../user/controller/dto/UserResponse.kt | 14 + .../controller/dto/VerifyEmailAuthRequest.kt | 16 + .../domains/user/service/JwtService.kt | 144 +++++++++ .../domains/user/service/UserService.kt | 111 +++++++ .../exception/GlobalExceptionHandler.kt | 66 ++++ .../muckpot/exception/MuckPotException.kt | 8 + .../filter/AuthenticationFailHandler.kt | 26 ++ .../muckpot/filter/JwtAuthorizationFilter.kt | 37 +++ .../yapp/muckpot/filter/JwtLogoutHandler.kt | 19 ++ .../com/yapp/muckpot/test/TestController.kt | 69 ++++ .../com/yapp/muckpot/test/TestRequest.kt | 15 + .../com/yapp/muckpot/test/TestResponse.kt | 24 ++ .../com/yapp/muckpot/test/TestService.kt | 24 ++ .../src/main/resources/application.yml | 27 ++ .../src/main/resources/logback-dev.xml | 93 ++++++ .../src/main/resources/logback-local.xml | 19 ++ .../com/yapp/muckpot/ApplicationTest.kt | 9 + .../com/yapp/muckpot/common/TimeUtilTest.kt | 115 +++++++ .../common/redisson/ConcurrencyHelper.kt | 30 ++ .../common/redisson/DistributedLockAopTest.kt | 33 ++ .../dto/MuckpotCreateRequestTest.kt | 91 ++++++ .../dto/MuckpotDetailResponseTest.kt | 58 ++++ .../dto/MuckpotUpdateRequestTest.kt | 36 +++ .../board/service/BoardServiceMockTest.kt | 114 +++++++ .../domains/board/service/BoardServiceTest.kt | 296 ++++++++++++++++++ .../user/controller/UserControllerTest.kt | 80 +++++ .../user/controller/dto/SignUpRequestTest.kt | 100 ++++++ .../domains/user/service/UserServiceTest.kt | 64 ++++ .../com/yapp/muckpot/test/TestServiceTest.kt | 20 ++ .../src/test/resources/application.yml | 13 + muckpot-domain/build.gradle.kts | 43 +++ .../com/yapp/muckpot/common/BaseErrorCode.kt | 5 + .../com/yapp/muckpot/common/BaseTimeEntity.kt | 17 + .../com/yapp/muckpot/common/Location.kt | 25 ++ .../com/yapp/muckpot/common/ResponseDto.kt | 31 ++ .../com/yapp/muckpot/common/TimeUtil.kt | 53 ++++ .../muckpot/common/constants/BoardConstant.kt | 12 + .../common/constants/RegexPatternConst.kt | 12 + .../muckpot/common/constants/TimeConstant.kt | 14 + .../com/yapp/muckpot/common/enums/Gender.kt | 5 + .../com/yapp/muckpot/common/enums/State.kt | 5 + .../yapp/muckpot/common/enums/StatusCode.kt | 16 + .../com/yapp/muckpot/common/enums/YesNo.kt | 5 + .../muckpot/common/extension/LocalDate.kt | 11 + .../com/yapp/muckpot/config/QuerydslConfig.kt | 16 + .../board/dto/ParticipantReadResponse.kt | 34 ++ .../muckpot/domains/board/entity/Board.kt | 139 ++++++++ .../domains/board/entity/Participant.kt | 43 +++ .../domains/board/exception/BoardErrorCode.kt | 18 ++ .../board/exception/ParticipantErrorCode.kt | 18 ++ .../repository/BoardQuerydslRepository.kt | 56 ++++ .../board/repository/BoardRepository.kt | 9 + .../ParticipantQuerydslRepository.kt | 29 ++ .../board/repository/ParticipantRepository.kt | 20 ++ .../muckpot/domains/test/entity/TestEntity.kt | 23 ++ .../test/repository/TestQuerydslRepository.kt | 20 ++ .../domains/test/repository/TestRepository.kt | 8 + .../domains/user/entity/MuckPotUser.kt | 80 +++++ .../domains/user/enums/JobGroupMain.kt | 33 ++ .../domains/user/enums/LocationType.kt | 5 + .../domains/user/enums/MuckPotStatus.kt | 7 + .../domains/user/exception/UserErrorCode.kt | 24 ++ .../user/repository/MuckPotUserRepository.kt | 8 + .../src/main/resources/application-domain.yml | 37 +++ .../com/yapp/muckpot/DomainTestApplication.kt | 6 + .../yapp/muckpot/config/CustomDataJpaTest.kt | 11 + .../com/yapp/muckpot/config/TestConfig.kt | 18 ++ .../muckpot/domains/board/entity/BoardTest.kt | 100 ++++++ .../repository/BoardQuerydslRepositoryTest.kt | 97 ++++++ .../ParticipantQuerydslRepositoryTest.kt | 73 +++++ .../repository/ParticipantRepositoryTest.kt | 63 ++++ .../domains/user/entity/MuckPotUserTest.kt | 37 +++ .../repository/MuckPotUserRepositoryTest.kt | 28 ++ .../src/testFixtures/kotlin/Fixture.kt | 85 +++++ .../kotlin/config/DomainContainerManager.kt | 31 ++ .../kotlin/config/DomainProperties.kt | 12 + muckpot-infra/build.gradle.kts | 21 ++ .../com/yapp/muckpot/email/EmailConfig.kt | 41 +++ .../com/yapp/muckpot/email/EmailService.kt | 33 ++ .../com/yapp/muckpot/email/EmailTemplates.kt | 31 ++ .../com/yapp/muckpot/redis/RedisConfig.kt | 43 +++ .../com/yapp/muckpot/redis/RedisService.kt | 31 ++ .../redis/RedissonCallNewTransaction.kt | 20 ++ .../src/main/resources/application-infra.yml | 26 ++ .../com/yapp/muckpot/InfraTestApplication.kt | 6 + .../yapp/muckpot/redis/RedisServiceTest.kt | 16 + .../kotlin/config/InfraContainerManager.kt | 29 ++ .../kotlin/config/InfraProperties.kt | 10 + settings.gradle.kts | 11 +- web1-api/build.gradle.kts | 4 - .../kotlin/com/yapp/web1be/Application.kt | 11 - .../com/yapp/web1be/test/TestController.kt | 14 - .../com/yapp/web1be/test/TestService.kt | 12 - .../src/main/resources/application-local.yml | 0 web1-api/src/main/resources/application.yml | 3 - .../com/yapp/web1be/ApplicationTests.kt | 12 - web1-domain/build.gradle.kts | 15 - .../kotlin/com/yapp/web1be/test/TestEntity.kt | 11 - .../src/main/resources/application-local.yml | 9 - .../src/main/resources/application.yml | 3 - .../com/yapp/web1be/ApplicationTests.kt | 12 - web1-infra/build.gradle.kts | 8 - .../src/main/resources/application-local.yml | 0 web1-infra/src/main/resources/application.yml | 3 - .../com/yapp/web1be/ApplicationTests.kt | 12 - 143 files changed, 5278 insertions(+), 148 deletions(-) create mode 100644 .github/workflows/cicd.yml create mode 100644 docker-compose.yml create mode 100644 muckpot-api/Dockerfile create mode 100644 muckpot-api/build.gradle.kts create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/Application.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/JwtConstant.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/SwaggerConstant.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/UrlConstant.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/ValidationMessageConstant.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/dto/CursorPaginationRequest.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/dto/CursorPaginationResponse.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/redisson/DistributedLock.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/redisson/DistributedLockAop.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/security/AuthenticationUser.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/CookieUtil.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/RandomCodeUtil.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/ResponseEntityUtil.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/ResponseWriter.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/SecurityContextHolderUtil.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/config/SecurityConfig.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/config/SwaggerConfig.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/config/TomcatConfig.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/BoardController.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotCreateRequest.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotCreateResponse.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotDetailResponse.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotReadResponse.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotUpdateRequest.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/service/BoardScheduler.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/service/BoardService.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/UserController.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/EmailAuthResponse.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/LoginRequest.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/SendEmailAuthRequest.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/SignUpRequest.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/UserResponse.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/VerifyEmailAuthRequest.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/service/JwtService.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/service/UserService.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/exception/GlobalExceptionHandler.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/exception/MuckPotException.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/filter/AuthenticationFailHandler.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/filter/JwtAuthorizationFilter.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/filter/JwtLogoutHandler.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestController.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestRequest.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestResponse.kt create mode 100644 muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestService.kt create mode 100644 muckpot-api/src/main/resources/application.yml create mode 100644 muckpot-api/src/main/resources/logback-dev.xml create mode 100644 muckpot-api/src/main/resources/logback-local.xml create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/ApplicationTest.kt create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/common/TimeUtilTest.kt create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/common/redisson/ConcurrencyHelper.kt create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/common/redisson/DistributedLockAopTest.kt create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotCreateRequestTest.kt create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotDetailResponseTest.kt create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotUpdateRequestTest.kt create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/service/BoardServiceMockTest.kt create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/service/BoardServiceTest.kt create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/user/controller/UserControllerTest.kt create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/user/controller/dto/SignUpRequestTest.kt create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/user/service/UserServiceTest.kt create mode 100644 muckpot-api/src/test/kotlin/com/yapp/muckpot/test/TestServiceTest.kt create mode 100644 muckpot-api/src/test/resources/application.yml create mode 100644 muckpot-domain/build.gradle.kts create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/BaseErrorCode.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/BaseTimeEntity.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/Location.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/ResponseDto.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/TimeUtil.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/constants/BoardConstant.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/constants/RegexPatternConst.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/constants/TimeConstant.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/Gender.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/State.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/StatusCode.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/YesNo.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/extension/LocalDate.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/config/QuerydslConfig.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/dto/ParticipantReadResponse.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/entity/Board.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/entity/Participant.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/exception/BoardErrorCode.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/exception/ParticipantErrorCode.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/BoardQuerydslRepository.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/BoardRepository.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantQuerydslRepository.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantRepository.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/test/entity/TestEntity.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/test/repository/TestQuerydslRepository.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/test/repository/TestRepository.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/entity/MuckPotUser.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/enums/JobGroupMain.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/enums/LocationType.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/enums/MuckPotStatus.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/exception/UserErrorCode.kt create mode 100644 muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/repository/MuckPotUserRepository.kt create mode 100644 muckpot-domain/src/main/resources/application-domain.yml create mode 100644 muckpot-domain/src/test/kotlin/com/yapp/muckpot/DomainTestApplication.kt create mode 100644 muckpot-domain/src/test/kotlin/com/yapp/muckpot/config/CustomDataJpaTest.kt create mode 100644 muckpot-domain/src/test/kotlin/com/yapp/muckpot/config/TestConfig.kt create mode 100644 muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/entity/BoardTest.kt create mode 100644 muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/repository/BoardQuerydslRepositoryTest.kt create mode 100644 muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantQuerydslRepositoryTest.kt create mode 100644 muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantRepositoryTest.kt create mode 100644 muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/user/entity/MuckPotUserTest.kt create mode 100644 muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/user/repository/MuckPotUserRepositoryTest.kt create mode 100644 muckpot-domain/src/testFixtures/kotlin/Fixture.kt create mode 100644 muckpot-domain/src/testFixtures/kotlin/config/DomainContainerManager.kt create mode 100644 muckpot-domain/src/testFixtures/kotlin/config/DomainProperties.kt create mode 100644 muckpot-infra/build.gradle.kts create mode 100644 muckpot-infra/src/main/kotlin/com/yapp/muckpot/email/EmailConfig.kt create mode 100644 muckpot-infra/src/main/kotlin/com/yapp/muckpot/email/EmailService.kt create mode 100644 muckpot-infra/src/main/kotlin/com/yapp/muckpot/email/EmailTemplates.kt create mode 100644 muckpot-infra/src/main/kotlin/com/yapp/muckpot/redis/RedisConfig.kt create mode 100644 muckpot-infra/src/main/kotlin/com/yapp/muckpot/redis/RedisService.kt create mode 100644 muckpot-infra/src/main/kotlin/com/yapp/muckpot/redis/RedissonCallNewTransaction.kt create mode 100644 muckpot-infra/src/main/resources/application-infra.yml create mode 100644 muckpot-infra/src/test/kotlin/com/yapp/muckpot/InfraTestApplication.kt create mode 100644 muckpot-infra/src/test/kotlin/com/yapp/muckpot/redis/RedisServiceTest.kt create mode 100644 muckpot-infra/src/testFixtures/kotlin/config/InfraContainerManager.kt create mode 100644 muckpot-infra/src/testFixtures/kotlin/config/InfraProperties.kt delete mode 100644 web1-api/build.gradle.kts delete mode 100644 web1-api/src/main/kotlin/com/yapp/web1be/Application.kt delete mode 100644 web1-api/src/main/kotlin/com/yapp/web1be/test/TestController.kt delete mode 100644 web1-api/src/main/kotlin/com/yapp/web1be/test/TestService.kt delete mode 100644 web1-api/src/main/resources/application-local.yml delete mode 100644 web1-api/src/main/resources/application.yml delete mode 100644 web1-api/src/test/kotlin/com/yapp/web1be/ApplicationTests.kt delete mode 100644 web1-domain/build.gradle.kts delete mode 100644 web1-domain/src/main/kotlin/com/yapp/web1be/test/TestEntity.kt delete mode 100644 web1-domain/src/main/resources/application-local.yml delete mode 100644 web1-domain/src/main/resources/application.yml delete mode 100644 web1-domain/src/test/kotlin/com/yapp/web1be/ApplicationTests.kt delete mode 100644 web1-infra/build.gradle.kts delete mode 100644 web1-infra/src/main/resources/application-local.yml delete mode 100644 web1-infra/src/main/resources/application.yml delete mode 100644 web1-infra/src/test/kotlin/com/yapp/web1be/ApplicationTests.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 928dc4df..6c9163b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,19 +10,15 @@ permissions: jobs: build: runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v3 - - name: Set up JDK 11 - uses: actions/setup-java@v3 - with: - java-version: '11' - distribution: 'temurin' + - uses: actions/checkout@v3 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' - - name: Gradle Clean & Build - run: ./gradlew clean build + - name: Gradle Clean & Build + run: ./gradlew clean build - - name: Check ktlint format - run: ./gradlew ktlintCheck - - - name: Test with Gradle - run: ./gradlew test \ No newline at end of file diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 00000000..35253ace --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,65 @@ +name: muckpot dev api CI/CD script + +on: + push: + branches: [ "develop" ] + +env: + ACTIVE_PROFILE: "dev" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check Out The Repository + uses: actions/checkout@v3 + + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + - name: Gradle Clean & Build + run: ./gradlew clean build + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v3 + with: + context: ./muckpot-api + push: true + args: --build-arg PROFILE=$ACTIVE_PROFILE + tags: muckpot/muckpot-api:${{ github.run_number }} + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: EC2 Docker Run + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_KEY }} + port: ${{ env.DEV_PORT }} + script: | + cd /var/www/html/api + sudo touch .env + echo "${{ secrets.ENV_VARS }}" | sudo tee .env > /dev/null + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/muckpot-api:${{ github.run_number }} + docker stop $(docker ps -a -q) + docker run -v ${HOME}/logs:/logs --restart=unless-stopped -d -p ${{ secrets.DEV_PORT }}:${{ secrets.DEV_PORT }} --env-file .env ${{ secrets.DOCKERHUB_USERNAME }}/muckpot-api:${{ github.run_number }} + docker rm $(docker ps -a -q --filter "status=exited") + docker image prune -a -f diff --git a/.gitignore b/.gitignore index e853a2d2..0b134298 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,9 @@ out/ ### VS Code ### .vscode/ -### dcoker mysql ### -.mysqldata/ \ No newline at end of file +### dcoker mariadb ### +.mariadata/ + +### .env ### +.env +.env.* \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index ae2aec62..3fbff370 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +val kotestVersion = "5.5.4" + plugins { id("org.springframework.boot") version "2.7.11" apply false id("io.spring.dependency-management") version "1.0.15.RELEASE" apply false @@ -8,6 +10,8 @@ plugins { kotlin("plugin.jpa") version "1.6.21" apply false id("org.jlleitschuh.gradle.ktlint") version "11.3.2" + + kotlin("kapt") version "1.6.21" apply false } java.sourceCompatibility = JavaVersion.VERSION_11 @@ -37,12 +41,20 @@ subprojects { apply(plugin = "org.jetbrains.kotlin.plugin.spring") apply(plugin = "org.springframework.boot") apply(plugin = "io.spring.dependency-management") + apply(plugin = "org.jlleitschuh.gradle.ktlint") dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") + testImplementation("io.kotest:kotest-framework-datatest:$kotestVersion") + testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.2") + testImplementation("io.mockk:mockk:1.12.4") + testImplementation("com.ninja-squad:springmockk:3.1.2") } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..85a29c56 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: "3.7" + +services: + redis: + container_name: web1Redis + image: "redis:alpine" + ports: + - 6379:6379 + environment: + - TZ=Asia/Seoul + + mariadb: + image: mariadb:10 + container_name: web1Maria + ports: + - 3306:3306 + environment: + - MYSQL_DATABASE=muckpot_local + - MYSQL_ROOT_PASSWORD=admin + - TZ=Asia/Seoul + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + volumes: + - ./mariadata:/var/lib/maria diff --git a/muckpot-api/Dockerfile b/muckpot-api/Dockerfile new file mode 100644 index 00000000..7727861d --- /dev/null +++ b/muckpot-api/Dockerfile @@ -0,0 +1,12 @@ +FROM openjdk:11 + +EXPOSE 8080 + +ARG JAR_FILE=build/libs/*.jar + +COPY ${JAR_FILE} app.jar + +ARG PROFILE=dev +ENV PROFILE=${PROFILE} + +ENTRYPOINT ["java","-Dspring.profiles.active=${PROFILE}","-jar","/app.jar"] \ No newline at end of file diff --git a/muckpot-api/build.gradle.kts b/muckpot-api/build.gradle.kts new file mode 100644 index 00000000..a32dee89 --- /dev/null +++ b/muckpot-api/build.gradle.kts @@ -0,0 +1,14 @@ +dependencies { + implementation(project(":muckpot-domain")) + implementation(project(":muckpot-infra")) + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("io.springfox:springfox-boot-starter:3.0.0") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("com.auth0:java-jwt:4.2.1") + implementation("org.redisson:redisson:3.20.0") + + testImplementation(testFixtures(project(":muckpot-domain"))) + testImplementation(testFixtures(project(":muckpot-infra"))) +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/Application.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/Application.kt new file mode 100644 index 00000000..122976d3 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/Application.kt @@ -0,0 +1,17 @@ +package com.yapp.muckpot + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.data.jpa.repository.config.EnableJpaAuditing +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.annotation.EnableScheduling + +@SpringBootApplication +@EnableJpaAuditing +@EnableAsync +@EnableScheduling +class Application + +fun main(args: Array) { + runApplication(*args) +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/JwtConstant.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/JwtConstant.kt new file mode 100644 index 00000000..416fc333 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/JwtConstant.kt @@ -0,0 +1,12 @@ +package com.yapp.muckpot.common.constants + +const val ACCESS_TOKEN_KEY = "accessToken" +const val REFRESH_TOKEN_KEY = "refreshToken" +const val JWT_LOGOUT_VALUE = "logout" + +const val USER_CLAIM = "user" +const val USER_EMAIL_CLAIM = "email" + +const val ACCESS_TOKEN_SECONDS = 3600L +const val REFRESH_TOKEN_BASIC_SECONDS = 3600 * 24 * 30L +const val REFRESH_TOKEN_KEEP_SECONDS = 3600 * 24 * 180L diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/SwaggerConstant.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/SwaggerConstant.kt new file mode 100644 index 00000000..201112ae --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/SwaggerConstant.kt @@ -0,0 +1,120 @@ +package com.yapp.muckpot.common.constants + +// User +const val TEST_SAMPLE = """ +{ + "id": 1, + "name": "test", + "currentTime": "20230515" +} +""" + +const val LOGIN_RESPONSE = """ +{ + "status": 200, + "result": { + "userId": 1, + "nickName": "nickName" + } +} +""" + +const val EMAIL_AUTH_REQ_RESPONSE = """ +{ + "status": 201, + "result": { + "verificationCode": "123456" + } +} +""" + +const val NO_BODY_RESPONSE = """ +{ + "status": 204 +} +""" + +const val SIGN_UP_RESPONSE = """ +{ + "status": 201, + "result": { + "userId": 1, + "nickName": "nickName" + } +} +""" + +// Board +const val MUCKPOT_SAVE_RESPONSE = """ +{ + "status": 201, + "result": { + "boardId": 18 + } +} +""" + +const val MUCKPOT_FIND_ALL = """ +{ + "status": 200, + "result": { + "list": [ + { + "boardId": 75, + "title": "같이 밥묵으실분", + "status": "모집마감", + "todayOrTomorrow": "오늘", + "elapsedTime": "0분 전", + "meetingTime": "04월 01일 (토) 오후 01:00", + "meetingPlace": "서울 성북구 안암동5가 104-30 캐치카페 안암", + "maxApply": 5, + "currentApply": 1, + "participants": [ + { + "userId": 48, + "nickName": "nickname2" + } + ] + }, + "lastId": 74 + } +} +""" + +const val MUCKPOT_FIND_BY_ID = """ +{ + "status": 200, + "result": { + "boardId": 114, + "title": "같이 밥묵으실분", + "content": "내용 입니다.", + "chatLink": "https://open.kakao.com/o/gSIkvvHc", + "status": "모집중", + "meetingDate": "07월 21일 (금)", + "meetingTime": "오후 01:00", + "createDate": "2023년 06월 11일", + "maxApply": 5, + "currentApply": 1, + "minAge": 20, + "maxAge": 100, + "locationName": "서울 성북구 안암동5가 104-30 캐치카페 안암", + "x": 127.02970799701643, + "y": 37.58392327180857, + "locationDetail": "6층", + "views": 1, + "participants": [ + { + "userId": 128, + "nickName": "nickname2", + "jobGroupMain": "개발", + "writer": true + } + ] + } +} +""" +const val MUCKPOT_JOIN_RESPONSE = """ +{ + "status": 201 +} +""" diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/UrlConstant.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/UrlConstant.kt new file mode 100644 index 00000000..bdd57a25 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/UrlConstant.kt @@ -0,0 +1,9 @@ +package com.yapp.muckpot.common.constants + +const val LOGIN_URL = "/api/v1/users/login" +const val SIGN_UP_URL = "/api/v1/users" +const val EMAIL_REQUEST_URL = "/api/v1/emails/request" +const val EMAIL_VERIFY_URL = "/api/v1/emails/verify" +const val USER_PROFILE_URL = "/api/v1/users/profile" +const val LOGOUT_URL = "/api/v1/users/logout" +const val REISSUE_JWT_URL = "/api/v1/users/refresh" diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/ValidationMessageConstant.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/ValidationMessageConstant.kt new file mode 100644 index 00000000..d3c750f9 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/constants/ValidationMessageConstant.kt @@ -0,0 +1,9 @@ +package com.yapp.muckpot.common.constants + +const val MAX_APPLY_MIN_INVALID = "참여 인원은 {value}명 이상 가능합니다." +const val TITLE_MAX_INVALID = "제목은 {max}(자)를 넘을 수 없습니다." +const val CONTENT_MAX_INVALID = "내용은 {max}(자)를 넘을 수 없습니다." +const val LINK_MAX_INVALID = "링크는 {max}(자)를 넘을 수 없습니다." +const val NOT_BLANK_COMMON = "공백일 수 없습니다." + +const val PASSWORD_PATTERN_INVALID = "영문과 숫자를 포함하여 8~20자로 입력해 주세요." diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/dto/CursorPaginationRequest.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/dto/CursorPaginationRequest.kt new file mode 100644 index 00000000..e534864e --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/dto/CursorPaginationRequest.kt @@ -0,0 +1,16 @@ +package com.yapp.muckpot.common.dto + +import io.swagger.annotations.ApiModel +import io.swagger.annotations.ApiParam + +@ApiModel(value = "커서기반 페이지요청(무한 스크롤)") +data class CursorPaginationRequest( + @field:ApiParam(value = "마지막 ID, 기본 null", required = false) + var lastId: Long? = null, + @field:ApiParam(value = "스크롤 당 데이터 크기, 기본 $COUNT_PER_SCROLL_DEFAULT", required = false) + var countPerScroll: Long = COUNT_PER_SCROLL_DEFAULT +) { + companion object { + private const val COUNT_PER_SCROLL_DEFAULT = 10L + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/dto/CursorPaginationResponse.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/dto/CursorPaginationResponse.kt new file mode 100644 index 00000000..328d7469 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/dto/CursorPaginationResponse.kt @@ -0,0 +1,12 @@ +package com.yapp.muckpot.common.dto + +import io.swagger.annotations.ApiModel +import io.swagger.annotations.ApiModelProperty + +@ApiModel(value = "커서기반 페이지요청 결과(무한 스크롤)") +data class CursorPaginationResponse( + @field:ApiModelProperty(notes = "데이터 리스트") + val list: List, + @field:ApiModelProperty(example = "11", notes = "마지막 id") + val lastId: Long? = null +) diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/redisson/DistributedLock.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/redisson/DistributedLock.kt new file mode 100644 index 00000000..ab7cb0ff --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/redisson/DistributedLock.kt @@ -0,0 +1,13 @@ +package com.yapp.muckpot.common.redisson + +import java.util.concurrent.TimeUnit + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class DistributedLock( + val identifier: String, + val lockName: String, + val waitTime: Long = 30L, + val leaseTime: Long = 10L, + val timeUnit: TimeUnit = TimeUnit.SECONDS +) diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/redisson/DistributedLockAop.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/redisson/DistributedLockAop.kt new file mode 100644 index 00000000..cd3e1ca6 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/redisson/DistributedLockAop.kt @@ -0,0 +1,62 @@ +package com.yapp.muckpot.common.redisson + +import com.yapp.muckpot.redis.RedissonCallNewTransaction +import mu.KLogging +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.RedissonClient +import org.springframework.stereotype.Component +import org.springframework.transaction.TransactionTimedOutException + +@Aspect +@Component +class DistributedLockAop( + private val redissonClient: RedissonClient, + private val redissonCallNewTransaction: RedissonCallNewTransaction +) { + + private val log = KLogging().logger + + @Around("@annotation(com.yapp.muckpot.common.redisson.DistributedLock)") + fun lock(joinPoint: ProceedingJoinPoint): Any? { + val signature = joinPoint.signature as MethodSignature + val method = signature.method + val distributedLock = method.getAnnotation(DistributedLock::class.java) + val baseKey: String = distributedLock.lockName + val dynamicKey = getDynamicKeyFromMethodArg(signature.parameterNames, joinPoint.args, distributedLock.identifier) + val rLock = redissonClient.getLock("$baseKey:$dynamicKey") + + try { + val isPossible = rLock.tryLock(distributedLock.waitTime, distributedLock.leaseTime, distributedLock.timeUnit) + if (!isPossible) { + throw Exception("래디슨 락 획득에 실패했습니다.") + } + + return redissonCallNewTransaction.proceed(joinPoint) + } catch (e: TransactionTimedOutException) { + throw e + } finally { + try { + rLock.unlock() + } catch (e: IllegalMonitorStateException) { + // 이미 unlock된 상태에서 다시 unlock 시도시 발생, 에러로그로 남김 + log.error(e) { "" } + } + } + } + + fun getDynamicKeyFromMethodArg( + methodParameterNames: Array, + args: Array, + paramName: String + ): String { + for (i in methodParameterNames.indices) { + if ((methodParameterNames[i] == paramName)) { + return args[i].toString() + } + } + throw Exception("잘못된 래디슨 키값입니다.") + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/security/AuthenticationUser.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/security/AuthenticationUser.kt new file mode 100644 index 00000000..6712c0b5 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/security/AuthenticationUser.kt @@ -0,0 +1,25 @@ +package com.yapp.muckpot.common.security + +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.GrantedAuthority +import java.time.LocalDateTime + +data class AuthenticationUser( + val userId: Long, + val credential: Any, + val createdAt: LocalDateTime = LocalDateTime.now(), + val authorities: List +) : AbstractAuthenticationToken(authorities) { + constructor(userId: Long, credential: Any, isAuthentication: Boolean, authorities: List) : + this(userId = userId, credential = credential, authorities = authorities) { + isAuthenticated = isAuthentication + } + + override fun getPrincipal(): Any { + return userId + } + + override fun getCredentials(): Any { + return credential + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/CookieUtil.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/CookieUtil.kt new file mode 100644 index 00000000..17eec2d2 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/CookieUtil.kt @@ -0,0 +1,53 @@ +package com.yapp.muckpot.common.utils + +import com.yapp.muckpot.domains.user.exception.UserErrorCode +import com.yapp.muckpot.exception.MuckPotException +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import org.springframework.web.util.WebUtils +import javax.servlet.http.Cookie +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +/** + * Cookie 접근 위한 공통 클래스 + */ +object CookieUtil { + private const val COOKIE_DOMAIN = ".mukpat.com" + + private val currentRequest: HttpServletRequest + get() = (RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes).request + private val currentResponse: HttpServletResponse + get() = (RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes).response + ?: throw IllegalStateException("HttpServletResponse 가 존재하지 않습니다.") + + /** + * 쿠키에서 토큰값 추출 + * + * @param tokenName 쿠키에 저장된 토큰이름 + * @exception MuckPotException 토큰 정보를 찾을 수 없는 경우 + */ + fun getToken(tokenName: String): String { + val cookie = WebUtils.getCookie(currentRequest, tokenName) + return cookie?.value ?: throw MuckPotException(UserErrorCode.NOT_FOUND_TOKEN) + } + + /** + * 현재 응답에 HttpOnly=true 쿠키 추가. + */ + fun addHttpOnlyCookie(name: String, value: String, expiredSeconds: Int) { + val cookie = Cookie(name, value).apply { + path = "/" + isHttpOnly = true + maxAge = expiredSeconds + domain = COOKIE_DOMAIN + } + // TODO 서버가 분리되면 해당 로직은 제거, (로컬에서 접근을 위한 설정) + currentRequest.getHeader("Origin")?.let { originName -> + if (originName.startsWith("http://localhost:")) { + cookie.domain = "localhost" + } + } + currentResponse.addCookie(cookie) + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/RandomCodeUtil.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/RandomCodeUtil.kt new file mode 100644 index 00000000..3c83823e --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/RandomCodeUtil.kt @@ -0,0 +1,21 @@ +package com.yapp.muckpot.common.utils + +import java.security.SecureRandom + +object RandomCodeUtil { + + fun generateRandomCode(): String { + val random = SecureRandom() + val codeLength = 6 + val digits = "0123456789" + val sb = StringBuilder(codeLength) + + for (i in 0 until codeLength) { + val randomIndex = random.nextInt(digits.length) + val randomDigit = digits[randomIndex] + sb.append(randomDigit) + } + + return sb.toString() + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/ResponseEntityUtil.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/ResponseEntityUtil.kt new file mode 100644 index 00000000..ec1c1b26 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/ResponseEntityUtil.kt @@ -0,0 +1,19 @@ +package com.yapp.muckpot.common.utils + +import com.yapp.muckpot.common.ResponseDto +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity + +object ResponseEntityUtil { + fun ok(body: Any?): ResponseEntity { + return ResponseEntity.ok(ResponseDto.success(body)) + } + + fun created(body: Any?): ResponseEntity { + return ResponseEntity.status(HttpStatus.CREATED).body(ResponseDto.created(body)) + } + + fun noContent(): ResponseEntity { + return ResponseEntity.status(HttpStatus.NO_CONTENT).body(ResponseDto.noContent()) + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/ResponseWriter.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/ResponseWriter.kt new file mode 100644 index 00000000..1990d051 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/ResponseWriter.kt @@ -0,0 +1,33 @@ +package com.yapp.muckpot.common.utils + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.ObjectMapper +import com.yapp.muckpot.common.ResponseDto +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import javax.servlet.http.HttpServletResponse + +/** + * filter 예외 발생 시 응답에 직접 데이터를 넣어주기 위한 클래스 + */ +object ResponseWriter { + private val objectMapper = ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL) + + /** + * Response에 직접 예외 응답 작성 + */ + fun writeResponse(response: HttpServletResponse, httpStatus: HttpStatus, message: String) { + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.status = httpStatus.value() + response.outputStream.use { os -> + objectMapper.writeValue( + os, + ResponseDto( + status = httpStatus.value(), + message + ) + ) + os.flush() + } + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/SecurityContextHolderUtil.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/SecurityContextHolderUtil.kt new file mode 100644 index 00000000..be289ee7 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/common/utils/SecurityContextHolderUtil.kt @@ -0,0 +1,20 @@ +package com.yapp.muckpot.common.utils + +import com.yapp.muckpot.domains.user.controller.dto.UserResponse +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder + +object SecurityContextHolderUtil { + /** + * SecurityContextHolder 에서 credential 을 가져온다. + * + * credential 정보 + * @see com.yapp.muckpot.common.security.AuthenticationUser + */ + fun getCredentialOrNull(): UserResponse? { + if (SecurityContextHolder.getContext().authentication is AnonymousAuthenticationToken) { + return null + } + return SecurityContextHolder.getContext().authentication.credentials as UserResponse + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/config/SecurityConfig.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/config/SecurityConfig.kt new file mode 100644 index 00000000..8675cc1f --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/config/SecurityConfig.kt @@ -0,0 +1,121 @@ +package com.yapp.muckpot.config + +import com.yapp.muckpot.common.constants.ACCESS_TOKEN_KEY +import com.yapp.muckpot.common.constants.EMAIL_REQUEST_URL +import com.yapp.muckpot.common.constants.EMAIL_VERIFY_URL +import com.yapp.muckpot.common.constants.LOGIN_URL +import com.yapp.muckpot.common.constants.LOGOUT_URL +import com.yapp.muckpot.common.constants.REFRESH_TOKEN_KEY +import com.yapp.muckpot.common.constants.REISSUE_JWT_URL +import com.yapp.muckpot.common.constants.SIGN_UP_URL +import com.yapp.muckpot.common.constants.USER_PROFILE_URL +import com.yapp.muckpot.domains.user.service.JwtService +import com.yapp.muckpot.filter.AuthenticationFailHandler +import com.yapp.muckpot.filter.JwtAuthorizationFilter +import com.yapp.muckpot.filter.JwtLogoutHandler +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.builders.WebSecurity +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.logout.LogoutHandler +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.UrlBasedCorsConfigurationSource +import javax.servlet.http.HttpServletResponse + +@Configuration +class SecurityConfig( + private val jwtService: JwtService, + @Value("\${api.option.permit-all}") + private val permitAll: Boolean, + @Value("\${api.option.allowed-origins}") + private val allowedOrigins: List +) { + @Bean + @Throws(Exception::class) + fun filterChain(http: HttpSecurity): SecurityFilterChain { + http + .authorizeHttpRequests { authz -> + authz + .antMatchers(HttpMethod.POST, *POST_PERMIT_ALL_URLS.toTypedArray()).permitAll() + .antMatchers(HttpMethod.GET, "/api/**", "/swagger-ui/**") + .permitAll() + .antMatchers("/api/**").apply { + if (permitAll) { + permitAll() + } else { + hasRole("USER") + } + } + } + .cors { cors -> + cors.configurationSource(corsConfigurationSource()) + } + .addFilterBefore(JwtAuthorizationFilter(jwtService), BasicAuthenticationFilter::class.java) + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .csrf().disable() + .formLogin().disable() + .httpBasic().disable() + .exceptionHandling() + .authenticationEntryPoint(AuthenticationFailHandler()) + .and() + .logout() + .logoutUrl(LOGOUT_URL) + .deleteCookies(ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY) + .addLogoutHandler(logoutHandler()) + .logoutSuccessHandler { _, response, _ -> + response.status = HttpServletResponse.SC_NO_CONTENT + } + return http.build() + } + + @Bean + fun corsConfigurationSource(): UrlBasedCorsConfigurationSource { + val configuration = CorsConfiguration() + val source = UrlBasedCorsConfigurationSource() + + configuration.allowedMethods = listOf("*") + configuration.allowedHeaders = listOf("*") + configuration.allowedOrigins = allowedOrigins + configuration.allowCredentials = true + source.registerCorsConfiguration("/**", configuration) + return source + } + + @Bean + fun webSecurityCustomizer(): WebSecurityCustomizer { + return WebSecurityCustomizer { web: WebSecurity -> + web.ignoring() + .antMatchers("/swagger-ui/**") + } + } + + @Bean + fun passwordEncoder(): PasswordEncoder { + return BCryptPasswordEncoder() + } + + @Bean + fun logoutHandler(): LogoutHandler { + return JwtLogoutHandler(jwtService) + } + + companion object { + val POST_PERMIT_ALL_URLS = listOf( + LOGIN_URL, + SIGN_UP_URL, + EMAIL_REQUEST_URL, + EMAIL_VERIFY_URL, + USER_PROFILE_URL, + REISSUE_JWT_URL + ) + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/config/SwaggerConfig.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/config/SwaggerConfig.kt new file mode 100644 index 00000000..58d6f5ca --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/config/SwaggerConfig.kt @@ -0,0 +1,36 @@ +package com.yapp.muckpot.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.RestController +import springfox.documentation.builders.ApiInfoBuilder +import springfox.documentation.builders.PathSelectors +import springfox.documentation.builders.RequestHandlerSelectors +import springfox.documentation.service.ApiInfo +import springfox.documentation.spi.DocumentationType +import springfox.documentation.spring.web.plugins.Docket +import springfox.documentation.swagger2.annotations.EnableSwagger2 + +@EnableSwagger2 +@Configuration +class SwaggerConfig { + @Bean + fun api(): Docket { + return Docket(DocumentationType.OAS_30) + .ignoredParameterTypes(AuthenticationPrincipal::class.java) + .select() + .apis(RequestHandlerSelectors.withClassAnnotation(RestController::class.java)) + .paths(PathSelectors.any()) + .build() + .apiInfo(apiInfo()) + } + + private fun apiInfo(): ApiInfo { + return ApiInfoBuilder() + .title("먹팟") + .description("먹팟 API 리스트") + .version("0.1") + .build() + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/config/TomcatConfig.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/config/TomcatConfig.kt new file mode 100644 index 00000000..5b00c51f --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/config/TomcatConfig.kt @@ -0,0 +1,15 @@ +package com.yapp.muckpot.config + +import org.apache.tomcat.util.http.LegacyCookieProcessor +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory +import org.springframework.boot.web.server.WebServerFactoryCustomizer +import org.springframework.context.annotation.Configuration + +@Configuration +class TomcatConfig : WebServerFactoryCustomizer { + override fun customize(factory: TomcatServletWebServerFactory) { + factory.addContextCustomizers({ context -> + context.cookieProcessor = LegacyCookieProcessor() + }) + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/BoardController.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/BoardController.kt new file mode 100644 index 00000000..0763320d --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/BoardController.kt @@ -0,0 +1,207 @@ +package com.yapp.muckpot.domains.board.controller + +import com.yapp.muckpot.common.ResponseDto +import com.yapp.muckpot.common.constants.MUCKPOT_FIND_ALL +import com.yapp.muckpot.common.constants.MUCKPOT_FIND_BY_ID +import com.yapp.muckpot.common.constants.MUCKPOT_JOIN_RESPONSE +import com.yapp.muckpot.common.constants.MUCKPOT_SAVE_RESPONSE +import com.yapp.muckpot.common.dto.CursorPaginationRequest +import com.yapp.muckpot.common.utils.ResponseEntityUtil +import com.yapp.muckpot.common.utils.SecurityContextHolderUtil +import com.yapp.muckpot.domains.board.controller.dto.MuckpotCreateRequest +import com.yapp.muckpot.domains.board.controller.dto.MuckpotCreateResponse +import com.yapp.muckpot.domains.board.controller.dto.MuckpotUpdateRequest +import com.yapp.muckpot.domains.board.service.BoardService +import com.yapp.muckpot.domains.user.enums.MuckPotStatus +import io.swagger.annotations.Api +import io.swagger.annotations.ApiOperation +import io.swagger.annotations.ApiResponse +import io.swagger.annotations.ApiResponses +import io.swagger.annotations.Example +import io.swagger.annotations.ExampleProperty +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import javax.validation.Valid + +@RestController +@Api(tags = ["먹팟 api"], description = "먹팟 API") +@RequestMapping("/api") +class BoardController( + private val boardService: BoardService +) { + @ApiResponses( + value = [ + ApiResponse( + code = 201, + examples = Example( + ExampleProperty( + value = MUCKPOT_SAVE_RESPONSE, + mediaType = MediaType.APPLICATION_JSON_VALUE + ) + ), + message = "성공" + ) + ] + ) + @ApiOperation(value = "먹팟 글 생성") + @PostMapping("/v1/boards") + fun saveBoard( + @AuthenticationPrincipal userId: Long, + @RequestBody @Valid + request: MuckpotCreateRequest + ): ResponseEntity { + return ResponseEntityUtil.created( + MuckpotCreateResponse( + boardService.saveBoard(userId, request) + ) + ) + } + + @ApiResponses( + value = [ + ApiResponse( + code = 200, + examples = Example( + ExampleProperty( + value = MUCKPOT_FIND_ALL, + mediaType = MediaType.APPLICATION_JSON_VALUE + ) + ), + message = "성공" + ) + ] + ) + @ApiOperation(value = "먹팟 글 리스트 조회") + @GetMapping("/v1/boards") + fun findAll(@ModelAttribute request: CursorPaginationRequest): ResponseEntity { + return ResponseEntityUtil.ok(boardService.findAllMuckpot(request)) + } + + @ApiResponses( + value = [ + ApiResponse( + code = 200, + examples = Example( + ExampleProperty( + value = MUCKPOT_FIND_BY_ID, + mediaType = MediaType.APPLICATION_JSON_VALUE + ) + ), + message = "성공" + ) + ] + ) + @ApiOperation(value = "먹팟 글 상세 조회") + @GetMapping("/v1/boards/{boardId}") + fun findByBoardId(@PathVariable boardId: Long): ResponseEntity { + return ResponseEntityUtil.ok( + boardService.findBoardDetailAndVisit( + boardId, + SecurityContextHolderUtil.getCredentialOrNull() + ) + ) + } + + @ApiOperation(value = "먹팟 글 수정") + @PatchMapping("/v1/boards/{boardId}") + fun updateBoard( + @AuthenticationPrincipal userId: Long, + @PathVariable boardId: Long, + @RequestBody @Valid + request: MuckpotUpdateRequest + ): ResponseEntity { + boardService.updateBoard(userId, boardId, request) + return ResponseEntityUtil.noContent() + } + + @ApiResponses( + value = [ + ApiResponse( + code = 201, + examples = Example( + ExampleProperty( + value = MUCKPOT_JOIN_RESPONSE, + mediaType = MediaType.APPLICATION_JSON_VALUE + ) + ), + message = "성공" + ) + ] + ) + @ApiOperation(value = "먹팟 참가 신청") + @PostMapping("/v1/boards/{boardId}/join") + fun joinBoard( + @AuthenticationPrincipal userId: Long, + @PathVariable boardId: Long + ): ResponseEntity { + return ResponseEntityUtil.created( + boardService.joinBoard(userId, boardId) + ) + } + + @ApiResponses( + value = [ + ApiResponse( + code = 204, + message = "먹팟 글 삭제 성공" + ) + ] + ) + @ApiOperation(value = "먹팟 글 삭제") + @DeleteMapping("/v1/boards/{boardId}") + fun deleteBoard( + @AuthenticationPrincipal userId: Long, + @PathVariable boardId: Long + ): ResponseEntity { + boardService.deleteBoard(userId, boardId) + return ResponseEntityUtil.noContent() + } + + @ApiResponses( + value = [ + ApiResponse( + code = 204, + message = "먹팟 글 상태변경 성공" + ) + ] + ) + @ApiOperation(value = "먹팟 글 상태변경") + @PatchMapping("/v1/boards/{boardId}/status") + fun changeStatus( + @AuthenticationPrincipal userId: Long, + @PathVariable boardId: Long, + @RequestParam("status") status: MuckPotStatus + ): ResponseEntity { + boardService.changeStatus(userId, boardId, status) + return ResponseEntityUtil.noContent() + } + + @ApiResponses( + value = [ + ApiResponse( + code = 204, + message = "먹팟 참가 신청 취소 성공" + ) + ] + ) + @ApiOperation(value = "먹팟 참가 신청 취소") + @DeleteMapping("/v1/boards/{boardId}/join") + fun deleteParticipant( + @AuthenticationPrincipal userId: Long, + @PathVariable boardId: Long + ): ResponseEntity { + boardService.cancelJoin(userId, boardId) + return ResponseEntityUtil.noContent() + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotCreateRequest.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotCreateRequest.kt new file mode 100644 index 00000000..b507f9ea --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotCreateRequest.kt @@ -0,0 +1,85 @@ +package com.yapp.muckpot.domains.board.controller.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import com.yapp.muckpot.common.Location +import com.yapp.muckpot.common.constants.AGE_MAX +import com.yapp.muckpot.common.constants.AGE_MIN +import com.yapp.muckpot.common.constants.CHAT_LINK_MAX +import com.yapp.muckpot.common.constants.CONTENT_MAX +import com.yapp.muckpot.common.constants.CONTENT_MAX_INVALID +import com.yapp.muckpot.common.constants.HHmm +import com.yapp.muckpot.common.constants.LINK_MAX_INVALID +import com.yapp.muckpot.common.constants.MAX_APPLY_MIN_INVALID +import com.yapp.muckpot.common.constants.NOT_BLANK_COMMON +import com.yapp.muckpot.common.constants.TITLE_MAX +import com.yapp.muckpot.common.constants.TITLE_MAX_INVALID +import com.yapp.muckpot.common.constants.YYYYMMDD +import com.yapp.muckpot.domains.board.entity.Board +import com.yapp.muckpot.domains.user.entity.MuckPotUser +import io.swagger.annotations.ApiModel +import io.swagger.annotations.ApiModelProperty +import org.hibernate.validator.constraints.Length +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import javax.validation.constraints.Min +import javax.validation.constraints.NotBlank + +@ApiModel(value = "먹팟생성 요청") +data class MuckpotCreateRequest( + @field:ApiModelProperty(notes = "만날 날짜", required = true, example = "2023-05-21") + @field:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = YYYYMMDD) + val meetingDate: LocalDate, + @field:ApiModelProperty(notes = "만날 시간", required = true, example = "13:00") + @field:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = HHmm) + val meetingTime: LocalTime, + @field:ApiModelProperty(notes = "최대 참여 인원", required = true, example = "5") + @field:Min(2, message = MAX_APPLY_MIN_INVALID) + val maxApply: Int = 2, + @field:ApiModelProperty(notes = "최소 나이", required = false, example = "20") + val minAge: Int? = null, + @field:ApiModelProperty(notes = "최대 나이", required = false, example = "100") + val maxAge: Int? = null, + @field:ApiModelProperty(notes = "주소", required = true, example = "서울 성북구 안암동5가 104-30 캐치카페 안암") + var locationName: String, + @field:ApiModelProperty(notes = "주소 상세", required = false, example = "6층") + var locationDetail: String? = null, + @field:ApiModelProperty(notes = "x 좌표", required = true, example = "127.02970799701643") + val x: Double, + @field:ApiModelProperty(notes = "y 좌표", required = true, example = "37.58392327180857") + val y: Double, + @field:ApiModelProperty(notes = "제목", required = true, example = "같이 밥묵으실분") + @field:Length(max = TITLE_MAX, message = TITLE_MAX_INVALID) + @field:NotBlank(message = NOT_BLANK_COMMON) + var title: String, + @field:ApiModelProperty(notes = "내용", required = false, example = "내용 입니다.") + @field:Length(max = CONTENT_MAX, message = CONTENT_MAX_INVALID) + var content: String? = null, + @field:ApiModelProperty(notes = "오픈채팅방 링크", required = true, example = "https://open.kakao.com/o/gSIkvvHc") + @field:Length(max = CHAT_LINK_MAX, message = LINK_MAX_INVALID) + @field:NotBlank(message = NOT_BLANK_COMMON) + var chatLink: String +) { + init { + title = title.trim() + content = content?.trim() + chatLink = chatLink.trim() + locationDetail = locationDetail?.trim() + } + + fun toBoard(user: MuckPotUser): Board { + return Board( + user = user, + title = title, + content = content, + location = Location(locationName, x, y), + locationDetail = locationDetail, + meetingTime = LocalDateTime.of(meetingDate, meetingTime), + minAge = minAge ?: AGE_MIN, + maxAge = maxAge ?: AGE_MAX, + maxApply = maxApply, + chatLink = chatLink, + currentApply = 1 + ) + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotCreateResponse.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotCreateResponse.kt new file mode 100644 index 00000000..53460a96 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotCreateResponse.kt @@ -0,0 +1,5 @@ +package com.yapp.muckpot.domains.board.controller.dto + +data class MuckpotCreateResponse( + val boardId: Long? +) diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotDetailResponse.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotDetailResponse.kt new file mode 100644 index 00000000..6980082b --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotDetailResponse.kt @@ -0,0 +1,95 @@ +package com.yapp.muckpot.domains.board.controller.dto + +import com.yapp.muckpot.common.TimeUtil +import com.yapp.muckpot.common.constants.KR_MM_DD_E +import com.yapp.muckpot.common.constants.KR_YYYY_MM_DD +import com.yapp.muckpot.common.constants.a_hhmm +import com.yapp.muckpot.domains.board.dto.ParticipantReadResponse +import com.yapp.muckpot.domains.board.entity.Board +import com.yapp.muckpot.domains.user.controller.dto.UserResponse + +data class MuckpotDetailResponse( + val boardId: Long, + val prevId: Long?, + val nextId: Long?, + val title: String, + val content: String? = null, + val chatLink: String, + val status: String, + val meetingDate: String, + val meetingTime: String, + val createDate: String, + val maxApply: Int, + val currentApply: Int, + var minAge: Int? = null, + var maxAge: Int? = null, + val locationName: String, + val x: Double, + val y: Double, + val locationDetail: String? = null, + val views: Int, + val userAge: Int?, + var participants: List +) { + init { + if (participants.isNotEmpty()) { + participants.first().writer = true + } + } + + fun sortParticipantsByLoginUser(loginUserInfo: UserResponse?) { + if (participants.size > 1) { + loginUserInfo?.let { userInfo -> + val mutableParticipants = participants.toMutableList() + val myParticipantIndex = mutableParticipants.indexOfFirst { it.userId == userInfo.userId } + if (myParticipantIndex != -1) { + val participant = mutableParticipants.removeAt(myParticipantIndex) + participants = listOf(participant) + mutableParticipants + } + } + } + } + + private fun changeIsNotAgeLimit() { + this.minAge = null + this.maxAge = null + } + + companion object { + fun of( + board: Board, + participants: List, + prevId: Long? = null, + nextId: Long? = null, + userAge: Int? = null + ): MuckpotDetailResponse { + val response = MuckpotDetailResponse( + boardId = board.id ?: 0, + prevId = prevId, + nextId = nextId, + title = board.title, + content = board.content, + chatLink = board.chatLink, + status = board.status.korNm, + meetingDate = TimeUtil.localeKoreanFormatting(board.meetingTime, KR_MM_DD_E), + meetingTime = TimeUtil.localeKoreanFormatting(board.meetingTime, a_hhmm), + createDate = TimeUtil.localeKoreanFormatting(board.createdAt, KR_YYYY_MM_DD), + maxApply = board.maxApply, + currentApply = board.currentApply, + minAge = board.minAge, + maxAge = board.maxAge, + locationName = board.location.locationName, + x = board.getX(), + y = board.getY(), + locationDetail = board.locationDetail, + views = board.views, + userAge = userAge, + participants = participants + ) + if (board.isNotAgeLimit()) { + response.changeIsNotAgeLimit() + } + return response + } + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotReadResponse.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotReadResponse.kt new file mode 100644 index 00000000..452b3ab0 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotReadResponse.kt @@ -0,0 +1,56 @@ +package com.yapp.muckpot.domains.board.controller.dto + +import com.yapp.muckpot.common.TimeUtil +import com.yapp.muckpot.common.constants.KR_MM_DD_E +import com.yapp.muckpot.common.constants.a_hhmm +import com.yapp.muckpot.domains.board.dto.ParticipantReadResponse +import com.yapp.muckpot.domains.board.entity.Board +import com.yapp.muckpot.domains.user.enums.MuckPotStatus + +data class MuckpotReadResponse( + val boardId: Long, + val title: String, + val status: String, + val todayOrTomorrow: String?, + val elapsedTime: String, + val meetingDateTime: String, + val meetingPlace: String, + val maxApply: Int, + val currentApply: Int, + var participants: List +) { + init { + limitParticipants() + } + + private fun limitParticipants() { + if (participants.size > PARTICIPANTS_MAX_CNT) { + participants = participants.take(PARTICIPANTS_MAX_CNT) + + ParticipantReadResponse.otherN(participants.size - PARTICIPANTS_MAX_CNT) + } + } + + companion object { + private const val PARTICIPANTS_MAX_CNT = 5 + + fun of(board: Board, participants: List): MuckpotReadResponse { + // TODO 만료 된 먹팟 종료 처리 후, 해당 로직은 제거. + var status = board.status.korNm + if (board.expired()) { + status = MuckPotStatus.DONE.korNm + } + return MuckpotReadResponse( + boardId = board.id ?: 0, + title = board.title, + status = status, + todayOrTomorrow = TimeUtil.isTodayOrTomorrow(board.createdAt.toLocalDate()), + elapsedTime = TimeUtil.formatElapsedTime(board.createdAt), + meetingDateTime = TimeUtil.localeKoreanFormatting(board.meetingTime, "$KR_MM_DD_E $a_hhmm"), + meetingPlace = board.location.locationName, + maxApply = board.maxApply, + currentApply = board.currentApply, + participants = participants + ) + } + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotUpdateRequest.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotUpdateRequest.kt new file mode 100644 index 00000000..90f85698 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotUpdateRequest.kt @@ -0,0 +1,85 @@ +package com.yapp.muckpot.domains.board.controller.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import com.yapp.muckpot.common.Location +import com.yapp.muckpot.common.constants.AGE_MAX +import com.yapp.muckpot.common.constants.AGE_MIN +import com.yapp.muckpot.common.constants.CHAT_LINK_MAX +import com.yapp.muckpot.common.constants.CONTENT_MAX +import com.yapp.muckpot.common.constants.CONTENT_MAX_INVALID +import com.yapp.muckpot.common.constants.HHmm +import com.yapp.muckpot.common.constants.LINK_MAX_INVALID +import com.yapp.muckpot.common.constants.MAX_APPLY_MIN_INVALID +import com.yapp.muckpot.common.constants.NOT_BLANK_COMMON +import com.yapp.muckpot.common.constants.TITLE_MAX +import com.yapp.muckpot.common.constants.TITLE_MAX_INVALID +import com.yapp.muckpot.common.constants.YYYYMMDD +import com.yapp.muckpot.domains.board.entity.Board +import com.yapp.muckpot.domains.board.exception.BoardErrorCode +import com.yapp.muckpot.exception.MuckPotException +import io.swagger.annotations.ApiModel +import io.swagger.annotations.ApiModelProperty +import org.hibernate.validator.constraints.Length +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import javax.validation.constraints.Min +import javax.validation.constraints.NotBlank + +@ApiModel(value = "먹팟수정 요청") +data class MuckpotUpdateRequest( + @field:ApiModelProperty(notes = "만날 날짜", required = true, example = "2023-05-21") + @field:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = YYYYMMDD) + val meetingDate: LocalDate, + @field:ApiModelProperty(notes = "만날 시간", required = true, example = "13:00") + @field:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = HHmm) + val meetingTime: LocalTime, + @field:ApiModelProperty(notes = "최대 참여 인원", required = true, example = "5") + @field:Min(2, message = MAX_APPLY_MIN_INVALID) + val maxApply: Int = 2, + @field:ApiModelProperty(notes = "최소 나이", required = false, example = "20") + val minAge: Int? = null, + @field:ApiModelProperty(notes = "최대 나이", required = false, example = "100") + val maxAge: Int? = null, + @field:ApiModelProperty(notes = "주소", required = true, example = "서울 성북구 안암동5가 104-30 캐치카페 안암") + var locationName: String, + @field:ApiModelProperty(notes = "주소 상세", required = false, example = "6층") + var locationDetail: String? = null, + @field:ApiModelProperty(notes = "x 좌표", required = true, example = "127.02970799701643") + val x: Double, + @field:ApiModelProperty(notes = "y 좌표", required = true, example = "37.58392327180857") + val y: Double, + @field:ApiModelProperty(notes = "제목", required = true, example = "같이 밥묵으실분") + @field:Length(max = TITLE_MAX, message = TITLE_MAX_INVALID) + @field:NotBlank(message = NOT_BLANK_COMMON) + var title: String, + @field:ApiModelProperty(notes = "내용", required = false, example = "내용 입니다.") + @field:Length(max = CONTENT_MAX, message = CONTENT_MAX_INVALID) + var content: String? = null, + @field:ApiModelProperty(notes = "오픈채팅방 링크", required = true, example = "https://open.kakao.com/o/gSIkvvHc") + @field:Length(max = CHAT_LINK_MAX, message = LINK_MAX_INVALID) + @field:NotBlank(message = NOT_BLANK_COMMON) + var chatLink: String +) { + init { + title = title.trim() + content = content?.trim() + chatLink = chatLink.trim() + locationDetail = locationDetail?.trim() + } + + fun updateBoard(board: Board) { + if (this.maxApply < board.currentApply) { + throw MuckPotException(BoardErrorCode.MAX_APPLY_UPDATE_FAIL) + } + board.title = this.title + board.content = this.content + board.location = Location(locationName, x, y) + board.locationDetail = this.locationDetail + board.meetingTime = LocalDateTime.of(meetingDate, meetingTime) + board.minAge = minAge ?: AGE_MIN + board.maxAge = maxAge ?: AGE_MAX + board.maxApply = this.maxApply + board.chatLink = this.chatLink + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/service/BoardScheduler.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/service/BoardScheduler.kt new file mode 100644 index 00000000..4da732e4 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/service/BoardScheduler.kt @@ -0,0 +1,26 @@ +package com.yapp.muckpot.domains.board.service + +import com.yapp.muckpot.domains.board.repository.BoardQuerydslRepository +import mu.KLogging +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class BoardScheduler( + private val boardQuerydslRepository: BoardQuerydslRepository +) { + private val log = KLogging().logger + + @Scheduled(cron = "0 $UPDATE_MINUTES $UPDATE_HOURS * * *") + @Transactional + fun updateDoneBoard() { + boardQuerydslRepository.updateLessThanCurrentTime() + log.debug { "참여시간 지나간 먹팟 상태 변경" } + } + + companion object { + private const val UPDATE_MINUTES = "0,15,30,45" + private const val UPDATE_HOURS = "10-22,0" + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/service/BoardService.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/service/BoardService.kt new file mode 100644 index 00000000..f46eeaa5 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/board/service/BoardService.kt @@ -0,0 +1,145 @@ +package com.yapp.muckpot.domains.board.service + +import com.yapp.muckpot.common.dto.CursorPaginationRequest +import com.yapp.muckpot.common.dto.CursorPaginationResponse +import com.yapp.muckpot.common.redisson.DistributedLock +import com.yapp.muckpot.domains.board.controller.dto.MuckpotCreateRequest +import com.yapp.muckpot.domains.board.controller.dto.MuckpotDetailResponse +import com.yapp.muckpot.domains.board.controller.dto.MuckpotReadResponse +import com.yapp.muckpot.domains.board.controller.dto.MuckpotUpdateRequest +import com.yapp.muckpot.domains.board.entity.Participant +import com.yapp.muckpot.domains.board.exception.BoardErrorCode +import com.yapp.muckpot.domains.board.exception.ParticipantErrorCode +import com.yapp.muckpot.domains.board.repository.BoardQuerydslRepository +import com.yapp.muckpot.domains.board.repository.BoardRepository +import com.yapp.muckpot.domains.board.repository.ParticipantQuerydslRepository +import com.yapp.muckpot.domains.board.repository.ParticipantRepository +import com.yapp.muckpot.domains.user.controller.dto.UserResponse +import com.yapp.muckpot.domains.user.enums.MuckPotStatus +import com.yapp.muckpot.domains.user.exception.UserErrorCode +import com.yapp.muckpot.domains.user.repository.MuckPotUserRepository +import com.yapp.muckpot.exception.MuckPotException +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class BoardService( + private val userRepository: MuckPotUserRepository, + private val boardRepository: BoardRepository, + private val boardQuerydslRepository: BoardQuerydslRepository, + private val participantRepository: ParticipantRepository, + private val participantQuerydslRepository: ParticipantQuerydslRepository +) { + @Transactional + fun saveBoard(userId: Long, request: MuckpotCreateRequest): Long? { + // TODO 먹팟 등록 시 같은 회사 인원에게 메일 전송 + val user = userRepository.findByIdOrNull(userId) + ?: throw MuckPotException(UserErrorCode.USER_NOT_FOUND) + val board = boardRepository.save(request.toBoard(user)) + participantRepository.save(Participant(user, board)) + return board.id + } + + @Transactional(readOnly = true) + fun findAllMuckpot(request: CursorPaginationRequest): CursorPaginationResponse { + val allBoard = boardQuerydslRepository.findAllWithPagination(request.lastId, request.countPerScroll) + val boardIds = allBoard.map { it.id } + val participantsByBoardId = participantQuerydslRepository.findByBoardIds(boardIds).groupBy { it.boardId } + val responseList = allBoard.map { MuckpotReadResponse.of(it, participantsByBoardId.getOrDefault(it.id, emptyList())) } + if (responseList.isNotEmpty()) { + return CursorPaginationResponse(responseList, responseList.last().boardId) + } + return CursorPaginationResponse(responseList) + } + + @Transactional + fun findBoardDetailAndVisit(boardId: Long, loginUserInfo: UserResponse?): MuckpotDetailResponse { + boardRepository.findByIdOrNull(boardId)?.let { board -> + if (board.user.id != loginUserInfo?.userId) { + board.visit() + } + val userAge: Int? = loginUserInfo?.let { userRepository.findByIdOrNull(it.userId)?.getAge() } + + return MuckpotDetailResponse.of( + board = board, + participants = participantQuerydslRepository.findByBoardIds(listOf(boardId)), + prevId = boardQuerydslRepository.findPrevId(boardId), + nextId = boardQuerydslRepository.findNextId(boardId), + userAge = userAge + ).apply { + sortParticipantsByLoginUser(loginUserInfo) + } + } ?: run { + throw MuckPotException(BoardErrorCode.BOARD_NOT_FOUND) + } + } + + @Transactional + fun updateBoard(userId: Long, boardId: Long, request: MuckpotUpdateRequest) { + // TODO 먹팟 수정 시 참여 인원에게 메일 전송 + boardRepository.findByIdOrNull(boardId)?.let { board -> + if (board.isNotMyBoard(userId)) { + throw MuckPotException(BoardErrorCode.BOARD_UNAUTHORIZED) + } + request.updateBoard(board) + } ?: run { + throw MuckPotException(BoardErrorCode.BOARD_NOT_FOUND) + } + } + + @DistributedLock(lockName = "joinLock", identifier = "boardId") + fun joinBoard(userId: Long, boardId: Long) { + boardRepository.findByIdOrNull(boardId)?.let { + val user = userRepository.findByIdOrNull(userId) ?: throw MuckPotException(UserErrorCode.USER_NOT_FOUND) + val participant = participantRepository.findByUserAndBoard(user, it) + if (participant != null) throw MuckPotException(ParticipantErrorCode.ALREADY_JOIN) + it.join(user.getAge()) + participantRepository.save(Participant(user, it)) + } ?: run { + throw MuckPotException(BoardErrorCode.BOARD_NOT_FOUND) + } + } + + @Transactional + fun deleteBoard(userId: Long, boardId: Long) { + // TODO 먹팟 삭제 시 참여 인원에게 메일 전송 + boardRepository.findByIdOrNull(boardId)?.let { board -> + if (board.isNotMyBoard(userId)) { + throw MuckPotException(BoardErrorCode.BOARD_UNAUTHORIZED) + } + participantRepository.deleteByBoard(board) + boardRepository.delete(board) + } ?: run { + throw MuckPotException(BoardErrorCode.BOARD_NOT_FOUND) + } + } + + @Transactional + fun changeStatus(userId: Long, boardId: Long, changeStatus: MuckPotStatus) { + boardRepository.findByIdOrNull(boardId)?.let { board -> + if (board.isNotMyBoard(userId)) { + throw MuckPotException(BoardErrorCode.BOARD_UNAUTHORIZED) + } + board.changeStatus(changeStatus) + } ?: run { + throw MuckPotException(BoardErrorCode.BOARD_NOT_FOUND) + } + } + + @Transactional + fun cancelJoin(userId: Long, boardId: Long) { + // TODO 먹팟 참가 신청 취소 시 참여 인원에게 메일 전송 기획 논의 + boardRepository.findByIdOrNull(boardId)?.let { board -> + val user = userRepository.findByIdOrNull(userId) + ?: throw MuckPotException(UserErrorCode.USER_NOT_FOUND) + val participant = participantRepository.findByUserAndBoard(user, board) + ?: throw MuckPotException(ParticipantErrorCode.PARTICIPANT_NOT_FOUND) + if (board.user.id == userId) throw MuckPotException(ParticipantErrorCode.WRITER_MUST_JOIN) + participantRepository.delete(participant) + board.cancelJoin() + } ?: run { + throw MuckPotException(BoardErrorCode.BOARD_NOT_FOUND) + } + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/UserController.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/UserController.kt new file mode 100644 index 00000000..df02f57d --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/UserController.kt @@ -0,0 +1,181 @@ +package com.yapp.muckpot.domains.user.controller + +import com.yapp.muckpot.common.ResponseDto +import com.yapp.muckpot.common.constants.ACCESS_TOKEN_KEY +import com.yapp.muckpot.common.constants.EMAIL_AUTH_REQ_RESPONSE +import com.yapp.muckpot.common.constants.LOGIN_RESPONSE +import com.yapp.muckpot.common.constants.NO_BODY_RESPONSE +import com.yapp.muckpot.common.constants.REFRESH_TOKEN_KEY +import com.yapp.muckpot.common.constants.SIGN_UP_RESPONSE +import com.yapp.muckpot.common.utils.ResponseEntityUtil +import com.yapp.muckpot.common.utils.SecurityContextHolderUtil +import com.yapp.muckpot.domains.user.controller.dto.LoginRequest +import com.yapp.muckpot.domains.user.controller.dto.SendEmailAuthRequest +import com.yapp.muckpot.domains.user.controller.dto.SignUpRequest +import com.yapp.muckpot.domains.user.controller.dto.VerifyEmailAuthRequest +import com.yapp.muckpot.domains.user.service.UserService +import io.swagger.annotations.Api +import io.swagger.annotations.ApiOperation +import io.swagger.annotations.ApiResponse +import io.swagger.annotations.ApiResponses +import io.swagger.annotations.Example +import io.swagger.annotations.ExampleProperty +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.CookieValue +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import javax.validation.Valid + +@RestController +@Api(tags = ["유저 api"], description = "유저 API") +@RequestMapping("/api") +class UserController( + private val userService: UserService +) { + + @ApiResponses( + value = [ + ApiResponse( + code = 200, + examples = Example( + ExampleProperty( + value = LOGIN_RESPONSE, + mediaType = MediaType.APPLICATION_JSON_VALUE + ) + ), + message = "성공" + ) + ] + ) + @ApiOperation(value = "로그인") + @PostMapping("/v1/users/login") + fun login( + @RequestBody @Valid + request: LoginRequest + ): ResponseEntity { + return ResponseEntityUtil.ok(userService.login(request)) + } + + @ApiResponses( + value = [ + ApiResponse( + code = 201, + examples = Example( + ExampleProperty( + value = EMAIL_AUTH_REQ_RESPONSE, + mediaType = MediaType.APPLICATION_JSON_VALUE + ) + ), + message = "성공" + ) + ] + ) + @ApiOperation(value = "이메일 인증 요청") + @PostMapping("/v1/emails/request") + fun sendEmailAuth( + @RequestBody @Valid + request: SendEmailAuthRequest + ): ResponseEntity { + return ResponseEntityUtil.created(userService.sendEmailAuth(request)) + } + + @ApiResponses( + value = [ + ApiResponse( + code = 204, + examples = Example( + ExampleProperty( + value = NO_BODY_RESPONSE, + mediaType = MediaType.APPLICATION_JSON_VALUE + ) + ), + message = "성공" + ) + ] + ) + @ApiOperation(value = "이메일 인증 검증") + @PostMapping("/v1/emails/verify") + fun verifyEmailAuth( + @RequestBody @Valid + request: VerifyEmailAuthRequest + ): ResponseEntity { + userService.verifyEmailAuth(request) + return ResponseEntityUtil.noContent() + } + + @ApiResponses( + value = [ + ApiResponse( + code = 200, + examples = Example( + ExampleProperty( + value = LOGIN_RESPONSE, + mediaType = MediaType.APPLICATION_JSON_VALUE + ) + ), + message = "성공" + ), + ApiResponse( + code = 204, + message = "비 로그인 유저 응답" + ) + ] + ) + @ApiOperation(value = "유저 프로필 조회") + @GetMapping("/v1/users/profile") + fun findLoginUserProfile(): ResponseEntity { + return SecurityContextHolderUtil.getCredentialOrNull()?.let { + ResponseEntityUtil.ok(it) + } ?: run { + ResponseEntityUtil.noContent() + } + } + + @ApiResponses( + value = [ + ApiResponse( + code = 201, + examples = Example( + ExampleProperty( + value = SIGN_UP_RESPONSE, + mediaType = MediaType.APPLICATION_JSON_VALUE + ) + ), + message = "성공" + ) + ] + ) + @ApiOperation(value = "회원가입") + @PostMapping("/v1/users") + fun signUp( + @RequestBody @Valid + request: SignUpRequest + ): ResponseEntity { + return ResponseEntityUtil.created(userService.signUp(request)) + } + + @ApiResponses( + value = [ + ApiResponse( + code = 204, + examples = Example( + ExampleProperty( + value = NO_BODY_RESPONSE, + mediaType = MediaType.APPLICATION_JSON_VALUE + ) + ), + message = "성공" + ) + ] + ) + @ApiOperation(value = "JWT 재발급") + @PostMapping("/v1/users/refresh") + fun reissueJwt(@CookieValue(REFRESH_TOKEN_KEY) refreshToken: String, @CookieValue(ACCESS_TOKEN_KEY) accessToken: String): ResponseEntity { + userService.reissueJwt(refreshToken, accessToken) + return ResponseEntityUtil.noContent() + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/EmailAuthResponse.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/EmailAuthResponse.kt new file mode 100644 index 00000000..c6ea1113 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/EmailAuthResponse.kt @@ -0,0 +1,5 @@ +package com.yapp.muckpot.domains.user.controller.dto + +class EmailAuthResponse( + val verificationCode: String +) diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/LoginRequest.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/LoginRequest.kt new file mode 100644 index 00000000..839572cc --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/LoginRequest.kt @@ -0,0 +1,25 @@ +package com.yapp.muckpot.domains.user.controller.dto + +import com.yapp.muckpot.common.constants.ONLY_NAVER +import com.yapp.muckpot.common.constants.PASSWORD_PATTERN_INVALID +import com.yapp.muckpot.common.constants.PW_PATTERN +import com.yapp.muckpot.common.enums.YesNo +import io.swagger.annotations.ApiModel +import io.swagger.annotations.ApiModelProperty +import javax.validation.constraints.Pattern + +@ApiModel(value = "로그인 요청") +data class LoginRequest( + // TODO 상용 시 samsung.com 으로 변경 + @field:ApiModelProperty(notes = "이메일", required = true, example = "user@naver.com") + @field:Pattern(regexp = ONLY_NAVER, message = "현재 버전은 네이버 사우만 이용 가능합니다.") + val email: String, + @field:ApiModelProperty(notes = "비밀번호", required = true, example = "abcd1234") + @field:Pattern( + regexp = PW_PATTERN, + message = PASSWORD_PATTERN_INVALID + ) + val password: String, + @field:ApiModelProperty(notes = "로그인 유지하기", required = true, example = "Y") + val keep: YesNo = YesNo.Y +) diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/SendEmailAuthRequest.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/SendEmailAuthRequest.kt new file mode 100644 index 00000000..e1614165 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/SendEmailAuthRequest.kt @@ -0,0 +1,14 @@ +package com.yapp.muckpot.domains.user.controller.dto + +import com.yapp.muckpot.common.constants.ONLY_NAVER +import io.swagger.annotations.ApiModel +import io.swagger.annotations.ApiModelProperty +import javax.validation.constraints.Pattern + +@ApiModel(value = "이메일 인증 요청") +data class SendEmailAuthRequest( + // TODO: 개발 서버에서 메일 정상 작동 확인 후 삼성전자로 변경 필요! + @field:ApiModelProperty(notes = "이메일", required = true, example = "co@naver.com") + @field:Pattern(regexp = ONLY_NAVER, message = "현재 버전은 네이버 사우만 이용 가능합니다.") // for test + val email: String +) diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/SignUpRequest.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/SignUpRequest.kt new file mode 100644 index 00000000..5fd3d51a --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/SignUpRequest.kt @@ -0,0 +1,66 @@ +package com.yapp.muckpot.domains.user.controller.dto + +import com.yapp.muckpot.common.Location +import com.yapp.muckpot.common.constants.ONLY_NAVER +import com.yapp.muckpot.common.constants.PASSWORD_PATTERN_INVALID +import com.yapp.muckpot.common.constants.PW_PATTERN +import com.yapp.muckpot.common.enums.Gender +import com.yapp.muckpot.domains.user.entity.MuckPotUser +import com.yapp.muckpot.domains.user.enums.JobGroupMain +import io.swagger.annotations.ApiModel +import io.swagger.annotations.ApiModelProperty +import javax.validation.constraints.Pattern +import javax.validation.constraints.Size + +@ApiModel(value = "회원가입") +data class SignUpRequest( + @field:ApiModelProperty(notes = "이메일", required = true, example = "co@naver.com") + @field:Pattern(regexp = ONLY_NAVER, message = "현재 버전은 네이버 사우만 이용 가능합니다.") + val email: String, + + @field:ApiModelProperty(notes = "비밀번호", required = true, example = "abc12345") + @field:Pattern( + regexp = PW_PATTERN, + message = PASSWORD_PATTERN_INVALID + ) + val password: String, + + @field:ApiModelProperty(notes = "닉네임", required = true, example = "맛도리") + @field:Size(min = 2, max = 10, message = "{min}~{max}자로 입력해 주세요.") + val nickname: String, + + @field:ApiModelProperty(notes = "직군 대분류", required = true, example = "개발") + val jobGroupMain: String, + + @field:ApiModelProperty(notes = "직군 소분류", required = false, example = "백엔드") + @field:Size(max = 10, message = "{max}자 이하로 입력해 주세요.") + val jobGroupSub: String?, + + @field:ApiModelProperty(notes = "먹팟 위치 이름", required = true, example = "삼성전자 본사") + val locationName: String, + + @field:ApiModelProperty(notes = "x 좌표", required = true, example = "0.0") + val x: Double, + + @field:ApiModelProperty(notes = "y 좌표", required = true, example = "0.0") + val y: Double, + + @field:ApiModelProperty(notes = "성별", required = true, example = "WOMEN") + val gender: Gender, + + @field:ApiModelProperty(notes = "출생년도", required = true, example = "1987") + val yearOfBirth: Int +) { + fun toUser(jobGroupMain: JobGroupMain, encodePw: String): MuckPotUser { + return MuckPotUser( + email = email, + password = encodePw, + nickName = nickname, + gender = gender, + yearOfBirth = yearOfBirth, + mainCategory = jobGroupMain, + subCategory = jobGroupSub, + location = Location(locationName, x, y) + ) + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/UserResponse.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/UserResponse.kt new file mode 100644 index 00000000..69d5e962 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/UserResponse.kt @@ -0,0 +1,14 @@ +package com.yapp.muckpot.domains.user.controller.dto + +import com.yapp.muckpot.domains.user.entity.MuckPotUser + +data class UserResponse( + val userId: Long, + val nickName: String = "" +) { + companion object { + fun of(user: MuckPotUser): UserResponse { + return UserResponse(user.id ?: 0, user.nickName) + } + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/VerifyEmailAuthRequest.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/VerifyEmailAuthRequest.kt new file mode 100644 index 00000000..7862c686 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/controller/dto/VerifyEmailAuthRequest.kt @@ -0,0 +1,16 @@ +package com.yapp.muckpot.domains.user.controller.dto + +import io.swagger.annotations.ApiModel +import io.swagger.annotations.ApiModelProperty +import javax.validation.constraints.Pattern + +@ApiModel(value = "이메일 인증 검증") +data class VerifyEmailAuthRequest( + // TODO: 개발 서버에서 메일 정상 작동 확인 후 삼성전자로 변경 필요! + // @field:Pattern(regexp = "^[A-Za-z0-9._%+-]+@samsung\\.com\$", message = "현재 버전은 삼성전자 사우만 이용 가능합니다.") + @field:ApiModelProperty(notes = "이메일", required = true, example = "co@naver.com") + @field:Pattern(regexp = "^[A-Za-z0-9._%+-]+@naver\\.com\$", message = "현재 버전은 네이버 사우만 이용 가능합니다.") // for test + val email: String, + @field:ApiModelProperty(notes = "인증 번호", required = true, example = "123456") + val verificationCode: String +) diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/service/JwtService.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/service/JwtService.kt new file mode 100644 index 00000000..5df30ce8 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/service/JwtService.kt @@ -0,0 +1,144 @@ +package com.yapp.muckpot.domains.user.service + +import com.auth0.jwt.JWT +import com.auth0.jwt.JWTVerifier +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.interfaces.DecodedJWT +import com.fasterxml.jackson.databind.ObjectMapper +import com.yapp.muckpot.common.constants.ACCESS_TOKEN_KEY +import com.yapp.muckpot.common.constants.ACCESS_TOKEN_SECONDS +import com.yapp.muckpot.common.constants.JWT_LOGOUT_VALUE +import com.yapp.muckpot.common.constants.MS +import com.yapp.muckpot.common.constants.REFRESH_TOKEN_BASIC_SECONDS +import com.yapp.muckpot.common.constants.REFRESH_TOKEN_KEEP_SECONDS +import com.yapp.muckpot.common.constants.REFRESH_TOKEN_KEY +import com.yapp.muckpot.common.constants.USER_CLAIM +import com.yapp.muckpot.common.constants.USER_EMAIL_CLAIM +import com.yapp.muckpot.common.enums.YesNo +import com.yapp.muckpot.common.utils.CookieUtil +import com.yapp.muckpot.domains.user.controller.dto.UserResponse +import com.yapp.muckpot.domains.user.exception.UserErrorCode +import com.yapp.muckpot.exception.MuckPotException +import com.yapp.muckpot.redis.RedisService +import mu.KLogging +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.util.* + +@Service +class JwtService( + private val objectMapper: ObjectMapper, + private val redisService: RedisService, + @Value("\${jwt.issuer}") + private val issuer: String, + @Value("\${jwt.secret-key}") + private val secretKey: String +) { + private val log = KLogging().logger + private val algorithm: Algorithm by lazy { Algorithm.HMAC512(secretKey) } + private val jwtVerifier: JWTVerifier by lazy { JWT.require(algorithm).build() } + + fun generateAccessToken(response: UserResponse): String { + val jwtBuilder = JWT.create() + .withIssuer(issuer) + .withClaim(USER_CLAIM, objectMapper.writeValueAsString(response)) + .withExpiresAt(Date(Date().time + ACCESS_TOKEN_SECONDS * MS)) + return jwtBuilder.sign(algorithm) + } + + fun generateRefreshToken(email: String, expiredSeconds: Long): String { + val jwtBuilder = JWT.create() + .withIssuer(issuer) + .withClaim(USER_EMAIL_CLAIM, email) + .withIssuedAt(Date()) + .withExpiresAt(Date(Date().time + expiredSeconds * MS)) + return jwtBuilder.sign(algorithm) + } + + /** + * 현재 HttpServletRequest의 AccessToken을 찾아 유저 정보 반환 + * + * @return UserResponse? 유저 정보가 없는 경우 null 반환 + */ + fun getCurrentUserClaim(): UserResponse? { + return try { + val token = CookieUtil.getToken(ACCESS_TOKEN_KEY) + if (isBlackListToken(token)) { + throw MuckPotException(UserErrorCode.IS_BLACKLIST_TOKEN) + } + val decodedJwt = jwtVerifier.verify(token) + objectMapper.readValue(decodedJwt.getClaim(USER_CLAIM).asString(), UserResponse::class.java) + } catch (exception: Exception) { + log.debug(exception) { exception.message } + null + } + } + + fun getRefreshTokenSeconds(keep: YesNo): Long { + return if (keep == YesNo.Y) { + REFRESH_TOKEN_KEEP_SECONDS + } else { + REFRESH_TOKEN_BASIC_SECONDS + } + } + + fun allTokenClear(): Boolean { + return try { + val accessToken = CookieUtil.getToken(ACCESS_TOKEN_KEY) + val decodedRefreshToken = jwtVerifier.verify(CookieUtil.getToken(REFRESH_TOKEN_KEY)) + val decodedAccessToken = jwtVerifier.verify(accessToken) + val email = decodedRefreshToken.getClaim(USER_EMAIL_CLAIM).asString() + redisService.setDataExpireWithNewest(accessToken, JWT_LOGOUT_VALUE, this.getTokenExpirationDuration(decodedAccessToken)) + redisService.deleteData(email) + true + } catch (exception: Exception) { + log.debug(exception) { exception.message } + false + } + } + + private fun isBlackListToken(token: String): Boolean { + return redisService.getData(token) != null + } + + private fun getTokenExpirationDuration(decodedJwt: DecodedJWT): Long { + return (decodedJwt.expiresAt.time - Date().time) / MS + } + + /** + * 현재 HttpServletRequest의 RefreshToken 찾아 이메일 정보 반환 + * + * @return Email? 유저 정보가 없는 경우 null 반환 + */ + fun getCurrentUserEmail(refreshToken: String): String? { + return try { + val decodedRefreshToken = jwtVerifier.verify(refreshToken) + decodedRefreshToken.getClaim(USER_EMAIL_CLAIM).asString() + } catch (exception: Exception) { + log.debug(exception) { "이메일 정보를 찾을 수 없습니다." } + null + } + } + + fun getLeftExpirationTime(token: String): Long { + val decodedToken = jwtVerifier.verify(token) + return getTokenExpirationDuration(decodedToken) + } + + fun generateNewRefreshFromOldRefresh(email: String, oldRefreshToken: String): String { + val decodedOldRefreshToken = jwtVerifier.verify(oldRefreshToken) + val expiredAt = decodedOldRefreshToken.expiresAt + val issuedAt = decodedOldRefreshToken.issuedAt + val jwtBuilder = JWT.create() + .withIssuer(issuer) + .withClaim(USER_EMAIL_CLAIM, email) + .withIssuedAt(issuedAt) + .withExpiresAt(expiredAt) + return jwtBuilder.sign(algorithm) + } + + fun isTokenExpired(token: String): Boolean { + val decodedToken = JWT.decode(token) + return decodedToken.expiresAt <= Date() + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/service/UserService.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/service/UserService.kt new file mode 100644 index 00000000..dbfb0314 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/domains/user/service/UserService.kt @@ -0,0 +1,111 @@ +package com.yapp.muckpot.domains.user.service + +import com.yapp.muckpot.common.constants.ACCESS_TOKEN_KEY +import com.yapp.muckpot.common.constants.ACCESS_TOKEN_SECONDS +import com.yapp.muckpot.common.constants.REFRESH_TOKEN_KEY +import com.yapp.muckpot.common.utils.CookieUtil +import com.yapp.muckpot.common.utils.RandomCodeUtil +import com.yapp.muckpot.domains.user.controller.dto.EmailAuthResponse +import com.yapp.muckpot.domains.user.controller.dto.LoginRequest +import com.yapp.muckpot.domains.user.controller.dto.SendEmailAuthRequest +import com.yapp.muckpot.domains.user.controller.dto.SignUpRequest +import com.yapp.muckpot.domains.user.controller.dto.UserResponse +import com.yapp.muckpot.domains.user.controller.dto.VerifyEmailAuthRequest +import com.yapp.muckpot.domains.user.enums.JobGroupMain +import com.yapp.muckpot.domains.user.exception.UserErrorCode +import com.yapp.muckpot.domains.user.repository.MuckPotUserRepository +import com.yapp.muckpot.email.EmailService +import com.yapp.muckpot.exception.MuckPotException +import com.yapp.muckpot.redis.RedisService +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UserService( + private val userRepository: MuckPotUserRepository, + private val jwtService: JwtService, + private val redisService: RedisService, + private val passwordEncoder: PasswordEncoder, + private val emailService: EmailService +) { + val THIRTY_MINS: Long = 60 * 30L + + @Transactional + fun login(request: LoginRequest): UserResponse { + userRepository.findByEmail(request.email)?.let { + if (!passwordEncoder.matches(request.password, it.password)) { + throw MuckPotException(UserErrorCode.LOGIN_FAIL) + } + val response = UserResponse.of(it) + val refreshTokenSeconds = jwtService.getRefreshTokenSeconds(request.keep) + val accessToken = jwtService.generateAccessToken(response) + val refreshToken = jwtService.generateRefreshToken(request.email, refreshTokenSeconds) + + redisService.setDataExpireWithNewest(request.email, refreshToken, refreshTokenSeconds) + CookieUtil.addHttpOnlyCookie(ACCESS_TOKEN_KEY, accessToken, ACCESS_TOKEN_SECONDS.toInt()) + CookieUtil.addHttpOnlyCookie(REFRESH_TOKEN_KEY, refreshToken, refreshTokenSeconds.toInt()) + return response + } ?: run { + throw MuckPotException(UserErrorCode.LOGIN_FAIL) + } + } + + @Transactional + fun sendEmailAuth(request: SendEmailAuthRequest): EmailAuthResponse { + userRepository.findByEmail(request.email)?.let { + throw MuckPotException(UserErrorCode.ALREADY_EXISTS_USER) + } ?: run { + val authKey = RandomCodeUtil.generateRandomCode() + emailService.sendAuthMail(authKey = authKey, to = request.email) + redisService.setDataExpireWithNewest(key = request.email, value = authKey, duration = THIRTY_MINS) + return EmailAuthResponse(authKey) + } + } + + @Transactional + fun verifyEmailAuth(request: VerifyEmailAuthRequest) { + val authKey = redisService.getData(request.email) + authKey?.let { + if (it != request.verificationCode) { + throw MuckPotException(UserErrorCode.EMAIL_VERIFY_FAIL) + } + } ?: run { + throw MuckPotException(UserErrorCode.NO_VERIFY_CODE) + } + } + + @Transactional + fun signUp(request: SignUpRequest): UserResponse { + userRepository.findByEmail(request.email)?.let { + throw MuckPotException(UserErrorCode.ALREADY_EXISTS_USER) + } ?: run { + val jobGroupMain = JobGroupMain.findByKorName(request.jobGroupMain) + val encodePw = passwordEncoder.encode(request.password) + val user = userRepository.save(request.toUser(jobGroupMain, encodePw)) + return UserResponse.of(user) + } + } + + @Transactional + fun reissueJwt(refreshToken: String, accessToken: String) { + if (!jwtService.isTokenExpired(accessToken)) throw MuckPotException(UserErrorCode.FAIL_JWT_REISSUE) + val email = jwtService.getCurrentUserEmail(refreshToken) + ?: throw MuckPotException(UserErrorCode.FAIL_JWT_REISSUE) + val redisToken = redisService.getData(email) ?: throw MuckPotException(UserErrorCode.FAIL_JWT_REISSUE) + if (redisToken != refreshToken) throw MuckPotException(UserErrorCode.FAIL_JWT_REISSUE) + + userRepository.findByEmail(email)?.let { user -> + val leftRefreshTokenSeconds = jwtService.getLeftExpirationTime(refreshToken) + val response = UserResponse.of(user) + val newAccessToken = jwtService.generateAccessToken(response) + val newRefreshToken = jwtService.generateNewRefreshFromOldRefresh(user.email, refreshToken) + + redisService.setDataExpireWithNewest(user.email, newRefreshToken, leftRefreshTokenSeconds) + CookieUtil.addHttpOnlyCookie(ACCESS_TOKEN_KEY, newAccessToken, ACCESS_TOKEN_SECONDS.toInt()) + CookieUtil.addHttpOnlyCookie(REFRESH_TOKEN_KEY, newRefreshToken, leftRefreshTokenSeconds.toInt()) + } ?: run { + throw MuckPotException(UserErrorCode.USER_NOT_FOUND) + } + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/exception/GlobalExceptionHandler.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/exception/GlobalExceptionHandler.kt new file mode 100644 index 00000000..956ca515 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/exception/GlobalExceptionHandler.kt @@ -0,0 +1,66 @@ +package com.yapp.muckpot.exception + +import com.fasterxml.jackson.databind.exc.InvalidFormatException +import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException +import com.yapp.muckpot.common.ResponseDto +import mu.KLogging +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatus.valueOf +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import javax.validation.ValidationException + +@RestControllerAdvice +class GlobalExceptionHandler { + private val log = KLogging().logger + + @ExceptionHandler(MuckPotException::class) + fun muckpotGlobalExceptionHandler(exception: MuckPotException): ResponseEntity { + log.error(exception) { "" } + val responseDto = exception.errorCode.toResponseDto() + return ResponseEntity.status(valueOf(responseDto.status)).body(responseDto) + } + + @ExceptionHandler(IllegalArgumentException::class) + fun badRequestErrorHandler(exception: Exception): ResponseEntity { + log.error(exception) { "" } + return ResponseEntity.badRequest() + .body(ResponseDto(HttpStatus.BAD_REQUEST.value(), exception.message)) + } + + @ExceptionHandler(value = [MethodArgumentNotValidException::class, ValidationException::class]) + fun methodArgumentNotValidExceptionHandler(exception: Exception): ResponseEntity { + log.error(exception) { "" } + var message = exception.message + if (exception is MethodArgumentNotValidException && exception.hasErrors()) { + message = exception.allErrors.firstOrNull()?.defaultMessage ?: exception.message + } + return ResponseEntity.badRequest() + .body(ResponseDto(HttpStatus.BAD_REQUEST.value(), message)) + } + + @ExceptionHandler(value = [HttpMessageNotReadableException::class]) + fun httpMessageNotReadableExceptionHandler(exception: HttpMessageNotReadableException): ResponseEntity { + log.error(exception) { "" } + var message = exception.message + val cause = exception.cause + if (cause is MissingKotlinParameterException) { + val name = cause.parameter.name + message = "{$name}값이 필요합니다." + } else if (cause is InvalidFormatException) { + message = "${cause.value}은(는) 유효하지 않은 포맷입니다." + } + return ResponseEntity.badRequest() + .body(ResponseDto(HttpStatus.BAD_REQUEST.value(), message)) + } + + @ExceptionHandler(Exception::class) + fun internalServerErrorHandler(exception: Exception): ResponseEntity { + log.error(exception) { "" } + return ResponseEntity.internalServerError() + .body(ResponseDto(HttpStatus.INTERNAL_SERVER_ERROR.value(), exception.message)) + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/exception/MuckPotException.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/exception/MuckPotException.kt new file mode 100644 index 00000000..45cbb759 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/exception/MuckPotException.kt @@ -0,0 +1,8 @@ +package com.yapp.muckpot.exception + +import com.yapp.muckpot.common.BaseErrorCode +import java.lang.RuntimeException + +class MuckPotException( + val errorCode: BaseErrorCode +) : RuntimeException() diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/filter/AuthenticationFailHandler.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/filter/AuthenticationFailHandler.kt new file mode 100644 index 00000000..7a26e271 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/filter/AuthenticationFailHandler.kt @@ -0,0 +1,26 @@ +package com.yapp.muckpot.filter + +import com.yapp.muckpot.common.utils.ResponseWriter +import mu.KLogging +import org.springframework.http.HttpStatus +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class AuthenticationFailHandler : AuthenticationEntryPoint { + private val log = KLogging().logger + + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException + ) { + log.debug(authException) { NO_AUTH } + ResponseWriter.writeResponse(response, HttpStatus.FORBIDDEN, NO_AUTH) + } + + companion object { + private const val NO_AUTH = "권한이 존재하지 않습니다." + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/filter/JwtAuthorizationFilter.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/filter/JwtAuthorizationFilter.kt new file mode 100644 index 00000000..b89f8793 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/filter/JwtAuthorizationFilter.kt @@ -0,0 +1,37 @@ +package com.yapp.muckpot.filter + +import com.yapp.muckpot.common.constants.LOGIN_URL +import com.yapp.muckpot.common.constants.SIGN_UP_URL +import com.yapp.muckpot.common.security.AuthenticationUser +import com.yapp.muckpot.common.utils.ResponseWriter +import com.yapp.muckpot.domains.user.service.JwtService +import org.springframework.http.HttpStatus +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter +import javax.servlet.FilterChain +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class JwtAuthorizationFilter(private val jwtService: JwtService) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + jwtService.getCurrentUserClaim()?.let { + val requestURI = request.requestURI.toString() + if (LOGIN_URL == requestURI || SIGN_UP_URL == requestURI) { + ResponseWriter.writeResponse(response, HttpStatus.BAD_REQUEST, "이미 로그인한 유저 입니다.") + return + } + val authentication = AuthenticationUser(it.userId, it, true, LOGIN_USER_AUTHORITIES) + SecurityContextHolder.getContext().authentication = authentication + } + filterChain.doFilter(request, response) + } + + companion object { + private val LOGIN_USER_AUTHORITIES = listOf(SimpleGrantedAuthority("ROLE_USER")) + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/filter/JwtLogoutHandler.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/filter/JwtLogoutHandler.kt new file mode 100644 index 00000000..f480c65d --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/filter/JwtLogoutHandler.kt @@ -0,0 +1,19 @@ +package com.yapp.muckpot.filter + +import com.yapp.muckpot.common.utils.ResponseWriter +import com.yapp.muckpot.domains.user.service.JwtService +import org.springframework.http.HttpStatus +import org.springframework.security.core.Authentication +import org.springframework.security.web.authentication.logout.LogoutHandler +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class JwtLogoutHandler( + private val jwtService: JwtService +) : LogoutHandler { + override fun logout(request: HttpServletRequest, response: HttpServletResponse, authentication: Authentication?) { + if (!jwtService.allTokenClear()) { + ResponseWriter.writeResponse(response, HttpStatus.BAD_REQUEST, "로그아웃 실패.") + } + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestController.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestController.kt new file mode 100644 index 00000000..84ee34b2 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestController.kt @@ -0,0 +1,69 @@ +package com.yapp.muckpot.test + +import com.yapp.muckpot.common.ResponseDto +import com.yapp.muckpot.common.constants.TEST_SAMPLE +import com.yapp.muckpot.common.utils.ResponseEntityUtil +import com.yapp.muckpot.redis.RedisService +import io.swagger.annotations.Api +import io.swagger.annotations.ApiOperation +import io.swagger.annotations.ApiResponse +import io.swagger.annotations.ApiResponses +import io.swagger.annotations.Example +import io.swagger.annotations.ExampleProperty +import mu.KLogging +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import javax.validation.Valid + +@RestController +@Api(tags = ["테스트 api"], description = "스웨거 사용 기본 템플릿") +@RequestMapping("/api") +class TestController( + private val testService: TestService, + private val redisService: RedisService +) { + private val log = KLogging().logger + + @GetMapping("/v1/test") + @ApiOperation(value = "Get 테스트") + @ApiResponses( + value = [ + ApiResponse( + code = 200, + examples = Example( + ExampleProperty( + value = TEST_SAMPLE, + mediaType = MediaType.APPLICATION_JSON_VALUE + ) + ), + message = "성공" + ) + ] + ) + fun getTest(): ResponseEntity { + log.info { "info" } + log.warn { "warn" } + log.error { "error" } + return ResponseEntityUtil.ok(testService.test()) + } + + @PostMapping("/v1/test") + @ApiOperation(value = "Post 테스트") + fun postTest( + @RequestBody @Valid + request: TestRequest + ): ResponseEntity { + return ResponseEntityUtil.created(testService.save(request)) + } + + @PostMapping("/v1/test/redis") + @ApiOperation(value = "redis 테스트") + fun redisTest(): String { + return redisService.redisString() + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestRequest.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestRequest.kt new file mode 100644 index 00000000..87adc5bb --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestRequest.kt @@ -0,0 +1,15 @@ +package com.yapp.muckpot.test + +import io.swagger.annotations.ApiModel +import io.swagger.annotations.ApiModelProperty +import javax.validation.constraints.NotBlank + +@ApiModel(value = "테스트 Request") +data class TestRequest( + @field:ApiModelProperty(notes = "이름", required = true, example = "홍길동") + @field:NotBlank(message = "이름을 입력해 주세요.") + var name: String?, + + @field:ApiModelProperty(notes = "나이", example = "10") + var age: Int = 0 +) diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestResponse.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestResponse.kt new file mode 100644 index 00000000..be85198a --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestResponse.kt @@ -0,0 +1,24 @@ +package com.yapp.muckpot.test + +import com.fasterxml.jackson.annotation.JsonFormat +import com.yapp.muckpot.domains.test.entity.TestEntity +import io.swagger.annotations.ApiModel +import io.swagger.annotations.ApiModelProperty +import java.time.LocalDate + +@ApiModel(value = "테스트 Response") +data class TestResponse( + @field:ApiModelProperty(notes = "테스트Dto 아이디", example = "test") + var id: Long? = null, + @field:ApiModelProperty(notes = "테스트Dto 이름", example = "test") + var name: String = "test", + @field:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyyMMdd") + @field:ApiModelProperty(notes = "현재 날짜", example = "20230515") + var currentTime: LocalDate +) { + companion object { + fun of(testEntity: TestEntity): TestResponse { + return TestResponse(testEntity.id, testEntity.name, testEntity.createdAt.toLocalDate()) + } + } +} diff --git a/muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestService.kt b/muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestService.kt new file mode 100644 index 00000000..1a23c980 --- /dev/null +++ b/muckpot-api/src/main/kotlin/com/yapp/muckpot/test/TestService.kt @@ -0,0 +1,24 @@ +package com.yapp.muckpot.test + +import com.yapp.muckpot.domains.test.entity.TestEntity +import com.yapp.muckpot.domains.test.repository.TestQuerydslRepository +import com.yapp.muckpot.domains.test.repository.TestRepository +import org.springframework.stereotype.Service + +@Service +class TestService( + private val testRepository: TestRepository, + private val querydslRepository: TestQuerydslRepository +) { + + fun test(): TestResponse { + val testEntity = TestEntity(null, "querydsl") + testRepository.save(testEntity) + testEntity.loggingTest() + return TestResponse.of(querydslRepository.getTestByName("querydsl") ?: TestEntity()) + } + + fun save(request: TestRequest): Long? { + return testRepository.save(TestEntity(null, request.name ?: "test")).id + } +} diff --git a/muckpot-api/src/main/resources/application.yml b/muckpot-api/src/main/resources/application.yml new file mode 100644 index 00000000..65a11069 --- /dev/null +++ b/muckpot-api/src/main/resources/application.yml @@ -0,0 +1,27 @@ +spring: + profiles: + include: + - domain + - infra + + mvc: + pathmatch: + matching-strategy: ant_path_matcher + + jpa: + open-in-view: false + +jwt: + issuer: "muckpot" + secret-key: ${JWT_SECRET_KEY:secret} + +logging: + config: classpath:logback-${spring.config.activate.on-profile}.xml + +api: + option: + permit-all: false # 배포: false, 테스트 : true + allowed-origins: + ${LOCAL_ORIGIN:localOrigin}, + ${PROD_ORIGIN:prodOrigin}, + ${PROD_ORIGIN_2:prodOrigin2} diff --git a/muckpot-api/src/main/resources/logback-dev.xml b/muckpot-api/src/main/resources/logback-dev.xml new file mode 100644 index 00000000..0645b145 --- /dev/null +++ b/muckpot-api/src/main/resources/logback-dev.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + UTF-8 + ${CONSOLE_LOG_PATTERN} + + + + + ${INFO_LOG_PATH}/${INFO_FILE_NAME} + + INFO + ACCEPT + DENY + + + ${INFO_LOG_PATH}/${ROLLING_FILE_NAME_PATTERN} + + 10MB + + 15 + + + ${FILE_LOG_PATTERN} + + + + + ${WARN_LOG_PATH}/${WARN_FILE_NAME} + + WARN + ACCEPT + DENY + + + ${WARN_LOG_PATH}/${ROLLING_FILE_NAME_PATTERN} + + 10MB + + 15 + + + ${FILE_LOG_PATTERN} + + + + + ${ERROR_LOG_PATH}/${ERROR_FILE_NAME} + + ERROR + ACCEPT + DENY + + + ${ERROR_LOG_PATH}/${ROLLING_FILE_NAME_PATTERN} + + 10MB + + 15 + + + ${FILE_LOG_PATTERN} + + + + + + + + + + + \ No newline at end of file diff --git a/muckpot-api/src/main/resources/logback-local.xml b/muckpot-api/src/main/resources/logback-local.xml new file mode 100644 index 00000000..0a6c62d8 --- /dev/null +++ b/muckpot-api/src/main/resources/logback-local.xml @@ -0,0 +1,19 @@ + + + + + + + + + UTF-8 + ${CONSOLE_LOG_PATTERN} + + + + + + + + \ No newline at end of file diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/ApplicationTest.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/ApplicationTest.kt new file mode 100644 index 00000000..606db15a --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/ApplicationTest.kt @@ -0,0 +1,9 @@ +package com.yapp.muckpot + +import io.kotest.core.spec.style.StringSpec +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class ApplicationTest : StringSpec({ + "contextLoads" {} +}) diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/common/TimeUtilTest.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/common/TimeUtilTest.kt new file mode 100644 index 00000000..9982e803 --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/common/TimeUtilTest.kt @@ -0,0 +1,115 @@ +package com.yapp.muckpot.common + +import com.yapp.muckpot.common.constants.A_DAY_AGO +import com.yapp.muckpot.common.constants.KR_MM_DD_E +import com.yapp.muckpot.common.constants.KR_YYYY_MM_DD +import com.yapp.muckpot.common.constants.N_HOURS_AGO +import com.yapp.muckpot.common.constants.N_MINUTES_AGO +import com.yapp.muckpot.common.constants.TODAY_KR +import com.yapp.muckpot.common.constants.TOMORROW_KR +import com.yapp.muckpot.common.constants.a_hhmm +import io.kotest.core.spec.style.FunSpec +import io.kotest.data.row +import io.kotest.datatest.withData +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import java.time.LocalDateTime + +class TimeUtilTest : FunSpec({ + val today = LocalDateTime.now() + val yesterday = today.minusDays(1) + val tomorrow = today.plusDays(1) + + context("오늘 내일 테스트") { + withData( + nameFn = { "${it.b} 응답하는 경우" }, + row(today, TODAY_KR), + row(tomorrow, TOMORROW_KR), + row(yesterday, null) + ) { (localDate, expect) -> + // when & then + TimeUtil.isTodayOrTomorrow(localDate.toLocalDate()) shouldBe expect + } + } + + context("시간 정책 - 60분 미만") { + withData( + nameFn = { "${it.a}분 전" }, + row(0L), + row(1L), + row(59L) + ) { (minute) -> + // when & then + TimeUtil.formatElapsedTime( + today.minusMinutes(minute) + ) shouldBe N_MINUTES_AGO.format(minute) + } + } + + context("시간 정책 - 1시간 이상 ~ 24시간 미만") { + withData( + nameFn = { "${it.a}시간 전" }, + row(1L), + row(23L) + ) { (hour) -> + // when & then + TimeUtil.formatElapsedTime( + today.minusHours(hour) + ) shouldBe N_HOURS_AGO.format(hour) + } + } + + context("시간 정책 - 24시간 이상 ~ 48시간 미만") { + withData( + nameFn = { "${it.a}시간 이전" }, + row(24L), + row(47L) + ) { (hour) -> + // when & then + TimeUtil.formatElapsedTime( + today.minusHours(hour) + ) shouldBe A_DAY_AGO + } + } + + context("시간 정책 - 48시간 이상") { + withData( + nameFn = { "${it.a}시간 이전" }, + row(48L), + row(100L) + ) { (hour) -> + val localDateTime = today.minusHours(hour) + // when & then + TimeUtil.formatElapsedTime(localDateTime) shouldBe localDateTime.toLocalDate().toString() + } + } + + context("a_hhmm 포 테스트") { + withData( + nameFn = { "${it.a}:${it.b}" }, + row(0, 0, "오전"), + row(11, 59, "오전"), + row(12, 0, "오후"), + row(23, 59, "오후") + ) { (hour, minute, amPm) -> + // given + val localDateTime = LocalDateTime.of(2023, 1, 1, hour, minute) + // when & then + TimeUtil.localeKoreanFormatting(localDateTime, a_hhmm) shouldContain amPm + } + } + + test("KR_MM_DD_E 포맷 테스트") { + // given + val localDateTime = LocalDateTime.of(2020, 1, 2, 12, 0) + // when & then + TimeUtil.localeKoreanFormatting(localDateTime, KR_MM_DD_E) shouldBe "01월 02일 (목)" + } + + test("KR_YYYY_MM_DD 포맷 테스트") { + // given + val localDateTime = LocalDateTime.of(2020, 1, 2, 12, 0) + // when & then + TimeUtil.localeKoreanFormatting(localDateTime, KR_YYYY_MM_DD) shouldBe "2020년 01월 02일" + } +}) diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/common/redisson/ConcurrencyHelper.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/common/redisson/ConcurrencyHelper.kt new file mode 100644 index 00000000..b4286f8e --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/common/redisson/ConcurrencyHelper.kt @@ -0,0 +1,30 @@ +package com.yapp.muckpot.common.redisson + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong + +class ConcurrencyHelper { + companion object { + private const val NUMBER_OF_THREADS = 20 + private const val NUMBER_OF_THREAD_POOL = 20 + + fun execute(operation: () -> Any?, successCount: AtomicLong) { + val executorService = Executors.newFixedThreadPool(NUMBER_OF_THREAD_POOL) + val latch = CountDownLatch(NUMBER_OF_THREADS) + for (i in 1..NUMBER_OF_THREADS) { + executorService.submit { + try { + operation() + successCount.getAndIncrement() + } catch (e: Throwable) { + Exception(e) + } finally { + latch.countDown() + } + } + } + latch.await() + } + } +} diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/common/redisson/DistributedLockAopTest.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/common/redisson/DistributedLockAopTest.kt new file mode 100644 index 00000000..d479f9e4 --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/common/redisson/DistributedLockAopTest.kt @@ -0,0 +1,33 @@ +package com.yapp.muckpot.common.redisson + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.stereotype.Service +import java.util.concurrent.atomic.AtomicLong + +@SpringBootTest +class DistributedLockAopTest @Autowired constructor( + private val redissonService: RedissonService +) : StringSpec({ + + "분산락 적용 동시성 테스트" { + val successCount = AtomicLong() + ConcurrencyHelper.execute( + { redissonService.test(1) }, + successCount + ) + redissonService.apply shouldBe successCount.toInt() + } +}) + +@Service +class RedissonService( + var apply: Int = 0 +) { + @DistributedLock("id", "testLock") + fun test(id: Int) { + apply += 1 + } +} diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotCreateRequestTest.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotCreateRequestTest.kt new file mode 100644 index 00000000..9014962c --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotCreateRequestTest.kt @@ -0,0 +1,91 @@ +package com.yapp.muckpot.domains.board.controller.dto + +import com.yapp.muckpot.common.constants.CHAT_LINK_MAX +import com.yapp.muckpot.common.constants.CONTENT_MAX +import com.yapp.muckpot.common.constants.NOT_BLANK_COMMON +import com.yapp.muckpot.common.constants.TITLE_MAX +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.time.LocalDate +import java.time.LocalTime +import javax.validation.ConstraintViolation +import javax.validation.Validation +import javax.validation.Validator + +class MuckpotCreateRequestTest : StringSpec({ + lateinit var validator: Validator + + fun createMuckpotCreateRequest( + meetingDate: LocalDate = LocalDate.now(), + meetingTime: LocalTime = LocalTime.of(12, 1), + maxApply: Int = 10, + minAge: Int = 20, + maxAge: Int = 100, + locationName: String = "location", + locationDetail: String? = null, + x: Double = 0.0, + y: Double = 0.0, + title: String = "title", + content: String? = null, + chatLink: String = "chat_link" + ): MuckpotCreateRequest { + return MuckpotCreateRequest( + meetingDate = meetingDate, + meetingTime = meetingTime, + maxApply = maxApply, + minAge = minAge, + maxAge = maxAge, + locationName = locationName, + locationDetail = locationDetail, + x = x, + y = y, + title = title, + content = content, + chatLink = chatLink + ) + } + + beforeTest { + validator = Validation.buildDefaultValidatorFactory().validator + } + + "제목은 최대 100자" { + val request = createMuckpotCreateRequest(title = "X".repeat(TITLE_MAX + 1)) + + val violations: MutableSet> = validator.validate(request) + violations.size shouldBe 1 + for (violation in violations) { + violation.message shouldBe "제목은 $TITLE_MAX(자)를 넘을 수 없습니다." + } + } + + "내용은 최대 2000자" { + val request = createMuckpotCreateRequest(content = "X".repeat(CONTENT_MAX + 1)) + + val violations: MutableSet> = validator.validate(request) + violations.size shouldBe 1 + for (violation in violations) { + violation.message shouldBe "내용은 $CONTENT_MAX(자)를 넘을 수 없습니다." + } + } + + "링크는 최대 300자" { + val request = createMuckpotCreateRequest(chatLink = "X".repeat(CHAT_LINK_MAX + 1)) + + val violations: MutableSet> = validator.validate(request) + violations.size shouldBe 1 + for (violation in violations) { + violation.message shouldBe "링크는 $CHAT_LINK_MAX(자)를 넘을 수 없습니다." + } + } + + "chatLink는 공백이 될 수 없다." { + val request = createMuckpotCreateRequest(chatLink = " ") + + val violations: MutableSet> = validator.validate(request) + violations.size shouldBe 1 + for (violation in violations) { + violation.message shouldBe NOT_BLANK_COMMON + } + } +}) diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotDetailResponseTest.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotDetailResponseTest.kt new file mode 100644 index 00000000..d669cb75 --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotDetailResponseTest.kt @@ -0,0 +1,58 @@ +package com.yapp.muckpot.domains.board.controller.dto + +import com.yapp.muckpot.common.constants.AGE_MAX +import com.yapp.muckpot.common.constants.AGE_MIN +import com.yapp.muckpot.domains.board.dto.ParticipantReadResponse +import com.yapp.muckpot.domains.user.controller.dto.UserResponse +import com.yapp.muckpot.domains.user.enums.JobGroupMain +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class MuckpotDetailResponseTest : StringSpec({ + "로그인 유저가 참여했다면 가장 첫번째로 정렬된다." { + // given + val loginUser = UserResponse(2, "user2") + val participants = listOf( + ParticipantReadResponse(1, 1, "user1", JobGroupMain.DEVELOPMENT), + ParticipantReadResponse(1, loginUser.userId, loginUser.nickName, JobGroupMain.DEVELOPMENT), + ParticipantReadResponse(1, 3, "user3", JobGroupMain.DEVELOPMENT) + ) + val response = MuckpotDetailResponse.of(Fixture.createBoard(), participants) + // when + response.sortParticipantsByLoginUser(loginUser) + // then + response.participants[0].userId shouldBe loginUser.userId + response.participants[0].nickName shouldBe loginUser.nickName + } + + "가장 첫번째 참여한 인원이 조직장이 된다." { + // given + val participants = listOf( + ParticipantReadResponse(1, 1, "user1", JobGroupMain.DEVELOPMENT), + ParticipantReadResponse(1, 2, "user2", JobGroupMain.DEVELOPMENT), + ParticipantReadResponse(1, 3, "user3", JobGroupMain.DEVELOPMENT) + ) + // when + val response = MuckpotDetailResponse.of(Fixture.createBoard(), participants) + // then + response.participants[0].writer shouldBe true + } + + "20 ~ 100은, 나이제한이 없는 경우" { + // given + val participants = listOf( + ParticipantReadResponse(1, 1, "user1", JobGroupMain.DEVELOPMENT) + ) + // when + val response = MuckpotDetailResponse.of( + Fixture.createBoard( + minAge = AGE_MIN, + maxAge = AGE_MAX + ), + participants + ) + // then + response.minAge shouldBe null + response.maxAge shouldBe null + } +}) diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotUpdateRequestTest.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotUpdateRequestTest.kt new file mode 100644 index 00000000..f80e169c --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/controller/dto/MuckpotUpdateRequestTest.kt @@ -0,0 +1,36 @@ +package com.yapp.muckpot.domains.board.controller.dto + +import com.yapp.muckpot.domains.board.exception.BoardErrorCode +import com.yapp.muckpot.exception.MuckPotException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.time.LocalDate +import java.time.LocalTime + +class MuckpotUpdateRequestTest : StringSpec({ + lateinit var request: MuckpotUpdateRequest + + beforeEach { + request = MuckpotUpdateRequest( + meetingDate = LocalDate.now(), + meetingTime = LocalTime.of(12, 15), + maxApply = 5, + minAge = 20, + maxAge = 100, + locationName = "location", + locationDetail = null, + x = 0.0, + y = 0.0, + title = "title", + content = null, + chatLink = "chat_link" + ) + } + + "현재 먹팟 참여인원 미만으로 변경할 수 없다." { + shouldThrow { + request.updateBoard(Fixture.createBoard(currentApply = request.maxApply + 1)) + }.errorCode shouldBe BoardErrorCode.MAX_APPLY_UPDATE_FAIL + } +}) diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/service/BoardServiceMockTest.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/service/BoardServiceMockTest.kt new file mode 100644 index 00000000..9c4730b4 --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/service/BoardServiceMockTest.kt @@ -0,0 +1,114 @@ +package com.yapp.muckpot.domains.board.service + +import Fixture +import com.ninjasquad.springmockk.MockkBean +import com.yapp.muckpot.common.dto.CursorPaginationRequest +import com.yapp.muckpot.domains.board.dto.ParticipantReadResponse +import com.yapp.muckpot.domains.board.repository.BoardQuerydslRepository +import com.yapp.muckpot.domains.board.repository.BoardRepository +import com.yapp.muckpot.domains.board.repository.ParticipantQuerydslRepository +import com.yapp.muckpot.domains.user.controller.dto.UserResponse +import com.yapp.muckpot.domains.user.enums.MuckPotStatus +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.every +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.repository.findByIdOrNull +import java.time.LocalDateTime + +@SpringBootTest +class BoardServiceMockTest @Autowired constructor( + private val boardService: BoardService, + @MockkBean + private val boardQuerydslRepository: BoardQuerydslRepository, + @MockkBean + private val participantQuerydslRepository: ParticipantQuerydslRepository, + @MockkBean + private val boardRepository: BoardRepository +) : FunSpec({ + context("findAllMuckpot 테스트") { + val allBoardSize = 3 + val allBoard = listOf( + Fixture.createBoard(id = 1, title = "board1").apply { meetingTime = LocalDateTime.MIN }, + Fixture.createBoard(id = 2, title = "board2"), + Fixture.createBoard(id = 3, title = "board3") + ) + val participantResponses = listOf( + ParticipantReadResponse(boardId = 1, userId = 1, nickName = "user1"), + ParticipantReadResponse(boardId = 1, userId = 2, nickName = "user2"), + ParticipantReadResponse(boardId = 1, userId = 3, nickName = "user3"), + ParticipantReadResponse(boardId = 1, userId = 4, nickName = "user4"), + ParticipantReadResponse(boardId = 1, userId = 5, nickName = "user5"), + ParticipantReadResponse(boardId = 1, userId = 6, nickName = "user6"), + ParticipantReadResponse(boardId = 1, userId = 7, nickName = "user7"), + ParticipantReadResponse(boardId = 1, userId = 8, nickName = "user8"), + ParticipantReadResponse(boardId = 2, userId = 1, nickName = "user1"), + ParticipantReadResponse(boardId = 2, userId = 2, nickName = "user2"), + ParticipantReadResponse(boardId = 3, userId = 1, nickName = "user1") + ) + beforeTest { + // given + every { boardQuerydslRepository.findAllWithPagination(any(), any()) } returns allBoard + every { participantQuerydslRepository.findByBoardIds(any()) } returns participantResponses + } + test("모든 먹팟 조회 성공") { + // when + val actual = boardService.findAllMuckpot(CursorPaginationRequest(null, allBoardSize.toLong())) + // then + actual.list shouldHaveSize 3 + actual.lastId shouldBe allBoard.last().id + } + + test("참가자가 6명을 넘어가면 마지막에 외N 명으로 응답한다") { + // when + val actual = boardService.findAllMuckpot(CursorPaginationRequest(null, allBoardSize.toLong())) + // then + actual.list[0].participants.last().nickName shouldBe "외 3명" + } + + test("현재 시간 이전인 경우 모집마감 으로 바꾸어 응답한다.") { + + // when + val actual = boardService.findAllMuckpot(CursorPaginationRequest(null, allBoardSize.toLong())) + // then + actual.list[0].status shouldBe MuckPotStatus.DONE.korNm + } + } + + context("findBoardDetailAndVisit 성공") { + val loginUser = UserResponse(2, "user2") + val board = Fixture.createBoard( + id = 1, + title = "board1", + meetingTime = LocalDateTime.of(2100, 12, 25, 12, 20, 30) + ).apply { + createdAt = LocalDateTime.of(2100, 12, 23, 12, 20, 30) + } + val participantResponses = listOf( + ParticipantReadResponse(boardId = 1, userId = 1, nickName = "user1"), + ParticipantReadResponse(boardId = 1, userId = 2, nickName = "user2"), + ParticipantReadResponse(boardId = 1, userId = 3, nickName = "user3") + ) + beforeTest { + // given + every { boardRepository.findByIdOrNull(any()) } returns board + every { participantQuerydslRepository.findByBoardIds(any()) } returns participantResponses + every { boardQuerydslRepository.findPrevId(any()) } returns null + every { boardQuerydslRepository.findNextId(any()) } returns null + } + + test("먹팟 상세조회 성공") { + // when + val actual = boardService.findBoardDetailAndVisit(1, loginUser) + + // then + actual.meetingDate shouldBe "12월 25일 (토)" + actual.meetingTime shouldBe "오후 12:20" + actual.createDate shouldBe "2100년 12월 23일" + actual.status shouldBe MuckPotStatus.IN_PROGRESS.korNm + actual.participants shouldHaveSize participantResponses.size + } + } +}) diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/service/BoardServiceTest.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/service/BoardServiceTest.kt new file mode 100644 index 00000000..42bdc367 --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/board/service/BoardServiceTest.kt @@ -0,0 +1,296 @@ +package com.yapp.muckpot.domains.board.service + +import Fixture +import com.yapp.muckpot.common.Location +import com.yapp.muckpot.common.enums.Gender +import com.yapp.muckpot.common.redisson.ConcurrencyHelper +import com.yapp.muckpot.domains.board.controller.dto.MuckpotCreateRequest +import com.yapp.muckpot.domains.board.controller.dto.MuckpotUpdateRequest +import com.yapp.muckpot.domains.board.entity.Participant +import com.yapp.muckpot.domains.board.exception.BoardErrorCode +import com.yapp.muckpot.domains.board.exception.ParticipantErrorCode +import com.yapp.muckpot.domains.board.repository.BoardRepository +import com.yapp.muckpot.domains.board.repository.ParticipantRepository +import com.yapp.muckpot.domains.user.controller.dto.UserResponse +import com.yapp.muckpot.domains.user.entity.MuckPotUser +import com.yapp.muckpot.domains.user.enums.JobGroupMain +import com.yapp.muckpot.domains.user.enums.MuckPotStatus +import com.yapp.muckpot.domains.user.repository.MuckPotUserRepository +import com.yapp.muckpot.exception.MuckPotException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.repository.findByIdOrNull +import java.time.LocalDate +import java.time.LocalTime +import java.util.concurrent.atomic.AtomicLong + +@SpringBootTest +class BoardServiceTest @Autowired constructor( + private val boardService: BoardService, + private val boardRepository: BoardRepository, + private val userRepository: MuckPotUserRepository, + private val participantRepository: ParticipantRepository +) : StringSpec({ + lateinit var user: MuckPotUser + var userId: Long = 0 + val createRequest = MuckpotCreateRequest( + meetingDate = LocalDate.now().plusDays(1), + meetingTime = LocalTime.of(12, 0), + maxApply = 70, + minAge = 20, + maxAge = 100, + locationName = "location", + locationDetail = null, + x = 0.0, + y = 0.0, + title = "title", + content = null, + chatLink = "chatLink" + ) + val updateRequest = MuckpotUpdateRequest( + meetingDate = LocalDate.now().plusDays(1), + meetingTime = LocalTime.of(12, 0), + maxApply = 6, + minAge = 25, + maxAge = 70, + locationName = "modify location", + locationDetail = "detail", + x = 1.0, + y = 1.0, + title = "modify title", + content = "content", + chatLink = "modify chatLink" + ) + + beforeEach { + user = userRepository.save(Fixture.createUser()) + userId = user.id!! + } + + afterEach { + participantRepository.deleteAll() + boardRepository.deleteAll() + userRepository.deleteAll() + } + + "먹팟 생성 성공" { + // when + val boardId = boardService.saveBoard(userId, createRequest) + + // then + val findBoard = boardRepository.findByIdOrNull(boardId)!! + val participant = participantRepository.findByBoard(findBoard) + + findBoard shouldNotBe null + findBoard.user.id shouldBe userId + findBoard.currentApply shouldBe 1 + participant shouldNotBe null + } + + "자신의 글은 조회수가 증가하지 않는다." { + // given + val boardId = boardService.saveBoard(userId, createRequest)!! + val loginUserInfo = UserResponse.of(user) + + // when + boardService.findBoardDetailAndVisit(boardId, loginUserInfo) + boardService.findBoardDetailAndVisit(boardId, loginUserInfo) + + // then + val findBoard = boardRepository.findByIdOrNull(boardId)!! + findBoard.views shouldBe 0 + } + + "먹팟 상세 조회시 조회수가 증가한다." { + // given + val boardId = boardService.saveBoard(userId, createRequest)!! + val otherUser = UserResponse.of(userRepository.save(Fixture.createUser())) + + // when + boardService.findBoardDetailAndVisit(boardId, otherUser) + + // then + val findBoard = boardRepository.findByIdOrNull(boardId)!! + findBoard.views shouldBe 1 + } + + "비로그인 유저도 조회수가 증가한다." { + // given + val boardId = boardService.saveBoard(userId, createRequest)!! + // when + boardService.findBoardDetailAndVisit(boardId, null) + // then + val findBoard = boardRepository.findByIdOrNull(boardId)!! + findBoard.views shouldBe 1 + } + + "이전, 이후 아이디도 함께 응답에 반환한다." { + // given + val prevBoardId = boardService.saveBoard(userId, createRequest)!! + val boardId = boardService.saveBoard(userId, createRequest)!! + val nextBoardId = boardService.saveBoard(userId, createRequest)!! + // when + val actual = boardService.findBoardDetailAndVisit(boardId, null) + // then + actual.prevId shouldBe nextBoardId + actual.nextId shouldBe prevBoardId + } + + "먹팟 수정 성공" { + // given + val boardId = boardService.saveBoard(userId, createRequest)!! + // when + boardService.updateBoard(userId, boardId, updateRequest) + // then + val actual = boardRepository.findByIdOrNull(boardId)!! + actual.maxApply shouldBe updateRequest.maxApply + actual.minAge shouldBe updateRequest.minAge + actual.maxAge shouldBe updateRequest.maxAge + actual.location.locationName shouldBe updateRequest.locationName + actual.getX() shouldBe updateRequest.x + actual.getY() shouldBe updateRequest.y + actual.title shouldBe updateRequest.title + actual.content shouldBe updateRequest.content + actual.chatLink shouldBe updateRequest.chatLink + } + + "자신의 글만 수정할 수 있다." { + // given + val otherUserId = -1L + val boardId = boardService.saveBoard(userId, createRequest)!! + // when & then + shouldThrow { + boardService.updateBoard(otherUserId, boardId, updateRequest) + }.errorCode shouldBe BoardErrorCode.BOARD_UNAUTHORIZED + } + + "먹팟 참가 신청 성공" { + // given + val boardId = boardService.saveBoard(userId, createRequest)!! + val applyUser = userRepository.save( + MuckPotUser( + null, "test1@naver.com", "pw", "nickname1", + Gender.MEN, 2000, JobGroupMain.DEVELOPMENT, "sub", Location("location", 0.0, 0.0), "url" + ) + ) + val applyUserId = applyUser.id!! + + // when + boardService.joinBoard(applyUserId, boardId) + + // then + val findBoard = boardRepository.findByIdOrNull(boardId)!! + val participant = participantRepository.findByBoard(findBoard) + findBoard.currentApply shouldBe 2 + participant shouldNotBe null + } + + "먹팟 중복 참가 신청 불가 검증" { + val boardId = boardService.saveBoard(userId, createRequest)!! + shouldThrow { + boardService.joinBoard(userId, boardId) + }.errorCode shouldBe ParticipantErrorCode.ALREADY_JOIN + } + + "먹팟 참가 동시성 테스트" { + // given + val boardId = boardService.saveBoard(userId, createRequest)!! + val applyUser = userRepository.save( + MuckPotUser( + null, "test1@naver.com", "pw", "nickname1", + Gender.MEN, 2000, JobGroupMain.DEVELOPMENT, "sub", Location("location", 0.0, 0.0), "url" + ) + ) + val applyId = applyUser.id!! + + // when + val successCount = AtomicLong() + ConcurrencyHelper.execute( + { boardService.joinBoard(applyId, boardId) }, + successCount + ) + + // then + val findBoard = boardRepository.findByIdOrNull(boardId)!! + findBoard.currentApply shouldBe 2 + } + + "먹팟 삭제 요청시 INACTIVE로 변경된다." { + // given + val boardId = boardService.saveBoard(userId, createRequest)!! + + // when + boardService.deleteBoard(userId, boardId) + + val findBoard = boardRepository.findByIdOrNull(boardId) + findBoard shouldBe null + } + + "자신의 글만 삭제할 수 있다." { + // given + val otherUserId = -1L + val boardId = boardService.saveBoard(userId, createRequest)!! + // when & then + shouldThrow { + boardService.deleteBoard(otherUserId, boardId) + }.errorCode shouldBe BoardErrorCode.BOARD_UNAUTHORIZED + } + + "글 삭제시 참여자 목록도 함께 삭제한다." { + // given + val board = boardRepository.save(Fixture.createBoard(user = user)) + // when + boardService.deleteBoard(userId, board.id!!) + // then + val findBoard = participantRepository.findByBoard(board) + findBoard shouldHaveSize 0 + } + + "먹팟 상태변경 성공" { + // given + val boardId = boardRepository.save(Fixture.createBoard(user = user)).id!! + // when + boardService.changeStatus(userId, boardId, MuckPotStatus.DONE) + // then + val actual = boardRepository.findByIdOrNull(boardId)!! + actual.status shouldBe MuckPotStatus.DONE + } + + "먹팟 참가 신청 취소 성공" { + // given + val applyUser = userRepository.save(Fixture.createUser()) + val board = boardRepository.save(Fixture.createBoard(user = user)) + participantRepository.save(Participant(applyUser, board)) + // when + boardService.cancelJoin(applyUser.id!!, board.id!!) + // then + val findParticipant = participantRepository.findByUserAndBoard(applyUser, board) + findParticipant shouldBe null + board.currentApply shouldBe 0 + } + + "기존 참가 신청 내역 없으면 참가 신청 취소 불가" { + // given + val applyUser = userRepository.save(Fixture.createUser()) + val board = boardRepository.save(Fixture.createBoard(user = user)) + // when & then + shouldThrow { + boardService.cancelJoin(applyUser.id!!, board.id!!) + }.errorCode shouldBe ParticipantErrorCode.PARTICIPANT_NOT_FOUND + } + + "먹팟 글 작성자는 참가 신청 취소할 수 없다." { + // given + val board = boardRepository.save(Fixture.createBoard(user = user)) + participantRepository.save(Participant(user, board)) + // when & then + shouldThrow { + boardService.cancelJoin(user.id!!, board.id!!) + }.errorCode shouldBe ParticipantErrorCode.WRITER_MUST_JOIN + } +}) diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/user/controller/UserControllerTest.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/user/controller/UserControllerTest.kt new file mode 100644 index 00000000..ba7346ca --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/user/controller/UserControllerTest.kt @@ -0,0 +1,80 @@ +package com.yapp.muckpot.domains.user.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.yapp.muckpot.common.constants.ACCESS_TOKEN_KEY +import com.yapp.muckpot.common.constants.JWT_LOGOUT_VALUE +import com.yapp.muckpot.common.enums.YesNo +import com.yapp.muckpot.domains.user.controller.dto.LoginRequest +import com.yapp.muckpot.domains.user.entity.MuckPotUser +import com.yapp.muckpot.domains.user.repository.MuckPotUserRepository +import com.yapp.muckpot.redis.RedisService +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import javax.servlet.http.Cookie + +@SpringBootTest +@AutoConfigureMockMvc +class UserControllerTest @Autowired constructor( + private val mockMvc: MockMvc, + private val passwordEncoder: PasswordEncoder, + private val userRepository: MuckPotUserRepository, + private val redisService: RedisService, + private val objectMapper: ObjectMapper +) : StringSpec({ + val pw = "abcd1234" + val user = Fixture.createUser(password = passwordEncoder.encode(pw)) + val loginRequest = LoginRequest( + email = user.email, + password = pw, + keep = YesNo.N + ) + lateinit var loginUser: MuckPotUser + lateinit var loginCookies: Array + lateinit var accessToken: String + + fun loginAndInit() { + loginUser = userRepository.save(user) + val response = mockMvc.perform( + post("/api/v1/users/login") + .content(objectMapper.writeValueAsBytes(loginRequest)) + .contentType(MediaType.APPLICATION_JSON) + ).andExpect( + status().isOk + ).andReturn().response + + loginCookies = response.cookies + accessToken = response.getCookie(ACCESS_TOKEN_KEY)?.value!! + } + + beforeTest { + loginAndInit() + } + + afterTest { + userRepository.delete(loginUser) + } + + "로그아웃 성공 - 리프레시 토큰 삭제, 블랙리스트 추가 확인" { + // when + mockMvc.perform( + post("/api/v1/users/logout") + .cookie(*loginCookies) + ).andExpect( + status().isNoContent + ) + // then + val blackList = redisService.getData(accessToken) + val refreshToken = redisService.getData(loginUser.email) + + blackList shouldBe JWT_LOGOUT_VALUE + refreshToken shouldBe null + } +}) diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/user/controller/dto/SignUpRequestTest.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/user/controller/dto/SignUpRequestTest.kt new file mode 100644 index 00000000..ef911814 --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/user/controller/dto/SignUpRequestTest.kt @@ -0,0 +1,100 @@ +package com.yapp.muckpot.domains.user.controller.dto + +import com.yapp.muckpot.common.constants.PASSWORD_PATTERN_INVALID +import com.yapp.muckpot.common.enums.Gender +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import javax.validation.ConstraintViolation +import javax.validation.Validation +import javax.validation.Validator + +class SignUpRequestTest : StringSpec({ + lateinit var validator: Validator + + fun createSignUpRequest( + email: String = "email@naver.com", + password: String = "abc1234!", + nickname: String = "닉네임", + jobGroupMain: String = "개발", + jobGroupSub: String = "직군 소분류", + locationName: String = "삼전 본사", + x: Double = 0.0, + y: Double = 0.0, + gender: Gender = Gender.WOMEN, + yearOfBirth: Int = 1996 + ): SignUpRequest { + return SignUpRequest( + email = email, + password = password, + nickname = nickname, + jobGroupMain = jobGroupMain, + jobGroupSub = jobGroupSub, + locationName = locationName, + x = x, + y = y, + gender = gender, + yearOfBirth = yearOfBirth + ) + } + + beforeTest { + validator = Validation.buildDefaultValidatorFactory().validator + } + + "이메일 형식 유효 검사" { + val request = createSignUpRequest(email = "email@validation.com") + + val violations: MutableSet> = validator.validate(request) + violations.size shouldBe 1 + for (violation in violations) { + violation.message shouldBe "현재 버전은 네이버 사우만 이용 가능합니다." + } + } + + "비밀번호 형식 검사" { + val request = createSignUpRequest(password = "12") + + val violations: MutableSet> = validator.validate(request) + violations.size shouldBe 1 + for (violation in violations) { + violation.message shouldBe PASSWORD_PATTERN_INVALID + } + } + + "비밀번호에 특수문자를 포함할 수 있다." { + val request = createSignUpRequest(password = "ab@cd!12$34#") + + val violations: MutableSet> = validator.validate(request) + violations.size shouldBe 0 + } + + "비밀번호에는 영어, 숫자를 최소한 1개 포함해야 한다" { + val request = createSignUpRequest(password = "abcdefgh!!") + + val violations: MutableSet> = validator.validate(request) + violations.size shouldBe 1 + for (violation in violations) { + violation.message shouldBe PASSWORD_PATTERN_INVALID + } + } + + "닉네임 글자수 2-10자 제한" { + val request = createSignUpRequest(nickname = "팟") + + val violations: MutableSet> = validator.validate(request) + violations.size shouldBe 1 + for (violation in violations) { + violation.message shouldBe "2~10자로 입력해 주세요." + } + } + + "직군 소분류 최대 10자 제한" { + val request = createSignUpRequest(jobGroupSub = "아아아아아아아아아아아아아아") + + val violations: MutableSet> = validator.validate(request) + violations.size shouldBe 1 + for (violation in violations) { + violation.message shouldBe "10자 이하로 입력해 주세요." + } + } +}) diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/user/service/UserServiceTest.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/user/service/UserServiceTest.kt new file mode 100644 index 00000000..350b8473 --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/domains/user/service/UserServiceTest.kt @@ -0,0 +1,64 @@ +package com.yapp.muckpot.domains.user.service + +import com.yapp.muckpot.common.enums.Gender +import com.yapp.muckpot.domains.user.controller.dto.SendEmailAuthRequest +import com.yapp.muckpot.domains.user.controller.dto.SignUpRequest +import com.yapp.muckpot.domains.user.exception.UserErrorCode +import com.yapp.muckpot.domains.user.repository.MuckPotUserRepository +import com.yapp.muckpot.exception.MuckPotException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.util.* + +@SpringBootTest +class UserServiceTest @Autowired constructor( + private val userService: UserService, + private val userRepository: MuckPotUserRepository +) : StringSpec({ + + val request = SignUpRequest( + email = UUID.randomUUID().toString().substring(0, 5) + "@naver.com", + password = "abc1234!", + nickname = UUID.randomUUID().toString().substring(0, 3), + jobGroupMain = "개발", + jobGroupSub = null, + locationName = "삼전 본사", + x = 0.0, + y = 0.0, + gender = Gender.WOMEN, + yearOfBirth = 1996 + ) + + afterEach { + userRepository.findByEmail(request.email)?.let { userRepository.delete(it) } + } + + "회원가입 성공" { + // when + val user = userService.signUp(request) + // then + user shouldNotBe null + user.nickName shouldBe request.nickname + } + + "중복 회원가입 불가 검증" { + userService.signUp(request) + + shouldThrow { + userService.signUp(request) + }.errorCode shouldBe UserErrorCode.ALREADY_EXISTS_USER + } + + "인증 메일 받기 단계에서 중복이메일 유효성 검사를 한다." { + // given + userService.signUp(request) + // when & then + shouldThrow { + userService.sendEmailAuth(SendEmailAuthRequest(request.email)) + }.errorCode shouldBe UserErrorCode.ALREADY_EXISTS_USER + } +}) diff --git a/muckpot-api/src/test/kotlin/com/yapp/muckpot/test/TestServiceTest.kt b/muckpot-api/src/test/kotlin/com/yapp/muckpot/test/TestServiceTest.kt new file mode 100644 index 00000000..c95ba44c --- /dev/null +++ b/muckpot-api/src/test/kotlin/com/yapp/muckpot/test/TestServiceTest.kt @@ -0,0 +1,20 @@ +package com.yapp.muckpot.test + +import com.yapp.muckpot.domains.test.entity.TestEntity +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk + +class TestServiceTest : StringSpec({ + val testService: TestService = mockk() + + "kotest 테스트 " { + // given + every { testService.test() } returns TestResponse.of(TestEntity()) + val result = testService.test() + + result.id shouldBe 1 + result.name shouldBe "test" + } +}) diff --git a/muckpot-api/src/test/resources/application.yml b/muckpot-api/src/test/resources/application.yml new file mode 100644 index 00000000..e79211ef --- /dev/null +++ b/muckpot-api/src/test/resources/application.yml @@ -0,0 +1,13 @@ +spring: + mvc: + pathmatch: + matching-strategy: ant_path_matcher + +jwt: + issuer: "muckpot" + secret-key: "test-secret" + +api: + option: + permit-all: false + allowed-origins: \ No newline at end of file diff --git a/muckpot-domain/build.gradle.kts b/muckpot-domain/build.gradle.kts new file mode 100644 index 00000000..e3e62c01 --- /dev/null +++ b/muckpot-domain/build.gradle.kts @@ -0,0 +1,43 @@ +val kotestVersion = "5.5.4" +val testContainerVersion = "1.17.6" + +plugins { + kotlin("kapt") + kotlin("plugin.noarg") + `java-test-fixtures` +} + +dependencies { + implementation(project(":muckpot-infra")) + + val kapt by configurations + // querydsl + api("com.querydsl:querydsl-jpa:5.0.0") + kapt("com.querydsl:querydsl-apt:5.0.0:jpa") + // https://mvnrepository.com/artifact/org.hibernate.orm/hibernate-spatial + implementation("org.hibernate:hibernate-spatial:5.6.15.Final") + + runtimeOnly("org.mariadb.jdbc:mariadb-java-client") + + testFixturesImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + testFixturesImplementation("org.testcontainers:testcontainers:$testContainerVersion") + testFixturesImplementation("org.testcontainers:junit-jupiter:$testContainerVersion") + testFixturesImplementation("org.testcontainers:mariadb:$testContainerVersion") +} + +allOpen { + annotation("javax.persistence.Entity") + annotation("javax.persistence.Embeddable") + annotation("javax.persistence.MappedSuperclass") +} + +noArg { + annotation("javax.persistence.Entity") + annotation("javax.persistence.Embeddable") + annotation("javax.persistence.MappedSuperclass") +} + +tasks { + withType { enabled = true } + withType { enabled = false } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/BaseErrorCode.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/BaseErrorCode.kt new file mode 100644 index 00000000..c33efb31 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/BaseErrorCode.kt @@ -0,0 +1,5 @@ +package com.yapp.muckpot.common + +interface BaseErrorCode { + fun toResponseDto(): ResponseDto +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/BaseTimeEntity.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/BaseTimeEntity.kt new file mode 100644 index 00000000..14a05804 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/BaseTimeEntity.kt @@ -0,0 +1,17 @@ +package com.yapp.muckpot.common + +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime +import javax.persistence.EntityListeners +import javax.persistence.MappedSuperclass + +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +abstract class BaseTimeEntity( + @CreatedDate + var createdAt: LocalDateTime = LocalDateTime.now(), + @LastModifiedDate + var updatedAt: LocalDateTime = LocalDateTime.now() +) diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/Location.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/Location.kt new file mode 100644 index 00000000..0177176d --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/Location.kt @@ -0,0 +1,25 @@ +package com.yapp.muckpot.common + +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.Point +import javax.persistence.Column +import javax.persistence.Embeddable + +@Embeddable +class Location( + @Column(name = "location_name") + var locationName: String, + + @Column(name = "location_point", columnDefinition = "Point") + val locationPoint: Point +) { + constructor(locationName: String, x: Double, y: Double) : this( + locationName, + GeometryFactory().createPoint(Coordinate(x, y)) + ) + + init { + require(locationName.isNotBlank()) { "위치 명을 입력해주세요" } + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/ResponseDto.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/ResponseDto.kt new file mode 100644 index 00000000..47271e57 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/ResponseDto.kt @@ -0,0 +1,31 @@ +package com.yapp.muckpot.common + +import com.fasterxml.jackson.annotation.JsonInclude +import com.yapp.muckpot.common.enums.StatusCode + +data class ResponseDto( + val status: Int, + @JsonInclude(JsonInclude.Include.NON_NULL) + val message: String? = null, + @JsonInclude(JsonInclude.Include.NON_NULL) + val result: Any? = null +) { + companion object { + fun fail(baseErrorCode: BaseErrorCode): ResponseDto { + return baseErrorCode.toResponseDto() + } + + fun success(data: Any? = "성공"): ResponseDto { + return ResponseDto(status = StatusCode.OK.code, null, result = data) + } + + fun created(data: Any? = "성공"): ResponseDto { + if (data == Unit) return ResponseDto(status = StatusCode.CREATED.code, null, result = null) + return ResponseDto(status = StatusCode.CREATED.code, null, result = data) + } + + fun noContent(): ResponseDto { + return ResponseDto(status = StatusCode.NO_CONTENT.code, message = null, result = null) + } + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/TimeUtil.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/TimeUtil.kt new file mode 100644 index 00000000..c03e01c4 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/TimeUtil.kt @@ -0,0 +1,53 @@ +package com.yapp.muckpot.common + +import com.yapp.muckpot.common.constants.A_DAY_AGO +import com.yapp.muckpot.common.constants.HOUR_IN_MINUTES +import com.yapp.muckpot.common.constants.MINUTES_IN_ONE_DAY +import com.yapp.muckpot.common.constants.MINUTES_IN_TWO_DAY +import com.yapp.muckpot.common.constants.NOT_TODAY_OR_TOMORROW +import com.yapp.muckpot.common.constants.N_HOURS_AGO +import com.yapp.muckpot.common.constants.N_MINUTES_AGO +import com.yapp.muckpot.common.constants.TODAY_KR +import com.yapp.muckpot.common.constants.TOMORROW_KR +import com.yapp.muckpot.common.extension.isToday +import com.yapp.muckpot.common.extension.isTomorrow +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.* + +object TimeUtil { + /** + * 현재 시간을 기준으로 오늘, 내일 계산 + * + * @return 오늘, 내일, null + */ + fun isTodayOrTomorrow(localDate: LocalDate): String? { + var todayOrTomorrow: String? = NOT_TODAY_OR_TOMORROW + if (localDate.isToday()) { + todayOrTomorrow = TODAY_KR + } else if (localDate.isTomorrow()) { + todayOrTomorrow = TOMORROW_KR + } + return todayOrTomorrow + } + + /** + * @param localDateTime localDateTime 이 현재시간부터 얼마큼 지났는지 정책에 따라 계산 + */ + fun formatElapsedTime(localDateTime: LocalDateTime): String { + val timeDiff = ChronoUnit.MINUTES.between(localDateTime, LocalDateTime.now()) + return when { + (timeDiff < HOUR_IN_MINUTES) -> N_MINUTES_AGO.format(timeDiff) + (timeDiff in HOUR_IN_MINUTES until MINUTES_IN_ONE_DAY) -> N_HOURS_AGO.format(timeDiff / HOUR_IN_MINUTES) + (timeDiff in MINUTES_IN_ONE_DAY until MINUTES_IN_TWO_DAY) -> A_DAY_AGO + else -> localDateTime.toLocalDate().toString() + } + } + + fun localeKoreanFormatting(localDateTime: LocalDateTime, pattern: String): String { + val formatter = DateTimeFormatter.ofPattern(pattern, Locale.KOREAN) + return localDateTime.format(formatter) + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/constants/BoardConstant.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/constants/BoardConstant.kt new file mode 100644 index 00000000..5cc0c9a8 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/constants/BoardConstant.kt @@ -0,0 +1,12 @@ +package com.yapp.muckpot.common.constants + +// 상수 +const val AGE_MIN = 20 +const val AGE_MAX = 100 +const val TITLE_MAX = 100 +const val CONTENT_MAX = 2000 +const val CHAT_LINK_MAX = 300 +const val MAX_APPLY_MIN = 2 + +// 예외 메시지 +const val AGE_EXP_MSG = "나이는 $AGE_MIN ~ $AGE_MAX 범위로 입력해주세요." diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/constants/RegexPatternConst.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/constants/RegexPatternConst.kt new file mode 100644 index 00000000..c822bc47 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/constants/RegexPatternConst.kt @@ -0,0 +1,12 @@ +package com.yapp.muckpot.common.constants + +const val EMAIL = "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,4}" +const val PW_PATTERN = "^(?=.*[a-zA-Z])(?=.*\\d).{8,20}\$" + +const val ONLY_NAVER = "^[A-Za-z0-9._%+-]+@naver\\.com\$" +const val YYYYMMDD = "yyyy-MM-dd" +const val HHmm = "HH:mm" + +const val KR_MM_DD_E = "MM월 dd일 (E)" +const val KR_YYYY_MM_DD = "YYYY년 MM월 dd일" +const val a_hhmm = "a hh:mm" diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/constants/TimeConstant.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/constants/TimeConstant.kt new file mode 100644 index 00000000..792ddeec --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/constants/TimeConstant.kt @@ -0,0 +1,14 @@ +package com.yapp.muckpot.common.constants + +val NOT_TODAY_OR_TOMORROW = null +const val TODAY_KR = "오늘" +const val TOMORROW_KR = "내일" + +const val HOUR_IN_MINUTES = 60 +const val MINUTES_IN_ONE_DAY = 1440 +const val MINUTES_IN_TWO_DAY = 2880 +const val MS = 1000 + +const val N_MINUTES_AGO = "%d분 전" +const val N_HOURS_AGO = "%d시간 전" +const val A_DAY_AGO = "하루 전" diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/Gender.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/Gender.kt new file mode 100644 index 00000000..62f3e0ec --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/Gender.kt @@ -0,0 +1,5 @@ +package com.yapp.muckpot.common.enums + +enum class Gender { + MEN, WOMEN +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/State.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/State.kt new file mode 100644 index 00000000..ee79b55f --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/State.kt @@ -0,0 +1,5 @@ +package com.yapp.muckpot.common.enums + +enum class State { + ACTIVE, INACTIVE +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/StatusCode.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/StatusCode.kt new file mode 100644 index 00000000..632ae4e5 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/StatusCode.kt @@ -0,0 +1,16 @@ +package com.yapp.muckpot.common.enums + +enum class StatusCode( + val code: Int +) { + OK(200), + CREATED(201), + NO_CONTENT(204), + + BAD_REQUEST(400), + UNAUTHORIZED(401), + FORBIDDEN(403), + NOT_FOUND(404), + + INTERNAL_SERVER(500); +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/YesNo.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/YesNo.kt new file mode 100644 index 00000000..4a87eff8 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/enums/YesNo.kt @@ -0,0 +1,5 @@ +package com.yapp.muckpot.common.enums + +enum class YesNo { + Y, N +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/extension/LocalDate.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/extension/LocalDate.kt new file mode 100644 index 00000000..a643d3d5 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/common/extension/LocalDate.kt @@ -0,0 +1,11 @@ +package com.yapp.muckpot.common.extension + +import java.time.LocalDate + +fun LocalDate.isToday(): Boolean { + return this == LocalDate.now() +} + +fun LocalDate.isTomorrow(): Boolean { + return this == LocalDate.now().plusDays(1) +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/config/QuerydslConfig.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/config/QuerydslConfig.kt new file mode 100644 index 00000000..1a8afebb --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/config/QuerydslConfig.kt @@ -0,0 +1,16 @@ +package com.yapp.muckpot.config + +import com.querydsl.jpa.impl.JPAQueryFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import javax.persistence.EntityManager + +@Configuration +class QuerydslConfig( + private val em: EntityManager +) { + @Bean + fun jpaQueryFactory(): JPAQueryFactory { + return JPAQueryFactory(em) + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/dto/ParticipantReadResponse.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/dto/ParticipantReadResponse.kt new file mode 100644 index 00000000..fa89d598 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/dto/ParticipantReadResponse.kt @@ -0,0 +1,34 @@ +package com.yapp.muckpot.domains.board.dto + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonInclude +import com.querydsl.core.annotations.QueryProjection +import com.yapp.muckpot.domains.user.enums.JobGroupMain + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class ParticipantReadResponse constructor( + @JsonIgnore + val boardId: Long? = null, + val userId: Long?, + val nickName: String, + val jobGroupMain: String? = null, + var writer: Boolean? = null +) { + @QueryProjection + constructor( + boardId: Long?, + userId: Long?, + nickName: String, + jobGroupMain: JobGroupMain + ) : this(boardId, userId, nickName, jobGroupMain.korName, null) + + companion object { + fun otherN(otherCnt: Int): ParticipantReadResponse { + return ParticipantReadResponse( + null, + null, + "외 ${otherCnt}명" + ) + } + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/entity/Board.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/entity/Board.kt new file mode 100644 index 00000000..5fa2e12f --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/entity/Board.kt @@ -0,0 +1,139 @@ +package com.yapp.muckpot.domains.board.entity + +import com.yapp.muckpot.common.BaseTimeEntity +import com.yapp.muckpot.common.Location +import com.yapp.muckpot.common.constants.AGE_EXP_MSG +import com.yapp.muckpot.common.constants.AGE_MAX +import com.yapp.muckpot.common.constants.AGE_MIN +import com.yapp.muckpot.common.constants.MAX_APPLY_MIN +import com.yapp.muckpot.common.enums.State +import com.yapp.muckpot.domains.user.entity.MuckPotUser +import com.yapp.muckpot.domains.user.enums.MuckPotStatus +import com.yapp.muckpot.domains.user.enums.MuckPotStatus.DONE +import com.yapp.muckpot.domains.user.enums.MuckPotStatus.IN_PROGRESS +import org.hibernate.annotations.SQLDelete +import org.hibernate.annotations.Where +import java.time.LocalDateTime +import javax.persistence.Column +import javax.persistence.Embedded +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.Table + +@Entity +@Table(name = "board") +@Where(clause = "state = \'ACTIVE\'") +@SQLDelete(sql = "UPDATE board SET state = 'INACTIVE' WHERE board_id = ?") +class Board( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "board_id") + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "writer_id", referencedColumnName = "user_id") + var user: MuckPotUser, + + @Column(name = "title", nullable = false) + var title: String, + + @Embedded + var location: Location, + + @Column(name = "location_detail") + var locationDetail: String? = null, + + @Column(name = "meeting_time", nullable = false) + var meetingTime: LocalDateTime, + + @Column(name = "content") + var content: String? = "", + + @Column(name = "views") + var views: Int = 0, + + @Column(name = "current_apply") + var currentApply: Int = 0, + + @Column(name = "max_apply", nullable = false) + var maxApply: Int = 2, + + @Column(name = "chat_link", nullable = false) + var chatLink: String, + + @Enumerated(value = EnumType.STRING) + @Column(name = "status") + var status: MuckPotStatus = IN_PROGRESS, + + @Column(name = "min_age") + var minAge: Int = AGE_MIN, + + @Column(name = "max_age") + var maxAge: Int = AGE_MAX, + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + var state: State = State.ACTIVE +) : BaseTimeEntity() { + init { + require(minAge in AGE_MIN..AGE_MAX) { AGE_EXP_MSG } + require(maxAge in AGE_MIN..AGE_MAX) { AGE_EXP_MSG } + require(minAge < maxAge) { "최소나이는 최대나이보다 작아야 합니다." } + require(maxApply >= MAX_APPLY_MIN) { "최대 인원은 ${MAX_APPLY_MIN}명 이상 가능합니다." } + require(meetingTime > LocalDateTime.now()) { "만날 시간은 현재시간 이후에 가능합니다." } + } + + fun join(userAge: Int) { + require(userAge in minAge..maxAge) { "참여 가능 나이가 아닙니다." } + require(currentApply < maxApply) { "참여 모집이 마감되었습니다." } + require(status == IN_PROGRESS) { "참여 모집이 마감되었습니다." } + this.currentApply++ + if (currentApply == maxApply) { + this.status = DONE + } + } + + fun visit() { + this.views++ + } + + fun expired(): Boolean { + return (this.status == IN_PROGRESS && this.meetingTime.isBefore(LocalDateTime.now())) + } + + fun getX(): Double { + return this.location.locationPoint.x + } + + fun getY(): Double { + return this.location.locationPoint.y + } + + fun isNotMyBoard(userId: Long): Boolean { + return this.user.id != userId + } + + fun changeStatus(changeStatus: MuckPotStatus) { + require( + (this.status == IN_PROGRESS && changeStatus == DONE) || + ((this.status == DONE && changeStatus == IN_PROGRESS) && currentApply < maxApply) + ) { "변경 가능한 상태가 아닙니다." } + + this.status = changeStatus + } + + fun cancelJoin() { + this.currentApply-- + } + + fun isNotAgeLimit(): Boolean { + return (this.minAge == AGE_MIN && this.maxAge == AGE_MAX) + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/entity/Participant.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/entity/Participant.kt new file mode 100644 index 00000000..7e9c26fc --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/entity/Participant.kt @@ -0,0 +1,43 @@ +package com.yapp.muckpot.domains.board.entity + +import com.yapp.muckpot.common.BaseTimeEntity +import com.yapp.muckpot.common.enums.State +import com.yapp.muckpot.domains.user.entity.MuckPotUser +import org.hibernate.annotations.SQLDelete +import org.hibernate.annotations.Where +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.Table + +@Entity +@Table(name = "participant") +@Where(clause = "state = \'ACTIVE\'") +@SQLDelete(sql = "UPDATE participant SET state = 'INACTIVE' WHERE participant_id = ?") +class Participant( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "participant_id") + var id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", referencedColumnName = "user_id") + var user: MuckPotUser, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id", referencedColumnName = "board_id") + var board: Board, + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + var state: State = State.ACTIVE +) : BaseTimeEntity() { + constructor(user: MuckPotUser, board: Board) : this(null, user = user, board = board) +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/exception/BoardErrorCode.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/exception/BoardErrorCode.kt new file mode 100644 index 00000000..c04ec1d4 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/exception/BoardErrorCode.kt @@ -0,0 +1,18 @@ +package com.yapp.muckpot.domains.board.exception + +import com.yapp.muckpot.common.BaseErrorCode +import com.yapp.muckpot.common.ResponseDto +import com.yapp.muckpot.common.enums.StatusCode + +enum class BoardErrorCode( + private val status: Int, + private val reason: String +) : BaseErrorCode { + BOARD_NOT_FOUND(StatusCode.BAD_REQUEST.code, "먹팟 정보를 찾을 수 없습니다."), + MAX_APPLY_UPDATE_FAIL(StatusCode.BAD_REQUEST.code, "현재 참여인원 이상으로만 설정 가능합니다."), + BOARD_UNAUTHORIZED(StatusCode.UNAUTHORIZED.code, "내가 작성한 글에만 접근할 수 있습니다."); + + override fun toResponseDto(): ResponseDto { + return ResponseDto(this.status, this.reason, null) + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/exception/ParticipantErrorCode.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/exception/ParticipantErrorCode.kt new file mode 100644 index 00000000..533c3955 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/exception/ParticipantErrorCode.kt @@ -0,0 +1,18 @@ +package com.yapp.muckpot.domains.board.exception + +import com.yapp.muckpot.common.BaseErrorCode +import com.yapp.muckpot.common.ResponseDto +import com.yapp.muckpot.common.enums.StatusCode + +enum class ParticipantErrorCode( + private val status: Int, + private val reason: String +) : BaseErrorCode { + ALREADY_JOIN(StatusCode.BAD_REQUEST.code, "이미 참여한 유저입니다."), + PARTICIPANT_NOT_FOUND(StatusCode.BAD_REQUEST.code, "참여 정보를 찾을 수 없습니다."), + WRITER_MUST_JOIN(StatusCode.BAD_REQUEST.code, "글 작성자는 참가 신청 취소 불가합니다."); + + override fun toResponseDto(): ResponseDto { + return ResponseDto(this.status, this.reason, null) + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/BoardQuerydslRepository.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/BoardQuerydslRepository.kt new file mode 100644 index 00000000..a93a1845 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/BoardQuerydslRepository.kt @@ -0,0 +1,56 @@ +package com.yapp.muckpot.domains.board.repository + +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.jpa.impl.JPAQueryFactory +import com.yapp.muckpot.domains.board.entity.Board +import com.yapp.muckpot.domains.board.entity.QBoard.board +import com.yapp.muckpot.domains.user.enums.MuckPotStatus +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Repository +@Transactional(readOnly = true) +class BoardQuerydslRepository( + private val queryFactory: JPAQueryFactory +) { + fun findAllWithPagination(lastId: Long?, countPerScroll: Long): List { + return queryFactory.from(board) + .select(board) + .where( + lessThanLastId(lastId) + ) + .orderBy(board.createdAt.desc()) + .limit(countPerScroll) + .fetch() + } + + fun findPrevId(boardId: Long): Long? { + return queryFactory.from(board) + .select(board.id.min()) + .where(board.id.gt(boardId)) + .fetchOne() + } + + fun findNextId(boardId: Long): Long? { + return queryFactory.from(board) + .select(board.id.max()) + .where(board.id.lt(boardId)) + .fetchOne() + } + + @Transactional + fun updateLessThanCurrentTime() { + queryFactory + .update(board) + .set(board.status, MuckPotStatus.DONE) + .where(board.meetingTime.lt(LocalDateTime.now()), board.status.eq(MuckPotStatus.IN_PROGRESS)) + .execute() + } + + private fun lessThanLastId(lastId: Long?): BooleanExpression? { + return lastId?.let { + board.id.lt(it) + } + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/BoardRepository.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/BoardRepository.kt new file mode 100644 index 00000000..4b26ef64 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/BoardRepository.kt @@ -0,0 +1,9 @@ +package com.yapp.muckpot.domains.board.repository + +import com.yapp.muckpot.domains.board.entity.Board +import com.yapp.muckpot.domains.user.enums.MuckPotStatus +import org.springframework.data.jpa.repository.JpaRepository + +interface BoardRepository : JpaRepository { + fun findByStatus(status: MuckPotStatus): List +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantQuerydslRepository.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantQuerydslRepository.kt new file mode 100644 index 00000000..17d2b705 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantQuerydslRepository.kt @@ -0,0 +1,29 @@ +package com.yapp.muckpot.domains.board.repository + +import com.querydsl.jpa.impl.JPAQueryFactory +import com.yapp.muckpot.domains.board.dto.ParticipantReadResponse +import com.yapp.muckpot.domains.board.dto.QParticipantReadResponse +import com.yapp.muckpot.domains.board.entity.QParticipant.participant +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional + +@Repository +class ParticipantQuerydslRepository( + private val queryFactory: JPAQueryFactory +) { + @Transactional(readOnly = true) + fun findByBoardIds(boardIds: List): List { + return queryFactory.select( + QParticipantReadResponse( + participant.board.id, + participant.user.id, + participant.user.nickName, + participant.user.mainCategory + ) + ) + .from(participant) + .where(participant.board.id.`in`(boardIds)) + .orderBy(participant.createdAt.asc()) + .fetch() + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantRepository.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantRepository.kt new file mode 100644 index 00000000..ac4b90fa --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantRepository.kt @@ -0,0 +1,20 @@ +package com.yapp.muckpot.domains.board.repository + +import com.yapp.muckpot.domains.board.entity.Board +import com.yapp.muckpot.domains.board.entity.Participant +import com.yapp.muckpot.domains.user.entity.MuckPotUser +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 + +interface ParticipantRepository : JpaRepository { + @Transactional + @Modifying + @Query("UPDATE Participant p SET p.state = 'INACTIVE' WHERE p.board = :board") + fun deleteByBoard(board: Board) + + fun findByBoard(board: Board): List + + fun findByUserAndBoard(user: MuckPotUser, board: Board): Participant? +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/test/entity/TestEntity.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/test/entity/TestEntity.kt new file mode 100644 index 00000000..da8c099c --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/test/entity/TestEntity.kt @@ -0,0 +1,23 @@ +package com.yapp.muckpot.domains.test.entity + +import com.yapp.muckpot.common.BaseTimeEntity +import mu.KLogging +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id + +@Entity +data class TestEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = 1, + val name: String = "test" +) : BaseTimeEntity() { + fun loggingTest() { + val log = KLogging().logger + log.info { "info" } + log.warn { "warn" } + log.error { "error" } + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/test/repository/TestQuerydslRepository.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/test/repository/TestQuerydslRepository.kt new file mode 100644 index 00000000..635f326e --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/test/repository/TestQuerydslRepository.kt @@ -0,0 +1,20 @@ +package com.yapp.muckpot.domains.test.repository + +import com.querydsl.jpa.impl.JPAQueryFactory +import com.yapp.muckpot.domains.test.entity.QTestEntity.testEntity +import com.yapp.muckpot.domains.test.entity.TestEntity +import org.springframework.stereotype.Repository + +@Repository +class TestQuerydslRepository( + private val queryFactory: JPAQueryFactory +) { + + fun getTestByName(name: String): TestEntity? { + return queryFactory.select(testEntity) + .from(testEntity) + .where(testEntity.name.eq(name)) + .limit(1) + .fetchOne() + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/test/repository/TestRepository.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/test/repository/TestRepository.kt new file mode 100644 index 00000000..6b8f5845 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/test/repository/TestRepository.kt @@ -0,0 +1,8 @@ +package com.yapp.muckpot.domains.test.repository + +import com.yapp.muckpot.domains.test.entity.TestEntity +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface TestRepository : JpaRepository diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/entity/MuckPotUser.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/entity/MuckPotUser.kt new file mode 100644 index 00000000..d73f4dbd --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/entity/MuckPotUser.kt @@ -0,0 +1,80 @@ +package com.yapp.muckpot.domains.user.entity + +import com.yapp.muckpot.common.BaseTimeEntity +import com.yapp.muckpot.common.Location +import com.yapp.muckpot.common.constants.EMAIL +import com.yapp.muckpot.common.enums.Gender +import com.yapp.muckpot.common.enums.State +import com.yapp.muckpot.domains.user.enums.JobGroupMain +import org.hibernate.annotations.SQLDelete +import org.hibernate.annotations.Where +import java.time.LocalDate +import java.time.Period +import javax.persistence.Column +import javax.persistence.Embedded +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.Table + +@Entity +@Table(name = "muckpot_user") +@Where(clause = "state = \'ACTIVE\'") +@SQLDelete(sql = "UPDATE muckpot_user SET state = 'INACTIVE' WHERE user_id = ?") +class MuckPotUser( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + val id: Long? = null, + + @Column(name = "email") + val email: String, + + @Column(name = "password") + var password: String, + + @Column(name = "nick_name") + var nickName: String, + + @Enumerated(value = EnumType.STRING) + @Column(name = "gender") + val gender: Gender, + + @Column(name = "year_of_birth") + var yearOfBirth: Int, + + @Enumerated(value = EnumType.STRING) + @Column(name = "main_category") + var mainCategory: JobGroupMain, + + @Column(name = "sub_category") + var subCategory: String? = null, + + @Embedded + var location: Location, + + @Column(name = "image_url") + var imageUrl: String? = null, + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + var state: State = State.ACTIVE +) : BaseTimeEntity() { + init { + require(email.isNotBlank()) { "이메일은 필수입니다" } + require(EMAIL.toRegex().matches(this.email)) { "유효한 이메일 형식이 아닙니다" } + require(password.isNotBlank()) { "비밀번호는 필수입니다" } + require(nickName.isNotBlank()) { "닉네임은 필수입니다" } + require(yearOfBirth in 1900..2023) { "잘못된 출생 연도입니다" } + require(mainCategory.name.isNotBlank()) { "주요 카테고리는 필수입니다" } + } + + fun getAge(): Int { + val currentDate = LocalDate.now() + val birthDate = LocalDate.of(yearOfBirth, 1, 1) + return Period.between(birthDate, currentDate).years + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/enums/JobGroupMain.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/enums/JobGroupMain.kt new file mode 100644 index 00000000..d0f1ddce --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/enums/JobGroupMain.kt @@ -0,0 +1,33 @@ +package com.yapp.muckpot.domains.user.enums + +import java.lang.IllegalArgumentException + +enum class JobGroupMain(val korName: String) { + DEVELOPMENT("개발"), + EDUCATION("교육"), + FINANCE("금융/재무"), + PLANNING("기획/경영"), + DATA("데이터"), + DESIGN("디자인"), + MARKETING("마케팅/시장조사"), + MEDIA("미디어/홍보"), + LAW("법률/법무"), + PRODUCTION("생산/제조"), + PRODUCTION_MANAGEMENT("생산관리/품질관리"), + SERVICE("서비스/고객지원"), + ENGINEERING("엔지니어링"), + R_D("연구개발"), + SALES("영업/제휴"), + DISTRIBUTION("유통/무역"), + MEDICINE("의약"), + HUMAN_AFFAIRS("인사/총무"), + PROFESSIONAL("전문직"), + PUBLIC("특수계층/공공"); + + companion object { + fun findByKorName(korName: String): JobGroupMain { + return values().find { it.korName == korName } + ?: throw IllegalArgumentException("직군 대분류가 존재하지 않습니다.") + } + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/enums/LocationType.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/enums/LocationType.kt new file mode 100644 index 00000000..114ea910 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/enums/LocationType.kt @@ -0,0 +1,5 @@ +package com.yapp.muckpot.domains.user.enums + +enum class LocationType { + COMPANY, RESTAURANT +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/enums/MuckPotStatus.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/enums/MuckPotStatus.kt new file mode 100644 index 00000000..db6fb0ca --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/enums/MuckPotStatus.kt @@ -0,0 +1,7 @@ +package com.yapp.muckpot.domains.user.enums + +enum class MuckPotStatus( + val korNm: String +) { + IN_PROGRESS("모집중"), DONE("모집마감") +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/exception/UserErrorCode.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/exception/UserErrorCode.kt new file mode 100644 index 00000000..7561fdb0 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/exception/UserErrorCode.kt @@ -0,0 +1,24 @@ +package com.yapp.muckpot.domains.user.exception + +import com.yapp.muckpot.common.BaseErrorCode +import com.yapp.muckpot.common.ResponseDto +import com.yapp.muckpot.common.enums.StatusCode + +enum class UserErrorCode( + private val status: Int, + private val reason: String +) : BaseErrorCode { + USER_NOT_FOUND(StatusCode.BAD_REQUEST.code, "유저를 찾을 수 없습니다."), + LOGIN_FAIL(StatusCode.UNAUTHORIZED.code, "아이디 혹은 비밀번호가 일치하지 않습니다."), + NO_VERIFY_CODE(StatusCode.UNAUTHORIZED.code, "인증 요청을 먼저 해주세요."), + EMAIL_VERIFY_FAIL(StatusCode.UNAUTHORIZED.code, "인증 코드가 일치하지 않습니다."), + ALREADY_EXISTS_USER(StatusCode.BAD_REQUEST.code, "이미 가입한 유저입니다."), + WRONG_MAIN_JOB(StatusCode.BAD_REQUEST.code, "잘못된 직군 대분류입니다."), + NOT_FOUND_TOKEN(StatusCode.BAD_REQUEST.code, "토큰 정보를 찾을 수 없습니다."), + IS_BLACKLIST_TOKEN(StatusCode.BAD_REQUEST.code, "로그아웃 된 토큰 정보입니다."), + FAIL_JWT_REISSUE(StatusCode.BAD_REQUEST.code, "JWT 재발급 실패"); + + override fun toResponseDto(): ResponseDto { + return ResponseDto(this.status, this.reason, null) + } +} diff --git a/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/repository/MuckPotUserRepository.kt b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/repository/MuckPotUserRepository.kt new file mode 100644 index 00000000..5d66c887 --- /dev/null +++ b/muckpot-domain/src/main/kotlin/com/yapp/muckpot/domains/user/repository/MuckPotUserRepository.kt @@ -0,0 +1,8 @@ +package com.yapp.muckpot.domains.user.repository + +import com.yapp.muckpot.domains.user.entity.MuckPotUser +import org.springframework.data.jpa.repository.JpaRepository + +interface MuckPotUserRepository : JpaRepository { + fun findByEmail(email: String): MuckPotUser? +} diff --git a/muckpot-domain/src/main/resources/application-domain.yml b/muckpot-domain/src/main/resources/application-domain.yml new file mode 100644 index 00000000..13d8c97a --- /dev/null +++ b/muckpot-domain/src/main/resources/application-domain.yml @@ -0,0 +1,37 @@ +spring: + config: + activate: + on-profile: local + datasource: + url: jdbc:mariadb://localhost:3306/muckpot_local + username: root + password: admin + driver-class-name: org.mariadb.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + generate-ddl: true + properties: + hibernate: + dialect: org.hibernate.spatial.dialect.mariadb.MariaDB103SpatialDialect + format_sql: true + show-sql: true + +--- + +spring: + config: + activate: + on-profile: dev + datasource: + url: jdbc:mariadb://${RDS_DEV_URL}/${RDS_DEV_DB_NAME} + username: ${RDS_DEV_ID} + password: ${RDS_DEV_PW} + driver-class-name: org.mariadb.jdbc.Driver + jpa: + hibernate: + ddl-auto: update # TODO DDL 완성 후 해당 옵션 변경해주어야 함 + generate-ddl: true + properties: + hibernate: + dialect: org.hibernate.spatial.dialect.mariadb.MariaDB103SpatialDialect \ No newline at end of file diff --git a/muckpot-domain/src/test/kotlin/com/yapp/muckpot/DomainTestApplication.kt b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/DomainTestApplication.kt new file mode 100644 index 00000000..ee62d577 --- /dev/null +++ b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/DomainTestApplication.kt @@ -0,0 +1,6 @@ +package com.yapp.muckpot + +import org.springframework.boot.autoconfigure.SpringBootApplication + +@SpringBootApplication +class DomainTestApplication diff --git a/muckpot-domain/src/test/kotlin/com/yapp/muckpot/config/CustomDataJpaTest.kt b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/config/CustomDataJpaTest.kt new file mode 100644 index 00000000..8e90057d --- /dev/null +++ b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/config/CustomDataJpaTest.kt @@ -0,0 +1,11 @@ +package com.yapp.muckpot.config + +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import + +@Target(AnnotationTarget.CLASS) +@DataJpaTest +@Import(TestConfig::class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +annotation class CustomDataJpaTest diff --git a/muckpot-domain/src/test/kotlin/com/yapp/muckpot/config/TestConfig.kt b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/config/TestConfig.kt new file mode 100644 index 00000000..b48b3202 --- /dev/null +++ b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/config/TestConfig.kt @@ -0,0 +1,18 @@ +package com.yapp.muckpot.config + +import com.querydsl.jpa.impl.JPAQueryFactory +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import javax.persistence.EntityManager +import javax.persistence.PersistenceContext + +@TestConfiguration +class TestConfig { + @PersistenceContext + lateinit var em: EntityManager + + @Bean + fun jpaQueryFactory(): JPAQueryFactory { + return JPAQueryFactory(em) + } +} diff --git a/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/entity/BoardTest.kt b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/entity/BoardTest.kt new file mode 100644 index 00000000..9be92203 --- /dev/null +++ b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/entity/BoardTest.kt @@ -0,0 +1,100 @@ +package com.yapp.muckpot.domains.board.entity + +import Fixture +import com.yapp.muckpot.common.constants.MAX_APPLY_MIN +import com.yapp.muckpot.domains.user.enums.MuckPotStatus +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.time.LocalDateTime + +class BoardTest : FunSpec({ + context("Board 유효성 검사") { + test("minAge는 maxAge 보다 작아야 한다.") { + shouldThrow { + Fixture.createBoard( + minAge = 25, + maxAge = 21 + ) + }.message shouldBe "최소나이는 최대나이보다 작아야 합니다." + } + + test("최대 참여인원은 2명 이상이어야 한다.") { + shouldThrow { + Fixture.createBoard( + maxApply = 1 + ) + }.message shouldBe "최대 인원은 ${MAX_APPLY_MIN}명 이상 가능합니다." + } + + test("만날 시간은 현재시간 이후로만 가능하다.") { + shouldThrow { + Fixture.createBoard( + meetingTime = LocalDateTime.now().minusMinutes(30) + ) + }.message shouldBe "만날 시간은 현재시간 이후에 가능합니다." + } + + test("정원이 초과된 경우 참여할 수 없다.") { + val board = Fixture.createBoard( + currentApply = 1, + maxApply = 2 + ) + board.join(23) + shouldThrow { + board.join(23) + }.message shouldBe "참여 모집이 마감되었습니다." + } + + test("나이 제한에 걸릴 경우 참여할 수 없다.") { + val board = Fixture.createBoard( + minAge = 21, + maxAge = 25 + ) + shouldThrow { + board.join(40) + }.message shouldBe "참여 가능 나이가 아닙니다." + } + + test("IN_PROGRESS 일 때는 DONE 으로만 변경할 수 있다.") { + val board = Fixture.createBoard(status = MuckPotStatus.IN_PROGRESS) + + shouldThrow { + board.changeStatus(MuckPotStatus.IN_PROGRESS) + }.message shouldBe "변경 가능한 상태가 아닙니다." + } + + test("DONE 일 때는 IN_PROGRESS 으로만 변경할 수 있다.") { + val board = Fixture.createBoard(status = MuckPotStatus.DONE) + + shouldThrow { + board.changeStatus(MuckPotStatus.DONE) + }.message shouldBe "변경 가능한 상태가 아닙니다." + } + + test("모집인원이 마감된 경우에는 IN_PROGRESS 로 변경할 수 없다.") { + val board = Fixture.createBoard( + status = MuckPotStatus.DONE, + currentApply = 3, + maxApply = 3 + ) + shouldThrow { + board.changeStatus(MuckPotStatus.IN_PROGRESS) + }.message shouldBe "변경 가능한 상태가 아닙니다." + } + + test("IN_PROGRESS -> DONE 변경 성공") { + val board = Fixture.createBoard() + board.changeStatus(MuckPotStatus.DONE) + + board.status shouldBe MuckPotStatus.DONE + } + + test("DONE -> IN_PROGRESS 변경 성공") { + val board = Fixture.createBoard(status = MuckPotStatus.DONE) + board.changeStatus(MuckPotStatus.IN_PROGRESS) + + board.status shouldBe MuckPotStatus.IN_PROGRESS + } + } +}) diff --git a/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/repository/BoardQuerydslRepositoryTest.kt b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/repository/BoardQuerydslRepositoryTest.kt new file mode 100644 index 00000000..06da21cc --- /dev/null +++ b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/repository/BoardQuerydslRepositoryTest.kt @@ -0,0 +1,97 @@ +package com.yapp.muckpot.domains.board.repository + +import Fixture +import com.querydsl.jpa.impl.JPAQueryFactory +import com.yapp.muckpot.config.CustomDataJpaTest +import com.yapp.muckpot.domains.board.entity.Board +import com.yapp.muckpot.domains.user.entity.MuckPotUser +import com.yapp.muckpot.domains.user.enums.MuckPotStatus +import com.yapp.muckpot.domains.user.repository.MuckPotUserRepository +import io.kotest.core.spec.style.StringSpec +import io.kotest.extensions.spring.SpringExtension +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import java.time.LocalDateTime + +@CustomDataJpaTest +class BoardQuerydslRepositoryTest( + private val userRepository: MuckPotUserRepository, + private val boardRepository: BoardRepository, + private val jpaQueryFactory: JPAQueryFactory +) : StringSpec({ + val boardQuerydslRepository = BoardQuerydslRepository(jpaQueryFactory) + + lateinit var user: MuckPotUser + lateinit var boards: List + + beforeEach { + user = Fixture.createUser() + boards = listOf( + Fixture.createBoard(title = "board1", user = user).apply { createdAt = LocalDateTime.now() }, + Fixture.createBoard(title = "board2", user = user).apply { createdAt = LocalDateTime.now().plusDays(1) }, + Fixture.createBoard(title = "board3", user = user).apply { createdAt = LocalDateTime.now().plusDays(2) } + ) + + userRepository.save(user) + boardRepository.saveAll(boards) + } + + afterEach { + boardRepository.deleteAll() + userRepository.deleteAll() + } + + "countPerScroll이 2인 경우" { + val countPerScroll = 2 + // when + val result = boardQuerydslRepository.findAllWithPagination(null, countPerScroll.toLong()) + // then + result shouldHaveSize countPerScroll + } + + "생성일자 기준 내림차순 정렬" { + // when + val result = boardQuerydslRepository.findAllWithPagination(null, 3) + // then + result[0].id shouldBe boards[2].id + result[1].id shouldBe boards[1].id + result[2].id shouldBe boards[0].id + } + + "이전 아이디는 현재 게시글 이후에 등록된 첫번째 글이다." { + // when + val todayPrev = boardQuerydslRepository.findPrevId(boards[0].id!!) + val tomorrowPrev = boardQuerydslRepository.findPrevId(boards[1].id!!) + val twoDaysLaterPrev = boardQuerydslRepository.findPrevId(boards[2].id!!) + // then + todayPrev shouldBe boards[1].id + tomorrowPrev shouldBe boards[2].id + twoDaysLaterPrev shouldBe null + } + + "다음 아이디는 현재 게시글 이전에 등록된 마지막 글이다." { + // when + val todayNext = boardQuerydslRepository.findNextId(boards[0].id!!) + val tomorrowNext = boardQuerydslRepository.findNextId(boards[1].id!!) + val twoDaysLaterNext = boardQuerydslRepository.findNextId(boards[2].id!!) + // then + todayNext shouldBe null + tomorrowNext shouldBe boards[0].id + twoDaysLaterNext shouldBe boards[1].id + } + + "현재시간 미만의 먹팟 상태 업데이트" { + // given + boards[0].apply { meetingTime = LocalDateTime.now().minusDays(1) } + boards[1].apply { meetingTime = LocalDateTime.now().minusDays(1) } + boards[2].apply { meetingTime = LocalDateTime.now().plusDays(1) } + boardRepository.saveAll(boards) + // when + boardQuerydslRepository.updateLessThanCurrentTime() + // then + val actual = boardRepository.findByStatus(MuckPotStatus.IN_PROGRESS) + actual shouldHaveSize 1 + } +}) { + override fun extensions() = listOf(SpringExtension) +} diff --git a/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantQuerydslRepositoryTest.kt b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantQuerydslRepositoryTest.kt new file mode 100644 index 00000000..d19dd333 --- /dev/null +++ b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantQuerydslRepositoryTest.kt @@ -0,0 +1,73 @@ +package com.yapp.muckpot.domains.board.repository + +import Fixture +import com.querydsl.jpa.impl.JPAQueryFactory +import com.yapp.muckpot.config.CustomDataJpaTest +import com.yapp.muckpot.domains.board.entity.Board +import com.yapp.muckpot.domains.board.entity.Participant +import com.yapp.muckpot.domains.user.entity.MuckPotUser +import com.yapp.muckpot.domains.user.repository.MuckPotUserRepository +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import java.time.LocalDateTime + +@CustomDataJpaTest +class ParticipantQuerydslRepositoryTest( + private val participantRepository: ParticipantRepository, + private val userRepository: MuckPotUserRepository, + private val boardRepository: BoardRepository, + private val jpaQueryFactory: JPAQueryFactory +) : StringSpec({ + val participantQuerydslRepository = ParticipantQuerydslRepository(jpaQueryFactory) + + lateinit var users: List + lateinit var boards: List + lateinit var participants: List + + beforeEach { + users = listOf( + Fixture.createUser(), + Fixture.createUser(), + Fixture.createUser() + ) + boards = listOf( + Fixture.createBoard(user = users[0]), + Fixture.createBoard(title = "board2", user = users[0]), + Fixture.createBoard(title = "board3", user = users[0]) + ) + participants = listOf( + Fixture.createParticipant(users[0], boards[0]), + Fixture.createParticipant(users[1], boards[0]), + Fixture.createParticipant(users[0], boards[1]), + Fixture.createParticipant(users[0], boards[2]) + .apply { createdAt = LocalDateTime.now().minusDays(3) } + ) + userRepository.saveAll(users) + boardRepository.saveAll(boards) + participantRepository.saveAll(participants) + } + + afterEach { + participantRepository.deleteAll() + boardRepository.deleteAll() + userRepository.deleteAll() + } + + "먹팟_ID 리스트를 조건으로 조회 성공" { + val boardIds = listOf(boards[0].id, boards[2].id) + // when + val actual = participantQuerydslRepository.findByBoardIds(boardIds) + // then + actual shouldHaveSize 3 + } + + "참여자 목록은 빠른 순서 정렬" { + val boardIds = listOf(boards[0].id, boards[2].id) + // when + val actual = participantQuerydslRepository.findByBoardIds(boardIds).first() + // then + actual.boardId shouldBe participants[3].board.id + actual.userId shouldBe participants[3].user.id + } +}) diff --git a/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantRepositoryTest.kt b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantRepositoryTest.kt new file mode 100644 index 00000000..0eceaed8 --- /dev/null +++ b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/board/repository/ParticipantRepositoryTest.kt @@ -0,0 +1,63 @@ +package com.yapp.muckpot.domains.board.repository + +import Fixture +import com.yapp.muckpot.config.CustomDataJpaTest +import com.yapp.muckpot.domains.board.entity.Participant +import com.yapp.muckpot.domains.user.repository.MuckPotUserRepository +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.springframework.beans.factory.annotation.Autowired + +@CustomDataJpaTest +class ParticipantRepositoryTest( + @Autowired val muckPotUserRepository: MuckPotUserRepository, + @Autowired val boardRepository: BoardRepository, + @Autowired val participantRepository: ParticipantRepository +) : StringSpec({ + + afterEach { + participantRepository.deleteAll() + boardRepository.deleteAll() + muckPotUserRepository.deleteAll() + } + + "Participant 데이터 저장 성공" { + // given + val user = Fixture.createUser() + val board = Fixture.createBoard(user = user) + muckPotUserRepository.save(user) + boardRepository.save(board) + // when + val saveParticipant = participantRepository.save(Participant(user, board)) + // then + saveParticipant.createdAt shouldNotBe null + } + + "Participant 데이터 소프트 삭제 성공" { + // given + val user = muckPotUserRepository.save(Fixture.createUser()) + val board = boardRepository.save(Fixture.createBoard(user = user)) + participantRepository.save(Participant(user, board)) + + // when + participantRepository.deleteByBoard(board) + + // then + val actual = participantRepository.findByBoard(board) + actual shouldHaveSize 0 + } + + "유저와 보드로 조회 할 수 있다." { + // given + val user = muckPotUserRepository.save(Fixture.createUser()) + val board = boardRepository.save(Fixture.createBoard(user = user)) + participantRepository.save(Participant(user, board)) + // when + val actual = participantRepository.findByUserAndBoard(user, board)!! + // then + actual.user.id shouldBe user.id + actual.board.id shouldBe board.id + } +}) diff --git a/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/user/entity/MuckPotUserTest.kt b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/user/entity/MuckPotUserTest.kt new file mode 100644 index 00000000..8a5df3e0 --- /dev/null +++ b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/user/entity/MuckPotUserTest.kt @@ -0,0 +1,37 @@ +package com.yapp.muckpot.domains.user.entity + +import com.yapp.muckpot.common.Location +import com.yapp.muckpot.common.enums.Gender +import com.yapp.muckpot.domains.user.enums.JobGroupMain +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.Point + +class MuckPotUserTest : FunSpec({ + val point: Point = GeometryFactory().createPoint(Coordinate(40.7128, -74.0060)) + + context("MuckPotUser 유효성 검사") { + test("올바르지 않은 이메일 포맷") { + // when & then + shouldThrow { + MuckPotUser( + null, "email#email.com", "pw", "nickname", + Gender.MEN, 2000, JobGroupMain.DEVELOPMENT, "sub", Location("location", point), "url" + ) + }.message shouldBe "유효한 이메일 형식이 아닙니다" + } + + test("올바르지 않은 출생년도") { + // when & then + shouldThrow { + MuckPotUser( + null, "email@email.com", "pw", "nickname", + Gender.MEN, 1899, JobGroupMain.DEVELOPMENT, "sub", Location("location", point), "url" + ) + }.message shouldBe "잘못된 출생 연도입니다" + } + } +}) diff --git a/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/user/repository/MuckPotUserRepositoryTest.kt b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/user/repository/MuckPotUserRepositoryTest.kt new file mode 100644 index 00000000..44df1129 --- /dev/null +++ b/muckpot-domain/src/test/kotlin/com/yapp/muckpot/domains/user/repository/MuckPotUserRepositoryTest.kt @@ -0,0 +1,28 @@ +package com.yapp.muckpot.domains.user.repository + +import Fixture.createUser +import com.yapp.muckpot.config.CustomDataJpaTest +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.springframework.beans.factory.annotation.Autowired + +@CustomDataJpaTest +class MuckPotUserRepositoryTest( + @Autowired val muckPotUserRepository: MuckPotUserRepository +) : StringSpec({ + "Point 타입 저장 성공" { + // given + val muckPotUser = createUser() + // when + val saveUser = muckPotUserRepository.save(muckPotUser) + // then + saveUser.id shouldNotBe null + } + + "findByEmail 호출 성공" { + val user = muckPotUserRepository.findByEmail("user@naver.com") + + user shouldBe null + } +}) diff --git a/muckpot-domain/src/testFixtures/kotlin/Fixture.kt b/muckpot-domain/src/testFixtures/kotlin/Fixture.kt new file mode 100644 index 00000000..d6b41a29 --- /dev/null +++ b/muckpot-domain/src/testFixtures/kotlin/Fixture.kt @@ -0,0 +1,85 @@ +import com.yapp.muckpot.common.Location +import com.yapp.muckpot.common.constants.AGE_MAX +import com.yapp.muckpot.common.constants.AGE_MIN +import com.yapp.muckpot.common.enums.Gender +import com.yapp.muckpot.common.enums.State +import com.yapp.muckpot.domains.board.entity.Board +import com.yapp.muckpot.domains.board.entity.Participant +import com.yapp.muckpot.domains.user.entity.MuckPotUser +import com.yapp.muckpot.domains.user.enums.JobGroupMain +import com.yapp.muckpot.domains.user.enums.MuckPotStatus +import java.time.LocalDateTime +import java.util.* + +object Fixture { + fun createUser( + id: Long? = null, + email: String = UUID.randomUUID().toString().substring(0, 5) + "@naver.com", + password: String = "abcd1234", + nickName: String = UUID.randomUUID().toString(), + gender: Gender = Gender.MEN, + yearOfBirth: Int = 2000, + mainCategory: JobGroupMain = JobGroupMain.DEVELOPMENT, + subCategory: String? = "subCategory", + location: Location = Location("userLocation", 40.7128, -74.0060), + imageUrl: String? = "image_url", + state: State = State.ACTIVE + ): MuckPotUser { + return MuckPotUser( + id, + email, + password, + nickName, + gender, + yearOfBirth, + mainCategory, + subCategory, + location, + imageUrl, + state + ) + } + + fun createBoard( + id: Long? = null, + user: MuckPotUser = createUser(), + title: String = "board_title", + location: Location = Location("boardLocation", 40.7128, -74.0060), + locationDetail: String? = null, + meetingTime: LocalDateTime = LocalDateTime.now().plusMinutes(30), + content: String? = "content", + views: Int = 0, + currentApply: Int = 0, + maxApply: Int = 2, + chatLink: String = "chat_link", + status: MuckPotStatus = MuckPotStatus.IN_PROGRESS, + minAge: Int = AGE_MIN, + maxAge: Int = AGE_MAX, + state: State = State.ACTIVE + ): Board { + return Board( + id, + user, + title, + location, + locationDetail, + meetingTime, + content, + views, + currentApply, + maxApply, + chatLink, + status, + minAge, + maxAge, + state + ) + } + + fun createParticipant( + user: MuckPotUser = createUser(), + board: Board = createBoard() + ): Participant { + return Participant(user, board) + } +} diff --git a/muckpot-domain/src/testFixtures/kotlin/config/DomainContainerManager.kt b/muckpot-domain/src/testFixtures/kotlin/config/DomainContainerManager.kt new file mode 100644 index 00000000..90ce0c7d --- /dev/null +++ b/muckpot-domain/src/testFixtures/kotlin/config/DomainContainerManager.kt @@ -0,0 +1,31 @@ +package config + +import io.kotest.core.listeners.AfterProjectListener +import io.kotest.core.listeners.BeforeProjectListener +import io.kotest.core.spec.AutoScan +import org.testcontainers.containers.MariaDBContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.utility.DockerImageName + +@AutoScan +object DomainContainerManager : BeforeProjectListener, AfterProjectListener { + private val DOMAIN_PROPERTIES = DomainProperties + + @Container + private val mariaDBContainer = MariaDBContainer(DockerImageName.parse("mariadb:10.5")).apply { + withDatabaseName("muckpot_test") + withUsername("test_user") + withPassword("12345") + } + + override suspend fun beforeProject() { + mariaDBContainer.start() + System.setProperty("spring.datasource.url", mariaDBContainer.jdbcUrl) + System.setProperty("spring.datasource.username", mariaDBContainer.username) + System.setProperty("spring.datasource.password", mariaDBContainer.password) + } + + override suspend fun afterProject() { + mariaDBContainer.stop() + } +} diff --git a/muckpot-domain/src/testFixtures/kotlin/config/DomainProperties.kt b/muckpot-domain/src/testFixtures/kotlin/config/DomainProperties.kt new file mode 100644 index 00000000..7c895d3a --- /dev/null +++ b/muckpot-domain/src/testFixtures/kotlin/config/DomainProperties.kt @@ -0,0 +1,12 @@ +package config + +object DomainProperties { + init { + System.setProperty("spring.jpa.hibernate.ddl-auto", "update") + System.setProperty("spring.jpa.generate-ddl", "true") + System.setProperty("spring.jpa.properties.hibernate.dialect", "org.hibernate.spatial.dialect.mariadb.MariaDB103SpatialDialect") + System.setProperty("spring.jpa.properties.hibernate.format_sql", "true") + System.setProperty("spring.jpa.show_sql", "true") + System.setProperty("logging.level.org.hibernate.type", "trace") + } +} diff --git a/muckpot-infra/build.gradle.kts b/muckpot-infra/build.gradle.kts new file mode 100644 index 00000000..4e944719 --- /dev/null +++ b/muckpot-infra/build.gradle.kts @@ -0,0 +1,21 @@ +val kotestVersion = "5.5.4" +val testContainerVersion = "1.17.6" + +plugins { + `java-test-fixtures` +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-data-redis") + implementation("org.springframework.boot:spring-boot-starter-mail") + implementation("org.redisson:redisson:3.20.0") + + testFixturesImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + testFixturesImplementation("org.testcontainers:testcontainers:$testContainerVersion") + testFixturesImplementation("org.testcontainers:junit-jupiter:$testContainerVersion") +} + +tasks { + withType { enabled = true } + withType { enabled = false } +} diff --git a/muckpot-infra/src/main/kotlin/com/yapp/muckpot/email/EmailConfig.kt b/muckpot-infra/src/main/kotlin/com/yapp/muckpot/email/EmailConfig.kt new file mode 100644 index 00000000..13e6440d --- /dev/null +++ b/muckpot-infra/src/main/kotlin/com/yapp/muckpot/email/EmailConfig.kt @@ -0,0 +1,41 @@ +package com.yapp.muckpot.email + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.mail.javamail.JavaMailSenderImpl +import java.util.* + +@Configuration +class EmailConfig( + @Value("\${spring.mail.host}") + private val host: String, + + @Value("\${spring.mail.port}") + private val port: Int, + + @Value("\${spring.mail.username}") + private val username: String, + + @Value("\${spring.mail.password}") + private val password: String +) { + + @Bean + fun javaMailSender(): JavaMailSender { + val mailSender = JavaMailSenderImpl().apply { + host = this@EmailConfig.host + port = this@EmailConfig.port + username = this@EmailConfig.username + password = this@EmailConfig.password + } + + val javaMailProperties = Properties() // TLS 사용 연결 + javaMailProperties["mail.smtp.auth"] = "true" + javaMailProperties["mail.smtp.starttls.enable"] = "true" + mailSender.javaMailProperties = javaMailProperties + + return mailSender + } +} diff --git a/muckpot-infra/src/main/kotlin/com/yapp/muckpot/email/EmailService.kt b/muckpot-infra/src/main/kotlin/com/yapp/muckpot/email/EmailService.kt new file mode 100644 index 00000000..bc1842e9 --- /dev/null +++ b/muckpot-infra/src/main/kotlin/com/yapp/muckpot/email/EmailService.kt @@ -0,0 +1,33 @@ +package com.yapp.muckpot.email +import mu.KLogging +import org.springframework.mail.MailException +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.mail.javamail.MimeMessageHelper +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Service + +@Service +class EmailService(private val javaMailSender: JavaMailSender) { + + private val log = KLogging().logger + + @Async + fun sendAuthMail(authKey: String, to: String) { + try { + val subject = EmailTemplates.AUTH_EMAIL_SUBJECT + val text = EmailTemplates.AUTH_EMAIL_TEXT + val textSetting = text.formatText(authKey) + + val email = javaMailSender.createMimeMessage() + val helper = MimeMessageHelper(email, true, "UTF-8") + helper.setTo(to) + helper.setFrom("muckpotinfo@gmail.com") + helper.setTo(to) + helper.setSubject(subject) + helper.setText(textSetting, true) + javaMailSender.send(email) + } catch (e: MailException) { + log.error { "Failed to send email: ${e.message}" } + } + } +} diff --git a/muckpot-infra/src/main/kotlin/com/yapp/muckpot/email/EmailTemplates.kt b/muckpot-infra/src/main/kotlin/com/yapp/muckpot/email/EmailTemplates.kt new file mode 100644 index 00000000..facc1d9b --- /dev/null +++ b/muckpot-infra/src/main/kotlin/com/yapp/muckpot/email/EmailTemplates.kt @@ -0,0 +1,31 @@ +package com.yapp.muckpot.email + +object EmailTemplates { + + const val AUTH_EMAIL_SUBJECT = "먹팟 이메일 인증 요청입니다." + val AUTH_EMAIL_TEXT = EmailTemplate( + "\n" + + " 먹팟 for Samsung\n" + + " \n" + + "
\n" + + "
\n" + + " 안녕하세요, 먹팟 계정 생성을 환영합니다!
요청하신 인증코드는 아래와 같습니다
\n" + + " %s\n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + " 해당 이메일은 발신 전용입니다. 기타 문의사항은 하기의 카카오톡 채널을 통해 연락주시길 바랍니다.
\n" + + " http://pf.kakao.com/_NVcQxj \n" + + "
\n" + + "
감사합니다.
먹팟 코리아
\n" + + "
\n" + + "
" + ) + + class EmailTemplate(private val text: String) { + fun formatText(vararg args: Any): String { + return text.format(*args) + } + } +} diff --git a/muckpot-infra/src/main/kotlin/com/yapp/muckpot/redis/RedisConfig.kt b/muckpot-infra/src/main/kotlin/com/yapp/muckpot/redis/RedisConfig.kt new file mode 100644 index 00000000..9ef74f31 --- /dev/null +++ b/muckpot-infra/src/main/kotlin/com/yapp/muckpot/redis/RedisConfig.kt @@ -0,0 +1,43 @@ +package com.yapp.muckpot.redis + +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 +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.StringRedisSerializer + +@Configuration +class RedisConfig( + @Value("\${spring.redis.host}") + private val redisHost: String, + + @Value("\${spring.redis.port}") + private val redisPort: Int +) { + @Bean + fun redisConnectionFactory(): RedisConnectionFactory { + return LettuceConnectionFactory(redisHost, redisPort) + } + private val REDISSON_HOST_PREFIX = "redis://" + + @Bean + fun redisTemplate(): RedisTemplate { + val redisTemplate = RedisTemplate() + redisTemplate.setConnectionFactory(redisConnectionFactory()) + redisTemplate.keySerializer = StringRedisSerializer() + redisTemplate.valueSerializer = StringRedisSerializer() + return redisTemplate + } + + @Bean + fun redissonClient(): RedissonClient { + val config = Config() + config.useSingleServer().address = "$REDISSON_HOST_PREFIX$redisHost:$redisPort" + return Redisson.create(config) + } +} diff --git a/muckpot-infra/src/main/kotlin/com/yapp/muckpot/redis/RedisService.kt b/muckpot-infra/src/main/kotlin/com/yapp/muckpot/redis/RedisService.kt new file mode 100644 index 00000000..740cfeef --- /dev/null +++ b/muckpot-infra/src/main/kotlin/com/yapp/muckpot/redis/RedisService.kt @@ -0,0 +1,31 @@ +package com.yapp.muckpot.redis + +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.core.ValueOperations +import org.springframework.stereotype.Service +import java.time.Duration + +@Service +class RedisService(private val redisTemplate: RedisTemplate) { + + fun redisString(): String { + val operations: ValueOperations = redisTemplate.opsForValue() + operations.set("test", "테스트") + return operations.get("test") as String + } + + fun setDataExpireWithNewest(key: String, value: String, duration: Long) { + if (redisTemplate.hasKey(key)) { + redisTemplate.delete(key) + } + redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(duration)) + } + + fun deleteData(key: String) { + redisTemplate.delete(key) + } + + fun getData(key: String): Any? { + return redisTemplate.opsForValue().get(key) + } +} diff --git a/muckpot-infra/src/main/kotlin/com/yapp/muckpot/redis/RedissonCallNewTransaction.kt b/muckpot-infra/src/main/kotlin/com/yapp/muckpot/redis/RedissonCallNewTransaction.kt new file mode 100644 index 00000000..38fa3aeb --- /dev/null +++ b/muckpot-infra/src/main/kotlin/com/yapp/muckpot/redis/RedissonCallNewTransaction.kt @@ -0,0 +1,20 @@ +package com.yapp.muckpot.redis + +import org.aspectj.lang.ProceedingJoinPoint +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional + +@Component +class RedissonCallNewTransaction { + /** + * 부모 트랜잭션의 유무와 관계없이 동시성에 대한 처리는 새로운 별도의 트랜잭션으로 동작 보장 + * 트랜잭션의 타임아웃을 락 획득 후 유지 시간인 leaseTime(10)보다 작게 설정 + * (락 leaseTimeOut 발생 전에 트랜잭션 rollback 시키기 위해) + * ref: https://devnm.tistory.com/37 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 9) + fun proceed(joinPoint: ProceedingJoinPoint): Any? { + return joinPoint.proceed() + } +} diff --git a/muckpot-infra/src/main/resources/application-infra.yml b/muckpot-infra/src/main/resources/application-infra.yml new file mode 100644 index 00000000..20cf4829 --- /dev/null +++ b/muckpot-infra/src/main/resources/application-infra.yml @@ -0,0 +1,26 @@ +spring: + mail: + host: smtp.gmail.com + port: ${GOOGLE_SMTP_PORT:8080} + username: ${GOOGLE_SMTP_USERNAME:test} + password: ${GOOGLE_SMTP_PASSWORD:test} + +--- + +spring: + config: + activate: + on-profile: local + redis: + host: ${LOCAL_REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + +--- + +spring: + config: + activate: + on-profile: dev + redis: + host: ${DEV_REDIS_HOST} + port: ${REDIS_PORT} \ No newline at end of file diff --git a/muckpot-infra/src/test/kotlin/com/yapp/muckpot/InfraTestApplication.kt b/muckpot-infra/src/test/kotlin/com/yapp/muckpot/InfraTestApplication.kt new file mode 100644 index 00000000..c8682821 --- /dev/null +++ b/muckpot-infra/src/test/kotlin/com/yapp/muckpot/InfraTestApplication.kt @@ -0,0 +1,6 @@ +package com.yapp.muckpot + +import org.springframework.boot.autoconfigure.SpringBootApplication + +@SpringBootApplication +class InfraTestApplication diff --git a/muckpot-infra/src/test/kotlin/com/yapp/muckpot/redis/RedisServiceTest.kt b/muckpot-infra/src/test/kotlin/com/yapp/muckpot/redis/RedisServiceTest.kt new file mode 100644 index 00000000..c7da02f9 --- /dev/null +++ b/muckpot-infra/src/test/kotlin/com/yapp/muckpot/redis/RedisServiceTest.kt @@ -0,0 +1,16 @@ +package com.yapp.muckpot.redis + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import org.springframework.context.annotation.Import + +@Import(RedisConfig::class, RedisService::class) +class RedisServiceTest constructor( + private val redisService: RedisService +) : StringSpec({ + "샘플 테스트" { + val actual = redisService.redisString() + + actual shouldBe "테스트" + } +}) diff --git a/muckpot-infra/src/testFixtures/kotlin/config/InfraContainerManager.kt b/muckpot-infra/src/testFixtures/kotlin/config/InfraContainerManager.kt new file mode 100644 index 00000000..493ada1d --- /dev/null +++ b/muckpot-infra/src/testFixtures/kotlin/config/InfraContainerManager.kt @@ -0,0 +1,29 @@ +package config + +import io.kotest.core.listeners.AfterProjectListener +import io.kotest.core.listeners.BeforeProjectListener +import io.kotest.core.spec.AutoScan +import org.testcontainers.containers.GenericContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.utility.DockerImageName + +@AutoScan +object InfraContainerManager : BeforeProjectListener, AfterProjectListener { + private val INFRA_PROPERTIES = InfraProperties + private const val REDIS_PORT = 6379 + + @Container + private val redisContainer = GenericContainer(DockerImageName.parse("redis:alpine")).apply { + withExposedPorts(REDIS_PORT) + } + + override suspend fun beforeProject() { + redisContainer.start() + System.setProperty("spring.redis.host", redisContainer.host) + System.setProperty("spring.redis.port", redisContainer.getMappedPort(REDIS_PORT).toString()) + } + + override suspend fun afterProject() { + redisContainer.stop() + } +} diff --git a/muckpot-infra/src/testFixtures/kotlin/config/InfraProperties.kt b/muckpot-infra/src/testFixtures/kotlin/config/InfraProperties.kt new file mode 100644 index 00000000..295206c1 --- /dev/null +++ b/muckpot-infra/src/testFixtures/kotlin/config/InfraProperties.kt @@ -0,0 +1,10 @@ +package config + +object InfraProperties { + init { + System.setProperty("spring.mail.host", "smtp.gmail.com") + System.setProperty("spring.mail.port", "8080") + System.setProperty("spring.mail.username", "test") + System.setProperty("spring.mail.password", "test") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 6d7299bf..e98209c6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,7 @@ -rootProject.name = "Web-1-BE" -include("web1-api") -include("web1-domain") -include("web1-infra") +rootProject.name = "Muckpot-BE" + +include( + "muckpot-api", + "muckpot-domain", + "muckpot-infra" +) diff --git a/web1-api/build.gradle.kts b/web1-api/build.gradle.kts deleted file mode 100644 index c8b1355a..00000000 --- a/web1-api/build.gradle.kts +++ /dev/null @@ -1,4 +0,0 @@ -dependencies { - implementation("org.springframework.boot:spring-boot-starter-web") - implementation(project(":web1-domain")) -} diff --git a/web1-api/src/main/kotlin/com/yapp/web1be/Application.kt b/web1-api/src/main/kotlin/com/yapp/web1be/Application.kt deleted file mode 100644 index baf76ca9..00000000 --- a/web1-api/src/main/kotlin/com/yapp/web1be/Application.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.yapp.web1be - -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.runApplication - -@SpringBootApplication -class Application - -fun main(args: Array) { - runApplication(*args) -} diff --git a/web1-api/src/main/kotlin/com/yapp/web1be/test/TestController.kt b/web1-api/src/main/kotlin/com/yapp/web1be/test/TestController.kt deleted file mode 100644 index d011b83d..00000000 --- a/web1-api/src/main/kotlin/com/yapp/web1be/test/TestController.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.yapp.web1be.test - -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RestController - -@RestController -class TestController( private val testService: TestService) { - - @GetMapping - fun test(): TestEntity { - return testService.test() - } - -} diff --git a/web1-api/src/main/kotlin/com/yapp/web1be/test/TestService.kt b/web1-api/src/main/kotlin/com/yapp/web1be/test/TestService.kt deleted file mode 100644 index 2b4b7e66..00000000 --- a/web1-api/src/main/kotlin/com/yapp/web1be/test/TestService.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.yapp.web1be.test - -import com.yapp.web1be.test.TestEntity -import org.springframework.stereotype.Service - -@Service -class TestService { - - fun test(): TestEntity { - return TestEntity(1, "test") - } -} \ No newline at end of file diff --git a/web1-api/src/main/resources/application-local.yml b/web1-api/src/main/resources/application-local.yml deleted file mode 100644 index e69de29b..00000000 diff --git a/web1-api/src/main/resources/application.yml b/web1-api/src/main/resources/application.yml deleted file mode 100644 index ef46c2ad..00000000 --- a/web1-api/src/main/resources/application.yml +++ /dev/null @@ -1,3 +0,0 @@ -spring: - profiles: - active: local \ No newline at end of file diff --git a/web1-api/src/test/kotlin/com/yapp/web1be/ApplicationTests.kt b/web1-api/src/test/kotlin/com/yapp/web1be/ApplicationTests.kt deleted file mode 100644 index f2ae8f88..00000000 --- a/web1-api/src/test/kotlin/com/yapp/web1be/ApplicationTests.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.yapp.web1be - -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest - -@SpringBootTest -class ApplicationTests { - - @Test - fun contextLoads() { - } -} diff --git a/web1-domain/build.gradle.kts b/web1-domain/build.gradle.kts deleted file mode 100644 index b3c40484..00000000 --- a/web1-domain/build.gradle.kts +++ /dev/null @@ -1,15 +0,0 @@ -dependencies { - runtimeOnly("com.h2database:h2") -} - -allOpen { - annotation("javax.persistence.Entity") - annotation("javax.persistence.Embeddable") - annotation("javax.persistence.MappedSuperclass") -} - -val jar: Jar by tasks -val bootJar: org.springframework.boot.gradle.tasks.bundling.BootJar by tasks - -bootJar.enabled = false -jar.enabled = true diff --git a/web1-domain/src/main/kotlin/com/yapp/web1be/test/TestEntity.kt b/web1-domain/src/main/kotlin/com/yapp/web1be/test/TestEntity.kt deleted file mode 100644 index ab018585..00000000 --- a/web1-domain/src/main/kotlin/com/yapp/web1be/test/TestEntity.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.yapp.web1be.test - -import javax.persistence.Entity -import javax.persistence.Id - -@Entity -data class TestEntity( - @Id - val id: Long, - val name: String -) diff --git a/web1-domain/src/main/resources/application-local.yml b/web1-domain/src/main/resources/application-local.yml deleted file mode 100644 index d8ef8af4..00000000 --- a/web1-domain/src/main/resources/application-local.yml +++ /dev/null @@ -1,9 +0,0 @@ -spring: - datasource: - driver-class-name: - url: jdbc:h2:mem:testdb;MODE=MySQL - username: sa - password: - jpa: - hibernate: - ddl-auto: create-drop \ No newline at end of file diff --git a/web1-domain/src/main/resources/application.yml b/web1-domain/src/main/resources/application.yml deleted file mode 100644 index ef46c2ad..00000000 --- a/web1-domain/src/main/resources/application.yml +++ /dev/null @@ -1,3 +0,0 @@ -spring: - profiles: - active: local \ No newline at end of file diff --git a/web1-domain/src/test/kotlin/com/yapp/web1be/ApplicationTests.kt b/web1-domain/src/test/kotlin/com/yapp/web1be/ApplicationTests.kt deleted file mode 100644 index f2ae8f88..00000000 --- a/web1-domain/src/test/kotlin/com/yapp/web1be/ApplicationTests.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.yapp.web1be - -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest - -@SpringBootTest -class ApplicationTests { - - @Test - fun contextLoads() { - } -} diff --git a/web1-infra/build.gradle.kts b/web1-infra/build.gradle.kts deleted file mode 100644 index 79c6eaf7..00000000 --- a/web1-infra/build.gradle.kts +++ /dev/null @@ -1,8 +0,0 @@ -dependencies { - -} -val jar: Jar by tasks -val bootJar: org.springframework.boot.gradle.tasks.bundling.BootJar by tasks - -bootJar.enabled = false -jar.enabled = true diff --git a/web1-infra/src/main/resources/application-local.yml b/web1-infra/src/main/resources/application-local.yml deleted file mode 100644 index e69de29b..00000000 diff --git a/web1-infra/src/main/resources/application.yml b/web1-infra/src/main/resources/application.yml deleted file mode 100644 index ef46c2ad..00000000 --- a/web1-infra/src/main/resources/application.yml +++ /dev/null @@ -1,3 +0,0 @@ -spring: - profiles: - active: local \ No newline at end of file diff --git a/web1-infra/src/test/kotlin/com/yapp/web1be/ApplicationTests.kt b/web1-infra/src/test/kotlin/com/yapp/web1be/ApplicationTests.kt deleted file mode 100644 index f2ae8f88..00000000 --- a/web1-infra/src/test/kotlin/com/yapp/web1be/ApplicationTests.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.yapp.web1be - -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest - -@SpringBootTest -class ApplicationTests { - - @Test - fun contextLoads() { - } -}