refs #723: externalize backoffice catalogs and MCP tools
This commit is contained in:
@@ -6,7 +6,14 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(BackofficeProperties.class)
|
||||
@EnableConfigurationProperties({
|
||||
BackofficeProperties.class,
|
||||
CatalogProperties.class,
|
||||
MaskingProperties.class,
|
||||
McpProperties.class,
|
||||
ProductProperties.class,
|
||||
SecuritySqlScriptProperties.class
|
||||
})
|
||||
public class AppConfig {
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -8,8 +8,8 @@ public record BackofficeProperties(
|
||||
Security security,
|
||||
Token token,
|
||||
Ords ords,
|
||||
Mcp mcp,
|
||||
Ai ai
|
||||
Ai ai,
|
||||
SelectAi selectAi
|
||||
) {
|
||||
|
||||
public record Security(
|
||||
@@ -55,13 +55,6 @@ public record BackofficeProperties(
|
||||
public record Ords(String baseUrl, Duration timeout, Duration agentTimeout) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Public HMM MCP endpoint used by Agent Factory and by operators who need to inspect the
|
||||
* deployed tool contract. It is deliberately separate from the optional legacy ORDS URL.
|
||||
*/
|
||||
public record Mcp(String publicUrl) {
|
||||
}
|
||||
|
||||
public record Ai(
|
||||
boolean enabled,
|
||||
String provider,
|
||||
@@ -80,4 +73,20 @@ public record BackofficeProperties(
|
||||
this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", "");
|
||||
}
|
||||
}
|
||||
|
||||
/** Separate ADB connection because Select AI profiles are owned by a schema-specific account. */
|
||||
public record SelectAi(
|
||||
String dbUrl,
|
||||
String dbUsername,
|
||||
String dbPassword,
|
||||
String profile
|
||||
) {
|
||||
|
||||
public boolean configured() {
|
||||
return dbUrl != null && !dbUrl.isBlank()
|
||||
&& dbUsername != null && !dbUsername.isBlank()
|
||||
&& dbPassword != null && !dbPassword.isBlank()
|
||||
&& profile != null && !profile.isBlank();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Deployment-provided allow-list for the structured-data and metadata screens. */
|
||||
@ConfigurationProperties(prefix = "backoffice.catalog")
|
||||
public record CatalogProperties(String owner, String objects) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** JSON configuration of database redaction policies this backoffice may manage. */
|
||||
@ConfigurationProperties(prefix = "backoffice.masking")
|
||||
public record MaskingProperties(String policies) {
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Product-neutral MCP endpoint labels and tool catalogue configuration. */
|
||||
@ConfigurationProperties(prefix = "backoffice.mcp")
|
||||
public record McpProperties(
|
||||
String publicUrl,
|
||||
String serverName,
|
||||
String toolName,
|
||||
String toolLabel,
|
||||
String toolDescription,
|
||||
String promptDescription,
|
||||
String tools
|
||||
) {
|
||||
|
||||
private static final String DEFAULT_PUBLIC_URL = "/mcp";
|
||||
private static final String DEFAULT_SERVER_NAME = "data-ai-backoffice";
|
||||
private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql";
|
||||
private static final String DEFAULT_TOOL_LABEL = "업무 데이터 Text2SQL";
|
||||
private static final String DEFAULT_TOOL_DESCRIPTION =
|
||||
"승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 "
|
||||
+ "트랜잭션에서 실행합니다.";
|
||||
private static final String DEFAULT_PROMPT_DESCRIPTION =
|
||||
"업무 데이터에서 조회할 내용을 자연어로 입력합니다.";
|
||||
|
||||
public String resolvedPublicUrl() {
|
||||
return requiredOrDefault(publicUrl, DEFAULT_PUBLIC_URL);
|
||||
}
|
||||
|
||||
public String resolvedServerName() {
|
||||
return requiredOrDefault(serverName, DEFAULT_SERVER_NAME);
|
||||
}
|
||||
|
||||
public String resolvedToolName() {
|
||||
return requiredOrDefault(toolName, DEFAULT_TOOL_NAME);
|
||||
}
|
||||
|
||||
public String resolvedToolLabel() {
|
||||
return requiredOrDefault(toolLabel, DEFAULT_TOOL_LABEL);
|
||||
}
|
||||
|
||||
public String resolvedToolDescription() {
|
||||
return requiredOrDefault(toolDescription, DEFAULT_TOOL_DESCRIPTION);
|
||||
}
|
||||
|
||||
public String resolvedPromptDescription() {
|
||||
return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION);
|
||||
}
|
||||
|
||||
private String requiredOrDefault(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Customer-facing labels that do not affect authorization or database identity. */
|
||||
@ConfigurationProperties(prefix = "backoffice.product")
|
||||
public record ProductProperties(String name, String title, String dataLabel) {
|
||||
|
||||
public String displayName() {
|
||||
return name == null || name.isBlank() ? "Data & AI Backoffice" : name.trim();
|
||||
}
|
||||
|
||||
public String pageTitle() {
|
||||
return title == null || title.isBlank() ? displayName() : title.trim();
|
||||
}
|
||||
|
||||
public String dataName() {
|
||||
return dataLabel == null || dataLabel.isBlank() ? "업무 데이터" : dataLabel.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Deployment-provided allow-list for bundled security SQL shown by the backoffice. */
|
||||
@ConfigurationProperties(prefix = "backoffice.security-sql-scripts")
|
||||
public record SecuritySqlScriptProperties(String scripts) {
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.masking;
|
||||
|
||||
public record ManagedMaskingPolicy(
|
||||
String objectName,
|
||||
String policyName
|
||||
) {
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.structured;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record StructuredDataCatalog(
|
||||
String sourceName,
|
||||
String owner,
|
||||
String pageHelp,
|
||||
String catalogDescription,
|
||||
int rowLimit,
|
||||
List<StructuredDataTable> tables
|
||||
) {
|
||||
}
|
||||
@@ -5,13 +5,17 @@ import java.util.List;
|
||||
public record StructuredDataTable(
|
||||
String key,
|
||||
String tableName,
|
||||
String objectType,
|
||||
String businessName,
|
||||
String description,
|
||||
List<String> previewColumns,
|
||||
String maskingPolicyName
|
||||
List<String> previewColumns
|
||||
) {
|
||||
|
||||
public StructuredDataTable(String key, String tableName, String businessName, String description) {
|
||||
this(key, tableName, businessName, description, List.of(), null);
|
||||
this(key, tableName, "TABLE", businessName, description, List.of());
|
||||
}
|
||||
|
||||
public boolean isTable() {
|
||||
return "TABLE".equals(objectType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.ManagedMaskingPolicy;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
|
||||
import com.cloudhandson.vpdbackoffice.service.MaskingPolicyTarget;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
@@ -31,7 +31,7 @@ public interface MaskingRuleMapper {
|
||||
|
||||
List<MaskingPolicyStatus> findPolicyStatuses(
|
||||
@Param("owner") String owner,
|
||||
@Param("policies") List<ManagedMaskingPolicy> policies
|
||||
@Param("policies") List<MaskingPolicyTarget> policies
|
||||
);
|
||||
|
||||
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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,122 @@
|
||||
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.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Validated deployment allow-list for data preview and metadata operations. */
|
||||
@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 objectMapper) {
|
||||
try {
|
||||
owner = requireName(properties.owner(), "BACKOFFICE_CATALOG_OWNER");
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("BACKOFFICE_CATALOG_OWNER 설정을 확인하세요.", exception);
|
||||
}
|
||||
objects = parse(properties.objects(), objectMapper);
|
||||
}
|
||||
|
||||
@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 objectMapper) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.");
|
||||
}
|
||||
try {
|
||||
List<StructuredDataTable> parsed = objectMapper.readValue(raw, new TypeReference<>() {});
|
||||
if (parsed.isEmpty()) {
|
||||
throw new IllegalArgumentException("카탈로그 객체가 비어 있습니다.");
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
Set<String> objectNames = new HashSet<>();
|
||||
List<StructuredDataTable> normalized = parsed.stream()
|
||||
.map(this::normalize)
|
||||
.peek(item -> {
|
||||
if (!keys.add(item.key())) {
|
||||
throw new IllegalArgumentException("중복 key: " + item.key());
|
||||
}
|
||||
if (!objectNames.add(item.tableName())) {
|
||||
throw new IllegalArgumentException("중복 tableName: " + item.tableName());
|
||||
}
|
||||
})
|
||||
.toList();
|
||||
return List.copyOf(normalized);
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("BACKOFFICE_CATALOG_OBJECTS 설정을 확인하세요.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private StructuredDataTable normalize(StructuredDataTable value) {
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException("null 카탈로그 객체");
|
||||
}
|
||||
String key = requiredText(value.key(), "key").toLowerCase(Locale.ROOT);
|
||||
if (!KEY.matcher(key).matches()) {
|
||||
throw new IllegalArgumentException("잘못된 key: " + value.key());
|
||||
}
|
||||
String objectName = requireName(value.tableName(), "tableName");
|
||||
String objectType = requiredText(value.objectType(), "objectType").toUpperCase(Locale.ROOT);
|
||||
if (!Set.of("TABLE", "VIEW").contains(objectType)) {
|
||||
throw new IllegalArgumentException("잘못된 objectType: " + value.objectType());
|
||||
}
|
||||
List<String> previewColumns = value.previewColumns() == null
|
||||
? List.of()
|
||||
: value.previewColumns().stream()
|
||||
.map(column -> requireName(column, "previewColumns"))
|
||||
.distinct()
|
||||
.toList();
|
||||
return new StructuredDataTable(
|
||||
key,
|
||||
objectName,
|
||||
objectType,
|
||||
requiredText(value.businessName(), "businessName"),
|
||||
requiredText(value.description(), "description"),
|
||||
List.copyOf(previewColumns)
|
||||
);
|
||||
}
|
||||
|
||||
private String requireName(String value, String field) {
|
||||
String normalized = requiredText(value, field).toUpperCase(Locale.ROOT);
|
||||
if (!NAME.matcher(normalized).matches()) {
|
||||
throw new IllegalArgumentException(field + " 형식이 올바르지 않습니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String requiredText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " 값은 필수입니다.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Loads the managed Data Redaction allow-list from deployment configuration. */
|
||||
@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()) {
|
||||
throw new IllegalArgumentException("마스킹 정책 목록이 비어 있습니다.");
|
||||
}
|
||||
Set<String> objectNames = new HashSet<>();
|
||||
Set<String> policyNames = new HashSet<>();
|
||||
List<MaskingPolicyTarget> normalized = parsed.stream()
|
||||
.map(item -> {
|
||||
if (item == null) {
|
||||
throw new IllegalArgumentException("null 마스킹 정책");
|
||||
}
|
||||
return new MaskingPolicyTarget(
|
||||
normalize(item.objectName()),
|
||||
normalize(item.policyName()));
|
||||
})
|
||||
.peek(item -> {
|
||||
if (!objectNames.add(item.objectName())) {
|
||||
throw new IllegalArgumentException("중복 objectName: " + item.objectName());
|
||||
}
|
||||
if (!policyNames.add(item.policyName())) {
|
||||
throw new IllegalArgumentException("중복 policyName: " + 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("Oracle 식별자 형식이 올바르지 않습니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Loads MCP tool contracts while retaining the #722 single Select AI compatibility fields. */
|
||||
@Service
|
||||
public class EnvironmentMcpToolCatalog implements McpToolCatalog {
|
||||
|
||||
private static final Pattern TOOL_NAME =
|
||||
Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}");
|
||||
private static final Pattern ARGUMENT_NAME =
|
||||
Pattern.compile("[A-Za-z][A-Za-z0-9_]{0,63}");
|
||||
private static final Pattern ORACLE_NAME =
|
||||
Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||
|
||||
private final List<McpToolDefinition> tools;
|
||||
|
||||
public EnvironmentMcpToolCatalog(
|
||||
McpProperties properties,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
tools = parse(properties, objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<McpToolDefinition> tools() {
|
||||
return tools;
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpToolDefinition require(String name) {
|
||||
return tools.stream()
|
||||
.filter(tool -> tool.name().equals(name))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AppException("등록되지 않은 MCP tool입니다: " + name));
|
||||
}
|
||||
|
||||
private List<McpToolDefinition> parse(
|
||||
McpProperties properties,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
String raw = properties.tools();
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return List.of(new McpToolDefinition(
|
||||
properties.resolvedToolName(),
|
||||
properties.resolvedToolLabel(),
|
||||
properties.resolvedToolDescription(),
|
||||
"prompt",
|
||||
properties.resolvedPromptDescription(),
|
||||
"SELECT_AI",
|
||||
"",
|
||||
""
|
||||
));
|
||||
}
|
||||
try {
|
||||
List<McpToolDefinition> parsed =
|
||||
objectMapper.readValue(raw, new TypeReference<>() {});
|
||||
if (parsed.isEmpty()) {
|
||||
throw new IllegalArgumentException("MCP 도구 목록이 비어 있습니다.");
|
||||
}
|
||||
Set<String> names = new HashSet<>();
|
||||
List<McpToolDefinition> normalized = parsed.stream()
|
||||
.map(this::normalize)
|
||||
.peek(tool -> {
|
||||
if (!names.add(tool.name())) {
|
||||
throw new IllegalArgumentException("중복 MCP tool name: " + tool.name());
|
||||
}
|
||||
})
|
||||
.toList();
|
||||
return List.copyOf(normalized);
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("BACKOFFICE_MCP_TOOLS 설정을 확인하세요.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private McpToolDefinition normalize(McpToolDefinition value) {
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException("null MCP tool");
|
||||
}
|
||||
String name = required(value.name(), "name");
|
||||
String argumentName = required(value.argumentName(), "argumentName");
|
||||
if (!TOOL_NAME.matcher(name).matches()) {
|
||||
throw new IllegalArgumentException("잘못된 MCP tool name");
|
||||
}
|
||||
if (!ARGUMENT_NAME.matcher(argumentName).matches()) {
|
||||
throw new IllegalArgumentException("잘못된 MCP argumentName");
|
||||
}
|
||||
String executionType = required(value.executionType(), "executionType")
|
||||
.toUpperCase(Locale.ROOT);
|
||||
if (!Set.of("AGENT_TOOL", "SELECT_AI").contains(executionType)) {
|
||||
throw new IllegalArgumentException("잘못된 MCP executionType");
|
||||
}
|
||||
String targetName = value.targetName() == null ? "" : value.targetName().trim();
|
||||
String targetParameterName =
|
||||
value.targetParameterName() == null ? "" : value.targetParameterName().trim();
|
||||
if ("AGENT_TOOL".equals(executionType)) {
|
||||
targetName = oracleName(targetName, "targetName");
|
||||
targetParameterName = oracleName(targetParameterName, "targetParameterName");
|
||||
} else if (!targetName.isBlank() || !targetParameterName.isBlank()) {
|
||||
throw new IllegalArgumentException("SELECT_AI에는 targetName을 지정할 수 없습니다.");
|
||||
}
|
||||
return new McpToolDefinition(
|
||||
name,
|
||||
required(value.label(), "label"),
|
||||
required(value.description(), "description"),
|
||||
argumentName,
|
||||
required(value.argumentDescription(), "argumentDescription"),
|
||||
executionType,
|
||||
targetName,
|
||||
targetParameterName
|
||||
);
|
||||
}
|
||||
|
||||
private String oracleName(String value, String field) {
|
||||
String normalized = required(value, field).toUpperCase(Locale.ROOT);
|
||||
if (!ORACLE_NAME.matcher(normalized).matches()) {
|
||||
throw new IllegalArgumentException("잘못된 " + field);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String required(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " 값은 필수입니다.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public interface MaskingPolicyCatalog {
|
||||
|
||||
List<MaskingPolicyTarget> targets();
|
||||
|
||||
default Set<String> objectNames() {
|
||||
return targets().stream()
|
||||
.map(MaskingPolicyTarget::objectName)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
default boolean containsObject(String objectName) {
|
||||
return objectName != null
|
||||
&& objectNames().contains(objectName.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@ package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
|
||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog;
|
||||
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;
|
||||
@@ -29,38 +27,43 @@ public class MaskingPolicySynchronizer {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final MaskingRuleMapper mapper;
|
||||
private final StructuredDataCatalog catalog;
|
||||
private final Map<String, String> managedPolicies;
|
||||
private final DataCatalog dataCatalog;
|
||||
private final MaskingPolicyCatalog policyCatalog;
|
||||
|
||||
public MaskingPolicySynchronizer(
|
||||
JdbcTemplate jdbcTemplate,
|
||||
MaskingRuleMapper mapper,
|
||||
StructuredDataCatalogProvider catalogProvider
|
||||
DataCatalog dataCatalog,
|
||||
MaskingPolicyCatalog policyCatalog
|
||||
) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.mapper = mapper;
|
||||
this.catalog = catalogProvider.catalog();
|
||||
Map<String, String> policies = new LinkedHashMap<>();
|
||||
catalog.tables().stream()
|
||||
.filter(table -> table.maskingPolicyName() != null)
|
||||
.forEach(table -> policies.put(table.tableName(), table.maskingPolicyName()));
|
||||
this.managedPolicies = Collections.unmodifiableMap(policies);
|
||||
this.dataCatalog = dataCatalog;
|
||||
this.policyCatalog = policyCatalog;
|
||||
}
|
||||
|
||||
public Set<String> managedObjectNames() {
|
||||
return managedPolicies.keySet();
|
||||
return policyCatalog.objectNames();
|
||||
}
|
||||
|
||||
public String owner() {
|
||||
return catalog.owner();
|
||||
return dataCatalog.owner();
|
||||
}
|
||||
|
||||
public boolean isManagedObject(String objectName) {
|
||||
return objectName != null && managedPolicies.containsKey(objectName.trim().toUpperCase(Locale.ROOT));
|
||||
return policyCatalog.containsObject(objectName);
|
||||
}
|
||||
|
||||
String managedPolicyName(String objectName) {
|
||||
return managedPolicies.get(objectName);
|
||||
return policyCatalog.targets().stream()
|
||||
.filter(target -> target.objectName().equals(objectName))
|
||||
.map(MaskingPolicyTarget::policyName)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
List<MaskingPolicyTarget> managedPolicies() {
|
||||
return policyCatalog.targets();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,9 +76,9 @@ public class MaskingPolicySynchronizer {
|
||||
public MaskingPolicySyncResult synchronize() {
|
||||
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
|
||||
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
|
||||
if (catalog.owner().equalsIgnoreCase(rule.owner())
|
||||
if (dataCatalog.owner().equalsIgnoreCase(rule.owner())
|
||||
&& rule.ruleEnabled()
|
||||
&& managedPolicies.containsKey(rule.objectName())) {
|
||||
&& policyCatalog.containsObject(rule.objectName())) {
|
||||
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
|
||||
}
|
||||
}
|
||||
@@ -85,9 +88,9 @@ public class MaskingPolicySynchronizer {
|
||||
int addedColumns = 0;
|
||||
int modifiedColumns = 0;
|
||||
int droppedColumns = 0;
|
||||
for (Map.Entry<String, String> policy : managedPolicies.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()) {
|
||||
@@ -147,7 +150,7 @@ public class MaskingPolicySynchronizer {
|
||||
SELECT enable
|
||||
FROM redaction_policies
|
||||
WHERE object_owner = ? AND object_name = ? AND policy_name = ?
|
||||
""", String.class, catalog.owner(), objectName, policyName);
|
||||
""", String.class, dataCatalog.owner(), objectName, policyName);
|
||||
return statuses.isEmpty() ? null : statuses.getFirst();
|
||||
}
|
||||
|
||||
@@ -156,7 +159,7 @@ public class MaskingPolicySynchronizer {
|
||||
SELECT column_name
|
||||
FROM redaction_columns
|
||||
WHERE object_owner = ? AND object_name = ?
|
||||
""", String.class, catalog.owner(), objectName).stream()
|
||||
""", String.class, dataCatalog.owner(), objectName).stream()
|
||||
.map(this::requiredColumnName)
|
||||
.toList();
|
||||
}
|
||||
@@ -166,7 +169,7 @@ public class MaskingPolicySynchronizer {
|
||||
BEGIN
|
||||
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||
END;
|
||||
""", catalog.owner(), objectName, policyName);
|
||||
""", dataCatalog.owner(), objectName, policyName);
|
||||
}
|
||||
|
||||
private void enablePolicy(String objectName, String policyName) {
|
||||
@@ -174,7 +177,7 @@ public class MaskingPolicySynchronizer {
|
||||
BEGIN
|
||||
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||
END;
|
||||
""", catalog.owner(), objectName, policyName);
|
||||
""", dataCatalog.owner(), objectName, policyName);
|
||||
}
|
||||
|
||||
private void dropColumn(String objectName, String policyName, String columnName) {
|
||||
@@ -185,7 +188,7 @@ public class MaskingPolicySynchronizer {
|
||||
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
|
||||
);
|
||||
END;
|
||||
""", catalog.owner(), objectName, policyName, columnName);
|
||||
""", dataCatalog.owner(), objectName, policyName, columnName);
|
||||
}
|
||||
|
||||
private void addPolicy(
|
||||
@@ -257,9 +260,9 @@ public class MaskingPolicySynchronizer {
|
||||
END;
|
||||
""".formatted(functionConstant);
|
||||
if (regexPattern == null) {
|
||||
jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName);
|
||||
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
||||
} else {
|
||||
jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -282,9 +285,9 @@ public class MaskingPolicySynchronizer {
|
||||
END;
|
||||
""".formatted(actionConstant, functionConstant);
|
||||
if (regexPattern == null) {
|
||||
jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName);
|
||||
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
||||
} else {
|
||||
jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +323,7 @@ public class MaskingPolicySynchronizer {
|
||||
object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ?
|
||||
);
|
||||
END;
|
||||
""", catalog.owner(), objectName, columnName, expressionName);
|
||||
""", dataCatalog.owner(), objectName, columnName, expressionName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
/** A validated database object-to-redaction-policy mapping. */
|
||||
public record MaskingPolicyTarget(String objectName, String policyName) {
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.ManagedMaskingPolicy;
|
||||
import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
|
||||
@@ -58,10 +57,10 @@ public class MaskingRuleService {
|
||||
|
||||
/** Reads Oracle Data Redaction state for the objects declared in the JSON catalogue. */
|
||||
public List<MaskingPolicyStatus> findPolicyStatuses() {
|
||||
List<ManagedMaskingPolicy> policies = maskingPolicySynchronizer.managedObjectNames().stream()
|
||||
.map(objectName -> new ManagedMaskingPolicy(
|
||||
objectName, maskingPolicySynchronizer.managedPolicyName(objectName)))
|
||||
.toList();
|
||||
List<MaskingPolicyTarget> policies = maskingPolicySynchronizer.managedPolicies();
|
||||
if (policies.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return mapper.findPolicyStatuses(maskingPolicySynchronizer.owner(), policies);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +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;
|
||||
@@ -8,45 +10,35 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Exposes the same read-only HMM HR tool contract as hmm-mcp.cloud-handson.com. */
|
||||
/** Authenticated MCP boundary backed by the deployment-provided tool allow-list. */
|
||||
@Service
|
||||
public class McpSseService {
|
||||
|
||||
private static final List<ToolSpec> HMM_TOOLS = List.of(
|
||||
new ToolSpec(
|
||||
"resolve_hr_term",
|
||||
"HMM_HR_TERM_RESOLVER",
|
||||
"term",
|
||||
"P_TERM",
|
||||
"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.",
|
||||
"HMM HR 용어 표준화"),
|
||||
new ToolSpec(
|
||||
"search_hr_data",
|
||||
"HMM_HR_NORMALIZED_DATA_SEARCH",
|
||||
"query",
|
||||
"P_QUERY",
|
||||
"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.",
|
||||
"HMM HR 데이터 조회"),
|
||||
new ToolSpec(
|
||||
"search_hr_policy",
|
||||
"HMM_HR_POLICY_SEARCH",
|
||||
"query",
|
||||
"P_QUERY",
|
||||
"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.",
|
||||
"HMM HR 규정 검색")
|
||||
);
|
||||
private static final String MCP_CALL_PATH = "/mcp (tools/call)";
|
||||
|
||||
private final HmmAiAgentToolRunner agentToolRunner;
|
||||
private final SelectAiService selectAiService;
|
||||
private final HmmMcpBearerAuthenticator bearerAuthenticator;
|
||||
private final McpToolCatalog toolCatalog;
|
||||
private final McpProperties mcpProperties;
|
||||
private final BackofficeProperties backofficeProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public McpSseService(
|
||||
HmmAiAgentToolRunner agentToolRunner,
|
||||
SelectAiService selectAiService,
|
||||
HmmMcpBearerAuthenticator bearerAuthenticator,
|
||||
McpToolCatalog toolCatalog,
|
||||
McpProperties mcpProperties,
|
||||
BackofficeProperties backofficeProperties,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.agentToolRunner = agentToolRunner;
|
||||
this.selectAiService = selectAiService;
|
||||
this.bearerAuthenticator = bearerAuthenticator;
|
||||
this.toolCatalog = toolCatalog;
|
||||
this.mcpProperties = mcpProperties;
|
||||
this.backofficeProperties = backofficeProperties;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -54,7 +46,7 @@ public class McpSseService {
|
||||
return handle(contextPath, request, "");
|
||||
}
|
||||
|
||||
/** Validates the user bearer before serving discovery or executing a tool. */
|
||||
/** Discovery and execution use the same HMM business-user Bearer token boundary. */
|
||||
public ObjectNode handle(String contextPath, JsonNode request, String bearerToken) {
|
||||
bearerAuthenticator.authenticate(bearerToken);
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
@@ -63,8 +55,10 @@ public class McpSseService {
|
||||
response.set("id", request.get("id"));
|
||||
}
|
||||
|
||||
String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
|
||||
JsonNode parameters = request == null ? objectMapper.createObjectNode() : request.path("params");
|
||||
String method =
|
||||
request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
|
||||
JsonNode parameters =
|
||||
request == null ? objectMapper.createObjectNode() : request.path("params");
|
||||
try {
|
||||
response.set("result", switch (method) {
|
||||
case "initialize" -> initializeResult(contextPath);
|
||||
@@ -73,20 +67,27 @@ public class McpSseService {
|
||||
case "tools/call" -> toolsCallResult(parameters, bearerToken);
|
||||
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
|
||||
});
|
||||
} catch (McpUnauthorizedException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
response.remove("result");
|
||||
ObjectNode error = objectMapper.createObjectNode();
|
||||
error.put("code", -32000);
|
||||
error.put("message", exception.getMessage());
|
||||
error.put("message", safeMessage(exception));
|
||||
response.set("error", error);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public List<McpToolView> registeredTools() {
|
||||
return HMM_TOOLS.stream()
|
||||
return toolCatalog.tools().stream()
|
||||
.map(tool -> new McpToolView(
|
||||
tool.name(), tool.description(), -1L, tool.displayName(), tool.agentToolName()))
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
-1L,
|
||||
tool.label(),
|
||||
tool.agentTool() ? tool.targetName() : MCP_CALL_PATH
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -94,8 +95,8 @@ public class McpSseService {
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
result.put("protocolVersion", "2024-11-05");
|
||||
ObjectNode serverInfo = objectMapper.createObjectNode();
|
||||
serverInfo.put("name", "hmm-hr-backoffice-" + contextPath);
|
||||
serverInfo.put("version", "1.0.0");
|
||||
serverInfo.put("name", mcpProperties.resolvedServerName() + "-" + contextPath);
|
||||
serverInfo.put("version", "1.1.0");
|
||||
result.set("serverInfo", serverInfo);
|
||||
ObjectNode capabilities = objectMapper.createObjectNode();
|
||||
capabilities.set("tools", objectMapper.createObjectNode());
|
||||
@@ -106,12 +107,12 @@ public class McpSseService {
|
||||
private ObjectNode toolsListResult() {
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
ArrayNode tools = objectMapper.createArrayNode();
|
||||
HMM_TOOLS.forEach(tool -> tools.add(toolDefinition(tool)));
|
||||
toolCatalog.tools().forEach(tool -> tools.add(toolDefinition(tool)));
|
||||
result.set("tools", tools);
|
||||
return result;
|
||||
}
|
||||
|
||||
private ObjectNode toolDefinition(ToolSpec tool) {
|
||||
private ObjectNode toolDefinition(McpToolDefinition tool) {
|
||||
ObjectNode item = objectMapper.createObjectNode();
|
||||
item.put("name", tool.name());
|
||||
item.put("description", tool.description());
|
||||
@@ -133,23 +134,34 @@ public class McpSseService {
|
||||
}
|
||||
|
||||
private ObjectNode toolsCallResult(JsonNode params, String bearerToken) {
|
||||
String requestedName = params.path("name").asText("");
|
||||
ToolSpec tool = HMM_TOOLS.stream()
|
||||
.filter(candidate -> candidate.name().equals(requestedName))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AppException("등록되지 않은 HMM MCP tool입니다: " + requestedName));
|
||||
McpToolDefinition tool = toolCatalog.require(params.path("name").asText(""));
|
||||
String argument = params.path("arguments").path(tool.argumentName()).asText("").trim();
|
||||
if (argument.isBlank()) {
|
||||
throw new AppException(tool.argumentName() + " 입력값은 비워둘 수 없습니다.");
|
||||
}
|
||||
ObjectNode input = objectMapper.createObjectNode();
|
||||
input.put(tool.agentParameterName(), argument);
|
||||
JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input, bearerToken);
|
||||
|
||||
JsonNode toolResponse;
|
||||
if (tool.agentTool()) {
|
||||
ObjectNode input = objectMapper.createObjectNode();
|
||||
input.put(tool.targetParameterName(), argument);
|
||||
toolResponse = agentToolRunner.run(tool.targetName(), input, bearerToken);
|
||||
} else {
|
||||
toolResponse = selectAiService.generateAndExecute(bearerToken, argument);
|
||||
}
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("toolName", tool.name());
|
||||
payload.put("agentTool", tool.agentToolName());
|
||||
payload.put("executionType", tool.executionType());
|
||||
if (tool.agentTool()) {
|
||||
payload.put("agentTool", tool.targetName());
|
||||
} else {
|
||||
BackofficeProperties.SelectAi selectAi =
|
||||
backofficeProperties == null ? null : backofficeProperties.selectAi();
|
||||
payload.put("profile", selectAi == null || selectAi.profile() == null
|
||||
? "" : selectAi.profile());
|
||||
}
|
||||
payload.set("response", toolResponse);
|
||||
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
ArrayNode content = objectMapper.createArrayNode();
|
||||
ObjectNode text = objectMapper.createObjectNode();
|
||||
@@ -161,6 +173,11 @@ public class McpSseService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private String safeMessage(Exception exception) {
|
||||
String message = exception.getMessage();
|
||||
return message == null || message.isBlank() ? "MCP 요청 처리에 실패했습니다." : message;
|
||||
}
|
||||
|
||||
private String pretty(Object value) {
|
||||
try {
|
||||
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
||||
@@ -168,19 +185,4 @@ public class McpSseService {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
private record ToolSpec(
|
||||
String name,
|
||||
String agentToolName,
|
||||
String argumentName,
|
||||
String agentParameterName,
|
||||
String description,
|
||||
String displayName
|
||||
) {
|
||||
String argumentDescription() {
|
||||
return "term".equals(argumentName)
|
||||
? "확인할 휴가·근태 용어, 동의어 또는 코드입니다."
|
||||
: "조직, 직원, 휴가, 근태 또는 규정에 대한 완전한 자연어 질문입니다.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface McpToolCatalog {
|
||||
|
||||
List<McpToolDefinition> tools();
|
||||
|
||||
McpToolDefinition require(String name);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
/** Validated MCP tool contract supplied by deployment configuration. */
|
||||
public record McpToolDefinition(
|
||||
String name,
|
||||
String label,
|
||||
String description,
|
||||
String argumentName,
|
||||
String argumentDescription,
|
||||
String executionType,
|
||||
String targetName,
|
||||
String targetParameterName
|
||||
) {
|
||||
|
||||
public boolean agentTool() {
|
||||
return "AGENT_TOOL".equals(executionType);
|
||||
}
|
||||
|
||||
public boolean selectAi() {
|
||||
return "SELECT_AI".equals(executionType);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.cloudhandson.vpdbackoffice.service;
|
||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
|
||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
|
||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
|
||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog;
|
||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -35,7 +34,7 @@ public class SchemaMetadataService {
|
||||
return structuredDataService.tables();
|
||||
}
|
||||
|
||||
public StructuredDataCatalog catalog() {
|
||||
public DataCatalog catalog() {
|
||||
return structuredDataService.catalog();
|
||||
}
|
||||
|
||||
@@ -47,7 +46,8 @@ public class SchemaMetadataService {
|
||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||
String tableName = table.tableName();
|
||||
String tableComment = tableComment(tableName);
|
||||
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
|
||||
Map<String, List<SchemaAnnotation>> annotations =
|
||||
table.isTable() ? annotationsByTarget(tableName) : Map.of();
|
||||
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
|
||||
return new SchemaMetadataView(
|
||||
table,
|
||||
@@ -77,6 +77,7 @@ public class SchemaMetadataService {
|
||||
@Transactional
|
||||
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
|
||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||
requireAnnotationTable(table);
|
||||
updateAnnotation(table.tableName(), null, annotationName, annotationValue);
|
||||
}
|
||||
|
||||
@@ -88,6 +89,7 @@ public class SchemaMetadataService {
|
||||
String annotationValue
|
||||
) {
|
||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||
requireAnnotationTable(table);
|
||||
String column = requireColumn(table.tableName(), columnName);
|
||||
updateAnnotation(table.tableName(), column, annotationName, annotationValue);
|
||||
}
|
||||
@@ -227,6 +229,12 @@ public class SchemaMetadataService {
|
||||
return column;
|
||||
}
|
||||
|
||||
private void requireAnnotationTable(StructuredDataTable table) {
|
||||
if (!table.isTable()) {
|
||||
throw new AppException("Oracle annotation은 TABLE 객체에서만 수정할 수 있습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private String requireSimpleName(String value, String label) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new AppException(label + "은(는) 필수입니다.");
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
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.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -17,46 +23,21 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
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 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다."
|
||||
),
|
||||
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를 노출합니다."
|
||||
)
|
||||
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(),
|
||||
@@ -68,7 +49,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 스크립트입니다."));
|
||||
@@ -91,7 +72,48 @@ 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()) {
|
||||
throw new IllegalArgumentException("보안 SQL 목록이 비어 있습니다.");
|
||||
}
|
||||
Set<String> scriptIds = new HashSet<>();
|
||||
Set<String> fileNames = new HashSet<>();
|
||||
parsed.forEach(definition -> {
|
||||
validate(definition);
|
||||
if (!scriptIds.add(definition.scriptId())) {
|
||||
throw new IllegalArgumentException("중복 scriptId");
|
||||
}
|
||||
if (!fileNames.add(definition.fileName())) {
|
||||
throw new IllegalArgumentException("중복 fileName");
|
||||
}
|
||||
});
|
||||
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("보안 SQL 정의가 올바르지 않습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean blank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
public record ScriptDefinition(
|
||||
String scriptId,
|
||||
String category,
|
||||
String fileName,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.Statement;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Generates and executes bounded read-only SQL through a configured Select AI profile. */
|
||||
@Service
|
||||
public class SelectAiService {
|
||||
|
||||
private static final int MAX_PROMPT_LENGTH = 4_000;
|
||||
private static final int MAX_RESULT_ROWS = 100;
|
||||
private static final int QUERY_TIMEOUT_SECONDS = 30;
|
||||
private static final Pattern UNSAFE_SQL = Pattern.compile(
|
||||
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
||||
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|"
|
||||
+ "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b"
|
||||
);
|
||||
|
||||
private final BackofficeProperties properties;
|
||||
private final HmmMcpBearerAuthenticator bearerAuthenticator;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SelectAiService(
|
||||
BackofficeProperties properties,
|
||||
HmmMcpBearerAuthenticator bearerAuthenticator,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.bearerAuthenticator = bearerAuthenticator;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
||||
bearerAuthenticator.authenticate(bearerToken);
|
||||
String normalizedPrompt = requiredPrompt(prompt);
|
||||
BackofficeProperties.SelectAi selectAi =
|
||||
properties == null ? null : properties.selectAi();
|
||||
if (selectAi == null || !selectAi.configured()) {
|
||||
throw new AppException("Select AI 연결 설정이 필요합니다. "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
||||
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD, BACKOFFICE_SELECT_AI_PROFILE을 확인하세요.");
|
||||
}
|
||||
|
||||
String generatedSql = generate(selectAi, normalizedPrompt);
|
||||
String normalizedSql = validateReadOnlySql(generatedSql);
|
||||
QueryExecution execution = executeReadOnly(
|
||||
selectAi, normalizedSql, bearerToken.trim());
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("status", "SHOWSQL_AND_EXECUTED");
|
||||
response.put("profile", selectAi.profile());
|
||||
response.put("generatedSql", normalizedSql);
|
||||
response.put("execution", "READ_ONLY_EXECUTED");
|
||||
response.put("rowCount", execution.items().size());
|
||||
response.put("truncated", execution.truncated());
|
||||
response.set("items", execution.items());
|
||||
response.put("nextStep", execution.truncated()
|
||||
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다."
|
||||
: "생성 SQL을 읽기 전용으로 실행한 결과입니다.");
|
||||
return response;
|
||||
}
|
||||
|
||||
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("Select AI SHOWSQL 생성 실패: " + safeMessage(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private QueryExecution executeReadOnly(
|
||||
BackofficeProperties.SelectAi selectAi,
|
||||
String generatedSql,
|
||||
String bearerToken
|
||||
) {
|
||||
ArrayNode items = objectMapper.createArrayNode();
|
||||
boolean truncated = false;
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword())) {
|
||||
boolean contextSet = false;
|
||||
try {
|
||||
try (CallableStatement statement = connection.prepareCall(
|
||||
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) {
|
||||
statement.setString(1, bearerToken);
|
||||
statement.execute();
|
||||
contextSet = true;
|
||||
}
|
||||
connection.setAutoCommit(false);
|
||||
connection.setReadOnly(true);
|
||||
try (Statement transaction = connection.createStatement()) {
|
||||
transaction.execute("SET TRANSACTION READ ONLY");
|
||||
}
|
||||
try (PreparedStatement statement = connection.prepareStatement(generatedSql)) {
|
||||
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||
statement.setFetchSize(MAX_RESULT_ROWS + 1);
|
||||
statement.setMaxRows(MAX_RESULT_ROWS + 1);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
ResultSetMetaData metadata = resultSet.getMetaData();
|
||||
while (resultSet.next()) {
|
||||
if (items.size() >= MAX_RESULT_ROWS) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
ObjectNode row = items.addObject();
|
||||
for (int columnIndex = 1;
|
||||
columnIndex <= metadata.getColumnCount();
|
||||
columnIndex++) {
|
||||
String column = metadata.getColumnLabel(columnIndex);
|
||||
if (column == null || column.isBlank()) {
|
||||
column = metadata.getColumnName(columnIndex);
|
||||
}
|
||||
putResultValue(row, column, resultSet.getObject(columnIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
connection.rollback();
|
||||
} finally {
|
||||
if (contextSet) {
|
||||
try (CallableStatement statement = connection.prepareCall(
|
||||
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) {
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("Select AI 생성 SQL 실행 실패: " + safeMessage(exception));
|
||||
}
|
||||
return new QueryExecution(items, truncated);
|
||||
}
|
||||
|
||||
private void putResultValue(ObjectNode row, String column, Object value) {
|
||||
if (value == null) {
|
||||
row.putNull(column);
|
||||
} else if (value instanceof BigDecimal number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof BigInteger number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof Integer number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof Long number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof Short number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof Float number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof Double number) {
|
||||
row.put(column, number);
|
||||
} else if (value instanceof Boolean bool) {
|
||||
row.put(column, bool);
|
||||
} else {
|
||||
row.put(column, String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
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 문장이 포함되어 있어 실행하지 않습니다.");
|
||||
}
|
||||
if (normalized.contains("--")
|
||||
|| normalized.contains("/*")
|
||||
|| normalized.contains("*/")
|
||||
|| UNSAFE_SQL.matcher(normalized).find()) {
|
||||
throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String safeMessage(Exception exception) {
|
||||
String message = exception.getMessage();
|
||||
return message == null || message.isBlank()
|
||||
? exception.getClass().getSimpleName()
|
||||
: message;
|
||||
}
|
||||
|
||||
private record QueryExecution(ArrayNode items, boolean truncated) {
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog;
|
||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class StructuredDataCatalogProvider {
|
||||
|
||||
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||
private static final Pattern TABLE_KEY = Pattern.compile("[a-z][a-z0-9-]{0,63}");
|
||||
private static final int MAX_ROW_LIMIT = 500;
|
||||
|
||||
private final StructuredDataCatalog catalog;
|
||||
|
||||
public StructuredDataCatalogProvider(
|
||||
ObjectMapper objectMapper,
|
||||
@Value("${backoffice.structured-data.catalog-location:classpath:/config/structured-data-catalog.json}")
|
||||
Resource catalogResource
|
||||
) {
|
||||
try (var input = catalogResource.getInputStream()) {
|
||||
this.catalog = validate(objectMapper.readValue(input, StructuredDataCatalog.class));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException(
|
||||
"정형 데이터 카탈로그 JSON을 읽을 수 없습니다: " + catalogResource.getDescription(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
public StructuredDataCatalog catalog() {
|
||||
return catalog;
|
||||
}
|
||||
|
||||
private StructuredDataCatalog validate(StructuredDataCatalog source) {
|
||||
if (source == null) {
|
||||
throw new IllegalStateException("정형 데이터 카탈로그가 비어 있습니다.");
|
||||
}
|
||||
String sourceName = requireText(source.sourceName(), "sourceName");
|
||||
String owner = requireOracleName(source.owner(), "owner");
|
||||
String pageHelp = requireText(source.pageHelp(), "pageHelp");
|
||||
String catalogDescription = requireText(source.catalogDescription(), "catalogDescription");
|
||||
if (source.rowLimit() < 1 || source.rowLimit() > MAX_ROW_LIMIT) {
|
||||
throw new IllegalStateException("정형 데이터 카탈로그 rowLimit은 1~" + MAX_ROW_LIMIT + " 범위여야 합니다.");
|
||||
}
|
||||
if (source.tables() == null || source.tables().isEmpty()) {
|
||||
throw new IllegalStateException("정형 데이터 카탈로그에는 테이블이 한 개 이상 필요합니다.");
|
||||
}
|
||||
|
||||
Set<String> keys = new HashSet<>();
|
||||
Set<String> tableNames = new HashSet<>();
|
||||
List<StructuredDataTable> tables = source.tables().stream().map(table -> {
|
||||
if (table == null) {
|
||||
throw new IllegalStateException("정형 데이터 카탈로그에 null 테이블 정의가 있습니다.");
|
||||
}
|
||||
String key = requireKey(table.key());
|
||||
String tableName = requireOracleName(table.tableName(), "tableName");
|
||||
if (!keys.add(key)) {
|
||||
throw new IllegalStateException("정형 데이터 카탈로그 key가 중복됩니다: " + key);
|
||||
}
|
||||
if (!tableNames.add(tableName)) {
|
||||
throw new IllegalStateException("정형 데이터 카탈로그 tableName이 중복됩니다: " + tableName);
|
||||
}
|
||||
List<String> previewColumns = table.previewColumns() == null
|
||||
? List.of()
|
||||
: table.previewColumns().stream()
|
||||
.map(column -> requireOracleName(column, "previewColumns"))
|
||||
.distinct()
|
||||
.toList();
|
||||
String maskingPolicyName = table.maskingPolicyName() == null || table.maskingPolicyName().isBlank()
|
||||
? null
|
||||
: requireOracleName(table.maskingPolicyName(), "maskingPolicyName");
|
||||
return new StructuredDataTable(
|
||||
key,
|
||||
tableName,
|
||||
requireText(table.businessName(), "businessName"),
|
||||
requireText(table.description(), "description"),
|
||||
List.copyOf(previewColumns),
|
||||
maskingPolicyName);
|
||||
}).toList();
|
||||
|
||||
return new StructuredDataCatalog(
|
||||
sourceName, owner, pageHelp, catalogDescription, source.rowLimit(), List.copyOf(tables));
|
||||
}
|
||||
|
||||
private String requireKey(String value) {
|
||||
String normalized = requireText(value, "key").toLowerCase(Locale.ROOT);
|
||||
if (!TABLE_KEY.matcher(normalized).matches()) {
|
||||
throw new IllegalStateException("정형 데이터 카탈로그 key 형식이 올바르지 않습니다: " + value);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String requireOracleName(String value, String field) {
|
||||
String normalized = requireText(value, field).toUpperCase(Locale.ROOT);
|
||||
if (!ORACLE_SIMPLE_NAME.matcher(normalized).matches()) {
|
||||
throw new IllegalStateException("정형 데이터 카탈로그 " + field + " 형식이 올바르지 않습니다: " + value);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalStateException("정형 데이터 카탈로그 " + field + " 값은 필수입니다.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog;
|
||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataPreview;
|
||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||
import java.util.List;
|
||||
@@ -12,18 +11,20 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class StructuredDataService {
|
||||
|
||||
private static final int ROW_LIMIT = 50;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final StructuredDataCatalog catalog;
|
||||
private final DataCatalog catalog;
|
||||
|
||||
public StructuredDataService(
|
||||
JdbcTemplate jdbcTemplate,
|
||||
StructuredDataCatalogProvider catalogProvider
|
||||
DataCatalog catalog
|
||||
) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.catalog = catalogProvider.catalog();
|
||||
this.catalog = catalog;
|
||||
}
|
||||
|
||||
public StructuredDataCatalog catalog() {
|
||||
public DataCatalog catalog() {
|
||||
return catalog;
|
||||
}
|
||||
|
||||
@@ -32,18 +33,15 @@ public class StructuredDataService {
|
||||
}
|
||||
|
||||
public List<StructuredDataTable> tables() {
|
||||
return catalog.tables();
|
||||
return catalog.objects();
|
||||
}
|
||||
|
||||
public String defaultKey() {
|
||||
return catalog.tables().getFirst().key();
|
||||
return catalog.objects().getFirst().key();
|
||||
}
|
||||
|
||||
public StructuredDataTable requireTable(String key) {
|
||||
return catalog.tables().stream()
|
||||
.filter(table -> table.key().equals(key))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다."));
|
||||
return catalog.require(key);
|
||||
}
|
||||
|
||||
public StructuredDataPreview preview(String key) {
|
||||
@@ -61,17 +59,17 @@ public class StructuredDataService {
|
||||
if (columns.isEmpty()) {
|
||||
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
|
||||
}
|
||||
List<String> previewColumns = table.previewColumns().isEmpty() ? columns : table.previewColumns();
|
||||
List<String> previewColumns =
|
||||
table.previewColumns().isEmpty() ? columns : table.previewColumns();
|
||||
if (!columns.containsAll(previewColumns)) {
|
||||
throw new AppException("정형 데이터 JSON의 미리보기 컬럼이 실제 테이블과 일치하지 않습니다.");
|
||||
throw new AppException("환경 카탈로그의 미리보기 컬럼이 실제 객체와 일치하지 않습니다.");
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
previewSql(table, previewColumns), catalog.rowLimit());
|
||||
return new StructuredDataPreview(table, previewColumns, rows, catalog.rowLimit());
|
||||
previewSql(table, previewColumns), ROW_LIMIT);
|
||||
return new StructuredDataPreview(table, previewColumns, rows, ROW_LIMIT);
|
||||
} catch (DataAccessException exception) {
|
||||
throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.sourceName() + "의 "
|
||||
+ catalog.owner() + " 조회 권한과 대상 테이블 상태를 확인하세요.");
|
||||
throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.owner()
|
||||
+ " 조회 권한과 대상 객체 상태를 확인하세요.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,15 +81,20 @@ public class StructuredDataService {
|
||||
return previewSql(table, table.previewColumns());
|
||||
}
|
||||
|
||||
private String previewSql(StructuredDataTable table, List<String> previewColumns) {
|
||||
private String previewSql(
|
||||
StructuredDataTable table,
|
||||
List<String> previewColumns
|
||||
) {
|
||||
StructuredDataTable approved = requireTable(table.key());
|
||||
if (!approved.tableName().equals(table.tableName())) {
|
||||
throw new AppException("선택할 수 없는 정형 데이터 테이블입니다.");
|
||||
throw new AppException("선택할 수 없는 카탈로그 객체입니다.");
|
||||
}
|
||||
String projection = previewColumns == null || previewColumns.isEmpty()
|
||||
? "*"
|
||||
: previewColumns.stream().map(column -> "\"" + column + "\"")
|
||||
.reduce((left, right) -> left + ", " + right).orElseThrow();
|
||||
: previewColumns.stream()
|
||||
.map(column -> "\"" + column + "\"")
|
||||
.reduce((left, right) -> left + ", " + right)
|
||||
.orElseThrow();
|
||||
return "SELECT " + projection + " FROM \"" + catalog.owner() + "\".\"" + approved.tableName()
|
||||
+ "\" WHERE ROWNUM <= ?";
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public class DashboardController {
|
||||
|
||||
@GetMapping("/")
|
||||
public String dashboard(Model model) {
|
||||
// The Smilegate PoC home is an identity-administration landing page.
|
||||
// The backoffice home is an identity-administration landing page.
|
||||
// It intentionally does not query legacy CB_* VPD catalog objects.
|
||||
model.addAttribute("users", userService.findAll());
|
||||
model.addAttribute("groups", groupService.findAll());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||
@@ -24,7 +24,7 @@ public class McpReasoningController {
|
||||
private final McpReasoningService reasoningService;
|
||||
private final BearerTokenService tokenService;
|
||||
private final UserMapper userMapper;
|
||||
private final BackofficeProperties properties;
|
||||
private final McpProperties mcpProperties;
|
||||
|
||||
public McpReasoningController(
|
||||
McpToolRegistry toolRegistry,
|
||||
@@ -32,14 +32,14 @@ public class McpReasoningController {
|
||||
McpReasoningService reasoningService,
|
||||
BearerTokenService tokenService,
|
||||
UserMapper userMapper,
|
||||
BackofficeProperties properties
|
||||
McpProperties mcpProperties
|
||||
) {
|
||||
this.toolRegistry = toolRegistry;
|
||||
this.mcpSseService = mcpSseService;
|
||||
this.reasoningService = reasoningService;
|
||||
this.tokenService = tokenService;
|
||||
this.userMapper = userMapper;
|
||||
this.properties = properties;
|
||||
this.mcpProperties = mcpProperties;
|
||||
}
|
||||
|
||||
@GetMapping("/mcp-reasoning")
|
||||
@@ -65,7 +65,7 @@ public class McpReasoningController {
|
||||
@GetMapping("/mcp-sse")
|
||||
public String ssePage(Model model) {
|
||||
model.addAttribute("tools", mcpSseService.registeredTools());
|
||||
model.addAttribute("hmmMcpPublicUrl", properties.mcp().publicUrl());
|
||||
model.addAttribute("hmmMcpPublicUrl", mcpProperties.resolvedPublicUrl());
|
||||
return "mcp-sse";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||
import com.cloudhandson.vpdbackoffice.config.ProductProperties;
|
||||
import com.cloudhandson.vpdbackoffice.service.DataCatalog;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
/** Supplies deployment labels to every server-rendered page. */
|
||||
@ControllerAdvice
|
||||
public class ProductModelAdvice {
|
||||
|
||||
private final ProductProperties product;
|
||||
private final DataCatalog catalog;
|
||||
private final McpProperties mcp;
|
||||
|
||||
public ProductModelAdvice(
|
||||
ProductProperties product,
|
||||
DataCatalog catalog,
|
||||
McpProperties mcp
|
||||
) {
|
||||
this.product = product;
|
||||
this.catalog = catalog;
|
||||
this.mcp = mcp;
|
||||
}
|
||||
|
||||
@ModelAttribute("product")
|
||||
ProductProperties product() {
|
||||
return product;
|
||||
}
|
||||
|
||||
@ModelAttribute("catalogOwner")
|
||||
String catalogOwner() {
|
||||
return catalog.owner();
|
||||
}
|
||||
|
||||
@ModelAttribute("mcp")
|
||||
McpProperties mcp() {
|
||||
return mcp;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.BackofficeSchemaService;
|
||||
import com.cloudhandson.vpdbackoffice.service.SettingService;
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -16,21 +16,21 @@ public class SettingController {
|
||||
|
||||
private final SettingService settingService;
|
||||
private final BackofficeSchemaService backofficeSchemaService;
|
||||
private final BackofficeProperties properties;
|
||||
private final McpProperties mcpProperties;
|
||||
|
||||
public SettingController(
|
||||
SettingService settingService,
|
||||
BackofficeSchemaService backofficeSchemaService,
|
||||
BackofficeProperties properties
|
||||
McpProperties mcpProperties
|
||||
) {
|
||||
this.settingService = settingService;
|
||||
this.backofficeSchemaService = backofficeSchemaService;
|
||||
this.properties = properties;
|
||||
this.mcpProperties = mcpProperties;
|
||||
}
|
||||
|
||||
@GetMapping("/settings")
|
||||
public String settings(Model model) {
|
||||
model.addAttribute("hmmMcpPublicUrl", properties.mcp().publicUrl());
|
||||
model.addAttribute("hmmMcpPublicUrl", mcpProperties.resolvedPublicUrl());
|
||||
try {
|
||||
model.addAttribute("ordsBaseUrl", settingService.ordsBaseUrl());
|
||||
} catch (DataAccessException exception) {
|
||||
|
||||
Reference in New Issue
Block a user