From 211dd2b2c827ae4727913f27255800e2292d820f Mon Sep 17 00:00:00 2001 From: devmrko Date: Wed, 22 Jul 2026 15:35:02 +0900 Subject: [PATCH] refs #703 #704: finalize smilegate game data poc --- .env.example | 7 + .gitignore | 4 + .../README.md | 76 ++++++++++ .../704-smilegate-branch-isolation/README.md | 43 ++++++ .../apps/poc4/mcp_discovery_ui.py | 32 ++-- .../config/app_profile.json | 26 ++-- .../config/mcp_servers.json | 18 +-- .../config/smilegate_demo_scenarios.json | 41 ++++++ .../config/vpd_token_presets.json | 2 +- .../tests/test_scenarios.py | 20 +-- pom.xml | 7 +- .../config/BackofficeProperties.java | 19 ++- .../vpdbackoffice/config/DbPoolWarmup.java | 2 +- .../domain/masking/MaskingTemplate.java | 6 +- .../domain/permission/PermissionView.java | 8 +- .../service/BackofficeSchemaService.java | 11 +- .../service/MaskingPolicySynchronizer.java | 10 +- .../service/MaskingRuleService.java | 2 +- .../vpdbackoffice/service/McpSseService.java | 41 ++---- .../service/OrdsProbeService.java | 2 +- .../service/SecuritySqlScriptService.java | 41 ++---- .../service/SelectAiAgentOrdsService.java | 137 ------------------ .../service/SmilegateSelectAiService.java | 125 ++++++++++++++++ src/main/resources/application.yml | 7 + src/main/resources/mapper/AuditMapper.xml | 5 +- .../resources/mapper/MaskingRuleMapper.xml | 29 ++-- .../resources/mapper/StakeholderMapper.xml | 43 +++--- src/main/resources/static/js/app.js | 16 +- .../resources/templates/masking-rules.html | 24 +-- src/main/resources/templates/mcp-sse.html | 14 +- .../resources/templates/operation-status.html | 4 +- src/main/resources/templates/permissions.html | 36 ++--- .../templates/security-sql-scripts.html | 12 +- src/main/resources/templates/tokens.html | 14 +- .../templates/user-masking-rules.html | 10 +- .../templates/vpd-filter-runtime.html | 6 +- .../MaskingPolicySynchronizerTest.java | 6 +- .../service/McpSseServiceTest.java | 42 +++--- .../service/SecuritySqlScriptServiceTest.java | 13 +- .../service/StructuredDataServiceTest.java | 18 +-- .../web/GuidedFlowTemplateTest.java | 10 +- .../web/MaskingRuleTemplateRenderTest.java | 32 ++-- 42 files changed, 576 insertions(+), 445 deletions(-) create mode 100644 docs/design/703-smilegate-backoffice-finalization/README.md create mode 100644 docs/design/704-smilegate-branch-isolation/README.md create mode 100644 poc4_active_source_20260714/config/smilegate_demo_scenarios.json delete mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiAgentOrdsService.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java 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/adb sql/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 @@ --> - SELECT s.user_id AS stakeholder_user_id, - s.user_nm AS username, - s.role, - s.channel, - s.access_scope, + SELECT TO_CHAR(u.user_id) AS stakeholder_user_id, + u.user_name AS username, + COALESCE(( + SELECT MIN(r.role_name) + FROM sg_user_role user_role + JOIN sg_app_role r ON r.role_id = user_role.role_id + WHERE user_role.user_id = u.user_id + ), 'DATA_AI_OPERATOR') AS role, + 'BACKOFFICE' AS channel, + u.dept_code AS access_scope, u.user_id AS app_user_id - FROM poc_2.kb_stakeholders s - JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id + FROM sg_app_user u WHERE u.active = 'Y' - ORDER BY CASE s.role WHEN '지점장' THEN 1 WHEN '설계사' THEN 2 ELSE 3 END, - s.channel, - s.user_nm, - s.user_id + ORDER BY role, u.dept_code, u.user_name, u.user_id diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index cec3b70..3db9525 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -728,16 +728,16 @@ function permissionRuleBusinessLabel(type, column, value) { return `토큰 이해관계자 본인 행 (${displayColumn})`; } if (type === 'OWN_CONTRACT') { - return `담당 설계사 본인 계약 (${displayColumn})`; + return `담당 게임 서비스 범위 (${displayColumn})`; } if (type === 'CHANNEL_CONTRACT') { - return `토큰 사용자 채널 계약 (${displayColumn})`; + return `토큰 채널 게임 서비스 범위 (${displayColumn})`; } if (type === 'OWN_CUSTOMER') { - return `담당 설계사 본인 계약에 연결된 고객/청구/외부보유 (${displayColumn})`; + return `담당 게임 사용자·거래 데이터 범위 (${displayColumn})`; } if (type === 'CHANNEL_CUSTOMER') { - return `토큰 사용자 채널 계약에 연결된 고객/청구/외부보유 (${displayColumn})`; + return `토큰 채널 게임 사용자·거래 데이터 범위 (${displayColumn})`; } if (type === 'STATIC_SQL') { return `정적 SQL 조건: ${value || '조건식 미입력'}`; @@ -813,10 +813,10 @@ function collectWizardPredicates(root) { return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`; } if (type === 'STAKEHOLDER_SELF') { - return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_ID = TOKEN_STAKEHOLDER_ID)`; + return '조건 코드 STAKEHOLDER_SELF: DB 행 접근 함수가 토큰 컨텍스트와 등록된 업무 관계를 기준으로 조건을 생성'; } if (type === 'STAKEHOLDER_CHANNEL') { - return `EXISTS (KB_CONTRACTS c: c.${displayColumn} = <현재행>.${displayColumn} AND TOKEN_ROLE = '${value}' AND c.FC_CHANNEL = TOKEN_CHANNEL)`; + return '조건 코드 STAKEHOLDER_CHANNEL: DB 행 접근 함수가 토큰 채널과 등록된 업무 관계를 기준으로 조건을 생성'; } if (type === 'TOKEN_SUBJECT') { return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`; @@ -828,10 +828,10 @@ function collectWizardPredicates(root) { return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')`; } if (type === 'OWN_CUSTOMER') { - return `${displayColumn} IN (SELECT CUST_ID FROM KB_CONTRACTS WHERE FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID'))`; + return '조건 코드 OWN_CUSTOMER: DB 행 접근 함수가 담당 사용자와 게임 데이터 관계를 기준으로 조건을 생성'; } if (type === 'CHANNEL_CUSTOMER') { - return `${displayColumn} IN (SELECT CUST_ID FROM KB_CONTRACTS WHERE FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL'))`; + return '조건 코드 CHANNEL_CUSTOMER: DB 행 접근 함수가 토큰 채널과 게임 데이터 관계를 기준으로 조건을 생성'; } if (type === 'DEPT' || type === 'EMP_NO') { return `${displayColumn} = ${sqlLiteral(value)}`; diff --git a/src/main/resources/templates/masking-rules.html b/src/main/resources/templates/masking-rules.html index 2ecd427..228cc26 100644 --- a/src/main/resources/templates/masking-rules.html +++ b/src/main/resources/templates/masking-rules.html @@ -108,8 +108,8 @@ 보호 객체DB 정책백오피스 활성 컬럼DB Redaction 컬럼레거시 VPD 컬럼 제어DB 정책 활성상태 판단 - POC_2.KB_CUSTOMERS - KB_CUSTOMER_PII_REDACT + SGMP_POC.CZN_COMN_USER_MST + SG_GAME_USER_REDACT 0 0 없음 @@ -142,17 +142,17 @@

