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 0c6fc6b..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,72 +4,73 @@ 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 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; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "subscription") public class Subscription { - @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) { - this.customer = customer; - this.phoneNumber = phoneNumber; - this.startDate = LocalDateTime.now(); - this.status = SubscriptionStatus.ACTIVE; - this.sendDay = 20; - } - - // 서비스 해지 처리 메서드 - public void terminate() { - this.status = SubscriptionStatus.TERMINATED; - this.endDate = LocalDateTime.now(); - } + 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 3e0d5c7..ec8b405 100644 --- a/src/main/java/com/project/core/service/CustomerService.java +++ b/src/main/java/com/project/core/service/CustomerService.java @@ -1,39 +1,48 @@ package com.project.core.service; -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 IllegalArgumentException("사용자를 찾을 수 없습니다")); - } - - @Transactional - public ChangeEmailResponse changeEmailEnc(Long userId, ChangeEmailRequest request) { - Customer customer = customerRepository.findById(userId).orElseThrow(()->new IllegalArgumentException("사용자를 찾을 수 없습니다")); + 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 IllegalArgumentException("사용자를 찾을 수 없습니다")); + @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 785dc43..81426ef 100644 --- a/src/main/java/com/project/core/service/PlanService.java +++ b/src/main/java/com/project/core/service/PlanService.java @@ -10,7 +10,12 @@ 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 java.time.Clock; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,128 +25,117 @@ @Transactional public class PlanService { - private final SubscriptionRepository subscriptionRepository; - private final PlanRepository planRepository; - private final SubscriptionPlanRepository subscriptionPlanRepository; - private final CustomerRepository customerRepository; - private final AESUtil aesUtil; - - /** - * 요금제 가입 (신규 개통) - * 1. 활성 회선이 0개면 -> Customer의 연락처 사용 시도 - * 2. 활성 회선이 있거나 위 번호가 이미 사용 중이면 -> 랜덤 번호 생성 - */ - public void joinSubscription(Long customerId, Long planId) { - // 고객, 요금제 조회 - Customer customer = customerRepository.findById(customerId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 고객입니다.")); - - Plan plan = planRepository.findById(planId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 요금제입니다.")); - - // 전화번호 결정 로직 - String phoneNumberEnc = determinePhoneNumber(customer); - - // 회선 생성 - Subscription newSub = Subscription.builder() - .customer(customer) - .phoneNumber(phoneNumberEnc) - .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; - - // 무한 루프 방지를 위한 최대 시도 횟수 - int retryCount = 0; - - do { - if (retryCount > 10) throw new RuntimeException("사용 가능한 전화번호를 찾을 수 없습니다."); - - randomPhone = PhoneUtil.generateRandomPhoneNumber(); - try { - randomPhoneEnc = aesUtil.encrypt(randomPhone); - } catch (Exception e) { - throw new RuntimeException("암호화 실패", e); - } - - retryCount++; - } while (subscriptionRepository.existsByPhoneNumber(randomPhoneEnc)); // DB에 이미 있는지(해지된 것 포함) 체크 - - return randomPhoneEnc; - } - - /** - * 요금제 변경 (기존 요금제 해지 -> 신규 요금제 가입 - */ - public void changePlan(Long subId, Long newPlanId) { - - // 회선 존재 여부 확인 - Subscription sub = subscriptionRepository.findById(subId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회선입니다.")); - - // 현재 사용 중인 요금제 찾아서 종료 처리 - subscriptionPlanRepository.findActivePlanBySubId(subId) - .ifPresent(SubscriptionPlan::expire); - - // 변경할 새 요금제 정보 조회 - Plan newPlan = planRepository.findById(newPlanId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 요금제입니다.")); - - // 새 요금제 가입 이력 생성 및 저장 - SubscriptionPlan newHistory = SubscriptionPlan.builder() - .subscription(sub) - .plan(newPlan) - .build(); - - subscriptionPlanRepository.save(newHistory); - } - - /** - * 요금제 해지 (회선 정지) - */ - public void terminateSubscription(Long subId) { - Subscription sub = subscriptionRepository.findById(subId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회선입니다.")); - - if (sub.getStatus() == SubscriptionStatus.TERMINATED) { - throw new IllegalStateException("이미 해지된 회선입니다."); - } - - sub.terminate(); - - 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 final int PHONE_NUMBER_GENERATION_ATTEMPT_LIMIT = 11; + + /** + * 요금제 가입 (신규 개통) 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() { + 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); + } + + /** 요금제 변경 (기존 요금제 해지 -> 신규 요금제 가입 */ + 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 fa62f09..8d0e42c 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; - 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 new file mode 100644 index 0000000..de06a1e --- /dev/null +++ b/src/main/java/com/project/global/config/ClockConfig.java @@ -0,0 +1,14 @@ +package com.project.global.config; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } +} 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..3f8a4e9 --- /dev/null +++ b/src/main/java/com/project/global/exception/code/domain/core/CoreErrorCode.java @@ -0,0 +1,31 @@ +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, "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 new file mode 100644 index 0000000..1615462 --- /dev/null +++ b/src/main/java/com/project/global/exception/core/EntityNotFoundException.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 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..5650cf8 --- /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..0ca5d1b --- /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); + } +} 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); + } + } }