refs #703 #704: finalize smilegate game data poc

This commit is contained in:
devmrko
2026-07-22 15:35:02 +09:00
parent 20c6a82338
commit 211dd2b2c8
42 changed files with 576 additions and 445 deletions

View File

@@ -258,8 +258,8 @@ public class BackofficeSchemaService {
"문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"),
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL",
"앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식")
new MaskingRuleSeed("MASK_IDENTIFIER_PARTIAL", "식별번호 부분 마스킹", "RRN_PARTIAL",
"앞 6자리만 보이고 나머지는 가리는 게임 사용자 식별번호 마스킹 방식")
);
private static final String MASKING_RULE_SEED_SQL = """
@@ -705,7 +705,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/62_kb_aso_masking_backoffice_metadata.sql
@sql/adb/71_sg_identity_administration.sql
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
-- 2. ORDS parsing schema로 접속
@@ -717,9 +717,8 @@ 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에서 컬럼에 연결한 뒤 실행
@sql/adb/64_kb_aso_masking_default_column_rules.sql
@sql/adb/63_kb_aso_masking_rule_runtime.sql
-- 4. 마스킹 규칙을 UI에서 게임 데이터 컬럼에 연결
-- DBMS_REDACT 정책은 백오피스가 SGMP_POC 대상에 자동 동기화합니다.
""".formatted(owner.toLowerCase());
}

View File

@@ -24,7 +24,7 @@ import org.springframework.stereotype.Service;
@Service
public class MaskingPolicySynchronizer {
private static final String OWNER = "POC_2";
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();
@@ -38,10 +38,10 @@ public class MaskingPolicySynchronizer {
private static Map<String, String> managedPolicyMap() {
Map<String, String> policies = new LinkedHashMap<>();
policies.put("KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT");
policies.put("KB_CLAIMS", "KB_CLAIM_AMOUNT_REDACT");
policies.put("KB_CONTRACTS", "KB_CONTRACT_PREMIUM_REDACT");
policies.put("KB_EXTERNAL_HOLDINGS", "KB_EXT_HOLDING_REDACT");
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);
}

View File

@@ -55,7 +55,7 @@ public class MaskingRuleService {
return mapper.findColumnRules();
}
/** Reads the actual Oracle Data Redaction state for the three managed KB objects. */
/** Reads the actual Oracle Data Redaction state for the managed Smilegate game-data objects. */
public List<MaskingPolicyStatus> findPolicyStatuses() {
return mapper.findPolicyStatuses();
}

View File

@@ -8,30 +8,29 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
import org.springframework.stereotype.Service;
/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */
/** MCP boundary exposing the Smilegate game-data Select AI SHOWSQL tool. */
@Service
public class McpSseService {
private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd";
private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query";
private static final String SELECT_AI_VPD_QUERY_PROFILE =
"KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1";
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 SELECT_AI_VPD_QUERY_PROFILE = "SGMP_POC_HAIKU45";
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
SELECT_AI_VPD_QUERY_TOOL,
"GPT-5.4-mini Select AI 자연어 질의를 행 접근 컨텍스트로 실행합니다. 테이블/컬럼 comment, annotation, constraint 메타데이터를 사용하고 생성 SQL은 KB 업무 테이블의 읽기 전용 SELECT/WITH만 허용합니다.",
"SGMP_POC_HAIKU45 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성합니다. 생성 SQL은 자동 실행하지 않으며 테이블·컬럼 comment, annotation, constraint를 참고합니다.",
-1L,
"KB Select AI 행 접근 자연어 조회",
"Smilegate 게임 데이터 Text2SQL",
SELECT_AI_VPD_QUERY_PATH
);
private final SelectAiAgentOrdsService selectAiAgentOrdsService;
private final SmilegateSelectAiService smilegateSelectAiService;
private final ObjectMapper objectMapper;
public McpSseService(
SelectAiAgentOrdsService selectAiAgentOrdsService,
SmilegateSelectAiService smilegateSelectAiService,
ObjectMapper objectMapper
) {
this.selectAiAgentOrdsService = selectAiAgentOrdsService;
this.smilegateSelectAiService = smilegateSelectAiService;
this.objectMapper = objectMapper;
}
@@ -106,17 +105,10 @@ public class McpSseService {
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다.");
prompt.put("description", "Smilegate 게임 로그·서비스 데이터에 대해 조회할 내용을 자연어로 입력합니다.");
prompt.put("maxLength", 4000);
properties.set("prompt", prompt);
ObjectNode limit = objectMapper.createObjectNode();
limit.put("type", "integer");
limit.put("description", "최대 반환 행 수. 1부터 100까지 허용하며 기본값은 50입니다.");
limit.put("minimum", 1);
limit.put("maximum", 100);
properties.set("limit", limit);
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode();
required.add("prompt");
@@ -139,11 +131,7 @@ public class McpSseService {
}
JsonNode response;
try {
response = selectAiAgentOrdsService.run(
token,
arguments.path("prompt").asText(""),
normalizeLimit(arguments.path("limit").asInt(50))
);
response = smilegateSelectAiService.generateShowSql(token, arguments.path("prompt").asText(""));
} catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult();
}
@@ -181,13 +169,6 @@ public class McpSseService {
return result;
}
private int normalizeLimit(int limit) {
if (limit < 1) {
return 50;
}
return Math.min(limit, 100);
}
private String pretty(Object value) {
try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);

View File

@@ -643,7 +643,7 @@ public class OrdsProbeService {
}
}
}
// APEX_JSON omits a ref-cursor column whose value is NULL. The KB
// APEX_JSON omits a ref-cursor column whose value is NULL. The
// object handlers always select every registered protected column, so
// a sensitive column missing from every JSON row is also a NULL result
// and must be reported as masked to the MCP/UI caller.

