From ed3eabd85c6b5cae6ef0a2a78a80c4f8d8ddfc73 Mon Sep 17 00:00:00 2001 From: BOMIN LYU <83059096+rbm0524@users.noreply.github.com> Date: Fri, 1 Nov 2024 21:56:41 +0900 Subject: [PATCH] =?UTF-8?q?9=EC=A3=BC=EC=B0=A8=20Develop=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: SimpleJpaXXXRepository 추가 * feat: JpaProductRepository 추가 * feat: JpaPaymentOrderRepository 추가 * feat: JpaPaymentEventRepository 추가 * feat: 멱등성 키 생성기 구현 * feat: 결제 준비 DTO 구현 * feat: 결제 주문 응답 DTO 구현 * feat: 결제 준비 기능 구현 * test: PaymentDatabaseHelper 추가 * test: JpaDatabaseCleanup 추가 * test: JpaPaymentDatabaseHelper 추가 * test: test 설정 파일 추가 * test: 결제 준비 기능 테스트 추가 - 결제 준비에 대한 정상 케이스 - 중복 결제 준비 요청에 대한 예외 발생 케이스 * feat: application.yml data.sql 수행 설정 * feat: data.sql 데이터 추가 * chore: build.gradle 스프링 시큐리티 의존성 제거 * feat: 결제 Repository 빈 등록 * feat: PaymentController 결제 준비 엔드포인트 구현 * feat: Api 응답 본문 구조 선언 * feat: 결제 주문 엔티티와 상품 연관관계 삭제 * feat: 결제 상태에 description 필드 추가 * feat: Entity 구현 * feat: Domain 구현 * feat: Mapper 구현 * feat: ProductRepository 추가 * feat: PaymentOrderRepository 추가 * feat: PaymentEventRepository 추가 * feat: SimpleJpaXXXRepository 추가 * feat: JpaProductRepository 추가 * feat: JpaPaymentOrderRepository 추가 * feat: JpaPaymentEventRepository 추가 * feat: 멱등성 키 생성기 구현 * feat: 결제 준비 DTO 구현 * feat: 결제 주문 응답 DTO 구현 * feat: 결제 준비 기능 구현 * test: PaymentDatabaseHelper 추가 * test: JpaDatabaseCleanup 추가 * test: JpaPaymentDatabaseHelper 추가 * test: test 설정 파일 추가 * test: 결제 준비 기능 테스트 추가 - 결제 준비에 대한 정상 케이스 - 중복 결제 준비 요청에 대한 예외 발생 케이스 * feat: application.yml data.sql 수행 설정 * feat: data.sql 데이터 추가 * feat: 결제 Repository 빈 등록 * feat: PaymentController 결제 준비 엔드포인트 구현 * feat: Api 응답 본문 구조 선언 * style: 파일 끝 빈 줄 추가 * feat: DTO record로 변경 * rename: package 이름 수정 memebr -> member * style: 오타 수정 * rename: 오타 수정 * chore: spring security 의존성 제거 사용하지 않음 * style: 코드 린트 적용 * style: 코드 린트 적용 * feat: application-test.yml 누락된 설정 추가 * feat: 애플리케이션 컨텍스트 로드 테스트 test 프로파일 활성화 * test: 결제 준비 테스트 유지보수 DTO 가 record 타입으로 변경됨에 따른 수정 * feat: 주문 상세 정보 생성 dto 작성 * feat: OrderDetailServcie 생성 주문 상세 정보 생성 메서드만 작성 추후 RUD도 제작 예정 * feat: OrderDetailController 생성 우선적으로 주문 생성만 만들어둠 * fix: member패키지 오타 수정 적용 및 spot 엔티티 필드 명 변경 적용 * feat: swagger config 작성 * feat: swagger 의존성 추가 * style: spotless 적용 * chore: build.gradle 버전 변수 추출 및 spotless 설정 변경 spotless 들여쓰기가 공백 2자에서 4자로 적용되도록 수정 * feat: BaseEntity 추가 * feat: AuditorProvider 구현 * feat: 영속성 설정 클래스 추가 * feat: 애플리케이션 실행 클래스 @EnableJpaAuditing 제거 PersistenceConfig 에서 적용되어 중복 제거 * feat: 카카오 로그인 구현 * refactor: 인텔리제이 마지막 빈 줄 설정 옵션 적용 * refactor: 불필요한 어노테이션 제거 * refactor: RestClient Bean으로 등록 후 Autowired 적용 * refactor: 구체적인 버전은 따로 변수로 추출해서 한 곳에서 관리 * refactor: 불필요한 어노테이션 제거 Json 필드 이름과 KakaoAccount 객체의 필드 이름이 같음 * refactor: @RequiredArgsConstructor 적용 생성자 생략 가능 * refactor: 정적 팩토리 메서드 네이밍 방식 반영 * feat: swagger config 작성 * feat: swagger 의존성 추가 * feat: Soft delete를 위해 isDeleted 속성 추가 * refactor: isDeleted가 false인 것만 조회하도록 수정 * refactor: isDeleted가 false인 것만 조회하도록 수정 * refactor: isDeleted 추가 * refactor: BaseEntity 상속하도록 수정 * refactor: createdAt, updateAt 필드 추가 * feat: ENUM 타입의 category 추가 * refactor: category의 타입을 Category(ENUM 클래스)로 변경 * feat: ENUM 클래스의 converter에 필요한 CodedEnum 인터페이스 작성 * feat: ENUM 클래스의 converter 작성 * style: 코드 컨벤션 수정 * style: camel case로 변경 * refactor: setter로 변경하던 isDeleted를 delete와 restore 메서드로 변경 * refactor: dirty checking하므로 save가 필요 없어서 삭제 * fix: Converter 어노테이션 붙임 * feat: 결제 주문 엔티티와 상품 연관관계 삭제 * feat: 결제 상태에 description 필드 추가 * feat: Entity 구현 * feat: Domain 구현 * feat: Mapper 구현 * feat: ProductRepository 추가 * feat: PaymentOrderRepository 추가 * feat: PaymentEventRepository 추가 * feat: SimpleJpaXXXRepository 추가 * feat: JpaProductRepository 추가 * feat: JpaPaymentOrderRepository 추가 * feat: JpaPaymentEventRepository 추가 * feat: 멱등성 키 생성기 구현 * feat: 결제 준비 DTO 구현 * feat: 결제 주문 응답 DTO 구현 * feat: 결제 준비 기능 구현 * test: PaymentDatabaseHelper 추가 * test: JpaDatabaseCleanup 추가 * test: JpaPaymentDatabaseHelper 추가 * test: test 설정 파일 추가 * test: 결제 준비 기능 테스트 추가 - 결제 준비에 대한 정상 케이스 - 중복 결제 준비 요청에 대한 예외 발생 케이스 * feat: application.yml data.sql 수행 설정 * feat: data.sql 데이터 추가 * feat: 결제 Repository 빈 등록 * feat: PaymentController 결제 준비 엔드포인트 구현 * feat: Api 응답 본문 구조 선언 * style: 파일 끝 빈 줄 추가 * feat: DTO record로 변경 * rename: package 이름 수정 memebr -> member * style: 오타 수정 * rename: 오타 수정 * style: 코드 린트 적용 * style: 코드 린트 적용 * feat: application-test.yml 누락된 설정 추가 * feat: 애플리케이션 컨텍스트 로드 테스트 test 프로파일 활성화 * test: 결제 준비 테스트 유지보수 DTO 가 record 타입으로 변경됨에 따른 수정 * fix: OrderDetail 엔티티 수정 팀원들과 회의 후 해당 엔티티 구조를 수정하였고, OrderParticipant는 필요가 없다고 판단 * remove: OrderParticipant 삭제 * remove: OrderParticipant 레포지토리 삭제 * refactor: OrderDetail 수정된 엔티티에 맞게 리팩토링 * fix: 충돌 해결 * feat: PaymentOrderRepository 결제 최종 금액 계산 구현 * refactor: JpaPaymentOrderRepository 조회 실패 시, 발생할 예외 구체화 IllegalArgumentException -> NoSuchElementException * feat: 결제 유효성 검사 구현 * style: PaymentValidationServiceTest 오타 수정 * feat: Mapstruct 사용을 위한 의존성 추가 * feat: Controller 계층 DTO 작성 * feat: Mapstruct를 이용해 SpotMapper 생성 * refactor: 이름 수정 * feat: SimpleSpotRepository를 가지는 Repository 계층 생성 * refactor: Controller 계층의 Dto를 반환하도록 수정, Service 계층의 Dto를 인자로 넘기도록 수정 * refactor: servicedto 패키지로 이동, accessLevel 조정 * refactor: 매핑 방식 변경 * feat: Member의 PK를 참조하도록 수정 * refactor: 조회 메서드 readOnly로 변경 * style: spotless 적용 # Conflicts: # src/main/java/com/ordertogether/team14_be/memebr/persistence/entity/Member.java # src/main/java/com/ordertogether/team14_be/payment/domain/PaymentEvent.java # src/main/java/com/ordertogether/team14_be/payment/domain/PaymentOrder.java # src/main/java/com/ordertogether/team14_be/payment/domain/PaymentOrderStatus.java # src/main/java/com/ordertogether/team14_be/payment/domain/Product.java # src/main/java/com/ordertogether/team14_be/spot/controller/SpotController.java # src/main/java/com/ordertogether/team14_be/spot/dto/SpotDto.java # src/main/java/com/ordertogether/team14_be/spot/entity/Spot.java # src/main/java/com/ordertogether/team14_be/spot/repository/SpotRepository.java # src/main/java/com/ordertogether/team14_be/spot/service/SpotService.java * feat: 카카오 로그인 구현 * refactor: @RequiredArgsConstructor 적용 생성자 생략 가능 * feat: PaymentOrder 와 PaymentEvent 연관관계 추가 * feat: 결제 상태에 description 필드 추가 * feat: Domain 구현 * rename: package 이름 수정 memebr -> member * style: 전역 상수변수 같은 경우에는 변수명을 대문자로 사용 * refactor: jwt에 사용자 정보를 담지 않음 email 대신 사용자의 아이디(pk)를 담음 * refactor: 생성자 주입방식 하나로 통일, @Autowired 지양 * refactor: Enum의 이름을 그대로 반환하도록 수정 * feat: 반경 n미터 내 Spot 조회하기 작성 * feat: 반경 n미터 내 Spot들만 처리해서 반환하는 메서드 작성 * feat: 최대/최소 경도, 위도 내에 해당하는 Spot을 반환하는 메서드 작성 * feat: 최대/최소 경도, 위도 내에 해당하는 Spot을 반환하는 쿼리 작성 * feat: SpotModifyRequest와 SpotDto를 매핑하는 메서드 추가 * refactor: NotNull로 Null 방지 * feat: ErrorResponse 정의 * feat: ErrorCode 정의 * feat: id에 해당하는 Spot이 없는 경우 Exception 정의 * feat: SpotExceptionHandler 작성 * refactor: JwtUtil Spring Bean 제거 * refactor: util 클래스는 상속이 불가능하게 final 설정 * refactor: 토큰 재료를 일반적인 파라미터명으로 변경 * refactor: db 관련 작업 트랜잭션 추가 * refactor: EXPIRE_TIME은 �애플리케이션 설정(application.yaml)으로 변경 * refactor: 카카오 관련 요소 한 곳(KakaoProperties)에서 관리 * refactor: jwt를 사용자 아이디를 가지고 생성 * fix: 프로그램 수행 가능하도록 함 * 7주차 weekly 병합 (#56) * fix: 프로그램 수행 가능하도록 함 * fix: 안 올라간 파일.. * feat: 토큰 해독 메서드 구현 * feat: 로그인 어노테이션 구현 * feat: 멤버 조회 수정 삭제 구현 * feat: 회원 추가정보 등록 후 회원가입 * fix: 로그인 에러 해결 * style: 안 쓰는 메서드 및 주석 제거, 파일 깔끔하게! * refactor: 회원 수정 db 재저장 * test: 회원 API 테스트코드 작성 * feat: 결제 파트 누락된 파일 추가 * refactor: 트랜잭션 변경 및 readOnly 적용 * feat: 전역에러핸들러 작성 * refactor: 클라이언트는 멤버아이디를 모른다.. 토큰 사용.. * refactor: 매직리터럴대신 HttpHeaders.AUTHORIZATION 사용 --------- Co-authored-by: westzeroright Co-authored-by: westzeroright <124443419+westzeroright@users.noreply.github.com> * feat: cors 설정 * refactor: Value를 private final로 설정하고 생성자 주입방식을 사용 * refactor: 중복된 메서드 modifyMemberInfo 제거 * refactor: 트랜잭션 추가 * refactor: 메서드명은 행위를 나타내도록 동사로 시작 loginKakaoUser * refactor: esponseEntity, HttpStatus, redirect등을 Controller(Web 계층)로 옮김 * refactor: 사용하지 않은 메서드 및 파일 제거 * feat: 의존성 geohash 추가 * refactor: 반지름 삭제 * refactor: geoHash, deadlineTime 추가 * feat: geoHash를 통해 반경 n미터 spot 조회하는 기능 작성 * feat: geoHash 기준으로 Spot 찾기 추가 * refactor: geoHash, deadlineTime(마감시간) 추가 * refactor: deadlineTime(마감시간) 추가 * fix: 중복 메서드 제거 * refactor: 줄바꿈 변경 * feat: 의존성 geohash 추가 * refactor: 반지름 삭제 * refactor: geoHash, deadlineTime 추가 * feat: geoHash를 통해 반경 n미터 spot 조회하는 기능 작성 * feat: geoHash 기준으로 Spot 찾기 추가 * refactor: geoHash, deadlineTime(마감시간) 추가 * refactor: deadlineTime(마감시간) 추가 * refactor: 줄바꿈 변경 * feat: API 응답 형식 필드 final 키워드 추가 * feat: API 응답 형식 생성 메서드 추가 * feat: 더미 데이터에 회원 데이터 추가 * style: 결제 준비 서비스 검증 메서드 변수명 수정 * feat: PaymentEventEntity paymentKey 필드 @Nullable 제거 결제 승인 요청 전까지 paymentKey 를 알 수 없음 * feat: 결제 승인 프로세스 예시 페이지 구현 * feat: 포인트 충전 기능 구현 * feat: 결제 주문 저장소 빈 등록 시, 주입될 repo 추가 * feat: PaymentEventRepository 결제 상태 변경 기능 구현 * feat: 결제 상태 변경 기능 구현 * test: PaymentDatabaseHelper 더미 데이터 생성 기능 추가 * feat: 결제 유효성 검사 기능 구현 * feat: TossPayments 응답 DTO 생성 - 결제 승인 응답 - 결제 실패 응답 * feat: TossPaymentsClient 구현 * feat: 결제 승인 DTO 구현 * feat: 결제 승인 기능 구현 * test: 테스트 코드 유지보수 - PaymentEvent 최초 저장 시, PaymentKey 값이 null 이되는 것을 반영 * fix: PaymentEventMapper 도메인 변환 시, paymentOrders 값이 채워지지 않던 버그 수정 * feat: PaymentViewController 추가 * feat: application.yml application-test.yml toss 설정 추가 * rename: PointUpdateService -> PointManagementService * feat: 포인트 충전 기능 구현 * feat: 결제 승인 기능 구현 * rename: PointUpdateService -> PointManagementService * feat: 카카오 로그인 구현 * feat: 포인트 충전 기능 구현 --------- Co-authored-by: 나제법 Co-authored-by: 나제법 <89574219+nove1080@users.noreply.github.com> Co-authored-by: ajy9851 Co-authored-by: westzeroright Co-authored-by: westzeroright <124443419+westzeroright@users.noreply.github.com> Co-authored-by: ajy9851 <130203472+ajy9851@users.noreply.github.com> --- build.gradle | 1 + .../ordertogether/team14_be/auth/JwtUtil.java | 1 + .../auth/application/service/AuthService.java | 45 +--- .../application/service/KakaoAuthService.java | 19 ++ .../auth/presentation/AuthController.java | 47 +++- .../common/web/response/ApiResponse.java | 10 +- .../team14_be/config/CorsConfig.java | 19 ++ .../team14_be/config/PersistenceConfig.java | 8 +- .../application/service/MemberService.java | 26 +- .../member/persistence/entity/Member.java | 26 +- .../application/service/MemberService.java | 26 ++ .../memebr/persistence/MemberRepository.java | 12 + .../payment/domain/PaymentStatus.java | 8 + .../jpa/entity/PaymentEventEntity.java | 1 - .../jpa/mapper/PaymentEventMapper.java | 18 +- .../repository/JpaPaymentEventRepository.java | 50 +++- .../repository/JpaPaymentOrderRepository.java | 23 +- .../SimpleJpaPaymentEventRepository.java | 18 ++ .../SimpleJpaPaymentOrderRepository.java | 12 +- .../repository/PaymentEventRepository.java | 16 +- .../repository/PaymentOrderRepository.java | 11 + .../service/PaymentConfirmService.java | 40 +++ .../service/PaymentPreparationService.java | 6 +- .../service/PaymentStatusUpdateService.java | 25 ++ .../service/PaymentValidationService.java | 39 +++ .../service/PointManagementService.java | 31 +++ .../payment/service/PointUpdateService.java | 31 +++ .../command/PaymentStatusUpdateCommand.java | 23 ++ .../web/controller/PaymentController.java | 15 ++ .../web/controller/PaymentViewController.java | 40 +++ .../web/request/PaymentConfirmRequest.java | 6 + .../response/PaymentConfirmationFailure.java | 3 + .../response/PaymentConfirmationResponse.java | 20 ++ .../web/toss/client/TossPaymentsClient.java | 53 ++++ .../toss/config/TossPaymentsClientConfig.java | 30 +++ .../web/toss/error/TossPaymentsError.java | 59 ++++ .../TossPaymentsConfirmationResponse.java | 104 ++++++++ .../spot/controller/SpotController.java | 8 +- .../controllerdto/SpotCreationRequest.java | 4 +- .../controllerdto/SpotCreationResponse.java | 4 +- .../spot/dto/servicedto/SpotDto.java | 3 + .../team14_be/spot/entity/Spot.java | 3 + .../spot/exception/ErrorResponse.java | 2 +- .../spot/repository/SimpleSpotRepository.java | 2 + .../spot/repository/SpotRepository.java | 9 +- .../team14_be/spot/service/SpotService.java | 48 +--- src/main/resources/application.yml | 5 + src/main/resources/data.sql | 2 + src/main/resources/static/style.css | 251 ++++++++++++++++++ src/main/resources/templates/checkout.html | 147 ++++++++++ src/main/resources/templates/fail.html | 34 +++ src/main/resources/templates/success.html | 67 +++++ .../helper/PaymentDatabaseHelper.java | 4 + .../helper/jpa/JpaPaymentDatabaseHelper.java | 75 ++++++ .../service/PaymentConfirmServiceTest.java | 101 +++++++ .../PaymentPreparationServiceTest.java | 2 +- .../PaymentStatusUpdateServiceTest.java | 72 +++++ .../service/PointManagementServiceTest.java | 61 +++++ .../service/PointUpdateServiceTest.java | 62 +++++ src/test/resources/application-test.yml | 12 + 60 files changed, 1766 insertions(+), 134 deletions(-) create mode 100644 src/main/java/com/ordertogether/team14_be/auth/application/service/KakaoAuthService.java create mode 100644 src/main/java/com/ordertogether/team14_be/config/CorsConfig.java create mode 100644 src/main/java/com/ordertogether/team14_be/memebr/application/service/MemberService.java create mode 100644 src/main/java/com/ordertogether/team14_be/memebr/persistence/MemberRepository.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/service/PaymentConfirmService.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/service/PaymentStatusUpdateService.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/service/PaymentValidationService.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/service/PointManagementService.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/service/PointUpdateService.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/service/command/PaymentStatusUpdateCommand.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/web/controller/PaymentViewController.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/web/request/PaymentConfirmRequest.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/web/response/PaymentConfirmationFailure.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/web/response/PaymentConfirmationResponse.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/web/toss/client/TossPaymentsClient.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/web/toss/config/TossPaymentsClientConfig.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/web/toss/error/TossPaymentsError.java create mode 100644 src/main/java/com/ordertogether/team14_be/payment/web/toss/response/TossPaymentsConfirmationResponse.java create mode 100644 src/main/resources/static/style.css create mode 100644 src/main/resources/templates/checkout.html create mode 100644 src/main/resources/templates/fail.html create mode 100644 src/main/resources/templates/success.html create mode 100644 src/test/java/com/ordertogether/team14_be/payment/service/PaymentConfirmServiceTest.java create mode 100644 src/test/java/com/ordertogether/team14_be/payment/service/PaymentStatusUpdateServiceTest.java create mode 100644 src/test/java/com/ordertogether/team14_be/payment/service/PointManagementServiceTest.java create mode 100644 src/test/java/com/ordertogether/team14_be/payment/service/PointUpdateServiceTest.java diff --git a/build.gradle b/build.gradle index 32b0c650..e0aa23c8 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,7 @@ dependencies { implementation "io.jsonwebtoken:jjwt-api:${jjwt_version}" implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${swagger_version}" implementation 'org.mapstruct:mapstruct:1.6.2' + implementation 'ch.hsr:geohash:1.4.0' annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.2' diff --git a/src/main/java/com/ordertogether/team14_be/auth/JwtUtil.java b/src/main/java/com/ordertogether/team14_be/auth/JwtUtil.java index a16a2c28..deaeb9c7 100644 --- a/src/main/java/com/ordertogether/team14_be/auth/JwtUtil.java +++ b/src/main/java/com/ordertogether/team14_be/auth/JwtUtil.java @@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +@Component public class JwtUtil { private final SecretKey key; private final int expireTime; diff --git a/src/main/java/com/ordertogether/team14_be/auth/application/service/AuthService.java b/src/main/java/com/ordertogether/team14_be/auth/application/service/AuthService.java index b760a7ae..0bca91c1 100644 --- a/src/main/java/com/ordertogether/team14_be/auth/application/service/AuthService.java +++ b/src/main/java/com/ordertogether/team14_be/auth/application/service/AuthService.java @@ -1,56 +1,29 @@ package com.ordertogether.team14_be.auth.application.service; import com.ordertogether.team14_be.auth.JwtUtil; -import com.ordertogether.team14_be.auth.application.dto.KakaoUserInfo; -import com.ordertogether.team14_be.auth.presentation.KakaoClient; -import com.ordertogether.team14_be.common.web.response.ApiResponse; import com.ordertogether.team14_be.member.application.service.MemberService; import com.ordertogether.team14_be.member.persistence.entity.Member; -import java.net.URI; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; -@RequiredArgsConstructor @Service public class AuthService { - - private final KakaoClient kakaoClient; private final MemberService memberService; private final JwtUtil jwtUtil; - @Value(("${FRONT_PAGE_SIGNUP}")) - String redirectPage; - - public ResponseEntity> kakaoLogin(String authorizationCode) { - String kakaoToken = kakaoClient.getAccessToken(authorizationCode); // 인가코드로부터 카카오토큰 발급 - KakaoUserInfo kakaoUserInfo = kakaoClient.getUserInfo((kakaoToken)); - String userKakaoEmail = kakaoUserInfo.kakaoAccount().email(); // 와 사용자 카카오 이메일이야 - - Optional existMember = memberService.findMemberByEmail(userKakaoEmail); - if (existMember.isPresent()) { - String serviceToken = - jwtUtil.generateToken(memberService.getMemberId(userKakaoEmail)); // 서비스 토큰 줘야징 - return ResponseEntity.ok((ApiResponse.with(HttpStatus.OK, "로그인 성공", serviceToken))); - } else { - return ResponseEntity.status(HttpStatus.FOUND) - .location( - URI.create(redirectPage + URLEncoder.encode(userKakaoEmail, StandardCharsets.UTF_8))) - .build(); - } + public AuthService(MemberService memberService, JwtUtil jwtUtil) { + this.memberService = memberService; + this.jwtUtil = jwtUtil; } - public ResponseEntity> register( - String email, String deliveryName, String phoneNumber) { + public String register(String email, String deliveryName, String phoneNumber) { Member member = new Member(email, deliveryName, phoneNumber); memberService.registerMember(member); Long memberId = memberService.getMemberId(email); String serviceToken = jwtUtil.generateToken(memberId); - return ResponseEntity.ok((ApiResponse.with(HttpStatus.OK, "회원가입 및 로그인 성공", serviceToken))); + return serviceToken; + } + + public String getServiceToken(String email) { + return jwtUtil.generateToken(memberService.getMemberId(email)); } } diff --git a/src/main/java/com/ordertogether/team14_be/auth/application/service/KakaoAuthService.java b/src/main/java/com/ordertogether/team14_be/auth/application/service/KakaoAuthService.java new file mode 100644 index 00000000..9c3d2c74 --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/auth/application/service/KakaoAuthService.java @@ -0,0 +1,19 @@ +package com.ordertogether.team14_be.auth.application.service; + +import com.ordertogether.team14_be.auth.application.dto.KakaoUserInfo; +import com.ordertogether.team14_be.auth.presentation.KakaoClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class KakaoAuthService { + private final KakaoClient kakaoClient; + + public String getKakaoUserEmail(String authorizationCode) { + String kakaoToken = kakaoClient.getAccessToken(authorizationCode); + KakaoUserInfo kakaoUserInfo = kakaoClient.getUserInfo((kakaoToken)); + String userKakaoEmail = kakaoUserInfo.kakaoAccount().email(); + return userKakaoEmail; + } +} diff --git a/src/main/java/com/ordertogether/team14_be/auth/presentation/AuthController.java b/src/main/java/com/ordertogether/team14_be/auth/presentation/AuthController.java index c62ccd68..9ea80103 100644 --- a/src/main/java/com/ordertogether/team14_be/auth/presentation/AuthController.java +++ b/src/main/java/com/ordertogether/team14_be/auth/presentation/AuthController.java @@ -1,9 +1,17 @@ package com.ordertogether.team14_be.auth.presentation; import com.ordertogether.team14_be.auth.application.service.AuthService; +import com.ordertogether.team14_be.auth.application.service.KakaoAuthService; import com.ordertogether.team14_be.common.web.response.ApiResponse; import com.ordertogether.team14_be.member.application.dto.MemberInfoRequest; -import lombok.RequiredArgsConstructor; +import com.ordertogether.team14_be.member.application.service.MemberService; +import com.ordertogether.team14_be.member.persistence.entity.Member; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -13,16 +21,49 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -@RequiredArgsConstructor @RestController @RequestMapping("/api/v1/auth") public class AuthController { private final AuthService authService; + private final KakaoAuthService kakaoAuthService; + private final String redirectPage; + private final MemberService memberService; + + public AuthController( + AuthService authService, + KakaoAuthService kakaoAuthService, + MemberService memberService, + @Value("${FRONT_PAGE_SIGNUP}") String redirectPage) { + this.authService = authService; + this.kakaoAuthService = kakaoAuthService; + this.memberService = memberService; + this.redirectPage = redirectPage; + } @GetMapping("/login") public ResponseEntity> getToken(@RequestHeader String authorizationCode) { - return authService.kakaoLogin(authorizationCode); + String userKakaoEmail = kakaoAuthService.getKakaoUserEmail(authorizationCode); + Optional existMember = memberService.findMemberByEmail(userKakaoEmail); + if (existMember.isPresent()) { + return ResponseEntity.ok( + ApiResponse.with(HttpStatus.OK, "로그인 성공", authService.getServiceToken(userKakaoEmail))); + + } else { + return ResponseEntity.status(HttpStatus.FOUND) + .location( + URI.create(redirectPage + URLEncoder.encode(userKakaoEmail, StandardCharsets.UTF_8))) + .build(); + } + } + + @PostMapping("/signup") + public ResponseEntity> signUpMember( + @RequestParam String email, @RequestBody MemberInfoRequest memberInfoRequest) { + String serviceToken = + authService.register( + email, memberInfoRequest.deliveryName(), memberInfoRequest.phoneNumber()); + return ResponseEntity.ok(ApiResponse.with(HttpStatus.OK, "로그인 성공", serviceToken)); } @PostMapping("/signup") diff --git a/src/main/java/com/ordertogether/team14_be/common/web/response/ApiResponse.java b/src/main/java/com/ordertogether/team14_be/common/web/response/ApiResponse.java index 23ccb295..f0f68931 100644 --- a/src/main/java/com/ordertogether/team14_be/common/web/response/ApiResponse.java +++ b/src/main/java/com/ordertogether/team14_be/common/web/response/ApiResponse.java @@ -10,11 +10,15 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public class ApiResponse { - private Integer status; - private String message; - private T data; + private final Integer status; + private final String message; + private final T data; public static ApiResponse with(HttpStatus httpStatus, String message, @Nullable T data) { return new ApiResponse<>(httpStatus.value(), message, data); } + + public static ApiResponse with(HttpStatus httpStatus, String message) { + return new ApiResponse<>(httpStatus.value(), message, null); + } } diff --git a/src/main/java/com/ordertogether/team14_be/config/CorsConfig.java b/src/main/java/com/ordertogether/team14_be/config/CorsConfig.java new file mode 100644 index 00000000..0faa7996 --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/config/CorsConfig.java @@ -0,0 +1,19 @@ +package com.ordertogether.team14_be.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry + .addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "DELETE") + .allowedHeaders("Authorization", "Content-Type") + .exposedHeaders("Custom-Header") + .maxAge(3600); + } +} diff --git a/src/main/java/com/ordertogether/team14_be/config/PersistenceConfig.java b/src/main/java/com/ordertogether/team14_be/config/PersistenceConfig.java index 8d24b129..b64acd15 100644 --- a/src/main/java/com/ordertogether/team14_be/config/PersistenceConfig.java +++ b/src/main/java/com/ordertogether/team14_be/config/PersistenceConfig.java @@ -27,8 +27,12 @@ public AuditorAware auditorProvider() { @Bean public PaymentEventRepository paymentEventRepository( - SimpleJpaPaymentEventRepository simpleJpaPaymentEventRepository) { - return new JpaPaymentEventRepository(simpleJpaPaymentEventRepository); + SimpleJpaPaymentEventRepository simpleJpaPaymentEventRepository, + SimpleJpaPaymentOrderRepository simpleJpaPaymentOrderRepository, + SimpleJpaProductRepository simpleJpaProductRepository) { + return new JpaPaymentEventRepository( + simpleJpaPaymentEventRepository, + paymentOrderRepository(simpleJpaPaymentOrderRepository, simpleJpaProductRepository)); } @Bean diff --git a/src/main/java/com/ordertogether/team14_be/member/application/service/MemberService.java b/src/main/java/com/ordertogether/team14_be/member/application/service/MemberService.java index 32f90e74..638cf1d7 100644 --- a/src/main/java/com/ordertogether/team14_be/member/application/service/MemberService.java +++ b/src/main/java/com/ordertogether/team14_be/member/application/service/MemberService.java @@ -1,6 +1,5 @@ package com.ordertogether.team14_be.member.application.service; -import com.ordertogether.team14_be.auth.JwtUtil; import com.ordertogether.team14_be.member.application.dto.MemberInfoResponse; import com.ordertogether.team14_be.member.application.exception.NotFoundMember; import com.ordertogether.team14_be.member.persistence.MemberRepository; @@ -37,6 +36,25 @@ public MemberInfoResponse findMemberInfo(Long memberId) { .build(); } + @Transactional(readOnly = true) + public Long getMemberId(String email) { + return memberRepository + .findByEmail(email) + .map(Member::getId) + .orElseThrow(() -> new NoSuchElementException("Member with email " + email + " not found")); + } + + @Transactional(readOnly = true) + public MemberInfoResponse findMemberInfo(Long memberId) { + Member member = findMember(memberId); + + return MemberInfoResponse.builder() + .deliveryName(member.getDeliveryName()) + .phoneNumber(member.getPhoneNumber()) + .point(member.getPoint()) + .build(); + } + @Transactional public MemberInfoResponse modifyMember(Long memberId, String deliveryName, String phoneNumber) { Member member = findMember(memberId); @@ -65,12 +83,8 @@ public Optional findMemberByEmail(String email) { return memberRepository.findByEmail(email); } + @Transactional public void registerMember(Member member) { memberRepository.saveAndFlush(member); } - - public Long getMemberId(String email) { - Member member = memberRepository.findByEmail(email).get(); - return member.getId(); - } } diff --git a/src/main/java/com/ordertogether/team14_be/member/persistence/entity/Member.java b/src/main/java/com/ordertogether/team14_be/member/persistence/entity/Member.java index 66bcfc8c..b7d03fdc 100644 --- a/src/main/java/com/ordertogether/team14_be/member/persistence/entity/Member.java +++ b/src/main/java/com/ordertogether/team14_be/member/persistence/entity/Member.java @@ -30,6 +30,16 @@ public class Member { protected Member() {} + public Member( + Long id, String email, int point, String phoneNumber, String deliveryName, String platform) { + this.id = id; + this.email = email; + this.point = point; + this.phoneNumber = phoneNumber; + this.deliveryName = deliveryName; + this.platform = platform; + } + public Member(String email, int point, String phoneNumber, String deliveryName, String platform) { this.email = email; this.point = point; @@ -73,18 +83,8 @@ public void modifyMemberInfo(String deliveryName, String phoneNumber) { this.phoneNumber = phoneNumber; } - public void modifyMemberInfo(String deliveryName, String phoneNumber) { - this.deliveryName = deliveryName; - this.phoneNumber = phoneNumber; - } - - public void modifyMemberInfo(String deliveryName, String phoneNumber) { - this.deliveryName = deliveryName; - this.phoneNumber = phoneNumber; - } - - public void modifyMemberInfo(String deliveryName, String phoneNumber) { - this.deliveryName = deliveryName; - this.phoneNumber = phoneNumber; + public Integer increasePoint(int point) { + this.point += point; + return this.point; } } diff --git a/src/main/java/com/ordertogether/team14_be/memebr/application/service/MemberService.java b/src/main/java/com/ordertogether/team14_be/memebr/application/service/MemberService.java new file mode 100644 index 00000000..9ae1f05a --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/memebr/application/service/MemberService.java @@ -0,0 +1,26 @@ +package com.ordertogether.team14_be.memebr.application.service; + +import com.ordertogether.team14_be.memebr.persistence.MemberRepository; +import com.ordertogether.team14_be.memebr.persistence.entity.Member; +import org.springframework.stereotype.Service; + +@Service +public class MemberService { + + private final MemberRepository memberRepository; + + public MemberService(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + public void findOrCreateMember(String email) { + Member member = + memberRepository + .findByEmail(email) + .orElseGet( + () -> { + Member newMember = Member.createMember(email); + return memberRepository.saveAndFlush(newMember); + }); + } +} diff --git a/src/main/java/com/ordertogether/team14_be/memebr/persistence/MemberRepository.java b/src/main/java/com/ordertogether/team14_be/memebr/persistence/MemberRepository.java new file mode 100644 index 00000000..cea83bb6 --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/memebr/persistence/MemberRepository.java @@ -0,0 +1,12 @@ +package com.ordertogether.team14_be.memebr.persistence; + +import com.ordertogether.team14_be.memebr.persistence.entity.Member; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MemberRepository extends JpaRepository { + + Optional findByEmail(String email); +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/domain/PaymentStatus.java b/src/main/java/com/ordertogether/team14_be/payment/domain/PaymentStatus.java index 001acfaa..c8b22524 100644 --- a/src/main/java/com/ordertogether/team14_be/payment/domain/PaymentStatus.java +++ b/src/main/java/com/ordertogether/team14_be/payment/domain/PaymentStatus.java @@ -13,4 +13,12 @@ public enum PaymentStatus { FAIL("결제 실패"); private final String description; + + public boolean isSuccess() { + return this == SUCCESS; + } + + public boolean isFail() { + return this == FAIL; + } } diff --git a/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/entity/PaymentEventEntity.java b/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/entity/PaymentEventEntity.java index 6a64f56d..07fb1010 100644 --- a/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/entity/PaymentEventEntity.java +++ b/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/entity/PaymentEventEntity.java @@ -40,7 +40,6 @@ public class PaymentEventEntity extends BaseTimeEntity { @Column(nullable = false) private String orderName; - @Column(nullable = false) private String paymentKey; // PSP 결제 식별자 @Builder.Default diff --git a/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/mapper/PaymentEventMapper.java b/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/mapper/PaymentEventMapper.java index db590005..3dc0af80 100644 --- a/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/mapper/PaymentEventMapper.java +++ b/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/mapper/PaymentEventMapper.java @@ -1,7 +1,9 @@ package com.ordertogether.team14_be.payment.persistence.jpa.mapper; import com.ordertogether.team14_be.payment.domain.PaymentEvent; +import com.ordertogether.team14_be.payment.domain.PaymentOrder; import com.ordertogether.team14_be.payment.persistence.jpa.entity.PaymentEventEntity; +import java.util.List; import lombok.experimental.UtilityClass; @UtilityClass @@ -18,14 +20,16 @@ public static PaymentEventEntity mapToEntity(PaymentEvent domain) { .build(); } - public static PaymentEvent mapToDomain(PaymentEventEntity entity) { + public static PaymentEvent mapToDomain( + PaymentEventEntity paymentEventEntity, List paymentOrders) { return PaymentEvent.builder() - .id(entity.getId()) - .buyerId(entity.getBuyerId()) - .orderId(entity.getOrderId()) - .orderName(entity.getOrderName()) - .paymentKey(entity.getPaymentKey()) - .paymentStatus(entity.getPaymentStatus()) + .id(paymentEventEntity.getId()) + .buyerId(paymentEventEntity.getBuyerId()) + .paymentOrders(paymentOrders) + .orderId(paymentEventEntity.getOrderId()) + .orderName(paymentEventEntity.getOrderName()) + .paymentKey(paymentEventEntity.getPaymentKey()) + .paymentStatus(paymentEventEntity.getPaymentStatus()) .build(); } } diff --git a/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/JpaPaymentEventRepository.java b/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/JpaPaymentEventRepository.java index 7c286b90..5a924265 100644 --- a/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/JpaPaymentEventRepository.java +++ b/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/JpaPaymentEventRepository.java @@ -1,33 +1,69 @@ package com.ordertogether.team14_be.payment.persistence.jpa.repository; import com.ordertogether.team14_be.payment.domain.PaymentEvent; +import com.ordertogether.team14_be.payment.domain.PaymentOrder; +import com.ordertogether.team14_be.payment.domain.PaymentStatus; import com.ordertogether.team14_be.payment.persistence.jpa.entity.PaymentEventEntity; import com.ordertogether.team14_be.payment.persistence.jpa.mapper.PaymentEventMapper; import com.ordertogether.team14_be.payment.persistence.repository.PaymentEventRepository; +import com.ordertogether.team14_be.payment.persistence.repository.PaymentOrderRepository; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor +@Transactional(readOnly = true) public class JpaPaymentEventRepository implements PaymentEventRepository { private final SimpleJpaPaymentEventRepository simpleJpaPaymentEventRepository; + private final PaymentOrderRepository paymentOrderRepository; @Override + @Transactional public PaymentEvent save(PaymentEvent paymentEvent) { PaymentEventEntity savedEntity = simpleJpaPaymentEventRepository.save(PaymentEventMapper.mapToEntity(paymentEvent)); - return PaymentEventMapper.mapToDomain(savedEntity); - } - - @Override - public Optional findById(Long id) { - return simpleJpaPaymentEventRepository.findById(id).map(PaymentEventMapper::mapToDomain); + return PaymentEventMapper.mapToDomain(savedEntity, paymentEvent.getPaymentOrders()); } @Override public Optional findByOrderId(String orderId) { + List paymentOrders = paymentOrderRepository.findByOrderId(orderId); return simpleJpaPaymentEventRepository .findByOrderId(orderId) - .map(PaymentEventMapper::mapToDomain); + .map(paymentEvent -> PaymentEventMapper.mapToDomain(paymentEvent, paymentOrders)); + } + + @Override + @Transactional + public Integer updatePaymentStatus( + String paymentKey, String orderId, PaymentStatus paymentStatus) { + return simpleJpaPaymentEventRepository.updatePaymentStatus(paymentKey, orderId, paymentStatus); + } + + @Override + @Transactional + public Integer updatePaymentStatusToExecuting(String orderId, String paymentKey) { + checkPreviousPaymentStatus(orderId); + simpleJpaPaymentEventRepository.updatePaymentKey(paymentKey, orderId); + + return simpleJpaPaymentEventRepository.updatePaymentStatus( + paymentKey, orderId, PaymentStatus.EXECUTING); + } + + private void checkPreviousPaymentStatus(String orderId) { + PaymentStatus previousStatus = + findByOrderId(orderId) + .orElseThrow( + () -> + new IllegalArgumentException( + "orderId: %s 에 해당하는 결제 정보가 없습니다.".formatted(orderId))) + .getPaymentStatus(); + + if (previousStatus.isSuccess() || previousStatus.isFail()) { + throw new IllegalArgumentException( + "이미 %s 상태인 결제 입니다.".formatted(previousStatus.getDescription())); + } } } diff --git a/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/JpaPaymentOrderRepository.java b/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/JpaPaymentOrderRepository.java index 1eade54e..6db87db5 100644 --- a/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/JpaPaymentOrderRepository.java +++ b/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/JpaPaymentOrderRepository.java @@ -6,11 +6,15 @@ import com.ordertogether.team14_be.payment.persistence.jpa.mapper.PaymentOrderMapper; import com.ordertogether.team14_be.payment.persistence.jpa.mapper.ProductMapper; import com.ordertogether.team14_be.payment.persistence.repository.PaymentOrderRepository; +import java.math.BigDecimal; import java.util.List; +import java.util.NoSuchElementException; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor +@Transactional(readOnly = true) public class JpaPaymentOrderRepository implements PaymentOrderRepository { private final SimpleJpaPaymentOrderRepository simpleJpaPaymentOrderRepository; @@ -24,6 +28,7 @@ public class JpaPaymentOrderRepository implements PaymentOrderRepository { * @return 저장된 결제 주문 정보 */ @Override + @Transactional public PaymentOrder save(PaymentOrder paymentOrder) { addMissingProductInfo(paymentOrder); PaymentOrderEntity savedEntity = @@ -40,6 +45,7 @@ private void addMissingProductInfo(PaymentOrder paymentOrder) { } @Override + @Transactional public List saveAll(List paymentOrders) { List savedEntities = simpleJpaPaymentOrderRepository.saveAll( @@ -53,12 +59,27 @@ public Optional findById(Long id) { return simpleJpaPaymentOrderRepository.findById(id).map(PaymentOrderMapper::mapToDomain); } + @Override + public List findByOrderId(String orderId) { + return simpleJpaPaymentOrderRepository.findByOrderId(orderId).stream() + .map(PaymentOrderMapper::mapToDomain) + .toList(); + } + + @Override + public BigDecimal getPaymentTotalAmount(String orderId) { + return simpleJpaPaymentOrderRepository + .getPaymentTotalAmount(orderId) + .orElseThrow( + () -> new NoSuchElementException("주문 번호: %s 에 해당하는 주문이 존재하지 않습니다.".formatted(orderId))); + } + private ProductEntity getProductEntity(PaymentOrder paymentOrder) { return simpleJpaProductRepository .findById(paymentOrder.getProductId()) .orElseThrow( () -> - new IllegalArgumentException( + new NoSuchElementException( String.format("상품 아이디 %s에 해당하는 상품이 없습니다.", paymentOrder.getProductId()))); } } diff --git a/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/SimpleJpaPaymentEventRepository.java b/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/SimpleJpaPaymentEventRepository.java index dce6ca28..eb870fe2 100644 --- a/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/SimpleJpaPaymentEventRepository.java +++ b/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/SimpleJpaPaymentEventRepository.java @@ -1,12 +1,30 @@ package com.ordertogether.team14_be.payment.persistence.jpa.repository; +import com.ordertogether.team14_be.payment.domain.PaymentStatus; import com.ordertogether.team14_be.payment.persistence.jpa.entity.PaymentEventEntity; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @Repository public interface SimpleJpaPaymentEventRepository extends JpaRepository { Optional findByOrderId(String orderId); + + @Modifying + @Query( + "UPDATE PaymentEventEntity pee" + + " SET pee.paymentStatus = :afterStatus" + + " WHERE pee.paymentKey = :paymentKey" + + " AND pee.orderId = :orderId") + Integer updatePaymentStatus(String paymentKey, String orderId, PaymentStatus afterStatus); + + @Modifying + @Query( + "UPDATE PaymentEventEntity pee" + + " SET pee.paymentKey = :paymentKey" + + " WHERE pee.orderId = :orderId") + Integer updatePaymentKey(String paymentKey, String orderId); } diff --git a/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/SimpleJpaPaymentOrderRepository.java b/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/SimpleJpaPaymentOrderRepository.java index f5c5d38e..5c2c0342 100644 --- a/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/SimpleJpaPaymentOrderRepository.java +++ b/src/main/java/com/ordertogether/team14_be/payment/persistence/jpa/repository/SimpleJpaPaymentOrderRepository.java @@ -1,8 +1,18 @@ package com.ordertogether.team14_be.payment.persistence.jpa.repository; import com.ordertogether.team14_be.payment.persistence.jpa.entity.PaymentOrderEntity; +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @Repository -public interface SimpleJpaPaymentOrderRepository extends JpaRepository {} +public interface SimpleJpaPaymentOrderRepository extends JpaRepository { + + @Query("SELECT SUM(po.amount) FROM PaymentOrderEntity po WHERE po.orderId = :orderId") + Optional getPaymentTotalAmount(String orderId); + + List findByOrderId(String orderId); +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/persistence/repository/PaymentEventRepository.java b/src/main/java/com/ordertogether/team14_be/payment/persistence/repository/PaymentEventRepository.java index 7084d746..3872bec6 100644 --- a/src/main/java/com/ordertogether/team14_be/payment/persistence/repository/PaymentEventRepository.java +++ b/src/main/java/com/ordertogether/team14_be/payment/persistence/repository/PaymentEventRepository.java @@ -1,13 +1,25 @@ package com.ordertogether.team14_be.payment.persistence.repository; import com.ordertogether.team14_be.payment.domain.PaymentEvent; +import com.ordertogether.team14_be.payment.domain.PaymentStatus; import java.util.Optional; public interface PaymentEventRepository { PaymentEvent save(PaymentEvent paymentEvent); - Optional findById(Long id); - Optional findByOrderId(String orderId); + + Integer updatePaymentStatus(String paymentKey, String orderId, PaymentStatus paymentStatus); + + /** + * 주문 상태를 '실행 중'으로 변경합니다.
+ * - PSP 에서 전달받은 결제 식별자 paymentKey 로 paymentKey 필드를 초기화합니다.
+ * - 주문 상태를 '실행 중'으로 변경합니다. + * + * @param orderId 주문 번호 + * @param paymentKey PSP 에서 전달 받은 결제 식별자 + * @return 변경된 행의 수 + */ + Integer updatePaymentStatusToExecuting(String orderId, String paymentKey); } diff --git a/src/main/java/com/ordertogether/team14_be/payment/persistence/repository/PaymentOrderRepository.java b/src/main/java/com/ordertogether/team14_be/payment/persistence/repository/PaymentOrderRepository.java index 7fa05b8b..38eba9c1 100644 --- a/src/main/java/com/ordertogether/team14_be/payment/persistence/repository/PaymentOrderRepository.java +++ b/src/main/java/com/ordertogether/team14_be/payment/persistence/repository/PaymentOrderRepository.java @@ -1,6 +1,7 @@ package com.ordertogether.team14_be.payment.persistence.repository; import com.ordertogether.team14_be.payment.domain.PaymentOrder; +import java.math.BigDecimal; import java.util.List; import java.util.Optional; @@ -11,4 +12,14 @@ public interface PaymentOrderRepository { List saveAll(List paymentOrders); Optional findById(Long id); + + List findByOrderId(String orderId); + + /** + * 주문 번호에 해당하는 주문에 대하여 총 결제 금액을 반환한다. + * + * @param orderId 주문번호 + * @return 총 결제 금액 + */ + BigDecimal getPaymentTotalAmount(String orderId); } diff --git a/src/main/java/com/ordertogether/team14_be/payment/service/PaymentConfirmService.java b/src/main/java/com/ordertogether/team14_be/payment/service/PaymentConfirmService.java new file mode 100644 index 00000000..0159a1fa --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/service/PaymentConfirmService.java @@ -0,0 +1,40 @@ +package com.ordertogether.team14_be.payment.service; + +import com.ordertogether.team14_be.payment.service.command.PaymentStatusUpdateCommand; +import com.ordertogether.team14_be.payment.web.request.PaymentConfirmRequest; +import com.ordertogether.team14_be.payment.web.response.PaymentConfirmationResponse; +import com.ordertogether.team14_be.payment.web.toss.client.TossPaymentsClient; +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +/** 결제 승인 서비스 */ +public class PaymentConfirmService { + + private final TossPaymentsClient tossPaymentsClient; + + private final PaymentValidationService paymentValidationService; + private final PaymentStatusUpdateService paymentStatusUpdateService; + private final PointManagementService pointManagementService; + + public PaymentConfirmationResponse confirm(PaymentConfirmRequest request) { + // 1. 결제 상태 변경 (준비 -> 실행 중) + paymentStatusUpdateService.updatePaymentStatusToExecuting( + request.orderId(), request.paymentKey()); + // 2. 결제 유효성 검사 + paymentValidationService.validate(request.orderId(), BigDecimal.valueOf(request.amount())); + // 3. 결제 승인 요청 + PaymentConfirmationResponse response = tossPaymentsClient.confirmPayment(request); + // 4. 승인 결과에 따른 결제 상태 업데이트 + paymentStatusUpdateService.updatePaymentStatus( + new PaymentStatusUpdateCommand( + request.paymentKey(), request.orderId(), response.paymentStatus())); + // 5. 포인트 충전 + pointManagementService.increasePoint(request.orderId()); + return response; + } +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/service/PaymentPreparationService.java b/src/main/java/com/ordertogether/team14_be/payment/service/PaymentPreparationService.java index 1b2b33cb..a2e18fe8 100644 --- a/src/main/java/com/ordertogether/team14_be/payment/service/PaymentPreparationService.java +++ b/src/main/java/com/ordertogether/team14_be/payment/service/PaymentPreparationService.java @@ -50,15 +50,16 @@ public PaymentPrepareResponse prepare(PaymentPrepareRequest request) { */ private void validateDuplicatePayment(PaymentPrepareRequest request) { String idempotentKey = IdempotentKeyGenerator.generate(request.getIdempotencySeed()); + paymentEventRepository .findByOrderId(idempotentKey) .ifPresent( - paymentEvent -> { + duplicatedPaymentEvent -> { throw new IllegalArgumentException( "Seed: %s 를 통해 생성된 결제는 이미 %s 상태인 주문입니다." .formatted( request.getIdempotencySeed(), - paymentEvent.getPaymentStatus().getDescription())); + duplicatedPaymentEvent.getPaymentStatus().getDescription())); }); } @@ -84,7 +85,6 @@ private PaymentEvent createPaymentEvent(PaymentPrepareRequest request, List createPaymentOrder(product, idempotencySeed)).toList()) .orderId(IdempotentKeyGenerator.generate(idempotencySeed)) .orderName(createOrderName(products)) - .paymentKey(IdempotentKeyGenerator.generate(idempotencySeed)) .build(); } diff --git a/src/main/java/com/ordertogether/team14_be/payment/service/PaymentStatusUpdateService.java b/src/main/java/com/ordertogether/team14_be/payment/service/PaymentStatusUpdateService.java new file mode 100644 index 00000000..5bcb1f94 --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/service/PaymentStatusUpdateService.java @@ -0,0 +1,25 @@ +package com.ordertogether.team14_be.payment.service; + +import com.ordertogether.team14_be.payment.persistence.repository.PaymentEventRepository; +import com.ordertogether.team14_be.payment.service.command.PaymentStatusUpdateCommand; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PaymentStatusUpdateService { + + private final PaymentEventRepository paymentEventRepository; + + @Transactional + public Integer updatePaymentStatusToExecuting(String orderId, String paymentKey) { + return paymentEventRepository.updatePaymentStatusToExecuting(orderId, paymentKey); + } + + @Transactional + public Integer updatePaymentStatus(PaymentStatusUpdateCommand command) { + return paymentEventRepository.updatePaymentStatus( + command.paymentKey(), command.orderId(), command.paymentStatus()); + } +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/service/PaymentValidationService.java b/src/main/java/com/ordertogether/team14_be/payment/service/PaymentValidationService.java new file mode 100644 index 00000000..ac7d5fc5 --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/service/PaymentValidationService.java @@ -0,0 +1,39 @@ +package com.ordertogether.team14_be.payment.service; + +import com.ordertogether.team14_be.payment.persistence.repository.PaymentOrderRepository; +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +/** 결제 유효성 검사 */ +public class PaymentValidationService { + + private final PaymentOrderRepository paymentOrderRepository; + + /** + * 요청된 결제 금액과 실제 결제 금액이 일치하는 지 검증합니다. + * + * @param orderId 주문 번호 + * @param requestedAmount 요청한 결제 금액 + * @return 결제 금액이 일치하면 true, 그렇지 않으면 {@link IllegalArgumentException} 발생 + */ + public boolean validate(String orderId, BigDecimal requestedAmount) { + BigDecimal expectedAmount = paymentOrderRepository.getPaymentTotalAmount(orderId); + + if (isAmountMismatch(requestedAmount, expectedAmount)) { + throw new IllegalArgumentException( + "주문 번호: %s 의 결제 요청 금액 %s 원은 예상 결제 금액 %s 원과 다릅니다." + .formatted(orderId, requestedAmount, expectedAmount)); + } + + return true; + } + + private boolean isAmountMismatch(BigDecimal requestedAmount, BigDecimal expectedAmount) { + return requestedAmount.compareTo(expectedAmount) != 0; + } +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/service/PointManagementService.java b/src/main/java/com/ordertogether/team14_be/payment/service/PointManagementService.java new file mode 100644 index 00000000..8a36c47d --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/service/PointManagementService.java @@ -0,0 +1,31 @@ +package com.ordertogether.team14_be.payment.service; + +import com.ordertogether.team14_be.member.application.service.MemberService; +import com.ordertogether.team14_be.payment.domain.PaymentEvent; +import com.ordertogether.team14_be.payment.persistence.repository.PaymentEventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PointManagementService { + + private final PaymentEventRepository paymentEventRepository; + private final MemberService memberService; + + @Transactional + public Integer increasePoint(String orderId) { + PaymentEvent paymentEvent = + paymentEventRepository + .findByOrderId(orderId) + .orElseThrow( + () -> + new IllegalArgumentException( + "orderId : %s 에 해당하는 PaymentEvent 가 없습니다.".formatted(orderId))); + + return memberService + .findMember(paymentEvent.getBuyerId()) + .increasePoint(paymentEvent.totalAmount().intValue()); + } +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/service/PointUpdateService.java b/src/main/java/com/ordertogether/team14_be/payment/service/PointUpdateService.java new file mode 100644 index 00000000..c3cec251 --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/service/PointUpdateService.java @@ -0,0 +1,31 @@ +package com.ordertogether.team14_be.payment.service; + +import com.ordertogether.team14_be.member.application.service.MemberService; +import com.ordertogether.team14_be.payment.domain.PaymentEvent; +import com.ordertogether.team14_be.payment.persistence.repository.PaymentEventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PointUpdateService { + + private final PaymentEventRepository paymentEventRepository; + private final MemberService memberService; + + @Transactional + public Integer increasePoint(String orderId) { + PaymentEvent paymentEvent = + paymentEventRepository + .findByOrderId(orderId) + .orElseThrow( + () -> + new IllegalArgumentException( + "orderId : %s 에 해당하는 PaymentEvent 가 없습니다.".formatted(orderId))); + + return memberService + .findMember(paymentEvent.getBuyerId()) + .increasePoint(paymentEvent.totalAmount().intValue()); + } +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/service/command/PaymentStatusUpdateCommand.java b/src/main/java/com/ordertogether/team14_be/payment/service/command/PaymentStatusUpdateCommand.java new file mode 100644 index 00000000..54836bd4 --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/service/command/PaymentStatusUpdateCommand.java @@ -0,0 +1,23 @@ +package com.ordertogether.team14_be.payment.service.command; + +import com.ordertogether.team14_be.payment.domain.PaymentStatus; +import java.util.Objects; + +public record PaymentStatusUpdateCommand( + String paymentKey, String orderId, PaymentStatus paymentStatus) { + + public PaymentStatusUpdateCommand( + String paymentKey, String orderId, PaymentStatus paymentStatus) { + validateObjectsNonnull(paymentKey, orderId, paymentStatus); + this.paymentKey = paymentKey; + this.orderId = orderId; + this.paymentStatus = paymentStatus; + } + + private void validateObjectsNonnull( + String paymentKey, String orderId, PaymentStatus paymentStatus) { + Objects.requireNonNull(paymentKey, "paymentKey 가 null 인 결제의 상태 변경은 불가능합니다."); + Objects.requireNonNull(orderId, "orderId 가 null 인 결제의 상태 변경은 불가능합니다."); + Objects.requireNonNull(paymentStatus, "paymentStatus 가 null 인 결제 상태 변경은 불가능합니다."); + } +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/web/controller/PaymentController.java b/src/main/java/com/ordertogether/team14_be/payment/web/controller/PaymentController.java index 5b17246e..e567160d 100644 --- a/src/main/java/com/ordertogether/team14_be/payment/web/controller/PaymentController.java +++ b/src/main/java/com/ordertogether/team14_be/payment/web/controller/PaymentController.java @@ -1,8 +1,11 @@ package com.ordertogether.team14_be.payment.web.controller; import com.ordertogether.team14_be.common.web.response.ApiResponse; +import com.ordertogether.team14_be.payment.service.PaymentConfirmService; import com.ordertogether.team14_be.payment.service.PaymentPreparationService; +import com.ordertogether.team14_be.payment.web.request.PaymentConfirmRequest; import com.ordertogether.team14_be.payment.web.request.PaymentPrepareRequest; +import com.ordertogether.team14_be.payment.web.response.PaymentConfirmationResponse; import com.ordertogether.team14_be.payment.web.response.PaymentPrepareResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -18,6 +21,7 @@ public class PaymentController { private final PaymentPreparationService paymentPreparationService; + private final PaymentConfirmService paymentConfirmService; @PostMapping public ResponseEntity> preparePayment( @@ -28,4 +32,15 @@ public ResponseEntity> preparePayment( return ResponseEntity.ok(ApiResponse.with(HttpStatus.OK, "결제 정보를 저장하였습니다.", data)); } + + @PostMapping("/confirm") + public ResponseEntity> confirmPayment( + @RequestBody PaymentConfirmRequest request) { + PaymentConfirmationResponse data = paymentConfirmService.confirm(request); + if (data.paymentStatus().isFail()) { + return ResponseEntity.badRequest() + .body(ApiResponse.with(HttpStatus.BAD_REQUEST, "결제에 실패하였습니다.", data)); + } + return ResponseEntity.ok(ApiResponse.with(HttpStatus.OK, "결제가 완료되었습니다.", data)); + } } diff --git a/src/main/java/com/ordertogether/team14_be/payment/web/controller/PaymentViewController.java b/src/main/java/com/ordertogether/team14_be/payment/web/controller/PaymentViewController.java new file mode 100644 index 00000000..9cc0572f --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/web/controller/PaymentViewController.java @@ -0,0 +1,40 @@ +package com.ordertogether.team14_be.payment.web.controller; + +import com.ordertogether.team14_be.payment.service.PaymentPreparationService; +import com.ordertogether.team14_be.payment.web.request.PaymentPrepareRequest; +import com.ordertogether.team14_be.payment.web.response.PaymentPrepareResponse; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +@RequiredArgsConstructor +public class PaymentViewController { + + private final PaymentPreparationService paymentPreparationService; + + @GetMapping("/success") + public String successPage() { + return "success"; + } + + @GetMapping("/fail") + public String failPage() { + return "fail"; + } + + @GetMapping("/") + public String preparePayment(Model model) { + PaymentPrepareResponse response = + paymentPreparationService.prepare( + new PaymentPrepareRequest(UUID.randomUUID().toString(), List.of(1L, 2L)) + .addBuyerId(1L)); + + model.addAttribute("orderId", response.orderId()); + model.addAttribute("orderName", response.orderName()); + return "checkout"; + } +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/web/request/PaymentConfirmRequest.java b/src/main/java/com/ordertogether/team14_be/payment/web/request/PaymentConfirmRequest.java new file mode 100644 index 00000000..1ca8dda7 --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/web/request/PaymentConfirmRequest.java @@ -0,0 +1,6 @@ +package com.ordertogether.team14_be.payment.web.request; + +import lombok.Builder; + +@Builder +public record PaymentConfirmRequest(String orderId, String paymentKey, Long amount) {} diff --git a/src/main/java/com/ordertogether/team14_be/payment/web/response/PaymentConfirmationFailure.java b/src/main/java/com/ordertogether/team14_be/payment/web/response/PaymentConfirmationFailure.java new file mode 100644 index 00000000..6179da5a --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/web/response/PaymentConfirmationFailure.java @@ -0,0 +1,3 @@ +package com.ordertogether.team14_be.payment.web.response; + +public record PaymentConfirmationFailure(String code, String message) {} diff --git a/src/main/java/com/ordertogether/team14_be/payment/web/response/PaymentConfirmationResponse.java b/src/main/java/com/ordertogether/team14_be/payment/web/response/PaymentConfirmationResponse.java new file mode 100644 index 00000000..8d71b363 --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/web/response/PaymentConfirmationResponse.java @@ -0,0 +1,20 @@ +package com.ordertogether.team14_be.payment.web.response; + +import com.ordertogether.team14_be.payment.domain.PaymentStatus; +import jakarta.annotation.Nullable; +import java.util.Objects; + +public record PaymentConfirmationResponse( + PaymentStatus paymentStatus, @Nullable PaymentConfirmationFailure failure) { + + public PaymentConfirmationResponse( + PaymentStatus paymentStatus, PaymentConfirmationFailure failure) { + if (paymentStatus.isFail()) { + Objects.requireNonNull( + failure, "결제 상태가 FAIL 인 경우, PaymentConfirmationFailure 가 null 일 수 없습니다."); + } + + this.paymentStatus = paymentStatus; + this.failure = failure; + } +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/web/toss/client/TossPaymentsClient.java b/src/main/java/com/ordertogether/team14_be/payment/web/toss/client/TossPaymentsClient.java new file mode 100644 index 00000000..13a9d2d4 --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/web/toss/client/TossPaymentsClient.java @@ -0,0 +1,53 @@ +package com.ordertogether.team14_be.payment.web.toss.client; + +import com.ordertogether.team14_be.payment.domain.PaymentStatus; +import com.ordertogether.team14_be.payment.web.request.PaymentConfirmRequest; +import com.ordertogether.team14_be.payment.web.response.PaymentConfirmationFailure; +import com.ordertogether.team14_be.payment.web.response.PaymentConfirmationResponse; +import com.ordertogether.team14_be.payment.web.toss.response.TossPaymentsConfirmationResponse; +import java.io.IOException; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse; + +@Component +@Slf4j +@RequiredArgsConstructor +public class TossPaymentsClient { + + private final RestClient tossRestClient; + private static final String URI = "/v1/payments/confirm"; + private static final String IDEMPOTENCY_HEADER_KEY = "Idempotency-Key"; + + public PaymentConfirmationResponse confirmPayment(PaymentConfirmRequest request) { + return tossRestClient + .post() + .uri(URI) + .header(IDEMPOTENCY_HEADER_KEY, request.paymentKey()) + .body(request) + .exchange( + (req, res) -> { + TossPaymentsConfirmationResponse tossResponse = + res.bodyTo(TossPaymentsConfirmationResponse.class); + + return new PaymentConfirmationResponse( + getPaymentStatus(res), getFailure(tossResponse)); + }); + } + + private static PaymentStatus getPaymentStatus(ConvertibleClientHttpResponse res) + throws IOException { + return res.getStatusCode().isError() ? PaymentStatus.FAIL : PaymentStatus.SUCCESS; + } + + private static PaymentConfirmationFailure getFailure( + TossPaymentsConfirmationResponse tossResponse) { + return Objects.isNull(tossResponse.failure()) + ? null + : new PaymentConfirmationFailure( + tossResponse.failure().code(), tossResponse.failure().message()); + } +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/web/toss/config/TossPaymentsClientConfig.java b/src/main/java/com/ordertogether/team14_be/payment/web/toss/config/TossPaymentsClientConfig.java new file mode 100644 index 00000000..d3257fde --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/web/toss/config/TossPaymentsClientConfig.java @@ -0,0 +1,30 @@ +package com.ordertogether.team14_be.payment.web.toss.config; + +import java.util.Base64; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestClient; + +@ConfigurationProperties(prefix = "pg.toss") +@RequiredArgsConstructor +public class TossPaymentsClientConfig { + + private final String secretKey; + private final String url; + + @Bean + public RestClient tossRestClient(RestClient.Builder builder) { + return builder + .defaultHeader(HttpHeaders.AUTHORIZATION, getBasicAuthorization()) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .baseUrl(url) + .build(); + } + + private String getBasicAuthorization() { + return "Basic " + Base64.getEncoder().encodeToString((secretKey + ":").getBytes()); + } +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/web/toss/error/TossPaymentsError.java b/src/main/java/com/ordertogether/team14_be/payment/web/toss/error/TossPaymentsError.java new file mode 100644 index 00000000..18f35a19 --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/web/toss/error/TossPaymentsError.java @@ -0,0 +1,59 @@ +package com.ordertogether.team14_be.payment.web.toss.error; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum TossPaymentsError { + ALREADY_PROCESSED_PAYMENT(400, "이미 처리된 결제 입니다."), + PROVIDER_ERROR(400, "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요."), + EXCEED_MAX_CARD_INSTALLMENT_PLAN(400, "설정 가능한 최대 할부 개월 수를 초과했습니다."), + INVALID_REQUEST(400, "잘못된 요청입니다."), + NOT_ALLOWED_POINT_USE(400, "포인트 사용이 불가한 카드로 카드 포인트 결제에 실패했습니다."), + INVALID_API_KEY(400, "잘못된 시크릿키 연동 정보 입니다."), + INVALID_REJECT_CARD(400, "카드 사용이 거절되었습니다. 카드사 문의가 필요합니다."), + BELOW_MINIMUM_AMOUNT(400, "신용카드는 결제금액이 100원 이상, 계좌는 200원이상부터 결제가 가능합니다."), + INVALID_CARD_EXPIRATION(400, "카드 정보를 다시 확인해주세요. (유효기간)"), + INVALID_STOPPED_CARD(400, "정지된 카드 입니다."), + EXCEED_MAX_DAILY_PAYMENT_COUNT(400, "하루 결제 가능 횟수를 초과했습니다."), + NOT_SUPPORTED_INSTALLMENT_PLAN_CARD_OR_MERCHANT(400, "할부가 지원되지 않는 카드 또는 가맹점 입니다."), + INVALID_CARD_INSTALLMENT_PLAN(400, "할부 개월 정보가 잘못되었습니다."), + NOT_SUPPORTED_MONTHLY_INSTALLMENT_PLAN(400, "할부가 지원되지 않는 카드입니다."), + EXCEED_MAX_PAYMENT_AMOUNT(400, "하루 결제 가능 금액을 초과했습니다."), + NOT_FOUND_TERMINAL_ID(400, "단말기번호(Terminal Id)가 없습니다. 토스페이먼츠로 문의 바랍니다."), + INVALID_AUTHORIZE_AUTH(400, "유효하지 않은 인증 방식입니다."), + INVALID_CARD_LOST_OR_STOLEN(400, "분실 혹은 도난 카드입니다."), + RESTRICTED_TRANSFER_ACCOUNT(400, "계좌는 등록 후 12시간 뒤부터 결제할 수 있습니다. 관련 정책은 해당 은행으로 문의해주세요."), + INVALID_CARD_NUMBER(400, "카드번호를 다시 확인해주세요."), + INVALID_UNREGISTERED_SUBMALL(400, "등록되지 않은 서브몰입니다. 서브몰이 없는 가맹점이라면 안심클릭이나 ISP 결제가 필요합니다."), + NOT_REGISTERED_BUSINESS(400, "등록되지 않은 사업자 번호입니다."), + EXCEED_MAX_ONE_DAY_WITHDRAW_AMOUNT(400, "1일 출금 한도를 초과했습니다."), + EXCEED_MAX_ONE_TIME_WITHDRAW_AMOUNT(400, "1회 출금 한도를 초과했습니다."), + CARD_PROCESSING_ERROR(400, "카드사에서 오류가 발생했습니다."), + EXCEED_MAX_AMOUNT(400, "거래금액 한도를 초과했습니다."), + INVALID_ACCOUNT_INFO_RE_REGISTER(400, "유효하지 않은 계좌입니다. 계좌 재등록 후 시도해주세요."), + NOT_AVAILABLE_PAYMENT(400, "결제가 불가능한 시간대입니다"), + UNAPPROVED_ORDER_ID(400, "아직 승인되지 않은 주문번호입니다."), + UNAUTHORIZED_KEY(401, "인증되지 않은 시크릿 키 혹은 클라이언트 키 입니다."), + REJECT_ACCOUNT_PAYMENT(403, "잔액부족으로 결제에 실패했습니다."), + REJECT_CARD_PAYMENT(403, "한도초과 혹은 잔액부족으로 결제에 실패했습니다."), + REJECT_CARD_COMPANY(403, "결제 승인이 거절되었습니다."), + FORBIDDEN_REQUEST(403, "허용되지 않은 요청입니다."), + REJECT_TOSSPAY_INVALID_ACCOUNT(403, "선택하신 출금 계좌가 출금이체 등록이 되어 있지 않아요. 계좌를 다시 등록해 주세요."), + EXCEED_MAX_AUTH_COUNT(403, "최대 인증 횟수를 초과했습니다. 카드사로 문의해주세요."), + EXCEED_MAX_ONE_DAY_AMOUNT(403, "일일 한도를 초과했습니다."), + NOT_AVAILABLE_BANK(403, "은행 서비스 시간이 아닙니다."), + INVALID_PASSWORD(403, "결제 비밀번호가 일치하지 않습니다."), + INCORRECT_BASIC_AUTH_FORMAT(403, "잘못된 요청입니다. ':' 를 포함해 인코딩해주세요."), + FDS_ERROR( + 403, "[토스페이먼츠] 위험거래가 감지되어 결제가 제한됩니다. 발송된 문자에 포함된 링크를 통해 본인인증 후 결제가 가능합니다. (고객센터: 1644-8051)"), + NOT_FOUND_PAYMENT(404, "존재하지 않는 결제 정보 입니다."), + NOT_FOUND_PAYMENT_SESSION(404, "결제 시간이 만료되어 결제 진행 데이터가 존재하지 않습니다."), + FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING(500, "결제가 완료되지 않았어요. 다시 시도해주세요."), + FAILED_INTERNAL_SYSTEM_PROCESSING(500, "내부 시스템 처리 작업이 실패했습니다. 잠시 후 다시 시도해주세요."), + UNKNOWN_PAYMENT_ERROR(500, "결제에 실패했어요. 같은 문제가 반복된다면 은행이나 카드사로 문의해주세요."), + UNKNOWN(500, "알 수 없는 에러입니다."); + + private final Integer statusCode; + + private final String description; +} diff --git a/src/main/java/com/ordertogether/team14_be/payment/web/toss/response/TossPaymentsConfirmationResponse.java b/src/main/java/com/ordertogether/team14_be/payment/web/toss/response/TossPaymentsConfirmationResponse.java new file mode 100644 index 00000000..45ef657f --- /dev/null +++ b/src/main/java/com/ordertogether/team14_be/payment/web/toss/response/TossPaymentsConfirmationResponse.java @@ -0,0 +1,104 @@ +package com.ordertogether.team14_be.payment.web.toss.response; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import lombok.Builder; + +@Builder +public record TossPaymentsConfirmationResponse( + String version, + String paymentKey, + String type, + String orderId, + String orderName, + String mId, + String currency, + String method, + BigDecimal totalAmount, + BigDecimal balanceAmount, + String status, + String requestedAt, + String approvedAt, + boolean useEscrow, + String lastTransactionKey, + BigDecimal suppliedAmount, + BigDecimal vat, + boolean cultureExpense, + BigDecimal taxFreeAmount, + int taxExemptionAmount, + List cancels, + boolean expired, + boolean isPartialCancelable, + Card card, + VirtualAccount virtualAccount, + Transfer transfer, + CashReceipt cashReceipt, + List cashReceipts, + Metadata metadata, + String receiptUrl, + EasyPay easyPay, + String country, + Failure failure, + RefundReceiveAccount refundReceiveAccount) { + + public static record Card( + BigDecimal amount, + String issuerCode, + String acquirerCode, + String number, + int installmentPlanMonths, + String approveNo, + boolean useCardPoint, + String cardType, + String ownerType, + String acquireStatus, + boolean isInterestFree, + String interestPayer) {} + + public static record Cancel( + BigDecimal cancelAmount, + String cancelReason, + BigDecimal taxFreeAmount, + int taxExemptionAmount, + BigDecimal refundableAmount, + BigDecimal easyPayDiscountAmount, + String canceledAt, + String transactionKey, + String receiptKey, + String cancelStatus, + String cancelRequestId) {} + + public static record CashReceipt( + String type, + String receiptKey, + String issueNumber, + String receiptUrl, + BigDecimal amount, + BigDecimal taxFreeAmount, + String businessNumber, + String transactionType, + String issueStatus, + String customerIdentityNumber, + String requestedAt) {} + + public static record EasyPay(String provider, BigDecimal amount, BigDecimal discountAmount) {} + + public static record Failure(String code, String message) {} + + public static record Metadata(Map metadata) {} + + public static record RefundReceiveAccount( + String bankCode, String accountNumber, String holderName) {} + + public static record Transfer(String bankCode, String settlementStatus) {} + + public static record VirtualAccount( + String accountType, + String accountNumber, + String bankCode, + String customerName, + String dueDate, + String refundStatus, + boolean expired) {} +} diff --git a/src/main/java/com/ordertogether/team14_be/spot/controller/SpotController.java b/src/main/java/com/ordertogether/team14_be/spot/controller/SpotController.java index a7cc2cc6..4e047ed8 100644 --- a/src/main/java/com/ordertogether/team14_be/spot/controller/SpotController.java +++ b/src/main/java/com/ordertogether/team14_be/spot/controller/SpotController.java @@ -39,10 +39,10 @@ public ResponseEntity getSpotDetail(@PathVariable Long id) { } // 반경 n미터 내 Spot 조회하기 - @GetMapping("/api/v1/spot/{lat}/{lng}/{radius}") // 현재 위치의 좌표와 반지름을 받아옴 - public ResponseEntity> getSpotByRadius( - @PathVariable BigDecimal lat, @PathVariable BigDecimal lng, @PathVariable int radius) { - return ResponseEntity.ok(spotService.getSpotByRadius(lat, lng, radius)); + @GetMapping("/api/v1/spot/{lat}/{lng}") // 현재 위치의 좌표로 hash값이 같은 튜플을 조회 + public ResponseEntity> getSpotByGeoHash( + @PathVariable BigDecimal lat, @PathVariable BigDecimal lng) { + return ResponseEntity.ok(spotService.getSpotByGeoHash(lat, lng)); } // Spot 수정하기 diff --git a/src/main/java/com/ordertogether/team14_be/spot/dto/controllerdto/SpotCreationRequest.java b/src/main/java/com/ordertogether/team14_be/spot/dto/controllerdto/SpotCreationRequest.java index d6e62e45..baadc5a3 100644 --- a/src/main/java/com/ordertogether/team14_be/spot/dto/controllerdto/SpotCreationRequest.java +++ b/src/main/java/com/ordertogether/team14_be/spot/dto/controllerdto/SpotCreationRequest.java @@ -3,6 +3,7 @@ import com.ordertogether.team14_be.spot.enums.Category; import jakarta.validation.constraints.NotNull; import java.math.BigDecimal; +import java.time.LocalTime; public record SpotCreationRequest( Long id, @@ -12,4 +13,5 @@ public record SpotCreationRequest( @NotNull(message = "카테고리를 선택해주세요") Category category, @NotNull(message = "최소 주문 금액을 입력해주세요") Integer minimumOrderAmount, @NotNull(message = "배달의 민족 함께 주문링크를 입력해주세요") String togetherOrderLink, - @NotNull(message = "픽업 장소를 입력해주세요") String pickUpLocation) {} + @NotNull(message = "픽업 장소를 입력해주세요") String pickUpLocation, + @NotNull(message = "주문 마감 시간을 입력해주세요") LocalTime deadlineTime) {} diff --git a/src/main/java/com/ordertogether/team14_be/spot/dto/controllerdto/SpotCreationResponse.java b/src/main/java/com/ordertogether/team14_be/spot/dto/controllerdto/SpotCreationResponse.java index 8695c545..bb065525 100644 --- a/src/main/java/com/ordertogether/team14_be/spot/dto/controllerdto/SpotCreationResponse.java +++ b/src/main/java/com/ordertogether/team14_be/spot/dto/controllerdto/SpotCreationResponse.java @@ -1,10 +1,12 @@ package com.ordertogether.team14_be.spot.dto.controllerdto; import com.ordertogether.team14_be.spot.enums.Category; +import java.time.LocalTime; public record SpotCreationResponse( Long id, Category category, String storeName, Integer minimumOrderAmount, - String pickUpLocation) {} + String pickUpLocation, + LocalTime deadlineTime) {} diff --git a/src/main/java/com/ordertogether/team14_be/spot/dto/servicedto/SpotDto.java b/src/main/java/com/ordertogether/team14_be/spot/dto/servicedto/SpotDto.java index d33bba55..c71c67ee 100644 --- a/src/main/java/com/ordertogether/team14_be/spot/dto/servicedto/SpotDto.java +++ b/src/main/java/com/ordertogether/team14_be/spot/dto/servicedto/SpotDto.java @@ -4,6 +4,7 @@ import jakarta.persistence.Column; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.LocalTime; import lombok.*; @Builder @@ -25,6 +26,8 @@ public class SpotDto { private String togetherOrderLink; private String pickUpLocation; private String deliveryStatus; + private LocalTime deadlineTime; + @Setter private String geoHash; private boolean isDeleted; private LocalDateTime createdAt; private LocalDateTime modifiedAt; diff --git a/src/main/java/com/ordertogether/team14_be/spot/entity/Spot.java b/src/main/java/com/ordertogether/team14_be/spot/entity/Spot.java index 20373b01..6d2f9906 100644 --- a/src/main/java/com/ordertogether/team14_be/spot/entity/Spot.java +++ b/src/main/java/com/ordertogether/team14_be/spot/entity/Spot.java @@ -5,6 +5,7 @@ import com.ordertogether.team14_be.spot.enums.Category; import jakarta.persistence.*; import java.math.BigDecimal; +import java.time.LocalTime; import lombok.*; import lombok.experimental.SuperBuilder; import org.hibernate.annotations.DynamicUpdate; @@ -42,6 +43,8 @@ public class Spot extends BaseEntity { private String pickUpLocation; private String deliveryStatus; + private LocalTime deadlineTime; + private String geoHash; @Builder.Default private Boolean isDeleted = false; public void delete() { diff --git a/src/main/java/com/ordertogether/team14_be/spot/exception/ErrorResponse.java b/src/main/java/com/ordertogether/team14_be/spot/exception/ErrorResponse.java index 40bb3bef..24effc29 100644 --- a/src/main/java/com/ordertogether/team14_be/spot/exception/ErrorResponse.java +++ b/src/main/java/com/ordertogether/team14_be/spot/exception/ErrorResponse.java @@ -2,5 +2,5 @@ import com.ordertogether.team14_be.spot.enums.ErrorCode; -// @Getter + public record ErrorResponse(String message, ErrorCode code) {} diff --git a/src/main/java/com/ordertogether/team14_be/spot/repository/SimpleSpotRepository.java b/src/main/java/com/ordertogether/team14_be/spot/repository/SimpleSpotRepository.java index 416031a2..f326be19 100644 --- a/src/main/java/com/ordertogether/team14_be/spot/repository/SimpleSpotRepository.java +++ b/src/main/java/com/ordertogether/team14_be/spot/repository/SimpleSpotRepository.java @@ -22,4 +22,6 @@ List findAroundSpotAndIsDeletedFalse( @Param("maxlng") BigDecimal maxlng, @Param("minlat") BigDecimal minlat, @Param("minlng") BigDecimal minlng); + + List findByGeoHash(String geoHash); } diff --git a/src/main/java/com/ordertogether/team14_be/spot/repository/SpotRepository.java b/src/main/java/com/ordertogether/team14_be/spot/repository/SpotRepository.java index 4eed28c5..7e3217ee 100644 --- a/src/main/java/com/ordertogether/team14_be/spot/repository/SpotRepository.java +++ b/src/main/java/com/ordertogether/team14_be/spot/repository/SpotRepository.java @@ -41,10 +41,9 @@ public void delete(Long id) { spot.delete(); } - public List findAroundSpotAndIsDeletedFalse( - BigDecimal maxX, BigDecimal maxY, BigDecimal minX, BigDecimal minY) { - List spots = simpleSpotRepository.findAroundSpotAndIsDeletedFalse(maxX, maxY, minX, minY); - - return spots.stream().map(SpotMapper.INSTANCE::toDto).toList(); + public List findBygeoHash(String geoHash) { + return simpleSpotRepository.findByGeoHash(geoHash).stream() + .map(SpotMapper.INSTANCE::toDto) + .toList(); } } diff --git a/src/main/java/com/ordertogether/team14_be/spot/service/SpotService.java b/src/main/java/com/ordertogether/team14_be/spot/service/SpotService.java index 0ec524d5..509ac61d 100644 --- a/src/main/java/com/ordertogether/team14_be/spot/service/SpotService.java +++ b/src/main/java/com/ordertogether/team14_be/spot/service/SpotService.java @@ -1,7 +1,6 @@ package com.ordertogether.team14_be.spot.service; -import static java.lang.Math.abs; - +import ch.hsr.geohash.GeoHash; import com.ordertogether.team14_be.spot.dto.controllerdto.SpotCreationResponse; import com.ordertogether.team14_be.spot.dto.controllerdto.SpotDetailResponse; import com.ordertogether.team14_be.spot.dto.controllerdto.SpotViewedResponse; @@ -18,7 +17,6 @@ @Service @RequiredArgsConstructor public class SpotService { - public static final int EARTH_RADIUS = 6371000; // 6371km private final SpotRepository spotRepository; // Spot 전체 조회하기 @@ -31,6 +29,10 @@ public List getSpot(BigDecimal lat, BigDecimal lng) { @Transactional public SpotCreationResponse createSpot(SpotDto spotDto) { + BigDecimal lat = spotDto.getLat(); + BigDecimal lng = spotDto.getLng(); + GeoHash geoHash = GeoHash.withCharacterPrecision(lat.doubleValue(), lng.doubleValue(), 12); + spotDto.setGeoHash(geoHash.toBase32()); Spot spot = SpotMapper.INSTANCE.toEntity(spotDto, new Spot()); return SpotMapper.INSTANCE.toSpotCreationResponse(spotRepository.save(spot)); } @@ -42,42 +44,16 @@ public SpotDetailResponse getSpot(Long id) { return SpotMapper.INSTANCE.toSpotDetailResponse(spotDto); } - // 반경 n미터 내 Spot 조회하기 @Transactional(readOnly = true) - public List getSpotByRadius(BigDecimal lat, BigDecimal lng, int radius) { - // m당 y 좌표 이동 값 - double mForLatitude = (1 / (EARTH_RADIUS * 1 * (Math.PI / 180))) / 1000; - // m당 x 좌표 이동 값 - double mForLongitude = - (1 / (EARTH_RADIUS * 1 * (Math.PI / 180) * Math.cos(Math.toRadians(lat.doubleValue())))) - / 1000; - - // 현재 위치 기준 검색 거리 좌표 - double maxY = lat.doubleValue() + (radius * mForLatitude); - double minY = lat.doubleValue() - (radius * mForLatitude); - double maxX = lng.doubleValue() + (radius * mForLongitude); - double minX = lng.doubleValue() - (radius * mForLongitude); + public List getSpotByGeoHash(BigDecimal lat, BigDecimal lng) { + int precision = 12; + GeoHash geoHash = + GeoHash.withCharacterPrecision(lat.doubleValue(), lng.doubleValue(), precision); - // 원의 지름에 해당하는 정사각형 내에 있는 Spot들을 모두 가져옴 - List resultAroundSpot = - spotRepository.findAroundSpotAndIsDeletedFalse( - BigDecimal.valueOf(maxX), - BigDecimal.valueOf(maxY), - BigDecimal.valueOf(minX), - BigDecimal.valueOf(minY)); + String hashString = geoHash.toBase32(); - // 자기 위치에서부터 반경 내에 있는 Spot만 반환 - return resultAroundSpot.stream() - .filter( - spotDto -> { - double distance = - Math.sqrt( - Math.pow(abs(spotDto.getLat().doubleValue() - lat.doubleValue()), 2) - + Math.pow(abs(spotDto.getLng().doubleValue() - lng.doubleValue()), 2)); - return distance <= radius; - }) - .map(SpotMapper.INSTANCE::toSpotViewedResponse) - .toList(); + List resultAroundSpot = spotRepository.findBygeoHash(hashString); + return resultAroundSpot.stream().map(SpotMapper.INSTANCE::toSpotViewedResponse).toList(); } @Transactional diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 95a30cc9..249b0064 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -40,6 +40,11 @@ kakao: api: url: ${KAKAO_USER_API_URL} +pg: + toss: + secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + url: https://api.tosspayments.com + key: jwt: secret-key: ${JWT_SECRET_KEY} diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 59b6346f..b3610722 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -1,3 +1,5 @@ +INSERT INTO member (id, email, point, phone_number, delivery_name, platform) VALUES (1, 'member1@example.com', 100000, '010-1234-5678', 'John Doe', 'Kakao'); + INSERT INTO product (id, name, price, created_at, modified_at, created_by, modified_by) VALUES (1, 'Product 1', 10000, now(), now(), 1, 1); INSERT INTO product (id, name, price, created_at, modified_at, created_by, modified_by) VALUES (2, 'Product 2', 20000, now(), now(), 1, 1); INSERT INTO product (id, name, price, created_at, modified_at, created_by, modified_by) VALUES (3, 'Product 3', 30000, now(), now(), 1, 1); diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css new file mode 100644 index 00000000..f6dd93b1 --- /dev/null +++ b/src/main/resources/static/style.css @@ -0,0 +1,251 @@ +body { + background-image: url('https://static.toss.im/ml-illust/img-back_005.jpg'); +} +.p { + padding: 0; + margin: 0; + font-family: Toss Product Sans, -apple-system, BlinkMacSystemFont, + Bazier Square, Noto Sans KR, Segoe UI, Apple SD Gothic Neo, Roboto, + Helvetica Neue, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, + Segoe UI Symbol, Noto Color Emoji; + color: #4e5968; + word-break: keep-all; + word-wrap: break-word; +} +.h4 { + font-size: 20px; + font-weight: 700; + color: #333D4B; +} +.wrapper { + max-width: 800px; + margin: 0 auto; +} +.button { + color: #f9fafb; + background-color: #3182f6; + margin: 0; + font-size: 15px; + font-weight: 400; + line-height: 18px; + white-space: nowrap; + text-align: center; + /* vertical-align: middle; */ + cursor: pointer; + border: 0 solid transparent; + user-select: none; + transition: background 0.2s ease, color 0.1s ease; + text-decoration: none; + border-radius: 7px; + padding: 11px 16px; +} +.button:hover { + color: #fff; + background-color: #1b64da; +} +.title { + margin: 0 0 4px; + font-size: 24px; + font-weight: 600; + color: #4e5968; +} +.result { + flex-direction: column; + align-items: center; + text-align: center; + text-wrap: balance; +} +.box_section { + background-color: white; + border-radius: 10px; + box-shadow: 0 10px 20px rgb(0 0 0 / 1%), 0 6px 6px rgb(0 0 0 / 6%); + padding: 40px 30px 50px 30px; + margin-top:30px; + margin-bottom:50px; + color: #333D4B +} +:root { + --checkable-size: 20px; + --checkable-input-top: 3px; + --checkable-input-left: 5px; + --checkable-input-width: 14px; + --checkable-input-height: 10px; + --checkable-input-svg: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1.343 4.574l4.243 4.243 7.07-7.071' fill='transparent' stroke-width='2' stroke='%23FFF' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + --checkable-label-text-padding: 8px; + --indeterminate-checkable-input-top: 7px; + --indeterminate-checkable-input-left: 5px; + --indeterminate-checkable-input-width: 14px +} + +:root .checkable--small { + --checkable-size: 20px; + --checkable-input-top: 2px; + --checkable-input-left: 4px; + --checkable-input-width: 12px; + --checkable-input-height: 9px; + --checkable-input-svg: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1.286 3.645l3.536 3.536 5.892-5.893' fill='transparent' stroke-width='2' stroke='%23FFF' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + --indeterminate-checkable-input-top: 5px; + --indeterminate-checkable-input-left: 4px; + --indeterminate-checkable-input-width: 12px +} + +.checkable { + position: relative; + display: flex +} + +.checkable+.checkable { + margin-top: 12px +} + +.checkable--inline { + display: inline-block +} + +.checkable--inline+.checkable--inline { + margin-top: 0; + margin-left: 18px +} + +.checkable__label { + display: inline-block; + max-width: 100%; + min-height: 20px; + min-height: var(--checkable-size); + line-height: 1.6; + padding-left: 20px; + padding-left: var(--checkable-size); + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + color: #4e5968; + color: var(--grey700); + cursor: pointer +} + +.checkable__input { + position: absolute; + margin: 0 0 0 -20px; + margin: 0 0 0 calc(var(--checkable-size)*-1); + top: 4px; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border: none; + cursor: pointer +} + +.checkable__input:after,.checkable__input:before { + content: ""; + position: absolute +} + +.checkable__input:before { + top: -4px; + left: 0; + width: 20px; + width: var(--checkable-size); + height: 20px; + height: var(--checkable-size); + border: 2px solid #d1d6db; + border: 2px solid #d1d6db; + background-color: #fff; + background-color: white; + transition: border-color .1s ease,background-color .1s ease +} + +.checkable__input:after { + opacity: 0; + transition: opacity .1s ease; + top: 3px; + top: var(--checkable-input-top); + left: 5px; + left: var(--checkable-input-left); + width: 14px; + width: var(--checkable-input-width); + height: 10px; + height: var(--checkable-input-height); + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1.343 4.574l4.243 4.243 7.07-7.071' fill='transparent' stroke-width='2' stroke='%23FFF' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-image: var(--checkable-input-svg); + background-repeat: no-repeat +} + +.checkable__input[type=checkbox]:indeterminate:after { + top: 7px; + top: var(--indeterminate-checkable-input-top); + left: 5px; + left: var(--indeterminate-checkable-input-left); + width: 14px; + width: var(--indeterminate-checkable-input-width); + height: 0; + border: 1px solid #fff; + border: 1px solid var(--white); + border-radius: 1px; + transform: rotate(0) +} + +.checkable__input:focus { + outline: 0 +} + +.checkable__input:focus:before,.checkable__input:hover:before { + background-color: #e8f3ff; + background-color: #e8f3ff; + border-color: #3182f6; + border-color: #3182f6 +} + +.checkable__input:checked:before,.checkable__input[type=checkbox]:indeterminate:before { + border-color: #3182f6; + border-color: #3182f6; + background-color: #3182f6; + background-color: #3182f6 +} + +.checkable__input:checked:after,.checkable__input[type=checkbox]:indeterminate:after { + opacity: 1 +} + +.checkable__input:disabled:before { + background-color: #f2f4f6; + background-color: var(--grey100); + border-color: rgba(0,23,51,.02); + border-color: var(--greyOpacity50) +} + +.checkable__input:disabled:checked:before,.checkable__input:disabled[type=checkbox]:indeterminate:before { + background-color: #e5e8eb; + background-color: var(--grey200); + border-color: #e5e8eb; + border-color: var(--grey200) +} + +.checkable__input[type=checkbox]:before { + border-radius: 6px +} + +.checkable__input[type=radio]:before { + border-radius: 12px +} + +.checkable__label-text { + display: inline-block; + padding-left: 13px; + color: #4e5968; + + /* padding-left: var(--checkable-label-text-padding) */ +} + +.checkable--disabled>.checkable__input { + cursor: not-allowed +} + +.checkable--disabled>.checkable__label { + color: #b0b8c1; + color: var(--grey400); + cursor: not-allowed +} + +.checkable--read-only { + pointer-events: none +} diff --git a/src/main/resources/templates/checkout.html b/src/main/resources/templates/checkout.html new file mode 100644 index 00000000..98f50b90 --- /dev/null +++ b/src/main/resources/templates/checkout.html @@ -0,0 +1,147 @@ + + + + + + + + + 토스페이먼츠 샘플 프로젝트 + + + + + + +
+
+ +
+ +
+ +
+
+ +
+
+ + +
+
+ + + + +
+
+ + + diff --git a/src/main/resources/templates/fail.html b/src/main/resources/templates/fail.html new file mode 100644 index 00000000..0f298edd --- /dev/null +++ b/src/main/resources/templates/fail.html @@ -0,0 +1,34 @@ + + + + + + + + + 토스페이먼츠 샘플 프로젝트 + + + +
+
+

+ + 결제 실패 +

+

+

+
+
+ + + + diff --git a/src/main/resources/templates/success.html b/src/main/resources/templates/success.html new file mode 100644 index 00000000..603d01af --- /dev/null +++ b/src/main/resources/templates/success.html @@ -0,0 +1,67 @@ + + + + + + + + + 토스페이먼츠 샘플 프로젝트 + + +
+
+

+ + 결제 성공 +

+ +

+

+

+
+
+ + + diff --git a/src/test/java/com/ordertogether/team14_be/helper/PaymentDatabaseHelper.java b/src/test/java/com/ordertogether/team14_be/helper/PaymentDatabaseHelper.java index 73e969bd..c8b44021 100644 --- a/src/test/java/com/ordertogether/team14_be/helper/PaymentDatabaseHelper.java +++ b/src/test/java/com/ordertogether/team14_be/helper/PaymentDatabaseHelper.java @@ -3,4 +3,8 @@ public interface PaymentDatabaseHelper { void clean(); + + void saveTestData(); + + void setOrderId(String orderId); } diff --git a/src/test/java/com/ordertogether/team14_be/helper/jpa/JpaPaymentDatabaseHelper.java b/src/test/java/com/ordertogether/team14_be/helper/jpa/JpaPaymentDatabaseHelper.java index 7f292b01..c2b6da2e 100644 --- a/src/test/java/com/ordertogether/team14_be/helper/jpa/JpaPaymentDatabaseHelper.java +++ b/src/test/java/com/ordertogether/team14_be/helper/jpa/JpaPaymentDatabaseHelper.java @@ -1,17 +1,92 @@ package com.ordertogether.team14_be.helper.jpa; import com.ordertogether.team14_be.helper.PaymentDatabaseHelper; +import com.ordertogether.team14_be.member.persistence.MemberRepository; +import com.ordertogether.team14_be.member.persistence.entity.Member; +import com.ordertogether.team14_be.payment.domain.PaymentEvent; +import com.ordertogether.team14_be.payment.domain.PaymentOrder; +import com.ordertogether.team14_be.payment.domain.PaymentStatus; +import com.ordertogether.team14_be.payment.domain.Product; +import com.ordertogether.team14_be.payment.persistence.repository.PaymentEventRepository; +import com.ordertogether.team14_be.payment.persistence.repository.PaymentOrderRepository; +import com.ordertogether.team14_be.payment.persistence.repository.ProductRepository; +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor public class JpaPaymentDatabaseHelper implements PaymentDatabaseHelper { private final JpaDatabaseCleanup jpaDatabaseCleanup; + private final PaymentEventRepository paymentEventRepository; + private final PaymentOrderRepository paymentOrderRepository; + private final ProductRepository productRepository; + private final MemberRepository memberRepository; + + private String orderId; @Override public void clean() { jpaDatabaseCleanup.execute(); } + + @Override + @Transactional + public void saveTestData() { + if (Objects.isNull(orderId)) { + throw new IllegalStateException("orderId is not set"); + } + memberRepository.save( + new Member(1L, "member1@example.com", 100000, "010-1234-5678", "member1", "Kakao")); + + productRepository.saveAll( + List.of( + Product.builder().id(1L).name("Product 1").price(BigDecimal.valueOf(10000)).build(), + Product.builder().id(2L).name("Product 2").price(BigDecimal.valueOf(20000)).build(), + Product.builder().id(3L).name("Product 3").price(BigDecimal.valueOf(30000)).build())); + + List paymentOrders = + paymentOrderRepository.saveAll( + List.of( + PaymentOrder.builder() + .id(1L) + .productId(1L) + .orderId(orderId) + .orderName("Product 1") + .amount(BigDecimal.valueOf(10000L)) + .build(), + PaymentOrder.builder() + .id(2L) + .productId(2L) + .orderId(orderId) + .orderName("Product 2") + .amount(BigDecimal.valueOf(20000L)) + .build(), + PaymentOrder.builder() + .id(3L) + .productId(3L) + .orderId(orderId) + .orderName("Product 3") + .amount(BigDecimal.valueOf(30000L)) + .build())); + + paymentEventRepository.save( + PaymentEvent.builder() + .id(1L) + .buyerId(1L) + .orderId(orderId) + .paymentOrders(paymentOrders) + .orderName("Product 1, Product 2, Product 3") + .paymentStatus(PaymentStatus.READY) + .build()); + } + + @Override + public void setOrderId(String orderId) { + this.orderId = orderId; + } } diff --git a/src/test/java/com/ordertogether/team14_be/payment/service/PaymentConfirmServiceTest.java b/src/test/java/com/ordertogether/team14_be/payment/service/PaymentConfirmServiceTest.java new file mode 100644 index 00000000..b5543afd --- /dev/null +++ b/src/test/java/com/ordertogether/team14_be/payment/service/PaymentConfirmServiceTest.java @@ -0,0 +1,101 @@ +package com.ordertogether.team14_be.payment.service; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.ordertogether.team14_be.helper.PaymentDatabaseHelper; +import com.ordertogether.team14_be.member.persistence.MemberRepository; +import com.ordertogether.team14_be.payment.domain.PaymentStatus; +import com.ordertogether.team14_be.payment.persistence.repository.PaymentEventRepository; +import com.ordertogether.team14_be.payment.persistence.repository.PaymentOrderRepository; +import com.ordertogether.team14_be.payment.persistence.repository.ProductRepository; +import com.ordertogether.team14_be.payment.web.request.PaymentConfirmRequest; +import com.ordertogether.team14_be.payment.web.response.PaymentConfirmationResponse; +import com.ordertogether.team14_be.payment.web.toss.client.TossPaymentsClient; +import java.util.NoSuchElementException; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ExtendWith(MockitoExtension.class) +@ActiveProfiles(profiles = "test") +class PaymentConfirmServiceTest { + + @MockBean PaymentConfirmService paymentConfirmService; + + @Autowired PaymentDatabaseHelper paymentDatabaseHelper; + + @Autowired PaymentValidationService paymentValidationService; + @Autowired PaymentStatusUpdateService paymentStatusUpdateService; + @Autowired PointManagementService pointManagementService; + + @Autowired PaymentOrderRepository paymentOrderRepository; + @Autowired PaymentEventRepository paymentEventRepository; + @Autowired ProductRepository productRepository; + @Autowired MemberRepository memberRepository; + + @Mock TossPaymentsClient tossPaymentsClient; + + @BeforeEach + void setUp() { + paymentConfirmService = + new PaymentConfirmService( + tossPaymentsClient, + paymentValidationService, + paymentStatusUpdateService, + pointManagementService); + + paymentDatabaseHelper.clean(); + + paymentDatabaseHelper.setOrderId("test-order-id"); + paymentDatabaseHelper.saveTestData(); + } + + @Test + @DisplayName("결제 승인 성공 시, 결제 상태를 성공으로 저장하고 포인트를 증가시킨다") + void shouldSaveSuccessStatusWhenNormallyRequest() { + // given + int beforePoint = + memberRepository + .findById(1L) + .orElseThrow(() -> new NoSuchElementException("Member not found")) + .getPoint(); + + long chargeAmount = 60000L; + PaymentConfirmRequest request = + PaymentConfirmRequest.builder() + .orderId("test-order-id") + .paymentKey(UUID.randomUUID().toString()) + .amount(chargeAmount) + .build(); + + // when + when(tossPaymentsClient.confirmPayment(any(PaymentConfirmRequest.class))) + .thenReturn(new PaymentConfirmationResponse(PaymentStatus.SUCCESS, null)); + + PaymentConfirmationResponse response = paymentConfirmService.confirm(request); + + // then + int afterPoint = + memberRepository + .findById(1L) + .orElseThrow(() -> new NoSuchElementException("Member not found")) + .getPoint(); + + assertAll( + () -> assertThat(afterPoint).isEqualTo(beforePoint + chargeAmount), + () -> assertThat(response.paymentStatus()).isEqualTo(PaymentStatus.SUCCESS), + () -> assertThat(response.failure()).isNull()); + } +} diff --git a/src/test/java/com/ordertogether/team14_be/payment/service/PaymentPreparationServiceTest.java b/src/test/java/com/ordertogether/team14_be/payment/service/PaymentPreparationServiceTest.java index 30ac7813..dff20c96 100644 --- a/src/test/java/com/ordertogether/team14_be/payment/service/PaymentPreparationServiceTest.java +++ b/src/test/java/com/ordertogether/team14_be/payment/service/PaymentPreparationServiceTest.java @@ -54,7 +54,7 @@ void shouldSuccessWhenNormalRequest() { assertThat(response.paymentOrders()).hasSize(3); assertThat(response.orderId()).isNotNull(); assertThat(response.orderName()).isEqualTo("Product 1,Product 2,Product 3"); - assertThat(response.paymentKey()).isNotNull(); + assertThat(response.paymentKey()).isNull(); response.paymentOrders().stream() .forEach( paymentOrder -> { diff --git a/src/test/java/com/ordertogether/team14_be/payment/service/PaymentStatusUpdateServiceTest.java b/src/test/java/com/ordertogether/team14_be/payment/service/PaymentStatusUpdateServiceTest.java new file mode 100644 index 00000000..297fd49f --- /dev/null +++ b/src/test/java/com/ordertogether/team14_be/payment/service/PaymentStatusUpdateServiceTest.java @@ -0,0 +1,72 @@ +package com.ordertogether.team14_be.payment.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ordertogether.team14_be.helper.PaymentDatabaseHelper; +import com.ordertogether.team14_be.payment.domain.PaymentEvent; +import com.ordertogether.team14_be.payment.domain.PaymentOrder; +import com.ordertogether.team14_be.payment.domain.PaymentStatus; +import com.ordertogether.team14_be.payment.persistence.repository.PaymentEventRepository; +import com.ordertogether.team14_be.payment.service.command.PaymentStatusUpdateCommand; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class PaymentStatusUpdateServiceTest { + + @Autowired PaymentStatusUpdateService paymentStatusUpdateService; + + @Autowired PaymentDatabaseHelper paymentDatabaseHelper; + + @Autowired PaymentEventRepository paymentEventRepository; + + @BeforeEach + void setup() { + paymentDatabaseHelper.clean(); + + paymentEventRepository.save( + PaymentEvent.builder() + .id(1L) + .paymentOrders( + List.of( + PaymentOrder.builder() + .id(1L) + .orderId("test-order-id") + .orderName("test-order-name01") + .amount(BigDecimal.valueOf(1000)) + .productId(1L) + .build(), + PaymentOrder.builder() + .id(2L) + .orderId("test-order-id") + .orderName("test-order-name02") + .amount(BigDecimal.valueOf(2000)) + .productId(2L) + .build())) + .paymentStatus(PaymentStatus.READY) + .buyerId(1L) + .paymentKey("test-payment-key") + .orderId("test-order-id") + .orderName("test-order-name01, test-order-name02") + .build()); + } + + @ParameterizedTest(name = "PaymentEvent의 상태를 READY 에서 {0} 으로 변경할 수 있다.") + @EnumSource + void shouldUpdateStatusWithNormalRequest(PaymentStatus paymentStatus) { + PaymentStatusUpdateCommand command = + new PaymentStatusUpdateCommand("test-payment-key", "test-order-id", paymentStatus); + paymentStatusUpdateService.updatePaymentStatus(command); + + PaymentEvent result = paymentEventRepository.findByOrderId("test-order-id").get(); + + assertThat(result.getPaymentStatus()).isEqualTo(paymentStatus); + } +} diff --git a/src/test/java/com/ordertogether/team14_be/payment/service/PointManagementServiceTest.java b/src/test/java/com/ordertogether/team14_be/payment/service/PointManagementServiceTest.java new file mode 100644 index 00000000..9daadf15 --- /dev/null +++ b/src/test/java/com/ordertogether/team14_be/payment/service/PointManagementServiceTest.java @@ -0,0 +1,61 @@ +package com.ordertogether.team14_be.payment.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ordertogether.team14_be.helper.PaymentDatabaseHelper; +import com.ordertogether.team14_be.member.persistence.MemberRepository; +import com.ordertogether.team14_be.payment.persistence.repository.PaymentEventRepository; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles(profiles = "test") +class PointManagementServiceTest { + + @Autowired PointManagementService pointManagementService; + + @Autowired MemberRepository memberRepository; + @Autowired PaymentEventRepository paymentEventRepository; + + @Autowired PaymentDatabaseHelper paymentDatabaseHelper; + + @BeforeEach + void setUp() { + paymentDatabaseHelper.clean(); + + paymentDatabaseHelper.setOrderId("test-order-id"); + paymentDatabaseHelper.saveTestData(); + } + + @Test + @DisplayName("구매 금액만큼 포인트가 증가한다") + void shouldIncreaseSuccessWhenNormallyRequest() { + // given + int beforePoint = + memberRepository + .findById(1L) + .orElseThrow(() -> new NoSuchElementException("Member not found")) + .getPoint(); + Long chargeAmount = + paymentEventRepository + .findByOrderId("test-order-id") + .orElseThrow(() -> new NoSuchElementException("PaymentEvent not found")) + .totalAmount(); + + // when + pointManagementService.increasePoint("test-order-id"); + + // then + int afterPoint = + memberRepository + .findById(1L) + .orElseThrow(() -> new NoSuchElementException("Member not found")) + .getPoint(); + assertThat(afterPoint).isEqualTo(beforePoint + chargeAmount); + } +} diff --git a/src/test/java/com/ordertogether/team14_be/payment/service/PointUpdateServiceTest.java b/src/test/java/com/ordertogether/team14_be/payment/service/PointUpdateServiceTest.java new file mode 100644 index 00000000..89abc11f --- /dev/null +++ b/src/test/java/com/ordertogether/team14_be/payment/service/PointUpdateServiceTest.java @@ -0,0 +1,62 @@ +package com.ordertogether.team14_be.payment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import com.ordertogether.team14_be.helper.PaymentDatabaseHelper; +import com.ordertogether.team14_be.member.persistence.MemberRepository; +import com.ordertogether.team14_be.payment.persistence.repository.PaymentEventRepository; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles(profiles = "test") +class PointUpdateServiceTest { + + @Autowired PointUpdateService pointUpdateService; + + @Autowired MemberRepository memberRepository; + @Autowired PaymentEventRepository paymentEventRepository; + + @Autowired PaymentDatabaseHelper paymentDatabaseHelper; + + @BeforeEach + void setUp() { + paymentDatabaseHelper.clean(); + + paymentDatabaseHelper.setOrderId("test-order-id"); + paymentDatabaseHelper.saveTestData(); + } + + @Test + @DisplayName("구매 금액만큼 포인트가 증가한다") + void shouldIncreaseSuccessWhenNormallyRequest() { + // given + int beforePoint = + memberRepository + .findById(1L) + .orElseThrow(() -> new NoSuchElementException("Member not found")) + .getPoint(); + Long chargeAmount = + paymentEventRepository + .findByOrderId("test-order-id") + .orElseThrow(() -> new NoSuchElementException("PaymentEvent not found")) + .totalAmount(); + + // when + pointUpdateService.increasePoint("test-order-id"); + + // then + int afterPoint = + memberRepository + .findById(1L) + .orElseThrow(() -> new NoSuchElementException("Member not found")) + .getPoint(); + assertThat(afterPoint).isEqualTo(beforePoint + chargeAmount); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index d780e3f5..edb4685d 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -42,6 +42,18 @@ kakao: api: url: ${KAKAO_USER_API_URL} +pg: + toss: + secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + url: https://api.tosspayments.com + key: jwt: secret-key: ${JWT_SECRET_KEY} + +jwt: + expire-time: 1 + +front: + page: + signup: ${FRONT_PAGE_SIGNUP}