361 lines
14 KiB
Java
361 lines
14 KiB
Java
package com.cloudhandson.vpdbackoffice.service;
|
|
|
|
import com.cloudhandson.vpdbackoffice.domain.ords.OrdsHandlerUpdateCommand;
|
|
import com.cloudhandson.vpdbackoffice.domain.ords.OrdsHandlerView;
|
|
import com.cloudhandson.vpdbackoffice.domain.ords.OrdsObjectHandlerResult;
|
|
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
|
|
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
|
import com.cloudhandson.vpdbackoffice.mapper.OrdsMetadataMapper;
|
|
import com.zaxxer.hikari.HikariConfig;
|
|
import com.zaxxer.hikari.HikariDataSource;
|
|
import jakarta.annotation.PreDestroy;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import javax.sql.DataSource;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
@Service
|
|
public class OrdsMetadataService {
|
|
|
|
private static final String VECTOR_SEARCH_OBJECT = "CB_VECTOR_SEARCH_DOCUMENTS";
|
|
private static final Logger log = LoggerFactory.getLogger(OrdsMetadataService.class);
|
|
|
|
private final OrdsMetadataMapper mapper;
|
|
private final JdbcTemplate jdbcTemplate;
|
|
private final JdbcTemplate ordsMetadataJdbcTemplate;
|
|
private final HikariDataSource ordsMetadataDataSource;
|
|
private final ProtectedObjectService protectedObjectService;
|
|
|
|
public OrdsMetadataService(
|
|
OrdsMetadataMapper mapper,
|
|
JdbcTemplate jdbcTemplate,
|
|
ProtectedObjectService protectedObjectService,
|
|
DataSource dataSource,
|
|
@Value("${backoffice.ords.metadata-db.url:}") String ordsMetadataUrl,
|
|
@Value("${backoffice.ords.metadata-db.username:}") String ordsMetadataUsername,
|
|
@Value("${backoffice.ords.metadata-db.password:}") String ordsMetadataPassword
|
|
) {
|
|
this.mapper = mapper;
|
|
this.jdbcTemplate = jdbcTemplate;
|
|
this.protectedObjectService = protectedObjectService;
|
|
HikariDataSource metadataDataSource = null;
|
|
JdbcTemplate metadataJdbcTemplate = new JdbcTemplate(dataSource);
|
|
if (hasText(ordsMetadataUrl) && hasText(ordsMetadataUsername)) {
|
|
HikariConfig config = new HikariConfig();
|
|
config.setPoolName("vpd-ords-metadata-pool");
|
|
config.setJdbcUrl(ordsMetadataUrl.trim());
|
|
config.setUsername(ordsMetadataUsername.trim());
|
|
config.setPassword(ordsMetadataPassword == null ? "" : ordsMetadataPassword);
|
|
config.setDriverClassName("oracle.jdbc.OracleDriver");
|
|
config.setMaximumPoolSize(2);
|
|
config.setMinimumIdle(0);
|
|
config.setConnectionTimeout(10000);
|
|
// ORDS metadata is an optional administrative connection. Do not fail
|
|
// the whole backoffice startup when the remote TNS host is temporarily
|
|
// unavailable; Handler create/update will report the connection error
|
|
// at the point of use instead.
|
|
config.setInitializationFailTimeout(-1);
|
|
try {
|
|
metadataDataSource = new HikariDataSource(config);
|
|
metadataJdbcTemplate = new JdbcTemplate(metadataDataSource);
|
|
} catch (RuntimeException exception) {
|
|
log.warn("ORDS metadata DB is unavailable; using the main DB for read-only fallback: {}",
|
|
exception.getMessage());
|
|
if (metadataDataSource != null) {
|
|
metadataDataSource.close();
|
|
}
|
|
}
|
|
}
|
|
this.ordsMetadataDataSource = metadataDataSource;
|
|
this.ordsMetadataJdbcTemplate = metadataJdbcTemplate;
|
|
}
|
|
|
|
public List<OrdsHandlerView> findHandlers() {
|
|
String packageSource = mapper.findHandlerPackageSource();
|
|
return mapper.findHandlers().stream()
|
|
.map(handler -> new OrdsHandlerView(
|
|
handler.handlerId(),
|
|
handler.schemaName(),
|
|
handler.parsingSchema(),
|
|
handler.moduleName(),
|
|
handler.basePath(),
|
|
handler.template(),
|
|
handler.method(),
|
|
handler.sourceType(),
|
|
handler.source(),
|
|
handler.parameters(),
|
|
packageSource
|
|
))
|
|
.toList();
|
|
}
|
|
|
|
public String objectQueryHandlerSource(long objectId) {
|
|
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
|
|
rejectGenericVectorHandler(object);
|
|
List<String> columns = protectedObjectService.findColumns(objectId).stream()
|
|
.map(ProtectedColumn::columnName)
|
|
.toList();
|
|
if (columns.isEmpty()) {
|
|
throw new AppException("보호 객체 컬럼이 없어 ORDS 조회 Handler Source를 만들 수 없습니다.");
|
|
}
|
|
requireIdentifier(object.owner(), "owner");
|
|
requireIdentifier(object.objectName(), "objectName");
|
|
columns.forEach(column -> requireIdentifier(column, "column"));
|
|
return objectQuerySource(object, columns);
|
|
}
|
|
|
|
@Transactional
|
|
public void updateHandlerSource(OrdsHandlerUpdateCommand command) {
|
|
if (command.source() == null || command.source().isBlank()) {
|
|
throw new AppException("ORDS Handler Source는 필수입니다.");
|
|
}
|
|
OrdsHandlerView handler = mapper.findHandler(command.handlerId());
|
|
if (handler == null) {
|
|
throw new AppException("수정할 ORDS Handler를 찾을 수 없습니다.");
|
|
}
|
|
String sourceType = handler.sourceType() == null ? "" : handler.sourceType().toLowerCase(Locale.ROOT);
|
|
if (!sourceType.contains("plsql") && !sourceType.contains("query")) {
|
|
throw new AppException("현재는 PL/SQL/SQL Query ORDS Handler만 수정할 수 있습니다: " + handler.sourceType());
|
|
}
|
|
String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
|
|
if (currentUser == null || !currentUser.equalsIgnoreCase(handler.parsingSchema())) {
|
|
throw new AppException("ORDS Handler 수정은 parsing schema로 DB에 연결했을 때만 가능합니다. 현재 연결 사용자: "
|
|
+ currentUser + ", Handler parsing schema: " + handler.parsingSchema());
|
|
}
|
|
jdbcTemplate.update("""
|
|
DECLARE
|
|
v_source_type VARCHAR2(30);
|
|
BEGIN
|
|
IF ? = 'QUERY' THEN
|
|
v_source_type := ORDS.source_type_query;
|
|
ELSE
|
|
v_source_type := ORDS.source_type_plsql;
|
|
END IF;
|
|
|
|
ORDS.DEFINE_HANDLER(
|
|
p_module_name => ?,
|
|
p_pattern => ?,
|
|
p_method => ?,
|
|
p_source_type => v_source_type,
|
|
p_source => ?,
|
|
p_items_per_page => 0
|
|
);
|
|
COMMIT;
|
|
END;
|
|
""",
|
|
handlerSourceTypeCode(handler.sourceType()),
|
|
handler.moduleName(),
|
|
handler.template(),
|
|
handler.method(),
|
|
command.source());
|
|
}
|
|
|
|
@Transactional
|
|
public OrdsObjectHandlerResult createObjectQueryHandler(long objectId) {
|
|
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
|
|
rejectGenericVectorHandler(object);
|
|
List<String> columns = protectedObjectService.findColumns(objectId).stream()
|
|
.map(ProtectedColumn::columnName)
|
|
.toList();
|
|
if (columns.isEmpty()) {
|
|
throw new AppException("보호 객체 컬럼이 없어 ORDS 조회 Handler를 만들 수 없습니다.");
|
|
}
|
|
requireIdentifier(object.owner(), "owner");
|
|
requireIdentifier(object.objectName(), "objectName");
|
|
columns.forEach(column -> requireIdentifier(column, "column"));
|
|
|
|
String currentUser = ordsMetadataJdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
|
|
if (currentUser == null || !currentUser.equalsIgnoreCase("CB_ORDS")) {
|
|
throw new AppException("ORDS 조회 Handler 생성은 ORDS parsing schema(CB_ORDS)로 DB에 연결했을 때만 가능합니다. 현재 연결 사용자: "
|
|
+ currentUser + ". 현재 연결에서는 소스 보기를 눌러 기본 PL/SQL을 확인한 뒤 ORDS Handler에 적용하세요.");
|
|
}
|
|
|
|
String moduleName = "cb.object.query";
|
|
String basePath = "cb-object-query/";
|
|
String template = object.owner().toLowerCase(Locale.ROOT) + "/" + object.objectName().toLowerCase(Locale.ROOT);
|
|
String ordsPath = "cb-ords/" + basePath + template;
|
|
String source = objectQueryHandlerSource(objectId);
|
|
|
|
ordsMetadataJdbcTemplate.update("""
|
|
DECLARE
|
|
v_module_count NUMBER;
|
|
BEGIN
|
|
SELECT COUNT(*)
|
|
INTO v_module_count
|
|
FROM user_ords_modules
|
|
WHERE name = ?;
|
|
|
|
IF v_module_count = 0 THEN
|
|
ORDS.DEFINE_MODULE(
|
|
p_module_name => ?,
|
|
p_base_path => ?,
|
|
p_items_per_page => 25,
|
|
p_status => 'PUBLISHED'
|
|
);
|
|
END IF;
|
|
|
|
ORDS.DEFINE_TEMPLATE(
|
|
p_module_name => ?,
|
|
p_pattern => ?
|
|
);
|
|
ORDS.DEFINE_HANDLER(
|
|
p_module_name => ?,
|
|
p_pattern => ?,
|
|
p_method => 'POST',
|
|
p_source_type => ORDS.source_type_plsql,
|
|
p_source => ?,
|
|
p_items_per_page => 0
|
|
);
|
|
ORDS.DEFINE_PARAMETER(
|
|
p_module_name => ?,
|
|
p_pattern => ?,
|
|
p_method => 'POST',
|
|
p_name => 'Authorization',
|
|
p_bind_variable_name => 'auth_header',
|
|
p_source_type => 'HEADER',
|
|
p_param_type => 'STRING',
|
|
p_access_method => 'IN'
|
|
);
|
|
ORDS.DEFINE_PARAMETER(
|
|
p_module_name => ?,
|
|
p_pattern => ?,
|
|
p_method => 'POST',
|
|
p_name => 'X-VPD-Probe-Id',
|
|
p_bind_variable_name => 'probe_id',
|
|
p_source_type => 'HEADER',
|
|
p_param_type => 'STRING',
|
|
p_access_method => 'IN'
|
|
);
|
|
ORDS.DEFINE_PARAMETER(
|
|
p_module_name => ?,
|
|
p_pattern => ?,
|
|
p_method => 'POST',
|
|
p_name => 'limit',
|
|
p_bind_variable_name => 'row_limit',
|
|
p_source_type => 'URI',
|
|
p_param_type => 'INT',
|
|
p_access_method => 'IN'
|
|
);
|
|
COMMIT;
|
|
END;
|
|
""",
|
|
moduleName,
|
|
moduleName, basePath,
|
|
moduleName, template,
|
|
moduleName, template, source,
|
|
moduleName, template,
|
|
moduleName, template,
|
|
moduleName, template);
|
|
protectedObjectService.updateOrdsPath(object.objectId(), ordsPath);
|
|
return new OrdsObjectHandlerResult(object.objectId(), ordsPath, moduleName, template);
|
|
}
|
|
|
|
private String objectQuerySource(ProtectedObject object, List<String> columns) {
|
|
String selectColumns = columns.stream()
|
|
.map(column -> "o." + column)
|
|
.reduce((left, right) -> left + ",\n " + right)
|
|
.orElseThrow();
|
|
String traceSelectColumns = columns.stream()
|
|
.map(column -> "o." + column)
|
|
.reduce((left, right) -> left + ", " + right)
|
|
.orElseThrow();
|
|
return """
|
|
DECLARE
|
|
v_rows SYS_REFCURSOR;
|
|
v_vpd_predicate VARCHAR2(32767);
|
|
v_effective_sql VARCHAR2(32767);
|
|
BEGIN
|
|
cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id);
|
|
|
|
-- This is the same predicate function invoked by DBMS_RLS for the
|
|
-- SELECT below. It is returned only as diagnostic metadata.
|
|
BEGIN
|
|
v_vpd_predicate := admin.cb_agent_doc_vpd_filter('%s', '%s');
|
|
v_effective_sql := 'SELECT %s FROM %s.%s o WHERE (' || v_vpd_predicate
|
|
|| ') AND ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500)';
|
|
EXCEPTION
|
|
WHEN OTHERS THEN
|
|
v_vpd_predicate := NULL;
|
|
v_effective_sql := NULL;
|
|
END;
|
|
|
|
OPEN v_rows FOR
|
|
SELECT %s
|
|
FROM %s.%s o
|
|
WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 50), 1), 500);
|
|
|
|
:status_code := 200;
|
|
OWA_UTIL.MIME_HEADER('application/json', FALSE);
|
|
HTP.P('Cache-Control: no-store');
|
|
OWA_UTIL.HTTP_HEADER_CLOSE;
|
|
|
|
APEX_JSON.OPEN_OBJECT;
|
|
IF v_effective_sql IS NOT NULL THEN
|
|
APEX_JSON.WRITE('vpd_predicate', v_vpd_predicate);
|
|
APEX_JSON.WRITE('effective_sql', v_effective_sql);
|
|
END IF;
|
|
APEX_JSON.WRITE('items', v_rows);
|
|
APEX_JSON.CLOSE_OBJECT;
|
|
|
|
cb_ords_handler_pkg.clear_vpd_context;
|
|
EXCEPTION
|
|
WHEN OTHERS THEN
|
|
cb_ords_handler_pkg.clear_vpd_context;
|
|
:status_code := 403;
|
|
OWA_UTIL.MIME_HEADER('application/json', FALSE);
|
|
HTP.P('Cache-Control: no-store');
|
|
OWA_UTIL.HTTP_HEADER_CLOSE;
|
|
APEX_JSON.OPEN_OBJECT;
|
|
APEX_JSON.WRITE('error', SQLERRM);
|
|
APEX_JSON.CLOSE_OBJECT;
|
|
END;
|
|
""".formatted(
|
|
object.owner(),
|
|
object.objectName(),
|
|
traceSelectColumns,
|
|
object.owner(),
|
|
object.objectName(),
|
|
selectColumns,
|
|
object.owner(),
|
|
object.objectName());
|
|
}
|
|
|
|
private void rejectGenericVectorHandler(ProtectedObject object) {
|
|
if (VECTOR_SEARCH_OBJECT.equalsIgnoreCase(object.objectName())) {
|
|
throw new AppException(
|
|
"벡터 검색 객체는 일반 Handler로 등록할 수 없습니다. "
|
|
+ "29_agent_ords_vector_search_ords.sql의 전용 Handler를 사용하세요(EMBEDDING 미노출).");
|
|
}
|
|
}
|
|
|
|
private String handlerSourceTypeCode(String sourceType) {
|
|
String normalized = sourceType == null ? "" : sourceType.toLowerCase(Locale.ROOT);
|
|
if (normalized.contains("query")) {
|
|
return "QUERY";
|
|
}
|
|
return "PLSQL";
|
|
}
|
|
|
|
private void requireIdentifier(String value, String label) {
|
|
if (value == null || !value.matches("[A-Z][A-Z0-9_$#]*")) {
|
|
throw new AppException("ORDS Handler 생성에 사용할 수 없는 " + label + " 식별자입니다: " + value);
|
|
}
|
|
}
|
|
|
|
private boolean hasText(String value) {
|
|
return value != null && !value.isBlank();
|
|
}
|
|
|
|
@PreDestroy
|
|
public void closeOrdsMetadataDataSource() {
|
|
if (ordsMetadataDataSource != null) {
|
|
ordsMetadataDataSource.close();
|
|
}
|
|
}
|
|
}
|