diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java index ab9520a..52bab3c 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/AppConfig.java @@ -9,6 +9,7 @@ import org.springframework.context.annotation.Configuration; @EnableConfigurationProperties({ BackofficeProperties.class, CatalogProperties.class, + GameScopeProperties.class, MaskingProperties.class, McpProperties.class, ProductProperties.class, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/GameScopeProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/GameScopeProperties.java new file mode 100644 index 0000000..fd8ad65 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/GameScopeProperties.java @@ -0,0 +1,26 @@ +package com.cloudhandson.vpdbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Names the customer-owned DB view used to resolve game query scopes. + * + *

The view is a stable integration contract, not a Smilegate-specific table. + * Each environment can expose its own game master, alias source, and approved + * object inventory behind this view without changing application code.

+ */ +@ConfigurationProperties(prefix = "backoffice.game-scope") +public record GameScopeProperties( + boolean enabled, + String viewName, + int maxScopes +) { + + public boolean configured() { + return enabled && viewName != null && !viewName.isBlank(); + } + + public int resolvedMaxScopes() { + return maxScopes >= 1 && maxScopes <= 20 ? maxScopes : 8; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/GameScopeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameScopeService.java new file mode 100644 index 0000000..04eee90 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/GameScopeService.java @@ -0,0 +1,191 @@ +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.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); + } + + /** + * 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) { + 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); + 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) {} +}