package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; import com.cloudhandson.vpdbackoffice.config.GameScopeProperties; import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; import org.springframework.stereotype.Service; /** * Resolves only DB-declared game scopes before an agent invokes a SQL worker. * *

The service never infers a game, table, prefix, or support status. It reads * the configured customer view and returns only aliases physically present in the * question. This makes unavailable games observable when they are retained in * the customer's game master, while keeping product-specific data out of code.

*/ @Service public class GameScopeService { private static final int MAX_QUESTION_LENGTH = 4_000; private static final Pattern VIEW_NAME = Pattern.compile( "^[A-Za-z][A-Za-z0-9_$#]*(?:\\.[A-Za-z][A-Za-z0-9_$#]*)?$"); private final BackofficeProperties properties; private final GameScopeProperties gameScopeProperties; private final BearerTokenService bearerTokenService; private final Clock clock; public GameScopeService( BackofficeProperties properties, GameScopeProperties gameScopeProperties, BearerTokenService bearerTokenService, Clock clock ) { this.properties = properties; this.gameScopeProperties = gameScopeProperties; this.bearerTokenService = bearerTokenService; this.clock = clock; } public GameScopeResult resolve(String bearerToken, String question) { requireActiveToken(bearerToken); String normalizedQuestion = requiredQuestion(question); BackofficeProperties.SelectAi selectAi = requiredSelectAi(); String viewName = requiredViewName(); String comparableQuestion = comparable(normalizedQuestion); List matches = new ArrayList<>(); String sql = "SELECT game_key, display_name, game_alias, query_allowed_yn, reason_code, " + "alias_priority, scope_version FROM " + viewName + " WHERE profile_name = ?" + " AND INSTR(?, REGEXP_REPLACE(UPPER(game_alias), '[[:space:][:punct:]]', '')) > 0" + " ORDER BY alias_priority DESC, LENGTH(game_alias) DESC, game_key"; try (Connection connection = DriverManager.getConnection( selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword()); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, selectAi.profile()); statement.setString(2, comparableQuestion); try (ResultSet rows = statement.executeQuery()) { while (rows.next()) { matches.add(new GameScope( rows.getString("GAME_KEY"), rows.getString("DISPLAY_NAME"), rows.getString("GAME_ALIAS"), "Y".equalsIgnoreCase(rows.getString("QUERY_ALLOWED_YN")) ? "SUPPORTED" : "UNSUPPORTED", rows.getString("REASON_CODE"), rows.getInt("ALIAS_PRIORITY"), rows.getString("SCOPE_VERSION") )); } } } catch (Exception exception) { throw new AppException("게임 범위 DB 조회 실패: " + exception.getMessage()); } Map uniqueGames = new LinkedHashMap<>(); for (GameScope match : matches) { uniqueGames.putIfAbsent(match.gameKey(), match); } List scopes = uniqueGames.values().stream() .sorted(Comparator.comparing(GameScope::aliasPriority).reversed() .thenComparing(GameScope::gameKey)) .limit(gameScopeProperties.resolvedMaxScopes()) .toList(); String status = scopes.isEmpty() ? "NO_MATCH" : "RESOLVED"; return new GameScopeResult(normalizedQuestion, status, scopes); } public boolean configured() { return gameScopeProperties != null && gameScopeProperties.configured(); } /** * Returns true when a generated SQL references a DB-declared game-prefix * object. Prefixes are read from the configured scope view; no game or table * name is embedded in application code. */ public boolean referencesGameScopedObject(String bearerToken, String sql) { return referencesGameScopedObjectOutsidePrefixes(bearerToken, sql, Set.of()); } /** * Returns true when SQL references a DB-declared game-prefix object whose * prefix is not present in the authoritative query plan. */ public boolean referencesGameScopedObjectOutsidePrefixes( String bearerToken, String sql, Set allowedPrefixes ) { requireActiveToken(bearerToken); if (!gameScopeProperties.configured() || sql == null || sql.isBlank()) { return false; } BackofficeProperties.SelectAi selectAi = requiredSelectAi(); String viewName = requiredViewName(); String prefixSql = "SELECT DISTINCT game_prefix FROM " + viewName + " WHERE profile_name = ? AND active_yn = 'Y'" + " AND query_allowed_yn = 'Y' AND game_prefix IS NOT NULL"; try (Connection connection = DriverManager.getConnection( selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword()); PreparedStatement statement = connection.prepareStatement(prefixSql)) { statement.setString(1, selectAi.profile()); try (ResultSet rows = statement.executeQuery()) { String normalizedSql = sql.toUpperCase(Locale.ROOT); Set normalizedAllowed = allowedPrefixes == null ? Set.of() : allowedPrefixes.stream() .filter(value -> value != null && !value.isBlank()) .map(value -> value.trim().toUpperCase(Locale.ROOT)) .collect(java.util.stream.Collectors.toUnmodifiableSet()); while (rows.next()) { String prefix = rows.getString("GAME_PREFIX"); if (prefix != null && Pattern.compile( "(? MAX_QUESTION_LENGTH) { throw new AppException("question은 " + MAX_QUESTION_LENGTH + "자 이하여야 합니다."); } return value; } private String comparable(String value) { return value.toUpperCase(Locale.ROOT).replaceAll("[\\s\\p{Punct}]", ""); } public record GameScope( String gameKey, String displayName, String matchedAlias, String status, String reasonCode, int aliasPriority, String scopeVersion ) {} public record GameScopeResult(String question, String status, List scopes) {} }