diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6b191af --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 프로젝트 개요 + +`eapim-admin-kjb`는 광주은행(KJB) 전용 커스터마이징을 위한 eapim-admin 확장 모듈입니다. 빠른 부팅 속도를 위해 메인 eapim-admin 프로젝트에서 분리되어 독립적으로 테스트할 수 있습니다. + +## 빌드 및 테스트 명령어 + +```bash +# 빌드 +gradle build +gradle clean build + +# 테스트 실행 (환경변수 필요) +KJB_EAPIM_TEST_ENABLED=TRUE gradle :eapim-admin-kjb:test + +# 특정 테스트 클래스 실행 +KJB_EAPIM_TEST_ENABLED=TRUE gradle :eapim-admin-kjb:test --tests "com.eactive.ext.kjb.ums.test.UmsTest.testSms" +KJB_EAPIM_TEST_ENABLED=TRUE gradle :eapim-admin-kjb:test --tests "com.eactive.ext.kjb.ums.test.UmsTest.testEmail" + +# SafeDB 실제 모드로 테스트 (Linux 서버) +KJB_EAPIM_TEST_ENABLED=TRUE gradle :eapim-admin-kjb:test -Psafedb +``` + +**테스트 실행 조건**: `KJB_EAPIM_TEST_ENABLED=TRUE` 환경변수가 설정되어야 UMS/이메일 테스트가 실행됩니다. + +## 모듈 의존성 + +``` +eapim-admin-kjb +├── elink-portal-common # JPA 엔티티 (MessageRequest, MessageCode 등) +└── kjb-safedb # 암호화 라이브러리 +``` + +의존 모듈들은 `../elink-portal-common/`, `../kjb-safedb/` 경로에 위치합니다. + +## 아키텍처 + +### 주요 컴포넌트 + +| 컴포넌트 | 위치 | 설명 | +|---------|------|------| +| `KjbProperty` | `common/` | DB 테이블 `TSEAIRM24`에서 로드되는 설정 관리. `@KjbPropertyValue` 애노테이션으로 키 매핑 | +| `KjbEaiBatchModule` | `eai/` | EAI 배치 시스템 HTTP 연동. 파일 기반 배치 전송 및 보관 기간 관리 | +| `KjbUmsService` | `ums/` | UMS API를 통한 카카오 알림톡 발송 | +| `KjbEmailSendModule` | `ums/` | EAI 배치를 통한 이메일 발송. 파일 생성 후 배치 호출 | +| `KjbObpModule` | `obp/` | OBP(Open Banking Platform) API 연동 | + +### 프로퍼티 키 목록 (`KjbProperty.java`) + +**EAI 배치 연동** +- `kjb.eai.batch.url` - EAI 배치 서버 URL +- `kjb.eai.batch.connection_timeout` - 연결 타임아웃 (초, 기본값: 5) +- `kjb.eai.batch.response_timeout` - 응답 타임아웃 (초, 기본값: 5) +- `kjb.eai.batch.charset` - 문자셋 (기본값: euc-kr) + +**이메일 발송 (EAI 배치 경유)** +- `kjb.ums.eai_batch.send_dir` - 발송 파일 디렉토리 +- `kjb.ums.eai_batch.backup_dir` - 백업 디렉토리 +- `kjb.ums.eai_batch.error_dir` - 에러 디렉토리 +- `kjb.ums.eai_batch.interface_id` - 인터페이스 ID (13자리) +- `kjb.ums.eai_batch.retention_date` - 파일 보관 기간 (일, 기본값: 60) + +**UMS (카카오 알림톡)** +- `kjb.ums.host` - UMS API 엔드포인트 +- `kjb.ums.api_key` - API 인증 키 (36자리 UUID 형식) +- `kjb.ums.connection_timeout` - 연결 타임아웃 (초, 기본값: 5) +- `kjb.ums.response_timeout` - 응답 타임아웃 (초, 기본값: 5) + +**OBP 연동** +- `kjb.obp.url` - OBP API URL +- `kjb.obp.trust_system.key/value` - 신뢰 시스템 헤더 +- `kjb.obp.partner_code.key/value` - 파트너 코드 헤더 + +### 메시지 채널 + +- `KjbUmsService`: 카카오 알림톡 (`MessageCode.Channels.KAKAO_ALIMTALK`) +- `KjbEmailSendModule`: 이메일 (`MessageCode.Channels.EMAIL`) + +## 로컬 개발 환경 (Windows) + +테스트 시 `OsUtil.isWindows()` 조건으로 로컬 경로가 자동 설정됩니다: +- 발송 디렉토리: `c:\kjb-logs\sendmail\snd` +- 백업 디렉토리: `c:\kjb-logs\sendmail\bak` +- 에러 디렉토리: `c:\kjb-logs\sendmail\error` + +URL이 빈 문자열로 설정되면 실제 API 호출 없이 모의 응답을 반환합니다. + +## 기술 스택 + +- **Java 8**: Gradle toolchain 강제 +- **Spring Framework 5.3**: 의존성 주입 +- **Apache HttpClient 5.1**: HTTP 통신 +- **Gson 2.3.1**: JSON 처리 +- **Lombok 1.18.28**: 보일러플레이트 코드 생성 +- **JUnit 5**: 테스트 프레임워크 +- **Logback 1.2.10**: 로깅 + +## 패키지 구조 + +``` +com.eactive.ext.kjb +├── common/ # 공통 (KjbProperty, 예외 클래스) +├── eai/ # EAI 배치 연동 +├── obp/ # OBP API 연동 +│ └── common/ # OBP HTTP 클라이언트 +├── ums/ # UMS/이메일 발송 +│ └── gson/ # UMS 요청/응답 JSON 모델 +└── utils/ # 유틸리티 (JSON, 검증, OS 판별) +``` diff --git a/src/main/java/com/eactive/ext/kjb/common/KjbObpException.java b/src/main/java/com/eactive/ext/kjb/common/KjbObpException.java new file mode 100644 index 0000000..93d9e8b --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/common/KjbObpException.java @@ -0,0 +1,22 @@ +package com.eactive.ext.kjb.common; + +public class KjbObpException extends Exception { + private int statusCode; + + public KjbObpException(String message) { + super(message); + } + + public KjbObpException(String message, Throwable cause) { + super(message, cause); + } + + public KjbObpException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/src/main/java/com/eactive/ext/kjb/common/KjbProperty.java b/src/main/java/com/eactive/ext/kjb/common/KjbProperty.java index 8e14209..d0ed3d4 100644 --- a/src/main/java/com/eactive/ext/kjb/common/KjbProperty.java +++ b/src/main/java/com/eactive/ext/kjb/common/KjbProperty.java @@ -91,6 +91,18 @@ public class KjbProperty { @NotEmpty private String obpPartnerCodeValue = "100001-01"; + @KjbPropertyValue(key = "kjb.obp.connection_timeout", defaultValue = "5") + @Min(1) @Max(10) + private int obpConnectionTimeout = 5; + + @KjbPropertyValue(key = "kjb.obp.response_timeout", defaultValue = "5") + @Min(1) @Max(30) + private int obpResponseTimeout = 5; + + @KjbPropertyValue(key = "kjb.obp.timeout", defaultValue = "10") + @Min(1) @Max(30) + private int obpTimeout = 10; + @KjbPropertyValue(key = "kjb.obp.url.commission", defaultValue = "/api/billing/billing/findBillingForCondition") @NotEmpty diff --git a/src/main/java/com/eactive/ext/kjb/obp/KjbObpModule.java b/src/main/java/com/eactive/ext/kjb/obp/KjbObpModule.java index 2cb1340..9e4dcab 100644 --- a/src/main/java/com/eactive/ext/kjb/obp/KjbObpModule.java +++ b/src/main/java/com/eactive/ext/kjb/obp/KjbObpModule.java @@ -1,36 +1,84 @@ package com.eactive.ext.kjb.obp; -import com.google.gson.JsonObject; +import com.eactive.ext.kjb.common.KjbObpException; +import com.eactive.ext.kjb.common.KjbProperty; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import java.util.Arrays; -import java.util.stream.Collectors; - @Slf4j public class KjbObpModule { + private static final String FAKE_PARTNER_CODE = "999999-99"; + + private static KjbObpModule _instance; + + private final KjbProperty property; + private final ObpClient client; + + public static synchronized KjbObpModule getInstance(KjbProperty kjbProperty) { + if (_instance == null) { + _instance = new KjbObpModule(kjbProperty); + } + return _instance; + } + + private KjbObpModule(KjbProperty kjbProperty) { + if (kjbProperty == null) { + throw new IllegalArgumentException("KjbProperty cannot be null"); + } + this.property = kjbProperty; + this.client = ObpClient.getInstance(kjbProperty); + } + /** - * Trim, 숫자이외 자동 치환 - * @param bizNo - * @return + * 사업자등록번호로 partnerCode 조회 + * @param bizNo 사업자등록번호 (숫자 외 문자 자동 제거) + * @return partnerCode + * @throws KjbObpException 조회 실패 시 */ - public static JsonObject queryOrganization(String bizNo) { + public String queryPartnerCode(String bizNo) throws KjbObpException { bizNo = ObpUtil.onlyDigit(bizNo); if (StringUtils.isEmpty(bizNo)) { - throw new IllegalArgumentException("BizNo is not empty."); + throw new IllegalArgumentException("BizNo is empty."); } if (bizNo.length() != 10) { - throw new IllegalArgumentException("BizNo is not valid."); + throw new IllegalArgumentException("BizNo must be 10 digits. (input: " + bizNo + ")"); } - return null; + // 가상모드: URL 미설정 시 가상 파트너코드 반환 + if (!client.isRealMode()) { + log.info("[OBP] 가상모드 - 가상 partnerCode 반환: {}", FAKE_PARTNER_CODE); + return FAKE_PARTNER_CODE; + } + + String path = String.format(property.getObpUrlOrganizationFmt(), bizNo); + log.debug("[OBP] queryPartnerCode - bizNo: {}, path: {}", bizNo, path); + + ObpResponse response = client.get(path); + + if (response.getStatus() != 200) { + log.warn("[OBP] queryPartnerCode failed - status: {}, body: {}", response.getStatus(), response.getBody()); + throw new KjbObpException(response.getStatus(), "OBP 조회 실패: " + response.getBody()); + } + + ObpOrganization org = ObpOrganization.fromResponse(response); + + if (org == null) { + throw new KjbObpException("OBP 응답 파싱 실패"); + } + + if (!org.hasPartnerCode()) { + throw new KjbObpException("partnerCode가 없습니다. bizNo: " + bizNo); + } + + return org.getPartnerCode(); } - - public static void main(String[] args) { - log.info("queryOrganization Test"); - queryOrganization("123asd123asd123"); + /** + * ObpClient 반환 (직접 API 호출용) + */ + public ObpClient getClient() { + return client; } } diff --git a/src/main/java/com/eactive/ext/kjb/obp/ObpClient.java b/src/main/java/com/eactive/ext/kjb/obp/ObpClient.java new file mode 100644 index 0000000..8618f33 --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/obp/ObpClient.java @@ -0,0 +1,183 @@ +package com.eactive.ext.kjb.obp; + +import com.eactive.ext.kjb.common.KjbProperty; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.entity.EntityBuilder; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.util.Timeout; + +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.*; + + +@Slf4j +public class ObpClient { + private static ObpClient _instance; + + private final KjbProperty property; + private final CloseableHttpClient httpClient; + private final RequestConfig requestConfig; + private final boolean isRealMode; + + public synchronized static ObpClient getInstance(KjbProperty kjbProperty) { + if (_instance == null) { + _instance = new ObpClient(kjbProperty); + } + + return _instance; + } + + private ObpClient(KjbProperty kjbProperty) { + if (kjbProperty == null) { + throw new IllegalArgumentException("KjbProperty cannot be null"); + } + + this.property = kjbProperty; + this.isRealMode = StringUtils.isNotEmpty(kjbProperty.getObpUrl()); + + this.requestConfig = RequestConfig.custom() + .setConnectTimeout(Timeout.ofSeconds(kjbProperty.getObpConnectionTimeout())) + .setResponseTimeout(Timeout.ofSeconds(kjbProperty.getObpResponseTimeout())) + .build(); + + if (isRealMode) { + this.httpClient = HttpClients.createDefault(); + } else { + this.httpClient = null; + log.warn("[OBP] URL이 설정되지 않음. 실제 OBP 연동 없이 동작함"); + } + } + + /** + * GET 요청 + * @param path API 경로 (예: /api/customer/...) + * @return ObpResponse + */ + public ObpResponse get(String path) { + return get(path, null); + } + + /** + * GET 요청 (추가 헤더 포함) + * @param path API 경로 + * @param additionalHeaders 추가 헤더 + * @return ObpResponse + */ + public ObpResponse get(String path, Map additionalHeaders) { + String url = property.getObpUrl() + path; + HttpGet httpGet = new HttpGet(url); + applyDefaultHeaders(httpGet); + applyAdditionalHeaders(httpGet, additionalHeaders); + httpGet.setConfig(requestConfig); + + return execute(httpGet); + } + + /** + * POST 요청 (JSON body) + * @param path API 경로 + * @param jsonBody JSON 문자열 + * @return ObpResponse + */ + public ObpResponse post(String path, String jsonBody) { + return post(path, jsonBody, null); + } + + /** + * POST 요청 (JSON body, 추가 헤더 포함) + * @param path API 경로 + * @param jsonBody JSON 문자열 + * @param additionalHeaders 추가 헤더 + * @return ObpResponse + */ + public ObpResponse post(String path, String jsonBody, Map additionalHeaders) { + String url = property.getObpUrl() + path; + HttpPost httpPost = new HttpPost(url); + applyDefaultHeaders(httpPost); + applyAdditionalHeaders(httpPost, additionalHeaders); + httpPost.setConfig(requestConfig); + + if (StringUtils.isNotEmpty(jsonBody)) { + httpPost.setEntity(EntityBuilder.create() + .setText(jsonBody) + .setContentType(ContentType.APPLICATION_JSON) + .build()); + } + + return execute(httpPost); + } + + private void applyDefaultHeaders(HttpUriRequestBase request) { + request.setHeader("Content-Type", "application/json; charset=utf-8"); + request.setHeader(property.getObpTrustSystemKey(), property.getObpTrustSystemValue()); + request.setHeader(property.getObpPartnerCodeKey(), property.getObpPartnerCodeValue()); + } + + private void applyAdditionalHeaders(HttpUriRequestBase request, Map additionalHeaders) { + if (additionalHeaders != null) { + additionalHeaders.forEach(request::setHeader); + } + } + + private ObpResponse execute(HttpUriRequestBase request) { + if (!isRealMode) { + log.warn("[OBP] URL 미설정으로 실제 호출하지 않음. 요청: {} {}", request.getMethod(), request.getRequestUri()); + ObpResponse mockResponse = new ObpResponse(); + mockResponse.setStatus(200); + mockResponse.setBody("{}"); + return mockResponse; + } + + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try { + log.debug("[OBP] 요청: {} {}", request.getMethod(), request.getRequestUri()); + + Future future = executor.submit(() -> + httpClient.execute(request, response -> { + ObpResponse obpResponse = new ObpResponse(); + obpResponse.setStatus(response.getCode()); + obpResponse.setBody(EntityUtils.toString(response.getEntity(), "UTF-8")); + + Arrays.asList(response.getHeaders()).forEach(header -> + obpResponse.getHeaders().put(header.getName(), header.getValue())); + + return obpResponse; + }) + ); + + ObpResponse response = future.get(property.getObpTimeout(), TimeUnit.SECONDS); + + if (log.isDebugEnabled()) { + log.debug("[OBP] 응답: status={}, body={}", response.getStatus(), response.getBody()); + } + + return response; + + } catch (InterruptedException | ExecutionException | TimeoutException e) { + log.error("[OBP] 호출 실패: {} {}", request.getMethod(), request.getRequestUri(), e); + ObpResponse errorResponse = new ObpResponse(); + errorResponse.setStatus(-1); + errorResponse.setBody(e.getMessage()); + return errorResponse; + } finally { + executor.shutdown(); + } + } + + /** + * 실제 모드 여부 + */ + public boolean isRealMode() { + return isRealMode; + } +} diff --git a/src/main/java/com/eactive/ext/kjb/obp/ObpOrganization.java b/src/main/java/com/eactive/ext/kjb/obp/ObpOrganization.java new file mode 100644 index 0000000..13625e4 --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/obp/ObpOrganization.java @@ -0,0 +1,121 @@ +package com.eactive.ext.kjb.obp; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +/** + * OBP Organization 조회 응답 + * - 필수 필드만 명시적으로 선언하고, 나머지는 rawJson으로 보관하여 유연성 확보 + */ +@Data +public class ObpOrganization { + private static final Gson gson = new Gson(); + + // 핵심 필드 + @SerializedName("partnerCode") + private String partnerCode; + + @SerializedName("id") + private String id; + + @SerializedName("name") + private String name; + + @SerializedName("state") + private String state; + + @SerializedName("status") + private String status; + + @SerializedName("businessManRegistrationNo") + private String businessManRegistrationNo; + + @SerializedName("corporateRegistrationNo") + private String corporateRegistrationNo; + + @SerializedName("representativeName") + private String representativeName; + + // 원본 JSON (추가 필드 접근용 + private transient JsonObject rawJson; + + /** + * JSON 문자열에서 파싱 + */ + public static ObpOrganization fromJson(String json) { + if (json == null || json.trim().isEmpty()) { + return null; + } + + ObpOrganization org = gson.fromJson(json, ObpOrganization.class); + org.rawJson = new JsonParser().parse(json).getAsJsonObject(); + return org; + } + + /** + * ObpResponse에서 파싱 + */ + public static ObpOrganization fromResponse(ObpResponse response) { + if (response == null || response.getStatus() != 200) { + return null; + } + return fromJson(response.getBody()); + } + + /** + * 원본 JSON에서 추가 필드 조회 + * @param fieldName 필드명 (예: "englishName", "description") + * @return 필드 값 (없으면 null) + */ + public String getField(String fieldName) { + if (rawJson == null || !rawJson.has(fieldName) || rawJson.get(fieldName).isJsonNull()) { + return null; + } + return rawJson.get(fieldName).getAsString(); + } + + /** + * 원본 JSON에서 중첩 필드 조회 + * @param path 경로 (예: "type.name", "type.parent.name") + * @return 필드 값 (없으면 null) + */ + public String getNestedField(String path) { + if (rawJson == null || path == null) { + return null; + } + + String[] parts = path.split("\\."); + JsonObject current = rawJson; + + for (int i = 0; i < parts.length - 1; i++) { + if (!current.has(parts[i]) || current.get(parts[i]).isJsonNull()) { + return null; + } + current = current.getAsJsonObject(parts[i]); + } + + String lastField = parts[parts.length - 1]; + if (!current.has(lastField) || current.get(lastField).isJsonNull()) { + return null; + } + + return current.get(lastField).getAsString(); + } + + /** + * 원본 JSON 객체 반환 + */ + public JsonObject getRawJson() { + return rawJson; + } + + /** + * partnerCode 유효성 확인 + */ + public boolean hasPartnerCode() { + return partnerCode != null && !partnerCode.trim().isEmpty(); + } +} diff --git a/src/main/java/com/eactive/ext/kjb/obp/ObpRequest.java b/src/main/java/com/eactive/ext/kjb/obp/ObpRequest.java new file mode 100644 index 0000000..287e4f3 --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/obp/ObpRequest.java @@ -0,0 +1,4 @@ +package com.eactive.ext.kjb.obp; + +public class ObpRequest { +} diff --git a/src/main/java/com/eactive/ext/kjb/obp/common/ObpResponse.java b/src/main/java/com/eactive/ext/kjb/obp/ObpResponse.java similarity index 84% rename from src/main/java/com/eactive/ext/kjb/obp/common/ObpResponse.java rename to src/main/java/com/eactive/ext/kjb/obp/ObpResponse.java index ab52836..ce32e47 100644 --- a/src/main/java/com/eactive/ext/kjb/obp/common/ObpResponse.java +++ b/src/main/java/com/eactive/ext/kjb/obp/ObpResponse.java @@ -1,4 +1,4 @@ -package com.eactive.ext.kjb.obp.common; +package com.eactive.ext.kjb.obp; import lombok.Data; diff --git a/src/main/java/com/eactive/ext/kjb/obp/common/ObpClient.java b/src/main/java/com/eactive/ext/kjb/obp/common/ObpClient.java deleted file mode 100644 index f0b04c3..0000000 --- a/src/main/java/com/eactive/ext/kjb/obp/common/ObpClient.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.eactive.ext.kjb.obp.common; - -import com.eactive.ext.kjb.common.KjbProperty; -import lombok.extern.slf4j.Slf4j; - - -@Slf4j -public class ObpClient { - private static ObpClient _instance; - - public synchronized static ObpClient getInstance(KjbProperty kjbProperty) { - if (_instance == null) { - _instance = new ObpClient(kjbProperty); - } - - return _instance; - } - - private ObpClient(KjbProperty kjbProperty) { - - } -} diff --git a/src/test/java/com/eactive/ext/kjb/ums/test/KjbPropertyTest.java b/src/test/java/com/eactive/ext/kjb/ums/test/KjbPropertyTest.java new file mode 100644 index 0000000..9bf6d78 --- /dev/null +++ b/src/test/java/com/eactive/ext/kjb/ums/test/KjbPropertyTest.java @@ -0,0 +1,28 @@ +package com.eactive.ext.kjb.ums.test; + +import com.eactive.ext.kjb.common.KjbProperty; +import com.eactive.ext.kjb.utils.ValidationUtil; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +@Slf4j +public class KjbPropertyTest { + + @Test + void testKjbProperty() { + Assumptions.assumeTrue( + "true".equalsIgnoreCase(System.getenv("KJB_EAPIM_TEST_ENABLED")), + "OS 환경변수 내 KJB_EAPIM_TEST_ENABLED=TRUE 설정되어 있어야 동작함"); + + + log.info("KjbProperty 테스트 시작"); + + + log.info("기본값 Validation 테스트"); + KjbProperty prop = new KjbProperty(); + ValidationUtil.validateOrThrow(prop); + + log.info("KjbProperty 테스트 완료"); + } +}