View File

@@ -19,39 +19,18 @@ public class SecuritySqlScriptService {
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
new ScriptDefinition(
"aso-masking-metadata",
"ASO / 마스킹",
"62_kb_aso_masking_backoffice_metadata.sql",
"컬럼 마스킹 규칙 메타데이터",
"ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다."
"smilegate-tool-users",
"Smilegate 사용자",
"70_sg_tool_user.sql",
"PoC 도구 사용자 초기 데이터",
"Data & AI TF 팀장·팀원 데모 사용자와 역할을 생성합니다. 게임 서비스 사용자가 아닌 PoC 도구 운영 사용자입니다."
),
new ScriptDefinition(
"aso-masking-runtime",
"ASO / 마스킹",
"63_kb_aso_masking_rule_runtime.sql",
"ASO 마스킹 런타임 적용",
"백오피스 컬럼 마스킹 규칙을 Oracle Data Redaction 정책과 신뢰 컨텍스트에 반영합니다."
),
new ScriptDefinition(
"aso-masking-default-columns",
"ASO / 마스킹",
"64_kb_aso_masking_default_column_rules.sql",
"ASO 기본 대상 컬럼",
"주민번호·청구/지급금·타사보유 컬럼의 마스킹 블랙리스트 초기값을 연결합니다."
),
new ScriptDefinition(
"select-ai-vpd-api",
"Select AI / 행 접근",
"65_kb_select_ai_vpd_query_api.sql",
"행 접근 적용 Select AI 조회 API",
"생성 SQL을 KB 업무 테이블의 단일 읽기 전용 SELECT/WITH로 검증해 행 접근 컨텍스트에서 실행합니다."
),
new ScriptDefinition(
"select-ai-vpd-ords",
"ORDS / Select AI",
"66_kb_select_ai_vpd_query_ords.sql",
"Select AI 행 접근 ORDS Endpoint",
"Bearer 토큰을 검증해 행 접근 컨텍스트를 설정한 뒤 Select AI 조회 API를 노출합니다."
"smilegate-identity-administration",
"Smilegate 권한",
"71_sg_identity_administration.sql",
"사용자·그룹·역할 관리 모델",
"Smilegate PoC 운영 사용자, 그룹, 역할, 권한 메타데이터와 백오피스 호환 뷰를 생성합니다."
)
);

View File

@@ -1,137 +0,0 @@
package com.cloudhandson.vpdbackoffice.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.util.UUID;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
/** Calls the ORDS boundary; the database endpoint owns bearer-to-row-access-context mapping. */
@Service
public class SelectAiAgentOrdsService {
static final String ORDS_PATH = "/cb-ords/kb-select-ai-vpd/query";
private static final int MAX_PROMPT_LENGTH = 4_000;
private static final int MAX_LIMIT = 100;
private final SettingService settingService;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
public SelectAiAgentOrdsService(
SettingService settingService,
RestTemplate ordsAgentRestTemplate,
ObjectMapper objectMapper
) {
this.settingService = settingService;
this.restTemplate = ordsAgentRestTemplate;
this.objectMapper = objectMapper;
}
/**
* Compatibility overload for callers of the former SQL-generation tool.
* The row-access query endpoint is stateless and therefore ignores conversationId.
*/
public JsonNode run(String bearerToken, String prompt, String conversationId) {
return run(bearerToken, prompt, 50);
}
public JsonNode run(String bearerToken, String prompt, int limit) {
String normalizedToken = required(bearerToken, "bearerToken");
String normalizedPrompt = required(prompt, "prompt");
if (normalizedPrompt.length() > MAX_PROMPT_LENGTH) {
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
}
int normalizedLimit = normalizeLimit(limit);
String baseUrl = settingService.ordsBaseUrl();
if (baseUrl == null || baseUrl.isBlank()) {
throw new AppException("ORDS base URL이 설정되지 않았습니다.");
}
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("prompt", normalizedPrompt);
requestBody.put("limit", normalizedLimit);
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(normalizedToken);
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-VPD-Probe-Id", UUID.randomUUID().toString());
try {
ResponseEntity<String> response = restTemplate.exchange(
endpoint(baseUrl),
HttpMethod.POST,
new HttpEntity<>(requestBody.toString(), headers),
String.class
);
JsonNode body = parse(response.getBody());
if (body.hasNonNull("error")) {
throw new AppException("Select AI 행 접근 ORDS 오류: " + body.path("error").asText());
}
return body;
} catch (HttpStatusCodeException e) {
if (e.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)
|| e.getStatusCode().isSameCodeAs(HttpStatus.FORBIDDEN)) {
throw new VpdTokenAccessDeniedException();
}
throw new AppException("Select AI 행 접근 ORDS HTTP " + e.getStatusCode().value()
+ ": " + responseError(e.getResponseBodyAsString()));
} catch (ResourceAccessException e) {
throw new AppException("Select AI 행 접근 ORDS 연결 또는 응답 시간 초과: " + e.getMessage());
}
}
private URI endpoint(String baseUrl) {
return UriComponentsBuilder.fromUriString(baseUrl)
.path(ORDS_PATH)
.build()
.toUri();
}
private JsonNode parse(String value) {
try {
if (value == null || value.isBlank()) {
throw new AppException("Select AI 행 접근 ORDS 응답 본문이 비어 있습니다.");
}
return objectMapper.readTree(value);
} catch (AppException e) {
throw e;
} catch (Exception e) {
throw new AppException("Select AI 행 접근 ORDS 응답 JSON 파싱 실패: " + e.getMessage());
}
}
private String responseError(String body) {
try {
JsonNode parsed = objectMapper.readTree(body);
return parsed.path("error").asText(body == null ? "" : body);
} catch (Exception ignored) {
return body == null ? "" : body;
}
}
private String required(String value, String name) {
if (value == null || value.isBlank()) {
throw new AppException(name + "은(는) 필수입니다.");
}
return value.trim();
}
private int normalizeLimit(int value) {
if (value < 1) {
return 50;
}
return Math.min(value, MAX_LIMIT);
}
}