컬럼 마스킹 규칙 등록

-

업무용 이름을 붙여 템플릿을 재사용합니다. 예: KB_RRN_STANDARD.

+

게임 데이터 기준의 업무용 이름을 붙여 템플릿을 재사용합니다. 예: GAME_USER_ID_MASK.

@@ -253,9 +253,9 @@ 대상 컬럼규칙템플릿백오피스 설정DB ASO 적용 상태 - POC_2.KB_CUSTOMERS.RRN_MASKED - 주민번호 기본 마스킹 - 주민등록번호 부분 마스킹 + SGMP_POC.CZN_COMN_USER_MST.USER_ID + 게임 사용자 식별자 기본 마스킹 + 식별자 부분 마스킹 기본 규칙 연결됨 MCP 연동
도움말 -

사용자 Bearer Token을 전달해 GPT-5.4-mini Select AI 자연어 조회를 실행하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하고, DB에서는 토큰 기준 행 접근 context가 적용됩니다.

+

사용자 Bearer Token을 검증한 뒤 SGMP_POC_HAIKU45 Select AI 프로파일로 게임 데이터 Text2SQL을 생성하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하며, 생성 SQL은 자동 실행하지 않습니다.

@@ -99,7 +99,7 @@
ORDS 행 접근 조회 도구 설명
- HTTP Authorization → 행 접근 컨텍스트 · prompt → GPT-5.4-mini Select AI 자연어 질의 · limit → 최대 반환 행 수 + HTTP Authorization → 활성 PoC 사용자 토큰 검증 · prompt → 게임 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행 @@ -116,10 +116,9 @@ tools/call parameter 예시 보기

