Merge branch 'master' into feature/crypto-module
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
package com.eactive.eai.adapter.handler;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import com.eactive.eai.common.property.PropManager;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
import com.eactive.eai.message.StandardItem;
|
||||
import com.eactive.eai.message.StandardMessage;
|
||||
|
||||
/**
|
||||
* 템플릿 기반 어댑터 에러 메시지 핸들러.
|
||||
*
|
||||
* [프로퍼티 설정]
|
||||
* AdapterPropManager 의 "AdapterErrorMessageHandler" 프로퍼티 그룹에서
|
||||
* "{adapterGroupId}.template" 키로 등록된 템플릿 문자열을 읽어
|
||||
* StandardMessage 의 필드 값으로 치환한 결과를 반환한다.
|
||||
*
|
||||
* [변수 치환 규칙]
|
||||
* 1. "${callprop.키}" → callProp.getProperty("키") 로 치환
|
||||
* "${callprop.키:기본값}" → 키 없거나 callProp null 이면 기본값 반환
|
||||
* 2. "${callprop[경로]}" → StandardMessage 경로 값을 키로 callProp 간접 조회
|
||||
* "${callprop[경로]:기본값}" → 키 결정 실패 또는 키 없으면 기본값 반환
|
||||
* 3. "${경로}" → StandardMessage.findItemValue("경로") 로 치환
|
||||
* "${경로:기본값}" → 경로 값이 null/공백이면 기본값 반환
|
||||
* 모든 형태에서 기본값 미지정 시 빈 문자열을 반환한다.
|
||||
*
|
||||
* [배열 반복 문법] (MSG_LIST 등 GRID 타입)
|
||||
* {{#foreach MSG.MSG_LIST}}
|
||||
* { "code":"${outp_msg_cd}", "msg":"${outp_msg_ctnt}" }
|
||||
* {{/foreach}}
|
||||
* → MSG_LIST 각 행을 반복하며 행 내 필드명(접두어 없이)으로 치환,
|
||||
* 반복 결과를 쉼표(,)로 이어 붙임
|
||||
*
|
||||
* [사용 예 – JSON 템플릿]
|
||||
* {
|
||||
* "adapterName": "${callprop.ADAPTER_NAME}",
|
||||
* "resultCode": "${MSG.MAIN_MSG.outp_msg_cd}",
|
||||
* "resultMsg": "${MSG.MAIN_MSG.outp_msg_ctnt}",
|
||||
* "errors": [
|
||||
* {{#foreach MSG.MSG_LIST}}
|
||||
* {
|
||||
* "code": "${outp_msg_cd}",
|
||||
* "msg": "${outp_msg_ctnt}",
|
||||
* "desc": "${outp_msg_desc}",
|
||||
* "field": "${err_occu_item_nm}"
|
||||
* }
|
||||
* {{/foreach}}
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* [사용 예 – XML 템플릿]
|
||||
* <error>
|
||||
* <adapter>${callprop.ADAPTER_NAME}</adapter>
|
||||
* <code>${MSG.MAIN_MSG.outp_msg_cd}</code>
|
||||
* <errors>
|
||||
* {{#foreach MSG.MSG_LIST}}
|
||||
* <item><code>${outp_msg_cd}</code><msg>${outp_msg_ctnt}</msg></item>
|
||||
* {{/foreach}}
|
||||
* </errors>
|
||||
* </error>
|
||||
*
|
||||
* [callProp 중첩 Properties 참조]
|
||||
* callProp 의 값이 Properties 인 경우 점(.) 으로 체인 탐색이 가능하다.
|
||||
* callProp.put("OUTBOUND", subProps) // subProps.setProperty("IF_ID", "SVC001")
|
||||
* → 템플릿에서 ${callprop.OUTBOUND.IF_ID} 로 참조
|
||||
* 중간 값이 Properties 가 아닌 경우 전체 keyPath 를 키로 폴백 조회한다.
|
||||
*/
|
||||
public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandler {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
|
||||
|
||||
/** AdapterPropManager 에서 템플릿을 조회할 프로퍼티 그룹 이름 */
|
||||
static final String PROP_GROUP = "AdapterErrorMessageHandler";
|
||||
|
||||
/** callProp 참조 변수 접두어: ${callprop.키} */
|
||||
static final String CALLPROP_PREFIX = "callprop.";
|
||||
|
||||
/** callProp 간접 참조 접두어: ${callprop[표준전문경로]} */
|
||||
static final String CALLPROP_INDIRECT_PREFIX = "callprop[";
|
||||
|
||||
/** ${path} 또는 ${callprop.key} 스칼라 변수 패턴 */
|
||||
private static final Pattern VAR_PATTERN =
|
||||
Pattern.compile("\\$\\{([^}]+)\\}");
|
||||
|
||||
/** {{#foreach path}}...{{/foreach}} 배열 반복 블록 패턴 */
|
||||
private static final Pattern FOREACH_PATTERN =
|
||||
Pattern.compile("\\{\\{#foreach\\s+([^}]+)\\}\\}(.*?)\\{\\{/foreach\\}\\}",
|
||||
Pattern.DOTALL);
|
||||
|
||||
@Override
|
||||
public Object generateNonStandardErrorResponseMessage(
|
||||
String inboundAdapterGroupName,
|
||||
String inboundAdapterName,
|
||||
Properties callProp,
|
||||
Object outboundRequestData,
|
||||
StandardMessage resStandardMessage) throws Exception {
|
||||
|
||||
String templateKey = inboundAdapterGroupName + ".template";
|
||||
String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
|
||||
|
||||
if (StringUtils.isBlank(template)) {
|
||||
if (logger.isWarn()) {
|
||||
logger.warn("TemplateAdapterErrorMsgHandler] template not found. group=" + PROP_GROUP
|
||||
+ ", key=" + templateKey);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return render(template, resStandardMessage, callProp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿에 StandardMessage 및 callProp 값을 치환해 최종 문자열을 반환한다.
|
||||
* package-private: 단위 테스트에서 직접 호출 가능
|
||||
*/
|
||||
String render(String template, StandardMessage msg, Properties callProp) {
|
||||
// 1단계: {{#foreach path}}...{{/foreach}} 배열 반복 처리
|
||||
StringBuffer sb = new StringBuffer();
|
||||
Matcher foreachMatcher = FOREACH_PATTERN.matcher(template);
|
||||
while (foreachMatcher.find()) {
|
||||
String arrayPath = foreachMatcher.group(1).trim();
|
||||
String blockContent = foreachMatcher.group(2);
|
||||
String rendered = renderForeach(arrayPath, blockContent, msg);
|
||||
foreachMatcher.appendReplacement(sb, Matcher.quoteReplacement(rendered));
|
||||
}
|
||||
foreachMatcher.appendTail(sb);
|
||||
|
||||
// 2단계: 나머지 ${path} / ${callprop.키} 스칼라 변수 치환
|
||||
String intermediate = sb.toString();
|
||||
StringBuffer result = new StringBuffer();
|
||||
Matcher varMatcher = VAR_PATTERN.matcher(intermediate);
|
||||
while (varMatcher.find()) {
|
||||
String expr = varMatcher.group(1).trim();
|
||||
String value = resolveScalar(expr, msg, callProp);
|
||||
varMatcher.appendReplacement(result, Matcher.quoteReplacement(value));
|
||||
}
|
||||
varMatcher.appendTail(result);
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 변수 표현식을 해석해 값을 반환한다.
|
||||
* 모든 형태에서 ':기본값' 접미사를 지원한다. 기본값 미지정 시 빈 문자열.
|
||||
* - "callprop[경로][:기본값]" → 표준전문 경로 값을 키로 callProp 간접 조회
|
||||
* - "callprop.키[:기본값]" → callProp 직접/중첩 조회
|
||||
* - "경로[:기본값]" → StandardMessage.findItemValue(경로)
|
||||
*/
|
||||
private String resolveScalar(String expr, StandardMessage msg, Properties callProp) {
|
||||
if (expr.startsWith(CALLPROP_INDIRECT_PREFIX)) {
|
||||
return resolveIndirectCallProp(expr, msg, callProp);
|
||||
}
|
||||
if (expr.startsWith(CALLPROP_PREFIX)) {
|
||||
String rest = expr.substring(CALLPROP_PREFIX.length());
|
||||
int colonIdx = rest.indexOf(':');
|
||||
String keyPath = colonIdx >= 0 ? rest.substring(0, colonIdx) : rest;
|
||||
String defaultVal = colonIdx >= 0 ? rest.substring(colonIdx + 1) : "";
|
||||
if (callProp == null) return defaultVal;
|
||||
String value = resolveCallProp(callProp, keyPath);
|
||||
return value != null ? value : defaultVal;
|
||||
}
|
||||
int colonIdx = expr.indexOf(':');
|
||||
String path = colonIdx >= 0 ? expr.substring(0, colonIdx) : expr;
|
||||
String defaultVal = colonIdx >= 0 ? expr.substring(colonIdx + 1) : "";
|
||||
String value = resolveMessagePath(msg, path);
|
||||
return StringUtils.isNotEmpty(value) ? value : defaultVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* StandardMessage 경로를 해석한다.
|
||||
* 1. findItemValue(path) 로 직접 조회한다.
|
||||
* 2. null 이면 경로를 뒤에서부터 분리하며, 부모 경로 값이 JSON 문자열인 경우
|
||||
* 나머지 경로(subPath)를 키로 JSON 필드를 추출한다.
|
||||
* 예) DATA.BIZDATA.acctNo → findItemValue("DATA.BIZDATA") → JSON 파싱 → acctNo
|
||||
* DATA.BIZDATA.addr.city → findItemValue("DATA.BIZDATA") → JSON 파싱 → addr.city
|
||||
*/
|
||||
private String resolveMessagePath(StandardMessage msg, String path) {
|
||||
String value = msg.findItemValue(path);
|
||||
if (value != null) return value;
|
||||
|
||||
int dotIdx = path.lastIndexOf('.');
|
||||
while (dotIdx > 0) {
|
||||
String parentPath = path.substring(0, dotIdx);
|
||||
String subPath = path.substring(dotIdx + 1);
|
||||
String parentVal = msg.findItemValue(parentPath);
|
||||
if (StringUtils.isNotBlank(parentVal)) {
|
||||
String extracted = extractFromJson(parentVal, subPath);
|
||||
if (extracted != null) return extracted;
|
||||
}
|
||||
dotIdx = parentPath.lastIndexOf('.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 문자열에서 dot 구분 경로로 필드 값을 추출한다.
|
||||
* 중간 경로가 JSON 오브젝트면 계속 탐색하고, 최종 값이 primitive 면 문자열로,
|
||||
* 오브젝트/배열이면 JSON 문자열 그대로 반환한다. 파싱 실패 시 null 반환.
|
||||
*/
|
||||
private String extractFromJson(String json, String fieldPath) {
|
||||
try {
|
||||
JsonElement el = new JsonParser().parse(json);
|
||||
for (String part : fieldPath.split("\\.", -1)) {
|
||||
if (!el.isJsonObject()) return null;
|
||||
el = el.getAsJsonObject().get(part);
|
||||
if (el == null) return null;
|
||||
}
|
||||
return el.isJsonPrimitive() ? el.getAsString() : el.toString();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ${callprop[경로]} 또는 ${callprop[경로]:기본값} 처리.
|
||||
* 1. 표준전문 경로로 callProp 키를 동적으로 결정한다.
|
||||
* 2. 결정된 키로 callProp 에서 값을 조회한다.
|
||||
* 3. 키 또는 값을 찾지 못하면 기본값(없으면 빈 문자열)을 반환한다.
|
||||
*/
|
||||
private String resolveIndirectCallProp(String expr, StandardMessage msg, Properties callProp) {
|
||||
int closeIdx = expr.indexOf(']');
|
||||
if (closeIdx < 0) return "";
|
||||
|
||||
String msgPath = expr.substring(CALLPROP_INDIRECT_PREFIX.length(), closeIdx);
|
||||
String defaultVal = (closeIdx + 1 < expr.length() && expr.charAt(closeIdx + 1) == ':')
|
||||
? expr.substring(closeIdx + 2)
|
||||
: "";
|
||||
|
||||
String key = msg.findItemValue(msgPath);
|
||||
if (StringUtils.isBlank(key)) return defaultVal;
|
||||
if (callProp == null) return defaultVal;
|
||||
|
||||
String value = resolveCallProp(callProp, key);
|
||||
return value != null ? value : defaultVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties 체인을 점(.) 구분자로 재귀 탐색한다.
|
||||
* - 첫 세그먼트의 값이 Properties 이면 나머지 경로로 재귀
|
||||
* - String 이면 반환, 그 외 Object 이면 toString() 반환
|
||||
* - 첫 세그먼트 값이 Properties 가 아닌 경우 전체 keyPath 로 폴백 조회
|
||||
*/
|
||||
private String resolveCallProp(Properties props, String keyPath) {
|
||||
int dotIdx = keyPath.indexOf('.');
|
||||
if (dotIdx < 0) {
|
||||
Object val = props.get(keyPath);
|
||||
if (val == null) return null;
|
||||
if (val instanceof String) return (String) val;
|
||||
return val.toString();
|
||||
}
|
||||
|
||||
String first = keyPath.substring(0, dotIdx);
|
||||
String rest = keyPath.substring(dotIdx + 1);
|
||||
Object val = props.get(first);
|
||||
if (val instanceof Properties) {
|
||||
return resolveCallProp((Properties) val, rest);
|
||||
}
|
||||
|
||||
// 중간 값이 Properties 가 아닌 경우: 전체 keyPath 를 키로 폴백
|
||||
Object direct = props.get(keyPath);
|
||||
if (direct == null) return null;
|
||||
if (direct instanceof String) return (String) direct;
|
||||
return direct.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* GRID 타입 배열 아이템을 반복하며 blockContent 를 각 행으로 치환,
|
||||
* 결과를 쉼표로 이어 반환한다. (foreach 블록 내부는 callProp 참조 미지원)
|
||||
*/
|
||||
private String renderForeach(String arrayPath, String blockContent, StandardMessage msg) {
|
||||
StandardItem arrayItem = msg.findItem(arrayPath);
|
||||
if (arrayItem == null) {
|
||||
if (logger.isWarn()) logger.warn("TemplateAdapterErrorMsgHandler] foreach path not found: " + arrayPath);
|
||||
return "";
|
||||
}
|
||||
|
||||
List<LinkedHashMap<String, StandardItem>> rows = arrayItem.getList();
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
List<String> renderedItems = new ArrayList<>();
|
||||
for (LinkedHashMap<String, StandardItem> row : rows) {
|
||||
String rowText = renderRow(blockContent, row);
|
||||
if (StringUtils.isNotBlank(rowText)) {
|
||||
renderedItems.add(rowText);
|
||||
}
|
||||
}
|
||||
return String.join(",", renderedItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열 한 행(LinkedHashMap) 기준으로 blockContent 의 ${fieldName} 을 치환한다.
|
||||
* fieldName 은 경로 없이 해당 행의 컬럼명만 사용한다.
|
||||
*/
|
||||
private String renderRow(String blockContent, LinkedHashMap<String, StandardItem> row) {
|
||||
StringBuffer result = new StringBuffer();
|
||||
Matcher varMatcher = VAR_PATTERN.matcher(blockContent);
|
||||
while (varMatcher.find()) {
|
||||
String fieldName = varMatcher.group(1).trim();
|
||||
String value = "";
|
||||
StandardItem item = row.get(fieldName);
|
||||
if (item != null) {
|
||||
value = StringUtils.defaultString(item.getValue());
|
||||
}
|
||||
varMatcher.appendReplacement(result, Matcher.quoteReplacement(value));
|
||||
}
|
||||
varMatcher.appendTail(result);
|
||||
return result.toString().trim();
|
||||
}
|
||||
}
|
||||
+8
@@ -40,7 +40,9 @@ import com.eactive.eai.adapter.AdapterManager;
|
||||
import com.eactive.eai.adapter.Keys;
|
||||
import com.eactive.eai.adapter.http.secure.HttpClient5SSLContextFactory;
|
||||
import com.eactive.eai.common.TransactionContextKeys;
|
||||
import com.eactive.eai.common.message.EAIMessageKeys;
|
||||
import com.eactive.eai.common.property.PropManager;
|
||||
import com.eactive.eai.common.server.EAIServerManager;
|
||||
import com.eactive.eai.common.util.HttpAdapterExtraLogUtil;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
import com.eactive.eai.httpouttlsinfo.HttpOutTlsInfoVO;
|
||||
@@ -566,4 +568,10 @@ public abstract class HttpClient5AdapterServiceSupport implements HttpClientAdap
|
||||
}
|
||||
|
||||
public abstract Object execute(Properties prop, Object message, Properties tempProp) throws Exception;
|
||||
|
||||
protected boolean isTas(Properties tempProp) {
|
||||
String simYn = tempProp.getProperty("SIM_YN");
|
||||
return EAIServerManager.getInstance().isTASEnabledEAIServer()
|
||||
&& StringUtils.equals(simYn, EAIMessageKeys.TRANTYPE_TAS);
|
||||
}
|
||||
}
|
||||
+2
-5
@@ -41,14 +41,11 @@ public class HttpClient5AdapterServiceBody extends HttpClient5AdapterServiceSupp
|
||||
boolean useForwardProxy = StringUtils.equalsIgnoreCase(prop.getProperty("FORWARD_PROXY_USE_YN", "N"), "Y");
|
||||
String forwardProxyUrl = prop.getProperty("FORWARD_PROXY_URL");
|
||||
|
||||
if(useForwardProxy) {
|
||||
if(useForwardProxy && !isTas(tempProp)) {
|
||||
java.net.URL url = new java.net.URL(forwardProxyUrl);
|
||||
|
||||
// 프로토콜, 호스트, 포트 추출
|
||||
String protocol = url.getProtocol();
|
||||
String host = url.getHost();
|
||||
int port = url.getPort();
|
||||
HttpHost proxy = new HttpHost(protocol,host, port);
|
||||
HttpHost proxy = new HttpHost(url.getProtocol(), url.getHost(), url.getPort());
|
||||
requestConfigBuilder.setProxy(proxy);
|
||||
}
|
||||
|
||||
|
||||
+15
-10
@@ -11,6 +11,7 @@ import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -294,14 +295,11 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
|
||||
|
||||
RequestConfig.Builder requestConfigBuilder = RequestConfig.custom();
|
||||
|
||||
if(useForwardProxy) {
|
||||
if(useForwardProxy && !isTas(tempProp)) {
|
||||
java.net.URL url = new java.net.URL(forwardProxyUrl);
|
||||
|
||||
// 프로토콜, 호스트, 포트 추출
|
||||
String protocol = url.getProtocol();
|
||||
String host = url.getHost();
|
||||
int port = url.getPort();
|
||||
HttpHost proxy = new HttpHost(protocol,host, port);
|
||||
HttpHost proxy = new HttpHost(url.getProtocol(), url.getHost(), url.getPort());
|
||||
requestConfigBuilder.setProxy(proxy);
|
||||
}
|
||||
|
||||
@@ -708,12 +706,19 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
|
||||
return new String(responseMessage, encode);
|
||||
}
|
||||
|
||||
String[] relayKeyArr = org.springframework.util.StringUtils.tokenizeToStringArray(relayHeaderKeys, ",");
|
||||
JSONObject headerJson = new JSONObject();
|
||||
for (String key : relayKeyArr) {
|
||||
String value = responseHeaderProp.getProperty(key);
|
||||
if (StringUtils.isNotBlank(value)) {
|
||||
headerJson.put(key, value);
|
||||
if (StringUtils.equalsIgnoreCase(relayHeaderKeys, "ALL")) {
|
||||
for (Enumeration<Object> e = responseHeaderProp.keys(); e.hasMoreElements(); ) {
|
||||
String key = (String)e.nextElement();
|
||||
headerJson.put(StringUtils.lowerCase(key), responseHeaderProp.getProperty(key));
|
||||
}
|
||||
} else {
|
||||
String[] relayKeyArr = org.springframework.util.StringUtils.tokenizeToStringArray(relayHeaderKeys, ",");
|
||||
for (String key : relayKeyArr) {
|
||||
String value = responseHeaderProp.getProperty(key);
|
||||
if (StringUtils.isNotBlank(value)) {
|
||||
headerJson.put(StringUtils.lowerCase(key), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.eactive.eai.agent.command.Command;
|
||||
import com.eactive.eai.agent.command.CommandException;
|
||||
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
|
||||
import com.eactive.eai.common.inflow.InflowControlUtil;
|
||||
import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
|
||||
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
|
||||
public class ReloadInflowClientControlCommand extends Command {
|
||||
@@ -31,10 +31,10 @@ public class ReloadInflowClientControlCommand extends Command {
|
||||
String keyName = (String) args;
|
||||
try {
|
||||
AbstractInflowControlManager base = InflowControlUtil.getInflowControlManager();
|
||||
if (!(base instanceof DualInflowControlManager)) {
|
||||
throw new IllegalStateException("DualInflowControlManager 가 활성화되지 않았습니다.");
|
||||
if (!(base instanceof ClientDualInflowControlManager)) {
|
||||
throw new IllegalStateException("ClientDualInflowControlManager 가 활성화되지 않았습니다.");
|
||||
}
|
||||
DualInflowControlManager manager = (DualInflowControlManager) base;
|
||||
ClientDualInflowControlManager manager = (ClientDualInflowControlManager) base;
|
||||
|
||||
if (keyName != null) {
|
||||
if ("ALL".equals(keyName)) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.eactive.eai.agent.command.Command;
|
||||
import com.eactive.eai.agent.command.CommandException;
|
||||
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
|
||||
import com.eactive.eai.common.inflow.InflowControlUtil;
|
||||
import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
|
||||
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
|
||||
public class RemoveInflowClientControlCommand extends Command {
|
||||
@@ -31,10 +31,10 @@ public class RemoveInflowClientControlCommand extends Command {
|
||||
String key = (String) args;
|
||||
try {
|
||||
AbstractInflowControlManager base = InflowControlUtil.getInflowControlManager();
|
||||
if (!(base instanceof DualInflowControlManager)) {
|
||||
throw new IllegalStateException("DualInflowControlManager 가 활성화되지 않았습니다.");
|
||||
if (!(base instanceof ClientDualInflowControlManager)) {
|
||||
throw new IllegalStateException("ClientDualInflowControlManager 가 활성화되지 않았습니다.");
|
||||
}
|
||||
DualInflowControlManager manager = (DualInflowControlManager) base;
|
||||
ClientDualInflowControlManager manager = (ClientDualInflowControlManager) base;
|
||||
manager.removeClient(key);
|
||||
if (logger.isWarn())
|
||||
logger.warn(this.name + "] " + key + " removed.");
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.eactive.eai.common.util.Logger;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket4j;
|
||||
import io.github.bucket4j.Refill;
|
||||
import io.github.bucket4j.local.LocalBucket;
|
||||
import io.github.bucket4j.local.LocalBucketBuilder;
|
||||
|
||||
@@ -345,12 +346,14 @@ public abstract class AbstractInflowControlManager implements Lifecycle, Bucket
|
||||
if (AbstractInflowControlManager.isInflowTarget(inflowTarget)) {
|
||||
LocalBucketBuilder builder = Bucket4j.builder();
|
||||
if (inflowTarget.getThresholdPerSecond() > 0) {
|
||||
builder.addLimit(Bandwidth.simple(inflowTarget.getThresholdPerSecond(), Duration.ofSeconds(1)));
|
||||
builder.addLimit(Bandwidth.classic(inflowTarget.getThresholdPerSecond(),
|
||||
Refill.greedy(inflowTarget.getThresholdPerSecond(), Duration.ofSeconds(1))));
|
||||
}
|
||||
|
||||
if (inflowTarget.getThreshold() > 0 && StringUtils.isNotBlank(inflowTarget.getThresholdTimeUnit())) {
|
||||
builder.addLimit(Bandwidth.simple(inflowTarget.getThreshold(),
|
||||
AbstractInflowControlManager.getPeriod(inflowTarget.getThresholdTimeUnit())));
|
||||
builder.addLimit(Bandwidth.classic(inflowTarget.getThreshold(),
|
||||
Refill.intervally(inflowTarget.getThreshold(),
|
||||
AbstractInflowControlManager.getPeriod(inflowTarget.getThresholdTimeUnit()))));
|
||||
}
|
||||
|
||||
return new CustomBucket(builder.build(), inflowTarget);
|
||||
@@ -369,14 +372,16 @@ public abstract class AbstractInflowControlManager implements Lifecycle, Bucket
|
||||
|
||||
if (groupVo.getThresholdPerSecond() > 0) {
|
||||
perSecondBucket = Bucket4j.builder()
|
||||
.addLimit(Bandwidth.simple(groupVo.getThresholdPerSecond(), Duration.ofSeconds(1)))
|
||||
.addLimit(Bandwidth.classic(groupVo.getThresholdPerSecond(),
|
||||
Refill.greedy(groupVo.getThresholdPerSecond(), Duration.ofSeconds(1))))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (groupVo.getThreshold() > 0 && StringUtils.isNotBlank(groupVo.getThresholdTimeUnit())) {
|
||||
thresholdBucket = Bucket4j.builder()
|
||||
.addLimit(Bandwidth.simple(groupVo.getThreshold(),
|
||||
AbstractInflowControlManager.getPeriod(groupVo.getThresholdTimeUnit())))
|
||||
.addLimit(Bandwidth.classic(groupVo.getThreshold(),
|
||||
Refill.intervally(groupVo.getThreshold(),
|
||||
AbstractInflowControlManager.getPeriod(groupVo.getThresholdTimeUnit()))))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.eactive.eai.common.inflow.dual;
|
||||
|
||||
import com.eactive.eai.common.inflow.InflowTargetVO;
|
||||
|
||||
/**
|
||||
* DualBucket에 클라이언트 유량제어 메서드를 추가한 인터페이스.
|
||||
* {@link ClientDualInflowControlManager}가 구현한다.
|
||||
*/
|
||||
public interface ClientDualBucket extends DualBucket {
|
||||
|
||||
/**
|
||||
* 클라이언트 유량제어 체크 — 차단 원인 포함.
|
||||
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
|
||||
*/
|
||||
String isClientPassDetail(String clientId);
|
||||
|
||||
/**
|
||||
* 클라이언트 유량제어 설정 조회 — 에러 메시지 생성용.
|
||||
* @return 등록된 설정이 없으면 null
|
||||
*/
|
||||
InflowTargetVO getClientInflowThreshold(String clientId);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.eactive.eai.common.inflow.dual;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.eactive.eai.common.inflow.Bucket;
|
||||
import com.eactive.eai.common.inflow.InflowTargetVO;
|
||||
import com.eactive.eai.common.inflow.InflowType;
|
||||
import com.eactive.eai.common.lifecycle.LifecycleException;
|
||||
import com.eactive.eai.common.util.ApplicationContextProvider;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
|
||||
/**
|
||||
* 어댑터/인터페이스 이중 버킷(DualInflowControlManager)에 클라이언트 유량제어를 추가한 관리자.
|
||||
*
|
||||
* <p>적용 방법: INFLOW.properties에 아래 한 줄 추가.
|
||||
* <pre>inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager</pre>
|
||||
*/
|
||||
@Component
|
||||
public class ClientDualInflowControlManager extends DualInflowControlManager implements ClientDualBucket {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
|
||||
|
||||
public static synchronized Bucket getBucket() {
|
||||
return ApplicationContextProvider.getContext().getBean(ClientDualInflowControlManager.class);
|
||||
}
|
||||
|
||||
private volatile Map<String, DualCustomBucket> clientBucketMap = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, TargetMetrics> clientMetricsMap = new ConcurrentHashMap<>();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void start() throws LifecycleException {
|
||||
super.start();
|
||||
try {
|
||||
initClients();
|
||||
} catch (Exception e) {
|
||||
throw new LifecycleException("ClientDualInflowControlManager init failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 초기화 / 리로드
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void initClients() throws Exception {
|
||||
this.clientBucketMap = loadBucketMap(InflowType.CLIENT);
|
||||
if (logger.isInfo()) logger.info("ClientDualInflowControlManager] initClients: " + clientBucketMap.size() + " buckets");
|
||||
}
|
||||
|
||||
public synchronized void reloadClient() throws Exception {
|
||||
initClients();
|
||||
clientMetricsMap.clear();
|
||||
}
|
||||
|
||||
public synchronized void reloadClient(String clientId) throws Exception {
|
||||
reloadBucketMap(clientBucketMap, InflowType.CLIENT, clientId);
|
||||
TargetMetrics m = clientMetricsMap.get(clientId);
|
||||
if (m != null) m.reset();
|
||||
}
|
||||
|
||||
public synchronized void removeClient(String clientId) {
|
||||
clientBucketMap.remove(clientId);
|
||||
clientMetricsMap.remove(clientId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 클라이언트 메트릭 접근자
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public TargetMetrics getClientMetrics(String clientId) { return clientMetricsMap.get(clientId); }
|
||||
public Map<String, TargetMetrics> getAllClientMetrics() { return Collections.unmodifiableMap(clientMetricsMap); }
|
||||
public void resetClientMetrics(String clientId) { TargetMetrics m = clientMetricsMap.get(clientId); if (m != null) m.reset(); }
|
||||
public void resetAllClientMetrics() { clientMetricsMap.values().forEach(TargetMetrics::reset); }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ClientDualBucket — 클라이언트 유량제어
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public String isClientPassDetail(String clientId) {
|
||||
DualCustomBucket b = clientBucketMap.get(clientId);
|
||||
if (b == null) return DualCustomBucket.RESULT_PASS;
|
||||
String result = b.tryConsume();
|
||||
recordMetrics(clientMetricsMap, clientId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InflowTargetVO getClientInflowThreshold(String clientId) {
|
||||
DualCustomBucket b = clientBucketMap.get(clientId);
|
||||
return (b == null) ? null : b.getInflowTargetVo();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 모니터링용 접근자
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public DualCustomBucket getClientBucket(String clientId) {
|
||||
return clientBucketMap.get(clientId);
|
||||
}
|
||||
|
||||
public Map<String, DualCustomBucket> getClientBucketMap() {
|
||||
return clientBucketMap;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package com.eactive.eai.common.inflow.dual;
|
||||
|
||||
import com.eactive.eai.common.inflow.Bucket;
|
||||
import com.eactive.eai.common.inflow.InflowTargetVO;
|
||||
|
||||
/**
|
||||
* Bucket 인터페이스 확장.
|
||||
* 기존 isAdapterPass/isInterfacePass(boolean)에 더해
|
||||
* 어느 버킷(PER_SECOND/THRESHOLD)에서 차단됐는지 반환하는 메서드 추가.
|
||||
* 클라이언트별 유량제어 메서드도 포함.
|
||||
*
|
||||
* <p>클라이언트 유량제어는 {@link ClientDualBucket}으로 분리됨.
|
||||
*/
|
||||
public interface DualBucket extends Bucket {
|
||||
|
||||
@@ -22,16 +22,4 @@ public interface DualBucket extends Bucket {
|
||||
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
|
||||
*/
|
||||
String isInterfacePassDetail(String inter);
|
||||
|
||||
/**
|
||||
* 클라이언트 유량제어 체크 — 차단 원인 포함.
|
||||
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
|
||||
*/
|
||||
String isClientPassDetail(String clientId);
|
||||
|
||||
/**
|
||||
* 클라이언트 유량제어 설정 조회 — 에러 메시지 생성용.
|
||||
* @return 등록된 설정이 없으면 null
|
||||
*/
|
||||
InflowTargetVO getClientInflowThreshold(String clientId);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
|
||||
import com.eactive.eai.common.inflow.Bucket;
|
||||
@@ -22,21 +21,17 @@ import com.eactive.eai.common.util.Logger;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket4j;
|
||||
import io.github.bucket4j.Refill;
|
||||
import io.github.bucket4j.local.LocalBucket;
|
||||
|
||||
/**
|
||||
* 어댑터/인터페이스/클라이언트 버킷을 이중 구조(perSecond + threshold)로 관리하는 유량제어 관리자.
|
||||
* 어댑터/인터페이스 버킷을 이중 구조(perSecond + threshold)로 관리하는 유량제어 관리자.
|
||||
*
|
||||
* <p>기존 InflowControlManager는 어댑터/인터페이스에 단일 LocalBucket(복수 Bandwidth)을 사용하여
|
||||
* 차단 시 어느 한도(초당/기간)를 초과했는지 알 수 없었다.
|
||||
* 이 클래스는 그룹 버킷(CustomGroupBucket)과 동일하게 두 버킷을 분리하고
|
||||
* {@link DualBucket#isAdapterPassDetail}/{@link DualBucket#isInterfacePassDetail}로
|
||||
* 차단 원인을 반환한다. 그룹 버킷 통과 여부는 {@link TargetMetrics}로 카운팅된다.
|
||||
* <p>클라이언트 유량제어는 {@link ClientDualInflowControlManager}로 분리됨.
|
||||
*
|
||||
* <p>적용 방법: INFLOW.properties에 아래 한 줄 추가.
|
||||
* <pre>inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.DualInflowControlManager</pre>
|
||||
* <pre>inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager</pre>
|
||||
*/
|
||||
@Component
|
||||
public class DualInflowControlManager extends AbstractInflowControlManager implements DualBucket {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
|
||||
@@ -48,11 +43,9 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
|
||||
|
||||
private volatile Map<String, DualCustomBucket> adapterBucketMap = new ConcurrentHashMap<>();
|
||||
private volatile Map<String, DualCustomBucket> interfaceBucketMap = new ConcurrentHashMap<>();
|
||||
private volatile Map<String, DualCustomBucket> clientBucketMap = new ConcurrentHashMap<>();
|
||||
|
||||
private final ConcurrentHashMap<String, TargetMetrics> adapterMetricsMap = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, TargetMetrics> interfaceMetricsMap = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, TargetMetrics> clientMetricsMap = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, TargetMetrics> groupMetricsMap = new ConcurrentHashMap<>();
|
||||
|
||||
public static class TargetMetrics {
|
||||
@@ -79,7 +72,6 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
|
||||
try {
|
||||
initAdapters();
|
||||
initInterfaces();
|
||||
initClients();
|
||||
} catch (Exception e) {
|
||||
throw new LifecycleException("DualInflowControlManager init failed: " + e.getMessage());
|
||||
}
|
||||
@@ -99,12 +91,7 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
|
||||
if (logger.isInfo()) logger.info("DualInflowControlManager] initInterfaces: " + interfaceBucketMap.size() + " buckets");
|
||||
}
|
||||
|
||||
private void initClients() throws Exception {
|
||||
this.clientBucketMap = loadBucketMap(InflowType.CLIENT);
|
||||
if (logger.isInfo()) logger.info("DualInflowControlManager] initClients: " + clientBucketMap.size() + " buckets");
|
||||
}
|
||||
|
||||
private Map<String, DualCustomBucket> loadBucketMap(InflowType type) throws Exception {
|
||||
protected Map<String, DualCustomBucket> loadBucketMap(InflowType type) throws Exception {
|
||||
Map<String, DualCustomBucket> newMap = new HashMap<>();
|
||||
for (InflowTargetVO vo : inflowControlDAO.getInflowList(type)) {
|
||||
DualCustomBucket bucket = makeBucket(vo);
|
||||
@@ -139,22 +126,6 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
|
||||
if (m != null) m.reset();
|
||||
}
|
||||
|
||||
public synchronized void reloadClient() throws Exception {
|
||||
initClients();
|
||||
clientMetricsMap.clear();
|
||||
}
|
||||
|
||||
public synchronized void reloadClient(String clientId) throws Exception {
|
||||
reloadBucketMap(clientBucketMap, InflowType.CLIENT, clientId);
|
||||
TargetMetrics m = clientMetricsMap.get(clientId);
|
||||
if (m != null) m.reset();
|
||||
}
|
||||
|
||||
public synchronized void removeClient(String clientId) {
|
||||
clientBucketMap.remove(clientId);
|
||||
clientMetricsMap.remove(clientId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void reloadGroup() throws Exception {
|
||||
super.reloadGroup();
|
||||
@@ -168,7 +139,7 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
|
||||
if (m != null) m.reset();
|
||||
}
|
||||
|
||||
private void reloadBucketMap(Map<String, DualCustomBucket> targetMap, InflowType type, String name) throws Exception {
|
||||
protected void reloadBucketMap(Map<String, DualCustomBucket> targetMap, InflowType type, String name) throws Exception {
|
||||
Map<String, InflowTargetVO> updated = inflowControlDAO.getInflowMap(type, name);
|
||||
if (updated == null) return;
|
||||
for (InflowTargetVO vo : updated.values()) {
|
||||
@@ -179,24 +150,20 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 어댑터/인터페이스/클라이언트 메트릭 접근자
|
||||
// 어댑터/인터페이스 메트릭 접근자
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public TargetMetrics getAdapterMetrics(String adapter) { return adapterMetricsMap.get(adapter); }
|
||||
public TargetMetrics getInterfaceMetrics(String inter) { return interfaceMetricsMap.get(inter); }
|
||||
public TargetMetrics getClientMetrics(String clientId) { return clientMetricsMap.get(clientId); }
|
||||
|
||||
public Map<String, TargetMetrics> getAllAdapterMetrics() { return Collections.unmodifiableMap(adapterMetricsMap); }
|
||||
public Map<String, TargetMetrics> getAllInterfaceMetrics() { return Collections.unmodifiableMap(interfaceMetricsMap); }
|
||||
public Map<String, TargetMetrics> getAllClientMetrics() { return Collections.unmodifiableMap(clientMetricsMap); }
|
||||
|
||||
public void resetAdapterMetrics(String adapter) { TargetMetrics m = adapterMetricsMap.get(adapter); if (m != null) m.reset(); }
|
||||
public void resetInterfaceMetrics(String inter) { TargetMetrics m = interfaceMetricsMap.get(inter); if (m != null) m.reset(); }
|
||||
public void resetClientMetrics(String clientId) { TargetMetrics m = clientMetricsMap.get(clientId); if (m != null) m.reset(); }
|
||||
|
||||
public void resetAllAdapterMetrics() { adapterMetricsMap.values().forEach(TargetMetrics::reset); }
|
||||
public void resetAllInterfaceMetrics() { interfaceMetricsMap.values().forEach(TargetMetrics::reset); }
|
||||
public void resetAllClientMetrics() { clientMetricsMap.values().forEach(TargetMetrics::reset); }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 그룹 메트릭 — isGroupPass() 카운팅 및 접근자
|
||||
@@ -255,32 +222,15 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String isClientPassDetail(String clientId) {
|
||||
DualCustomBucket b = clientBucketMap.get(clientId);
|
||||
if (b == null) return DualCustomBucket.RESULT_PASS;
|
||||
String result = b.tryConsume();
|
||||
recordMetrics(clientMetricsMap, clientId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void recordMetrics(ConcurrentHashMap<String, TargetMetrics> metricsMap, String key, String result) {
|
||||
protected void recordMetrics(ConcurrentHashMap<String, TargetMetrics> metricsMap, String key, String result) {
|
||||
TargetMetrics m = metricsMap.computeIfAbsent(key, k -> new TargetMetrics());
|
||||
if (result == null) m.allowed.incrementAndGet();
|
||||
else if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(result)) m.rejectedPerSecond.incrementAndGet();
|
||||
else m.rejectedThreshold.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InflowTargetVO getClientInflowThreshold(String clientId) {
|
||||
DualCustomBucket b = clientBucketMap.get(clientId);
|
||||
return (b == null) ? null : b.getInflowTargetVo();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bucket 인터페이스 오버라이드 — 이중 버킷으로 교체 (boolean 반환 유지)
|
||||
// isAdapterPassDetail/isInterfacePassDetail이 토큰을 소비하므로
|
||||
// 두 메서드를 동일 요청에서 중복 호출하면 토큰이 이중 소비됨에 주의.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
@@ -295,7 +245,6 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 어댑터/인터페이스 키·VO·삭제 — Dual 맵 기준으로 오버라이드
|
||||
// (base의 adapterBucketList / interfaceBucketList 는 읽지 않아 DAO 이중 호출 제거)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
@@ -356,19 +305,16 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
|
||||
return interfaceBucketMap;
|
||||
}
|
||||
|
||||
public DualCustomBucket getClientBucket(String clientId) {
|
||||
return clientBucketMap.get(clientId);
|
||||
}
|
||||
|
||||
public Map<String, DualCustomBucket> getClientBucketMap() {
|
||||
return clientBucketMap;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 버킷 팩토리
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private DualCustomBucket makeBucket(InflowTargetVO vo) {
|
||||
/**
|
||||
* perSecond 버킷: greedy refill — 초당 N개 요청을 균등하게 허용.
|
||||
* threshold 버킷: intervally refill — 기간(시/일/월 등) 단위 쿼터를 기간 시작 시 일괄 충전.
|
||||
* Bandwidth.simple은 두 버킷 모두 greedy로 생성되어 시간 단위 구분이 없어지므로 classic으로 교체.
|
||||
*/
|
||||
protected DualCustomBucket makeBucket(InflowTargetVO vo) {
|
||||
if (!AbstractInflowControlManager.isInflowTarget(vo)) {
|
||||
return null;
|
||||
}
|
||||
@@ -378,14 +324,16 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
|
||||
|
||||
if (vo.getThresholdPerSecond() > 0) {
|
||||
perSecondBucket = Bucket4j.builder()
|
||||
.addLimit(Bandwidth.simple(vo.getThresholdPerSecond(), Duration.ofSeconds(1)))
|
||||
.addLimit(Bandwidth.classic(vo.getThresholdPerSecond(),
|
||||
Refill.greedy(vo.getThresholdPerSecond(), Duration.ofSeconds(1))))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (vo.getThreshold() > 0 && StringUtils.isNotBlank(vo.getThresholdTimeUnit())) {
|
||||
Duration period = AbstractInflowControlManager.getPeriod(vo.getThresholdTimeUnit());
|
||||
thresholdBucket = Bucket4j.builder()
|
||||
.addLimit(Bandwidth.simple(vo.getThreshold(),
|
||||
AbstractInflowControlManager.getPeriod(vo.getThresholdTimeUnit())))
|
||||
.addLimit(Bandwidth.classic(vo.getThreshold(),
|
||||
Refill.intervally(vo.getThreshold(), period)))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package com.eactive.eai.inbound.processor;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import com.eactive.eai.common.inflow.Bucket;
|
||||
import com.eactive.eai.common.inflow.InflowTargetVO;
|
||||
import com.eactive.eai.common.inflow.dual.DualBucket;
|
||||
import com.eactive.eai.common.inflow.dual.DualCustomBucket;
|
||||
|
||||
/**
|
||||
* 어댑터/인터페이스 유량제어 차단 시 어느 버킷(PER_SECOND/THRESHOLD)에서 차단됐는지
|
||||
* 에러 메시지에 포함하는 RequestProcessor 확장.
|
||||
*
|
||||
* <p>DualInflowControlManager와 함께 사용:
|
||||
* <pre>
|
||||
* inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.DualInflowControlManager
|
||||
* </pre>
|
||||
*
|
||||
* <p>Spring Bean 등록 후 기존 RequestProcessor 대신 이 클래스를 어댑터에 설정한다.
|
||||
*/
|
||||
public class DualRequestProcessor extends RequestProcessor {
|
||||
|
||||
@Override
|
||||
protected String checkClientInflow(Bucket bucket, String clientId) {
|
||||
if (StringUtils.isBlank(clientId)) return null;
|
||||
if (bucket instanceof DualBucket) {
|
||||
return ((DualBucket) bucket).isClientPassDetail(clientId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected InflowTargetVO resolveClientInflowThreshold(Bucket bucket, String clientId) {
|
||||
if (bucket instanceof DualBucket) {
|
||||
return ((DualBucket) bucket).getClientInflowThreshold(clientId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String checkAdapterInflow(Bucket bucket, String adapterGroupName) {
|
||||
if (bucket instanceof DualBucket) {
|
||||
return ((DualBucket) bucket).isAdapterPassDetail(adapterGroupName);
|
||||
}
|
||||
return super.checkAdapterInflow(bucket, adapterGroupName);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String checkInterfaceInflow(Bucket bucket, String eaiSvcCd) {
|
||||
if (bucket instanceof DualBucket) {
|
||||
return ((DualBucket) bucket).isInterfacePassDetail(eaiSvcCd);
|
||||
}
|
||||
return super.checkInterfaceInflow(bucket, eaiSvcCd);
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단 원인에 따라 에러 메시지에 한도 정보를 포함.
|
||||
* <ul>
|
||||
* <li>PER_SECOND: "API 호출 한도 초과 [name: Nreq/sec]"</li>
|
||||
* <li>THRESHOLD: "API 호출 한도 초과 [name: Nreq/UNIT]"</li>
|
||||
* <li>그 외: "API 호출 한도 초과 [name]" (기존 동작)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Override
|
||||
protected String buildInflowTargetErrorMsg(InflowTargetVO inflowTargetVO, String blockedType) {
|
||||
if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(blockedType)) {
|
||||
return String.format("API 호출 한도 초과 [%s: %dreq/sec]",
|
||||
inflowTargetVO.getName(),
|
||||
inflowTargetVO.getThresholdPerSecond());
|
||||
}
|
||||
if (DualCustomBucket.RESULT_BLOCKED_THRESHOLD.equals(blockedType)) {
|
||||
return String.format("API 호출 한도 초과 [%s: %dreq/%s]",
|
||||
inflowTargetVO.getName(),
|
||||
inflowTargetVO.getThreshold(),
|
||||
inflowTargetVO.getThresholdTimeUnit());
|
||||
}
|
||||
return super.buildInflowTargetErrorMsg(inflowTargetVO, blockedType);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import com.eactive.eai.adapter.AdapterPropManager;
|
||||
import com.eactive.eai.adapter.AdapterVO;
|
||||
import com.eactive.eai.adapter.http.HttpStatusException;
|
||||
import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey;
|
||||
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceKey;
|
||||
import com.eactive.eai.common.EAIKeys;
|
||||
import com.eactive.eai.common.exception.ExceptionUtil;
|
||||
import com.eactive.eai.common.header.HeaderAction;
|
||||
@@ -352,24 +353,26 @@ public class HTTPProcess extends DefaultProcess {
|
||||
this.tempProp.put(HttpClientAdapterServiceKey.INSTANCE_ID, instanceId);
|
||||
}
|
||||
|
||||
EAIServerManager eaiServerManager = EAIServerManager.getInstance();
|
||||
AdapterManager adapterManager = AdapterManager.getInstance();
|
||||
AdapterGroupVO adptGrpVO = adapterManager.getAdapterGroupVO(reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp());
|
||||
if (eaiServerManager.isTASEnabledEAIServer() && EAIMessageKeys.TRANTYPE_TAS.equals(reqEaiMsg.getTranType())
|
||||
// RESTAdapter는 교체하지 않는다
|
||||
&& !StringUtils.equals(adptGrpVO.getType(), com.eactive.eai.adapter.Keys.TYPE_REST)) {
|
||||
PropManager manager = PropManager.getInstance();
|
||||
Properties prop = manager.getProperties(RouteKeys.SIM);
|
||||
|
||||
if (reqEaiMsg.getCurrentSvcMsg().isSyncItfTp()) {
|
||||
this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_SYNC, "_SIM_OU_HTT_SyC");
|
||||
} else {
|
||||
this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_ASYNC, "_SIM_OU_HTT_AsC");
|
||||
}
|
||||
this.reqEaiMsg.getSvcMsg(0).setPsvSysItfTp(this.adapterGroupName);
|
||||
} else {
|
||||
this.adapterGroupName = reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp();
|
||||
}
|
||||
// EAIServerManager eaiServerManager = EAIServerManager.getInstance();
|
||||
// AdapterManager adapterManager = AdapterManager.getInstance();
|
||||
// AdapterGroupVO adptGrpVO = adapterManager.getAdapterGroupVO(reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp());
|
||||
// if (eaiServerManager.isTASEnabledEAIServer() && EAIMessageKeys.TRANTYPE_TAS.equals(reqEaiMsg.getTranType())
|
||||
// // RESTAdapter는 교체하지 않는다
|
||||
// && !StringUtils.equals(adptGrpVO.getType(), com.eactive.eai.adapter.Keys.TYPE_REST)) {
|
||||
// PropManager manager = PropManager.getInstance();
|
||||
// Properties prop = manager.getProperties(RouteKeys.SIM);
|
||||
//
|
||||
// if (reqEaiMsg.getCurrentSvcMsg().isSyncItfTp()) {
|
||||
// this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_SYNC, "_SIM_OU_HTT_SyC");
|
||||
// } else {
|
||||
// this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_ASYNC, "_SIM_OU_HTT_AsC");
|
||||
// }
|
||||
// this.reqEaiMsg.getSvcMsg(0).setPsvSysItfTp(this.adapterGroupName);
|
||||
// } else {
|
||||
// this.adapterGroupName = reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp();
|
||||
// }
|
||||
|
||||
this.adapterGroupName = reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp();
|
||||
|
||||
this.svcTsmtUsgTp = this.reqEaiMsg.getSvcTsmtUsgTp(); // Inbound 호출방식
|
||||
this.psvItfTp = this.reqEaiMsg.getCurrentSvcMsg().getPsvItfTp(); // 호출방식
|
||||
@@ -404,6 +407,7 @@ public class HTTPProcess extends DefaultProcess {
|
||||
}
|
||||
|
||||
// Inbound Adapter mesageType
|
||||
AdapterManager adapterManager = AdapterManager.getInstance();
|
||||
AdapterGroupVO inAdptGrpVO = adapterManager.getAdapterGroupVO(this.reqEaiMsg.getSngSysItfTp());
|
||||
this.inAdapterMsgType = MessageType.ASC;
|
||||
|
||||
@@ -592,6 +596,22 @@ public class HTTPProcess extends DefaultProcess {
|
||||
logger.debug(guidLogPrefix + " HTTP NONSTANDARD Request Message : "
|
||||
+ MessageUtil.toAdapterTypeString(this.reqObject, this.inboundCharset));
|
||||
}
|
||||
// TAS 선택시 시뮬레이터 url로 변경, HTT 어댑터도 RST 처럼 방식을 변경하였다.
|
||||
EAIServerManager eaiServerManager = EAIServerManager.getInstance();
|
||||
if (eaiServerManager.isTASEnabledEAIServer() && EAIMessageKeys.TRANTYPE_TAS.equals(reqEaiMsg.getTranType())) {
|
||||
PropManager manager = PropManager.getInstance();
|
||||
Properties prop = manager.getProperties(RouteKeys.SIM);
|
||||
String simAddr = prop.getProperty(RouteKeys.SIM_REST_MOCKSERVER);
|
||||
if (StringUtils.isBlank(simAddr)) {
|
||||
throw new Exception(String.format("Properties %s > %s not setted", RouteKeys.SIM,
|
||||
RouteKeys.SIM_REST_MOCKSERVER));
|
||||
}
|
||||
|
||||
String url = StringUtils.removeEnd(simAddr, "/") + "/mockapi/" + this.reqEaiMsg.getEAISvcCd();
|
||||
this.outboundProp.put(HttpClientAdapterServiceKey.URL, url);
|
||||
this.tempProp.remove(HttpAdapterServiceKey.INBOUND_REWRITE_PATH);
|
||||
logger.debug("simUrl=" + url);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
String[] msgArgs = new String[1];
|
||||
msgArgs[0] = this.reqEaiMsg.getEAISvcCd();
|
||||
|
||||
+725
@@ -0,0 +1,725 @@
|
||||
package com.eactive.eai.adapter.handler;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Properties;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
||||
import com.eactive.eai.common.property.PropManager;
|
||||
import com.eactive.eai.common.util.ApplicationContextProvider;
|
||||
import com.eactive.eai.message.StandardItem;
|
||||
import com.eactive.eai.message.StandardMessage;
|
||||
import com.eactive.eai.message.StandardType;
|
||||
|
||||
/**
|
||||
* TemplateAdapterErrorMsgHandler 단위테스트
|
||||
*
|
||||
* - render() : package-private 이므로 같은 패키지에서 직접 호출
|
||||
* - generateNonStandardErrorResponseMessage() : ApplicationContextProvider 에
|
||||
* mock ApplicationContext 를 리플렉션으로 주입하여 PropManager 격리
|
||||
*/
|
||||
class TemplateAdapterErrorMsgHandlerTest {
|
||||
|
||||
private TemplateAdapterErrorMsgHandler handler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
handler = new TemplateAdapterErrorMsgHandler();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// StandardMessage 빌더 헬퍼
|
||||
// ================================================================
|
||||
|
||||
/** FIELD 아이템 (type=2) */
|
||||
private static StandardItem field(String name, String value) {
|
||||
return new StandardItem(name, 3, StandardType.FIELD, 1, 0, 300, 1, "", "", value, name);
|
||||
}
|
||||
|
||||
/** GROUP 아이템 (type=3, size=1 → 활성) */
|
||||
private static StandardItem group(String name) {
|
||||
return new StandardItem(name, 2, StandardType.GROUP, 1, 0, 0, 1, "", "", null, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* DJB 표준전문 MSG 영역을 모사한 StandardMessage 생성.
|
||||
*
|
||||
* 구조:
|
||||
* MSG (GROUP)
|
||||
* MAIN_MSG (GROUP)
|
||||
* outp_msg_cd, outp_msg_ctnt, outp_msg_desc
|
||||
* MSG_LIST (GRID)
|
||||
* row[n]: outp_msg_cd, outp_msg_ctnt, outp_msg_desc, err_occu_item_nm
|
||||
*
|
||||
* @param listRows null 또는 빈 배열이면 MSG_LIST 행 없음
|
||||
*/
|
||||
private StandardMessage buildMsg(String errCode, String errMsg, String errDesc,
|
||||
String[][] listRows) {
|
||||
StandardMessage msg = new StandardMessage();
|
||||
|
||||
StandardItem msgGroup = group("MSG");
|
||||
|
||||
StandardItem mainMsg = group("MAIN_MSG");
|
||||
mainMsg.addItem(field("outp_msg_cd", errCode));
|
||||
mainMsg.addItem(field("outp_msg_ctnt", errMsg));
|
||||
mainMsg.addItem(field("outp_msg_desc", errDesc));
|
||||
msgGroup.addItem(mainMsg);
|
||||
|
||||
StandardItem msgList = new StandardItem(
|
||||
"MSG_LIST", 2, StandardType.GRID, 0, 0, 0, 1, "", "", null, "MSG_LIST");
|
||||
msgList.addItem(field("outp_msg_cd", ""));
|
||||
msgList.addItem(field("outp_msg_ctnt", ""));
|
||||
msgList.addItem(field("outp_msg_desc", ""));
|
||||
msgList.addItem(field("err_occu_item_nm", ""));
|
||||
|
||||
if (listRows != null) {
|
||||
for (String[] row : listRows) {
|
||||
LinkedHashMap<String, StandardItem> rowMap = new LinkedHashMap<>();
|
||||
rowMap.put("outp_msg_cd", field("outp_msg_cd", get(row, 0)));
|
||||
rowMap.put("outp_msg_ctnt", field("outp_msg_ctnt", get(row, 1)));
|
||||
rowMap.put("outp_msg_desc", field("outp_msg_desc", get(row, 2)));
|
||||
rowMap.put("err_occu_item_nm", field("err_occu_item_nm", get(row, 3)));
|
||||
msgList.addArray(rowMap);
|
||||
}
|
||||
}
|
||||
msgGroup.addItem(msgList);
|
||||
msg.addItem(msgGroup);
|
||||
return msg;
|
||||
}
|
||||
|
||||
private static String get(String[] arr, int idx) {
|
||||
return (arr != null && arr.length > idx) ? arr[idx] : "";
|
||||
}
|
||||
|
||||
private static Properties props(String... keyValues) {
|
||||
Properties p = new Properties();
|
||||
for (int i = 0; i < keyValues.length - 1; i += 2) {
|
||||
p.setProperty(keyValues[i], keyValues[i + 1]);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 스칼라 변수 치환 ${path}
|
||||
// ================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("StandardMessage 스칼라 변수 치환 ${path}")
|
||||
class ScalarSubstitution {
|
||||
|
||||
@Test
|
||||
@DisplayName("단순 JSON 템플릿 치환")
|
||||
void 단순_치환() {
|
||||
StandardMessage msg = buildMsg("E001", "오류발생", "상세설명", null);
|
||||
String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"msg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"}";
|
||||
|
||||
String result = handler.render(template, msg, null);
|
||||
|
||||
assertEquals("{\"code\":\"E001\",\"msg\":\"오류발생\"}", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("동일 변수 중복 치환")
|
||||
void 동일_변수_중복_치환() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
String template = "${MSG.MAIN_MSG.outp_msg_cd}/${MSG.MAIN_MSG.outp_msg_cd}";
|
||||
|
||||
assertEquals("E001/E001", handler.render(template, msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("존재하지 않는 경로는 빈 문자열로 치환")
|
||||
void 없는_경로_빈_문자열() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
|
||||
assertEquals("", handler.render("${NONE.PATH}", msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("변수 없는 템플릿은 원본 그대로 반환")
|
||||
void 변수_없는_템플릿_원본() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
|
||||
assertEquals("{\"static\":\"value\"}", handler.render("{\"static\":\"value\"}", msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("outp_msg_desc 필드 치환")
|
||||
void outp_msg_desc_치환() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "상세설명입니다", null);
|
||||
|
||||
assertEquals("상세설명입니다", handler.render("${MSG.MAIN_MSG.outp_msg_desc}", msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("경로:기본값 – 경로 존재 시 값 반환")
|
||||
void 표준전문_기본값_경로존재() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
|
||||
assertEquals("E001", handler.render("${MSG.MAIN_MSG.outp_msg_cd:DEFAULT}", msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("경로:기본값 – 경로 없으면 기본값 반환")
|
||||
void 표준전문_기본값_경로없음() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
|
||||
assertEquals("UNKNOWN", handler.render("${NONE.PATH:UNKNOWN}", msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("경로:기본값 – 기본값이 빈 문자열")
|
||||
void 표준전문_기본값_빈문자열() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
|
||||
assertEquals("", handler.render("${NONE.PATH:}", msg, null));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// callProp 변수 치환 ${callprop.키}
|
||||
// ================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("callProp 변수 치환 ${callprop.키}")
|
||||
class CallPropSubstitution {
|
||||
|
||||
@Test
|
||||
@DisplayName("callProp 값을 템플릿에서 참조")
|
||||
void callProp_단순_참조() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER", "IF_ID", "SVC001");
|
||||
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop.IF_ID}\"}";
|
||||
|
||||
String result = handler.render(template, msg, callProp);
|
||||
|
||||
assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC001\"}", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callProp 과 StandardMessage 혼합 참조")
|
||||
void callProp_stdmsg_혼합() {
|
||||
StandardMessage msg = buildMsg("E001", "오류발생", "", null);
|
||||
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
|
||||
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}";
|
||||
|
||||
String result = handler.render(template, msg, callProp);
|
||||
|
||||
assertEquals("{\"adapter\":\"MY_ADAPTER\",\"code\":\"E001\"}", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callProp 에 없는 키는 빈 문자열")
|
||||
void callProp_없는_키_빈문자열() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
Properties callProp = new Properties();
|
||||
|
||||
assertEquals("", handler.render("${callprop.NONE_KEY}", msg, callProp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callProp 이 null 이면 빈 문자열")
|
||||
void callProp_null_빈문자열() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
|
||||
assertEquals("", handler.render("${callprop.ADAPTER_NAME}", msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callProp 값이 Properties 인 경우 중첩 탐색")
|
||||
void callProp_중첩_Properties() {
|
||||
Properties sub = new Properties();
|
||||
sub.setProperty("IF_ID", "SVC001");
|
||||
sub.setProperty("SVC_NM", "조회서비스");
|
||||
|
||||
Properties callProp = new Properties();
|
||||
callProp.put("OUTBOUND", sub);
|
||||
callProp.setProperty("ADAPTER_NAME", "MY_ADAPTER");
|
||||
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop.OUTBOUND.IF_ID}\",\"svcNm\":\"${callprop.OUTBOUND.SVC_NM}\"}";
|
||||
|
||||
String result = handler.render(template, msg, callProp);
|
||||
|
||||
assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC001\",\"svcNm\":\"조회서비스\"}", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("중첩 Properties 3단계 탐색")
|
||||
void callProp_3단계_중첩() {
|
||||
Properties level2 = new Properties();
|
||||
level2.setProperty("CODE", "L2CODE");
|
||||
|
||||
Properties level1 = new Properties();
|
||||
level1.put("LEVEL2", level2);
|
||||
|
||||
Properties callProp = new Properties();
|
||||
callProp.put("LEVEL1", level1);
|
||||
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
|
||||
assertEquals("L2CODE", handler.render("${callprop.LEVEL1.LEVEL2.CODE}", msg, callProp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("중간 값이 Properties 가 아니면 전체 keyPath 로 폴백 조회")
|
||||
void callProp_중간값_비Properties_폴백() {
|
||||
Properties callProp = new Properties();
|
||||
callProp.setProperty("A.B", "FALLBACK_VALUE"); // 점 포함 키
|
||||
callProp.setProperty("A", "NOT_PROPS"); // String 값
|
||||
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
|
||||
// A 는 String 이므로 Properties 가 아님 → "A.B" 전체 키로 폴백
|
||||
assertEquals("FALLBACK_VALUE", handler.render("${callprop.A.B}", msg, callProp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("중첩 Properties 에서 없는 키는 빈 문자열")
|
||||
void callProp_중첩_없는키() {
|
||||
Properties sub = new Properties();
|
||||
sub.setProperty("EXIST", "VALUE");
|
||||
|
||||
Properties callProp = new Properties();
|
||||
callProp.put("SUB", sub);
|
||||
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
|
||||
assertEquals("", handler.render("${callprop.SUB.NONE_KEY}", msg, callProp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callprop.키:기본값 – 키 존재 시 값 반환")
|
||||
void callProp_직접_기본값_키존재() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
|
||||
|
||||
assertEquals("MY_ADAPTER", handler.render("${callprop.ADAPTER_NAME:DEFAULT}", msg, callProp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callprop.키:기본값 – 키 없으면 기본값 반환")
|
||||
void callProp_직접_기본값_키없음() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
Properties callProp = new Properties();
|
||||
|
||||
assertEquals("DEFAULT_VAL", handler.render("${callprop.NONE_KEY:DEFAULT_VAL}", msg, callProp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callprop.키:기본값 – callProp null 이면 기본값 반환")
|
||||
void callProp_직접_기본값_null() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
|
||||
assertEquals("FALLBACK", handler.render("${callprop.ADAPTER_NAME:FALLBACK}", msg, null));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// callProp 간접 참조 ${callprop[표준전문경로]} / ${callprop[경로]:기본값}
|
||||
// ================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("callProp 간접 참조 ${callprop[경로]}")
|
||||
class IndirectCallPropSubstitution {
|
||||
|
||||
/** 표준전문에 cp_key 필드가 있는 메시지 */
|
||||
private StandardMessage buildMsgWithKey(String cpKey, String errCode) {
|
||||
StandardMessage msg = buildMsg(errCode, "오류", "", null);
|
||||
// MSG 그룹 아래 cp_key 필드 추가
|
||||
StandardItem msgGroup = msg.findItem("MSG");
|
||||
if (msgGroup != null) {
|
||||
msgGroup.addItem(field("cp_key", cpKey));
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("표준전문 경로 값으로 callProp 키를 결정해 값 조회")
|
||||
void 간접_참조_정상() {
|
||||
StandardMessage msg = buildMsgWithKey("ADAPTER_NAME", "E001");
|
||||
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
|
||||
String template = "${callprop[MSG.cp_key]}";
|
||||
|
||||
assertEquals("MY_ADAPTER", handler.render(template, msg, callProp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("표준전문 경로 값이 없으면 기본값 반환")
|
||||
void 표준전문_경로_없으면_기본값() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
|
||||
String template = "${callprop[MSG.NONE_KEY]:DEFAULT_ADAPTER}";
|
||||
|
||||
assertEquals("DEFAULT_ADAPTER", handler.render(template, msg, callProp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callProp 에 해당 키 없으면 기본값 반환")
|
||||
void callProp_키_없으면_기본값() {
|
||||
StandardMessage msg = buildMsgWithKey("MISSING_KEY", "E001");
|
||||
Properties callProp = props("OTHER_KEY", "OTHER_VALUE");
|
||||
String template = "${callprop[MSG.cp_key]:FALLBACK}";
|
||||
|
||||
assertEquals("FALLBACK", handler.render(template, msg, callProp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기본값 미지정 시 빈 문자열")
|
||||
void 기본값_없으면_빈문자열() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
|
||||
String template = "${callprop[MSG.NONE_KEY]}";
|
||||
|
||||
assertEquals("", handler.render(template, msg, callProp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callProp 이 null 이면 기본값 반환")
|
||||
void callProp_null_기본값() {
|
||||
StandardMessage msg = buildMsgWithKey("ADAPTER_NAME", "E001");
|
||||
String template = "${callprop[MSG.cp_key]:NO_PROP}";
|
||||
|
||||
assertEquals("NO_PROP", handler.render(template, msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기본값이 빈 문자열인 경우")
|
||||
void 기본값이_빈문자열() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
Properties callProp = new Properties();
|
||||
String template = "${callprop[MSG.NONE_KEY]:}";
|
||||
|
||||
assertEquals("", handler.render(template, msg, callProp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("간접 참조와 직접 참조 혼합")
|
||||
void 간접_직접_혼합() {
|
||||
StandardMessage msg = buildMsgWithKey("IF_ID_KEY", "E001");
|
||||
Properties callProp = props("IF_ID_KEY", "SVC_001", "ADAPTER_NAME", "MY_ADAPTER");
|
||||
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop[MSG.cp_key]}\"}";
|
||||
|
||||
assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC_001\"}", handler.render(template, msg, callProp));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 배열 반복 {{#foreach path}}...{{/foreach}}
|
||||
// ================================================================
|
||||
|
||||
// ================================================================
|
||||
// DATA 영역 변수 치환 ${DATA.xxx}
|
||||
// ================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("DATA 영역 변수 치환 ${DATA.xxx}")
|
||||
class DataSectionSubstitution {
|
||||
|
||||
/**
|
||||
* DATA > BIZDATA(FIELD, JSON String value) 구조의 StandardMessage 생성.
|
||||
* BIZDATA 는 파싱 없이 JSON 문자열 그대로 세팅되는 형태.
|
||||
*/
|
||||
private StandardMessage buildMsgWithBizData(String bizDataJson) {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
|
||||
StandardItem dataGroup = group("DATA");
|
||||
dataGroup.addItem(field("BIZDATA", bizDataJson));
|
||||
msg.addItem(dataGroup);
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DATA.BIZDATA JSON 필드 치환 – 단일 depth")
|
||||
void DATA_BIZDATA_JSON_필드_치환() {
|
||||
StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\",\"custNm\":\"홍길동\"}");
|
||||
String template = "{\"acctNo\":\"${DATA.BIZDATA.acctNo}\",\"custNm\":\"${DATA.BIZDATA.custNm}\"}";
|
||||
|
||||
assertEquals("{\"acctNo\":\"1234567890\",\"custNm\":\"홍길동\"}",
|
||||
handler.render(template, msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DATA.BIZDATA JSON 필드 치환 – 중첩 depth")
|
||||
void DATA_BIZDATA_JSON_중첩_필드_치환() {
|
||||
StandardMessage msg = buildMsgWithBizData("{\"addr\":{\"city\":\"서울\",\"zip\":\"12345\"}}");
|
||||
|
||||
assertEquals("서울", handler.render("${DATA.BIZDATA.addr.city}", msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DATA.BIZDATA 원본 JSON 문자열 치환")
|
||||
void DATA_BIZDATA_원본_치환() {
|
||||
String rawJson = "{\"acctNo\":\"1234567890\"}";
|
||||
StandardMessage msg = buildMsgWithBizData(rawJson);
|
||||
|
||||
assertEquals(rawJson, handler.render("${DATA.BIZDATA}", msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DATA.BIZDATA 와 MSG 영역 혼합 치환")
|
||||
void DATA_MSG_혼합_치환() {
|
||||
StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"9876543210\"}");
|
||||
String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"acctNo\":\"${DATA.BIZDATA.acctNo}\"}";
|
||||
|
||||
assertEquals("{\"code\":\"E001\",\"acctNo\":\"9876543210\"}",
|
||||
handler.render(template, msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DATA.BIZDATA JSON 에 없는 필드는 빈 문자열")
|
||||
void DATA_BIZDATA_없는_JSON_필드_빈문자열() {
|
||||
StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\"}");
|
||||
|
||||
assertEquals("", handler.render("${DATA.BIZDATA.noneField}", msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DATA.BIZDATA JSON 에 없는 필드에 기본값 지정")
|
||||
void DATA_BIZDATA_없는_JSON_필드_기본값() {
|
||||
StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\"}");
|
||||
|
||||
assertEquals("UNKNOWN", handler.render("${DATA.BIZDATA.noneField:UNKNOWN}", msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DATA 영역 미설정 시 기본값 반환")
|
||||
void DATA_없으면_기본값() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
|
||||
assertEquals("{}", handler.render("${DATA.BIZDATA:{}}", msg, null));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("배열 반복 블록 {{#foreach}}")
|
||||
class ForeachBlock {
|
||||
|
||||
@Test
|
||||
@DisplayName("MSG_LIST 1건 렌더링")
|
||||
void 단건_렌더링() {
|
||||
StandardMessage msg = buildMsg("E001", "대표오류", "",
|
||||
new String[][]{{"E001", "메시지A", "설명A", "fieldA"}});
|
||||
String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\",\"m\":\"${outp_msg_ctnt}\"}{{/foreach}}]";
|
||||
|
||||
assertEquals("[{\"c\":\"E001\",\"m\":\"메시지A\"}]", handler.render(template, msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MSG_LIST 2건 → 쉼표로 연결")
|
||||
void 다건_쉼표_연결() {
|
||||
StandardMessage msg = buildMsg("E001", "대표오류", "",
|
||||
new String[][]{
|
||||
{"E001", "오류A", "설명A", "fieldA"},
|
||||
{"E002", "오류B", "설명B", "fieldB"}
|
||||
});
|
||||
String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";
|
||||
|
||||
assertEquals("[{\"c\":\"E001\"},{\"c\":\"E002\"}]", handler.render(template, msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MSG_LIST 가 비어있으면 foreach 결과는 빈 문자열")
|
||||
void 빈_배열() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", new String[][]{});
|
||||
String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";
|
||||
|
||||
assertEquals("[]", handler.render(template, msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("foreach 경로가 없으면 빈 문자열")
|
||||
void 없는_경로() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
String template = "[{{#foreach NONE.LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";
|
||||
|
||||
assertEquals("[]", handler.render(template, msg, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("err_occu_item_nm 필드 포함 렌더링")
|
||||
void err_occu_item_nm_포함() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "",
|
||||
new String[][]{{"E001", "오류A", "설명A", "accountNo"}});
|
||||
String template = "{{#foreach MSG.MSG_LIST}}{\"field\":\"${err_occu_item_nm}\"}{{/foreach}}";
|
||||
|
||||
assertEquals("{\"field\":\"accountNo\"}", handler.render(template, msg, null));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 혼합 템플릿 (스칼라 + callProp + 배열)
|
||||
// ================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("혼합 템플릿")
|
||||
class MixedTemplate {
|
||||
|
||||
@Test
|
||||
@DisplayName("JSON – MAIN_MSG + callProp + MSG_LIST 2건")
|
||||
void JSON_전체_혼합() {
|
||||
StandardMessage msg = buildMsg("E001", "대표오류", "대표설명",
|
||||
new String[][]{
|
||||
{"E001", "오류A", "설명A", "fieldA"},
|
||||
{"E002", "오류B", "설명B", "fieldB"}
|
||||
});
|
||||
Properties callProp = props("ADAPTER_NAME", "TEST_ADAPTER");
|
||||
|
||||
String template =
|
||||
"{" +
|
||||
"\"adapter\":\"${callprop.ADAPTER_NAME}\"," +
|
||||
"\"resultCode\":\"${MSG.MAIN_MSG.outp_msg_cd}\"," +
|
||||
"\"resultMsg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"," +
|
||||
"\"errors\":[{{#foreach MSG.MSG_LIST}}" +
|
||||
"{\"code\":\"${outp_msg_cd}\",\"msg\":\"${outp_msg_ctnt}\",\"field\":\"${err_occu_item_nm}\"}" +
|
||||
"{{/foreach}}]" +
|
||||
"}";
|
||||
|
||||
String result = handler.render(template, msg, callProp);
|
||||
|
||||
assertEquals(
|
||||
"{\"adapter\":\"TEST_ADAPTER\"," +
|
||||
"\"resultCode\":\"E001\",\"resultMsg\":\"대표오류\"," +
|
||||
"\"errors\":[{\"code\":\"E001\",\"msg\":\"오류A\",\"field\":\"fieldA\"}," +
|
||||
"{\"code\":\"E002\",\"msg\":\"오류B\",\"field\":\"fieldB\"}]}",
|
||||
result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("XML – 스칼라 + callProp 치환")
|
||||
void XML_스칼라_callProp() {
|
||||
StandardMessage msg = buildMsg("E001", "오류발생", "", null);
|
||||
Properties callProp = props("IF_ID", "IF_001");
|
||||
String template = "<error><ifId>${callprop.IF_ID}</ifId><code>${MSG.MAIN_MSG.outp_msg_cd}</code></error>";
|
||||
|
||||
assertEquals("<error><ifId>IF_001</ifId><code>E001</code></error>",
|
||||
handler.render(template, msg, callProp));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("XML – foreach 배열")
|
||||
void XML_foreach_배열() {
|
||||
StandardMessage msg = buildMsg("E001", "오류", "",
|
||||
new String[][]{{"E001", "오류A", "", ""}, {"E002", "오류B", "", ""}});
|
||||
String template =
|
||||
"<errors>{{#foreach MSG.MSG_LIST}}" +
|
||||
"<item><code>${outp_msg_cd}</code><msg>${outp_msg_ctnt}</msg></item>" +
|
||||
"{{/foreach}}</errors>";
|
||||
|
||||
assertEquals(
|
||||
"<errors>" +
|
||||
"<item><code>E001</code><msg>오류A</msg></item>," +
|
||||
"<item><code>E002</code><msg>오류B</msg></item>" +
|
||||
"</errors>",
|
||||
handler.render(template, msg, null));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// generateNonStandardErrorResponseMessage – PropManager mock
|
||||
// ================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("generateNonStandardErrorResponseMessage")
|
||||
class GenerateMethod {
|
||||
|
||||
private void injectMockPropManager(PropManager mockPropManager) throws Exception {
|
||||
ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class);
|
||||
Mockito.when(mockCtx.getBean(PropManager.class)).thenReturn(mockPropManager);
|
||||
Field ctxField = ApplicationContextProvider.class.getDeclaredField("context");
|
||||
ctxField.setAccessible(true);
|
||||
ctxField.set(null, mockCtx);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("템플릿 미설정 시 null 반환")
|
||||
void 템플릿_없으면_null() throws Exception {
|
||||
PropManager mockProp = Mockito.mock(PropManager.class);
|
||||
Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null);
|
||||
injectMockPropManager(mockProp);
|
||||
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
Object result = handler.generateNonStandardErrorResponseMessage(
|
||||
"TEST_GROUP", "TEST_ADAPTER", new Properties(), null, msg);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("빈 템플릿 시 null 반환")
|
||||
void 빈_템플릿_null() throws Exception {
|
||||
PropManager mockProp = Mockito.mock(PropManager.class);
|
||||
Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(" ");
|
||||
injectMockPropManager(mockProp);
|
||||
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
Object result = handler.generateNonStandardErrorResponseMessage(
|
||||
"TEST_GROUP", "TEST_ADAPTER", new Properties(), null, msg);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("템플릿 설정 시 StandardMessage 치환 결과 반환")
|
||||
void 템플릿_stdmsg_치환() throws Exception {
|
||||
String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}";
|
||||
PropManager mockProp = Mockito.mock(PropManager.class);
|
||||
Mockito.when(mockProp.getProperty(
|
||||
eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
|
||||
eq("MY_GROUP.template"))).thenReturn(template);
|
||||
injectMockPropManager(mockProp);
|
||||
|
||||
StandardMessage msg = buildMsg("E999", "테스트오류", "", null);
|
||||
Object result = handler.generateNonStandardErrorResponseMessage(
|
||||
"MY_GROUP", "MY_ADAPTER", new Properties(), null, msg);
|
||||
|
||||
assertEquals("{\"code\":\"E999\"}", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callProp 값이 템플릿에 치환되어 반환")
|
||||
void 템플릿_callProp_치환() throws Exception {
|
||||
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}";
|
||||
PropManager mockProp = Mockito.mock(PropManager.class);
|
||||
Mockito.when(mockProp.getProperty(
|
||||
eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
|
||||
eq("MY_GROUP.template"))).thenReturn(template);
|
||||
injectMockPropManager(mockProp);
|
||||
|
||||
StandardMessage msg = buildMsg("E999", "테스트오류", "", null);
|
||||
Properties callProp = props("ADAPTER_NAME", "REAL_ADAPTER");
|
||||
|
||||
Object result = handler.generateNonStandardErrorResponseMessage(
|
||||
"MY_GROUP", "MY_ADAPTER", callProp, null, msg);
|
||||
|
||||
assertEquals("{\"adapter\":\"REAL_ADAPTER\",\"code\":\"E999\"}", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("템플릿 키는 {adapterGroupId}.template 형식으로 조회")
|
||||
void 템플릿_키_형식_검증() throws Exception {
|
||||
PropManager mockProp = Mockito.mock(PropManager.class);
|
||||
Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null);
|
||||
injectMockPropManager(mockProp);
|
||||
|
||||
StandardMessage msg = buildMsg("E001", "오류", "", null);
|
||||
handler.generateNonStandardErrorResponseMessage(
|
||||
"SAMPLE_GROUP", "ANY_ADAPTER", new Properties(), null, msg);
|
||||
|
||||
Mockito.verify(mockProp).getProperty(
|
||||
TemplateAdapterErrorMsgHandler.PROP_GROUP,
|
||||
"SAMPLE_GROUP.template");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -11,7 +11,7 @@ import org.springframework.test.util.ReflectionTestUtils;
|
||||
import com.eactive.eai.agent.command.CommandException;
|
||||
import com.eactive.eai.common.inflow.InflowControlManager;
|
||||
import com.eactive.eai.common.inflow.InflowControlUtil;
|
||||
import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
|
||||
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
|
||||
|
||||
/**
|
||||
* ReloadInflowClientControlCommand 단위 테스트.
|
||||
@@ -21,11 +21,11 @@ import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
|
||||
*/
|
||||
class ReloadInflowClientControlCommandTest {
|
||||
|
||||
private DualInflowControlManager mockDualManager;
|
||||
private ClientDualInflowControlManager mockDualManager;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockDualManager = mock(DualInflowControlManager.class);
|
||||
mockDualManager = mock(ClientDualInflowControlManager.class);
|
||||
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", null);
|
||||
}
|
||||
|
||||
@@ -51,11 +51,11 @@ class ReloadInflowClientControlCommandTest {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DualInflowControlManager 비활성
|
||||
// ClientDualInflowControlManager 비활성
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void execute_notDualManager_throwsCommandException() {
|
||||
void execute_notClientDualManager_throwsCommandException() {
|
||||
InflowControlManager baseMgr = mock(InflowControlManager.class);
|
||||
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", baseMgr);
|
||||
|
||||
|
||||
+5
-5
@@ -11,7 +11,7 @@ import org.springframework.test.util.ReflectionTestUtils;
|
||||
import com.eactive.eai.agent.command.CommandException;
|
||||
import com.eactive.eai.common.inflow.InflowControlManager;
|
||||
import com.eactive.eai.common.inflow.InflowControlUtil;
|
||||
import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
|
||||
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
|
||||
|
||||
/**
|
||||
* RemoveInflowClientControlCommand 단위 테스트.
|
||||
@@ -21,11 +21,11 @@ import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
|
||||
*/
|
||||
class RemoveInflowClientControlCommandTest {
|
||||
|
||||
private DualInflowControlManager mockDualManager;
|
||||
private ClientDualInflowControlManager mockDualManager;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockDualManager = mock(DualInflowControlManager.class);
|
||||
mockDualManager = mock(ClientDualInflowControlManager.class);
|
||||
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", null);
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ class RemoveInflowClientControlCommandTest {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DualInflowControlManager 비활성
|
||||
// ClientDualInflowControlManager 비활성
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void execute_notDualManager_throwsCommandException() {
|
||||
void execute_notClientDualManager_throwsCommandException() {
|
||||
InflowControlManager baseMgr = mock(InflowControlManager.class);
|
||||
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", baseMgr);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.eactive.eai.common.inflow;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -36,9 +38,13 @@ class InflowControlManagerTest {
|
||||
InflowControlManager inflowControlManager;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws LifecycleException {
|
||||
void setUp() throws Exception {
|
||||
if (!inflowControlManager.isStarted()) {
|
||||
inflowControlManager.start();
|
||||
} else {
|
||||
inflowControlManager.reloadAdapter();
|
||||
inflowControlManager.reloadInterface();
|
||||
inflowControlManager.reloadGroup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +63,22 @@ class InflowControlManagerTest {
|
||||
assertNull(inflowControlManager.getGroupBucket("non-existent-group"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adapter_spikePasses_thenBurstBlocked() {
|
||||
for (int i = 0; i < 5; i++) assertTrue(inflowControlManager.isAdapterPass("TEST_ADAPTER"));
|
||||
assertFalse(inflowControlManager.isAdapterPass("TEST_ADAPTER"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void interface_spikePasses_thenBurstBlocked() {
|
||||
for (int i = 0; i < 5; i++) assertTrue(inflowControlManager.isInterfacePass("TEST_INTERFACE"));
|
||||
assertFalse(inflowControlManager.isInterfacePass("TEST_INTERFACE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void group_spikePasses_thenBurstBlocked() {
|
||||
for (int i = 0; i < 5; i++) assertNull(inflowControlManager.isGroupPass("test-group-quota"));
|
||||
assertEquals(CustomGroupBucket.RESULT_BLOCKED_THRESHOLD, inflowControlManager.isGroupPass("test-group-quota"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package com.eactive.eai.common.inflow.dual;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import com.eactive.eai.common.inflow.InflowTargetVO;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket4j;
|
||||
import io.github.bucket4j.local.LocalBucket;
|
||||
|
||||
/**
|
||||
* ClientDualInflowControlManager 클라이언트 메트릭 단위 테스트.
|
||||
*/
|
||||
class ClientDualInflowControlManagerMetricsTest {
|
||||
|
||||
private ClientDualInflowControlManager manager;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
manager = new ClientDualInflowControlManager();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private InflowTargetVO makeTargetVO(String name) {
|
||||
InflowTargetVO vo = new InflowTargetVO();
|
||||
vo.setName(name);
|
||||
vo.setThresholdPerSecond(100);
|
||||
vo.setThreshold(10000);
|
||||
vo.setThresholdTimeUnit("DAY");
|
||||
vo.setActivate(true);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private LocalBucket stableBucket(long capacity) {
|
||||
return Bucket4j.builder()
|
||||
.addLimit(Bandwidth.simple(capacity, Duration.ofHours(1)))
|
||||
.build();
|
||||
}
|
||||
|
||||
private LocalBucket exhaustedBucket(long capacity) {
|
||||
LocalBucket b = stableBucket(capacity);
|
||||
b.tryConsume(capacity);
|
||||
return b;
|
||||
}
|
||||
|
||||
private DualCustomBucket passDualBucket(String name) {
|
||||
return new DualCustomBucket(stableBucket(100), null, makeTargetVO(name));
|
||||
}
|
||||
|
||||
private DualCustomBucket blockedPerSecondDualBucket(String name) {
|
||||
return new DualCustomBucket(exhaustedBucket(1), null, makeTargetVO(name));
|
||||
}
|
||||
|
||||
private DualCustomBucket blockedThresholdDualBucket(String name) {
|
||||
return new DualCustomBucket(stableBucket(100), exhaustedBucket(1), makeTargetVO(name));
|
||||
}
|
||||
|
||||
private void injectClientMap(Map<String, DualCustomBucket> map) {
|
||||
ReflectionTestUtils.setField(manager, "clientBucketMap", map);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// isClientPassDetail() — 카운터 증가
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void isClientPassDetail_bucketNotRegistered_noMetricsCreated() {
|
||||
manager.isClientPassDetail("UNKNOWN");
|
||||
|
||||
assertNull(manager.getClientMetrics("UNKNOWN"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isClientPassDetail_pass_incrementsAllowed() {
|
||||
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
|
||||
map.put("C1", passDualBucket("C1"));
|
||||
injectClientMap(map);
|
||||
|
||||
manager.isClientPassDetail("C1");
|
||||
|
||||
assertEquals(1, manager.getClientMetrics("C1").allowed.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isClientPassDetail_blockedThreshold_incrementsRejectedThreshold() {
|
||||
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
|
||||
map.put("C1", blockedThresholdDualBucket("C1"));
|
||||
injectClientMap(map);
|
||||
|
||||
manager.isClientPassDetail("C1");
|
||||
|
||||
assertEquals(1, manager.getClientMetrics("C1").rejectedThreshold.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isClientPassDetail_blockedPerSecond_incrementsRejectedPerSecond() {
|
||||
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
|
||||
map.put("C1", blockedPerSecondDualBucket("C1"));
|
||||
injectClientMap(map);
|
||||
|
||||
manager.isClientPassDetail("C1");
|
||||
|
||||
assertEquals(1, manager.getClientMetrics("C1").rejectedPerSecond.get());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// getAllClientMetrics — 조회 및 불변성
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void getAllClientMetrics_empty_returnsEmptyMap() {
|
||||
assertTrue(manager.getAllClientMetrics().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAllClientMetrics_afterCalls_containsEntries() {
|
||||
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
|
||||
map.put("C1", passDualBucket("C1"));
|
||||
map.put("C2", passDualBucket("C2"));
|
||||
injectClientMap(map);
|
||||
|
||||
manager.isClientPassDetail("C1");
|
||||
manager.isClientPassDetail("C2");
|
||||
|
||||
Map<String, DualInflowControlManager.TargetMetrics> all = manager.getAllClientMetrics();
|
||||
assertEquals(2, all.size());
|
||||
assertTrue(all.containsKey("C1"));
|
||||
assertTrue(all.containsKey("C2"));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// resetClientMetrics / resetAllClientMetrics
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void resetClientMetrics_resetsTargetClient() {
|
||||
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
|
||||
map.put("C1", passDualBucket("C1"));
|
||||
injectClientMap(map);
|
||||
|
||||
manager.isClientPassDetail("C1");
|
||||
manager.resetClientMetrics("C1");
|
||||
|
||||
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetAllClientMetrics_resetsAll() {
|
||||
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
|
||||
map.put("C1", passDualBucket("C1"));
|
||||
map.put("C2", passDualBucket("C2"));
|
||||
injectClientMap(map);
|
||||
|
||||
manager.isClientPassDetail("C1");
|
||||
manager.isClientPassDetail("C2");
|
||||
manager.resetAllClientMetrics();
|
||||
|
||||
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
|
||||
assertEquals(0, manager.getClientMetrics("C2").allowed.get());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// removeClient — 메트릭 자동 삭제
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void removeClient_cleansUpMetrics() {
|
||||
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
|
||||
map.put("C1", passDualBucket("C1"));
|
||||
injectClientMap(map);
|
||||
|
||||
manager.isClientPassDetail("C1");
|
||||
assertNotNull(manager.getClientMetrics("C1"));
|
||||
|
||||
manager.removeClient("C1");
|
||||
|
||||
assertNull(manager.getClientMetrics("C1"));
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package com.eactive.eai.common.inflow.dual;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.eactive.eai.common.inflow.InflowTargetVO;
|
||||
|
||||
/**
|
||||
* DualInflowControlManager.makeBucket() 버킷 생성 동작 검증.
|
||||
*
|
||||
* <p>검증 포인트:
|
||||
* - threshold/perSecond 버킷이 실제로 생성되는지
|
||||
* - 할당량 이내 spike는 통과하고 초과 burst는 차단되는지
|
||||
* - perSecond/threshold 중 어느 쪽이 binding 제약인지 구분되는지
|
||||
*/
|
||||
class DualInflowControlManagerMakeBucketTest {
|
||||
|
||||
private final DualInflowControlManager manager = new DualInflowControlManager();
|
||||
|
||||
private InflowTargetVO vo(long perSecond, long threshold, String timeUnit) {
|
||||
InflowTargetVO vo = new InflowTargetVO();
|
||||
vo.setName("TEST");
|
||||
vo.setThresholdPerSecond(perSecond);
|
||||
vo.setThreshold(threshold);
|
||||
vo.setThresholdTimeUnit(timeUnit);
|
||||
vo.setActivate(true);
|
||||
return vo;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// threshold 단독 (5건/분)
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void thresholdOnly_spikePasses_thenBurstBlocked() {
|
||||
DualCustomBucket bucket = manager.makeBucket(vo(0, 5, "MIN"));
|
||||
|
||||
for (int i = 0; i < 5; i++) assertNull(bucket.tryConsume());
|
||||
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// perSecond 단독
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void perSecondOnly_spikePasses_thenBurstBlocked() {
|
||||
DualCustomBucket bucket = manager.makeBucket(vo(5, 0, null));
|
||||
|
||||
for (int i = 0; i < 5; i++) assertNull(bucket.tryConsume());
|
||||
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// perSecond + threshold 동시 설정 — binding 제약 구분
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void both_perSecondIsBinding_blockedByPerSecond() {
|
||||
// perSecond=3 이 binding: threshold=100/MIN은 여유 있음
|
||||
DualCustomBucket bucket = manager.makeBucket(vo(3, 100, "MIN"));
|
||||
|
||||
for (int i = 0; i < 3; i++) assertNull(bucket.tryConsume());
|
||||
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
|
||||
}
|
||||
|
||||
@Test
|
||||
void both_thresholdIsBinding_blockedByThreshold() {
|
||||
// threshold=3/MIN 이 binding: perSecond=100은 여유 있음
|
||||
DualCustomBucket bucket = manager.makeBucket(vo(100, 3, "MIN"));
|
||||
|
||||
for (int i = 0; i < 3; i++) assertNull(bucket.tryConsume());
|
||||
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 비활성 / 조건 미충족 → null 반환
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void inactiveVO_returnsNull() {
|
||||
InflowTargetVO vo = vo(5, 0, null);
|
||||
vo.setActivate(false);
|
||||
assertNull(manager.makeBucket(vo));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noLimitsSet_returnsNull() {
|
||||
assertNull(manager.makeBucket(vo(0, 0, null)));
|
||||
}
|
||||
}
|
||||
+3
-88
@@ -21,9 +21,8 @@ import io.github.bucket4j.local.LocalBucket;
|
||||
/**
|
||||
* DualInflowControlManager 메트릭 단위 테스트.
|
||||
*
|
||||
* <p>start() 없이 버킷 맵을 ReflectionTestUtils로 직접 주입하여
|
||||
* isAdapterPassDetail / isInterfacePassDetail / isClientPassDetail / isGroupPass() 의
|
||||
* 카운팅 동작과 getXxxMetrics / resetXxxMetrics 메서드를 검증한다.
|
||||
* <p>어댑터/인터페이스/그룹 메트릭을 검증한다.
|
||||
* 클라이언트 메트릭은 ClientDualInflowControlManagerMetricsTest 에서 검증한다.
|
||||
*/
|
||||
class DualInflowControlManagerMetricsTest {
|
||||
|
||||
@@ -100,10 +99,6 @@ class DualInflowControlManagerMetricsTest {
|
||||
ReflectionTestUtils.setField(manager, "interfaceBucketMap", map);
|
||||
}
|
||||
|
||||
private void injectClientMap(Map<String, DualCustomBucket> map) {
|
||||
ReflectionTestUtils.setField(manager, "clientBucketMap", map);
|
||||
}
|
||||
|
||||
private void injectGroupBucketList(Map<String, CustomGroupBucket> map) {
|
||||
ReflectionTestUtils.setField(manager, "groupBucketList", map);
|
||||
}
|
||||
@@ -358,7 +353,6 @@ class DualInflowControlManagerMetricsTest {
|
||||
|
||||
@Test
|
||||
void isAdapterPassDetail_bucketNotRegistered_noMetricsCreated() {
|
||||
// 버킷 없는 어댑터는 메트릭 맵에 항목을 생성하지 않음
|
||||
manager.isAdapterPassDetail("UNKNOWN");
|
||||
|
||||
assertNull(manager.getAdapterMetrics("UNKNOWN"));
|
||||
@@ -473,39 +467,6 @@ class DualInflowControlManagerMetricsTest {
|
||||
assertEquals(1, manager.getInterfaceMetrics("IF2").rejectedPerSecond.get());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// isClientPassDetail() — 카운터 증가
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void isClientPassDetail_bucketNotRegistered_noMetricsCreated() {
|
||||
manager.isClientPassDetail("UNKNOWN");
|
||||
|
||||
assertNull(manager.getClientMetrics("UNKNOWN"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isClientPassDetail_pass_incrementsAllowed() {
|
||||
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
|
||||
map.put("C1", passDualBucket("C1"));
|
||||
injectClientMap(map);
|
||||
|
||||
manager.isClientPassDetail("C1");
|
||||
|
||||
assertEquals(1, manager.getClientMetrics("C1").allowed.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isClientPassDetail_blockedThreshold_incrementsRejectedThreshold() {
|
||||
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
|
||||
map.put("C1", blockedThresholdDualBucket("C1"));
|
||||
injectClientMap(map);
|
||||
|
||||
manager.isClientPassDetail("C1");
|
||||
|
||||
assertEquals(1, manager.getClientMetrics("C1").rejectedThreshold.get());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// getAllXxxMetrics — 조회 및 불변성
|
||||
// =========================================================================
|
||||
@@ -537,11 +498,6 @@ class DualInflowControlManagerMetricsTest {
|
||||
assertTrue(manager.getAllInterfaceMetrics().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAllClientMetrics_empty_returnsEmptyMap() {
|
||||
assertTrue(manager.getAllClientMetrics().isEmpty());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// resetXxxMetrics / resetAllXxxMetrics
|
||||
// =========================================================================
|
||||
@@ -560,7 +516,7 @@ class DualInflowControlManagerMetricsTest {
|
||||
manager.resetAdapterMetrics("A1");
|
||||
|
||||
assertEquals(0, manager.getAdapterMetrics("A1").allowed.get());
|
||||
assertEquals(1, manager.getAdapterMetrics("A2").allowed.get()); // 영향 없음
|
||||
assertEquals(1, manager.getAdapterMetrics("A2").allowed.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -601,33 +557,6 @@ class DualInflowControlManagerMetricsTest {
|
||||
assertDoesNotThrow(() -> manager.resetAllInterfaceMetrics());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetClientMetrics_resetsTargetClient() {
|
||||
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
|
||||
map.put("C1", passDualBucket("C1"));
|
||||
injectClientMap(map);
|
||||
|
||||
manager.isClientPassDetail("C1");
|
||||
manager.resetClientMetrics("C1");
|
||||
|
||||
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetAllClientMetrics_resetsAll() {
|
||||
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
|
||||
map.put("C1", passDualBucket("C1"));
|
||||
map.put("C2", passDualBucket("C2"));
|
||||
injectClientMap(map);
|
||||
|
||||
manager.isClientPassDetail("C1");
|
||||
manager.isClientPassDetail("C2");
|
||||
manager.resetAllClientMetrics();
|
||||
|
||||
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
|
||||
assertEquals(0, manager.getClientMetrics("C2").allowed.get());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// remove 시 메트릭 자동 삭제
|
||||
// =========================================================================
|
||||
@@ -659,18 +588,4 @@ class DualInflowControlManagerMetricsTest {
|
||||
|
||||
assertNull(manager.getInterfaceMetrics("IF1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeClient_cleansUpMetrics() {
|
||||
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
|
||||
map.put("C1", passDualBucket("C1"));
|
||||
ReflectionTestUtils.setField(manager, "clientBucketMap", map);
|
||||
|
||||
manager.isClientPassDetail("C1");
|
||||
assertNotNull(manager.getClientMetrics("C1"));
|
||||
|
||||
manager.removeClient("C1");
|
||||
|
||||
assertNull(manager.getClientMetrics("C1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
package com.eactive.eai.inbound.processor;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import com.eactive.eai.common.inflow.Bucket;
|
||||
import com.eactive.eai.common.inflow.InflowTargetVO;
|
||||
import com.eactive.eai.common.inflow.dual.DualBucket;
|
||||
import com.eactive.eai.common.inflow.dual.DualCustomBucket;
|
||||
import com.eactive.eai.common.server.EAIServerManager;
|
||||
import com.eactive.eai.common.util.ApplicationContextProvider;
|
||||
|
||||
/**
|
||||
* DualRequestProcessor 단위 테스트.
|
||||
*
|
||||
* <p>RequestProcessor 클래스는 static 초기화 블록에서 EAIServerManager.getInstance()를 호출한다.
|
||||
* @BeforeAll에서 mock ApplicationContext를 주입한 뒤 DualRequestProcessor를 최초 로딩시킨다.
|
||||
*
|
||||
* <p>주의: DualRequestProcessor를 필드 타입으로 선언하면 테스트 클래스 로딩 시 함께 로딩되어
|
||||
* @BeforeAll 실행 전에 NPE가 발생한다. 반드시 메서드 본문 내에서만 참조해야 한다.
|
||||
*/
|
||||
class DualRequestProcessorTest {
|
||||
|
||||
@BeforeAll
|
||||
static void setupMockApplicationContext() {
|
||||
ApplicationContext mockContext = mock(ApplicationContext.class);
|
||||
EAIServerManager mockServerManager = mock(EAIServerManager.class);
|
||||
when(mockContext.getBean(EAIServerManager.class)).thenReturn(mockServerManager);
|
||||
when(mockServerManager.getLocalServerName()).thenReturn("TEST-SERVER");
|
||||
when(mockServerManager.getGroupInstId()).thenReturn("TEST-INST");
|
||||
|
||||
ReflectionTestUtils.setField(ApplicationContextProvider.class, "context", mockContext);
|
||||
}
|
||||
|
||||
private InflowTargetVO makeVO(String name, long perSec, long threshold, String unit) {
|
||||
InflowTargetVO vo = new InflowTargetVO();
|
||||
vo.setName(name);
|
||||
vo.setThresholdPerSecond(perSec);
|
||||
vo.setThreshold(threshold);
|
||||
vo.setThresholdTimeUnit(unit);
|
||||
vo.setActivate(true);
|
||||
return vo;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// checkAdapterInflow
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void checkAdapterInflow_dualBucket_pass_returnsNull() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
DualBucket mockBucket = mock(DualBucket.class);
|
||||
when(mockBucket.isAdapterPassDetail("ADAPTER_A")).thenReturn(null);
|
||||
|
||||
assertNull(processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
|
||||
verify(mockBucket).isAdapterPassDetail("ADAPTER_A");
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAdapterInflow_dualBucket_blockedPerSecond() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
DualBucket mockBucket = mock(DualBucket.class);
|
||||
when(mockBucket.isAdapterPassDetail("ADAPTER_A")).thenReturn(DualCustomBucket.RESULT_BLOCKED_PER_SECOND);
|
||||
|
||||
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND,
|
||||
processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAdapterInflow_dualBucket_blockedThreshold() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
DualBucket mockBucket = mock(DualBucket.class);
|
||||
when(mockBucket.isAdapterPassDetail("ADAPTER_A")).thenReturn(DualCustomBucket.RESULT_BLOCKED_THRESHOLD);
|
||||
|
||||
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD,
|
||||
processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAdapterInflow_nonDualBucket_pass_returnsNull() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
Bucket mockBucket = mock(Bucket.class);
|
||||
when(mockBucket.isAdapterPass("ADAPTER_A")).thenReturn(true);
|
||||
|
||||
assertNull(processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAdapterInflow_nonDualBucket_blocked_returnsBlocked() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
Bucket mockBucket = mock(Bucket.class);
|
||||
when(mockBucket.isAdapterPass("ADAPTER_A")).thenReturn(false);
|
||||
|
||||
assertEquals("BLOCKED", processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// checkInterfaceInflow
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void checkInterfaceInflow_dualBucket_pass_returnsNull() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
DualBucket mockBucket = mock(DualBucket.class);
|
||||
when(mockBucket.isInterfacePassDetail("IF_001")).thenReturn(null);
|
||||
|
||||
assertNull(processor.checkInterfaceInflow(mockBucket, "IF_001"));
|
||||
verify(mockBucket).isInterfacePassDetail("IF_001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkInterfaceInflow_dualBucket_blockedPerSecond() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
DualBucket mockBucket = mock(DualBucket.class);
|
||||
when(mockBucket.isInterfacePassDetail("IF_001")).thenReturn(DualCustomBucket.RESULT_BLOCKED_PER_SECOND);
|
||||
|
||||
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND,
|
||||
processor.checkInterfaceInflow(mockBucket, "IF_001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkInterfaceInflow_nonDualBucket_pass_returnsNull() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
Bucket mockBucket = mock(Bucket.class);
|
||||
when(mockBucket.isInterfacePass("IF_001")).thenReturn(true);
|
||||
|
||||
assertNull(processor.checkInterfaceInflow(mockBucket, "IF_001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkInterfaceInflow_nonDualBucket_blocked_returnsBlocked() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
Bucket mockBucket = mock(Bucket.class);
|
||||
when(mockBucket.isInterfacePass("IF_001")).thenReturn(false);
|
||||
|
||||
assertEquals("BLOCKED", processor.checkInterfaceInflow(mockBucket, "IF_001"));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// buildInflowTargetErrorMsg
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void buildInflowTargetErrorMsg_perSecond_includesReqPerSec() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
InflowTargetVO vo = makeVO("결제API", 30, 1000, "MIN");
|
||||
|
||||
String msg = processor.buildInflowTargetErrorMsg(vo, DualCustomBucket.RESULT_BLOCKED_PER_SECOND);
|
||||
|
||||
assertEquals("API 호출 한도 초과 [결제API: 30req/sec]", msg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildInflowTargetErrorMsg_threshold_includesReqPerUnit() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
InflowTargetVO vo = makeVO("결제API", 30, 1000, "MIN");
|
||||
|
||||
String msg = processor.buildInflowTargetErrorMsg(vo, DualCustomBucket.RESULT_BLOCKED_THRESHOLD);
|
||||
|
||||
assertEquals("API 호출 한도 초과 [결제API: 1000req/MIN]", msg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildInflowTargetErrorMsg_blockedFallback_simpleFormat() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
InflowTargetVO vo = makeVO("결제API", 30, 1000, "MIN");
|
||||
|
||||
String msg = processor.buildInflowTargetErrorMsg(vo, "BLOCKED");
|
||||
|
||||
assertEquals("API 호출 한도 초과 [결제API]", msg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildInflowTargetErrorMsg_nullBlockedType_simpleFormat() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
InflowTargetVO vo = makeVO("결제API", 30, 1000, "MIN");
|
||||
|
||||
String msg = processor.buildInflowTargetErrorMsg(vo, null);
|
||||
|
||||
assertEquals("API 호출 한도 초과 [결제API]", msg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildInflowTargetErrorMsg_hourUnit() {
|
||||
DualRequestProcessor processor = new DualRequestProcessor();
|
||||
InflowTargetVO vo = makeVO("조회API", 10, 500, "HOUR");
|
||||
|
||||
String msg = processor.buildInflowTargetErrorMsg(vo, DualCustomBucket.RESULT_BLOCKED_THRESHOLD);
|
||||
|
||||
assertEquals("API 호출 한도 초과 [조회API: 500req/HOUR]", msg);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
INSERT INTO inflow_control_group (group_id, group_name, threshold, threshold_per_second, threshold_time_unit, use_yn) VALUES
|
||||
('test-group-001', '테스트 그룹', 100, 10, 'MIN', '1');
|
||||
('test-group-001', '테스트 그룹', 100, 10, 'MIN', '1'),
|
||||
('test-group-quota', '쿼터전용 그룹', 5, 0, 'MIN', '1');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
INSERT INTO tseaifr09 (type,name,threshold,useyn,thresholdpersecond,thresholdtimeunit) VALUES
|
||||
('01','_AA0_IN_NET_AsS',10,'1',0,NULL),
|
||||
('01','_AB0_IN_NET_AsS',3,'1',0,NULL),
|
||||
('01','_TST_IN_NET_AsS',1,'1',0,NULL),
|
||||
('02','FASSFEP00000004S2',10,'1',0,NULL),
|
||||
('02','FDASFEP00000009S2',2,'1',0,NULL);
|
||||
INSERT INTO tseaifr09 (type,name,threshold,useyn,thresholdpersecond,thresholdtimeunit) VALUES
|
||||
('01','_AA0_IN_NET_AsS',10,'1',0,NULL),
|
||||
('01','_AB0_IN_NET_AsS',3,'1',0,NULL),
|
||||
('01','_TST_IN_NET_AsS',1,'1',0,NULL),
|
||||
('02','FASSFEP00000004S2',10,'1',0,NULL),
|
||||
('02','FDASFEP00000009S2',2,'1',0,NULL),
|
||||
('01','TEST_ADAPTER',5,'1',0,'MIN'),
|
||||
('02','TEST_INTERFACE',5,'1',0,'MIN');
|
||||
Reference in New Issue
Block a user