add metadata-backed game scope resolver

This commit is contained in:
devmrko
2026-07-27 13:04:43 +09:00
parent 9c48d86696
commit 443d6677a3
3 changed files with 218 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import org.springframework.context.annotation.Configuration;
@EnableConfigurationProperties({ @EnableConfigurationProperties({
BackofficeProperties.class, BackofficeProperties.class,
CatalogProperties.class, CatalogProperties.class,
GameScopeProperties.class,
MaskingProperties.class, MaskingProperties.class,
McpProperties.class, McpProperties.class,
ProductProperties.class, ProductProperties.class,

View File

@@ -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.
*
* <p>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.</p>
*/
@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;
}
}

View File

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