View File

@@ -0,0 +1,125 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import org.springframework.stereotype.Service;
/**
* Generates reviewed read-only SQL through the schema-owned Smilegate Select AI profile.
* The generated SQL is never executed by this service.
*/
@Service
public class SmilegateSelectAiService {
private static final int MAX_PROMPT_LENGTH = 4_000;
private static final String ACTION_SHOWSQL = "showsql";
private final BackofficeProperties properties;
private final BearerTokenService bearerTokenService;
private final Clock clock;
private final ObjectMapper objectMapper;
public SmilegateSelectAiService(
BackofficeProperties properties,
BearerTokenService bearerTokenService,
Clock clock,
ObjectMapper objectMapper
) {
this.properties = properties;
this.bearerTokenService = bearerTokenService;
this.clock = clock;
this.objectMapper = objectMapper;
}
public JsonNode generateShowSql(String bearerToken, String prompt) {
requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || !selectAi.configured()) {
throw new AppException("Smilegate Select AI 연결 설정이 필요합니다. "
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
}
String generatedSql = generate(selectAi, normalizedPrompt);
String normalizedSql = validateReadOnlySql(generatedSql);
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "SHOWSQL");
response.put("profile", selectAi.profile());
response.put("generatedSql", normalizedSql);
response.put("execution", "NOT_EXECUTED");
response.put("nextStep", "생성 SQL을 검토한 뒤 Database Actions 또는 승인된 실행 경로에서 실행하세요.");
return response;
}
private void requireActiveToken(String bearerToken) {
if (bearerToken == null || bearerToken.isBlank()) {
throw new VpdTokenAccessDeniedException();
}
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
if (token == null || !token.active(now)) {
throw new VpdTokenAccessDeniedException();
}
}
private String requiredPrompt(String prompt) {
String normalized = prompt == null ? "" : prompt.trim();
if (normalized.isEmpty()) {
throw new AppException("prompt는 필수입니다.");
}
if (normalized.length() > MAX_PROMPT_LENGTH) {
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
}
return normalized;
}
private String generate(BackofficeProperties.SelectAi selectAi, String prompt) {
String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, 'showsql') FROM dual";
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, prompt);
statement.setString(2, selectAi.profile());
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next() || resultSet.getString(1) == null) {
throw new AppException("Select AI가 생성 SQL을 반환하지 않았습니다.");
}
return resultSet.getString(1);
}
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException("Smilegate Select AI SHOWSQL 생성 실패: " + exception.getMessage());
}
}
private String validateReadOnlySql(String generatedSql) {
String normalized = generatedSql == null ? "" : generatedSql.trim();
if (normalized.startsWith("```")) {
int firstLineEnd = normalized.indexOf('\n');
int closingFence = normalized.lastIndexOf("```");
if (firstLineEnd >= 0 && closingFence > firstLineEnd) {
normalized = normalized.substring(firstLineEnd + 1, closingFence).trim();
}
}
normalized = normalized.replaceFirst(";\\s*$", "").trim();
if (!normalized.matches("(?is)^(select|with)\\b.*")) {
throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다.");
}
if (normalized.contains(";")) {
throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다.");
}
return normalized;
}
}