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) {}
}