회선번호 처리 개선 및 다양한 API 기능 강화

- 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 <noreply@anthropic.com>
This commit is contained in:
hiondal 2025-09-10 19:25:13 +09:00
parent 9bfdeda316
commit 2599d57a37
17 changed files with 323 additions and 238 deletions

View File

@ -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`

View File

@ -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;
@ -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);
}
}

View File

@ -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<String> 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<Void> 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; // 높은 우선순위로 설정
}
}

View File

@ -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:
@ -40,27 +62,6 @@ spring:
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:
default-property-inclusion: non_null
@ -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:}

View File

@ -48,7 +48,7 @@ public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Value("${cors.allowed-origins")
@Value("${cors.allowed-origins}")
private String allowedOrigins;
/**

View File

@ -155,7 +155,6 @@ public class BillController {
public ResponseEntity<ApiResponse<BillHistoryResponse>> 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("요금조회 이력 조회 완료 - 총 {}건, 페이지: {}/{}",

View File

@ -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;
}
}
}

View File

@ -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 {

View File

@ -60,7 +60,7 @@ public class MockDataCreateService {
CustomerEntity customer = createCustomer(request, selectedProduct);
customerRepository.save(customer);
// 4. 요금 정보 생성 (최근 3개월)
// 4. 요금 정보 생성 (최근 6개월)
List<BillEntity> bills = createBills(customer, selectedProduct);
billRepository.saveAll(bills);
@ -115,13 +115,13 @@ public class MockDataCreateService {
}
/**
* 요금 정보 생성 (최근 3개월)
* 요금 정보 생성 (최근 6개월)
*/
private List<BillEntity> createBills(CustomerEntity customer, ProductEntity product) {
List<BillEntity> 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<BillEntity> bills = billRepository.findByLineNumberOrderByBillingMonthDesc(lineNumber);
if (bills.isEmpty()) {

View File

@ -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

View File

@ -70,14 +70,23 @@ public class ProductController {
public ResponseEntity<CustomerInfoResponse> 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<ProductChangeHistoryResponse> 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);

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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

View File

@ -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<UserInfoResponse> 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<UserInfoResponse> 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

View File

@ -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);
}
}

View File

@ -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<String> 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<String> 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<AuthUserEntity> 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<AuthUserEntity> existingCustomer = authUserRepository.findByCustomerId(request.getCustomerId());
if (existingCustomer.isPresent() && !existingCustomer.get().getUserId().equals(userId)) {
throw new RuntimeException("이미 다른 사용자가 사용하는 고객 ID입니다: " + request.getCustomerId());
}
// 현재 사용자가 아닌 다른 사용자가 같은 lineNumber를 사용하는지 확인
Optional<AuthUserEntity> existingLine = authUserRepository.findByLineNumber(request.getLineNumber());
if (existingLine.isPresent() && !existingLine.get().getUserId().equals(userId)) {
throw new RuntimeException("이미 다른 사용자가 사용하는 회선번호입니다: " + request.getLineNumber());
}
}
/**
* 사용자 권한 업데이트 (기존 권한 모두 제거 새로 추가)
*/
private void updateUserPermissions(String userId, List<String> permissionCodes) {
// 기존 권한 모두 철회
authUserPermissionRepository.deleteAllByUserId(userId);
// 권한 부여
grantUserPermissions(userId, permissionCodes);
}
/**
* 권한 코드 유효성 검증
*/
@ -332,9 +353,9 @@ public class UserService {
}
/**
* 사용자 등록 응답 생성
* 사용자 등록/업데이트 응답 생성
*/
private UserRegistrationResponse buildRegistrationResponse(AuthUserEntity user, List<String> permissions, String userName) {
private UserRegistrationResponse buildRegistrationResponse(AuthUserEntity user, List<String> 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();
}