From 2599d57a37b970197bbd3cc68478384088f7d78d Mon Sep 17 00:00:00 2001 From: hiondal Date: Wed, 10 Sep 2025 19:25:13 +0900 Subject: [PATCH] =?UTF-8?q?=ED=9A=8C=EC=84=A0=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=8B=A4?= =?UTF-8?q?=EC=96=91=ED=95=9C=20API=20=EA=B8=B0=EB=8A=A5=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - user-service: 회원등록 API를 upsert 방식으로 변경 (기존 사용자 업데이트 지원) - user-service: userName 필드 응답 누락 문제 해결 (DB 데이터 업데이트) - kos-mock: Mock 데이터 생성 기간을 3개월에서 6개월로 확장 - product-service: 회선번호 대시 처리 지원 (010-1234-5678, 01012345678 모두 허용) - bill-service: 회선번호 대시 선택적 처리 지원 (유연한 입력 형식) - api-gateway: CORS 중복 헤더 제거 필터 추가 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 7 + .../gateway/config/GatewayConfig.java | 58 +----- .../filter/CorsDedupeGlobalFilter.java | 83 +++++++++ .../src/main/resources/application.yml | 47 +++-- .../phonebill/bill/config/SecurityConfig.java | 2 +- .../bill/controller/BillController.java | 18 +- .../bill/dto/BillInquiryRequest.java | 30 +++- .../controller/MockDataController.java | 2 +- .../service/MockDataCreateService.java | 8 +- .../product/config/SecurityConfig.java | 2 +- .../product/controller/ProductController.java | 34 +++- .../product/dto/ProductChangeRequest.java | 20 ++- .../dto/ProductChangeValidationRequest.java | 20 ++- .../phonebill/user/config/SecurityConfig.java | 2 +- .../user/controller/UserController.java | 54 ------ .../user/exception/UserNotFoundException.java | 7 - .../phonebill/user/service/UserService.java | 167 ++++++++++-------- 17 files changed, 323 insertions(+), 238 deletions(-) create mode 100644 api-gateway/src/main/java/com/unicorn/phonebill/gateway/filter/CorsDedupeGlobalFilter.java diff --git a/CLAUDE.md b/CLAUDE.md index 2667297..df3e513 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -524,6 +524,13 @@ QA Engineer - **DB 연결정보**: 각 서비스별 별도 DB 사용 (auth, bill_inquiry, product_change) - **Redis 공유**: 모든 서비스가 동일한 Redis 인스턴스 사용 +## CORS 중복 헤더 방지 +- **문제**: API Gateway + 백엔드 서비스에서 동시에 CORS 헤더 추가 시 브라우저 에러 발생 +- **원인**: 동일한 CORS 헤더(Access-Control-Allow-Origin 등)가 중복되면 브라우저가 거부 +- **해결책**: GlobalFilter + ServerHttpResponseDecorator 사용하여 백엔드 CORS 헤더 제거 +- **구현**: `new HttpHeaders()` → `originalHeaders.forEach()` → CORS 헤더만 제외하고 복사 +- **주의사항**: ReadOnlyHttpHeaders 직접 수정 불가, ResponseDecorator로 감싸서 처리 필요 + ## 쿠버네티스 DB 접근 방법 - **패스워드 확인**: `kubectl get secret -n {namespace} {secret-name} -o jsonpath='{.data.postgres-password}' | base64 -d` - **환경변수 확인**: `kubectl exec -n {namespace} {pod-name} -c postgresql -- env | grep POSTGRES` diff --git a/api-gateway/src/main/java/com/unicorn/phonebill/gateway/config/GatewayConfig.java b/api-gateway/src/main/java/com/unicorn/phonebill/gateway/config/GatewayConfig.java index f71d91f..ec137ab 100644 --- a/api-gateway/src/main/java/com/unicorn/phonebill/gateway/config/GatewayConfig.java +++ b/api-gateway/src/main/java/com/unicorn/phonebill/gateway/config/GatewayConfig.java @@ -6,11 +6,6 @@ import org.springframework.cloud.gateway.route.RouteLocator; import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.reactive.CorsWebFilter; -import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; - -import java.util.Arrays; /** * Spring Cloud Gateway 라우팅 및 CORS 설정 @@ -32,8 +27,6 @@ public class GatewayConfig { private final JwtAuthenticationGatewayFilterFactory jwtAuthFilter; - @Value("${cors.allowed-origins}") - private String allowedOrigins; @Value("${services.user-service.url}") private String userServiceUrl; @@ -123,7 +116,7 @@ public class GatewayConfig { .setBackoff(java.time.Duration.ofSeconds(1), java.time.Duration.ofSeconds(5), 2, true)) ) .uri(kosMockUrl)) - + // 주의: Gateway 자체 엔드포인트는 라우팅하지 않음 // Health Check와 Swagger UI는 Spring Boot에서 직접 제공 @@ -131,51 +124,8 @@ public class GatewayConfig { } /** - * CORS 설정 - * - * 프론트엔드에서 API Gateway로의 크로스 오리진 요청을 허용합니다. - * 개발/운영 환경에 따라 허용 오리진을 다르게 설정합니다. - * - * @return CorsWebFilter + * CORS 설정은 application.yml의 globalcors에서 관리 + * add-to-simple-url-handler-mapping: true 설정으로 + * 라우트 predicate와 매치되지 않는 OPTIONS 요청도 처리 */ - @Bean - public CorsWebFilter corsWebFilter() { - CorsConfiguration corsConfig = new CorsConfiguration(); - - // 환경변수에서 허용할 Origin 패턴 설정 - String[] origins = allowedOrigins.split(","); - corsConfig.setAllowedOriginPatterns(Arrays.asList(origins)); - - // 허용할 HTTP 메서드 - corsConfig.setAllowedMethods(Arrays.asList( - "GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD" - )); - - // 허용할 헤더 - corsConfig.setAllowedHeaders(Arrays.asList( - "Authorization", - "Content-Type", - "X-Requested-With", - "X-Request-ID", - "X-User-Agent" - )); - - // 노출할 헤더 (클라이언트가 접근 가능한 헤더) - corsConfig.setExposedHeaders(Arrays.asList( - "X-Request-ID", - "X-Response-Time", - "X-Rate-Limit-Remaining" - )); - - // 자격 증명 허용 (쿠키, Authorization 헤더 등) - corsConfig.setAllowCredentials(true); - - // Preflight 요청 캐시 시간 (초) - corsConfig.setMaxAge(3600L); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", corsConfig); - - return new CorsWebFilter(source); - } } \ No newline at end of file diff --git a/api-gateway/src/main/java/com/unicorn/phonebill/gateway/filter/CorsDedupeGlobalFilter.java b/api-gateway/src/main/java/com/unicorn/phonebill/gateway/filter/CorsDedupeGlobalFilter.java new file mode 100644 index 0000000..ea17cc6 --- /dev/null +++ b/api-gateway/src/main/java/com/unicorn/phonebill/gateway/filter/CorsDedupeGlobalFilter.java @@ -0,0 +1,83 @@ +package com.unicorn.phonebill.gateway.filter; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpResponseDecorator; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.Arrays; +import java.util.List; + +/** + * Global Filter for removing duplicate CORS headers + * + * 이 필터는 백엔드 서비스에서 오는 CORS 헤더를 제거하여 + * API Gateway의 GlobalCors 설정만 사용하도록 합니다. + * + * @author 이개발(백엔더) + * @version 1.0.0 + * @since 2025-01-08 + */ +@Slf4j +@Component +public class CorsDedupeGlobalFilter implements GlobalFilter, Ordered { + + private static final List CORS_HEADERS = Arrays.asList( + "Access-Control-Allow-Origin", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Headers", + "Access-Control-Allow-Credentials", + "Access-Control-Expose-Headers", + "Access-Control-Max-Age" + ); + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + String path = exchange.getRequest().getPath().value(); + log.info("=== CorsDedupeGlobalFilter 시작 - Path: {}", path); + + // Response를 감싸서 헤더 수정 가능하게 만들기 + ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(exchange.getResponse()) { + @Override + public HttpHeaders getHeaders() { + HttpHeaders originalHeaders = super.getHeaders(); + HttpHeaders filteredHeaders = new HttpHeaders(); + + // 원본 헤더를 안전하게 복사하면서 CORS 헤더는 제외 + final int[] removedCount = {0}; + originalHeaders.forEach((key, values) -> { + if (CORS_HEADERS.contains(key)) { + removedCount[0]++; + log.info("CORS 헤더 제거: {} = {}", key, values); + } else { + try { + filteredHeaders.addAll(key, values); + } catch (Exception e) { + log.warn("헤더 추가 실패: {} = {}, 에러: {}", key, values, e.getMessage()); + } + } + }); + + if (removedCount[0] > 0) { + log.info("=== CorsDedupeGlobalFilter 완료 - 제거된 헤더 수: {}", removedCount[0]); + } + + return filteredHeaders; + } + }; + + // 수정된 response로 exchange 생성 + ServerWebExchange mutatedExchange = exchange.mutate().response(decoratedResponse).build(); + return chain.filter(mutatedExchange); + } + + @Override + public int getOrder() { + return -1; // 높은 우선순위로 설정 + } +} \ No newline at end of file diff --git a/api-gateway/src/main/resources/application.yml b/api-gateway/src/main/resources/application.yml index 4390d6c..ffe626c 100644 --- a/api-gateway/src/main/resources/application.yml +++ b/api-gateway/src/main/resources/application.yml @@ -30,6 +30,28 @@ spring: response-timeout: 60s connect-timeout: 5000 + # Global CORS 설정 + globalcors: + cors-configurations: + '[/**]': + allowedOrigins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000}" + allowedMethods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + - HEAD + allowedHeaders: "*" + allow-credentials: true + max-age: 3600 + + # Discovery 설정 비활성화 (직접 라우팅 사용) + discovery: + locator: + enabled: false + + # Default filters default-filters: - name: AddRequestHeader args: @@ -39,27 +61,6 @@ spring: args: name: X-Gateway-Response value: API-Gateway - - # Global CORS 설정 - globalcors: - cors-configurations: - '[/**]': - allowed-origin-patterns: "*" - allowed-methods: - - GET - - POST - - PUT - - DELETE - - OPTIONS - - HEAD - allowed-headers: "*" - allow-credentials: true - max-age: 3600 - - # Discovery 설정 비활성화 (직접 라우팅 사용) - discovery: - locator: - enabled: false # JSON 설정 jackson: @@ -69,10 +70,6 @@ spring: deserialization: fail-on-unknown-properties: false -# CORS -cors: - allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000} - # JWT 토큰 설정 jwt: secret: ${JWT_SECRET:} diff --git a/bill-service/src/main/java/com/phonebill/bill/config/SecurityConfig.java b/bill-service/src/main/java/com/phonebill/bill/config/SecurityConfig.java index a736fc6..92dee83 100644 --- a/bill-service/src/main/java/com/phonebill/bill/config/SecurityConfig.java +++ b/bill-service/src/main/java/com/phonebill/bill/config/SecurityConfig.java @@ -48,7 +48,7 @@ public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; - @Value("${cors.allowed-origins") + @Value("${cors.allowed-origins}") private String allowedOrigins; /** diff --git a/bill-service/src/main/java/com/phonebill/bill/controller/BillController.java b/bill-service/src/main/java/com/phonebill/bill/controller/BillController.java index e3e4759..fc13cab 100644 --- a/bill-service/src/main/java/com/phonebill/bill/controller/BillController.java +++ b/bill-service/src/main/java/com/phonebill/bill/controller/BillController.java @@ -155,7 +155,6 @@ public class BillController { public ResponseEntity> getBillHistory( @Parameter(description = "회선번호 (미입력시 인증된 사용자의 모든 회선)") @RequestParam(required = false) - @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "회선번호 형식이 올바르지 않습니다") String lineNumber, @Parameter(description = "조회 시작일 (YYYY-MM-DD)") @@ -177,11 +176,22 @@ public class BillController { @Parameter(description = "처리 상태 필터") @RequestParam(required = false) BillInquiryResponse.ProcessStatus status) { - log.info("요금조회 이력 조회 - 회선: {}, 기간: {} ~ {}, 페이지: {}/{}", - lineNumber, startDate, endDate, page, size); + // 회선번호 정규화 (입력된 경우에만) + String normalizedLineNumber = null; + if (lineNumber != null && !lineNumber.trim().isEmpty()) { + normalizedLineNumber = lineNumber.replaceAll("-", ""); + + // 유효성 검증 + if (!normalizedLineNumber.matches("^010\\d{8}$")) { + throw new IllegalArgumentException("회선번호는 010으로 시작하는 11자리 숫자이거나 010-XXXX-XXXX 형식이어야 합니다"); + } + } + + log.info("요금조회 이력 조회 - 회선: {} (original: {}), 기간: {} ~ {}, 페이지: {}/{}", + normalizedLineNumber, lineNumber, startDate, endDate, page, size); BillHistoryResponse historyData = billInquiryService.getBillHistory( - lineNumber, startDate, endDate, page, size, status + normalizedLineNumber, startDate, endDate, page, size, status ); log.info("요금조회 이력 조회 완료 - 총 {}건, 페이지: {}/{}", diff --git a/bill-service/src/main/java/com/phonebill/bill/dto/BillInquiryRequest.java b/bill-service/src/main/java/com/phonebill/bill/dto/BillInquiryRequest.java index d2c3bb9..288b21d 100644 --- a/bill-service/src/main/java/com/phonebill/bill/dto/BillInquiryRequest.java +++ b/bill-service/src/main/java/com/phonebill/bill/dto/BillInquiryRequest.java @@ -7,6 +7,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; /** * 요금조회 요청 DTO @@ -20,6 +21,7 @@ import lombok.NoArgsConstructor; * @since 2025-09-08 */ @Getter +@Setter @Builder @NoArgsConstructor @AllArgsConstructor @@ -27,14 +29,10 @@ public class BillInquiryRequest { /** * 조회할 회선번호 (필수) - * 010-XXXX-XXXX 형식만 허용 + * 010-XXXX-XXXX 또는 01XXXXXXXXX 형식 허용 */ @JsonProperty("lineNumber") @NotBlank(message = "회선번호는 필수입니다") - @Pattern( - regexp = "^010-\\d{4}-\\d{4}$", - message = "회선번호는 010-XXXX-XXXX 형식이어야 합니다" - ) private String lineNumber; /** @@ -47,4 +45,26 @@ public class BillInquiryRequest { message = "조회월은 YYYYMM 형식이어야 합니다" ) private String inquiryMonth; + + /** + * 회선번호 설정 시 정규화 처리 + * - 대시가 있는 경우: 010-1234-5678 → 01012345678 + * - 대시가 없는 경우: 01012345678 → 그대로 유지 + * - 유효성 검증: 010으로 시작하는 11자리 숫자 + */ + public void setLineNumber(String lineNumber) { + if (lineNumber != null) { + // 대시 제거하여 정규화 + String normalized = lineNumber.replaceAll("-", ""); + + // 유효성 검증: 010으로 시작하는 11자리 숫자 + if (!normalized.matches("^010\\d{8}$")) { + throw new IllegalArgumentException("회선번호는 010으로 시작하는 11자리 숫자이거나 010-XXXX-XXXX 형식이어야 합니다"); + } + + this.lineNumber = normalized; + } else { + this.lineNumber = null; + } + } } \ No newline at end of file diff --git a/kos-mock/src/main/java/com/phonebill/kosmock/controller/MockDataController.java b/kos-mock/src/main/java/com/phonebill/kosmock/controller/MockDataController.java index 0155b49..e5f8d83 100644 --- a/kos-mock/src/main/java/com/phonebill/kosmock/controller/MockDataController.java +++ b/kos-mock/src/main/java/com/phonebill/kosmock/controller/MockDataController.java @@ -21,7 +21,7 @@ import org.springframework.web.bind.annotation.*; * Mock 데이터 생성 및 조회 API 컨트롤러 */ @RestController -@RequestMapping("/api/v1/mock-datas") +@RequestMapping("/api/v1/kos/mock-datas") @RequiredArgsConstructor @Tag(name = "Mock Data Management", description = "Mock 데이터 생성, 조회 및 관리 API") public class MockDataController { diff --git a/kos-mock/src/main/java/com/phonebill/kosmock/service/MockDataCreateService.java b/kos-mock/src/main/java/com/phonebill/kosmock/service/MockDataCreateService.java index d624efe..0b896df 100644 --- a/kos-mock/src/main/java/com/phonebill/kosmock/service/MockDataCreateService.java +++ b/kos-mock/src/main/java/com/phonebill/kosmock/service/MockDataCreateService.java @@ -60,7 +60,7 @@ public class MockDataCreateService { CustomerEntity customer = createCustomer(request, selectedProduct); customerRepository.save(customer); - // 4. 요금 정보 생성 (최근 3개월) + // 4. 요금 정보 생성 (최근 6개월) List bills = createBills(customer, selectedProduct); billRepository.saveAll(bills); @@ -115,13 +115,13 @@ public class MockDataCreateService { } /** - * 요금 정보 생성 (최근 3개월) + * 요금 정보 생성 (최근 6개월) */ private List createBills(CustomerEntity customer, ProductEntity product) { List bills = new ArrayList<>(); Random random = new Random(); - for (int month = 0; month < 3; month++) { + for (int month = 0; month < 6; month++) { LocalDateTime billDate = LocalDateTime.now().minusMonths(month); String billingMonth = billDate.format(DateTimeFormatter.ofPattern("yyyyMM")); @@ -241,7 +241,7 @@ public class MockDataCreateService { return null; } - // 최근 3개월 요금 정보 조회 + // 최근 6개월 요금 정보 조회 List bills = billRepository.findByLineNumberOrderByBillingMonthDesc(lineNumber); if (bills.isEmpty()) { diff --git a/product-service/src/main/java/com/unicorn/phonebill/product/config/SecurityConfig.java b/product-service/src/main/java/com/unicorn/phonebill/product/config/SecurityConfig.java index 30824d2..189b008 100644 --- a/product-service/src/main/java/com/unicorn/phonebill/product/config/SecurityConfig.java +++ b/product-service/src/main/java/com/unicorn/phonebill/product/config/SecurityConfig.java @@ -30,7 +30,7 @@ import java.util.List; public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; - @Value("${cors.allowed-origins") + @Value("${cors.allowed-origins}") private String allowedOrigins; @Bean diff --git a/product-service/src/main/java/com/unicorn/phonebill/product/controller/ProductController.java b/product-service/src/main/java/com/unicorn/phonebill/product/controller/ProductController.java index 87da76b..2f39322 100644 --- a/product-service/src/main/java/com/unicorn/phonebill/product/controller/ProductController.java +++ b/product-service/src/main/java/com/unicorn/phonebill/product/controller/ProductController.java @@ -70,14 +70,23 @@ public class ProductController { public ResponseEntity getCustomerInfo( @Parameter(description = "고객 회선번호", example = "01012345678") @RequestParam("lineNumber") - @Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다") String lineNumber) { String userId = getCurrentUserId(); - logger.info("고객 정보 조회 요청: lineNumber={}, userId={}", lineNumber, userId); + + // 회선번호에서 대시 제거 + String normalizedLineNumber = lineNumber.replaceAll("-", ""); + + // 정규화된 회선번호 유효성 검증 + if (!normalizedLineNumber.matches("^010[0-9]{8}$")) { + throw new IllegalArgumentException("회선번호는 010으로 시작하는 11자리 숫자여야 합니다"); + } + + logger.info("고객 정보 조회 요청: lineNumber={} (original: {}), userId={}", + normalizedLineNumber, lineNumber, userId); try { - CustomerInfoResponse response = productService.getCustomerInfo(lineNumber); + CustomerInfoResponse response = productService.getCustomerInfo(normalizedLineNumber); return ResponseEntity.ok(response); } catch (Exception e) { logger.error("고객 정보 조회 실패: lineNumber={}, userId={}", lineNumber, userId, e); @@ -204,7 +213,6 @@ public class ProductController { public ResponseEntity getProductChangeHistory( @Parameter(description = "회선번호 (미입력시 로그인 고객 기준)") @RequestParam(required = false) - @Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다") String lineNumber, @Parameter(description = "조회 시작일 (YYYY-MM-DD)") @RequestParam(required = false) String startDate, @@ -216,8 +224,20 @@ public class ProductController { @RequestParam(defaultValue = "10") int size) { String userId = getCurrentUserId(); - logger.info("상품변경 이력 조회 요청: lineNumber={}, startDate={}, endDate={}, page={}, size={}, userId={}", - lineNumber, startDate, endDate, page, size, userId); + + // 회선번호 정규화 (입력된 경우에만) + String normalizedLineNumber = null; + if (lineNumber != null && !lineNumber.trim().isEmpty()) { + normalizedLineNumber = lineNumber.replaceAll("-", ""); + + // 정규화된 회선번호 유효성 검증 + if (!normalizedLineNumber.matches("^010[0-9]{8}$")) { + throw new IllegalArgumentException("회선번호는 010으로 시작하는 11자리 숫자여야 합니다"); + } + } + + logger.info("상품변경 이력 조회 요청: lineNumber={} (original: {}), startDate={}, endDate={}, page={}, size={}, userId={}", + normalizedLineNumber, lineNumber, startDate, endDate, page, size, userId); try { // 페이지 번호를 0-based로 변환 @@ -227,7 +247,7 @@ public class ProductController { validateDateRange(startDate, endDate); ProductChangeHistoryResponse response = productService.getProductChangeHistory( - lineNumber, startDate, endDate, pageable); + normalizedLineNumber, startDate, endDate, pageable); return ResponseEntity.ok(response); } catch (Exception e) { logger.error("상품변경 이력 조회 실패: lineNumber={}, userId={}", lineNumber, userId, e); diff --git a/product-service/src/main/java/com/unicorn/phonebill/product/dto/ProductChangeRequest.java b/product-service/src/main/java/com/unicorn/phonebill/product/dto/ProductChangeRequest.java index 4b8d2dd..3c06351 100644 --- a/product-service/src/main/java/com/unicorn/phonebill/product/dto/ProductChangeRequest.java +++ b/product-service/src/main/java/com/unicorn/phonebill/product/dto/ProductChangeRequest.java @@ -21,7 +21,6 @@ public class ProductChangeRequest { @JsonProperty("lineNumber") @NotBlank(message = "회선번호는 필수입니다") - @Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다") private String lineNumber; @JsonProperty("currentProductCode") @@ -31,4 +30,23 @@ public class ProductChangeRequest { @JsonProperty("targetProductCode") @NotBlank(message = "변경 대상 상품 코드는 필수입니다") private String targetProductCode; + + /** + * 회선번호 설정 시 대시 제거 및 유효성 검증 + */ + public void setLineNumber(String lineNumber) { + if (lineNumber != null) { + // 대시 제거 + String normalized = lineNumber.replaceAll("-", ""); + + // 유효성 검증 + if (!normalized.matches("^010[0-9]{8}$")) { + throw new IllegalArgumentException("회선번호는 010으로 시작하는 11자리 숫자여야 합니다"); + } + + this.lineNumber = normalized; + } else { + this.lineNumber = null; + } + } } \ No newline at end of file diff --git a/product-service/src/main/java/com/unicorn/phonebill/product/dto/ProductChangeValidationRequest.java b/product-service/src/main/java/com/unicorn/phonebill/product/dto/ProductChangeValidationRequest.java index 56d843a..c4d25da 100644 --- a/product-service/src/main/java/com/unicorn/phonebill/product/dto/ProductChangeValidationRequest.java +++ b/product-service/src/main/java/com/unicorn/phonebill/product/dto/ProductChangeValidationRequest.java @@ -19,7 +19,6 @@ import jakarta.validation.constraints.Pattern; public class ProductChangeValidationRequest { @NotBlank(message = "회선번호는 필수입니다") - @Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다") private String lineNumber; @NotBlank(message = "현재 상품 코드는 필수입니다") @@ -27,4 +26,23 @@ public class ProductChangeValidationRequest { @NotBlank(message = "변경 대상 상품 코드는 필수입니다") private String targetProductCode; + + /** + * 회선번호 설정 시 대시 제거 및 유효성 검증 + */ + public void setLineNumber(String lineNumber) { + if (lineNumber != null) { + // 대시 제거 + String normalized = lineNumber.replaceAll("-", ""); + + // 유효성 검증 + if (!normalized.matches("^010[0-9]{8}$")) { + throw new IllegalArgumentException("회선번호는 010으로 시작하는 11자리 숫자여야 합니다"); + } + + this.lineNumber = normalized; + } else { + this.lineNumber = null; + } + } } \ No newline at end of file diff --git a/user-service/src/main/java/com/phonebill/user/config/SecurityConfig.java b/user-service/src/main/java/com/phonebill/user/config/SecurityConfig.java index 7974a4a..78878d6 100644 --- a/user-service/src/main/java/com/phonebill/user/config/SecurityConfig.java +++ b/user-service/src/main/java/com/phonebill/user/config/SecurityConfig.java @@ -29,7 +29,7 @@ import java.util.List; public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; - @Value("${cors.allowed-origins") + @Value("${cors.allowed-origins}") private String allowedOrigins; @Bean diff --git a/user-service/src/main/java/com/phonebill/user/controller/UserController.java b/user-service/src/main/java/com/phonebill/user/controller/UserController.java index 867f94a..ba0a5a9 100644 --- a/user-service/src/main/java/com/phonebill/user/controller/UserController.java +++ b/user-service/src/main/java/com/phonebill/user/controller/UserController.java @@ -79,60 +79,6 @@ public class UserController { return ResponseEntity.ok(response); } - /** - * 고객 ID로 사용자 정보 조회 - * @param customerId 고객 ID - * @return 사용자 정보 - */ - @Operation( - summary = "고객 ID로 사용자 정보 조회", - description = "고객 ID로 해당 고객의 사용자 정보를 조회합니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공"), - @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) - @GetMapping("/by-customer/{customerId}") - public ResponseEntity getUserInfoByCustomerId( - @Parameter(description = "고객 ID", required = true) - @PathVariable String customerId - ) { - log.info("고객 ID로 사용자 정보 조회 요청: customerId={}", customerId); - - UserInfoResponse response = userService.getUserInfoByCustomerId(customerId); - - log.info("고객 ID로 사용자 정보 조회 성공: customerId={}", customerId); - return ResponseEntity.ok(response); - } - - /** - * 회선번호로 사용자 정보 조회 - * @param lineNumber 회선번호 - * @return 사용자 정보 - */ - @Operation( - summary = "회선번호로 사용자 정보 조회", - description = "회선번호로 해당 회선의 사용자 정보를 조회합니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공"), - @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) - @GetMapping("/by-line/{lineNumber}") - public ResponseEntity getUserInfoByLineNumber( - @Parameter(description = "회선번호", required = true) - @PathVariable String lineNumber - ) { - log.info("회선번호로 사용자 정보 조회 요청: lineNumber={}", lineNumber); - - UserInfoResponse response = userService.getUserInfoByLineNumber(lineNumber); - - log.info("회선번호로 사용자 정보 조회 성공: lineNumber={}", lineNumber); - return ResponseEntity.ok(response); - } - /** * 권한 부여 * @param userId 사용자 ID diff --git a/user-service/src/main/java/com/phonebill/user/exception/UserNotFoundException.java b/user-service/src/main/java/com/phonebill/user/exception/UserNotFoundException.java index b017025..6819da5 100644 --- a/user-service/src/main/java/com/phonebill/user/exception/UserNotFoundException.java +++ b/user-service/src/main/java/com/phonebill/user/exception/UserNotFoundException.java @@ -17,11 +17,4 @@ public class UserNotFoundException extends RuntimeException { return new UserNotFoundException("사용자를 찾을 수 없습니다. userId: " + userId); } - public static UserNotFoundException byCustomerId(String customerId) { - return new UserNotFoundException("사용자를 찾을 수 없습니다. customerId: " + customerId); - } - - public static UserNotFoundException byLineNumber(String lineNumber) { - return new UserNotFoundException("사용자를 찾을 수 없습니다. lineNumber: " + lineNumber); - } } \ No newline at end of file diff --git a/user-service/src/main/java/com/phonebill/user/service/UserService.java b/user-service/src/main/java/com/phonebill/user/service/UserService.java index b9fca00..a669f7c 100644 --- a/user-service/src/main/java/com/phonebill/user/service/UserService.java +++ b/user-service/src/main/java/com/phonebill/user/service/UserService.java @@ -88,54 +88,6 @@ public class UserService { .build(); } - /** - * 고객 ID로 사용자 정보 조회 - * @param customerId 고객 ID - * @return 사용자 정보 - */ - public UserInfoResponse getUserInfoByCustomerId(String customerId) { - AuthUserEntity user = authUserRepository.findByCustomerId(customerId) - .orElseThrow(() -> UserNotFoundException.byCustomerId(customerId)); - - // 사용자 권한 목록 조회 - List permissions = authUserPermissionRepository.findPermissionCodesByUserId(user.getUserId()); - - return UserInfoResponse.builder() - .userId(user.getUserId()) - .customerId(user.getCustomerId()) - .lineNumber(user.getLineNumber()) - .userName(user.getUserName()) - .accountStatus(user.getAccountStatus().name()) - .lastLoginAt(user.getLastLoginAt()) - .lastPasswordChangedAt(user.getLastPasswordChangedAt()) - .permissions(permissions) - .build(); - } - - /** - * 회선번호로 사용자 정보 조회 - * @param lineNumber 회선번호 - * @return 사용자 정보 - */ - public UserInfoResponse getUserInfoByLineNumber(String lineNumber) { - AuthUserEntity user = authUserRepository.findByLineNumber(lineNumber) - .orElseThrow(() -> UserNotFoundException.byLineNumber(lineNumber)); - - // 사용자 권한 목록 조회 - List permissions = authUserPermissionRepository.findPermissionCodesByUserId(user.getUserId()); - - return UserInfoResponse.builder() - .userId(user.getUserId()) - .customerId(user.getCustomerId()) - .lineNumber(user.getLineNumber()) - .userName(user.getUserName()) - .accountStatus(user.getAccountStatus().name()) - .lastLoginAt(user.getLastLoginAt()) - .lastPasswordChangedAt(user.getLastPasswordChangedAt()) - .permissions(permissions) - .build(); - } - /** * 권한 부여 * @param userId 사용자 ID @@ -231,45 +183,53 @@ public class UserService { } /** - * 사용자 등록 + * 사용자 등록 또는 업데이트 (Upsert) * @param request 사용자 등록 요청 - * @return 등록된 사용자 정보 + * @return 등록/업데이트된 사용자 정보 */ @Transactional public UserRegistrationResponse registerUser(UserRegistrationRequest request) { - log.info("사용자 등록 요청: userId={}, customerId={}", request.getUserId(), request.getCustomerId()); - - // 중복 검사 - validateUserUniqueness(request); + log.info("사용자 등록/업데이트 요청: userId={}, customerId={}", request.getUserId(), request.getCustomerId()); // 권한 코드 유효성 검증 validatePermissionCodes(request.getPermissions()); - // 사용자 엔티티 생성 - AuthUserEntity user = createUserEntity(request); + // 기존 사용자 확인 + Optional existingUser = authUserRepository.findById(request.getUserId()); - // 사용자 저장 - AuthUserEntity savedUser = authUserRepository.save(user); + AuthUserEntity savedUser; + boolean isUpdate = false; - // 권한 부여 - grantUserPermissions(savedUser.getUserId(), request.getPermissions()); + if (existingUser.isPresent()) { + // 업데이트 로직 + savedUser = updateExistingUser(existingUser.get(), request); + isUpdate = true; + log.info("기존 사용자 업데이트: userId={}", request.getUserId()); + } else { + // 새 사용자 등록 전 유니크 검사 + validateUserUniquenessForNewUser(request); + + // 사용자 엔티티 생성 + AuthUserEntity user = createUserEntity(request); + + // 사용자 저장 + savedUser = authUserRepository.save(user); + log.info("신규 사용자 등록: userId={}", request.getUserId()); + } + + // 권한 부여/업데이트 + updateUserPermissions(savedUser.getUserId(), request.getPermissions()); // 응답 생성 - UserRegistrationResponse response = buildRegistrationResponse(savedUser, request.getPermissions(), request.getUserName()); + UserRegistrationResponse response = buildRegistrationResponse(savedUser, request.getPermissions(), request.getUserName(), isUpdate); - log.info("사용자 등록 완료: userId={}", savedUser.getUserId()); return response; } /** - * 사용자 유니크 필드 중복 검사 + * 신규 사용자 등록 시 유니크 필드 중복 검사 */ - private void validateUserUniqueness(UserRegistrationRequest request) { - // 사용자 ID 중복 확인 - if (authUserRepository.existsByUserId(request.getUserId())) { - throw new RuntimeException("이미 존재하는 사용자 ID입니다: " + request.getUserId()); - } - + private void validateUserUniquenessForNewUser(UserRegistrationRequest request) { // 고객 ID 중복 확인 if (authUserRepository.existsByCustomerId(request.getCustomerId())) { throw new RuntimeException("이미 존재하는 고객 ID입니다: " + request.getCustomerId()); @@ -281,6 +241,67 @@ public class UserService { } } + /** + * 기존 사용자 정보 업데이트 + */ + private AuthUserEntity updateExistingUser(AuthUserEntity existingUser, UserRegistrationRequest request) { + // 다른 사용자가 같은 customerId나 lineNumber를 사용하는지 확인 + validateUniqueFieldsForUpdate(existingUser.getUserId(), request); + + // Salt 생성 (UUID 기반) + String salt = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16); + + // password + salt 결합 후 해시 + String saltedPassword = request.getPassword() + salt; + String hashedPassword = passwordEncoder.encode(saltedPassword); + + // 기존 엔티티 업데이트 (Builder 패턴 사용을 위해 새 엔티티 생성) + AuthUserEntity updatedUser = AuthUserEntity.builder() + .userId(existingUser.getUserId()) + .customerId(request.getCustomerId()) + .lineNumber(request.getLineNumber()) + .userName(request.getUserName()) + .passwordHash(hashedPassword) + .passwordSalt(salt) + .accountStatus(existingUser.getAccountStatus()) + .failedLoginCount(existingUser.getFailedLoginCount()) + .lastFailedLoginAt(existingUser.getLastFailedLoginAt()) + .accountLockedUntil(existingUser.getAccountLockedUntil()) + .lastLoginAt(existingUser.getLastLoginAt()) + .lastPasswordChangedAt(existingUser.getLastPasswordChangedAt()) + .build(); + + return authUserRepository.save(updatedUser); + } + + /** + * 업데이트 시 다른 사용자와의 유니크 필드 중복 검사 + */ + private void validateUniqueFieldsForUpdate(String userId, UserRegistrationRequest request) { + // 현재 사용자가 아닌 다른 사용자가 같은 customerId를 사용하는지 확인 + Optional existingCustomer = authUserRepository.findByCustomerId(request.getCustomerId()); + if (existingCustomer.isPresent() && !existingCustomer.get().getUserId().equals(userId)) { + throw new RuntimeException("이미 다른 사용자가 사용하는 고객 ID입니다: " + request.getCustomerId()); + } + + // 현재 사용자가 아닌 다른 사용자가 같은 lineNumber를 사용하는지 확인 + Optional existingLine = authUserRepository.findByLineNumber(request.getLineNumber()); + if (existingLine.isPresent() && !existingLine.get().getUserId().equals(userId)) { + throw new RuntimeException("이미 다른 사용자가 사용하는 회선번호입니다: " + request.getLineNumber()); + } + } + + /** + * 사용자 권한 업데이트 (기존 권한 모두 제거 후 새로 추가) + */ + private void updateUserPermissions(String userId, List permissionCodes) { + // 기존 권한 모두 철회 + authUserPermissionRepository.deleteAllByUserId(userId); + + // 새 권한 부여 + grantUserPermissions(userId, permissionCodes); + } + /** * 권한 코드 유효성 검증 */ @@ -332,9 +353,9 @@ public class UserService { } /** - * 사용자 등록 응답 생성 + * 사용자 등록/업데이트 응답 생성 */ - private UserRegistrationResponse buildRegistrationResponse(AuthUserEntity user, List permissions, String userName) { + private UserRegistrationResponse buildRegistrationResponse(AuthUserEntity user, List permissions, String userName, boolean isUpdate) { UserRegistrationResponse.UserData userData = UserRegistrationResponse.UserData.builder() .userId(user.getUserId()) .customerId(user.getCustomerId()) @@ -345,9 +366,11 @@ public class UserService { .permissions(permissions) .build(); + String message = isUpdate ? "사용자 정보가 성공적으로 업데이트되었습니다." : "사용자가 성공적으로 등록되었습니다."; + return UserRegistrationResponse.builder() .success(true) - .message("사용자가 성공적으로 등록되었습니다.") + .message(message) .data(userData) .build(); }