213 lines
8.5 KiB
Java
213 lines
8.5 KiB
Java
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.
|
|
*
|
|
* <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);
|
|
}
|
|
|
|
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<String> 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<String> 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(
|
|
"(?<![A-Z0-9_$#])" + Pattern.quote(prefix.toUpperCase(Locale.ROOT))
|
|
+ "_[A-Z0-9_$#]+(?![A-Z0-9_$#])").matcher(normalizedSql).find()
|
|
&& !normalizedAllowed.contains(prefix.toUpperCase(Locale.ROOT))) {
|
|
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) {}
|
|
}
|