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 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 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 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 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(); } } }