From cdf09da35a053a9d57068bef7cfb97929af18f49 Mon Sep 17 00:00:00 2001 From: k0081915 Date: Wed, 14 Jan 2026 13:23:29 +0900 Subject: [PATCH 1/6] =?UTF-8?q?UPLUS-27=20refactor:=20Clock=20Bean=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=EB=B0=9B=EC=95=84=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/entity/subscription/Subscription.java | 9 +++++---- .../com/project/core/service/PlanService.java | 6 +++++- .../com/project/global/config/ClockConfig.java | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/project/global/config/ClockConfig.java diff --git a/src/main/java/com/project/core/infra/entity/subscription/Subscription.java b/src/main/java/com/project/core/infra/entity/subscription/Subscription.java index 0c6fc6b..7fb968b 100644 --- a/src/main/java/com/project/core/infra/entity/subscription/Subscription.java +++ b/src/main/java/com/project/core/infra/entity/subscription/Subscription.java @@ -11,6 +11,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Clock; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -59,17 +60,17 @@ public class Subscription { // 할인 이력 (1:N) @Builder - public Subscription(Customer customer, String phoneNumber) { + public Subscription(Customer customer, String phoneNumber, Clock clock) { this.customer = customer; this.phoneNumber = phoneNumber; - this.startDate = LocalDateTime.now(); + this.startDate = LocalDateTime.now(clock); this.status = SubscriptionStatus.ACTIVE; this.sendDay = 20; } // 서비스 해지 처리 메서드 - public void terminate() { + public void terminate(Clock clock) { this.status = SubscriptionStatus.TERMINATED; - this.endDate = LocalDateTime.now(); + this.endDate = LocalDateTime.now(clock); } } diff --git a/src/main/java/com/project/core/service/PlanService.java b/src/main/java/com/project/core/service/PlanService.java index 785dc43..24c805c 100644 --- a/src/main/java/com/project/core/service/PlanService.java +++ b/src/main/java/com/project/core/service/PlanService.java @@ -15,6 +15,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; + @Service @RequiredArgsConstructor @Transactional @@ -25,6 +27,7 @@ public class PlanService { private final SubscriptionPlanRepository subscriptionPlanRepository; private final CustomerRepository customerRepository; private final AESUtil aesUtil; + private final Clock clock; /** * 요금제 가입 (신규 개통) @@ -46,6 +49,7 @@ public void joinSubscription(Long customerId, Long planId) { Subscription newSub = Subscription.builder() .customer(customer) .phoneNumber(phoneNumberEnc) + .clock(clock) .build(); subscriptionRepository.save(newSub); @@ -139,7 +143,7 @@ public void terminateSubscription(Long subId) { throw new IllegalStateException("이미 해지된 회선입니다."); } - sub.terminate(); + sub.terminate(clock); subscriptionPlanRepository.findActivePlanBySubId(subId) .ifPresent(SubscriptionPlan::expire); diff --git a/src/main/java/com/project/global/config/ClockConfig.java b/src/main/java/com/project/global/config/ClockConfig.java new file mode 100644 index 0000000..004da26 --- /dev/null +++ b/src/main/java/com/project/global/config/ClockConfig.java @@ -0,0 +1,15 @@ +package com.project.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } +} From 0e2298afdfc050419148d76fad678b6f29abda42 Mon Sep 17 00:00:00 2001 From: k0081915 Date: Wed, 14 Jan 2026 13:26:53 +0900 Subject: [PATCH 2/6] =?UTF-8?q?UPLUS-27=20refactor:=20send=20day=20?= =?UTF-8?q?=EC=83=81=EC=88=98=EB=A1=9C=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/core/infra/entity/subscription/Subscription.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/project/core/infra/entity/subscription/Subscription.java b/src/main/java/com/project/core/infra/entity/subscription/Subscription.java index 7fb968b..7e04f19 100644 --- a/src/main/java/com/project/core/infra/entity/subscription/Subscription.java +++ b/src/main/java/com/project/core/infra/entity/subscription/Subscription.java @@ -22,6 +22,8 @@ @Table(name = "subscription") public class Subscription { + private static final int DEFAULT_SEND_DAY = 20; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "sub_id") @@ -65,7 +67,7 @@ public Subscription(Customer customer, String phoneNumber, Clock clock) { this.phoneNumber = phoneNumber; this.startDate = LocalDateTime.now(clock); this.status = SubscriptionStatus.ACTIVE; - this.sendDay = 20; + this.sendDay = DEFAULT_SEND_DAY; } // 서비스 해지 처리 메서드 From 187f57a9ab9cdbd85cbe5c59c2a4a75b3a41f0a0 Mon Sep 17 00:00:00 2001 From: k0081915 Date: Wed, 14 Jan 2026 14:47:17 +0900 Subject: [PATCH 3/6] =?UTF-8?q?UPLUS-27=20feat:=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=98=88=EC=99=B8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/core/service/CustomerService.java | 8 +++--- .../com/project/core/service/PlanService.java | 26 +++++++++++------- .../code/domain/GlobalErrorCode.java | 7 +++-- .../code/domain/core/CoreErrorCode.java | 27 +++++++++++++++++++ .../core/EntityNotFoundException.java | 11 ++++++++ .../exception/core/InvalidStateException.java | 10 +++++++ .../core/OperationFailedException.java | 10 +++++++ 7 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java create mode 100644 src/main/java/com/project/global/exception/core/EntityNotFoundException.java create mode 100644 src/main/java/com/project/global/exception/core/InvalidStateException.java create mode 100644 src/main/java/com/project/global/exception/core/OperationFailedException.java diff --git a/src/main/java/com/project/core/service/CustomerService.java b/src/main/java/com/project/core/service/CustomerService.java index 3e0d5c7..21b5349 100644 --- a/src/main/java/com/project/core/service/CustomerService.java +++ b/src/main/java/com/project/core/service/CustomerService.java @@ -1,5 +1,7 @@ package com.project.core.service; +import com.project.global.exception.code.domain.core.CoreErrorCode; +import com.project.global.exception.core.EntityNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,12 +20,12 @@ public class CustomerService { @Transactional public Customer loadByContactEnc(String contactEnc) { - return customerRepository.findByContactEnc(contactEnc).orElseThrow(()->new IllegalArgumentException("사용자를 찾을 수 없습니다")); + return customerRepository.findByContactEnc(contactEnc).orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.CUSTOMER_NOT_FOUND)); } @Transactional public ChangeEmailResponse changeEmailEnc(Long userId, ChangeEmailRequest request) { - Customer customer = customerRepository.findById(userId).orElseThrow(()->new IllegalArgumentException("사용자를 찾을 수 없습니다")); + Customer customer = customerRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.CUSTOMER_NOT_FOUND)); customer.changeEmailEnc(request.emailEnc()); return new ChangeEmailResponse(customer.getEmailEnc()); @@ -31,7 +33,7 @@ public ChangeEmailResponse changeEmailEnc(Long userId, ChangeEmailRequest reques @Transactional public ChangeGradeResponse changeUserGrade(Long userId, ChangeGradeRequest request) { - Customer customer = customerRepository.findById(userId).orElseThrow(()->new IllegalArgumentException("사용자를 찾을 수 없습니다")); + Customer customer = customerRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.CUSTOMER_NOT_FOUND)); customer.changeGrade(request.grade()); return new ChangeGradeResponse(customer.getGrade()); diff --git a/src/main/java/com/project/core/service/PlanService.java b/src/main/java/com/project/core/service/PlanService.java index 24c805c..c4b2add 100644 --- a/src/main/java/com/project/core/service/PlanService.java +++ b/src/main/java/com/project/core/service/PlanService.java @@ -10,6 +10,10 @@ import com.project.core.infra.repository.plan.SubscriptionPlanRepository; import com.project.core.infra.repository.subscription.SubscriptionRepository; import com.project.core.util.PhoneUtil; +import com.project.global.exception.code.domain.core.CoreErrorCode; +import com.project.global.exception.core.EntityNotFoundException; +import com.project.global.exception.core.InvalidStateException; +import com.project.global.exception.core.OperationFailedException; import com.project.global.util.AESUtil; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -29,6 +33,8 @@ public class PlanService { private final AESUtil aesUtil; private final Clock clock; + private static int MAX_PHONE_NUMBER_GENERATION_ATTEMPTS = 0; + /** * 요금제 가입 (신규 개통) * 1. 활성 회선이 0개면 -> Customer의 연락처 사용 시도 @@ -37,10 +43,10 @@ public class PlanService { public void joinSubscription(Long customerId, Long planId) { // 고객, 요금제 조회 Customer customer = customerRepository.findById(customerId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 고객입니다.")); + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.CUSTOMER_NOT_FOUND)); Plan plan = planRepository.findById(planId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 요금제입니다.")); + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.PLAN_NOT_FOUND)); // 전화번호 결정 로직 String phoneNumberEnc = determinePhoneNumber(customer); @@ -88,19 +94,19 @@ private String generateUniqueRandomPhoneNumberEnc() { String randomPhoneEnc; // 무한 루프 방지를 위한 최대 시도 횟수 - int retryCount = 0; + MAX_PHONE_NUMBER_GENERATION_ATTEMPTS = 0; do { - if (retryCount > 10) throw new RuntimeException("사용 가능한 전화번호를 찾을 수 없습니다."); + if (MAX_PHONE_NUMBER_GENERATION_ATTEMPTS > 10) throw new OperationFailedException(CoreErrorCode.PHONE_NUMBER_GENERATION_FAILED); randomPhone = PhoneUtil.generateRandomPhoneNumber(); try { randomPhoneEnc = aesUtil.encrypt(randomPhone); } catch (Exception e) { - throw new RuntimeException("암호화 실패", e); + throw new OperationFailedException(CoreErrorCode.ENCRYPTION_FAILED); } - retryCount++; + MAX_PHONE_NUMBER_GENERATION_ATTEMPTS++; } while (subscriptionRepository.existsByPhoneNumber(randomPhoneEnc)); // DB에 이미 있는지(해지된 것 포함) 체크 return randomPhoneEnc; @@ -113,7 +119,7 @@ public void changePlan(Long subId, Long newPlanId) { // 회선 존재 여부 확인 Subscription sub = subscriptionRepository.findById(subId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회선입니다.")); + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.SUBSCRIPTION_NOT_FOUND)); // 현재 사용 중인 요금제 찾아서 종료 처리 subscriptionPlanRepository.findActivePlanBySubId(subId) @@ -121,7 +127,7 @@ public void changePlan(Long subId, Long newPlanId) { // 변경할 새 요금제 정보 조회 Plan newPlan = planRepository.findById(newPlanId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 요금제입니다.")); + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.PLAN_NOT_FOUND)); // 새 요금제 가입 이력 생성 및 저장 SubscriptionPlan newHistory = SubscriptionPlan.builder() @@ -137,10 +143,10 @@ public void changePlan(Long subId, Long newPlanId) { */ public void terminateSubscription(Long subId) { Subscription sub = subscriptionRepository.findById(subId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회선입니다.")); + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.SUBSCRIPTION_NOT_FOUND)); if (sub.getStatus() == SubscriptionStatus.TERMINATED) { - throw new IllegalStateException("이미 해지된 회선입니다."); + throw new InvalidStateException(CoreErrorCode.SUBSCRIPTION_ALREADY_TERMINATED); } sub.terminate(clock); diff --git a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java index 66c4dc6..b7f3c3c 100644 --- a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java @@ -7,8 +7,11 @@ @Getter @RequiredArgsConstructor public enum GlobalErrorCode implements BaseErrorCode { - EXAMPLE_NOT_FOUND(HttpStatus.BAD_REQUEST, "EXAMPLE_001", "Example을 찾을 수 없습니다"), - INTERNAL_SERVER_ERROR(HttpStatus.BAD_REQUEST, "EXAMPLE_001", "Example을 찾을 수 없습니다"), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 요청입니다."), + METHOD_ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST, "COMMON_002", "올바르지 않은 요청입니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_003", "지원하지 않은 Http Method 입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_004", "서버 에러가 발생했습니다."), + BLOCKED_API(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_005", "운영 환경에서 사용할 수 없는 API 입니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java b/src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java new file mode 100644 index 0000000..83ff901 --- /dev/null +++ b/src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java @@ -0,0 +1,27 @@ +package com.project.global.exception.code.domain.core; + +import com.project.global.exception.code.domain.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum CoreErrorCode implements BaseErrorCode { + // 고객 + CUSTOMER_NOT_FOUND(HttpStatus.NOT_FOUND, "CUSTOMER_001", "존재하지 않는 고객입니다."), + + // 요금제 + PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_001", "존재하지 않는 요금제입니다.."), + + // 회선/가입 + SUBSCRIPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "SUB_001", "존재하지 않는 회선입니다."), + SUBSCRIPTION_ALREADY_TERMINATED(HttpStatus.BAD_REQUEST, "SUB_002", "이미 해지된 회선입니다."), + PHONE_NUMBER_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "SUB_003", "사용 가능한 전화번호 생성에 실패했습니다. (재시도 횟수 초과)"), + ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "SUB_004", "암호화/복호화 처리 중 오류가 발생했습니다."), + ; + + private final HttpStatus httpStatus; + private final String customCode; + private final String message; +} diff --git a/src/main/java/com/project/global/exception/core/EntityNotFoundException.java b/src/main/java/com/project/global/exception/core/EntityNotFoundException.java new file mode 100644 index 0000000..8713e26 --- /dev/null +++ b/src/main/java/com/project/global/exception/core/EntityNotFoundException.java @@ -0,0 +1,11 @@ +package com.project.global.exception.core; + +import com.project.global.exception.BaseException; +import com.project.global.exception.code.domain.BaseErrorCode; +import com.project.global.exception.code.domain.core.CoreErrorCode; + +public class EntityNotFoundException extends BaseException { + public EntityNotFoundException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/project/global/exception/core/InvalidStateException.java b/src/main/java/com/project/global/exception/core/InvalidStateException.java new file mode 100644 index 0000000..7322fab --- /dev/null +++ b/src/main/java/com/project/global/exception/core/InvalidStateException.java @@ -0,0 +1,10 @@ +package com.project.global.exception.core; + +import com.project.global.exception.BaseException; +import com.project.global.exception.code.domain.BaseErrorCode; + +public class InvalidStateException extends BaseException { + public InvalidStateException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/project/global/exception/core/OperationFailedException.java b/src/main/java/com/project/global/exception/core/OperationFailedException.java new file mode 100644 index 0000000..d8f1a08 --- /dev/null +++ b/src/main/java/com/project/global/exception/core/OperationFailedException.java @@ -0,0 +1,10 @@ +package com.project.global.exception.core; + +import com.project.global.exception.BaseException; +import com.project.global.exception.code.domain.BaseErrorCode; + +public class OperationFailedException extends BaseException { + public OperationFailedException(BaseErrorCode errorCode) { + super(errorCode); + } +} From 02f15441ca0ce8b4337b6de0062f273dca924654 Mon Sep 17 00:00:00 2001 From: k0081915 Date: Wed, 14 Jan 2026 14:53:03 +0900 Subject: [PATCH 4/6] =?UTF-8?q?UPLUS-27=20refactor:=20Random=20->=20Thread?= =?UTF-8?q?LocalRandom=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/project/core/util/PhoneUtil.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/project/core/util/PhoneUtil.java b/src/main/java/com/project/core/util/PhoneUtil.java index fa62f09..4b00356 100644 --- a/src/main/java/com/project/core/util/PhoneUtil.java +++ b/src/main/java/com/project/core/util/PhoneUtil.java @@ -1,14 +1,13 @@ package com.project.core.util; -import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; public class PhoneUtil { - private static final Random random = new Random(); // 010-XXXX-XXXX 형식의 랜덤 번호 생성 public static String generateRandomPhoneNumber() { - int mid = random.nextInt(9000) + 1000; // 1000 ~ 9999 - int last = random.nextInt(9000) + 1000; + int mid = ThreadLocalRandom.current().nextInt(1000, 10000); + int last = ThreadLocalRandom.current().nextInt(1000, 10000); return String.format("010-%d-%d", mid, last); } } From 23aa0482eca4692ecf112fb6e010cedc234c76c8 Mon Sep 17 00:00:00 2001 From: k0081915 Date: Wed, 14 Jan 2026 15:42:48 +0900 Subject: [PATCH 5/6] =?UTF-8?q?UPLUS-27=20feat:=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20Exception=20=EA=B3=B5=ED=86=B5=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?,=20=ED=83=AD=20=EC=82=AC=EC=9D=B4=EC=A6=88=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/controller/CustomerController.java | 15 +- .../core/controller/PlanController.java | 32 +-- .../dto/request/FindCustomerRequest.java | 6 +- .../dto/request/SubscriptionJoinRequest.java | 5 +- .../entity/subscription/Subscription.java | 116 ++++---- .../customer/CustomerRepository.java | 7 +- .../subscription/SubscriptionRepository.java | 8 +- .../project/core/service/CustomerService.java | 61 +++-- .../com/project/core/service/PlanService.java | 252 +++++++++--------- .../java/com/project/core/util/PhoneUtil.java | 12 +- .../project/global/config/ClockConfig.java | 11 +- .../code/domain/core/CoreErrorCode.java | 30 ++- .../core/EntityNotFoundException.java | 7 +- .../exception/core/InvalidStateException.java | 6 +- .../core/OperationFailedException.java | 6 +- .../java/com/project/global/util/AESUtil.java | 153 ++++++----- 16 files changed, 368 insertions(+), 359 deletions(-) diff --git a/src/main/java/com/project/core/controller/CustomerController.java b/src/main/java/com/project/core/controller/CustomerController.java index c94fa9d..6fc41a0 100644 --- a/src/main/java/com/project/core/controller/CustomerController.java +++ b/src/main/java/com/project/core/controller/CustomerController.java @@ -1,15 +1,13 @@ package com.project.core.controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - import com.project.core.controller.dto.request.FindCustomerRequest; import com.project.core.infra.entity.customer.Customer; import com.project.core.service.CustomerService; - import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/user") @@ -19,8 +17,7 @@ public class CustomerController { @GetMapping public Long save(@RequestBody FindCustomerRequest request) { - Customer customer = customerService.loadByContactEnc(request.contactEnc()); - return customer.getCustomerId(); + Customer customer = customerService.loadByContactEnc(request.contactEnc()); + return customer.getCustomerId(); } - } diff --git a/src/main/java/com/project/core/controller/PlanController.java b/src/main/java/com/project/core/controller/PlanController.java index b4bfb20..685b9a8 100644 --- a/src/main/java/com/project/core/controller/PlanController.java +++ b/src/main/java/com/project/core/controller/PlanController.java @@ -12,23 +12,23 @@ @RequestMapping("/plan") public class PlanController { - private final PlanService planService; + private final PlanService planService; - @PostMapping("/join") - public ResponseEntity joinSubscription(@RequestBody SubscriptionJoinRequest request) { - planService.joinSubscription(request.customerId(), request.planId()); - return ResponseEntity.ok().build(); - } + @PostMapping("/join") + public ResponseEntity joinSubscription(@RequestBody SubscriptionJoinRequest request) { + planService.joinSubscription(request.customerId(), request.planId()); + return ResponseEntity.ok().build(); + } - @PostMapping("/change") - public ResponseEntity changePlan(@RequestBody PlanChangeRequest request) { - planService.changePlan(request.subId(), request.planId()); - return ResponseEntity.ok().build(); - } + @PostMapping("/change") + public ResponseEntity changePlan(@RequestBody PlanChangeRequest request) { + planService.changePlan(request.subId(), request.planId()); + return ResponseEntity.ok().build(); + } - @PostMapping("/{subId}/terminate") - public ResponseEntity terminateSubscription(@PathVariable Long subId) { - planService.terminateSubscription(subId); - return ResponseEntity.ok().build(); - } + @PostMapping("/{subId}/terminate") + public ResponseEntity terminateSubscription(@PathVariable Long subId) { + planService.terminateSubscription(subId); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/project/core/controller/dto/request/FindCustomerRequest.java b/src/main/java/com/project/core/controller/dto/request/FindCustomerRequest.java index 9ffcc4e..c95a154 100644 --- a/src/main/java/com/project/core/controller/dto/request/FindCustomerRequest.java +++ b/src/main/java/com/project/core/controller/dto/request/FindCustomerRequest.java @@ -1,7 +1,3 @@ package com.project.core.controller.dto.request; -public record FindCustomerRequest( - String contactEnc -) { -} - +public record FindCustomerRequest(String contactEnc) {} diff --git a/src/main/java/com/project/core/controller/dto/request/SubscriptionJoinRequest.java b/src/main/java/com/project/core/controller/dto/request/SubscriptionJoinRequest.java index 5b244c1..968fe30 100644 --- a/src/main/java/com/project/core/controller/dto/request/SubscriptionJoinRequest.java +++ b/src/main/java/com/project/core/controller/dto/request/SubscriptionJoinRequest.java @@ -1,6 +1,3 @@ package com.project.core.controller.dto.request; -public record SubscriptionJoinRequest( - Long customerId, - Long planId -) {} +public record SubscriptionJoinRequest(Long customerId, Long planId) {} diff --git a/src/main/java/com/project/core/infra/entity/subscription/Subscription.java b/src/main/java/com/project/core/infra/entity/subscription/Subscription.java index 7e04f19..8adb51f 100644 --- a/src/main/java/com/project/core/infra/entity/subscription/Subscription.java +++ b/src/main/java/com/project/core/infra/entity/subscription/Subscription.java @@ -4,17 +4,15 @@ import com.project.core.infra.entity.plan.SubscriptionPlan; import com.project.core.infra.entity.subscription.enums.SubscriptionStatus; import com.project.core.infra.entity.vas.SubscriptionVas; - import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - import java.time.Clock; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -22,57 +20,57 @@ @Table(name = "subscription") public class Subscription { - private static final int DEFAULT_SEND_DAY = 20; - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "sub_id") - private Long subId; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "customer_id", nullable = false) - private Customer customer; - - @Column(name = "phone_number", nullable = false) - private String phoneNumber; - - @Column(name = "start_date", nullable = false) - private LocalDateTime startDate; - - @Column(name = "end_date") - private LocalDateTime endDate; - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false, length = 10) - private SubscriptionStatus status; - - @Column(name = "send_day", nullable = false) - private Integer sendDay; - -//--------------------------------------------------------------------- - - // 요금제 이력 (1:N) - @OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL) - private List planHistory = new ArrayList<>(); - - // 부가서비스 이력 (1:N) - @OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL) - private List vasHistory = new ArrayList<>(); - - // 할인 이력 (1:N) - - @Builder - public Subscription(Customer customer, String phoneNumber, Clock clock) { - this.customer = customer; - this.phoneNumber = phoneNumber; - this.startDate = LocalDateTime.now(clock); - this.status = SubscriptionStatus.ACTIVE; - this.sendDay = DEFAULT_SEND_DAY; - } - - // 서비스 해지 처리 메서드 - public void terminate(Clock clock) { - this.status = SubscriptionStatus.TERMINATED; - this.endDate = LocalDateTime.now(clock); - } + private static final int DEFAULT_SEND_DAY = 20; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "sub_id") + private Long subId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "customer_id", nullable = false) + private Customer customer; + + @Column(name = "phone_number", nullable = false) + private String phoneNumber; + + @Column(name = "start_date", nullable = false) + private LocalDateTime startDate; + + @Column(name = "end_date") + private LocalDateTime endDate; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 10) + private SubscriptionStatus status; + + @Column(name = "send_day", nullable = false) + private Integer sendDay; + + // --------------------------------------------------------------------- + + // 요금제 이력 (1:N) + @OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL) + private List planHistory = new ArrayList<>(); + + // 부가서비스 이력 (1:N) + @OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL) + private List vasHistory = new ArrayList<>(); + + // 할인 이력 (1:N) + + @Builder + public Subscription(Customer customer, String phoneNumber, Clock clock) { + this.customer = customer; + this.phoneNumber = phoneNumber; + this.startDate = LocalDateTime.now(clock); + this.status = SubscriptionStatus.ACTIVE; + this.sendDay = DEFAULT_SEND_DAY; + } + + // 서비스 해지 처리 메서드 + public void terminate(Clock clock) { + this.status = SubscriptionStatus.TERMINATED; + this.endDate = LocalDateTime.now(clock); + } } diff --git a/src/main/java/com/project/core/infra/repository/customer/CustomerRepository.java b/src/main/java/com/project/core/infra/repository/customer/CustomerRepository.java index 45f604c..6bd0929 100644 --- a/src/main/java/com/project/core/infra/repository/customer/CustomerRepository.java +++ b/src/main/java/com/project/core/infra/repository/customer/CustomerRepository.java @@ -1,12 +1,9 @@ package com.project.core.infra.repository.customer; +import com.project.core.infra.entity.customer.Customer; import java.util.Optional; - import org.springframework.data.jpa.repository.JpaRepository; -import com.project.core.infra.entity.customer.Customer; - - public interface CustomerRepository extends JpaRepository { - Optional findByContactEnc(String contactEnc); + Optional findByContactEnc(String contactEnc); } diff --git a/src/main/java/com/project/core/infra/repository/subscription/SubscriptionRepository.java b/src/main/java/com/project/core/infra/repository/subscription/SubscriptionRepository.java index f85dcb9..383b0e0 100644 --- a/src/main/java/com/project/core/infra/repository/subscription/SubscriptionRepository.java +++ b/src/main/java/com/project/core/infra/repository/subscription/SubscriptionRepository.java @@ -6,7 +6,9 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface SubscriptionRepository extends JpaRepository { - long countByCustomerAndStatus(Customer customer, SubscriptionStatus status); - boolean existsByPhoneNumberAndStatus(String phoneNumber, SubscriptionStatus status); - boolean existsByPhoneNumber(String phoneNumber); + long countByCustomerAndStatus(Customer customer, SubscriptionStatus status); + + boolean existsByPhoneNumberAndStatus(String phoneNumber, SubscriptionStatus status); + + boolean existsByPhoneNumber(String phoneNumber); } diff --git a/src/main/java/com/project/core/service/CustomerService.java b/src/main/java/com/project/core/service/CustomerService.java index 21b5349..ec8b405 100644 --- a/src/main/java/com/project/core/service/CustomerService.java +++ b/src/main/java/com/project/core/service/CustomerService.java @@ -1,41 +1,48 @@ package com.project.core.service; -import com.project.global.exception.code.domain.core.CoreErrorCode; -import com.project.global.exception.core.EntityNotFoundException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import com.project.core.controller.dto.request.ChangeEmailRequest; import com.project.core.controller.dto.request.ChangeGradeRequest; import com.project.core.controller.dto.response.ChangeEmailResponse; import com.project.core.controller.dto.response.ChangeGradeResponse; import com.project.core.infra.entity.customer.Customer; import com.project.core.infra.repository.customer.CustomerRepository; - +import com.project.global.exception.code.domain.core.CoreErrorCode; +import com.project.global.exception.core.EntityNotFoundException; import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -@Service @RequiredArgsConstructor +@Service +@RequiredArgsConstructor public class CustomerService { - private final CustomerRepository customerRepository; - - @Transactional - public Customer loadByContactEnc(String contactEnc) { - return customerRepository.findByContactEnc(contactEnc).orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.CUSTOMER_NOT_FOUND)); - } - - @Transactional - public ChangeEmailResponse changeEmailEnc(Long userId, ChangeEmailRequest request) { - Customer customer = customerRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.CUSTOMER_NOT_FOUND)); + private final CustomerRepository customerRepository; + + @Transactional + public Customer loadByContactEnc(String contactEnc) { + return customerRepository + .findByContactEnc(contactEnc) + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.CUSTOMER_NOT_FOUND)); + } + + @Transactional + public ChangeEmailResponse changeEmailEnc(Long userId, ChangeEmailRequest request) { + Customer customer = + customerRepository + .findById(userId) + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.CUSTOMER_NOT_FOUND)); + + customer.changeEmailEnc(request.emailEnc()); + return new ChangeEmailResponse(customer.getEmailEnc()); + } - customer.changeEmailEnc(request.emailEnc()); - return new ChangeEmailResponse(customer.getEmailEnc()); - } - - @Transactional - public ChangeGradeResponse changeUserGrade(Long userId, ChangeGradeRequest request) { - Customer customer = customerRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.CUSTOMER_NOT_FOUND)); + @Transactional + public ChangeGradeResponse changeUserGrade(Long userId, ChangeGradeRequest request) { + Customer customer = + customerRepository + .findById(userId) + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.CUSTOMER_NOT_FOUND)); - customer.changeGrade(request.grade()); - return new ChangeGradeResponse(customer.getGrade()); - } + customer.changeGrade(request.grade()); + return new ChangeGradeResponse(customer.getGrade()); + } } diff --git a/src/main/java/com/project/core/service/PlanService.java b/src/main/java/com/project/core/service/PlanService.java index c4b2add..d1f49d9 100644 --- a/src/main/java/com/project/core/service/PlanService.java +++ b/src/main/java/com/project/core/service/PlanService.java @@ -15,143 +15,135 @@ import com.project.global.exception.core.InvalidStateException; import com.project.global.exception.core.OperationFailedException; import com.project.global.util.AESUtil; +import java.time.Clock; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.Clock; - @Service @RequiredArgsConstructor @Transactional public class PlanService { - private final SubscriptionRepository subscriptionRepository; - private final PlanRepository planRepository; - private final SubscriptionPlanRepository subscriptionPlanRepository; - private final CustomerRepository customerRepository; - private final AESUtil aesUtil; - private final Clock clock; - - private static int MAX_PHONE_NUMBER_GENERATION_ATTEMPTS = 0; - - /** - * 요금제 가입 (신규 개통) - * 1. 활성 회선이 0개면 -> Customer의 연락처 사용 시도 - * 2. 활성 회선이 있거나 위 번호가 이미 사용 중이면 -> 랜덤 번호 생성 - */ - public void joinSubscription(Long customerId, Long planId) { - // 고객, 요금제 조회 - Customer customer = customerRepository.findById(customerId) - .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.CUSTOMER_NOT_FOUND)); - - Plan plan = planRepository.findById(planId) - .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.PLAN_NOT_FOUND)); - - // 전화번호 결정 로직 - String phoneNumberEnc = determinePhoneNumber(customer); - - // 회선 생성 - Subscription newSub = Subscription.builder() - .customer(customer) - .phoneNumber(phoneNumberEnc) - .clock(clock) - .build(); - subscriptionRepository.save(newSub); - - // 초기 요금제 이력 생성 - SubscriptionPlan initPlan = SubscriptionPlan.builder() - .subscription(newSub) - .plan(plan) - .build(); - subscriptionPlanRepository.save(initPlan); - } - - // 번호 결정 메소드 - private String determinePhoneNumber(Customer customer) { - // 고객의 현재 활성 회선 수 확인 (ACTIVE인 것만) - long activeCount = subscriptionRepository.countByCustomerAndStatus(customer, SubscriptionStatus.ACTIVE); - - // 활성 회선이 0개라면? -> 쓰던 번호(Customer 연락처) 복구 시도 - if (activeCount == 0) { - String customerContactEnc = customer.getContactEnc(); - - // 중복 체크 - boolean isUsed = subscriptionRepository.existsByPhoneNumberAndStatus(customerContactEnc, SubscriptionStatus.ACTIVE); - - if (!isUsed) { - return customerContactEnc; - } - } - - // 회선이 이미 있거나, 내 번호를 못 쓰는 상황 -> 새 랜덤 번호 채번 - return generateUniqueRandomPhoneNumberEnc(); - } - - // 중복 없는 랜덤 번호 생성 (암호화된 값 반환) - private String generateUniqueRandomPhoneNumberEnc() { - String randomPhone; - String randomPhoneEnc; - - // 무한 루프 방지를 위한 최대 시도 횟수 - MAX_PHONE_NUMBER_GENERATION_ATTEMPTS = 0; - - do { - if (MAX_PHONE_NUMBER_GENERATION_ATTEMPTS > 10) throw new OperationFailedException(CoreErrorCode.PHONE_NUMBER_GENERATION_FAILED); - - randomPhone = PhoneUtil.generateRandomPhoneNumber(); - try { - randomPhoneEnc = aesUtil.encrypt(randomPhone); - } catch (Exception e) { - throw new OperationFailedException(CoreErrorCode.ENCRYPTION_FAILED); - } - - MAX_PHONE_NUMBER_GENERATION_ATTEMPTS++; - } while (subscriptionRepository.existsByPhoneNumber(randomPhoneEnc)); // DB에 이미 있는지(해지된 것 포함) 체크 - - return randomPhoneEnc; - } - - /** - * 요금제 변경 (기존 요금제 해지 -> 신규 요금제 가입 - */ - public void changePlan(Long subId, Long newPlanId) { - - // 회선 존재 여부 확인 - Subscription sub = subscriptionRepository.findById(subId) - .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.SUBSCRIPTION_NOT_FOUND)); - - // 현재 사용 중인 요금제 찾아서 종료 처리 - subscriptionPlanRepository.findActivePlanBySubId(subId) - .ifPresent(SubscriptionPlan::expire); - - // 변경할 새 요금제 정보 조회 - Plan newPlan = planRepository.findById(newPlanId) - .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.PLAN_NOT_FOUND)); - - // 새 요금제 가입 이력 생성 및 저장 - SubscriptionPlan newHistory = SubscriptionPlan.builder() - .subscription(sub) - .plan(newPlan) - .build(); - - subscriptionPlanRepository.save(newHistory); - } - - /** - * 요금제 해지 (회선 정지) - */ - public void terminateSubscription(Long subId) { - Subscription sub = subscriptionRepository.findById(subId) - .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.SUBSCRIPTION_NOT_FOUND)); - - if (sub.getStatus() == SubscriptionStatus.TERMINATED) { - throw new InvalidStateException(CoreErrorCode.SUBSCRIPTION_ALREADY_TERMINATED); - } - - sub.terminate(clock); - - subscriptionPlanRepository.findActivePlanBySubId(subId) - .ifPresent(SubscriptionPlan::expire); - } + private final SubscriptionRepository subscriptionRepository; + private final PlanRepository planRepository; + private final SubscriptionPlanRepository subscriptionPlanRepository; + private final CustomerRepository customerRepository; + private final AESUtil aesUtil; + private final Clock clock; + + private static int MAX_PHONE_NUMBER_GENERATION_ATTEMPTS = 0; + + /** + * 요금제 가입 (신규 개통) 1. 활성 회선이 0개면 -> Customer의 연락처 사용 시도 2. 활성 회선이 있거나 위 번호가 이미 사용 중이면 -> 랜덤 번호 생성 + */ + public void joinSubscription(Long customerId, Long planId) { + // 고객, 요금제 조회 + Customer customer = + customerRepository + .findById(customerId) + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.CUSTOMER_NOT_FOUND)); + + Plan plan = + planRepository + .findById(planId) + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.PLAN_NOT_FOUND)); + + // 전화번호 결정 로직 + String phoneNumberEnc = determinePhoneNumber(customer); + + // 회선 생성 + Subscription newSub = + Subscription.builder().customer(customer).phoneNumber(phoneNumberEnc).clock(clock).build(); + subscriptionRepository.save(newSub); + + // 초기 요금제 이력 생성 + SubscriptionPlan initPlan = SubscriptionPlan.builder().subscription(newSub).plan(plan).build(); + subscriptionPlanRepository.save(initPlan); + } + + // 번호 결정 메소드 + private String determinePhoneNumber(Customer customer) { + // 고객의 현재 활성 회선 수 확인 (ACTIVE인 것만) + long activeCount = + subscriptionRepository.countByCustomerAndStatus(customer, SubscriptionStatus.ACTIVE); + + // 활성 회선이 0개라면? -> 쓰던 번호(Customer 연락처) 복구 시도 + if (activeCount == 0) { + String customerContactEnc = customer.getContactEnc(); + + // 중복 체크 + boolean isUsed = + subscriptionRepository.existsByPhoneNumberAndStatus( + customerContactEnc, SubscriptionStatus.ACTIVE); + + if (!isUsed) { + return customerContactEnc; + } + } + + // 회선이 이미 있거나, 내 번호를 못 쓰는 상황 -> 새 랜덤 번호 채번 + return generateUniqueRandomPhoneNumberEnc(); + } + + // 중복 없는 랜덤 번호 생성 (암호화된 값 반환) + private String generateUniqueRandomPhoneNumberEnc() { + String randomPhone; + String randomPhoneEnc; + + // 무한 루프 방지를 위한 최대 시도 횟수 + MAX_PHONE_NUMBER_GENERATION_ATTEMPTS = 0; + + do { + if (MAX_PHONE_NUMBER_GENERATION_ATTEMPTS > 10) + throw new OperationFailedException(CoreErrorCode.PHONE_NUMBER_GENERATION_FAILED); + + randomPhone = PhoneUtil.generateRandomPhoneNumber(); + randomPhoneEnc = aesUtil.encrypt(randomPhone); + MAX_PHONE_NUMBER_GENERATION_ATTEMPTS++; + } while (subscriptionRepository.existsByPhoneNumber(randomPhoneEnc)); // DB에 이미 있는지(해지된 것 포함) 체크 + + return randomPhoneEnc; + } + + /** 요금제 변경 (기존 요금제 해지 -> 신규 요금제 가입 */ + public void changePlan(Long subId, Long newPlanId) { + + // 회선 존재 여부 확인 + Subscription sub = + subscriptionRepository + .findById(subId) + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.SUBSCRIPTION_NOT_FOUND)); + + // 현재 사용 중인 요금제 찾아서 종료 처리 + subscriptionPlanRepository.findActivePlanBySubId(subId).ifPresent(SubscriptionPlan::expire); + + // 변경할 새 요금제 정보 조회 + Plan newPlan = + planRepository + .findById(newPlanId) + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.PLAN_NOT_FOUND)); + + // 새 요금제 가입 이력 생성 및 저장 + SubscriptionPlan newHistory = + SubscriptionPlan.builder().subscription(sub).plan(newPlan).build(); + + subscriptionPlanRepository.save(newHistory); + } + + /** 요금제 해지 (회선 정지) */ + public void terminateSubscription(Long subId) { + Subscription sub = + subscriptionRepository + .findById(subId) + .orElseThrow(() -> new EntityNotFoundException(CoreErrorCode.SUBSCRIPTION_NOT_FOUND)); + + if (sub.getStatus() == SubscriptionStatus.TERMINATED) { + throw new InvalidStateException(CoreErrorCode.SUBSCRIPTION_ALREADY_TERMINATED); + } + + sub.terminate(clock); + + subscriptionPlanRepository.findActivePlanBySubId(subId).ifPresent(SubscriptionPlan::expire); + } } diff --git a/src/main/java/com/project/core/util/PhoneUtil.java b/src/main/java/com/project/core/util/PhoneUtil.java index 4b00356..8d0e42c 100644 --- a/src/main/java/com/project/core/util/PhoneUtil.java +++ b/src/main/java/com/project/core/util/PhoneUtil.java @@ -4,10 +4,10 @@ public class PhoneUtil { - // 010-XXXX-XXXX 형식의 랜덤 번호 생성 - public static String generateRandomPhoneNumber() { - int mid = ThreadLocalRandom.current().nextInt(1000, 10000); - int last = ThreadLocalRandom.current().nextInt(1000, 10000); - return String.format("010-%d-%d", mid, last); - } + // 010-XXXX-XXXX 형식의 랜덤 번호 생성 + public static String generateRandomPhoneNumber() { + int mid = ThreadLocalRandom.current().nextInt(1000, 10000); + int last = ThreadLocalRandom.current().nextInt(1000, 10000); + return String.format("010-%d-%d", mid, last); + } } diff --git a/src/main/java/com/project/global/config/ClockConfig.java b/src/main/java/com/project/global/config/ClockConfig.java index 004da26..de06a1e 100644 --- a/src/main/java/com/project/global/config/ClockConfig.java +++ b/src/main/java/com/project/global/config/ClockConfig.java @@ -1,15 +1,14 @@ package com.project.global.config; +import java.time.Clock; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import java.time.Clock; - @Configuration public class ClockConfig { - @Bean - public Clock clock() { - return Clock.systemDefaultZone(); - } + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } } diff --git a/src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java b/src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java index 83ff901..f24b5bb 100644 --- a/src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java @@ -8,20 +8,24 @@ @Getter @RequiredArgsConstructor public enum CoreErrorCode implements BaseErrorCode { - // 고객 - CUSTOMER_NOT_FOUND(HttpStatus.NOT_FOUND, "CUSTOMER_001", "존재하지 않는 고객입니다."), + // 고객 + CUSTOMER_NOT_FOUND(HttpStatus.NOT_FOUND, "CUSTOMER_001", "존재하지 않는 고객입니다."), - // 요금제 - PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_001", "존재하지 않는 요금제입니다.."), + // 요금제 + PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_001", "존재하지 않는 요금제입니다.."), - // 회선/가입 - SUBSCRIPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "SUB_001", "존재하지 않는 회선입니다."), - SUBSCRIPTION_ALREADY_TERMINATED(HttpStatus.BAD_REQUEST, "SUB_002", "이미 해지된 회선입니다."), - PHONE_NUMBER_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "SUB_003", "사용 가능한 전화번호 생성에 실패했습니다. (재시도 횟수 초과)"), - ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "SUB_004", "암호화/복호화 처리 중 오류가 발생했습니다."), - ; + // 회선/가입 + SUBSCRIPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "SUB_001", "존재하지 않는 회선입니다."), + SUBSCRIPTION_ALREADY_TERMINATED(HttpStatus.BAD_REQUEST, "SUB_002", "이미 해지된 회선입니다."), + PHONE_NUMBER_GENERATION_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "SUB_003", "사용 가능한 전화번호 생성에 실패했습니다. (재시도 횟수 초과)"), - private final HttpStatus httpStatus; - private final String customCode; - private final String message; + // 보안 + ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "SEC_001", "암호화/복호화 처리 중 오류가 발생했습니다."), + AES_KEY_LENGTH_INCORRECT(HttpStatus.INTERNAL_SERVER_ERROR, "SEC_002", "AES Secret Key는 32바이트여야 합니다."), + ; + + private final HttpStatus httpStatus; + private final String customCode; + private final String message; } diff --git a/src/main/java/com/project/global/exception/core/EntityNotFoundException.java b/src/main/java/com/project/global/exception/core/EntityNotFoundException.java index 8713e26..1615462 100644 --- a/src/main/java/com/project/global/exception/core/EntityNotFoundException.java +++ b/src/main/java/com/project/global/exception/core/EntityNotFoundException.java @@ -2,10 +2,9 @@ import com.project.global.exception.BaseException; import com.project.global.exception.code.domain.BaseErrorCode; -import com.project.global.exception.code.domain.core.CoreErrorCode; public class EntityNotFoundException extends BaseException { - public EntityNotFoundException(BaseErrorCode errorCode) { - super(errorCode); - } + public EntityNotFoundException(BaseErrorCode errorCode) { + super(errorCode); + } } diff --git a/src/main/java/com/project/global/exception/core/InvalidStateException.java b/src/main/java/com/project/global/exception/core/InvalidStateException.java index 7322fab..5650cf8 100644 --- a/src/main/java/com/project/global/exception/core/InvalidStateException.java +++ b/src/main/java/com/project/global/exception/core/InvalidStateException.java @@ -4,7 +4,7 @@ import com.project.global.exception.code.domain.BaseErrorCode; public class InvalidStateException extends BaseException { - public InvalidStateException(BaseErrorCode errorCode) { - super(errorCode); - } + public InvalidStateException(BaseErrorCode errorCode) { + super(errorCode); + } } diff --git a/src/main/java/com/project/global/exception/core/OperationFailedException.java b/src/main/java/com/project/global/exception/core/OperationFailedException.java index d8f1a08..0ca5d1b 100644 --- a/src/main/java/com/project/global/exception/core/OperationFailedException.java +++ b/src/main/java/com/project/global/exception/core/OperationFailedException.java @@ -4,7 +4,7 @@ import com.project.global.exception.code.domain.BaseErrorCode; public class OperationFailedException extends BaseException { - public OperationFailedException(BaseErrorCode errorCode) { - super(errorCode); - } + public OperationFailedException(BaseErrorCode errorCode) { + super(errorCode); + } } diff --git a/src/main/java/com/project/global/util/AESUtil.java b/src/main/java/com/project/global/util/AESUtil.java index 5830111..20c8bab 100644 --- a/src/main/java/com/project/global/util/AESUtil.java +++ b/src/main/java/com/project/global/util/AESUtil.java @@ -1,76 +1,97 @@ package com.project.global.util; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.crypto.Cipher; -import javax.crypto.spec.IvParameterSpec; -import javax.crypto.spec.SecretKeySpec; +import com.project.global.exception.code.domain.core.CoreErrorCode; +import com.project.global.exception.core.InvalidStateException; +import com.project.global.exception.core.OperationFailedException; +import jakarta.annotation.PostConstruct; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; import java.security.SecureRandom; import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; @Component public class AESUtil { - @Value("${ureca.secret-key}") - private String secretKey; - - private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; - - - // 암호화 (Encrypt) - public String encrypt(String plainText) throws Exception { - if(plainText == null) return null; - - // 1. 랜덤 IV(Initialization Vector) 생성 (16바이트) - byte[] iv = new byte[16]; - new SecureRandom().nextBytes(iv); - IvParameterSpec ivSpec = new IvParameterSpec(iv); - - // 2. 키 생성 - SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES"); - - // 3. 암호화 초기화 - Cipher cipher = Cipher.getInstance(ALGORITHM); - cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); - - // 4. 암호화 수행 - byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); - - // 5. 결과 합치기 (IV + EncryptedBytes) - // 복호화할 때 IV가 필요하므로 앞에 붙여서 저장함 - byte[] combined = new byte[iv.length + encrypted.length]; - System.arraycopy(iv, 0, combined, 0, iv.length); - System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); - - // 6. Base64 인코딩하여 반환 - return Base64.getEncoder().encodeToString(combined); - } - - // 복호화 (Decrypt) - public String decrypt(String cipherText) throws Exception { - if (cipherText == null) return null; - - // 1. Base64 디코딩 - byte[] decoded = Base64.getDecoder().decode(cipherText); - - // 2. IV와 암호문 분리 - byte[] iv = new byte[16]; - System.arraycopy(decoded, 0, iv, 0, 16); - IvParameterSpec ivSpec = new IvParameterSpec(iv); - - byte[] encrypted = new byte[decoded.length - 16]; - System.arraycopy(decoded, 16, encrypted, 0, encrypted.length); - - // 3. 키 생성 - SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES"); - - // 4. 복호화 초기화 - Cipher cipher = Cipher.getInstance(ALGORITHM); - cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); - - // 5. 복호화 수행 및 문자열 반환 - return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); - } + @Value("${ureca.secret-key}") + private String secretKey; + + private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + @PostConstruct + public void init() { + // AES-256은 32바이트(256비트) 키가 필수입니다. + byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length != 32) { + throw new InvalidStateException(CoreErrorCode.AES_KEY_LENGTH_INCORRECT); + } + } + + // 암호화 (Encrypt) + public String encrypt(String plainText) { + if (plainText == null) return null; + + try { + // 1. 랜덤 IV(Initialization Vector) 생성 (16바이트) + byte[] iv = new byte[16]; + SECURE_RANDOM.nextBytes(iv); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + + // 2. 키 생성 + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES"); + + // 3. 암호화 초기화 + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); + + // 4. 암호화 수행 + byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + + // 5. 결과 합치기 (IV + EncryptedBytes) + // 복호화할 때 IV가 필요하므로 앞에 붙여서 저장함 + byte[] combined = new byte[iv.length + encrypted.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); + + // 6. Base64 인코딩하여 반환 + return Base64.getEncoder().encodeToString(combined); + } catch (GeneralSecurityException e) { + throw new OperationFailedException(CoreErrorCode.ENCRYPTION_FAILED); + } + } + + // 복호화 (Decrypt) + public String decrypt(String cipherText) { + if (cipherText == null) return null; + + try { + // 1. Base64 디코딩 + byte[] decoded = Base64.getDecoder().decode(cipherText); + + // 2. IV와 암호문 분리 + byte[] iv = new byte[16]; + System.arraycopy(decoded, 0, iv, 0, 16); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + + byte[] encrypted = new byte[decoded.length - 16]; + System.arraycopy(decoded, 16, encrypted, 0, encrypted.length); + + // 3. 키 생성 + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES"); + + // 4. 복호화 초기화 + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); + + // 5. 복호화 수행 및 문자열 반환 + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } catch (GeneralSecurityException | IllegalArgumentException e) { + throw new OperationFailedException(CoreErrorCode.ENCRYPTION_FAILED); + } + } } From 24bfdd57e493f6f3cfee25a1128ec8096741ae9f Mon Sep 17 00:00:00 2001 From: k0081915 Date: Wed, 14 Jan 2026 15:53:35 +0900 Subject: [PATCH 6/6] =?UTF-8?q?UPLUS-27=20refactor:=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=ED=9A=9F=EC=88=98=20=EC=A0=9C=ED=95=9C=20=EC=83=81?= =?UTF-8?q?=EC=88=98=20=EC=84=A0=EC=96=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project/core/service/PlanService.java | 26 +++++++------------ .../code/domain/core/CoreErrorCode.java | 2 +- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/project/core/service/PlanService.java b/src/main/java/com/project/core/service/PlanService.java index d1f49d9..81426ef 100644 --- a/src/main/java/com/project/core/service/PlanService.java +++ b/src/main/java/com/project/core/service/PlanService.java @@ -32,7 +32,7 @@ public class PlanService { private final AESUtil aesUtil; private final Clock clock; - private static int MAX_PHONE_NUMBER_GENERATION_ATTEMPTS = 0; + private static final int PHONE_NUMBER_GENERATION_ATTEMPT_LIMIT = 11; /** * 요금제 가입 (신규 개통) 1. 활성 회선이 0개면 -> Customer의 연락처 사용 시도 2. 활성 회선이 있거나 위 번호가 이미 사용 중이면 -> 랜덤 번호 생성 @@ -88,22 +88,14 @@ private String determinePhoneNumber(Customer customer) { // 중복 없는 랜덤 번호 생성 (암호화된 값 반환) private String generateUniqueRandomPhoneNumberEnc() { - String randomPhone; - String randomPhoneEnc; - - // 무한 루프 방지를 위한 최대 시도 횟수 - MAX_PHONE_NUMBER_GENERATION_ATTEMPTS = 0; - - do { - if (MAX_PHONE_NUMBER_GENERATION_ATTEMPTS > 10) - throw new OperationFailedException(CoreErrorCode.PHONE_NUMBER_GENERATION_FAILED); - - randomPhone = PhoneUtil.generateRandomPhoneNumber(); - randomPhoneEnc = aesUtil.encrypt(randomPhone); - MAX_PHONE_NUMBER_GENERATION_ATTEMPTS++; - } while (subscriptionRepository.existsByPhoneNumber(randomPhoneEnc)); // DB에 이미 있는지(해지된 것 포함) 체크 - - return randomPhoneEnc; + for (int i = 0; i < PHONE_NUMBER_GENERATION_ATTEMPT_LIMIT; i++) { + String randomPhone = PhoneUtil.generateRandomPhoneNumber(); + String randomPhoneEnc = aesUtil.encrypt(randomPhone); + if (!subscriptionRepository.existsByPhoneNumber(randomPhoneEnc)) { + return randomPhoneEnc; + } + } + throw new OperationFailedException(CoreErrorCode.PHONE_NUMBER_GENERATION_FAILED); } /** 요금제 변경 (기존 요금제 해지 -> 신규 요금제 가입 */ diff --git a/src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java b/src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java index f24b5bb..3f8a4e9 100644 --- a/src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java @@ -12,7 +12,7 @@ public enum CoreErrorCode implements BaseErrorCode { CUSTOMER_NOT_FOUND(HttpStatus.NOT_FOUND, "CUSTOMER_001", "존재하지 않는 고객입니다."), // 요금제 - PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_001", "존재하지 않는 요금제입니다.."), + PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_001", "존재하지 않는 요금제입니다."), // 회선/가입 SUBSCRIPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "SUB_001", "존재하지 않는 회선입니다."),