diff --git a/.env.example b/.env.example
index ca57789..c709aa9 100644
--- a/.env.example
+++ b/.env.example
@@ -51,6 +51,13 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
export BACKOFFICE_ORDS_DB_PASSWORD=""
+# Smilegate Select AI는 프로파일 소유 스키마(SGMP_POC)로 별도 접속합니다.
+# 원문 비밀번호는 .env 또는 배포 환경 secret에만 두며 Git에 올리지 않습니다.
+export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}"
+export BACKOFFICE_SELECT_AI_DB_USERNAME="SGMP_POC"
+export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
+export BACKOFFICE_SELECT_AI_PROFILE="SGMP_POC_HAIKU45"
+
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
export BACKOFFICE_AI_ENABLED="false"
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
diff --git a/.gitignore b/.gitignore
index 6631670..3640865 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,10 @@ logs/
# Java / Maven
target/
+# Python / Streamlit
+__pycache__/
+*.py[cod]
+
# Locally downloaded development tools (for example SQLcl)
.tools/
diff --git a/docs/design/703-smilegate-backoffice-finalization/README.md b/docs/design/703-smilegate-backoffice-finalization/README.md
new file mode 100644
index 0000000..d0f5be2
--- /dev/null
+++ b/docs/design/703-smilegate-backoffice-finalization/README.md
@@ -0,0 +1,76 @@
+# 설계서: 스마일게이트 백오피스 잔여 UI 전환 및 운영 검증
+
+## 추적성
+
+- Redmine: #703 `[Smilegate] 백오피스 잔여 UI 전환 및 운영 검증`
+- 관련 설계: `docs/design/smilegate-demo-rebranding/README.md`, `docs/design/smilegate-identity-administration/README.md`
+- 구현 대상: `src/main/resources/templates/`, `src/main/resources/static/js/app.js`, `src/main/java/com/cloudhandson/vpdbackoffice/`, `poc4_active_source_20260714/`
+- 검증 대상: Maven·Streamlit 설정 테스트, 인증 후 핵심 메뉴 HTTP 응답, 화면의 잔여 고객사 문구 검사
+- 상태: Implemented / deployment pending
+
+## 프로젝트 개요
+
+이 저장소의 Spring Boot 백오피스는 Oracle VPD, Data Redaction, FGA, ORDS 및 Select AI PoC의 운영 설정을 확인하고 관리한다. 공개 데모의 고객·업무 대상은 스마일게이트 게임 로그와 서비스 데이터 분석이다.
+
+## 목표
+
+백오피스에 남은 KB손해보험/보험 업무 예시를 스마일게이트 게임 데이터 기준으로 전환한다. 화면 문구만 바꾸지 않고, 실제 MCP Select AI 안내·행 접근 규칙 요약·마스킹 동기화 대상도 `SGMP_POC` 게임 데이터와 모순되지 않게 맞춘다.
+
+## 범위
+
+1. 웹 화면과 브라우저에서 실행되는 JavaScript에 노출된 기존 보험 업무 예시를 게임 사용자·게임 서비스·판매/환불 데이터 예시로 교체한다.
+2. 권한 규칙의 표시명과 미리보기는 기존 조건 코드의 저장 형식을 보존하면서 게임 데이터 의미로 설명한다.
+3. MCP 데모의 tool 식별자·설명·질의 예시를 `SGMP_POC` Select AI 프로파일 기반으로 전환한다. 행 접근 토큰을 전제로 하는 기존 KB ORDS endpoint를 게임 데이터 endpoint인 것처럼 표시하지 않는다.
+4. Data Redaction 동기화는 `SGMP_POC`의 실제 게임 사용자·판매 데이터 컬럼만 관리 대상으로 삼는다.
+5. 내부 호환용 `CB_*` 뷰와 과거 SQL 이력은 실행 경로에서 제외한다. Smilegate 공개 화면·MCP 설정은 이력의 고객 데이터나 endpoint를 참조하지 않는다.
+6. Streamlit 외피는 Smilegate 프로필·게임 데이터 시나리오·`oracle.select_ai.smilegate_game_text2sql` MCP 하나만 노출한다. 이전 고객용 토큰 프리셋 및 감사·보안관리 탭은 기본 실행 경로에서 제외한다.
+
+## 설계 결정
+
+### 1. 업무 용어는 데이터 모델의 사실에 맞춘다
+
+- 사용자 식별자: `CZN_COMN_USER_MST.GUID`/`AUID`, `COMN_SALES_USER_MST.USER_KEY_VAL`
+- 게임 서비스 식별: `COMN_GAME_ALIAS_BAS`의 `GAME_ID`, `GAME_PREFIX`, `GAME_NM`, `GAME_ALIAS_NM`
+- 거래/서비스 데이터: `COMN_SALES_TXN`, `COMN_REFUND_TXN`, `CZN_CUSTOM_*`
+
+화면 예시는 위 객체를 사용하되, 실제로 존재하지 않는 담당자·채널 컬럼을 SQL 예시로 만들지 않는다.
+
+### 2. 조건 코드의 호환성과 표시 의미를 분리한다
+
+`OWN_CONTRACT`, `CHANNEL_CONTRACT`, `OWN_CUSTOMER`, `CHANNEL_CUSTOMER` 같은 과거 코드값은 저장값 호환을 위해 유지한다. 화면에는 각각 `담당 게임 서비스`, `토큰 채널 게임 서비스`, `담당 게임 사용자 데이터`, `토큰 채널 게임 사용자 데이터`로 표시한다. VPD 구현이 게임 데이터에 대한 실제 관계를 갖지 않는 조건은 설명에서 일반적인 보안 범위 조건으로만 제시하고, 존재하지 않는 조인 SQL을 제안하지 않는다.
+
+### 3. MCP/Select AI는 현재 실행 경계를 정직하게 표시한다
+
+MCP tool은 `SGMP_POC_HAIKU45` 프로파일을 기준으로 게임 데이터의 읽기 전용 `SELECT`/`WITH` 질의를 **생성**하는 용도로 안내한다. 생성 단계는 `SHOWSQL`만 사용하며 모델이 만든 SQL을 백오피스가 자동 실행하지 않는다. 운영자는 Database Actions 또는 검증된 실행 경로에서 SQL을 검토·실행한다.
+
+프로파일은 `SGMP_POC` 소유이므로 일반 백오피스 관리 DB 연결(ADMIN)에서 사용할 수 없다. MCP Text2SQL 서비스는 별도 `BACKOFFICE_SELECT_AI_DB_URL`, `BACKOFFICE_SELECT_AI_DB_USERNAME`, `BACKOFFICE_SELECT_AI_DB_PASSWORD` 환경 변수로 `SGMP_POC` 연결을 만들고, 설정이 없을 때는 명확한 설정 오류만 반환한다. 비밀 값은 Git·화면·로그에 저장하지 않는다.
+
+호출 전에 백오피스의 Bearer 토큰 해시를 검증하고 활성 사용자 토큰에만 Text2SQL 요청을 허용한다. 현재 PoC의 두 데모 운영 사용자는 게임 데이터 전체 권한을 갖지만, 후속 권한 세분화 시 이 지점에 역할별 데이터 범위 검증을 추가한다.
+
+### 4. 마스킹 대상은 관리 가능한 실제 객체로 제한한다
+
+마스킹 동기화 대상 owner는 `SGMP_POC`다. 관리 정책은 실제 컬럼 존재 여부를 검증한 뒤 사용자 식별자와 거래 사용자 식별자에만 적용한다. 대상에 없는 규칙은 DBMS_REDACT 호출 전에 화면 설정 오류로 처리한다.
+
+## 변경 파일과 책임
+
+| 영역 | 파일 | 변경 |
+| --- | --- | --- |
+| 행 접근 화면 | `templates/permissions.html`, `static/js/app.js`, `PermissionView.java` | 보험 용어와 존재하지 않는 KB SQL 예시 제거 |
+| 마스킹 화면 | `templates/masking-rules.html`, `templates/user-masking-rules.html`, `MaskingPolicySynchronizer.java` | 게임 데이터 예시 및 실제 `SGMP_POC` 관리 대상 사용 |
+| VPD/운영 화면 | `templates/vpd-filter-runtime.html`, `templates/operation-status.html` | 게임 데이터 상태 표시 예시 적용 |
+| MCP 화면 | `templates/mcp-sse.html`, `McpSseService.java`, `SmilegateSelectAiService.java` | 게임 데이터 Select AI 도구, 토큰 검증 및 SHOWSQL 생성 |
+| 보안 스크립트 화면 | `SecuritySqlScriptService.java` | UI에 노출되는 KB 설명을 게임 데이터 설명으로 교체 |
+| Streamlit 외피 | `poc4_active_source_20260714/config/`, `apps/poc4/mcp_discovery_ui.py` | Smilegate 로그인/헤더/시나리오와 단일 게임 Text2SQL MCP 계약 적용 |
+
+## 완료 기준
+
+1. Smilegate 공개 화면·활성 MCP 설정에서 기존 고객사명·보험 원장·기존 endpoint가 검색되지 않는다. 과거 SQL 이력 및 미실행 호환 코드는 제외한다.
+2. `SGMP_POC` 게임 데이터 객체만 마스킹 동기화 대상으로 선택된다.
+3. `mvn test`가 통과한다.
+4. 인증된 `admin`으로 주요 메뉴가 오류 배너 없이 200 응답을 반환하고, Streamlit의 MCP는 Text2SQL 생성 결과를 정상 표기한다.
+5. 변경 사항은 #703을 참조하는 Git 커밋과 Redmine 작업 로그로 남긴다.
+
+## 위험 및 완화
+
+- 과거 KB ORDS API는 게임 데이터 정책을 보장하지 않는다. endpoint 이름만 치환해 기존 API를 재사용하지 않는다.
+- 운영 VM SSH 키 인증이 거부될 수 있다. 로컬 빌드·공개 URL 확인을 먼저 수행하고, 배포 시에는 승인된 운영 접속 경로를 사용한다.
diff --git a/docs/design/704-smilegate-branch-isolation/README.md b/docs/design/704-smilegate-branch-isolation/README.md
new file mode 100644
index 0000000..2d1a57b
--- /dev/null
+++ b/docs/design/704-smilegate-branch-isolation/README.md
@@ -0,0 +1,43 @@
+# 설계서: Smilegate 전용 브랜치 분리
+
+## 추적성
+
+- Redmine: #704 `[Release] Smilegate 전용 브랜치 분리`
+- 관련 이슈: #703 `[Smilegate] 백오피스 잔여 UI 전환 및 운영 검증`
+- 작업 경로: `/Users/joungminko/claude-workspace/vpd-smilegate-rebrand`
+- 원격: `https://gittea.cloud-handson.com/joungmin/vpd-permission-poc.git`
+- 상태: Draft
+
+## 프로젝트 개요
+
+`vpd-permission-poc`은 Spring Boot VPD 관리 백오피스를 포함한다. HMM과 Smilegate 데모는 현재 같은 원격 저장소를 사용하지만, 고객별 화면·데이터 모델·배포 기준은 분리돼야 한다.
+
+## 목표
+
+Smilegate 작업본을 원격 `smilegate` 브랜치로 분리한다. HMM은 기존 `main` 브랜치를 그대로 사용하고, Smilegate 변경은 `smilegate` 브랜치만 기준으로 커밋·푸시·배포한다.
+
+## 범위
+
+1. detached HEAD 상태의 Smilegate worktree에서 `smilegate` 브랜치를 생성한다.
+2. `origin/smilegate`를 생성하고 현재 worktree의 upstream으로 설정한다.
+3. #703의 Smilegate 전용 설계서와 UI 변경만 `smilegate` 브랜치에 기록한다.
+4. HMM 작업본, `origin/main`, 다른 worktree의 파일과 HEAD를 변경하지 않는다.
+
+## 비범위
+
+- HMM의 로컬 수정·브랜치·배포 변경
+- 기존 `main`의 이력 재작성 또는 강제 푸시
+- 원격 저장소를 새로 생성하거나 삭제하는 작업
+
+## 검증 기준
+
+1. `git branch --show-current`은 Smilegate worktree에서 `smilegate`를 반환한다.
+2. `git rev-parse --abbrev-ref @{u}`는 `origin/smilegate`를 반환한다.
+3. `origin/main`의 커밋 ID는 분리 전후 동일하다.
+4. HMM 작업본의 status와 HEAD는 분리 작업으로 변경되지 않는다.
+
+## 운영 규칙
+
+- Smilegate 배포는 `origin/smilegate`의 검증된 커밋만 사용한다.
+- HMM 변경은 `main` 또는 HMM 전용 작업 경로에서만 수행한다.
+- 공통 기반을 변경해야 하면 두 고객 브랜치에 적용하기 전에 영향 범위를 별도 이슈로 검토한다.
diff --git a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py
index 1f5b94c..14576bf 100644
--- a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py
+++ b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py
@@ -62,7 +62,7 @@ from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
LOG = logging.getLogger(__name__)
MCP_PROTOCOL_VERSION = "2025-11-25"
-PREFERRED_TOOL = "ords.query.kb_select_ai_vpd"
+PREFERRED_TOOL = "oracle.select_ai.smilegate_game_text2sql"
DEFAULT_QUESTION = ""
MAX_RESPONSE_BYTES = 1_000_000
MAX_CONVERSATION_MESSAGES = 8
@@ -77,17 +77,21 @@ DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci"
ENV_FILE = ROOT / ".env"
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json"
-DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json"
+DEMO_SCENARIOS_FILE = ROOT / "config" / "smilegate_demo_scenarios.json"
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
-DEFAULT_VPD_USER_ID = "FC00789"
-VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/"
+DEFAULT_VPD_USER_ID = "sg-teamlead"
+VPD_OPERATIONS_URL = "https://smilegate-backoffice.cloud-handson.com/"
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember"
PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
-AUDIT_SCHEMA = "POC_2"
+AUDIT_SCHEMA = "SGMP_POC"
+REFERENCE_EVIDENCE_ENABLED = (
+ os.environ.get("POC4_REFERENCE_EVIDENCE_ENABLED", "").strip().lower()
+ in {"1", "true", "yes"}
+)
AUDIT_DB_ENV_FILE = Path(
os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env")
).expanduser()
@@ -2374,6 +2378,8 @@ def collect_security_evidence(
) -> Mapping[str, Any]:
"""Collect predefined, token-scoped security evidence without exposing secrets."""
+ if not REFERENCE_EVIDENCE_ENABLED:
+ return {}
kind = _security_evidence_kind(question)
if not kind or token_preset is None:
return {}
@@ -2938,6 +2944,8 @@ def collect_business_evidence(
) -> Mapping[str, Any]:
"""Collect token-validated, explicitly scoped business and catalog evidence."""
+ if not REFERENCE_EVIDENCE_ENABLED:
+ return {}
kind = _business_evidence_kind(question)
if not kind or token_preset is None:
return {}
@@ -7069,8 +7077,8 @@ def main() -> None:
else:
selected_token_preset = None
if configured_mcp_bearer:
- # The HMM MCP gateway uses its own server-side credential. VPD user
- # tokens remain UI context only and must never be forwarded to it.
+ # The Smilegate MCP gateway uses a server-side credential. UI-only
+ # context must never be forwarded as a second bearer token.
st.caption("MCP 인증: 서버 관리 토큰 적용")
manual_bearer_token = ""
elif selected_token_preset is not None:
@@ -7256,9 +7264,7 @@ def main() -> None:
'
',
unsafe_allow_html=True,
)
- architecture_tab, scenario_tab, audit_tab, operations_tab = st.tabs(
- ["아키텍처", "시나리오", "감사로그", "보안관리"]
- )
+ architecture_tab, scenario_tab = st.tabs(["아키텍처", "시나리오"])
with architecture_tab:
_render_architecture_tab()
@@ -7359,12 +7365,6 @@ def main() -> None:
height=0,
)
- with audit_tab:
- _render_fga_audit_tab()
-
- with operations_tab:
- _render_vpd_operations_tab()
-
if not submitted:
return
diff --git a/poc4_active_source_20260714/config/app_profile.json b/poc4_active_source_20260714/config/app_profile.json
index eea68d3..304c152 100644
--- a/poc4_active_source_20260714/config/app_profile.json
+++ b/poc4_active_source_20260714/config/app_profile.json
@@ -1,21 +1,21 @@
{
"version": 1,
"product": {
- "name": "AI 업무 에이전트",
- "short_name": "AGENT",
- "page_title": "AI 업무 에이전트",
+ "name": "SMILEGATE DATA & AI POC",
+ "short_name": "SMILEGATE",
+ "page_title": "SMILEGATE DATA & AI POC",
"page_icon": "🤖",
- "header_title": "AI 업무 에이전트",
- "header_description": "사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다.",
- "login_kicker": "DATA & AI DEMO",
- "login_title": "AI 업무 에이전트",
- "login_description": "사용자 인증 후 업무 질의와 보안 관리 기능을 이용할 수 있습니다.",
- "login_footer": "인증된 DEMO 사용자만 접근할 수 있습니다."
+ "header_title": "스마일게이트 게임 데이터 AI 에이전트",
+ "header_description": "게임 로그·서비스 데이터를 기반으로 AI 업무 효율화와 데이터 플랫폼 활용 방식을 검증합니다.",
+ "login_kicker": "SMILEGATE DATA & AI POC",
+ "login_title": "스마일게이트 게임 데이터 AI 에이전트",
+ "login_description": "사용자 인증 후 게임 데이터 AI 질의와 보안 관리 기능을 이용할 수 있습니다.",
+ "login_footer": "승인된 Data & AI PoC 사용자만 접근할 수 있습니다."
},
"theme": {
- "primary_color": "#003b70",
- "text_color": "#172b3a",
- "muted_color": "#667785",
- "border_color": "#dfe7ed"
+ "primary_color": "#113F67",
+ "text_color": "#15283B",
+ "muted_color": "#5D6C7C",
+ "border_color": "#D7E0E8"
}
}
diff --git a/poc4_active_source_20260714/config/mcp_servers.json b/poc4_active_source_20260714/config/mcp_servers.json
index 83c305c..a5fd504 100644
--- a/poc4_active_source_20260714/config/mcp_servers.json
+++ b/poc4_active_source_20260714/config/mcp_servers.json
@@ -1,22 +1,20 @@
{
- "default_server_id": "hmm_hr_mcp",
+ "default_server_id": "smilegate_game_data_mcp",
"servers": [
{
- "id": "hmm_hr_mcp",
+ "id": "smilegate_game_data_mcp",
"enabled": true,
- "provider": "hmm_compat_mcp",
+ "provider": "smilegate_select_ai_mcp",
"transport": "http",
- "endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
- "auth_token_env": "HMM_MCP_BEARER_TOKEN",
+ "endpoint_url": "https://smilegate-backoffice.cloud-handson.com/mcp",
+ "auth_token_env": "SMILEGATE_MCP_BEARER_TOKEN",
"timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
- "default_tool": "search_hr_data",
+ "default_tool": "oracle.select_ai.smilegate_game_text2sql",
"router_model_profile": "gpt54_mini_oci",
"tool_allowlist": [
- "search_hr_data",
- "resolve_hr_term",
- "search_hr_policy"
+ "oracle.select_ai.smilegate_game_text2sql"
],
- "description": "HMM HR knowledge and data MCP server"
+ "description": "Smilegate game-data Text2SQL MCP server"
}
]
}
diff --git a/poc4_active_source_20260714/config/smilegate_demo_scenarios.json b/poc4_active_source_20260714/config/smilegate_demo_scenarios.json
new file mode 100644
index 0000000..46ca34a
--- /dev/null
+++ b/poc4_active_source_20260714/config/smilegate_demo_scenarios.json
@@ -0,0 +1,41 @@
+{
+ "version": 1,
+ "description": "Smilegate Data & AI PoC 화면에 표시할 게임 데이터 질의 샘플입니다.",
+ "scenarios": [
+ {
+ "id": "GAME-01",
+ "enabled": true,
+ "category": "활성 사용자",
+ "title": "카제나 최신 AU",
+ "question": "카제나 최신 기준 활성 사용자 수(AU)를 알려줘"
+ },
+ {
+ "id": "GAME-02",
+ "enabled": true,
+ "category": "매출",
+ "title": "게임별 판매 현황",
+ "question": "최신 기준 게임별 판매 건수와 판매 금액을 보여줘"
+ },
+ {
+ "id": "GAME-03",
+ "enabled": true,
+ "category": "환불",
+ "title": "최근 환불 현황",
+ "question": "최신 기준 게임별 환불 건수와 환불 금액을 보여줘"
+ },
+ {
+ "id": "GAME-04",
+ "enabled": true,
+ "category": "게임·서버",
+ "title": "게임 서버 구성",
+ "question": "등록된 게임과 게임 서버 정보를 보여줘"
+ },
+ {
+ "id": "GAME-05",
+ "enabled": true,
+ "category": "사용자 분석",
+ "title": "신규 사용자 현황",
+ "question": "최신 월 기준 게임별 신규 사용자 수를 보여줘"
+ }
+ ]
+}
diff --git a/poc4_active_source_20260714/config/vpd_token_presets.json b/poc4_active_source_20260714/config/vpd_token_presets.json
index 494a426..dae699c 100644
--- a/poc4_active_source_20260714/config/vpd_token_presets.json
+++ b/poc4_active_source_20260714/config/vpd_token_presets.json
@@ -1,7 +1,7 @@
{
"presets": [
{
- "enabled": true,
+ "enabled": false,
"default": true,
"token": "vpd_live_REPLACE_WITH_USER_TOKEN",
"user_id": "FC00789",
diff --git a/poc4_active_source_20260714/tests/test_scenarios.py b/poc4_active_source_20260714/tests/test_scenarios.py
index 679f931..8a1a034 100644
--- a/poc4_active_source_20260714/tests/test_scenarios.py
+++ b/poc4_active_source_20260714/tests/test_scenarios.py
@@ -16,29 +16,29 @@ class DemoScenarioConfigTest(unittest.TestCase):
with patch.dict(
"os.environ",
{
- "AGENT_CONSOLE_SHORT_NAME": "HMM",
- "AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트",
- "AGENT_CONSOLE_PRIMARY_COLOR": "#003b70",
+ "AGENT_CONSOLE_SHORT_NAME": "SMILEGATE",
+ "AGENT_CONSOLE_PAGE_TITLE": "SMILEGATE DATA & AI POC",
+ "AGENT_CONSOLE_PRIMARY_COLOR": "#113F67",
},
clear=False,
):
profile = load_app_profile(path)
- self.assertEqual(profile.short_name, "HMM")
- self.assertEqual(profile.page_title, "HMM AI 업무 에이전트")
- self.assertEqual(profile.primary_color, "#003b70")
+ self.assertEqual(profile.short_name, "SMILEGATE")
+ self.assertEqual(profile.page_title, "SMILEGATE DATA & AI POC")
+ self.assertEqual(profile.primary_color, "#113F67")
def test_profile_reads_dotenv_values(self) -> None:
path = Path(__file__).parents[1] / "config" / "app_profile.json"
with tempfile.TemporaryDirectory() as temp_dir:
env_file = Path(temp_dir) / ".env"
- env_file.write_text("AGENT_CONSOLE_SHORT_NAME=HMM\n", encoding="utf-8")
+ env_file.write_text("AGENT_CONSOLE_SHORT_NAME=SMILEGATE\n", encoding="utf-8")
profile = load_app_profile(path, env_file)
- self.assertEqual(profile.short_name, "HMM")
+ self.assertEqual(profile.short_name, "SMILEGATE")
- def test_hmm_scenarios_are_enabled_and_unique(self) -> None:
- path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json"
+ def test_smilegate_scenarios_are_enabled_and_unique(self) -> None:
+ path = Path(__file__).parents[1] / "config" / "smilegate_demo_scenarios.json"
scenarios = load_demo_scenarios(path)
self.assertGreaterEqual(len(scenarios), 3)
diff --git a/pom.xml b/pom.xml
index 8fc9b81..6153fbf 100644
--- a/pom.xml
+++ b/pom.xml
@@ -98,11 +98,8 @@
sql/adbsql/adb
- 62_kb_aso_masking_backoffice_metadata.sql
- 63_kb_aso_masking_rule_runtime.sql
- 64_kb_aso_masking_default_column_rules.sql
- 65_kb_select_ai_vpd_query_api.sql
- 66_kb_select_ai_vpd_query_ords.sql
+ 70_sg_tool_user.sql
+ 71_sg_identity_administration.sql
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java
index a959001..804fcec 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java
@@ -8,7 +8,8 @@ public record BackofficeProperties(
Security security,
Token token,
Ords ords,
- Ai ai
+ Ai ai,
+ SelectAi selectAi
) {
public record Security(
@@ -72,4 +73,20 @@ public record BackofficeProperties(
this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", "");
}
}
+
+ /** Separate ADB connection because Select AI profiles are owned by SGMP_POC. */
+ public record SelectAi(
+ String dbUrl,
+ String dbUsername,
+ String dbPassword,
+ String profile
+ ) {
+
+ public boolean configured() {
+ return dbUrl != null && !dbUrl.isBlank()
+ && dbUsername != null && !dbUsername.isBlank()
+ && dbPassword != null && !dbPassword.isBlank()
+ && profile != null && !profile.isBlank();
+ }
+ }
}
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java
index 1ea66b1..85e1faa 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java
@@ -51,6 +51,6 @@ public class DbPoolWarmup {
groupService.findGroupRoles();
permissionService.findRoles();
permissionService.findPermissionViews();
- log.info("HMM identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
+ log.info("Smilegate identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
}
}
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java
index a46e5d1..5c74db0 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/MaskingTemplate.java
@@ -27,10 +27,10 @@ public enum MaskingTemplate {
"A******** (예시)"),
RRN_PARTIAL(
"RRN_PARTIAL",
- "주민등록번호 부분 마스킹",
- "앞 6자리만 표시하고 나머지는 가리는 사전 정의 식별번호 규칙입니다.",
+ "식별번호 부분 마스킹",
+ "앞 6자리만 표시하고 나머지를 가리는 사전 정의 식별번호 규칙입니다.",
"DBMS_REDACT.REGEXP",
- "900101-******* (예시)");
+ "123456-******* (예시)");
private final String code;
private final String label;
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java
index 7c9532b..06dba2c 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/permission/PermissionView.java
@@ -50,16 +50,16 @@ public record PermissionView(
return "토큰으로 식별된 이해관계자 본인 행";
}
if (upper.contains(" OWN_CONTRACT")) {
- return "담당 설계사 본인 계약";
+ return "담당 게임 서비스 범위";
}
if (upper.contains(" CHANNEL_CONTRACT")) {
- return "토큰 사용자의 채널 계약";
+ return "토큰 채널 게임 서비스 범위";
}
if (upper.contains(" OWN_CUSTOMER")) {
- return "담당 설계사 본인 계약에 연결된 고객/청구/외부보유";
+ return "담당 게임 사용자·거래 데이터 범위";
}
if (upper.contains(" CHANNEL_CUSTOMER")) {
- return "토큰 사용자 채널 계약에 연결된 고객/청구/외부보유";
+ return "토큰 채널 게임 사용자·거래 데이터 범위";
}
if (upper.contains(" STATIC_SQL ")) {
return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", "");
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java
index 2712457..e5c7862 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java
@@ -258,8 +258,8 @@ public class BackofficeSchemaService {
"문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"),
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
- new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL",
- "앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식")
+ new MaskingRuleSeed("MASK_IDENTIFIER_PARTIAL", "식별번호 부분 마스킹", "RRN_PARTIAL",
+ "앞 6자리만 보이고 나머지는 가리는 게임 사용자 식별번호 마스킹 방식")
);
private static final String MASKING_RULE_SEED_SQL = """
@@ -705,7 +705,7 @@ public class BackofficeSchemaService {
@sql/adb/17_agent_ords_security_local_vpd_setup.sql
@sql/adb/25_agent_ords_security_backoffice_support.sql
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql
- @sql/adb/62_kb_aso_masking_backoffice_metadata.sql
+ @sql/adb/71_sg_identity_administration.sql
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
-- 2. ORDS parsing schema로 접속
@@ -717,9 +717,8 @@ public class BackofficeSchemaService {
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
GRANT SELECT ON . TO cb_ords;
- -- 4. 마스킹 규칙을 UI에서 컬럼에 연결한 뒤 실행
- @sql/adb/64_kb_aso_masking_default_column_rules.sql
- @sql/adb/63_kb_aso_masking_rule_runtime.sql
+ -- 4. 마스킹 규칙을 UI에서 게임 데이터 컬럼에 연결
+ -- DBMS_REDACT 정책은 백오피스가 SGMP_POC 대상에 자동 동기화합니다.
""".formatted(owner.toLowerCase());
}
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java
index ce4b4ae..02dd396 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java
@@ -24,7 +24,7 @@ import org.springframework.stereotype.Service;
@Service
public class MaskingPolicySynchronizer {
- private static final String OWNER = "POC_2";
+ private static final String OWNER = "SGMP_POC";
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private static final Map MANAGED_POLICIES = managedPolicyMap();
@@ -38,10 +38,10 @@ public class MaskingPolicySynchronizer {
private static Map managedPolicyMap() {
Map policies = new LinkedHashMap<>();
- policies.put("KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT");
- policies.put("KB_CLAIMS", "KB_CLAIM_AMOUNT_REDACT");
- policies.put("KB_CONTRACTS", "KB_CONTRACT_PREMIUM_REDACT");
- policies.put("KB_EXTERNAL_HOLDINGS", "KB_EXT_HOLDING_REDACT");
+ policies.put("CZN_COMN_USER_MST", "SG_CZN_USER_REDACT");
+ policies.put("COMN_SALES_USER_MST", "SG_SALES_USER_REDACT");
+ policies.put("COMN_SALES_TXN", "SG_SALES_TXN_REDACT");
+ policies.put("COMN_REFUND_TXN", "SG_REFUND_TXN_REDACT");
return Collections.unmodifiableMap(policies);
}
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java
index 05335de..8ddef0b 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java
@@ -55,7 +55,7 @@ public class MaskingRuleService {
return mapper.findColumnRules();
}
- /** Reads the actual Oracle Data Redaction state for the three managed KB objects. */
+ /** Reads the actual Oracle Data Redaction state for the managed Smilegate game-data objects. */
public List findPolicyStatuses() {
return mapper.findPolicyStatuses();
}
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java
index 1765adf..a563acd 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java
@@ -8,30 +8,29 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
import org.springframework.stereotype.Service;
-/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */
+/** MCP boundary exposing the Smilegate game-data Select AI SHOWSQL tool. */
@Service
public class McpSseService {
- private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd";
- private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query";
- private static final String SELECT_AI_VPD_QUERY_PROFILE =
- "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1";
+ private static final String SELECT_AI_VPD_QUERY_TOOL = "oracle.select_ai.smilegate_game_text2sql";
+ private static final String SELECT_AI_VPD_QUERY_PATH = "/mcp (tools/call)";
+ private static final String SELECT_AI_VPD_QUERY_PROFILE = "SGMP_POC_HAIKU45";
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
SELECT_AI_VPD_QUERY_TOOL,
- "GPT-5.4-mini Select AI 자연어 질의를 행 접근 컨텍스트로 실행합니다. 테이블/컬럼 comment, annotation, constraint 메타데이터를 사용하고 생성 SQL은 KB 업무 테이블의 읽기 전용 SELECT/WITH만 허용합니다.",
+ "SGMP_POC_HAIKU45 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성합니다. 생성 SQL은 자동 실행하지 않으며 테이블·컬럼 comment, annotation, constraint를 참고합니다.",
-1L,
- "KB Select AI 행 접근 자연어 조회",
+ "Smilegate 게임 데이터 Text2SQL",
SELECT_AI_VPD_QUERY_PATH
);
- private final SelectAiAgentOrdsService selectAiAgentOrdsService;
+ private final SmilegateSelectAiService smilegateSelectAiService;
private final ObjectMapper objectMapper;
public McpSseService(
- SelectAiAgentOrdsService selectAiAgentOrdsService,
+ SmilegateSelectAiService smilegateSelectAiService,
ObjectMapper objectMapper
) {
- this.selectAiAgentOrdsService = selectAiAgentOrdsService;
+ this.smilegateSelectAiService = smilegateSelectAiService;
this.objectMapper = objectMapper;
}
@@ -106,17 +105,10 @@ public class McpSseService {
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
- prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다.");
+ prompt.put("description", "Smilegate 게임 로그·서비스 데이터에 대해 조회할 내용을 자연어로 입력합니다.");
prompt.put("maxLength", 4000);
properties.set("prompt", prompt);
- ObjectNode limit = objectMapper.createObjectNode();
- limit.put("type", "integer");
- limit.put("description", "최대 반환 행 수. 1부터 100까지 허용하며 기본값은 50입니다.");
- limit.put("minimum", 1);
- limit.put("maximum", 100);
- properties.set("limit", limit);
-
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode();
required.add("prompt");
@@ -139,11 +131,7 @@ public class McpSseService {
}
JsonNode response;
try {
- response = selectAiAgentOrdsService.run(
- token,
- arguments.path("prompt").asText(""),
- normalizeLimit(arguments.path("limit").asInt(50))
- );
+ response = smilegateSelectAiService.generateShowSql(token, arguments.path("prompt").asText(""));
} catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult();
}
@@ -181,13 +169,6 @@ public class McpSseService {
return result;
}
- private int normalizeLimit(int limit) {
- if (limit < 1) {
- return 50;
- }
- return Math.min(limit, 100);
- }
-
private String pretty(Object value) {
try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java
index 25b569e..a7f4d21 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java
@@ -643,7 +643,7 @@ public class OrdsProbeService {
}
}
}
- // APEX_JSON omits a ref-cursor column whose value is NULL. The KB
+ // APEX_JSON omits a ref-cursor column whose value is NULL. The
// object handlers always select every registered protected column, so
// a sensitive column missing from every JSON row is also a NULL result
// and must be reported as masked to the MCP/UI caller.
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java
index fab300f..92b3f50 100644
--- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptService.java
@@ -19,39 +19,18 @@ public class SecuritySqlScriptService {
private static final List CURATED_SCRIPTS = List.of(
new ScriptDefinition(
- "aso-masking-metadata",
- "ASO / 마스킹",
- "62_kb_aso_masking_backoffice_metadata.sql",
- "컬럼 마스킹 규칙 메타데이터",
- "ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다."
+ "smilegate-tool-users",
+ "Smilegate 사용자",
+ "70_sg_tool_user.sql",
+ "PoC 도구 사용자 초기 데이터",
+ "Data & AI TF 팀장·팀원 데모 사용자와 역할을 생성합니다. 게임 서비스 사용자가 아닌 PoC 도구 운영 사용자입니다."
),
new ScriptDefinition(
- "aso-masking-runtime",
- "ASO / 마스킹",
- "63_kb_aso_masking_rule_runtime.sql",
- "ASO 마스킹 런타임 적용",
- "백오피스 컬럼 마스킹 규칙을 Oracle Data Redaction 정책과 신뢰 컨텍스트에 반영합니다."
- ),
- new ScriptDefinition(
- "aso-masking-default-columns",
- "ASO / 마스킹",
- "64_kb_aso_masking_default_column_rules.sql",
- "ASO 기본 대상 컬럼",
- "주민번호·청구/지급금·타사보유 컬럼의 마스킹 블랙리스트 초기값을 연결합니다."
- ),
- new ScriptDefinition(
- "select-ai-vpd-api",
- "Select AI / 행 접근",
- "65_kb_select_ai_vpd_query_api.sql",
- "행 접근 적용 Select AI 조회 API",
- "생성 SQL을 KB 업무 테이블의 단일 읽기 전용 SELECT/WITH로 검증해 행 접근 컨텍스트에서 실행합니다."
- ),
- new ScriptDefinition(
- "select-ai-vpd-ords",
- "ORDS / Select AI",
- "66_kb_select_ai_vpd_query_ords.sql",
- "Select AI 행 접근 ORDS Endpoint",
- "Bearer 토큰을 검증해 행 접근 컨텍스트를 설정한 뒤 Select AI 조회 API를 노출합니다."
+ "smilegate-identity-administration",
+ "Smilegate 권한",
+ "71_sg_identity_administration.sql",
+ "사용자·그룹·역할 관리 모델",
+ "Smilegate PoC 운영 사용자, 그룹, 역할, 권한 메타데이터와 백오피스 호환 뷰를 생성합니다."
)
);
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java
deleted file mode 100644
index bec7d62..0000000
--- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java
+++ /dev/null
@@ -1,137 +0,0 @@
-package com.cloudhandson.vpdbackoffice.service;
-
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ObjectNode;
-import java.net.URI;
-import java.util.UUID;
-import org.springframework.http.HttpEntity;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpMethod;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.http.HttpStatus;
-import org.springframework.stereotype.Service;
-import org.springframework.web.client.HttpStatusCodeException;
-import org.springframework.web.client.ResourceAccessException;
-import org.springframework.web.client.RestTemplate;
-import org.springframework.web.util.UriComponentsBuilder;
-
-/** Calls the ORDS boundary; the database endpoint owns bearer-to-row-access-context mapping. */
-@Service
-public class SelectAiAgentOrdsService {
-
- static final String ORDS_PATH = "/cb-ords/kb-select-ai-vpd/query";
- private static final int MAX_PROMPT_LENGTH = 4_000;
- private static final int MAX_LIMIT = 100;
-
- private final SettingService settingService;
- private final RestTemplate restTemplate;
- private final ObjectMapper objectMapper;
-
- public SelectAiAgentOrdsService(
- SettingService settingService,
- RestTemplate ordsAgentRestTemplate,
- ObjectMapper objectMapper
- ) {
- this.settingService = settingService;
- this.restTemplate = ordsAgentRestTemplate;
- this.objectMapper = objectMapper;
- }
-
- /**
- * Compatibility overload for callers of the former SQL-generation tool.
- * The row-access query endpoint is stateless and therefore ignores conversationId.
- */
- public JsonNode run(String bearerToken, String prompt, String conversationId) {
- return run(bearerToken, prompt, 50);
- }
-
- public JsonNode run(String bearerToken, String prompt, int limit) {
- String normalizedToken = required(bearerToken, "bearerToken");
- String normalizedPrompt = required(prompt, "prompt");
- if (normalizedPrompt.length() > MAX_PROMPT_LENGTH) {
- throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
- }
- int normalizedLimit = normalizeLimit(limit);
- String baseUrl = settingService.ordsBaseUrl();
- if (baseUrl == null || baseUrl.isBlank()) {
- throw new AppException("ORDS base URL이 설정되지 않았습니다.");
- }
-
- ObjectNode requestBody = objectMapper.createObjectNode();
- requestBody.put("prompt", normalizedPrompt);
- requestBody.put("limit", normalizedLimit);
-
- HttpHeaders headers = new HttpHeaders();
- headers.setBearerAuth(normalizedToken);
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-VPD-Probe-Id", UUID.randomUUID().toString());
-
- try {
- ResponseEntity response = restTemplate.exchange(
- endpoint(baseUrl),
- HttpMethod.POST,
- new HttpEntity<>(requestBody.toString(), headers),
- String.class
- );
- JsonNode body = parse(response.getBody());
- if (body.hasNonNull("error")) {
- throw new AppException("Select AI 행 접근 ORDS 오류: " + body.path("error").asText());
- }
- return body;
- } catch (HttpStatusCodeException e) {
- if (e.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)
- || e.getStatusCode().isSameCodeAs(HttpStatus.FORBIDDEN)) {
- throw new VpdTokenAccessDeniedException();
- }
- throw new AppException("Select AI 행 접근 ORDS HTTP " + e.getStatusCode().value()
- + ": " + responseError(e.getResponseBodyAsString()));
- } catch (ResourceAccessException e) {
- throw new AppException("Select AI 행 접근 ORDS 연결 또는 응답 시간 초과: " + e.getMessage());
- }
- }
-
- private URI endpoint(String baseUrl) {
- return UriComponentsBuilder.fromUriString(baseUrl)
- .path(ORDS_PATH)
- .build()
- .toUri();
- }
-
- private JsonNode parse(String value) {
- try {
- if (value == null || value.isBlank()) {
- throw new AppException("Select AI 행 접근 ORDS 응답 본문이 비어 있습니다.");
- }
- return objectMapper.readTree(value);
- } catch (AppException e) {
- throw e;
- } catch (Exception e) {
- throw new AppException("Select AI 행 접근 ORDS 응답 JSON 파싱 실패: " + e.getMessage());
- }
- }
-
- private String responseError(String body) {
- try {
- JsonNode parsed = objectMapper.readTree(body);
- return parsed.path("error").asText(body == null ? "" : body);
- } catch (Exception ignored) {
- return body == null ? "" : body;
- }
- }
-
- private String required(String value, String name) {
- if (value == null || value.isBlank()) {
- throw new AppException(name + "은(는) 필수입니다.");
- }
- return value.trim();
- }
-
- private int normalizeLimit(int value) {
- if (value < 1) {
- return 50;
- }
- return Math.min(value, MAX_LIMIT);
- }
-}
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java
new file mode 100644
index 0000000..dc367c5
--- /dev/null
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java
@@ -0,0 +1,125 @@
+package com.cloudhandson.vpdbackoffice.service;
+
+import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
+import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+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 org.springframework.stereotype.Service;
+
+/**
+ * Generates reviewed read-only SQL through the schema-owned Smilegate Select AI profile.
+ * The generated SQL is never executed by this service.
+ */
+@Service
+public class SmilegateSelectAiService {
+
+ private static final int MAX_PROMPT_LENGTH = 4_000;
+ private static final String ACTION_SHOWSQL = "showsql";
+
+ private final BackofficeProperties properties;
+ private final BearerTokenService bearerTokenService;
+ private final Clock clock;
+ private final ObjectMapper objectMapper;
+
+ public SmilegateSelectAiService(
+ BackofficeProperties properties,
+ BearerTokenService bearerTokenService,
+ Clock clock,
+ ObjectMapper objectMapper
+ ) {
+ this.properties = properties;
+ this.bearerTokenService = bearerTokenService;
+ this.clock = clock;
+ this.objectMapper = objectMapper;
+ }
+
+ public JsonNode generateShowSql(String bearerToken, String prompt) {
+ requireActiveToken(bearerToken);
+ String normalizedPrompt = requiredPrompt(prompt);
+ BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
+ if (selectAi == null || !selectAi.configured()) {
+ throw new AppException("Smilegate Select AI 연결 설정이 필요합니다. "
+ + "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
+ + "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
+ }
+
+ String generatedSql = generate(selectAi, normalizedPrompt);
+ String normalizedSql = validateReadOnlySql(generatedSql);
+ ObjectNode response = objectMapper.createObjectNode();
+ response.put("status", "SHOWSQL");
+ response.put("profile", selectAi.profile());
+ response.put("generatedSql", normalizedSql);
+ response.put("execution", "NOT_EXECUTED");
+ response.put("nextStep", "생성 SQL을 검토한 뒤 Database Actions 또는 승인된 실행 경로에서 실행하세요.");
+ return response;
+ }
+
+ 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 requiredPrompt(String prompt) {
+ String normalized = prompt == null ? "" : prompt.trim();
+ if (normalized.isEmpty()) {
+ throw new AppException("prompt는 필수입니다.");
+ }
+ if (normalized.length() > MAX_PROMPT_LENGTH) {
+ throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
+ }
+ return normalized;
+ }
+
+ private String generate(BackofficeProperties.SelectAi selectAi, String prompt) {
+ String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, 'showsql') FROM dual";
+ try (Connection connection = DriverManager.getConnection(
+ selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, prompt);
+ statement.setString(2, selectAi.profile());
+ try (ResultSet resultSet = statement.executeQuery()) {
+ if (!resultSet.next() || resultSet.getString(1) == null) {
+ throw new AppException("Select AI가 생성 SQL을 반환하지 않았습니다.");
+ }
+ return resultSet.getString(1);
+ }
+ } catch (AppException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ throw new AppException("Smilegate Select AI SHOWSQL 생성 실패: " + exception.getMessage());
+ }
+ }
+
+ private String validateReadOnlySql(String generatedSql) {
+ String normalized = generatedSql == null ? "" : generatedSql.trim();
+ if (normalized.startsWith("```")) {
+ int firstLineEnd = normalized.indexOf('\n');
+ int closingFence = normalized.lastIndexOf("```");
+ if (firstLineEnd >= 0 && closingFence > firstLineEnd) {
+ normalized = normalized.substring(firstLineEnd + 1, closingFence).trim();
+ }
+ }
+ normalized = normalized.replaceFirst(";\\s*$", "").trim();
+ if (!normalized.matches("(?is)^(select|with)\\b.*")) {
+ throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다.");
+ }
+ if (normalized.contains(";")) {
+ throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다.");
+ }
+ return normalized;
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 5728558..97e93a2 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -68,3 +68,10 @@ backoffice:
oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}}
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
+ select-ai:
+ # Cloud AI profiles are schema-owned. This connection must use SGMP_POC,
+ # not the ADMIN connection used by the backoffice control plane.
+ db-url: ${BACKOFFICE_SELECT_AI_DB_URL:}
+ db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:}
+ db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:}
+ profile: ${BACKOFFICE_SELECT_AI_PROFILE:SGMP_POC_HAIKU45}
diff --git a/src/main/resources/mapper/AuditMapper.xml b/src/main/resources/mapper/AuditMapper.xml
index e68c641..306c78c 100644
--- a/src/main/resources/mapper/AuditMapper.xml
+++ b/src/main/resources/mapper/AuditMapper.xml
@@ -3,9 +3,10 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
- INSERT INTO hmm_access_audit (
- event_type, key_id, object_id, status, row_count, error_code, message, created_at
+ INSERT INTO sg_audit_event (
+ audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at
) VALUES (
+ sg_audit_event_seq.NEXTVAL,
#{eventType,jdbcType=VARCHAR},
#{keyId,jdbcType=NUMERIC},
#{objectId,jdbcType=NUMERIC},
diff --git a/src/main/resources/mapper/MaskingRuleMapper.xml b/src/main/resources/mapper/MaskingRuleMapper.xml
index c3d707a..e82c4a7 100644
--- a/src/main/resources/mapper/MaskingRuleMapper.xml
+++ b/src/main/resources/mapper/MaskingRuleMapper.xml
@@ -68,10 +68,10 @@
-->