add metadata-backed game scope resolver
This commit is contained in:
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
@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<GameScope> 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<String, GameScope> uniqueGames = new LinkedHashMap<>();
|
||||
for (GameScope match : matches) {
|
||||
uniqueGames.putIfAbsent(match.gameKey(), match);
|
||||
}
|
||||
List<GameScope> 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(
|
||||
"(?<![A-Z0-9_$#])" + Pattern.quote(prefix.toUpperCase(Locale.ROOT))
|
||||
+ "_[A-Z0-9_$#]+(?![A-Z0-9_$#])").matcher(normalizedSql).find()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("게임 범위 prefix 검증 실패: " + exception.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String requiredViewName() {
|
||||
String value = gameScopeProperties == null ? "" : gameScopeProperties.viewName();
|
||||
if (gameScopeProperties == null || !gameScopeProperties.configured() || !VIEW_NAME.matcher(value.trim()).matches()) {
|
||||
throw new AppException("게임 범위 view 설정이 필요합니다. BACKOFFICE_GAME_SCOPE_VIEW를 확인하세요.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private BackofficeProperties.SelectAi requiredSelectAi() {
|
||||
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
||||
if (selectAi == null || !selectAi.configured()) {
|
||||
throw new AppException("게임 범위 DB 연결 설정이 필요합니다.");
|
||||
}
|
||||
return selectAi;
|
||||
}
|
||||
|
||||
private void requireActiveToken(String bearerToken) {
|
||||
if (bearerToken == null || bearerToken.isBlank()) {
|
||||
throw new VpdTokenAccessDeniedException();
|
||||
}
|
||||
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
|
||||
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
||||
if (token == null || !token.active(now)) {
|
||||
throw new VpdTokenAccessDeniedException();
|
||||
}
|
||||
}
|
||||
|
||||
private String requiredQuestion(String question) {
|
||||
String value = question == null ? "" : question.trim();
|
||||
if (value.isEmpty()) {
|
||||
throw new AppException("question은 필수입니다.");
|
||||
}
|
||||
if (value.length() > 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<GameScope> scopes) {}
|
||||
}
|
||||
Reference in New Issue
Block a user