tools/call Arguments

{
-  "prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.",
-  "limit": 50
+  "prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
 }
-

등록 tool은 ords.query.kb_select_ai_vpd 하나입니다. HTTP Authorization의 사용자 Bearer Token으로 컨텍스트를 설정한 뒤 GPT-5.4-mini가 comment, annotation, constraint를 참고해 생성한 검증된 읽기 전용 KB 원장 SQL만 실행합니다.

+

등록 tool은 oracle.select_ai.smilegate_game_text2sql 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 SGMP_POC_HAIKU45가 comment, annotation, constraint를 참고해 읽기 전용 게임 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.

@@ -153,10 +152,9 @@ "id": 3, "method": "tools/call", "params": { - "name": "ords.query.kb_select_ai_vpd", + "name": "oracle.select_ai.smilegate_game_text2sql", "arguments": { - "prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.", - "limit": 50 + "prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘." } } } diff --git a/src/main/resources/templates/operation-status.html b/src/main/resources/templates/operation-status.html index c3d0507..dd039c9 100644 --- a/src/main/resources/templates/operation-status.html +++ b/src/main/resources/templates/operation-status.html @@ -159,8 +159,8 @@ 보호 객체DB 정책백오피스 활성 컬럼DB Redaction 컬럼상태확인 결과 - POC_2.KB_CLAIMS - KB_CLAIM_AMOUNT_REDACT + SGMP_POC.COMN_SALES_TXN + SG_SALES_TXN_REDACT 0 0 적용됨 diff --git a/src/main/resources/templates/permissions.html b/src/main/resources/templates/permissions.html index d686dc0..5513bff 100644 --- a/src/main/resources/templates/permissions.html +++ b/src/main/resources/templates/permissions.html @@ -9,7 +9,7 @@
도움말

이 화면은 행 접근 규칙만 저장합니다. 저장값은 최종 SQL이 아니라 CB_AGENT_DOC_VPD_FILTER가 읽어서 대상 테이블의 WHERE predicate로 바꾸는 매핑 데이터입니다.

-

컬럼 원문/마스킹은 이 화면에서 처리하지 않습니다. 주민번호, 보험료, 청구금액 같은 컬럼 표시는 컬럼 마스킹의 ASO/Data Redaction 정책에서 관리합니다.

+

컬럼 원문/마스킹은 이 화면에서 처리하지 않습니다. 게임 사용자 식별자, 결제금액 같은 민감 컬럼 표시는 컬럼 마스킹의 ASO/Data Redaction 정책에서 관리합니다.

