308 lines
12 KiB
Java
308 lines
12 KiB
Java
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.Locale;
|
|
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) {
|
|
HmmMcpPrincipal principal = 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을 확인하세요.");
|
|
}
|
|
if (!selectAi.runtimeConfigured()) {
|
|
throw new AppException("VPD 런타임 연결 설정이 필요합니다. "
|
|
+ "BACKOFFICE_SELECT_AI_RUNTIME_DB_URL, "
|
|
+ "BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME, "
|
|
+ "BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD를 확인하세요.");
|
|
}
|
|
|
|
String generatedSql = generate(
|
|
selectAi, vpdAwarePrompt(normalizedPrompt, principal));
|
|
String normalizedSql = validateReadOnlySql(generatedSql);
|
|
QueryExecution execution = executeReadOnly(
|
|
selectAi, normalizedSql, bearerToken.trim(), principal);
|
|
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.put("vpdEnforced", true);
|
|
response.put("scopeEmployeeCode", principal.employeeCode());
|
|
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 vpdAwarePrompt(String prompt, HmmMcpPrincipal principal) {
|
|
return """
|
|
Oracle SQL 생성 규칙:
|
|
- 프로필에 승인된 HMM HR 객체만 사용하세요.
|
|
- 단일 읽기 전용 SELECT 또는 WITH 문을 생성하세요.
|
|
- 행 접근 권한은 실행 세션의 Oracle VPD가 강제하므로 권한을 추정하거나 우회하지 마세요.
|
|
- 현재 인증 사용자 사번은 %s입니다. '나', '내', '우리 팀'은 이 사용자를 기준으로 해석하세요.
|
|
|
|
사용자 질문: %s
|
|
""".formatted(principal.employeeCode(), prompt);
|
|
}
|
|
|
|
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,
|
|
HmmMcpPrincipal principal
|
|
) {
|
|
ArrayNode items = objectMapper.createArrayNode();
|
|
boolean truncated = false;
|
|
try (Connection connection = DriverManager.getConnection(
|
|
selectAi.runtimeDbUrl(),
|
|
selectAi.runtimeDbUsername(),
|
|
selectAi.runtimeDbPassword())) {
|
|
verifyNonExemptRuntime(connection);
|
|
boolean contextSet = false;
|
|
try {
|
|
try (CallableStatement statement = connection.prepareCall(
|
|
"BEGIN CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT(?); END;")) {
|
|
statement.setString(1, "Bearer " + bearerToken);
|
|
statement.execute();
|
|
contextSet = true;
|
|
}
|
|
verifyVpdContext(connection, principal);
|
|
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, columnIndex);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
try {
|
|
connection.rollback();
|
|
} finally {
|
|
if (contextSet) {
|
|
try (CallableStatement statement = connection.prepareCall(
|
|
"BEGIN CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT; END;")) {
|
|
statement.execute();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (Exception exception) {
|
|
throw new AppException("Select AI 생성 SQL 실행 실패: " + safeMessage(exception));
|
|
}
|
|
return new QueryExecution(items, truncated);
|
|
}
|
|
|
|
private void verifyNonExemptRuntime(Connection connection) throws Exception {
|
|
String runtimeUser;
|
|
try (Statement statement = connection.createStatement();
|
|
ResultSet resultSet = statement.executeQuery("SELECT USER FROM dual")) {
|
|
if (!resultSet.next()) {
|
|
throw new AppException("VPD 런타임 DB 사용자를 확인할 수 없습니다.");
|
|
}
|
|
runtimeUser = resultSet.getString(1);
|
|
}
|
|
if (runtimeUser == null || "ADMIN".equals(runtimeUser.toUpperCase(Locale.ROOT))) {
|
|
throw new AppException("VPD 런타임 연결은 ADMIN을 사용할 수 없습니다.");
|
|
}
|
|
try (PreparedStatement statement = connection.prepareStatement(
|
|
"SELECT COUNT(*) FROM SESSION_PRIVS WHERE PRIVILEGE = ?")) {
|
|
statement.setString(1, "EXEMPT ACCESS POLICY");
|
|
try (ResultSet resultSet = statement.executeQuery()) {
|
|
if (!resultSet.next() || resultSet.getInt(1) != 0) {
|
|
throw new AppException(
|
|
"VPD 런타임 계정에 EXEMPT ACCESS POLICY가 있어 실행을 차단했습니다.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void verifyVpdContext(
|
|
Connection connection,
|
|
HmmMcpPrincipal principal
|
|
) throws Exception {
|
|
String sql = "SELECT SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_CODE') FROM dual";
|
|
try (Statement statement = connection.createStatement();
|
|
ResultSet resultSet = statement.executeQuery(sql)) {
|
|
String employeeCode =
|
|
resultSet.next() ? resultSet.getString(1) : null;
|
|
if (employeeCode == null
|
|
|| !employeeCode.equalsIgnoreCase(principal.employeeCode())) {
|
|
throw new AppException("Bearer Token 사용자와 VPD 세션 컨텍스트가 일치하지 않습니다.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void putResultValue(
|
|
ObjectNode row,
|
|
String column,
|
|
ResultSet resultSet,
|
|
int columnIndex
|
|
) throws Exception {
|
|
Object value = resultSet.getObject(columnIndex);
|
|
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 {
|
|
// Oracle-specific temporal types such as TIMESTAMPTZ otherwise render as
|
|
// oracle.sql.TIMESTAMPTZ@<identity>, which is not usable MCP evidence.
|
|
row.put(column, resultSet.getString(columnIndex));
|
|
}
|
|
}
|
|
|
|
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) {
|
|
}
|
|
}
|