refs #722: externalize backoffice customer configuration
This commit is contained in:
@@ -272,10 +272,16 @@ public class BackofficeSchemaService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final BackofficeProperties properties;
|
||||
private final DataCatalog dataCatalog;
|
||||
|
||||
public BackofficeSchemaService(JdbcTemplate jdbcTemplate, BackofficeProperties properties) {
|
||||
public BackofficeSchemaService(
|
||||
JdbcTemplate jdbcTemplate,
|
||||
BackofficeProperties properties,
|
||||
DataCatalog dataCatalog
|
||||
) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.properties = properties;
|
||||
this.dataCatalog = dataCatalog;
|
||||
}
|
||||
|
||||
public SchemaPreflightView preflight() {
|
||||
@@ -705,7 +711,7 @@ public class BackofficeSchemaService {
|
||||
@sql/adb/17_agent_ords_security_local_vpd_setup.sql
|
||||
@sql/adb/25_agent_ords_security_backoffice_support.sql
|
||||
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
|
||||
@sql/adb/71_sg_identity_administration.sql
|
||||
-- 4. 배포 환경에서 선택한 사용자·권한 초기화 SQL을 별도로 실행
|
||||
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
|
||||
|
||||
-- 2. ORDS parsing schema로 접속
|
||||
@@ -717,9 +723,9 @@ public class BackofficeSchemaService {
|
||||
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
|
||||
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
|
||||
|
||||
-- 4. 마스킹 규칙을 UI에서 게임 데이터 컬럼에 연결
|
||||
-- DBMS_REDACT 정책은 백오피스가 SGMP_POC 대상에 자동 동기화합니다.
|
||||
""".formatted(owner.toLowerCase());
|
||||
-- 5. 마스킹 규칙을 UI에서 등록된 업무 데이터 컬럼에 연결
|
||||
-- DBMS_REDACT 정책은 백오피스가 %s 대상에 자동 동기화합니다.
|
||||
""".formatted(owner.toLowerCase(), dataCatalog.owner());
|
||||
}
|
||||
|
||||
private void appendSql(StringBuilder builder, String sql) {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||
import java.util.List;
|
||||
|
||||
public interface DataCatalog {
|
||||
String owner();
|
||||
List<StructuredDataTable> objects();
|
||||
StructuredDataTable require(String key);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.CatalogProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class EnvironmentDataCatalog implements DataCatalog {
|
||||
private static final Pattern NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||
private static final Pattern KEY = Pattern.compile("[a-z][a-z0-9-]{0,63}");
|
||||
private final String owner;
|
||||
private final List<StructuredDataTable> objects;
|
||||
|
||||
public EnvironmentDataCatalog(CatalogProperties properties, ObjectMapper mapper) {
|
||||
owner = requireName(properties.owner());
|
||||
objects = parse(properties.objects(), mapper);
|
||||
}
|
||||
|
||||
@Override public String owner() { return owner; }
|
||||
@Override public List<StructuredDataTable> objects() { return objects; }
|
||||
@Override public StructuredDataTable require(String key) {
|
||||
return objects.stream().filter(item -> item.key().equals(key)).findFirst()
|
||||
.orElseThrow(() -> new AppException("선택할 수 없는 카탈로그 객체입니다."));
|
||||
}
|
||||
|
||||
private List<StructuredDataTable> parse(String raw, ObjectMapper mapper) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.");
|
||||
}
|
||||
try {
|
||||
List<StructuredDataTable> values = mapper.readValue(raw, new TypeReference<>() {});
|
||||
if (values.isEmpty() || values.stream().map(StructuredDataTable::key).distinct().count() != values.size()) throw new IllegalArgumentException();
|
||||
values.forEach(this::validate);
|
||||
return List.copyOf(values);
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.", exception);
|
||||
}
|
||||
}
|
||||
private void validate(StructuredDataTable value) {
|
||||
if (value == null || value.key() == null || !KEY.matcher(value.key()).matches()
|
||||
|| !NAME.matcher(value.tableName().toUpperCase(Locale.ROOT)).matches()
|
||||
|| !("TABLE".equalsIgnoreCase(value.objectType()) || "VIEW".equalsIgnoreCase(value.objectType()))) throw new IllegalArgumentException();
|
||||
}
|
||||
private String requireName(String value) {
|
||||
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
if (!NAME.matcher(normalized).matches()) throw new IllegalStateException("BACKOFFICE_CATALOG_OWNER 설정을 확인하세요.");
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.MaskingProperties;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Loads the managed redaction policy allow-list from BACKOFFICE_MASKING_POLICIES. */
|
||||
@Service
|
||||
public class EnvironmentMaskingPolicyCatalog implements MaskingPolicyCatalog {
|
||||
private static final Pattern NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||
private final List<MaskingPolicyTarget> targets;
|
||||
|
||||
public EnvironmentMaskingPolicyCatalog(MaskingProperties properties, ObjectMapper objectMapper) {
|
||||
targets = parse(properties.policies(), objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MaskingPolicyTarget> targets() {
|
||||
return targets;
|
||||
}
|
||||
|
||||
private List<MaskingPolicyTarget> parse(String raw, ObjectMapper objectMapper) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
List<MaskingPolicyTarget> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
|
||||
if (parsed.isEmpty()
|
||||
|| parsed.stream().map(MaskingPolicyTarget::objectName).distinct().count() != parsed.size()) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
List<MaskingPolicyTarget> normalized = parsed.stream()
|
||||
.map(item -> new MaskingPolicyTarget(normalize(item.objectName()), normalize(item.policyName())))
|
||||
.toList();
|
||||
return List.copyOf(normalized);
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("BACKOFFICE_MASKING_POLICIES 설정을 확인하세요.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
if (!NAME.matcher(normalized).matches()) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public interface MaskingPolicyCatalog {
|
||||
List<MaskingPolicyTarget> targets();
|
||||
|
||||
default Set<String> objectNames() {
|
||||
return targets().stream().map(MaskingPolicyTarget::objectName).collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
default boolean containsObject(String objectName) {
|
||||
return objectName != null && objectNames().contains(objectName.trim().toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -24,37 +23,31 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class MaskingPolicySynchronizer {
|
||||
|
||||
private static final String OWNER = "SGMP_POC";
|
||||
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||
private static final Map<String, String> MANAGED_POLICIES = managedPolicyMap();
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final MaskingRuleMapper mapper;
|
||||
private final DataCatalog dataCatalog;
|
||||
private final MaskingPolicyCatalog policyCatalog;
|
||||
|
||||
public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) {
|
||||
public MaskingPolicySynchronizer(
|
||||
JdbcTemplate jdbcTemplate,
|
||||
MaskingRuleMapper mapper,
|
||||
DataCatalog dataCatalog,
|
||||
MaskingPolicyCatalog policyCatalog
|
||||
) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
private static Map<String, String> managedPolicyMap() {
|
||||
Map<String, String> policies = new LinkedHashMap<>();
|
||||
policies.put("CZN_COMN_USER_MST", "SG_CZN_USER_REDACT");
|
||||
policies.put("COMN_SALES_USER_MST", "SG_SALES_USER_REDACT");
|
||||
policies.put("COMN_SALES_TXN", "SG_SALES_TXN_REDACT");
|
||||
policies.put("COMN_REFUND_TXN", "SG_REFUND_TXN_REDACT");
|
||||
return Collections.unmodifiableMap(policies);
|
||||
this.dataCatalog = dataCatalog;
|
||||
this.policyCatalog = policyCatalog;
|
||||
}
|
||||
|
||||
public Set<String> managedObjectNames() {
|
||||
return MANAGED_POLICIES.keySet();
|
||||
return policyCatalog.objectNames();
|
||||
}
|
||||
|
||||
public boolean isManagedObject(String objectName) {
|
||||
return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
static String managedPolicyName(String objectName) {
|
||||
return MANAGED_POLICIES.get(objectName);
|
||||
return policyCatalog.containsObject(objectName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,9 +60,9 @@ public class MaskingPolicySynchronizer {
|
||||
public MaskingPolicySyncResult synchronize() {
|
||||
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
|
||||
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
|
||||
if (OWNER.equalsIgnoreCase(rule.owner())
|
||||
if (dataCatalog.owner().equalsIgnoreCase(rule.owner())
|
||||
&& rule.ruleEnabled()
|
||||
&& MANAGED_POLICIES.containsKey(rule.objectName())) {
|
||||
&& policyCatalog.containsObject(rule.objectName())) {
|
||||
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
|
||||
}
|
||||
}
|
||||
@@ -79,9 +72,9 @@ public class MaskingPolicySynchronizer {
|
||||
int addedColumns = 0;
|
||||
int modifiedColumns = 0;
|
||||
int droppedColumns = 0;
|
||||
for (Map.Entry<String, String> policy : MANAGED_POLICIES.entrySet()) {
|
||||
String objectName = policy.getKey();
|
||||
String policyName = policy.getValue();
|
||||
for (MaskingPolicyTarget policy : policyCatalog.targets()) {
|
||||
String objectName = policy.objectName();
|
||||
String policyName = policy.policyName();
|
||||
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
|
||||
String enableStatus = policyEnableStatus(objectName, policyName);
|
||||
if (desired.isEmpty()) {
|
||||
@@ -141,7 +134,7 @@ public class MaskingPolicySynchronizer {
|
||||
SELECT enable
|
||||
FROM redaction_policies
|
||||
WHERE object_owner = ? AND object_name = ? AND policy_name = ?
|
||||
""", String.class, OWNER, objectName, policyName);
|
||||
""", String.class, dataCatalog.owner(), objectName, policyName);
|
||||
return statuses.isEmpty() ? null : statuses.getFirst();
|
||||
}
|
||||
|
||||
@@ -150,7 +143,7 @@ public class MaskingPolicySynchronizer {
|
||||
SELECT column_name
|
||||
FROM redaction_columns
|
||||
WHERE object_owner = ? AND object_name = ?
|
||||
""", String.class, OWNER, objectName).stream()
|
||||
""", String.class, dataCatalog.owner(), objectName).stream()
|
||||
.map(this::requiredColumnName)
|
||||
.toList();
|
||||
}
|
||||
@@ -160,7 +153,7 @@ public class MaskingPolicySynchronizer {
|
||||
BEGIN
|
||||
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||
END;
|
||||
""", OWNER, objectName, policyName);
|
||||
""", dataCatalog.owner(), objectName, policyName);
|
||||
}
|
||||
|
||||
private void enablePolicy(String objectName, String policyName) {
|
||||
@@ -168,7 +161,7 @@ public class MaskingPolicySynchronizer {
|
||||
BEGIN
|
||||
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||
END;
|
||||
""", OWNER, objectName, policyName);
|
||||
""", dataCatalog.owner(), objectName, policyName);
|
||||
}
|
||||
|
||||
private void dropColumn(String objectName, String policyName, String columnName) {
|
||||
@@ -179,7 +172,7 @@ public class MaskingPolicySynchronizer {
|
||||
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
|
||||
);
|
||||
END;
|
||||
""", OWNER, objectName, policyName, columnName);
|
||||
""", dataCatalog.owner(), objectName, policyName, columnName);
|
||||
}
|
||||
|
||||
private void addPolicy(
|
||||
@@ -251,9 +244,9 @@ public class MaskingPolicySynchronizer {
|
||||
END;
|
||||
""".formatted(functionConstant);
|
||||
if (regexPattern == null) {
|
||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
|
||||
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
||||
} else {
|
||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -276,9 +269,9 @@ public class MaskingPolicySynchronizer {
|
||||
END;
|
||||
""".formatted(actionConstant, functionConstant);
|
||||
if (regexPattern == null) {
|
||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName);
|
||||
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
||||
} else {
|
||||
jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +307,7 @@ public class MaskingPolicySynchronizer {
|
||||
object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ?
|
||||
);
|
||||
END;
|
||||
""", OWNER, objectName, columnName, expressionName);
|
||||
""", dataCatalog.owner(), objectName, columnName, expressionName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
/** A validated object-to-redaction-policy mapping supplied by deployment configuration. */
|
||||
public record MaskingPolicyTarget(String objectName, String policyName) {
|
||||
}
|
||||
@@ -28,19 +28,25 @@ public class MaskingRuleService {
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
private final AuditService auditService;
|
||||
private final MaskingPolicySynchronizer maskingPolicySynchronizer;
|
||||
private final DataCatalog dataCatalog;
|
||||
private final MaskingPolicyCatalog maskingPolicyCatalog;
|
||||
|
||||
public MaskingRuleService(
|
||||
MaskingRuleMapper mapper,
|
||||
UserMapper userMapper,
|
||||
ProtectedObjectService protectedObjectService,
|
||||
AuditService auditService,
|
||||
MaskingPolicySynchronizer maskingPolicySynchronizer
|
||||
MaskingPolicySynchronizer maskingPolicySynchronizer,
|
||||
DataCatalog dataCatalog,
|
||||
MaskingPolicyCatalog maskingPolicyCatalog
|
||||
) {
|
||||
this.mapper = mapper;
|
||||
this.userMapper = userMapper;
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
this.auditService = auditService;
|
||||
this.maskingPolicySynchronizer = maskingPolicySynchronizer;
|
||||
this.dataCatalog = dataCatalog;
|
||||
this.maskingPolicyCatalog = maskingPolicyCatalog;
|
||||
}
|
||||
|
||||
public List<MaskingRule> findAllRules() {
|
||||
@@ -55,9 +61,12 @@ public class MaskingRuleService {
|
||||
return mapper.findColumnRules();
|
||||
}
|
||||
|
||||
/** Reads the actual Oracle Data Redaction state for the managed Smilegate game-data objects. */
|
||||
/** Reads the actual Oracle Data Redaction state for configured managed objects. */
|
||||
public List<MaskingPolicyStatus> findPolicyStatuses() {
|
||||
return mapper.findPolicyStatuses();
|
||||
if (maskingPolicyCatalog.targets().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return mapper.findPolicyStatuses(dataCatalog.owner(), maskingPolicyCatalog.targets());
|
||||
}
|
||||
|
||||
public Set<String> managedObjectNames() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -9,26 +10,27 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** MCP boundary exposing the Smilegate game-data Select AI generation and read-only execution tool. */
|
||||
/** MCP boundary exposing a configured Select AI generation and read-only execution tool. */
|
||||
@Service
|
||||
public class McpSseService {
|
||||
|
||||
private static final String SELECT_AI_VPD_QUERY_TOOL = "oracle.select_ai.smilegate_game_text2sql";
|
||||
private static final String SELECT_AI_VPD_QUERY_PATH = "/mcp (tools/call)";
|
||||
private static final String DEFAULT_SELECT_AI_PROFILE = "SGMP_POC_OCI_GPT54MINI";
|
||||
|
||||
private final SmilegateSelectAiService smilegateSelectAiService;
|
||||
private final SelectAiService selectAiService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final BackofficeProperties properties;
|
||||
private final McpProperties mcpProperties;
|
||||
|
||||
public McpSseService(
|
||||
SmilegateSelectAiService smilegateSelectAiService,
|
||||
SelectAiService selectAiService,
|
||||
ObjectMapper objectMapper,
|
||||
BackofficeProperties properties
|
||||
BackofficeProperties properties,
|
||||
McpProperties mcpProperties
|
||||
) {
|
||||
this.smilegateSelectAiService = smilegateSelectAiService;
|
||||
this.selectAiService = selectAiService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
this.mcpProperties = mcpProperties;
|
||||
}
|
||||
|
||||
public ObjectNode handle(String contextPath, JsonNode request) {
|
||||
@@ -93,7 +95,7 @@ public class McpSseService {
|
||||
|
||||
private ObjectNode selectAiVpdQueryTool() {
|
||||
ObjectNode item = objectMapper.createObjectNode();
|
||||
item.put("name", SELECT_AI_VPD_QUERY_TOOL);
|
||||
item.put("name", toolName());
|
||||
item.put("description", selectAiVpdQueryView().description());
|
||||
|
||||
ObjectNode schema = objectMapper.createObjectNode();
|
||||
@@ -102,7 +104,7 @@ public class McpSseService {
|
||||
|
||||
ObjectNode prompt = objectMapper.createObjectNode();
|
||||
prompt.put("type", "string");
|
||||
prompt.put("description", "Smilegate 게임 로그·서비스 데이터에 대해 조회할 내용을 자연어로 입력합니다.");
|
||||
prompt.put("description", promptDescription());
|
||||
prompt.put("maxLength", 4000);
|
||||
properties.set("prompt", prompt);
|
||||
|
||||
@@ -117,7 +119,7 @@ public class McpSseService {
|
||||
|
||||
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
|
||||
String toolName = params.path("name").asText("");
|
||||
if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) {
|
||||
if (!toolName().equals(toolName)) {
|
||||
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName);
|
||||
}
|
||||
|
||||
@@ -128,13 +130,13 @@ public class McpSseService {
|
||||
}
|
||||
JsonNode response;
|
||||
try {
|
||||
response = smilegateSelectAiService.generateAndExecute(token, arguments.path("prompt").asText(""));
|
||||
response = selectAiService.generateAndExecute(token, arguments.path("prompt").asText(""));
|
||||
} catch (VpdTokenAccessDeniedException ignored) {
|
||||
return tokenAccessDeniedResult();
|
||||
}
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL);
|
||||
payload.put("toolName", toolName());
|
||||
payload.put("profile", selectAiProfile());
|
||||
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
|
||||
payload.set("response", response);
|
||||
@@ -169,10 +171,10 @@ public class McpSseService {
|
||||
private McpToolView selectAiVpdQueryView() {
|
||||
String profile = selectAiProfile();
|
||||
return new McpToolView(
|
||||
SELECT_AI_VPD_QUERY_TOOL,
|
||||
profile + " 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.",
|
||||
toolName(),
|
||||
profile + " 프로파일로 " + toolDescription(),
|
||||
-1L,
|
||||
"Smilegate 게임 데이터 Text2SQL",
|
||||
toolLabel(),
|
||||
SELECT_AI_VPD_QUERY_PATH
|
||||
);
|
||||
}
|
||||
@@ -180,11 +182,29 @@ public class McpSseService {
|
||||
private String selectAiProfile() {
|
||||
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
|
||||
return DEFAULT_SELECT_AI_PROFILE;
|
||||
return "";
|
||||
}
|
||||
return selectAi.profile().trim();
|
||||
}
|
||||
|
||||
private String toolName() {
|
||||
return mcpProperties == null ? "oracle.select_ai.data_text2sql" : mcpProperties.resolvedToolName();
|
||||
}
|
||||
|
||||
private String toolLabel() {
|
||||
return mcpProperties == null ? "업무 데이터 Text2SQL" : mcpProperties.resolvedToolLabel();
|
||||
}
|
||||
|
||||
private String toolDescription() {
|
||||
return mcpProperties == null
|
||||
? "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 실행합니다."
|
||||
: mcpProperties.resolvedToolDescription();
|
||||
}
|
||||
|
||||
private String promptDescription() {
|
||||
return mcpProperties == null ? "업무 데이터에서 조회할 내용을 자연어로 입력합니다." : mcpProperties.resolvedPromptDescription();
|
||||
}
|
||||
|
||||
private String pretty(Object value) {
|
||||
try {
|
||||
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
||||
|
||||
@@ -23,20 +23,21 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Service
|
||||
public class SchemaMetadataService {
|
||||
|
||||
private static final String OWNER = "SGMP_POC";
|
||||
private static final int MAX_COMMENT_LENGTH = 4000;
|
||||
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
|
||||
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||
|
||||
private final SchemaMetadataMapper mapper;
|
||||
private final StructuredDataService structuredDataService;
|
||||
private final DataCatalog catalog;
|
||||
|
||||
public SchemaMetadataService(
|
||||
SchemaMetadataMapper mapper,
|
||||
StructuredDataService structuredDataService
|
||||
StructuredDataService structuredDataService, DataCatalog catalog
|
||||
) {
|
||||
this.mapper = mapper;
|
||||
this.structuredDataService = structuredDataService;
|
||||
this.catalog = catalog;
|
||||
}
|
||||
|
||||
public List<StructuredDataTable> tables() {
|
||||
@@ -54,7 +55,7 @@ public class SchemaMetadataService {
|
||||
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
|
||||
return new SchemaMetadataView(
|
||||
table,
|
||||
nullToEmpty(mapper.findTableComment(OWNER, tableName)),
|
||||
nullToEmpty(mapper.findTableComment(catalog.owner(), tableName)),
|
||||
annotations.getOrDefault(tableTargetKey(), List.of()),
|
||||
columns
|
||||
);
|
||||
@@ -65,7 +66,7 @@ public class SchemaMetadataService {
|
||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
|
||||
mapper.updateTableComment(OWNER, tableName, quoteLiteral(normalizedComment));
|
||||
mapper.updateTableComment(catalog.owner(), tableName, quoteLiteral(normalizedComment));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -74,7 +75,7 @@ public class SchemaMetadataService {
|
||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||
String column = requireColumn(tableName, columnName);
|
||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
|
||||
mapper.updateColumnComment(OWNER, tableName, column, quoteLiteral(normalizedComment));
|
||||
mapper.updateColumnComment(catalog.owner(), tableName, column, quoteLiteral(normalizedComment));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -106,16 +107,16 @@ public class SchemaMetadataService {
|
||||
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
|
||||
if (annotationExists(tableName, columnName, key)) {
|
||||
if (columnName == null) {
|
||||
mapper.dropTableAnnotation(OWNER, tableName, key);
|
||||
mapper.dropTableAnnotation(catalog.owner(), tableName, key);
|
||||
} else {
|
||||
mapper.dropColumnAnnotation(OWNER, tableName, columnName, key);
|
||||
mapper.dropColumnAnnotation(catalog.owner(), tableName, columnName, key);
|
||||
}
|
||||
}
|
||||
if (!value.isBlank()) {
|
||||
if (columnName == null) {
|
||||
mapper.addTableAnnotation(OWNER, tableName, key, quoteLiteral(value));
|
||||
mapper.addTableAnnotation(catalog.owner(), tableName, key, quoteLiteral(value));
|
||||
} else {
|
||||
mapper.addColumnAnnotation(OWNER, tableName, columnName, key, quoteLiteral(value));
|
||||
mapper.addColumnAnnotation(catalog.owner(), tableName, columnName, key, quoteLiteral(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,7 +125,7 @@ public class SchemaMetadataService {
|
||||
String tableName,
|
||||
Map<String, List<SchemaAnnotation>> annotations
|
||||
) {
|
||||
return mapper.findColumns(OWNER, tableName).stream()
|
||||
return mapper.findColumns(catalog.owner(), tableName).stream()
|
||||
.map(row -> toColumn(row, annotations))
|
||||
.toList();
|
||||
}
|
||||
@@ -175,7 +176,7 @@ public class SchemaMetadataService {
|
||||
|
||||
private String requireColumn(String tableName, String columnName) {
|
||||
String column = requireSimpleName(columnName, "column name");
|
||||
if (mapper.countColumn(OWNER, tableName, column) == 0) {
|
||||
if (mapper.countColumn(catalog.owner(), tableName, column) == 0) {
|
||||
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
|
||||
}
|
||||
return column;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.SecuritySqlScriptProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScript;
|
||||
import com.cloudhandson.vpdbackoffice.domain.securityscript.SecuritySqlScriptSummary;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -17,25 +21,18 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class SecuritySqlScriptService {
|
||||
|
||||
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
|
||||
new ScriptDefinition(
|
||||
"smilegate-tool-users",
|
||||
"Smilegate 사용자",
|
||||
"70_sg_tool_user.sql",
|
||||
"PoC 도구 사용자 초기 데이터",
|
||||
"Data & AI TF 팀장·팀원 데모 사용자와 역할을 생성합니다. 게임 서비스 사용자가 아닌 PoC 도구 운영 사용자입니다."
|
||||
),
|
||||
new ScriptDefinition(
|
||||
"smilegate-identity-administration",
|
||||
"Smilegate 권한",
|
||||
"71_sg_identity_administration.sql",
|
||||
"사용자·그룹·역할 관리 모델",
|
||||
"Smilegate PoC 운영 사용자, 그룹, 역할, 권한 메타데이터와 백오피스 호환 뷰를 생성합니다."
|
||||
)
|
||||
private static final Pattern SCRIPT_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}");
|
||||
private static final Pattern RESOURCE_PATH = Pattern.compile(
|
||||
"(?:[A-Za-z0-9][A-Za-z0-9_-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.sql"
|
||||
);
|
||||
private final List<ScriptDefinition> scripts;
|
||||
|
||||
public SecuritySqlScriptService(SecuritySqlScriptProperties properties, ObjectMapper objectMapper) {
|
||||
scripts = parse(properties.scripts(), objectMapper);
|
||||
}
|
||||
|
||||
public List<SecuritySqlScriptSummary> list() {
|
||||
return CURATED_SCRIPTS.stream()
|
||||
return scripts.stream()
|
||||
.map(definition -> new SecuritySqlScriptSummary(
|
||||
definition.scriptId(),
|
||||
definition.category(),
|
||||
@@ -47,7 +44,7 @@ public class SecuritySqlScriptService {
|
||||
}
|
||||
|
||||
public SecuritySqlScript find(String scriptId) {
|
||||
ScriptDefinition definition = CURATED_SCRIPTS.stream()
|
||||
ScriptDefinition definition = scripts.stream()
|
||||
.filter(candidate -> candidate.scriptId().equals(scriptId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AppException("조회할 수 없는 보안 SQL 스크립트입니다."));
|
||||
@@ -70,7 +67,36 @@ public class SecuritySqlScriptService {
|
||||
}
|
||||
}
|
||||
|
||||
private record ScriptDefinition(
|
||||
private List<ScriptDefinition> parse(String raw, ObjectMapper objectMapper) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
List<ScriptDefinition> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
|
||||
if (parsed.isEmpty() || parsed.stream().map(ScriptDefinition::scriptId).distinct().count() != parsed.size()) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
parsed.forEach(this::validate);
|
||||
return List.copyOf(parsed);
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("BACKOFFICE_SECURITY_SQL_SCRIPTS 설정을 확인하세요.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void validate(ScriptDefinition definition) {
|
||||
if (definition == null
|
||||
|| definition.scriptId() == null || !SCRIPT_ID.matcher(definition.scriptId()).matches()
|
||||
|| definition.fileName() == null || !RESOURCE_PATH.matcher(definition.fileName()).matches()
|
||||
|| blank(definition.category()) || blank(definition.title()) || blank(definition.description())) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean blank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
public record ScriptDefinition(
|
||||
String scriptId,
|
||||
String category,
|
||||
String fileName,
|
||||
|
||||
@@ -21,10 +21,10 @@ import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Generates and executes bounded read-only SQL through the schema-owned Smilegate Select AI profile.
|
||||
* Generates and executes bounded read-only SQL through the configured schema-owned Select AI profile.
|
||||
*/
|
||||
@Service
|
||||
public class SmilegateSelectAiService {
|
||||
public class SelectAiService {
|
||||
|
||||
private static final int MAX_PROMPT_LENGTH = 4_000;
|
||||
private static final int MAX_RESULT_ROWS = 100;
|
||||
@@ -40,7 +40,7 @@ public class SmilegateSelectAiService {
|
||||
private final Clock clock;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SmilegateSelectAiService(
|
||||
public SelectAiService(
|
||||
BackofficeProperties properties,
|
||||
BearerTokenService bearerTokenService,
|
||||
Clock clock,
|
||||
@@ -57,7 +57,7 @@ public class SmilegateSelectAiService {
|
||||
String normalizedPrompt = requiredPrompt(prompt);
|
||||
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||
if (selectAi == null || !selectAi.configured()) {
|
||||
throw new AppException("Smilegate Select AI 연결 설정이 필요합니다. "
|
||||
throw new AppException("Select AI 연결 설정이 필요합니다. "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
|
||||
}
|
||||
@@ -117,7 +117,7 @@ public class SmilegateSelectAiService {
|
||||
} catch (AppException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("Smilegate Select AI SHOWSQL 생성 실패: " + exception.getMessage());
|
||||
throw new AppException("Select AI SHOWSQL 생성 실패: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ public class SmilegateSelectAiService {
|
||||
connection.rollback();
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("Smilegate Select AI 생성 SQL 실행 실패: " + exception.getMessage());
|
||||
throw new AppException("Select AI 생성 SQL 실행 실패: " + exception.getMessage());
|
||||
}
|
||||
return new QueryExecution(items, truncated);
|
||||
}
|
||||
@@ -11,36 +11,25 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class StructuredDataService {
|
||||
|
||||
private static final String OWNER = "SGMP_POC";
|
||||
private static final int ROW_LIMIT = 50;
|
||||
private static final List<StructuredDataTable> TABLES = List.of(
|
||||
new StructuredDataTable("game-users", "CZN_COMN_USER_MST", "게임 사용자", "카제나 게임 사용자 마스터"),
|
||||
new StructuredDataTable("characters", "CZN_COMN_CHARACTER_MST", "캐릭터", "카제나 캐릭터 마스터"),
|
||||
new StructuredDataTable("sales", "COMN_SALES_TXN", "판매 거래", "게임 상품 판매 거래"),
|
||||
new StructuredDataTable("refunds", "COMN_REFUND_TXN", "환불 거래", "게임 상품 환불 거래"),
|
||||
new StructuredDataTable("products", "COMN_SALES_PRODUCT_DISP_BAS", "상품", "판매 상품 전시 기준"),
|
||||
new StructuredDataTable("game-servers", "COMN_GAME_SERVER_BAS", "게임 서버", "게임 서버 기준 정보"),
|
||||
new StructuredDataTable("game-aliases", "COMN_GAME_ALIAS_BAS", "게임 별칭", "게임명·별칭·prefix 매핑"));
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final DataCatalog catalog;
|
||||
|
||||
public StructuredDataService(JdbcTemplate jdbcTemplate) {
|
||||
public StructuredDataService(JdbcTemplate jdbcTemplate, DataCatalog catalog) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.catalog = catalog;
|
||||
}
|
||||
|
||||
public List<StructuredDataTable> tables() {
|
||||
return TABLES;
|
||||
return catalog.objects();
|
||||
}
|
||||
|
||||
public String defaultKey() {
|
||||
return TABLES.getFirst().key();
|
||||
return catalog.objects().getFirst().key();
|
||||
}
|
||||
|
||||
public StructuredDataTable requireTable(String key) {
|
||||
return TABLES.stream()
|
||||
.filter(table -> table.key().equals(key))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다."));
|
||||
return catalog.require(key);
|
||||
}
|
||||
|
||||
public StructuredDataPreview preview(String key) {
|
||||
@@ -54,7 +43,7 @@ public class StructuredDataService {
|
||||
AND table_name = ?
|
||||
ORDER BY column_id
|
||||
""",
|
||||
(resultSet, rowNum) -> resultSet.getString(1), OWNER, table.tableName());
|
||||
(resultSet, rowNum) -> resultSet.getString(1), catalog.owner(), table.tableName());
|
||||
if (columns.isEmpty()) {
|
||||
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
|
||||
}
|
||||
@@ -63,24 +52,11 @@ public class StructuredDataService {
|
||||
previewSql(table), ROW_LIMIT);
|
||||
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
|
||||
} catch (DataAccessException exception) {
|
||||
throw new AppException("게임 데이터를 조회할 수 없습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요.");
|
||||
throw new AppException("카탈로그 데이터를 조회할 수 없습니다. DB 권한과 대상 객체 상태를 확인하세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The table is selected from a closed application whitelist, so the query
|
||||
* text remains fixed and no request value can become a SQL identifier.
|
||||
*/
|
||||
private String previewSql(StructuredDataTable table) {
|
||||
return switch (table.key()) {
|
||||
case "game-users" -> "SELECT * FROM SGMP_POC.CZN_COMN_USER_MST WHERE ROWNUM <= ?";
|
||||
case "characters" -> "SELECT * FROM SGMP_POC.CZN_COMN_CHARACTER_MST WHERE ROWNUM <= ?";
|
||||
case "sales" -> "SELECT * FROM SGMP_POC.COMN_SALES_TXN WHERE ROWNUM <= ?";
|
||||
case "refunds" -> "SELECT * FROM SGMP_POC.COMN_REFUND_TXN WHERE ROWNUM <= ?";
|
||||
case "products" -> "SELECT * FROM SGMP_POC.COMN_SALES_PRODUCT_DISP_BAS WHERE ROWNUM <= ?";
|
||||
case "game-servers" -> "SELECT * FROM SGMP_POC.COMN_GAME_SERVER_BAS WHERE ROWNUM <= ?";
|
||||
case "game-aliases" -> "SELECT * FROM SGMP_POC.COMN_GAME_ALIAS_BAS WHERE ROWNUM <= ?";
|
||||
default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다.");
|
||||
};
|
||||
return "SELECT * FROM \"" + catalog.owner() + "\".\"" + table.tableName() + "\" WHERE ROWNUM <= ?";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user