@@ -158,15 +158,15 @@ diff --git a/src/main/resources/templates/tokens.html b/src/main/resources/templates/tokens.html index d787dcc..4ed66c4 100644 --- a/src/main/resources/templates/tokens.html +++ b/src/main/resources/templates/tokens.html @@ -1,14 +1,14 @@ - +
-

HMM 액세스 토큰

+

Smilegate 액세스 토큰

도움말 -

HMM HR 직원에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.

+

Data & AI PoC 도구 사용자에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.

@@ -27,14 +27,14 @@
-

직원 토큰 발급

-

활성 HMM 직원에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.

+

도구 사용자 토큰 발급

+

활성 Data & AI PoC 사용자에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.

diff --git a/src/main/resources/templates/user-masking-rules.html b/src/main/resources/templates/user-masking-rules.html index 1a9f90b..749f854 100644 --- a/src/main/resources/templates/user-masking-rules.html +++ b/src/main/resources/templates/user-masking-rules.html @@ -26,7 +26,7 @@
@@ -85,9 +85,9 @@ 사용자대상 컬럼기본 규칙원문 표시 상태상태 - KB_VPD_ADMIN - POC_2.KB_CUSTOMERS.RRN_MASKED - 주민번호 기본 마스킹 · 주민등록번호 부분 마스킹 + sg-teamlead + SGMP_POC.CZN_COMN_USER_MST.GUID + 게임 사용자 식별자 기본 마스킹 · 식별번호 부분 마스킹 원문 표시 예외 Y diff --git a/src/main/resources/templates/vpd-filter-runtime.html b/src/main/resources/templates/vpd-filter-runtime.html index d170855..7c39ef5 100644 --- a/src/main/resources/templates/vpd-filter-runtime.html +++ b/src/main/resources/templates/vpd-filter-runtime.html @@ -82,8 +82,8 @@ - POC_2.KB_CONTRACTS - KB_KB_CONTRACTS_ROW_POLICY + SGMP_POC.CZN_COMN_USER_MST + SG_CZN_USER_ROW_POLICY SELECT 적용됨 ADMIN.CB_AGENT_DOC_VPD_FILTER @@ -105,7 +105,7 @@ 토큰 신뢰 경계Bearer Token은 ORDS에서 검증하고 요청 종료 시 컨텍스트를 정리합니다.ORDS Handler / CB_AGENT_CTX_PKG 권한 결합권한 내부 규칙은 AND, ALLOW 권한은 OR, DENY 조건은 최종적으로 제외합니다.행 접근 규칙 오류·미권한유효한 컨텍스트나 ALLOW 권한이 없으면 행 접근은 차단돼야 합니다.접근 검증 - 컬럼 보호행 필터(VPD)와 보험료·주민번호 등의 ASO/Data Redaction 마스킹을 분리해 확인합니다.컬럼 마스킹 + 컬럼 보호행 필터(VPD)와 게임 사용자 식별자·거래 식별자의 ASO/Data Redaction 마스킹을 분리해 확인합니다.컬럼 마스킹
diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java index a8ece9b..73e3ff6 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java @@ -23,8 +23,8 @@ class MaskingPolicySynchronizerTest { } @Test - void kbContractsPremiumPolicyIsManagedByBackoffice() { - assertThat(MaskingPolicySynchronizer.managedPolicyName("KB_CONTRACTS")) - .isEqualTo("KB_CONTRACT_PREMIUM_REDACT"); + void smilegateSalesTransactionPolicyIsManagedByBackoffice() { + assertThat(MaskingPolicySynchronizer.managedPolicyName("COMN_SALES_TXN")) + .isEqualTo("SG_SALES_TXN_REDACT"); } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index b9cf90c..d312f0f 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -9,9 +9,9 @@ import org.junit.jupiter.api.Test; class McpSseServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); - private final SelectAiAgentOrdsService selectAiAgentOrdsService = new CapturingSelectAiAgentOrdsService(); + private final SmilegateSelectAiService selectAiService = new CapturingSmilegateSelectAiService(); private final McpSseService service = new McpSseService( - selectAiAgentOrdsService, + selectAiService, objectMapper ); @@ -22,12 +22,12 @@ class McpSseServiceTest { var tools = response.path("result").path("tools"); assertThat(tools).hasSize(1); var selectAi = tools.get(0); - assertThat(selectAi.path("name").asText()).isEqualTo("ords.query.kb_select_ai_vpd"); + assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.smilegate_game_text2sql"); assertThat(selectAi.path("inputSchema").path("required")) .extracting(node -> node.asText()) .contains("prompt"); assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse(); - assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isTrue(); + assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isFalse(); assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse(); } @@ -35,31 +35,29 @@ class McpSseServiceTest { void callsVpdSelectAiThroughOrdsService() { ObjectNode request = request(2, "tools/call"); ObjectNode params = (ObjectNode) request.putObject("params"); - params.put("name", "ords.query.kb_select_ai_vpd"); + params.put("name", "oracle.select_ai.smilegate_game_text2sql"); ObjectNode arguments = params.putObject("arguments"); - arguments.put("prompt", "고객 수를 조회해 줘"); - arguments.put("limit", 25); + arguments.put("prompt", "카제나 AU를 조회해 줘"); ObjectNode response = service.handle("default", request, "user-bearer"); - CapturingSelectAiAgentOrdsService agentService = - (CapturingSelectAiAgentOrdsService) selectAiAgentOrdsService; + CapturingSmilegateSelectAiService agentService = + (CapturingSmilegateSelectAiService) selectAiService; assertThat(agentService.bearerToken).isEqualTo("user-bearer"); - assertThat(agentService.prompt).isEqualTo("고객 수를 조회해 줘"); - assertThat(agentService.limit).isEqualTo(25); + assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘"); assertThat(response.path("error").isMissingNode()).isTrue(); assertThat(response.path("result").path("isError").asBoolean()).isFalse(); assertThat(response.path("result").path("content").get(0).path("text").asText()) - .contains("KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1") - .contains("SELECT COUNT(*) FROM KB_CUSTOMERS"); + .contains("SGMP_POC_HAIKU45") + .contains("SELECT 1 FROM DUAL"); } @Test void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() { ObjectNode request = request(3, "tools/call"); ObjectNode params = (ObjectNode) request.putObject("params"); - params.put("name", "ords.query.kb_select_ai_vpd"); - params.putObject("arguments").put("prompt", "고객 수를 조회해 줘"); + params.put("name", "oracle.select_ai.smilegate_game_text2sql"); + params.putObject("arguments").put("prompt", "카제나 AU를 조회해 줘"); ObjectNode response = service.handle("default", request, ""); @@ -78,24 +76,22 @@ class McpSseServiceTest { return request; } - private static final class CapturingSelectAiAgentOrdsService extends SelectAiAgentOrdsService { + private static final class CapturingSmilegateSelectAiService extends SmilegateSelectAiService { private String bearerToken; private String prompt; - private int limit; - private CapturingSelectAiAgentOrdsService() { - super(null, null, new ObjectMapper()); + private CapturingSmilegateSelectAiService() { + super(null, null, null, new ObjectMapper()); } @Override - public JsonNode run(String bearerToken, String prompt, int limit) { + public JsonNode generateShowSql(String bearerToken, String prompt) { this.bearerToken = bearerToken; this.prompt = prompt; - this.limit = limit; return new ObjectMapper().createObjectNode() - .put("profile", "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1") - .put("generatedSql", "SELECT COUNT(*) FROM KB_CUSTOMERS"); + .put("profile", "SGMP_POC_HAIKU45") + .put("generatedSql", "SELECT 1 FROM DUAL"); } } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java index 934b908..f738c2a 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/SecuritySqlScriptServiceTest.java @@ -14,16 +14,13 @@ class SecuritySqlScriptServiceTest { assertThat(service.list()) .extracting(item -> item.fileName()) .containsExactly( - "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" ); - assertThat(service.find("select-ai-vpd-ords").source()) - .contains("POST /ords/cb-ords/kb-select-ai-vpd/query") - .contains("Authorization: Bearer "); + assertThat(service.find("smilegate-identity-administration").source()) + .contains("create table sg_app_user") + .contains("DATA_AI_TF"); } @Test diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java index 8b549fa..a0dc7dd 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java @@ -12,22 +12,22 @@ class StructuredDataServiceTest { private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class)); @Test - void exposesOnlyTheSevenApprovedStructuredTables() { + void exposesOnlyTheSevenApprovedSmilegateStructuredTables() { assertThat(service.tables()) .extracting(table -> table.tableName()) .containsExactly( - "KB_CUSTOMERS", - "KB_PRODUCTS", - "KB_CONTRACTS", - "KB_COVERAGES", - "KB_CLAIMS", - "KB_EXTERNAL_HOLDINGS", - "KB_STAKEHOLDERS"); + "CZN_COMN_USER_MST", + "CZN_COMN_CHARACTER_MST", + "COMN_SALES_TXN", + "COMN_REFUND_TXN", + "COMN_SALES_PRODUCT_DISP_BAS", + "COMN_GAME_SERVER_BAS", + "COMN_GAME_ALIAS_BAS"); } @Test void rejectsAnyTableOutsideTheServerSideAllowlist() { - assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG")) + assertThatThrownBy(() -> service.requireTable("security-audit-log")) .isInstanceOf(AppException.class) .hasMessage("선택할 수 없는 정형 데이터 테이블입니다."); } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java index cc660c6..01829a7 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -35,7 +35,7 @@ class GuidedFlowTemplateTest { .doesNotContain("dashboard-workflow") .doesNotContain(">01<", ">02<"); assertThat(layout) - .contains("데이터 접근 제어") + .contains("운영 사용자 관리") .contains("접근 제어") .contains("보호·검증") .contains("연동 도구") @@ -122,7 +122,7 @@ class GuidedFlowTemplateTest { .contains("data-wizard-target=\"2\">대상") .doesNotContain(">1 역할<", ">2 객체<", "wizard-step-number"); assertThat(tokens) - .contains("직원 토큰 발급", "검증 세션 발급") + .contains("도구 사용자 토큰 발급", "검증 세션 발급") .doesNotContain("1. 검증할 사용자 선택", "2. 검증 세션 발급"); assertThat(probe) .contains("접근 검증 실행", "검증 결과") @@ -198,7 +198,7 @@ class GuidedFlowTemplateTest { String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js")); assertThat(html) - .contains("value=\"OWN_CUSTOMER\">본인 담당 고객") + .contains("value=\"OWN_CUSTOMER\">담당 게임 사용자") .contains("value=\"STATIC_SQL\">정적 SQL 조건식") .contains("행 규칙의 두 가지 적용 방식 보기") .contains("한 권한 안의 규칙은 모두 AND") @@ -221,8 +221,8 @@ class GuidedFlowTemplateTest { String filters = template("vpd-filter-policies.html"); assertThat(roles) - .contains("표시 보호 등급 상한") - .contains("행 접근 권한은 행 접근 규칙과 Oracle VPD 정책에서 결정합니다"); + .contains("스마일게이트 Data & AI PoC 운영 역할") + .contains("데이터 취급 등급"); assertThat(tokens) .contains("회수·만료 포함") .contains("현재 사용 가능한 토큰만"); diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java index 8fae5e3..211f3ca 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/MaskingRuleTemplateRenderTest.java @@ -31,26 +31,26 @@ class MaskingRuleTemplateRenderTest { var engine = new SpringTemplateEngine(); engine.setTemplateResolver(resolver); - var object = new ProtectedObject(7L, "POC_2", "KB_CUSTOMERS", "kb/customers", "Y"); - var contractObject = new ProtectedObject(8L, "POC_2", "KB_CONTRACTS", "kb/contracts", "Y"); - var column = new ProtectedColumn(11L, 7L, "RRN_MASKED", "Y", null, "RESTRICTED", "NULLIFY"); - var rule = new MaskingRule(1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "테스트", "Y"); - var columnRule = new ColumnMaskingRule(11L, 7L, "POC_2", "KB_CUSTOMERS", "RRN_MASKED", - 1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "Y"); + var object = new ProtectedObject(7L, "SGMP_POC", "CZN_COMN_USER_MST", "sgmp-poc/czn-users", "Y"); + var transactionObject = new ProtectedObject(8L, "SGMP_POC", "COMN_SALES_TXN", "sgmp-poc/sales", "Y"); + var column = new ProtectedColumn(11L, 7L, "GUID", "Y", null, "RESTRICTED", "NULLIFY"); + var rule = new MaskingRule(1L, "GAME_GUID_MASK", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "테스트", "Y"); + var columnRule = new ColumnMaskingRule(11L, 7L, "SGMP_POC", "CZN_COMN_USER_MST", "GUID", + 1L, "GAME_GUID_MASK", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "Y"); var context = new Context(Locale.KOREAN); context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token")); context.setVariable("templates", List.of(MaskingTemplate.values())); context.setVariable("rules", List.of(rule)); context.setVariable("columnRules", List.of(columnRule)); var status = new MaskingPolicyStatus( - "POC_2", "KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT", "YES", 1, 1, 0, 0 + "SGMP_POC", "CZN_COMN_USER_MST", "SG_CZN_USER_REDACT", "YES", 1, 1, 0, 0 ); context.setVariable("policyStatuses", List.of(status)); - context.setVariable("policyStatusByObjectName", Map.of("KB_CUSTOMERS", status)); - context.setVariable("objects", List.of(object, contractObject)); - context.setVariable("maskingTargetObjects", List.of(object, contractObject)); + context.setVariable("policyStatusByObjectName", Map.of("CZN_COMN_USER_MST", status)); + context.setVariable("objects", List.of(object, transactionObject)); + context.setVariable("maskingTargetObjects", List.of(object, transactionObject)); context.setVariable("sensitiveColumnsByObject", Map.of(7L, List.of(column), 8L, List.of())); - context.setVariable("availableMaskingColumnsByObject", Map.of(7L, List.of("CUST_NM"), 8L, List.of("PREMIUM"))); + context.setVariable("availableMaskingColumnsByObject", Map.of(7L, List.of("GUID"), 8L, List.of("GUID"))); String rulesPage = engine.process("masking-rules", context); @@ -64,13 +64,13 @@ class MaskingRuleTemplateRenderTest { .contains("DB Redaction 컬럼") .contains("레거시 VPD 컬럼 제어") .contains("마스킹 대상 컬럼 추가") - .contains("POC_2.KB_CONTRACTS.PREMIUM") - .contains("KB_CUSTOMER_PII_REDACT") + .contains("SGMP_POC.COMN_SALES_TXN.GUID") + .contains("SG_CZN_USER_REDACT") .contains("적용됨"); - context.setVariable("users", List.of(new AppUser(2L, "KB_VPD_ADMIN", "ADMIN", "SEC", "N", "Y"))); - context.setVariable("userRules", List.of(new UserMaskingRule(2L, "KB_VPD_ADMIN", 11L, - "POC_2", "KB_CUSTOMERS", "RRN_MASKED", "주민번호 기본 마스킹", "RRN_PARTIAL", "UNMASK", "Y"))); + context.setVariable("users", List.of(new AppUser(2L, "sg-teamlead", "SG-001", "DATA_AI", "N", "Y"))); + context.setVariable("userRules", List.of(new UserMaskingRule(2L, "sg-teamlead", 11L, + "SGMP_POC", "CZN_COMN_USER_MST", "GUID", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "UNMASK", "Y"))); String userPage = engine.process("user-masking-rules", context); assertThat(userPage)