Compare commits
29 Commits
hmm-backof
...
32fccced20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32fccced20 | ||
|
|
a46ebe6bfd | ||
|
|
1771dccd96 | ||
|
|
cbee6c31c7 | ||
|
|
f730c73ceb | ||
|
|
b106e40631 | ||
|
|
99798aa6bb | ||
|
|
1fe8404e4b | ||
|
|
1fcc231eff | ||
|
|
63f294a857 | ||
|
|
20836e458d | ||
|
|
9eb1f4d1fd | ||
|
|
914dc087ed | ||
|
|
27757ebff3 | ||
|
|
4211f11cae | ||
|
|
a91ad3c364 | ||
|
|
90d76f1033 | ||
|
|
211dd2b2c8 | ||
|
|
20c6a82338 | ||
|
|
b3f2422b63 | ||
|
|
1538aadb14 | ||
|
|
404244534a | ||
|
|
ea4fc1de8b | ||
|
|
0eb68664f1 | ||
|
|
4eec33b2ed | ||
|
|
b4ed8649f1 | ||
|
|
f7295277f1 | ||
|
|
075eb46ef8 | ||
|
|
2b66e27bfb |
@@ -51,6 +51,13 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}"
|
|||||||
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
|
export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS"
|
||||||
export BACKOFFICE_ORDS_DB_PASSWORD=""
|
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 탭) ---
|
# --- (2c) OpenAI 호환 AI 호출 (MCP-style Reasoning 탭) ---
|
||||||
export BACKOFFICE_AI_ENABLED="false"
|
export BACKOFFICE_AI_ENABLED="false"
|
||||||
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
|
export BACKOFFICE_AI_PROVIDER="openai" # openai | oci
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,6 +17,10 @@ logs/
|
|||||||
# Java / Maven
|
# Java / Maven
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
# Python / Streamlit
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
# Locally downloaded development tools (for example SQLcl)
|
# Locally downloaded development tools (for example SQLcl)
|
||||||
.tools/
|
.tools/
|
||||||
|
|
||||||
|
|||||||
58
deploy/caddy/smilegate/Caddyfile
Normal file
58
deploy/caddy/smilegate/Caddyfile
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
servers {
|
||||||
|
protocols h1 h2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http://193.122.114.213 {
|
||||||
|
encode zstd gzip
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
}
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/smilegate-console-access.log
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
reverse_proxy 127.0.0.1:8622 {
|
||||||
|
transport http {
|
||||||
|
versions 1.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
smilegate.cloud-handson.com {
|
||||||
|
encode zstd gzip
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
}
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/smilegate-console-access.log
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
reverse_proxy 127.0.0.1:8622 {
|
||||||
|
transport http {
|
||||||
|
versions 1.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
smilegate-backoffice.cloud-handson.com {
|
||||||
|
# Edge clients have left completed zstd-compressed HTML navigations pending.
|
||||||
|
# Use broadly supported gzip on the management UI; the console keeps zstd.
|
||||||
|
encode gzip
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
}
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/smilegate-backoffice-access.log
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
reverse_proxy 127.0.0.1:8082 {
|
||||||
|
transport http {
|
||||||
|
versions 1.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
docs/design/703-smilegate-backoffice-finalization/README.md
Normal file
87
docs/design/703-smilegate-backoffice-finalization/README.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# 설계서: 스마일게이트 백오피스 잔여 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 하나만 노출한다. 이전 고객용 토큰 프리셋 및 감사·보안관리 탭은 기본 실행 경로에서 제외한다.
|
||||||
|
7. `/schema-metadata`의 테이블 comment·컬럼 comment·annotation 조회와 저장 DDL은 모두 `SchemaMetadataMapper`로 수행한다. 메타데이터 조회는 `SGMP_POC` owner와 허용된 테이블 목록으로 한정한다.
|
||||||
|
|
||||||
|
## 설계 결정
|
||||||
|
|
||||||
|
### 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 호출 전에 화면 설정 오류로 처리한다.
|
||||||
|
|
||||||
|
### 5. 스키마 메타데이터 접근은 MyBatis로 통일한다
|
||||||
|
|
||||||
|
`/schema-metadata`는 화면 카드 목록을 정적 허용 목록에서 만들고, 선택된 테이블의 comment·컬럼·annotation만 조회한다. 서비스 계층에는 JDBC 직접 실행을 두지 않는다. table/column comment 사전 조회는 `owner = 'SGMP_POC'` 조건을 갖는다. Oracle의 `ALL_ANNOTATIONS_USAGE`에는 객체 owner 컬럼이 없으므로 annotation 조회는 허용 목록에서 선택된 정확한 `OBJECT_NAME`과 `OBJECT_TYPE='TABLE'`로 한정한다. DDL에 쓰이는 테이블·컬럼·annotation 이름은 호출 전에 대문자 식별자 규칙과 허용 테이블 목록으로 검증한다.
|
||||||
|
|
||||||
|
백오피스 도메인은 Caddy에서 `gzip`만 사용한다. Edge가 HTTP/2 `zstd` HTML 응답을 완료된 상태에서도 pending으로 표시한 운영 증거가 있어, Streamlit 콘솔과 분리해 관리 UI 응답의 압축 호환성을 우선한다.
|
||||||
|
|
||||||
|
공통 화면 head의 외부 UI 보조 스크립트는 `defer`로 로드한다. CDN 지연이 정적 서버 렌더링 화면의 HTML 파싱·첫 표시를 막아서는 안 된다. `schema-metadata`는 서버 렌더링만으로 테이블 선택과 comment/annotation 보기를 제공한다.
|
||||||
|
|
||||||
|
|
||||||
|
## 변경 파일과 책임
|
||||||
|
|
||||||
|
| 영역 | 파일 | 변경 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 행 접근 화면 | `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 계약 적용 |
|
||||||
|
| 스키마 메타데이터 | `SchemaMetadataService.java`, `SchemaMetadataMapper.java`, `SchemaMetadataMapper.xml` | 직접 JDBC 제거, MyBatis 조회·DDL 통일, `SGMP_POC` owner 조건 강제 |
|
||||||
|
|
||||||
|
## 완료 기준
|
||||||
|
|
||||||
|
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 확인을 먼저 수행하고, 배포 시에는 승인된 운영 접속 경로를 사용한다.
|
||||||
43
docs/design/704-smilegate-branch-isolation/README.md
Normal file
43
docs/design/704-smilegate-branch-isolation/README.md
Normal file
@@ -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 전용 작업 경로에서만 수행한다.
|
||||||
|
- 공통 기반을 변경해야 하면 두 고객 브랜치에 적용하기 전에 영향 범위를 별도 이슈로 검토한다.
|
||||||
70
docs/design/706-smilegate-qa-history/README.md
Normal file
70
docs/design/706-smilegate-qa-history/README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# 설계서: 스마일게이트 고객 질답 검증 이력
|
||||||
|
|
||||||
|
## 추적성
|
||||||
|
|
||||||
|
- Redmine: #706 `[Smilegate] 고객 엑셀 질답 검증 이력 및 실행 화면`
|
||||||
|
- 기준 질답서: `/Users/joungminko/claude-workspace/oci-data-flow-aidp/docs/reports/sgmp-select-ai-full-qa-term-dict-final-v2-20260721.md`
|
||||||
|
- 기준 데이터: 표준 DW 샘플 28건 + 카제나 샘플 19건 = 47건
|
||||||
|
- 대상 스키마: `SGMP_POC`
|
||||||
|
- 대상 화면: `poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py`
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
`vpd-permission-poc`은 Oracle Autonomous Database의 게임 데이터와 Select AI/MCP를 연결해 자연어 데이터 질의를 검증하는 PoC다. 이번 기능은 고객이 제공한 Excel 기반 질답서를 실행 가능한 기준 시나리오로 바꾸고, 데모 중 실제 답변 품질을 설명 가능하게 남긴다.
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
1. 고객 Excel에서 정리한 47개 질문을 질문 마스터로 보관한다.
|
||||||
|
2. 기준 답변, 기준 SQL, 과거 검증 결과와 이후 실행 결과를 모두 순차 이력으로 보관한다.
|
||||||
|
3. 사용자가 후보 테이블에서 질문을 고르거나 자유 질의를 입력해 즉시 실행할 수 있게 한다.
|
||||||
|
4. 후보 질문은 SQL 의미 검증과 실행 결과로 `PASS`, `WARN`, `FAIL`을 표시한다. 정답 기준이 없는 자유 질의는 `REVIEW`로 표시한다.
|
||||||
|
|
||||||
|
## 데이터 모델
|
||||||
|
|
||||||
|
테이블은 사용자 요청에 따라 두 개만 둔다.
|
||||||
|
|
||||||
|
| 테이블 | 키 | 역할 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SG_AI_QA_QUESTION` | `QUESTION_ID` | 고객 Excel 질문, 출처, 기대 포인트, 원본 샘플 SQL, 기준 SQL/답변, SQL 판정 규칙을 보관한다. 자유 질의도 해시 기준으로 이 테이블에 한 번만 등록한다. |
|
||||||
|
| `SG_AI_QA_ANSWER` | `ANSWER_SEQ` | 질문별 실행 이력이다. 과거 47건도 `HISTORICAL`로 적재하고, 포털 실행은 `LIVE`로 계속 추가한다. |
|
||||||
|
|
||||||
|
`SG_AI_QA_ANSWER.QUESTION_ID`는 질문 마스터를 참조한다. 실행 결과는 JSON, 생성 SQL·답변·판정 근거는 CLOB으로 저장한다. 따라서 질문 기준은 바뀌어도 이미 실행된 이력의 원문과 당시 판정을 보존한다.
|
||||||
|
|
||||||
|
## 판정 규칙
|
||||||
|
|
||||||
|
1. 기준 시나리오는 `required_sql_terms`와 `recommended_sql_terms`를 사용한다.
|
||||||
|
2. 필수 테이블·컬럼·집계·기간 규칙이 빠지거나 모델 오류 문구가 SQL에 섞이면 `FAIL`이다.
|
||||||
|
3. 권장 필터가 빠졌거나 지원 범위가 일부인 경우 `WARN`이다.
|
||||||
|
4. 미지원 게임 질문은 별칭 조회를 거치지 않고 임의 게임 ID나 테이블을 만들어 내면 `FAIL`이다. 안전하게 거절하거나 별칭 조회 결과가 0건이면 `PASS`이다.
|
||||||
|
5. 월간 NRU/AU, 재화 보유/사용 등 기존 질답서의 개별 보정 규칙은 같은 판정기에 반영한다.
|
||||||
|
6. 자유 질의는 기준 질문을 선택하지 않은 경우 `REVIEW`로 저장한다. 실행 성공을 정답으로 표시하지 않는다.
|
||||||
|
|
||||||
|
문장 표현의 유사도만으로 정답을 판정하지 않는다. 집계값, 생성 SQL, 실행 결과가 근거가 되므로 고객에게 왜 통과 또는 실패인지 보여줄 수 있다.
|
||||||
|
|
||||||
|
## 화면 흐름
|
||||||
|
|
||||||
|
1. `검증 시나리오` 탭에서 47개 후보를 표 형태로 표시한다. 케이스, 구분, 제목, 질문, 기대 포인트, 최근 판정, 최근 실행 시각을 보여 준다.
|
||||||
|
2. 행을 선택하면 질문 입력란이 채워지고, 우측 또는 하단에 기준 답변·기준 SQL·원본 Excel 출처를 표시한다.
|
||||||
|
3. 사용자는 선택된 기준 질문을 그대로 실행하거나 자유 텍스트를 작성한다.
|
||||||
|
4. 실행 뒤에는 현재 답변, 생성 SQL, 조회 행, 판정, 판정 근거를 표시하고 `SG_AI_QA_ANSWER`에 저장한다.
|
||||||
|
5. 같은 질문의 과거 답변은 최신 순 표로 보여 주며, 과거 기준 검증과 현재 실행을 구분한다.
|
||||||
|
|
||||||
|
## 적재 기준
|
||||||
|
|
||||||
|
- 기준 원본은 `sgmp-select-ai-full-qa-term-dict-final-v2-20260721.md`와 동시 생성된 JSON이다.
|
||||||
|
- JSON의 `STD-05` 실행 출력은 비정상적으로 크므로, 이력 조회 안정성을 위해 저장 시 안전한 길이로 절단하고 원본 보고서 경로를 질문에 남긴다.
|
||||||
|
- 과거 레코드는 `HISTORICAL`, 포털에서 수행하는 새 레코드는 `LIVE`로 구분한다.
|
||||||
|
|
||||||
|
## 완료 기준
|
||||||
|
|
||||||
|
- ADB에 질문 마스터 47건과 과거 답변 이력 47건이 있다.
|
||||||
|
- 답변 이력 키는 증가하는 `ANSWER_SEQ`이며 질문 외래키가 유효하다.
|
||||||
|
- 후보 선택, 자유 질의, 기준 답변/SQL, 과거 이력, PASS/WARN/FAIL/REVIEW 표기가 한 화면에서 작동한다.
|
||||||
|
- 생성 SQL의 핵심 규칙을 바꾼 실패 케이스가 `FAIL`로 판정되는 단위 테스트가 있다.
|
||||||
|
- 실제 포털 실행 한 건이 ADB 이력에 저장되는 것을 확인한다.
|
||||||
|
|
||||||
|
## 비범위
|
||||||
|
|
||||||
|
- 이 기능은 Select AI의 정답을 하드코딩해 바꾸지 않는다.
|
||||||
|
- 과거 대화 SQLite 저장소를 이번 작업에서 전면 이전하지 않는다. 고객 질답 검증 이력만 ADB의 두 테이블에 저장한다.
|
||||||
|
- 자유 질의에 임의의 정답을 부여하지 않는다.
|
||||||
@@ -57,12 +57,13 @@ from src.agent_console.presentation import (
|
|||||||
render_login_brand,
|
render_login_brand,
|
||||||
)
|
)
|
||||||
from src.agent_console.profile import AppProfile, AppProfileError, load_app_profile
|
from src.agent_console.profile import AppProfile, AppProfileError, load_app_profile
|
||||||
from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
|
from src.poc4.qa_history import QaJudgment, QaQuestion, evaluate_sql, load_benchmark_questions
|
||||||
|
from src.poc4.qa_history_store import QaHistoryStore, QaHistoryStoreError
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
MCP_PROTOCOL_VERSION = "2025-11-25"
|
MCP_PROTOCOL_VERSION = "2025-11-25"
|
||||||
PREFERRED_TOOL = "ords.query.kb_select_ai_vpd"
|
PREFERRED_TOOL = "oracle.select_ai.smilegate_game_text2sql"
|
||||||
DEFAULT_QUESTION = ""
|
DEFAULT_QUESTION = ""
|
||||||
MAX_RESPONSE_BYTES = 1_000_000
|
MAX_RESPONSE_BYTES = 1_000_000
|
||||||
MAX_CONVERSATION_MESSAGES = 8
|
MAX_CONVERSATION_MESSAGES = 8
|
||||||
@@ -77,17 +78,21 @@ DEFAULT_QUERY_MODEL_PROFILE = "gpt54_mini_oci"
|
|||||||
ENV_FILE = ROOT / ".env"
|
ENV_FILE = ROOT / ".env"
|
||||||
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
|
MCP_SERVERS_FILE = ROOT / "config" / "mcp_servers.json"
|
||||||
VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json"
|
VPD_TOKEN_PRESETS_FILE = ROOT / "config" / "vpd_token_presets.json"
|
||||||
DEMO_SCENARIOS_FILE = ROOT / "config" / "hmm_demo_scenarios.json"
|
QA_BENCHMARK_FILE = ROOT / "config" / "smilegate_qa_benchmark.json"
|
||||||
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
|
APP_PROFILE_FILE = ROOT / "config" / "app_profile.json"
|
||||||
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
|
CHAT_DB_FILE = ROOT / "data" / "poc4_mcp_chat.sqlite3"
|
||||||
DEFAULT_VPD_USER_ID = "FC00789"
|
DEFAULT_VPD_USER_ID = "1001"
|
||||||
VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/"
|
VPD_OPERATIONS_URL = "https://smilegate-backoffice.cloud-handson.com/"
|
||||||
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
|
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
|
||||||
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
|
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
|
||||||
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
|
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
|
||||||
PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember"
|
PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember"
|
||||||
PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
|
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(
|
AUDIT_DB_ENV_FILE = Path(
|
||||||
os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env")
|
os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env")
|
||||||
).expanduser()
|
).expanduser()
|
||||||
@@ -1885,12 +1890,180 @@ def new_conversation_id() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _question_label(question: object) -> str:
|
def _question_label(question: object) -> str:
|
||||||
question_id = str(getattr(question, "question_id"))
|
question_id = str(
|
||||||
category = str(getattr(question, "category"))
|
getattr(question, "question_code", getattr(question, "question_id", ""))
|
||||||
title = str(getattr(question, "title", getattr(question, "text")))
|
)
|
||||||
|
category = str(getattr(question, "category", ""))
|
||||||
|
title = str(
|
||||||
|
getattr(question, "title", getattr(question, "question_text", ""))
|
||||||
|
)
|
||||||
return f"{question_id} · {category} · {title}"
|
return f"{question_id} · {category} · {title}"
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_resource(show_spinner=False)
|
||||||
|
def _qa_history_store() -> QaHistoryStore:
|
||||||
|
return QaHistoryStore()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_qa_questions() -> tuple[list[QaQuestion], QaHistoryStore | None, str]:
|
||||||
|
try:
|
||||||
|
store = _qa_history_store()
|
||||||
|
questions = store.list_questions(limit=200)
|
||||||
|
if questions:
|
||||||
|
return questions, store, ""
|
||||||
|
return [], store, "질답 이력 DB에 아직 적재된 기준 질문이 없습니다."
|
||||||
|
except QaHistoryStoreError as exc:
|
||||||
|
try:
|
||||||
|
fallback = list(load_benchmark_questions(QA_BENCHMARK_FILE))
|
||||||
|
except Exception:
|
||||||
|
fallback = []
|
||||||
|
return fallback, None, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _judgment_label(status: str) -> str:
|
||||||
|
labels = {
|
||||||
|
"PASS": "통과",
|
||||||
|
"WARN": "검토",
|
||||||
|
"FAIL": "실패",
|
||||||
|
"REVIEW": "수동 검토",
|
||||||
|
}
|
||||||
|
return labels.get(str(status or "").upper(), "미실행")
|
||||||
|
|
||||||
|
|
||||||
|
def _qa_answer_preview(value: object, limit: int = 180) -> str:
|
||||||
|
text = str(value or "").strip().replace("\n", " ")
|
||||||
|
return text if len(text) <= limit else text[:limit] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def _render_qa_benchmark_panel(
|
||||||
|
questions: list[QaQuestion],
|
||||||
|
store: QaHistoryStore | None,
|
||||||
|
store_error: str,
|
||||||
|
) -> QaQuestion | None:
|
||||||
|
st.markdown(
|
||||||
|
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
||||||
|
"고객 질답 검증 시나리오"
|
||||||
|
"</div>",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
st.caption(
|
||||||
|
"고객 제공 Excel에서 정리한 47개 후보입니다. 후보를 선택하면 기준 답변과 과거 검증 이력을 확인하고 바로 실행할 수 있습니다."
|
||||||
|
)
|
||||||
|
if store_error:
|
||||||
|
st.warning(
|
||||||
|
"현재는 파일 기준 후보만 표시합니다. ADB 이력 저장은 연결 복구 후 사용할 수 있습니다. "
|
||||||
|
+ store_error
|
||||||
|
)
|
||||||
|
if not questions:
|
||||||
|
st.error("표시할 고객 질답 기준이 없습니다.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
category_options = tuple(sorted({question.category for question in questions}))
|
||||||
|
category = st.selectbox(
|
||||||
|
"시나리오 구분",
|
||||||
|
options=("ALL", *category_options),
|
||||||
|
format_func=lambda item: "전체 47개" if item == "ALL" else item,
|
||||||
|
key="poc4_qa_category_filter",
|
||||||
|
)
|
||||||
|
visible_questions = [
|
||||||
|
question for question in questions if category == "ALL" or question.category == category
|
||||||
|
]
|
||||||
|
st.dataframe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"케이스": question.question_code,
|
||||||
|
"구분": question.category,
|
||||||
|
"제목": question.title,
|
||||||
|
"질문": question.question_text,
|
||||||
|
"기대 기준": _qa_answer_preview(question.expected_focus, 150),
|
||||||
|
"최근 판정": _judgment_label(question.last_judgment_status),
|
||||||
|
"최근 실행": question.last_evaluated_at or "과거 기준",
|
||||||
|
}
|
||||||
|
for question in visible_questions
|
||||||
|
],
|
||||||
|
hide_index=True,
|
||||||
|
width="stretch",
|
||||||
|
height=min(520, 110 + 36 * len(visible_questions)),
|
||||||
|
column_config={
|
||||||
|
"질문": st.column_config.TextColumn(width="large"),
|
||||||
|
"기대 기준": st.column_config.TextColumn(width="large"),
|
||||||
|
"최근 실행": st.column_config.TextColumn(width="medium"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
by_code = {question.question_code: question for question in visible_questions}
|
||||||
|
selected_code = st.selectbox(
|
||||||
|
"실행할 검증 후보 선택",
|
||||||
|
options=("", *by_code),
|
||||||
|
format_func=lambda code: "선택 안 함 · 자유 질의" if not code else _question_label(by_code[code]),
|
||||||
|
key="poc4_qa_question_code",
|
||||||
|
)
|
||||||
|
selected = by_code.get(selected_code)
|
||||||
|
if selected is None:
|
||||||
|
st.info("자유 텍스트 질문도 실행하고 이력으로 남길 수 있습니다. 자유 질의는 정답 기준이 없어 수동 검토로 표시됩니다.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
with st.expander("선택한 질문의 정답 기준·원본·과거 답변", expanded=True):
|
||||||
|
left, right = st.columns(2)
|
||||||
|
with left:
|
||||||
|
st.markdown("**정답 기준**")
|
||||||
|
st.write(selected.expected_focus or "정답 기준 설명이 없습니다.")
|
||||||
|
st.markdown("**기준 답변**")
|
||||||
|
st.code(selected.baseline_answer or "기준 실행 결과가 없습니다.", language=None)
|
||||||
|
with right:
|
||||||
|
st.markdown("**고객 원본 출처**")
|
||||||
|
st.write(
|
||||||
|
f"{selected.source_document} / {selected.source_sheet} / 행 {selected.source_row or '-'}"
|
||||||
|
)
|
||||||
|
if selected.source_scenario:
|
||||||
|
st.caption(selected.source_scenario)
|
||||||
|
st.markdown("**기준 SQL**")
|
||||||
|
st.code(selected.baseline_sql or selected.sample_sql or "기준 SQL이 없습니다.", language="sql")
|
||||||
|
|
||||||
|
if store is not None and selected.question_id is not None:
|
||||||
|
try:
|
||||||
|
answers = store.list_answers(selected.question_id, limit=30)
|
||||||
|
except QaHistoryStoreError as exc:
|
||||||
|
st.warning(str(exc))
|
||||||
|
answers = []
|
||||||
|
if answers:
|
||||||
|
st.markdown("**과거 답변 이력**")
|
||||||
|
st.dataframe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"순번": item["answer_seq"],
|
||||||
|
"구분": "과거 기준" if item["answer_kind"] == "HISTORICAL" else "이번 실행",
|
||||||
|
"판정": _judgment_label(item["judgment_status"]),
|
||||||
|
"실행 상태": item["execution_status"],
|
||||||
|
"실행 시각": item["requested_at"],
|
||||||
|
"답변": _qa_answer_preview(item["answer_text"]),
|
||||||
|
}
|
||||||
|
for item in answers
|
||||||
|
],
|
||||||
|
hide_index=True,
|
||||||
|
width="stretch",
|
||||||
|
)
|
||||||
|
answer_by_seq = {str(item["answer_seq"]): item for item in answers}
|
||||||
|
detail_seq = st.selectbox(
|
||||||
|
"답변 이력 상세",
|
||||||
|
options=tuple(answer_by_seq),
|
||||||
|
format_func=lambda value: (
|
||||||
|
f"#{value} · {_judgment_label(answer_by_seq[value]['judgment_status'])} · "
|
||||||
|
f"{answer_by_seq[value]['requested_at']}"
|
||||||
|
),
|
||||||
|
key=f"poc4_qa_answer_detail_{selected.question_id}",
|
||||||
|
)
|
||||||
|
detail = answer_by_seq[detail_seq]
|
||||||
|
st.markdown("**판정 근거**")
|
||||||
|
st.write(detail["judgment_reason"] or "판정 근거가 없습니다.")
|
||||||
|
st.markdown("**생성 SQL**")
|
||||||
|
st.code(detail["generated_sql"] or "생성 SQL이 없습니다.", language="sql")
|
||||||
|
st.markdown("**실행 답변**")
|
||||||
|
st.write(detail["answer_text"] or "답변이 없습니다.")
|
||||||
|
else:
|
||||||
|
st.info("아직 저장된 답변 이력이 없습니다.")
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
def _apply_console_theme(profile: AppProfile) -> None:
|
def _apply_console_theme(profile: AppProfile) -> None:
|
||||||
apply_console_theme(st, profile)
|
apply_console_theme(st, profile)
|
||||||
|
|
||||||
@@ -2374,6 +2547,8 @@ def collect_security_evidence(
|
|||||||
) -> Mapping[str, Any]:
|
) -> Mapping[str, Any]:
|
||||||
"""Collect predefined, token-scoped security evidence without exposing secrets."""
|
"""Collect predefined, token-scoped security evidence without exposing secrets."""
|
||||||
|
|
||||||
|
if not REFERENCE_EVIDENCE_ENABLED:
|
||||||
|
return {}
|
||||||
kind = _security_evidence_kind(question)
|
kind = _security_evidence_kind(question)
|
||||||
if not kind or token_preset is None:
|
if not kind or token_preset is None:
|
||||||
return {}
|
return {}
|
||||||
@@ -2938,6 +3113,8 @@ def collect_business_evidence(
|
|||||||
) -> Mapping[str, Any]:
|
) -> Mapping[str, Any]:
|
||||||
"""Collect token-validated, explicitly scoped business and catalog evidence."""
|
"""Collect token-validated, explicitly scoped business and catalog evidence."""
|
||||||
|
|
||||||
|
if not REFERENCE_EVIDENCE_ENABLED:
|
||||||
|
return {}
|
||||||
kind = _business_evidence_kind(question)
|
kind = _business_evidence_kind(question)
|
||||||
if not kind or token_preset is None:
|
if not kind or token_preset is None:
|
||||||
return {}
|
return {}
|
||||||
@@ -3636,6 +3813,9 @@ def load_vpd_token_presets(
|
|||||||
if not isinstance(item, Mapping) or item.get("enabled", True) is not True:
|
if not isinstance(item, Mapping) or item.get("enabled", True) is not True:
|
||||||
continue
|
continue
|
||||||
token = _normalized_bearer(item.get("token"))
|
token = _normalized_bearer(item.get("token"))
|
||||||
|
if not token:
|
||||||
|
token_env = str(item.get("token_env") or "").strip()
|
||||||
|
token = _normalized_bearer(_runtime_env_value(token_env)) if token_env else ""
|
||||||
user_id = str(item.get("user_id") or "").strip()
|
user_id = str(item.get("user_id") or "").strip()
|
||||||
if not token or not user_id or user_id in seen:
|
if not token or not user_id or user_id in seen:
|
||||||
continue
|
continue
|
||||||
@@ -6323,6 +6503,89 @@ def _render_vpd_operations_tab() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_ai_execution_summary(payload: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
return {
|
||||||
|
"generated_sql": "",
|
||||||
|
"execution_status": "UNKNOWN",
|
||||||
|
"execution_succeeded": False,
|
||||||
|
"result": {},
|
||||||
|
}
|
||||||
|
generated_sql = str(
|
||||||
|
payload.get("generatedSql") or payload.get("generated_sql") or ""
|
||||||
|
).strip()
|
||||||
|
status = str(payload.get("status") or "").strip().upper()
|
||||||
|
execution = str(payload.get("execution") or "").strip().upper()
|
||||||
|
execution_succeeded = (
|
||||||
|
status == "SHOWSQL_AND_EXECUTED" and execution == "READ_ONLY_EXECUTED"
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
key: payload.get(key)
|
||||||
|
for key in (
|
||||||
|
"status",
|
||||||
|
"execution",
|
||||||
|
"rowCount",
|
||||||
|
"truncated",
|
||||||
|
"columns",
|
||||||
|
"items",
|
||||||
|
"generatedSql",
|
||||||
|
)
|
||||||
|
if key in payload
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"generated_sql": generated_sql,
|
||||||
|
"execution_status": status or execution or "UNKNOWN",
|
||||||
|
"execution_succeeded": execution_succeeded,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_qa_history(
|
||||||
|
*,
|
||||||
|
store: QaHistoryStore | None,
|
||||||
|
benchmark_question: QaQuestion | None,
|
||||||
|
question_text: str,
|
||||||
|
conversation_id: str,
|
||||||
|
requested_by: str,
|
||||||
|
model_profile: str,
|
||||||
|
answer_text: str,
|
||||||
|
mcp_result: Any,
|
||||||
|
duration_ms: int,
|
||||||
|
) -> tuple[QaQuestion | None, QaJudgment | None]:
|
||||||
|
if store is None:
|
||||||
|
return benchmark_question, None
|
||||||
|
try:
|
||||||
|
history_question = benchmark_question or store.find_or_create_free_text_question(
|
||||||
|
question_text
|
||||||
|
)
|
||||||
|
execution = _select_ai_execution_summary(mcp_result)
|
||||||
|
judgment = evaluate_sql(
|
||||||
|
benchmark_question,
|
||||||
|
execution["generated_sql"],
|
||||||
|
execution_succeeded=bool(execution["execution_succeeded"]),
|
||||||
|
error_text=_bounded_json(mcp_result, max_chars=8000),
|
||||||
|
)
|
||||||
|
store.record_answer(
|
||||||
|
question_id=int(history_question.question_id or 0),
|
||||||
|
answer_kind="LIVE",
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
requested_by=requested_by,
|
||||||
|
model_profile=model_profile,
|
||||||
|
generated_sql=execution["generated_sql"],
|
||||||
|
answer_text=answer_text,
|
||||||
|
result=execution["result"],
|
||||||
|
execution_output=_bounded_json(mcp_result, max_chars=24_000),
|
||||||
|
execution_status=str(execution["execution_status"]),
|
||||||
|
judgment_status=judgment.status,
|
||||||
|
judgment_reason=judgment.reason,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
return history_question, judgment
|
||||||
|
except QaHistoryStoreError as exc:
|
||||||
|
st.warning(f"질답 이력 저장에 실패했습니다: {exc}")
|
||||||
|
return benchmark_question, None
|
||||||
|
|
||||||
|
|
||||||
def _process_submitted_question(
|
def _process_submitted_question(
|
||||||
*,
|
*,
|
||||||
question: str,
|
question: str,
|
||||||
@@ -6338,6 +6601,8 @@ def _process_submitted_question(
|
|||||||
limit: int,
|
limit: int,
|
||||||
selected_token_preset: VpdTokenPreset | None,
|
selected_token_preset: VpdTokenPreset | None,
|
||||||
execution_mode_override: str,
|
execution_mode_override: str,
|
||||||
|
benchmark_question: QaQuestion | None,
|
||||||
|
qa_history_store: QaHistoryStore | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
normalized_question = question.strip()
|
normalized_question = question.strip()
|
||||||
if not normalized_question:
|
if not normalized_question:
|
||||||
@@ -6880,6 +7145,32 @@ def _process_submitted_question(
|
|||||||
],
|
],
|
||||||
"discovery_failures": discovery_failures,
|
"discovery_failures": discovery_failures,
|
||||||
}
|
}
|
||||||
|
history_question, history_judgment = _record_qa_history(
|
||||||
|
store=qa_history_store,
|
||||||
|
benchmark_question=benchmark_question,
|
||||||
|
question_text=normalized_question,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
requested_by=(
|
||||||
|
selected_token_preset.user_id
|
||||||
|
if selected_token_preset is not None
|
||||||
|
else "portal-user"
|
||||||
|
),
|
||||||
|
model_profile=answer_model_profile,
|
||||||
|
answer_text=str(assistant_message["content"]),
|
||||||
|
mcp_result=answer_source,
|
||||||
|
duration_ms=round((perf_counter() - process_started) * 1000),
|
||||||
|
)
|
||||||
|
if history_judgment is not None:
|
||||||
|
assistant_message["details"]["qa_history"] = {
|
||||||
|
"question_id": history_question.question_id if history_question else None,
|
||||||
|
"question_code": history_question.question_code if history_question else "",
|
||||||
|
"judgment_status": history_judgment.status,
|
||||||
|
"judgment_reason": history_judgment.reason,
|
||||||
|
}
|
||||||
|
st.info(
|
||||||
|
"질답 검증 판정: "
|
||||||
|
f"{_judgment_label(history_judgment.status)} · {history_judgment.reason}"
|
||||||
|
)
|
||||||
refresh_progress(98, "질의 결과를 저장하고 있습니다.")
|
refresh_progress(98, "질의 결과를 저장하고 있습니다.")
|
||||||
save_chat_turn(
|
save_chat_turn(
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
@@ -6934,27 +7225,13 @@ def main() -> None:
|
|||||||
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
|
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
|
||||||
_render_portal_login(profile)
|
_render_portal_login(profile)
|
||||||
return
|
return
|
||||||
try:
|
qa_questions, qa_history_store, qa_store_error = _load_qa_questions()
|
||||||
questions = load_demo_scenarios(DEMO_SCENARIOS_FILE)
|
|
||||||
except ScenarioConfigError as exc:
|
|
||||||
st.error(str(exc))
|
|
||||||
return
|
|
||||||
scenario_key = "poc4_mcp_discovery_scenario"
|
|
||||||
question_text_key = "poc4_mcp_discovery_question_text"
|
question_text_key = "poc4_mcp_discovery_question_text"
|
||||||
loaded_scenario_key = "poc4_mcp_discovery_loaded_scenario_id"
|
loaded_qa_question_key = "poc4_mcp_discovery_loaded_qa_question_code"
|
||||||
conversation_id_key = "poc4_mcp_discovery_conversation_id"
|
conversation_id_key = "poc4_mcp_discovery_conversation_id"
|
||||||
chat_page_key = "poc4_mcp_discovery_page"
|
chat_page_key = "poc4_mcp_discovery_page"
|
||||||
query_progress_notice_key = "poc4_query_progress_notice"
|
query_progress_notice_key = "poc4_query_progress_notice"
|
||||||
mcp_cache_generation_key = "poc4_mcp_tools_cache_generation"
|
mcp_cache_generation_key = "poc4_mcp_tools_cache_generation"
|
||||||
selected_scenario_state = st.session_state.get(scenario_key)
|
|
||||||
scenario_ids = {question.question_id for question in questions}
|
|
||||||
if str(getattr(selected_scenario_state, "question_id", "")).strip().upper() not in (
|
|
||||||
"",
|
|
||||||
*scenario_ids,
|
|
||||||
):
|
|
||||||
st.session_state.pop(scenario_key, None)
|
|
||||||
st.session_state.pop(loaded_scenario_key, None)
|
|
||||||
st.session_state[question_text_key] = DEFAULT_QUESTION
|
|
||||||
init_chat_store()
|
init_chat_store()
|
||||||
if conversation_id_key not in st.session_state:
|
if conversation_id_key not in st.session_state:
|
||||||
st.session_state[conversation_id_key] = new_conversation_id()
|
st.session_state[conversation_id_key] = new_conversation_id()
|
||||||
@@ -7069,8 +7346,8 @@ def main() -> None:
|
|||||||
else:
|
else:
|
||||||
selected_token_preset = None
|
selected_token_preset = None
|
||||||
if configured_mcp_bearer:
|
if configured_mcp_bearer:
|
||||||
# The HMM MCP gateway uses its own server-side credential. VPD user
|
# The Smilegate MCP gateway uses a server-side credential. UI-only
|
||||||
# tokens remain UI context only and must never be forwarded to it.
|
# context must never be forwarded as a second bearer token.
|
||||||
st.caption("MCP 인증: 서버 관리 토큰 적용")
|
st.caption("MCP 인증: 서버 관리 토큰 적용")
|
||||||
manual_bearer_token = ""
|
manual_bearer_token = ""
|
||||||
elif selected_token_preset is not None:
|
elif selected_token_preset is not None:
|
||||||
@@ -7243,7 +7520,7 @@ def main() -> None:
|
|||||||
except (OSError, UnicodeError, ValueError):
|
except (OSError, UnicodeError, ValueError):
|
||||||
st.warning("MCP 설정 JSON을 읽지 못했습니다.")
|
st.warning("MCP 설정 JSON을 읽지 못했습니다.")
|
||||||
st.caption(f"config: {MCP_SERVERS_FILE}")
|
st.caption(f"config: {MCP_SERVERS_FILE}")
|
||||||
st.caption(f"scenario config: {DEMO_SCENARIOS_FILE}")
|
st.caption(f"QA benchmark: {QA_BENCHMARK_FILE}")
|
||||||
st.caption(f"token presets: {VPD_TOKEN_PRESETS_FILE}")
|
st.caption(f"token presets: {VPD_TOKEN_PRESETS_FILE}")
|
||||||
st.caption(f"selected LLM model: {selected_query_model_profile}")
|
st.caption(f"selected LLM model: {selected_query_model_profile}")
|
||||||
st.caption(f"default model: {default_query_model_profile}")
|
st.caption(f"default model: {default_query_model_profile}")
|
||||||
@@ -7256,42 +7533,41 @@ def main() -> None:
|
|||||||
'<div id="kb-main-tabs-anchor" style="scroll-margin-top: 0.75rem;"></div>',
|
'<div id="kb-main-tabs-anchor" style="scroll-margin-top: 0.75rem;"></div>',
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
architecture_tab, scenario_tab, audit_tab, operations_tab = st.tabs(
|
architecture_tab, scenario_tab = st.tabs(["아키텍처", "시나리오"])
|
||||||
["아키텍처", "시나리오", "감사로그", "보안관리"]
|
|
||||||
)
|
|
||||||
with architecture_tab:
|
with architecture_tab:
|
||||||
_render_architecture_tab()
|
_render_architecture_tab()
|
||||||
|
|
||||||
with scenario_tab:
|
with scenario_tab:
|
||||||
st.markdown(
|
selected_qa_question = _render_qa_benchmark_panel(
|
||||||
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
qa_questions,
|
||||||
"질문 입력"
|
qa_history_store,
|
||||||
"</div>",
|
qa_store_error,
|
||||||
unsafe_allow_html=True,
|
|
||||||
)
|
|
||||||
selected_scenario = st.selectbox(
|
|
||||||
"업무 데모 질의 샘플",
|
|
||||||
options=(None, *questions),
|
|
||||||
format_func=lambda item: (
|
|
||||||
"선택 안 함 · 직접 질문" if item is None else _question_label(item)
|
|
||||||
),
|
|
||||||
key=scenario_key,
|
|
||||||
)
|
|
||||||
selected_scenario_id = (
|
|
||||||
None
|
|
||||||
if selected_scenario is None
|
|
||||||
else str(getattr(selected_scenario, "question_id"))
|
|
||||||
)
|
)
|
||||||
if question_text_key not in st.session_state:
|
if question_text_key not in st.session_state:
|
||||||
st.session_state[question_text_key] = DEFAULT_QUESTION
|
st.session_state[question_text_key] = DEFAULT_QUESTION
|
||||||
if st.session_state.get(loaded_scenario_key, "") != str(
|
selected_qa_code = (
|
||||||
selected_scenario_id or ""
|
selected_qa_question.question_code if selected_qa_question is not None else ""
|
||||||
):
|
)
|
||||||
if selected_scenario is not None:
|
if st.session_state.get(loaded_qa_question_key, "") != selected_qa_code:
|
||||||
st.session_state[question_text_key] = str(
|
st.session_state[question_text_key] = (
|
||||||
getattr(selected_scenario, "text")
|
selected_qa_question.question_text
|
||||||
)
|
if selected_qa_question is not None
|
||||||
st.session_state[loaded_scenario_key] = str(selected_scenario_id or "")
|
else DEFAULT_QUESTION
|
||||||
|
)
|
||||||
|
st.session_state[loaded_qa_question_key] = selected_qa_code
|
||||||
|
|
||||||
|
st.markdown(
|
||||||
|
'<div class="kb-section-title input" role="heading" aria-level="3">'
|
||||||
|
"질문 실행"
|
||||||
|
"</div>",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
if selected_qa_question is not None:
|
||||||
|
st.caption(
|
||||||
|
"선택한 후보 문장을 수정하지 않고 실행하면 자동 판정합니다. 문장을 수정하면 자유 질의로 저장되어 수동 검토 대상이 됩니다."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.caption("자유 질의는 실행 이력으로 남기며, 고객 기준 정답과 자동 비교하지 않습니다.")
|
||||||
|
|
||||||
with st.form("poc4_mcp_question_form"):
|
with st.form("poc4_mcp_question_form"):
|
||||||
question = st.text_area(
|
question = st.text_area(
|
||||||
@@ -7300,7 +7576,7 @@ def main() -> None:
|
|||||||
key=question_text_key,
|
key=question_text_key,
|
||||||
height=120,
|
height=120,
|
||||||
max_chars=1_000,
|
max_chars=1_000,
|
||||||
placeholder="업무 질문을 직접 입력하거나 위 질의 샘플을 선택하세요.",
|
placeholder="고객 검증 후보를 선택하거나 업무 질문을 직접 입력하세요.",
|
||||||
)
|
)
|
||||||
submitted = st.form_submit_button(
|
submitted = st.form_submit_button(
|
||||||
"질문 전송",
|
"질문 전송",
|
||||||
@@ -7359,12 +7635,6 @@ def main() -> None:
|
|||||||
height=0,
|
height=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
with audit_tab:
|
|
||||||
_render_fga_audit_tab()
|
|
||||||
|
|
||||||
with operations_tab:
|
|
||||||
_render_vpd_operations_tab()
|
|
||||||
|
|
||||||
if not submitted:
|
if not submitted:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -7383,6 +7653,13 @@ def main() -> None:
|
|||||||
limit=int(limit),
|
limit=int(limit),
|
||||||
selected_token_preset=selected_token_preset,
|
selected_token_preset=selected_token_preset,
|
||||||
execution_mode_override=execution_mode_override,
|
execution_mode_override=execution_mode_override,
|
||||||
|
benchmark_question=(
|
||||||
|
selected_qa_question
|
||||||
|
if selected_qa_question is not None
|
||||||
|
and question.strip() == selected_qa_question.question_text.strip()
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
qa_history_store=qa_history_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"product": {
|
"product": {
|
||||||
"name": "AI 업무 에이전트",
|
"name": "SMILEGATE DATA & AI POC",
|
||||||
"short_name": "AGENT",
|
"short_name": "SMILEGATE",
|
||||||
"page_title": "AI 업무 에이전트",
|
"page_title": "SMILEGATE DATA & AI POC",
|
||||||
"page_icon": "🤖",
|
"page_icon": "🤖",
|
||||||
"header_title": "AI 업무 에이전트",
|
"header_title": "스마일게이트 게임 데이터 AI 에이전트",
|
||||||
"header_description": "사용자 권한에 맞는 업무 질의와 보안 관리 기능을 제공합니다.",
|
"header_description": "게임 로그·서비스 데이터를 기반으로 AI 업무 효율화와 데이터 플랫폼 활용 방식을 검증합니다.",
|
||||||
"login_kicker": "DATA & AI DEMO",
|
"login_kicker": "SMILEGATE DATA & AI POC",
|
||||||
"login_title": "AI 업무 에이전트",
|
"login_title": "스마일게이트 게임 데이터 AI 에이전트",
|
||||||
"login_description": "사용자 인증 후 업무 질의와 보안 관리 기능을 이용할 수 있습니다.",
|
"login_description": "사용자 인증 후 게임 데이터 AI 질의와 보안 관리 기능을 이용할 수 있습니다.",
|
||||||
"login_footer": "인증된 DEMO 사용자만 접근할 수 있습니다."
|
"login_footer": "승인된 Data & AI PoC 사용자만 접근할 수 있습니다."
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"primary_color": "#003b70",
|
"primary_color": "#113F67",
|
||||||
"text_color": "#172b3a",
|
"text_color": "#15283B",
|
||||||
"muted_color": "#667785",
|
"muted_color": "#5D6C7C",
|
||||||
"border_color": "#dfe7ed"
|
"border_color": "#D7E0E8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
{
|
{
|
||||||
"default_server_id": "hmm_hr_mcp",
|
"default_server_id": "smilegate_game_data_mcp",
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
"id": "hmm_hr_mcp",
|
"id": "smilegate_game_data_mcp",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "hmm_compat_mcp",
|
"provider": "smilegate_select_ai_mcp",
|
||||||
"transport": "http",
|
"transport": "http",
|
||||||
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
|
"endpoint_url": "https://smilegate-backoffice.cloud-handson.com/mcp",
|
||||||
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
|
"auth_token_env": "SMILEGATE_MCP_BEARER_TOKEN",
|
||||||
"timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
|
"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",
|
"router_model_profile": "gpt54_mini_oci",
|
||||||
"tool_allowlist": [
|
"tool_allowlist": [
|
||||||
"search_hr_data",
|
"oracle.select_ai.smilegate_game_text2sql"
|
||||||
"resolve_hr_term",
|
|
||||||
"search_hr_policy"
|
|
||||||
],
|
],
|
||||||
"description": "HMM HR knowledge and data MCP server"
|
"description": "Smilegate game-data Text2SQL MCP server"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "최신 월 기준 게임별 신규 사용자 수를 보여줘"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1832
poc4_active_source_20260714/config/smilegate_qa_benchmark.json
Normal file
1832
poc4_active_source_20260714/config/smilegate_qa_benchmark.json
Normal file
File diff suppressed because one or more lines are too long
@@ -3,12 +3,22 @@
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"default": true,
|
"default": true,
|
||||||
"token": "vpd_live_REPLACE_WITH_USER_TOKEN",
|
"user_id": "1001",
|
||||||
"user_id": "FC00789",
|
"name": "Data & AI TF 팀장",
|
||||||
"name": "김설계",
|
"role": "DATA_AI_POC_ADMIN",
|
||||||
"role": "설계사",
|
"channel": "DATA_AI_TF",
|
||||||
"channel": "설계사",
|
"scope": "SGMP_POC 게임 데이터 전체",
|
||||||
"scope": "본인 담당 계약 고객"
|
"token_env": "SMILEGATE_TEAMLEAD_BEARER_TOKEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"default": false,
|
||||||
|
"user_id": "1002",
|
||||||
|
"name": "Data & AI TF 팀원",
|
||||||
|
"role": "DATA_AI_POC_ADMIN",
|
||||||
|
"channel": "DATA_AI_TF",
|
||||||
|
"scope": "SGMP_POC 게임 데이터 전체",
|
||||||
|
"token_env": "SMILEGATE_TEAMMEMBER_BEARER_TOKEN"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
openpyxl>=3.1,<4
|
openpyxl>=3.1,<4
|
||||||
|
oci>=2.180,<3
|
||||||
oracledb>=2,<4
|
oracledb>=2,<4
|
||||||
pandas>=2,<3
|
pandas>=2,<3
|
||||||
streamlit>=1.35,<2
|
streamlit>=1.35,<2
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create and seed the Smilegate customer QA benchmark history tables."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from src.poc4.qa_history_store import QaHistoryStore, ensure_schema
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--benchmark",
|
||||||
|
type=Path,
|
||||||
|
default=ROOT / "config" / "smilegate_qa_benchmark.json",
|
||||||
|
help="Customer Excel benchmark JSON generated from the approved QA report.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--env-file",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Optional environment file containing the QA DB connection settings.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.benchmark.is_file():
|
||||||
|
raise SystemExit(f"Benchmark file not found: {args.benchmark}")
|
||||||
|
store = QaHistoryStore(env_file=args.env_file)
|
||||||
|
ensure_schema(store)
|
||||||
|
question_count, historical_insert_count = store.seed_benchmark(args.benchmark)
|
||||||
|
print(
|
||||||
|
"qa_history_sync"
|
||||||
|
f" questions={question_count}"
|
||||||
|
f" historical_answers_inserted={historical_insert_count}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -33,8 +33,54 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
|
|||||||
input, textarea, [data-baseweb="select"] > div, [data-testid="stSidebar"] button {{
|
input, textarea, [data-baseweb="select"] > div, [data-testid="stSidebar"] button {{
|
||||||
background:#fff !important; border:1px solid var(--console-border) !important;
|
background:#fff !important; border:1px solid var(--console-border) !important;
|
||||||
border-radius:4px !important; box-shadow:none !important; }}
|
border-radius:4px !important; box-shadow:none !important; }}
|
||||||
|
/* Streamlit JSON uses an independently styled code surface. Keep the
|
||||||
|
MCP detail payload readable even when the browser/system prefers a
|
||||||
|
dark code theme. */
|
||||||
|
[data-testid="stJson"], [data-testid="stJson"] > div,
|
||||||
|
[data-testid="stJson"] .react-json-view, [data-testid="stJson"] pre {{
|
||||||
|
background:#f8fafc !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; color-scheme:light !important; }}
|
||||||
|
[data-testid="stJson"] *, [data-testid="stJson"] pre *,
|
||||||
|
[data-testid="stJson"] code {{
|
||||||
|
background:transparent !important; color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
/* Chat responses are rendered in a separate Streamlit surface. Without
|
||||||
|
these rules a dark browser theme can leave the answer card dark while
|
||||||
|
its Markdown keeps the light-theme text color. */
|
||||||
|
div[data-testid="stChatMessage"], div[data-testid="stChatMessageContent"] {{
|
||||||
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; color-scheme:light !important; }}
|
||||||
|
div[data-testid="stChatMessage"] [data-testid="stMarkdownContainer"],
|
||||||
|
div[data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] *,
|
||||||
|
div[data-testid="stChatMessage"] [data-testid="stCaptionContainer"],
|
||||||
|
div[data-testid="stChatMessage"] [data-testid="stCaptionContainer"] * {{
|
||||||
|
color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
/* Streamlit expanders use a dark summary bar in dark browser themes. */
|
||||||
|
details, details > summary {{
|
||||||
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; color-scheme:light !important; }}
|
||||||
|
details > summary *, details > summary::marker {{
|
||||||
|
color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
|
[data-testid="stExpander"] > details,
|
||||||
|
[data-testid="stExpander"] > details > summary,
|
||||||
|
[data-testid="stExpander"] > details > summary > div,
|
||||||
|
[data-testid="stExpander"] > details > summary > div > div {{
|
||||||
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
border-color:var(--console-border) !important; }}
|
||||||
|
[data-testid="stExpander"] > details > summary *,
|
||||||
|
[data-testid="stExpander"] > details > summary svg {{
|
||||||
|
color:var(--console-text) !important; fill:var(--console-text) !important;
|
||||||
|
stroke:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
|
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
|
||||||
border-radius:4px !important; box-shadow:none !important; }}
|
background:#fff !important; color:var(--console-text) !important;
|
||||||
|
-webkit-text-fill-color:var(--console-text) !important;
|
||||||
|
border:1px solid var(--console-border) !important;
|
||||||
|
border-radius:4px !important; box-shadow:none !important; color-scheme:light !important; }}
|
||||||
|
div[data-testid="stButton"] > button *, div[data-testid="stFormSubmitButton"] > button * {{
|
||||||
|
color:var(--console-text) !important; -webkit-text-fill-color:var(--console-text) !important; }}
|
||||||
div[data-testid="stButton"] > button[kind="primary"],
|
div[data-testid="stButton"] > button[kind="primary"],
|
||||||
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
|
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
|
||||||
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
|
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ ALLOWED_OCI_SETTINGS = frozenset(
|
|||||||
"OCI_PROFILE",
|
"OCI_PROFILE",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_COMPARTMENT_ID = re.compile(r"^ocid1\.compartment\.[A-Za-z0-9._-]+$")
|
# OCI permits the tenancy OCID when the root compartment is selected.
|
||||||
|
_COMPARTMENT_OR_ROOT_ID = re.compile(
|
||||||
|
r"^ocid1\.(?:compartment|tenancy)\.[A-Za-z0-9._-]+$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CompletionClient(Protocol):
|
class CompletionClient(Protocol):
|
||||||
@@ -95,7 +98,7 @@ def load_oci_settings() -> OCISettings:
|
|||||||
raise ValueError("unsupported OCI authentication mode")
|
raise ValueError("unsupported OCI authentication mode")
|
||||||
|
|
||||||
compartment_id = values.get("OCI_GENAI_COMPARTMENT_ID", "").strip()
|
compartment_id = values.get("OCI_GENAI_COMPARTMENT_ID", "").strip()
|
||||||
if not _COMPARTMENT_ID.fullmatch(compartment_id):
|
if not _COMPARTMENT_OR_ROOT_ID.fullmatch(compartment_id):
|
||||||
raise ValueError("OCI Generative AI compartment is not configured")
|
raise ValueError("OCI Generative AI compartment is not configured")
|
||||||
return OCISettings(
|
return OCISettings(
|
||||||
auth_type=auth_type,
|
auth_type=auth_type,
|
||||||
|
|||||||
289
poc4_active_source_20260714/src/poc4/qa_history.py
Normal file
289
poc4_active_source_20260714/src/poc4/qa_history.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"""Customer QA benchmark parsing and deterministic SQL evaluation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
class QaBenchmarkError(RuntimeError):
|
||||||
|
"""Raised when the QA benchmark source cannot be used safely."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QaQuestion:
|
||||||
|
question_id: int | None
|
||||||
|
question_code: str
|
||||||
|
category: str
|
||||||
|
title: str
|
||||||
|
question_text: str
|
||||||
|
source_document: str
|
||||||
|
source_sheet: str
|
||||||
|
source_row: int | None
|
||||||
|
source_scenario: str
|
||||||
|
sample_sql: str
|
||||||
|
expected_focus: str
|
||||||
|
baseline_sql: str
|
||||||
|
baseline_answer: str
|
||||||
|
support_level: str
|
||||||
|
evaluation_rule: Mapping[str, Any]
|
||||||
|
last_judgment_status: str = ""
|
||||||
|
last_evaluated_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QaJudgment:
|
||||||
|
status: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def question_fingerprint(question_text: str) -> str:
|
||||||
|
normalized = " ".join(str(question_text or "").split()).casefold()
|
||||||
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_text(value: object) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: object) -> tuple[str, ...]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return ()
|
||||||
|
return tuple(_compact_text(item) for item in value if _compact_text(item))
|
||||||
|
|
||||||
|
|
||||||
|
def load_benchmark_questions(path: Path) -> tuple[QaQuestion, ...]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, ValueError) as exc:
|
||||||
|
raise QaBenchmarkError(f"질답 기준 파일을 읽지 못했습니다: {path}") from exc
|
||||||
|
rows = payload.get("scenarios") if isinstance(payload, Mapping) else None
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
raise QaBenchmarkError("질답 기준 파일에 scenarios 배열이 필요합니다.")
|
||||||
|
|
||||||
|
questions: list[QaQuestion] = []
|
||||||
|
seen_codes: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, Mapping):
|
||||||
|
raise QaBenchmarkError("질답 기준의 각 시나리오는 객체여야 합니다.")
|
||||||
|
source = row.get("source") if isinstance(row.get("source"), Mapping) else {}
|
||||||
|
history = (
|
||||||
|
row.get("historical_answer")
|
||||||
|
if isinstance(row.get("historical_answer"), Mapping)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
code = _compact_text(row.get("case_id")).upper()
|
||||||
|
question_text = _compact_text(row.get("question"))
|
||||||
|
if not code or not question_text:
|
||||||
|
raise QaBenchmarkError("각 질답 기준에는 case_id와 question이 필요합니다.")
|
||||||
|
if code in seen_codes:
|
||||||
|
raise QaBenchmarkError(f"중복된 질답 case_id입니다: {code}")
|
||||||
|
evaluation_rule = row.get("evaluation_rule")
|
||||||
|
if not isinstance(evaluation_rule, Mapping):
|
||||||
|
evaluation_rule = {}
|
||||||
|
questions.append(
|
||||||
|
QaQuestion(
|
||||||
|
question_id=None,
|
||||||
|
question_code=code,
|
||||||
|
category=_compact_text(row.get("category")) or "GENERAL",
|
||||||
|
title=_compact_text(row.get("title")) or code,
|
||||||
|
question_text=question_text,
|
||||||
|
source_document=_compact_text(source.get("workbook")),
|
||||||
|
source_sheet=_compact_text(source.get("sheet")),
|
||||||
|
source_row=_number_or_none(source.get("excel_row")),
|
||||||
|
source_scenario=_compact_text(source.get("scenario")),
|
||||||
|
sample_sql=_compact_text(source.get("sample_query")),
|
||||||
|
expected_focus=_compact_text(row.get("expected_focus")),
|
||||||
|
baseline_sql=_compact_text(history.get("generated_sql")),
|
||||||
|
baseline_answer=_compact_text(history.get("answer_text")),
|
||||||
|
support_level=_compact_text(row.get("support_level")).upper() or "UNKNOWN",
|
||||||
|
evaluation_rule={
|
||||||
|
"required_sql_terms": list(
|
||||||
|
_string_list(evaluation_rule.get("required_sql_terms"))
|
||||||
|
),
|
||||||
|
"recommended_sql_terms": list(
|
||||||
|
_string_list(evaluation_rule.get("recommended_sql_terms"))
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen_codes.add(code)
|
||||||
|
return tuple(questions)
|
||||||
|
|
||||||
|
|
||||||
|
def question_from_record(record: Mapping[str, Any]) -> QaQuestion:
|
||||||
|
rule = record.get("evaluation_rule")
|
||||||
|
if isinstance(rule, str):
|
||||||
|
try:
|
||||||
|
rule = json.loads(rule)
|
||||||
|
except ValueError:
|
||||||
|
rule = {}
|
||||||
|
if not isinstance(rule, Mapping):
|
||||||
|
rule = {}
|
||||||
|
return QaQuestion(
|
||||||
|
question_id=_number_or_none(record.get("question_id")),
|
||||||
|
question_code=_compact_text(record.get("question_code")),
|
||||||
|
category=_compact_text(record.get("category")) or "GENERAL",
|
||||||
|
title=_compact_text(record.get("title")) or _compact_text(record.get("question_code")),
|
||||||
|
question_text=_compact_text(record.get("question_text")),
|
||||||
|
source_document=_compact_text(record.get("source_document")),
|
||||||
|
source_sheet=_compact_text(record.get("source_sheet")),
|
||||||
|
source_row=_number_or_none(record.get("source_row")),
|
||||||
|
source_scenario=_compact_text(record.get("source_scenario")),
|
||||||
|
sample_sql=_compact_text(record.get("sample_sql")),
|
||||||
|
expected_focus=_compact_text(record.get("expected_focus")),
|
||||||
|
baseline_sql=_compact_text(record.get("baseline_sql")),
|
||||||
|
baseline_answer=_compact_text(record.get("baseline_answer")),
|
||||||
|
support_level=_compact_text(record.get("support_level")).upper() or "UNKNOWN",
|
||||||
|
evaluation_rule={
|
||||||
|
"required_sql_terms": list(
|
||||||
|
_string_list(rule.get("required_sql_terms"))
|
||||||
|
),
|
||||||
|
"recommended_sql_terms": list(
|
||||||
|
_string_list(rule.get("recommended_sql_terms"))
|
||||||
|
),
|
||||||
|
},
|
||||||
|
last_judgment_status=_compact_text(record.get("last_judgment_status")),
|
||||||
|
last_evaluated_at=_compact_text(record.get("last_evaluated_at")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _number_or_none(value: object) -> int | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _append_issue(issues: list[str], condition: bool, message: str) -> None:
|
||||||
|
if condition:
|
||||||
|
issues.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_sql(
|
||||||
|
question: QaQuestion | None,
|
||||||
|
generated_sql: str,
|
||||||
|
*,
|
||||||
|
execution_succeeded: bool,
|
||||||
|
error_text: str = "",
|
||||||
|
) -> QaJudgment:
|
||||||
|
"""Evaluate the generated SQL against the customer-approved benchmark rule."""
|
||||||
|
if question is None or not question.question_code:
|
||||||
|
return QaJudgment(
|
||||||
|
status="REVIEW",
|
||||||
|
reason="자유 질의입니다. 고객 기준 정답 시나리오와 연결되지 않아 수동 검토가 필요합니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
sql = _compact_text(generated_sql)
|
||||||
|
upper_sql = sql.upper()
|
||||||
|
lower_sql = sql.lower()
|
||||||
|
execution_status = "PASS" if execution_succeeded else "FAIL_EXECUTION"
|
||||||
|
issues: list[str] = []
|
||||||
|
failure_markers = ("could not be generated", "exception encountered", "invalid identifier", "ora-")
|
||||||
|
has_failure_text = any(marker in lower_sql for marker in failure_markers)
|
||||||
|
required = _string_list(question.evaluation_rule.get("required_sql_terms"))
|
||||||
|
recommended = _string_list(question.evaluation_rule.get("recommended_sql_terms"))
|
||||||
|
missing_required = [term for term in required if term.upper() not in upper_sql]
|
||||||
|
missing_recommended = [term for term in recommended if term.upper() not in upper_sql]
|
||||||
|
|
||||||
|
if not execution_succeeded:
|
||||||
|
issues.append(f"실행 상태가 {execution_status}입니다.")
|
||||||
|
if not sql:
|
||||||
|
issues.append("생성 SQL이 없습니다.")
|
||||||
|
if has_failure_text:
|
||||||
|
issues.append("생성 SQL에 오류 또는 생성 실패 문구가 포함되어 있습니다.")
|
||||||
|
if missing_required:
|
||||||
|
issues.append("필수 SQL 요소 누락: " + ", ".join(missing_required))
|
||||||
|
if missing_recommended:
|
||||||
|
issues.append("권장 SQL 요소 누락: " + ", ".join(missing_recommended))
|
||||||
|
_append_issue(
|
||||||
|
issues,
|
||||||
|
bool(re.search(r'_[A-Z0-9]*YN"\s*=\s*\'1\'', sql, flags=re.IGNORECASE)),
|
||||||
|
"*_YN 컬럼은 샘플 메타데이터의 Y/N 값으로 비교해야 합니다.",
|
||||||
|
)
|
||||||
|
_append_issue(
|
||||||
|
issues,
|
||||||
|
bool(re.search(r'_[A-Z0-9]*FLAG"\s*=\s*\'Y\'', sql, flags=re.IGNORECASE)),
|
||||||
|
"*_FLAG 컬럼은 샘플 메타데이터의 0/1 값으로 비교해야 합니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
support = question.support_level
|
||||||
|
if support == "UNSUPPORTED":
|
||||||
|
uses_alias_lookup = "COMN_GAME_ALIAS_BAS" in upper_sql
|
||||||
|
substitutes_sample_game = "STOVE_CHAOSZERO" in upper_sql
|
||||||
|
if not sql and any(marker in error_text.lower() for marker in failure_markers):
|
||||||
|
return QaJudgment("PASS", "미지원 게임 질문이 실행 가능한 SQL로 변환되지 않았습니다. 기대한 안전 차단입니다.")
|
||||||
|
if uses_alias_lookup and not substitutes_sample_game and execution_succeeded:
|
||||||
|
return QaJudgment("PASS", "미지원 게임을 별칭 테이블로만 확인했고 샘플 게임 ID를 임의 대입하지 않았습니다.")
|
||||||
|
return QaJudgment("FAIL", "미지원 게임이 안전한 별칭 조회로 제한되지 않았거나 실행에 실패했습니다.")
|
||||||
|
|
||||||
|
if not execution_succeeded or not sql or has_failure_text or missing_required:
|
||||||
|
return QaJudgment("FAIL", "\n".join(issues) or "필수 SQL 또는 실행 검증에 실패했습니다.")
|
||||||
|
|
||||||
|
_apply_case_specific_rules(question.question_code, sql, upper_sql, issues)
|
||||||
|
if any(issue.startswith("필수") or issue.startswith("월간") or issue.startswith("일별") or issue.startswith("주간") or issue.startswith("CZN-") for issue in issues):
|
||||||
|
return QaJudgment("FAIL", "\n".join(issues))
|
||||||
|
if support == "PARTIAL":
|
||||||
|
issues.append("지원 범위가 일부인 질문이므로 결과 범위를 함께 검토해야 합니다.")
|
||||||
|
if issues:
|
||||||
|
return QaJudgment("WARN", "\n".join(issues))
|
||||||
|
return QaJudgment("PASS", "필수 테이블·컬럼·집계 조건과 실행 결과를 확인했습니다.")
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_case_specific_rules(code: str, sql: str, upper_sql: str, issues: list[str]) -> None:
|
||||||
|
if code == "STD-26":
|
||||||
|
_append_issue(issues, "TRUNC(DATE" in upper_sql, "월간 NRU는 DATE 리터럴이 아니라 BASE_DT 기준 월 범위를 사용해야 합니다.")
|
||||||
|
_append_issue(issues, "MAX" not in upper_sql or "BASE_DT" not in upper_sql, "월간 NRU는 최신 MAX(BASE_DT) 스냅샷을 기준으로 해야 합니다.")
|
||||||
|
_append_issue(issues, "RAW_NRU_DT" in upper_sql and "TRUNC(" not in upper_sql, "월간 NRU의 RAW_NRU_DT 기간은 BASE_DT 기준으로 계산해야 합니다.")
|
||||||
|
elif code == "STD-27":
|
||||||
|
_append_issue(issues, "AU_FLAG" in upper_sql, "월간 AU는 AU_FLAG가 아니라 LAST_CONN_DT 전월 조건으로 계산해야 합니다.")
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("MAX", "BASE_DT", "ADD_MONTHS")), "월간 AU는 최신 BASE_DT의 전월을 기준으로 해야 합니다.")
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("STD_USER_YN", "EXPT_USER_YN", "LAST_CONN_DT")), "월간 AU에는 STD_USER_YN, EXPT_USER_YN, LAST_CONN_DT 조건이 필요합니다.")
|
||||||
|
elif code == "STD-22":
|
||||||
|
_append_issue(issues, "NRU_FLAG" not in upper_sql, "일별 NRU는 NRU_FLAG=1을 사용해야 합니다.")
|
||||||
|
elif code == "STD-25":
|
||||||
|
_append_issue(issues, "LAST_CONN_DT" not in upper_sql, "최근 7일 AU는 LAST_CONN_DT 기간 조건을 사용해야 합니다.")
|
||||||
|
_append_issue(issues, "AU_FLAG" in upper_sql, "최근 7일 AU는 AU_FLAG로 제한하면 안 됩니다.")
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("STD_USER_YN", "EXPT_USER_YN")), "최근 7일 AU에는 STD_USER_YN과 EXPT_USER_YN 조건이 필요합니다.")
|
||||||
|
_append_issue(issues, "GROUP BY" in upper_sql, "최근 7일 AU는 일자별 목록이 아니라 단일 집계여야 합니다.")
|
||||||
|
elif code == "STD-28":
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("STD_USER_YN", "EXPT_USER_YN")), "전체 유저 수에는 STD_USER_YN과 EXPT_USER_YN 조건이 필요합니다.")
|
||||||
|
_append_issue(issues, "RU_FLAG" in upper_sql, "이 기준의 전체 유저 수는 RU_FLAG가 아니라 STD_USER_YN으로 계산해야 합니다.")
|
||||||
|
elif code in {"CZN-07", "CZN-08", "CZN-13", "CZN-17"}:
|
||||||
|
_apply_goods_rules(code, sql, upper_sql, issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_goods_rules(code: str, sql: str, upper_sql: str, issues: list[str]) -> None:
|
||||||
|
dimension_name_terms = ("DIM_KR_NM", "DIM_EN_NM", "DIM_CTG", "CD_DESC", "CD_DTL_DESC")
|
||||||
|
has_dimension_name = any(term in upper_sql for term in dimension_name_terms)
|
||||||
|
if code in {"CZN-07", "CZN-08", "CZN-17"}:
|
||||||
|
_append_issue(issues, "CZN_COMN_SVC_DIM_BAS" not in upper_sql or not has_dimension_name, f"{code}은 CZN_COMN_SVC_DIM_BAS의 이름/분류 컬럼으로 재화명을 해석해야 합니다.")
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("DIM_TYPE_DIV_CD", "DIM_CTG")) or "크리스탈" not in sql, f"{code}은 GOODS_AGG와 크리스탈 분류 조건을 사용해야 합니다.")
|
||||||
|
_append_issue(issues, "HAVE_CNT" in upper_sql and "<>" not in upper_sql and "!=" not in upper_sql, f"{code}은 HAVE_CNT <> 0으로 0 보유량을 제외해야 합니다.")
|
||||||
|
if code == "CZN-07":
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("CZN_COMN_USER_MST", "EXPT_USER_YN")), "CZN-07은 유저 마스터를 조인하고 제외 유저를 필터링해야 합니다.")
|
||||||
|
_append_issue(issues, "GROUP BY" not in upper_sql or "BASE_DT" not in upper_sql, "CZN-07은 기간별 일자 집계를 위해 BASE_DT GROUP BY가 필요합니다.")
|
||||||
|
elif code == "CZN-08":
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("AU_FLAG", "EXPT_USER_YN")), "CZN-08은 표준 AU와 제외 유저 조건이 필요합니다.")
|
||||||
|
_append_issue(issues, "STD_USER_YN" in upper_sql, "CZN-08 기준에는 STD_USER_YN을 추가하면 모집단이 과도하게 좁아집니다.")
|
||||||
|
_append_issue(issues, "AVG(" in upper_sql, "CZN-08 1인당 평균은 AVG(HAVE_CNT)가 아니라 합계/고유 유저 수여야 합니다.")
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("SUM", "COUNT", "DISTINCT", "/")), "CZN-08 평균은 SUM(HAVE_CNT)/COUNT(DISTINCT GUID)로 계산해야 합니다.")
|
||||||
|
elif code == "CZN-13":
|
||||||
|
_append_issue(issues, "CZN_CUSTOM_GOODS_HAVE_TXN" in upper_sql, "CZN-13은 보유 스냅샷이 아니라 재화 변동 테이블을 사용해야 합니다.")
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("CZN_CUSTOM_GOODS_CHANGE_TXN", "GOODS_CHANGE_CNT")), "CZN-13은 GOODS_CHANGE_CNT를 사용해야 합니다.")
|
||||||
|
_append_issue(issues, "CZN_COMN_SVC_DIM_BAS" not in upper_sql or not has_dimension_name, "CZN-13은 차원 테이블로 에테르를 해석해야 합니다.")
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("DIM_TYPE_DIV_CD", "DIM_CTG", "CHANGE_TYPE_CD", "'USE'")) or "에테르" not in sql, "CZN-13은 에테르 사용 분류와 CHANGE_TYPE_CD='USE' 조건이 필요합니다.")
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("CZN_COMN_USER_MST", "EXPT_USER_YN", "COUNT", "DISTINCT", "GUID", "SUM")), "CZN-13은 대상 유저 조인, 고유 사용 유저 수, 사용량 합계가 필요합니다.")
|
||||||
|
elif code == "CZN-17":
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("CZN_CUSTOM_GOODS_HAVE_TXN", "HAVE_CNT")), "CZN-17은 재화 보유 스냅샷을 사용해야 합니다.")
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("CZN_COMN_CHARACTER_MST", "AU_FLAG", "EXPT_USER_YN")), "CZN-17은 캐릭터 AU와 제외 유저 조건이 필요합니다.")
|
||||||
|
_append_issue(issues, "AVG(" in upper_sql, "CZN-17 평균은 AVG(HAVE_CNT)가 아니라 합계/고유 캐릭터 수여야 합니다.")
|
||||||
|
_append_issue(issues, any(term not in upper_sql for term in ("SUM", "COUNT", "DISTINCT", "/", "CUID")), "CZN-17 평균은 SUM(HAVE_CNT)/COUNT(DISTINCT CUID)로 계산해야 합니다.")
|
||||||
|
_append_issue(issues, "GROUP BY" in upper_sql, "CZN-17은 기준일 단일 집계여야 하므로 상세 GROUP BY를 사용하면 안 됩니다.")
|
||||||
|
_append_issue(issues, any(term in upper_sql for term in ("LEVEL_COL", "ACM_CONN_DCNT", "TDAY_PLAY_TIME")), "CZN-17에는 레벨·접속일·플레이타임 지표가 포함되면 안 됩니다.")
|
||||||
566
poc4_active_source_20260714/src/poc4/qa_history_store.py
Normal file
566
poc4_active_source_20260714/src/poc4/qa_history_store.py
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
"""Oracle ADB persistence for the Smilegate customer QA benchmark."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator, Mapping
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
import oracledb
|
||||||
|
|
||||||
|
from src.poc4.qa_history import QaQuestion, load_benchmark_questions, question_fingerprint, question_from_record
|
||||||
|
|
||||||
|
|
||||||
|
class QaHistoryStoreError(RuntimeError):
|
||||||
|
"""A safe user-facing persistence error."""
|
||||||
|
|
||||||
|
|
||||||
|
QUESTION_TABLE = "SG_AI_QA_QUESTION"
|
||||||
|
ANSWER_TABLE = "SG_AI_QA_ANSWER"
|
||||||
|
HISTORICAL_RUN_KEY = "HISTORICAL:2026-07-21:term-dict-final-v2"
|
||||||
|
|
||||||
|
|
||||||
|
def _env_value(name: str, env_file: Path | None = None) -> str:
|
||||||
|
value = os.environ.get(name, "").strip()
|
||||||
|
if value or env_file is None or not env_file.is_file():
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
lines = env_file.read_text(encoding="utf-8").splitlines()
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
return ""
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
if line.startswith("export "):
|
||||||
|
line = line[7:].lstrip()
|
||||||
|
key, raw = line.split("=", 1)
|
||||||
|
if key.strip() != name:
|
||||||
|
continue
|
||||||
|
raw = raw.strip()
|
||||||
|
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {"'", '"'}:
|
||||||
|
raw = raw[1:-1]
|
||||||
|
return raw.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_oracle_dsn(raw_dsn: str) -> tuple[str, str]:
|
||||||
|
value = str(raw_dsn or "").strip()
|
||||||
|
if value.startswith("jdbc:oracle:thin:@"):
|
||||||
|
value = value[len("jdbc:oracle:thin:@"):]
|
||||||
|
if "?" not in value:
|
||||||
|
return value, ""
|
||||||
|
dsn, query = value.split("?", 1)
|
||||||
|
parsed = parse_qs(query, keep_blank_values=False)
|
||||||
|
wallet_dir = (parsed.get("TNS_ADMIN") or parsed.get("tns_admin") or [""])[0]
|
||||||
|
return dsn.strip(), wallet_dir.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_lob(value: Any) -> Any:
|
||||||
|
return value.read() if hasattr(value, "read") else value
|
||||||
|
|
||||||
|
|
||||||
|
def _record_from_cursor(cursor: Any, row: Any) -> dict[str, Any]:
|
||||||
|
names = [column[0].lower() for column in cursor.description]
|
||||||
|
return {name: _read_lob(value) for name, value in zip(names, row)}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_json(value: Mapping[str, Any] | None) -> str:
|
||||||
|
payload = dict(value or {})
|
||||||
|
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||||
|
if len(text) <= 120_000:
|
||||||
|
return text
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"truncated": True,
|
||||||
|
"preview": text[:119_800],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _answer_record(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
result_json = str(row.get("result_json") or "").strip()
|
||||||
|
try:
|
||||||
|
result = json.loads(result_json) if result_json else {}
|
||||||
|
except ValueError:
|
||||||
|
result = {"raw": result_json}
|
||||||
|
return {
|
||||||
|
"answer_seq": row.get("answer_seq"),
|
||||||
|
"question_id": row.get("question_id"),
|
||||||
|
"answer_kind": str(row.get("answer_kind") or ""),
|
||||||
|
"run_key": str(row.get("run_key") or ""),
|
||||||
|
"conversation_id": str(row.get("conversation_id") or ""),
|
||||||
|
"requested_by": str(row.get("requested_by") or ""),
|
||||||
|
"requested_at": str(row.get("requested_at") or ""),
|
||||||
|
"model_profile": str(row.get("model_profile") or ""),
|
||||||
|
"generated_sql": str(row.get("generated_sql") or ""),
|
||||||
|
"answer_text": str(row.get("answer_text") or ""),
|
||||||
|
"result": result,
|
||||||
|
"execution_output": str(row.get("execution_output") or ""),
|
||||||
|
"execution_status": str(row.get("execution_status") or ""),
|
||||||
|
"judgment_status": str(row.get("judgment_status") or ""),
|
||||||
|
"judgment_reason": str(row.get("judgment_reason") or ""),
|
||||||
|
"duration_ms": row.get("duration_ms"),
|
||||||
|
"created_at": str(row.get("created_at") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class QaHistoryStore:
|
||||||
|
def __init__(self, *, env_file: Path | None = None) -> None:
|
||||||
|
self._env_file = env_file
|
||||||
|
self._pool: Any | None = None
|
||||||
|
|
||||||
|
def _config(self) -> dict[str, str]:
|
||||||
|
username = (
|
||||||
|
_env_value("POC4_QA_DB_USERNAME", self._env_file)
|
||||||
|
or _env_value("BACKOFFICE_SELECT_AI_DB_USERNAME", self._env_file)
|
||||||
|
)
|
||||||
|
password = (
|
||||||
|
_env_value("POC4_QA_DB_PASSWORD", self._env_file)
|
||||||
|
or _env_value("BACKOFFICE_SELECT_AI_DB_PASSWORD", self._env_file)
|
||||||
|
)
|
||||||
|
raw_dsn = (
|
||||||
|
_env_value("POC4_QA_DB_DSN", self._env_file)
|
||||||
|
or _env_value("BACKOFFICE_SELECT_AI_DB_URL", self._env_file)
|
||||||
|
)
|
||||||
|
dsn, wallet_from_dsn = _normalize_oracle_dsn(raw_dsn)
|
||||||
|
wallet_dir = (
|
||||||
|
_env_value("POC4_QA_DB_WALLET_DIR", self._env_file)
|
||||||
|
or wallet_from_dsn
|
||||||
|
or _env_value("ORACLE_WALLET_DIR", self._env_file)
|
||||||
|
)
|
||||||
|
if not username or not password or not dsn:
|
||||||
|
raise QaHistoryStoreError("질답 이력 DB 접속 설정을 확인해 주세요.")
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"dsn": dsn,
|
||||||
|
"wallet_dir": wallet_dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_pool(self) -> Any:
|
||||||
|
if self._pool is not None:
|
||||||
|
return self._pool
|
||||||
|
config = self._config()
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"user": config["username"],
|
||||||
|
"password": config["password"],
|
||||||
|
"dsn": config["dsn"],
|
||||||
|
"min": 1,
|
||||||
|
"max": 3,
|
||||||
|
"increment": 1,
|
||||||
|
"getmode": oracledb.POOL_GETMODE_WAIT,
|
||||||
|
}
|
||||||
|
wallet_dir = Path(config["wallet_dir"]).expanduser()
|
||||||
|
if config["wallet_dir"]:
|
||||||
|
if not wallet_dir.is_dir():
|
||||||
|
raise QaHistoryStoreError("질답 이력 DB Wallet 경로를 확인해 주세요.")
|
||||||
|
kwargs["config_dir"] = str(wallet_dir)
|
||||||
|
kwargs["wallet_location"] = str(wallet_dir)
|
||||||
|
try:
|
||||||
|
self._pool = oracledb.create_pool(**kwargs)
|
||||||
|
return self._pool
|
||||||
|
except (oracledb.Error, OSError, ValueError) as exc:
|
||||||
|
raise QaHistoryStoreError("질답 이력 DB에 연결하지 못했습니다.") from exc
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connection(self) -> Iterator[Any]:
|
||||||
|
try:
|
||||||
|
with self._get_pool().acquire() as connection:
|
||||||
|
yield connection
|
||||||
|
except QaHistoryStoreError:
|
||||||
|
raise
|
||||||
|
except (oracledb.Error, OSError, ValueError) as exc:
|
||||||
|
raise QaHistoryStoreError("질답 이력 DB 작업에 실패했습니다.") from exc
|
||||||
|
|
||||||
|
def list_questions(self, *, limit: int = 200) -> list[QaQuestion]:
|
||||||
|
sql = f"""
|
||||||
|
SELECT q.question_id, q.question_code, q.category, q.title,
|
||||||
|
q.question_text, q.source_document, q.source_sheet,
|
||||||
|
q.source_row, q.source_scenario, q.sample_sql,
|
||||||
|
q.expected_focus, q.baseline_sql, q.baseline_answer,
|
||||||
|
q.support_level, q.evaluation_rule_json,
|
||||||
|
latest.judgment_status AS last_judgment_status,
|
||||||
|
TO_CHAR(latest.evaluated_at AT TIME ZONE 'Asia/Seoul',
|
||||||
|
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS last_evaluated_at
|
||||||
|
FROM {QUESTION_TABLE} q
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT question_id, judgment_status, evaluated_at
|
||||||
|
FROM (
|
||||||
|
SELECT question_id, judgment_status, evaluated_at,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY question_id ORDER BY answer_seq DESC
|
||||||
|
) AS row_no
|
||||||
|
FROM {ANSWER_TABLE}
|
||||||
|
)
|
||||||
|
WHERE row_no = 1
|
||||||
|
) latest ON latest.question_id = q.question_id
|
||||||
|
WHERE q.active_yn = 'Y'
|
||||||
|
ORDER BY q.category, q.question_code
|
||||||
|
FETCH FIRST :row_limit ROWS ONLY
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, {"row_limit": int(limit)})
|
||||||
|
rows = [_record_from_cursor(cursor, row) for row in cursor]
|
||||||
|
return [question_from_record(row) for row in rows]
|
||||||
|
|
||||||
|
def get_question(self, question_id: int) -> QaQuestion | None:
|
||||||
|
sql = f"""
|
||||||
|
SELECT question_id, question_code, category, title, question_text,
|
||||||
|
source_document, source_sheet, source_row, source_scenario,
|
||||||
|
sample_sql, expected_focus, baseline_sql, baseline_answer,
|
||||||
|
support_level, evaluation_rule_json
|
||||||
|
FROM {QUESTION_TABLE}
|
||||||
|
WHERE question_id = :question_id AND active_yn = 'Y'
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, {"question_id": int(question_id)})
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return question_from_record(_record_from_cursor(cursor, row)) if row else None
|
||||||
|
|
||||||
|
def list_answers(self, question_id: int, *, limit: int = 30) -> list[dict[str, Any]]:
|
||||||
|
sql = f"""
|
||||||
|
SELECT answer_seq, question_id, answer_kind, run_key, conversation_id,
|
||||||
|
requested_by,
|
||||||
|
TO_CHAR(requested_at AT TIME ZONE 'Asia/Seoul',
|
||||||
|
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS requested_at,
|
||||||
|
model_profile, generated_sql, answer_text, result_json,
|
||||||
|
execution_output, execution_status, judgment_status,
|
||||||
|
judgment_reason, duration_ms,
|
||||||
|
TO_CHAR(created_at AT TIME ZONE 'Asia/Seoul',
|
||||||
|
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS created_at
|
||||||
|
FROM {ANSWER_TABLE}
|
||||||
|
WHERE question_id = :question_id
|
||||||
|
ORDER BY answer_seq DESC
|
||||||
|
FETCH FIRST :row_limit ROWS ONLY
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, {"question_id": int(question_id), "row_limit": int(limit)})
|
||||||
|
rows = [_record_from_cursor(cursor, row) for row in cursor]
|
||||||
|
return [_answer_record(row) for row in rows]
|
||||||
|
|
||||||
|
def find_or_create_free_text_question(self, question_text: str) -> QaQuestion:
|
||||||
|
normalized = str(question_text or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise QaHistoryStoreError("자유 질의가 비어 있습니다.")
|
||||||
|
fingerprint = question_fingerprint(normalized)
|
||||||
|
code = f"ADHOC-{fingerprint[:12].upper()}"
|
||||||
|
merge_sql = f"""
|
||||||
|
MERGE INTO {QUESTION_TABLE} target
|
||||||
|
USING (SELECT :question_hash AS question_hash FROM dual) source
|
||||||
|
ON (target.question_hash = source.question_hash)
|
||||||
|
WHEN NOT MATCHED THEN INSERT (
|
||||||
|
question_code, question_source, question_hash, category, title,
|
||||||
|
question_text, support_level, evaluation_rule_json, active_yn
|
||||||
|
) VALUES (
|
||||||
|
:question_code, 'FREE_TEXT', :question_hash, 'FREE_TEXT',
|
||||||
|
:title, :question_text, 'REVIEW', '{{}}', 'Y'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
merge_sql,
|
||||||
|
{
|
||||||
|
"question_hash": fingerprint,
|
||||||
|
"question_code": code,
|
||||||
|
"title": normalized[:180],
|
||||||
|
"question_text": normalized,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
cursor.execute(
|
||||||
|
f"""SELECT question_id FROM {QUESTION_TABLE}
|
||||||
|
WHERE question_hash = :question_hash""",
|
||||||
|
{"question_hash": fingerprint},
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise QaHistoryStoreError("자유 질의 마스터를 저장하지 못했습니다.")
|
||||||
|
question = self.get_question(int(row[0]))
|
||||||
|
if question is None:
|
||||||
|
raise QaHistoryStoreError("자유 질의 마스터를 다시 읽지 못했습니다.")
|
||||||
|
return question
|
||||||
|
|
||||||
|
def record_answer(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
question_id: int,
|
||||||
|
answer_kind: str,
|
||||||
|
conversation_id: str,
|
||||||
|
requested_by: str,
|
||||||
|
model_profile: str,
|
||||||
|
generated_sql: str,
|
||||||
|
answer_text: str,
|
||||||
|
result: Mapping[str, Any] | None,
|
||||||
|
execution_output: str,
|
||||||
|
execution_status: str,
|
||||||
|
judgment_status: str,
|
||||||
|
judgment_reason: str,
|
||||||
|
duration_ms: int | None,
|
||||||
|
run_key: str = "",
|
||||||
|
) -> None:
|
||||||
|
sql = f"""
|
||||||
|
INSERT INTO {ANSWER_TABLE} (
|
||||||
|
question_id, answer_kind, run_key, conversation_id, requested_by,
|
||||||
|
requested_at, model_profile, generated_sql, answer_text, result_json,
|
||||||
|
execution_output, execution_status, judgment_status,
|
||||||
|
judgment_reason, duration_ms
|
||||||
|
) VALUES (
|
||||||
|
:question_id, :answer_kind, :run_key, :conversation_id,
|
||||||
|
:requested_by, SYSTIMESTAMP, :model_profile, :generated_sql,
|
||||||
|
:answer_text, :result_json, :execution_output, :execution_status,
|
||||||
|
:judgment_status, :judgment_reason, :duration_ms
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
sql,
|
||||||
|
{
|
||||||
|
"question_id": int(question_id),
|
||||||
|
"answer_kind": str(answer_kind)[:20],
|
||||||
|
"run_key": str(run_key)[:100] or None,
|
||||||
|
"conversation_id": str(conversation_id)[:100] or None,
|
||||||
|
"requested_by": str(requested_by)[:100] or None,
|
||||||
|
"model_profile": str(model_profile)[:100] or None,
|
||||||
|
"generated_sql": str(generated_sql or ""),
|
||||||
|
"answer_text": str(answer_text or ""),
|
||||||
|
"result_json": _to_json(result),
|
||||||
|
"execution_output": str(execution_output or ""),
|
||||||
|
"execution_status": str(execution_status)[:40] or None,
|
||||||
|
"judgment_status": str(judgment_status)[:20],
|
||||||
|
"judgment_reason": str(judgment_reason or ""),
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def seed_benchmark(self, benchmark_file: Path) -> tuple[int, int]:
|
||||||
|
questions = load_benchmark_questions(benchmark_file)
|
||||||
|
raw = json.loads(benchmark_file.read_text(encoding="utf-8"))
|
||||||
|
raw_by_code = {
|
||||||
|
str(item.get("case_id") or "").upper(): item
|
||||||
|
for item in raw.get("scenarios", [])
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
}
|
||||||
|
seeded_questions = 0
|
||||||
|
seeded_answers = 0
|
||||||
|
for question in questions:
|
||||||
|
question_id = self._upsert_benchmark_question(question)
|
||||||
|
seeded_questions += 1
|
||||||
|
raw_item = raw_by_code[question.question_code]
|
||||||
|
history = raw_item.get("historical_answer") if isinstance(raw_item.get("historical_answer"), Mapping) else {}
|
||||||
|
inserted = self._seed_historical_answer(question_id, history, raw)
|
||||||
|
seeded_answers += 1 if inserted else 0
|
||||||
|
return seeded_questions, seeded_answers
|
||||||
|
|
||||||
|
def _upsert_benchmark_question(self, question: QaQuestion) -> int:
|
||||||
|
sql = f"""
|
||||||
|
MERGE INTO {QUESTION_TABLE} target
|
||||||
|
USING (SELECT :question_code AS question_code FROM dual) source
|
||||||
|
ON (target.question_code = source.question_code)
|
||||||
|
WHEN MATCHED THEN UPDATE SET
|
||||||
|
question_source = 'CUSTOMER_EXCEL',
|
||||||
|
question_hash = :question_hash,
|
||||||
|
category = :category,
|
||||||
|
title = :title,
|
||||||
|
question_text = :question_text,
|
||||||
|
source_document = :source_document,
|
||||||
|
source_sheet = :source_sheet,
|
||||||
|
source_row = :source_row,
|
||||||
|
source_scenario = :source_scenario,
|
||||||
|
sample_sql = :sample_sql,
|
||||||
|
expected_focus = :expected_focus,
|
||||||
|
baseline_sql = :baseline_sql,
|
||||||
|
baseline_answer = :baseline_answer,
|
||||||
|
support_level = :support_level,
|
||||||
|
evaluation_rule_json = :evaluation_rule_json,
|
||||||
|
active_yn = 'Y',
|
||||||
|
updated_at = SYSTIMESTAMP
|
||||||
|
WHEN NOT MATCHED THEN INSERT (
|
||||||
|
question_code, question_source, question_hash, category, title,
|
||||||
|
question_text, source_document, source_sheet, source_row,
|
||||||
|
source_scenario, sample_sql, expected_focus, baseline_sql,
|
||||||
|
baseline_answer, support_level, evaluation_rule_json, active_yn
|
||||||
|
) VALUES (
|
||||||
|
:question_code, 'CUSTOMER_EXCEL', :question_hash, :category,
|
||||||
|
:title, :question_text, :source_document, :source_sheet,
|
||||||
|
:source_row, :source_scenario, :sample_sql, :expected_focus,
|
||||||
|
:baseline_sql, :baseline_answer, :support_level,
|
||||||
|
:evaluation_rule_json, 'Y'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
binds = {
|
||||||
|
"question_code": question.question_code,
|
||||||
|
"question_hash": question_fingerprint(question.question_text),
|
||||||
|
"category": question.category[:30],
|
||||||
|
"title": question.title[:200],
|
||||||
|
"question_text": question.question_text,
|
||||||
|
"source_document": question.source_document[:255] or None,
|
||||||
|
"source_sheet": question.source_sheet[:255] or None,
|
||||||
|
"source_row": question.source_row,
|
||||||
|
"source_scenario": question.source_scenario,
|
||||||
|
"sample_sql": question.sample_sql,
|
||||||
|
"expected_focus": question.expected_focus,
|
||||||
|
"baseline_sql": question.baseline_sql,
|
||||||
|
"baseline_answer": question.baseline_answer,
|
||||||
|
"support_level": question.support_level[:20],
|
||||||
|
"evaluation_rule_json": json.dumps(question.evaluation_rule, ensure_ascii=False),
|
||||||
|
}
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, binds)
|
||||||
|
connection.commit()
|
||||||
|
cursor.execute(
|
||||||
|
f"SELECT question_id FROM {QUESTION_TABLE} WHERE question_code = :question_code",
|
||||||
|
{"question_code": question.question_code},
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise QaHistoryStoreError(f"질문 마스터를 적재하지 못했습니다: {question.question_code}")
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
def _seed_historical_answer(
|
||||||
|
self,
|
||||||
|
question_id: int,
|
||||||
|
history: Mapping[str, Any],
|
||||||
|
benchmark: Mapping[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
exists_sql = f"""
|
||||||
|
SELECT COUNT(*) FROM {ANSWER_TABLE}
|
||||||
|
WHERE question_id = :question_id AND run_key = :run_key
|
||||||
|
"""
|
||||||
|
with self._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(exists_sql, {"question_id": question_id, "run_key": HISTORICAL_RUN_KEY})
|
||||||
|
if int(cursor.fetchone()[0]) > 0:
|
||||||
|
return False
|
||||||
|
result = {
|
||||||
|
"source_report": str(benchmark.get("source_report") or ""),
|
||||||
|
"source_redmine": benchmark.get("source_redmine"),
|
||||||
|
"historical_execution_output": str(history.get("execution_output") or ""),
|
||||||
|
}
|
||||||
|
self.record_answer(
|
||||||
|
question_id=question_id,
|
||||||
|
answer_kind="HISTORICAL",
|
||||||
|
run_key=HISTORICAL_RUN_KEY,
|
||||||
|
conversation_id="",
|
||||||
|
requested_by="customer-excel-baseline",
|
||||||
|
model_profile=str(history.get("profile") or ""),
|
||||||
|
generated_sql=str(history.get("generated_sql") or ""),
|
||||||
|
answer_text=str(history.get("answer_text") or ""),
|
||||||
|
result=result,
|
||||||
|
execution_output=str(history.get("execution_output") or ""),
|
||||||
|
execution_status=str(history.get("execution_status") or ""),
|
||||||
|
judgment_status=str(history.get("judgment_status") or "REVIEW"),
|
||||||
|
judgment_reason=str(history.get("judgment_reason") or ""),
|
||||||
|
duration_ms=int(history.get("duration_ms") or 0),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def schema_statements() -> tuple[str, ...]:
|
||||||
|
return (
|
||||||
|
f"""
|
||||||
|
CREATE TABLE {QUESTION_TABLE} (
|
||||||
|
question_id NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY,
|
||||||
|
question_code VARCHAR2(30) UNIQUE,
|
||||||
|
question_source VARCHAR2(30) NOT NULL,
|
||||||
|
question_hash VARCHAR2(64) NOT NULL UNIQUE,
|
||||||
|
category VARCHAR2(30) NOT NULL,
|
||||||
|
title VARCHAR2(200) NOT NULL,
|
||||||
|
question_text CLOB NOT NULL,
|
||||||
|
source_document VARCHAR2(255),
|
||||||
|
source_sheet VARCHAR2(255),
|
||||||
|
source_row NUMBER,
|
||||||
|
source_scenario CLOB,
|
||||||
|
sample_sql CLOB,
|
||||||
|
expected_focus CLOB,
|
||||||
|
baseline_sql CLOB,
|
||||||
|
baseline_answer CLOB,
|
||||||
|
support_level VARCHAR2(20) NOT NULL,
|
||||||
|
evaluation_rule_json CLOB CHECK (evaluation_rule_json IS JSON),
|
||||||
|
active_yn CHAR(1) DEFAULT 'Y' NOT NULL CHECK (active_yn IN ('Y', 'N')),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT sg_ai_qa_question_source_ck
|
||||||
|
CHECK (question_source IN ('CUSTOMER_EXCEL', 'FREE_TEXT'))
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE {ANSWER_TABLE} (
|
||||||
|
answer_seq NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
question_id NUMBER NOT NULL,
|
||||||
|
answer_kind VARCHAR2(20) NOT NULL,
|
||||||
|
run_key VARCHAR2(100),
|
||||||
|
conversation_id VARCHAR2(100),
|
||||||
|
requested_by VARCHAR2(100),
|
||||||
|
requested_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
model_profile VARCHAR2(100),
|
||||||
|
generated_sql CLOB,
|
||||||
|
answer_text CLOB,
|
||||||
|
result_json CLOB CHECK (result_json IS JSON),
|
||||||
|
execution_output CLOB,
|
||||||
|
execution_status VARCHAR2(40),
|
||||||
|
judgment_status VARCHAR2(20) NOT NULL,
|
||||||
|
judgment_reason CLOB,
|
||||||
|
duration_ms NUMBER,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT sg_ai_qa_answer_question_fk
|
||||||
|
FOREIGN KEY (question_id)
|
||||||
|
REFERENCES {QUESTION_TABLE} (question_id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT sg_ai_qa_answer_kind_ck
|
||||||
|
CHECK (answer_kind IN ('HISTORICAL', 'LIVE')),
|
||||||
|
CONSTRAINT sg_ai_qa_answer_judgment_ck
|
||||||
|
CHECK (judgment_status IN ('PASS', 'WARN', 'FAIL', 'REVIEW'))
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE INDEX sg_ai_qa_answer_question_ix
|
||||||
|
ON {ANSWER_TABLE} (question_id, answer_seq DESC)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE UNIQUE INDEX sg_ai_qa_answer_run_uk
|
||||||
|
ON {ANSWER_TABLE} (question_id, run_key)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema(store: QaHistoryStore) -> None:
|
||||||
|
objects = (QUESTION_TABLE, ANSWER_TABLE)
|
||||||
|
with store._connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT table_name FROM user_tables WHERE table_name IN (:q, :a)",
|
||||||
|
{"q": objects[0], "a": objects[1]},
|
||||||
|
)
|
||||||
|
existing = {str(row[0]) for row in cursor}
|
||||||
|
statements = schema_statements()
|
||||||
|
if QUESTION_TABLE not in existing:
|
||||||
|
cursor.execute(statements[0])
|
||||||
|
if ANSWER_TABLE not in existing:
|
||||||
|
cursor.execute(statements[1])
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT index_name FROM user_indexes WHERE index_name IN (:ix1, :ix2)",
|
||||||
|
{"ix1": "SG_AI_QA_ANSWER_QUESTION_IX", "ix2": "SG_AI_QA_ANSWER_RUN_UK"},
|
||||||
|
)
|
||||||
|
indexes = {str(row[0]) for row in cursor}
|
||||||
|
if "SG_AI_QA_ANSWER_QUESTION_IX" not in indexes:
|
||||||
|
cursor.execute(statements[2])
|
||||||
|
if "SG_AI_QA_ANSWER_RUN_UK" not in indexes:
|
||||||
|
cursor.execute(statements[3])
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
51
poc4_active_source_20260714/tests/test_oci_genai_settings.py
Normal file
51
poc4_active_source_20260714/tests/test_oci_genai_settings.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""OCI GenAI configuration validation tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from src.oci_genai_sdk import ALLOWED_OCI_SETTINGS, load_oci_settings
|
||||||
|
|
||||||
|
|
||||||
|
class OCISettingsTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._previous = {key: os.environ.get(key) for key in ALLOWED_OCI_SETTINGS}
|
||||||
|
os.environ.update(
|
||||||
|
{
|
||||||
|
"OCI_AUTH_TYPE": "config_file",
|
||||||
|
"OCI_CONFIG_FILE": "/home/opc/.oci/config",
|
||||||
|
"OCI_PROFILE": "DEFAULT",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
for key, value in self._previous.items():
|
||||||
|
if value is None:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
else:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
def test_accepts_a_child_compartment_ocid(self) -> None:
|
||||||
|
os.environ["OCI_GENAI_COMPARTMENT_ID"] = "ocid1.compartment.oc1..example"
|
||||||
|
|
||||||
|
settings = load_oci_settings()
|
||||||
|
|
||||||
|
self.assertEqual("ocid1.compartment.oc1..example", settings.compartment_id)
|
||||||
|
|
||||||
|
def test_accepts_a_tenancy_ocid_for_the_root_compartment(self) -> None:
|
||||||
|
os.environ["OCI_GENAI_COMPARTMENT_ID"] = "ocid1.tenancy.oc1..example"
|
||||||
|
|
||||||
|
settings = load_oci_settings()
|
||||||
|
|
||||||
|
self.assertEqual("ocid1.tenancy.oc1..example", settings.compartment_id)
|
||||||
|
|
||||||
|
def test_rejects_an_invalid_compartment_identifier(self) -> None:
|
||||||
|
os.environ["OCI_GENAI_COMPARTMENT_ID"] = "not-an-ocid"
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "compartment is not configured"):
|
||||||
|
load_oci_settings()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
80
poc4_active_source_20260714/tests/test_qa_history.py
Normal file
80
poc4_active_source_20260714/tests/test_qa_history.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from src.poc4.qa_history import evaluate_sql, load_benchmark_questions
|
||||||
|
from src.poc4.qa_history_store import _normalize_oracle_dsn, schema_statements
|
||||||
|
|
||||||
|
|
||||||
|
class QaHistoryTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
benchmark = Path(__file__).parents[1] / "config" / "smilegate_qa_benchmark.json"
|
||||||
|
cls.questions = {item.question_code: item for item in load_benchmark_questions(benchmark)}
|
||||||
|
|
||||||
|
def test_customer_excel_benchmark_contains_all_47_cases(self) -> None:
|
||||||
|
self.assertEqual(47, len(self.questions))
|
||||||
|
self.assertIn("STD-01", self.questions)
|
||||||
|
self.assertIn("CZN-19", self.questions)
|
||||||
|
|
||||||
|
def test_supported_query_passes_when_required_terms_are_present(self) -> None:
|
||||||
|
judgment = evaluate_sql(
|
||||||
|
self.questions["STD-13"],
|
||||||
|
"SELECT SUM(PAYMT_AMT) FROM COMN_SALES_TXN",
|
||||||
|
execution_succeeded=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("PASS", judgment.status)
|
||||||
|
|
||||||
|
def test_monthly_au_with_au_flag_fails(self) -> None:
|
||||||
|
judgment = evaluate_sql(
|
||||||
|
self.questions["STD-27"],
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM CZN_COMN_USER_MST
|
||||||
|
WHERE AU_FLAG = 1
|
||||||
|
AND BASE_DT = (SELECT MAX(BASE_DT) FROM CZN_COMN_USER_MST)
|
||||||
|
AND LAST_CONN_DT >= ADD_MONTHS(BASE_DT, -1)
|
||||||
|
AND STD_USER_YN = 'Y'
|
||||||
|
AND EXPT_USER_YN = 'N'
|
||||||
|
""",
|
||||||
|
execution_succeeded=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("FAIL", judgment.status)
|
||||||
|
self.assertIn("AU_FLAG", judgment.reason)
|
||||||
|
|
||||||
|
def test_unsupported_game_requires_safe_alias_lookup(self) -> None:
|
||||||
|
safe = evaluate_sql(
|
||||||
|
self.questions["STD-02"],
|
||||||
|
"SELECT GAME_ID FROM COMN_GAME_ALIAS_BAS WHERE GAME_NM LIKE '%버블리즈%'",
|
||||||
|
execution_succeeded=True,
|
||||||
|
)
|
||||||
|
unsafe = evaluate_sql(
|
||||||
|
self.questions["STD-02"],
|
||||||
|
"SELECT COUNT(*) FROM CZN_COMN_USER_MST WHERE GAME_ID = 'STOVE_CHAOSZERO'",
|
||||||
|
execution_succeeded=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("PASS", safe.status)
|
||||||
|
self.assertEqual("FAIL", unsafe.status)
|
||||||
|
|
||||||
|
def test_free_text_is_review_not_automatic_pass(self) -> None:
|
||||||
|
judgment = evaluate_sql(None, "SELECT 1 FROM DUAL", execution_succeeded=True)
|
||||||
|
self.assertEqual("REVIEW", judgment.status)
|
||||||
|
|
||||||
|
def test_jdbc_url_wallet_is_normalized_for_python_driver(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
("sgmpaipoc_medium", "/home/opc/wallet/sgmpaipoc"),
|
||||||
|
_normalize_oracle_dsn(
|
||||||
|
"jdbc:oracle:thin:@sgmpaipoc_medium?TNS_ADMIN=/home/opc/wallet/sgmpaipoc"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schema_defines_two_history_tables_and_indexes(self) -> None:
|
||||||
|
statements = "\n".join(schema_statements())
|
||||||
|
self.assertIn("CREATE TABLE SG_AI_QA_QUESTION", statements)
|
||||||
|
self.assertIn("CREATE TABLE SG_AI_QA_ANSWER", statements)
|
||||||
|
self.assertIn("answer_seq NUMBER GENERATED ALWAYS AS IDENTITY", statements)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -16,29 +16,29 @@ class DemoScenarioConfigTest(unittest.TestCase):
|
|||||||
with patch.dict(
|
with patch.dict(
|
||||||
"os.environ",
|
"os.environ",
|
||||||
{
|
{
|
||||||
"AGENT_CONSOLE_SHORT_NAME": "HMM",
|
"AGENT_CONSOLE_SHORT_NAME": "SMILEGATE",
|
||||||
"AGENT_CONSOLE_PAGE_TITLE": "HMM AI 업무 에이전트",
|
"AGENT_CONSOLE_PAGE_TITLE": "SMILEGATE DATA & AI POC",
|
||||||
"AGENT_CONSOLE_PRIMARY_COLOR": "#003b70",
|
"AGENT_CONSOLE_PRIMARY_COLOR": "#113F67",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
profile = load_app_profile(path)
|
profile = load_app_profile(path)
|
||||||
|
|
||||||
self.assertEqual(profile.short_name, "HMM")
|
self.assertEqual(profile.short_name, "SMILEGATE")
|
||||||
self.assertEqual(profile.page_title, "HMM AI 업무 에이전트")
|
self.assertEqual(profile.page_title, "SMILEGATE DATA & AI POC")
|
||||||
self.assertEqual(profile.primary_color, "#003b70")
|
self.assertEqual(profile.primary_color, "#113F67")
|
||||||
|
|
||||||
def test_profile_reads_dotenv_values(self) -> None:
|
def test_profile_reads_dotenv_values(self) -> None:
|
||||||
path = Path(__file__).parents[1] / "config" / "app_profile.json"
|
path = Path(__file__).parents[1] / "config" / "app_profile.json"
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
env_file = Path(temp_dir) / ".env"
|
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)
|
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:
|
def test_smilegate_scenarios_are_enabled_and_unique(self) -> None:
|
||||||
path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json"
|
path = Path(__file__).parents[1] / "config" / "smilegate_demo_scenarios.json"
|
||||||
scenarios = load_demo_scenarios(path)
|
scenarios = load_demo_scenarios(path)
|
||||||
|
|
||||||
self.assertGreaterEqual(len(scenarios), 3)
|
self.assertGreaterEqual(len(scenarios), 3)
|
||||||
|
|||||||
7
pom.xml
7
pom.xml
@@ -98,11 +98,8 @@
|
|||||||
<directory>sql/adb</directory>
|
<directory>sql/adb</directory>
|
||||||
<targetPath>sql/adb</targetPath>
|
<targetPath>sql/adb</targetPath>
|
||||||
<includes>
|
<includes>
|
||||||
<include>62_kb_aso_masking_backoffice_metadata.sql</include>
|
<include>70_sg_tool_user.sql</include>
|
||||||
<include>63_kb_aso_masking_rule_runtime.sql</include>
|
<include>71_sg_identity_administration.sql</include>
|
||||||
<include>64_kb_aso_masking_default_column_rules.sql</include>
|
|
||||||
<include>65_kb_select_ai_vpd_query_api.sql</include>
|
|
||||||
<include>66_kb_select_ai_vpd_query_ords.sql</include>
|
|
||||||
</includes>
|
</includes>
|
||||||
</resource>
|
</resource>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
62
sql/adb/47_smilegate_qa_history.sql
Normal file
62
sql/adb/47_smilegate_qa_history.sql
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
-- Smilegate customer Excel QA benchmark history.
|
||||||
|
-- This script is also applied by poc4_active_source_20260714/scripts/
|
||||||
|
-- sync_smilegate_qa_history.py with existence checks for repeatable deployment.
|
||||||
|
|
||||||
|
CREATE TABLE SG_AI_QA_QUESTION (
|
||||||
|
question_id NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY,
|
||||||
|
question_code VARCHAR2(30) UNIQUE,
|
||||||
|
question_source VARCHAR2(30) NOT NULL,
|
||||||
|
question_hash VARCHAR2(64) NOT NULL UNIQUE,
|
||||||
|
category VARCHAR2(30) NOT NULL,
|
||||||
|
title VARCHAR2(200) NOT NULL,
|
||||||
|
question_text CLOB NOT NULL,
|
||||||
|
source_document VARCHAR2(255),
|
||||||
|
source_sheet VARCHAR2(255),
|
||||||
|
source_row NUMBER,
|
||||||
|
source_scenario CLOB,
|
||||||
|
sample_sql CLOB,
|
||||||
|
expected_focus CLOB,
|
||||||
|
baseline_sql CLOB,
|
||||||
|
baseline_answer CLOB,
|
||||||
|
support_level VARCHAR2(20) NOT NULL,
|
||||||
|
evaluation_rule_json CLOB CHECK (evaluation_rule_json IS JSON),
|
||||||
|
active_yn CHAR(1) DEFAULT 'Y' NOT NULL CHECK (active_yn IN ('Y', 'N')),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT sg_ai_qa_question_source_ck
|
||||||
|
CHECK (question_source IN ('CUSTOMER_EXCEL', 'FREE_TEXT'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE SG_AI_QA_ANSWER (
|
||||||
|
answer_seq NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
question_id NUMBER NOT NULL,
|
||||||
|
answer_kind VARCHAR2(20) NOT NULL,
|
||||||
|
run_key VARCHAR2(100),
|
||||||
|
conversation_id VARCHAR2(100),
|
||||||
|
requested_by VARCHAR2(100),
|
||||||
|
requested_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
model_profile VARCHAR2(100),
|
||||||
|
generated_sql CLOB,
|
||||||
|
answer_text CLOB,
|
||||||
|
result_json CLOB CHECK (result_json IS JSON),
|
||||||
|
execution_output CLOB,
|
||||||
|
execution_status VARCHAR2(40),
|
||||||
|
judgment_status VARCHAR2(20) NOT NULL,
|
||||||
|
judgment_reason CLOB,
|
||||||
|
duration_ms NUMBER,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT sg_ai_qa_answer_question_fk
|
||||||
|
FOREIGN KEY (question_id)
|
||||||
|
REFERENCES SG_AI_QA_QUESTION (question_id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT sg_ai_qa_answer_kind_ck
|
||||||
|
CHECK (answer_kind IN ('HISTORICAL', 'LIVE')),
|
||||||
|
CONSTRAINT sg_ai_qa_answer_judgment_ck
|
||||||
|
CHECK (judgment_status IN ('PASS', 'WARN', 'FAIL', 'REVIEW'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX sg_ai_qa_answer_question_ix
|
||||||
|
ON SG_AI_QA_ANSWER (question_id, answer_seq DESC);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX sg_ai_qa_answer_run_uk
|
||||||
|
ON SG_AI_QA_ANSWER (question_id, run_key);
|
||||||
@@ -130,6 +130,69 @@ begin
|
|||||||
)');
|
)');
|
||||||
create_if_missing('create sequence sg_permission_seq start with 1 increment by 1 nocache');
|
create_if_missing('create sequence sg_permission_seq start with 1 increment by 1 nocache');
|
||||||
create_if_missing('create sequence sg_permission_rule_seq start with 1 increment by 1 nocache');
|
create_if_missing('create sequence sg_permission_rule_seq start with 1 increment by 1 nocache');
|
||||||
|
create_if_missing('create table sg_vpd_policy_note (
|
||||||
|
object_owner varchar2(128) not null,
|
||||||
|
object_name varchar2(128) not null,
|
||||||
|
policy_name varchar2(128) not null,
|
||||||
|
description varchar2(2000),
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_vpd_policy_note_pk primary key (object_owner, object_name, policy_name)
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_vpd_filter_note (
|
||||||
|
function_owner varchar2(128) not null,
|
||||||
|
function_name varchar2(128) not null,
|
||||||
|
description varchar2(2000),
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_vpd_filter_note_pk primary key (function_owner, function_name)
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_masking_rule (
|
||||||
|
rule_id number primary key,
|
||||||
|
rule_code varchar2(100) not null unique,
|
||||||
|
rule_name varchar2(200) not null,
|
||||||
|
template_code varchar2(100) not null,
|
||||||
|
description varchar2(2000),
|
||||||
|
enabled_yn char(1) default ''Y'' not null,
|
||||||
|
created_at timestamp default systimestamp not null,
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_masking_rule_enabled_ck check (enabled_yn in (''Y'', ''N''))
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_access_bearer_token (
|
||||||
|
key_id number primary key, user_id number not null, key_prefix varchar2(100) not null,
|
||||||
|
key_hash varchar2(256) not null unique, expires_at timestamp not null, revoked_at timestamp,
|
||||||
|
description varchar2(500), created_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_access_bearer_token_user_fk foreign key (user_id) references sg_app_user(user_id)
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_backoffice_setting (
|
||||||
|
setting_key varchar2(200) primary key, setting_value varchar2(4000),
|
||||||
|
updated_at timestamp default systimestamp not null
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_vector_document_chunk (
|
||||||
|
chunk_id number primary key, document_id varchar2(200) not null, chunk_no number not null,
|
||||||
|
content clob not null, embedding vector(1536, float32), created_at timestamp default systimestamp not null
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_vector_document_tag (
|
||||||
|
chunk_id number not null, tech_tag varchar2(200) not null,
|
||||||
|
constraint sg_vector_document_tag_pk primary key (chunk_id, tech_tag),
|
||||||
|
constraint sg_vector_document_tag_chunk_fk foreign key (chunk_id) references sg_vector_document_chunk(chunk_id)
|
||||||
|
)');
|
||||||
|
create_if_missing('create sequence sg_vector_chunk_seq start with 1 increment by 1 nocache');
|
||||||
|
create_if_missing('create table sg_column_masking_rule (
|
||||||
|
column_id number primary key,
|
||||||
|
rule_id number not null,
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_column_masking_rule_column_fk foreign key (column_id) references sg_protected_column(column_id),
|
||||||
|
constraint sg_column_masking_rule_rule_fk foreign key (rule_id) references sg_masking_rule(rule_id)
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_user_masking_rule (
|
||||||
|
user_id number not null,
|
||||||
|
column_id number not null,
|
||||||
|
decision varchar2(30) not null,
|
||||||
|
active_yn char(1) default ''Y'' not null,
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_user_masking_rule_pk primary key (user_id, column_id),
|
||||||
|
constraint sg_user_masking_rule_user_fk foreign key (user_id) references sg_app_user(user_id),
|
||||||
|
constraint sg_user_masking_rule_column_fk foreign key (column_id) references sg_protected_column(column_id)
|
||||||
|
)');
|
||||||
end;
|
end;
|
||||||
/
|
/
|
||||||
|
|
||||||
@@ -168,3 +231,99 @@ merge into sg_group_role t using (select 2001 group_id, 3002 role_id from dual)
|
|||||||
on (t.group_id=s.group_id and t.role_id=s.role_id) when not matched then insert (group_id, role_id) values (s.group_id, s.role_id);
|
on (t.group_id=s.group_id and t.role_id=s.role_id) when not matched then insert (group_id, role_id) values (s.group_id, s.role_id);
|
||||||
|
|
||||||
commit;
|
commit;
|
||||||
|
|
||||||
|
-- Compatibility layer for remaining backoffice modules. The data is stored
|
||||||
|
-- only in SG_* tables; these views prevent older controller paths from
|
||||||
|
-- querying non-existent CB_* physical tables during the Smilegate transition.
|
||||||
|
create or replace view cb_app_user as
|
||||||
|
select user_id, user_name, employee_no, dept_code, can_read_contents, active from sg_app_user;
|
||||||
|
create or replace view cb_app_group as
|
||||||
|
select group_id, group_code, group_name, description, active_yn from sg_app_group;
|
||||||
|
create or replace view cb_app_role as
|
||||||
|
select role_id, role_name, max_sensitivity_level from sg_app_role;
|
||||||
|
create or replace view cb_user_role as select user_id, role_id from sg_user_role;
|
||||||
|
create or replace view cb_user_group as select group_id, user_id from sg_user_group;
|
||||||
|
create or replace view cb_group_role as select group_id, role_id from sg_group_role;
|
||||||
|
create or replace view cb_protected_object as
|
||||||
|
select object_id, owner, object_name, ords_path, enabled_yn, description from sg_protected_object;
|
||||||
|
create or replace view cb_protected_column as
|
||||||
|
select column_id, object_id, column_name, sensitive_yn, visible_role_id, sensitivity_level, redaction_method from sg_protected_column;
|
||||||
|
create or replace view cb_permission as
|
||||||
|
select perm_id, role_id, target_name, action_name, permission_effect from sg_permission;
|
||||||
|
create or replace view cb_permission_rule as
|
||||||
|
select rule_id, perm_id, rule_column, rule_type, rule_value from sg_permission_rule;
|
||||||
|
create or replace view cb_permission_column as
|
||||||
|
select permission_id, column_name from sg_permission_column;
|
||||||
|
create or replace view cb_vpd_policy_note as
|
||||||
|
select object_owner, object_name, policy_name, description, updated_at from sg_vpd_policy_note;
|
||||||
|
create or replace view cb_vpd_filter_note as
|
||||||
|
select function_owner, function_name, description, updated_at from sg_vpd_filter_note;
|
||||||
|
create or replace view cb_masking_rule as
|
||||||
|
select rule_id, rule_code, rule_name, template_code, description, enabled_yn from sg_masking_rule;
|
||||||
|
create or replace view cb_column_masking_rule as
|
||||||
|
select column_id, rule_id, updated_at from sg_column_masking_rule;
|
||||||
|
create or replace view cb_user_masking_rule as
|
||||||
|
select user_id, column_id, decision, active_yn, updated_at from sg_user_masking_rule;
|
||||||
|
create or replace view cb_ords_probe_audit as
|
||||||
|
select audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at from sg_audit_event;
|
||||||
|
create or replace view cb_backoffice_setting as
|
||||||
|
select setting_key, setting_value, updated_at from sg_backoffice_setting;
|
||||||
|
|
||||||
|
-- PoC administrator access: both demo operators can manage and query every
|
||||||
|
-- Smilegate game-data object registered in SGMP_POC.
|
||||||
|
merge into sg_app_role t
|
||||||
|
using (select 3099 role_id, 'DATA_AI_POC_ADMIN' role_name,
|
||||||
|
'Full access to all Smilegate PoC game-data objects' description,
|
||||||
|
'RESTRICTED' max_sensitivity_level from dual) s
|
||||||
|
on (t.role_id = s.role_id)
|
||||||
|
when matched then update set t.role_name=s.role_name, t.description=s.description,
|
||||||
|
t.max_sensitivity_level=s.max_sensitivity_level, t.updated_at=systimestamp
|
||||||
|
when not matched then insert (role_id, role_name, description, max_sensitivity_level)
|
||||||
|
values (s.role_id, s.role_name, s.description, s.max_sensitivity_level);
|
||||||
|
|
||||||
|
merge into sg_user_role t
|
||||||
|
using (select 1001 user_id, 3099 role_id from dual union all select 1002, 3099 from dual) s
|
||||||
|
on (t.user_id=s.user_id and t.role_id=s.role_id)
|
||||||
|
when not matched then insert (user_id, role_id) values (s.user_id, s.role_id);
|
||||||
|
|
||||||
|
declare
|
||||||
|
l_object_id number;
|
||||||
|
l_permission_id number;
|
||||||
|
begin
|
||||||
|
for source_object in (
|
||||||
|
select table_name
|
||||||
|
from all_tables
|
||||||
|
where owner = 'SGMP_POC'
|
||||||
|
and table_name not in ('SEMANTIC_METADATA_CHANGE_LOG', 'SGMP_TERM_CONTEXT_CACHE',
|
||||||
|
'SGMP_TERM_DICTIONARY', 'SGMP_TERM_SEARCH_LOG', 'SGMP_TERM_SYNONYM')
|
||||||
|
order by table_name
|
||||||
|
) loop
|
||||||
|
begin
|
||||||
|
select object_id into l_object_id
|
||||||
|
from sg_protected_object
|
||||||
|
where owner = 'SGMP_POC' and object_name = source_object.table_name;
|
||||||
|
exception
|
||||||
|
when no_data_found then
|
||||||
|
select nvl(max(object_id), 0) + 1 into l_object_id from sg_protected_object;
|
||||||
|
insert into sg_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description)
|
||||||
|
values (l_object_id, 'SGMP_POC', source_object.table_name,
|
||||||
|
'/sgmp-poc/' || lower(source_object.table_name), 'Y',
|
||||||
|
'Smilegate PoC game-data object');
|
||||||
|
end;
|
||||||
|
|
||||||
|
begin
|
||||||
|
select perm_id into l_permission_id
|
||||||
|
from sg_permission
|
||||||
|
where role_id = 3099 and target_name = source_object.table_name;
|
||||||
|
exception
|
||||||
|
when no_data_found then
|
||||||
|
l_permission_id := sg_permission_seq.nextval;
|
||||||
|
insert into sg_permission (perm_id, role_id, target_name, action_name, permission_effect)
|
||||||
|
values (l_permission_id, 3099, source_object.table_name, 'SELECT', 'ALLOW');
|
||||||
|
insert into sg_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||||
|
values (sg_permission_rule_seq.nextval, l_permission_id, null, 'ALL', null);
|
||||||
|
end;
|
||||||
|
end loop;
|
||||||
|
commit;
|
||||||
|
end;
|
||||||
|
/
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ public record BackofficeProperties(
|
|||||||
Security security,
|
Security security,
|
||||||
Token token,
|
Token token,
|
||||||
Ords ords,
|
Ords ords,
|
||||||
Ai ai
|
Ai ai,
|
||||||
|
SelectAi selectAi
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public record Security(
|
public record Security(
|
||||||
@@ -72,4 +73,20 @@ public record BackofficeProperties(
|
|||||||
this(enabled, "openai", baseUrl, model, apiKey, timeout, "", "", "", "", "");
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,6 @@ public class DbPoolWarmup {
|
|||||||
groupService.findGroupRoles();
|
groupService.findGroupRoles();
|
||||||
permissionService.findRoles();
|
permissionService.findRoles();
|
||||||
permissionService.findPermissionViews();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public class SecurityConfig {
|
|||||||
.rememberMeCookieName("VPD_REMEMBER_ME")
|
.rememberMeCookieName("VPD_REMEMBER_ME")
|
||||||
.tokenValiditySeconds(security.rememberMeValiditySeconds())
|
.tokenValiditySeconds(security.rememberMeValiditySeconds())
|
||||||
.useSecureCookie(true)
|
.useSecureCookie(true)
|
||||||
.alwaysRemember(false));
|
.alwaysRemember(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
return http
|
return http
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ public enum MaskingTemplate {
|
|||||||
"A******** (예시)"),
|
"A******** (예시)"),
|
||||||
RRN_PARTIAL(
|
RRN_PARTIAL(
|
||||||
"RRN_PARTIAL",
|
"RRN_PARTIAL",
|
||||||
"주민등록번호 부분 마스킹",
|
"식별번호 부분 마스킹",
|
||||||
"앞 6자리만 표시하고 나머지는 가리는 사전 정의 식별번호 규칙입니다.",
|
"앞 6자리만 표시하고 나머지를 가리는 사전 정의 식별번호 규칙입니다.",
|
||||||
"DBMS_REDACT.REGEXP",
|
"DBMS_REDACT.REGEXP",
|
||||||
"900101-******* (예시)");
|
"123456-******* (예시)");
|
||||||
|
|
||||||
private final String code;
|
private final String code;
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|||||||
@@ -50,16 +50,16 @@ public record PermissionView(
|
|||||||
return "토큰으로 식별된 이해관계자 본인 행";
|
return "토큰으로 식별된 이해관계자 본인 행";
|
||||||
}
|
}
|
||||||
if (upper.contains(" OWN_CONTRACT")) {
|
if (upper.contains(" OWN_CONTRACT")) {
|
||||||
return "담당 설계사 본인 계약";
|
return "담당 게임 서비스 범위";
|
||||||
}
|
}
|
||||||
if (upper.contains(" CHANNEL_CONTRACT")) {
|
if (upper.contains(" CHANNEL_CONTRACT")) {
|
||||||
return "토큰 사용자의 채널 계약";
|
return "토큰 채널 게임 서비스 범위";
|
||||||
}
|
}
|
||||||
if (upper.contains(" OWN_CUSTOMER")) {
|
if (upper.contains(" OWN_CUSTOMER")) {
|
||||||
return "담당 설계사 본인 계약에 연결된 고객/청구/외부보유";
|
return "담당 게임 사용자·거래 데이터 범위";
|
||||||
}
|
}
|
||||||
if (upper.contains(" CHANNEL_CUSTOMER")) {
|
if (upper.contains(" CHANNEL_CUSTOMER")) {
|
||||||
return "토큰 사용자 채널 계약에 연결된 고객/청구/외부보유";
|
return "토큰 채널 게임 사용자·거래 데이터 범위";
|
||||||
}
|
}
|
||||||
if (upper.contains(" STATIC_SQL ")) {
|
if (upper.contains(" STATIC_SQL ")) {
|
||||||
return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", "");
|
return "정적 SQL 조건: " + rawRule.replaceFirst("(?i)^\\s*STATIC_SQL\\s+", "");
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row from ALL_ANNOTATIONS_USAGE for one table or column annotation.
|
||||||
|
*/
|
||||||
|
public record SchemaMetadataAnnotationRow(
|
||||||
|
String columnName,
|
||||||
|
String annotationName,
|
||||||
|
String annotationValue
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row used only while assembling the schema metadata screen.
|
||||||
|
*/
|
||||||
|
public record SchemaMetadataColumnRow(
|
||||||
|
String columnName,
|
||||||
|
String dataType,
|
||||||
|
String nullable,
|
||||||
|
String comment
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.mapper;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow;
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All database access for the schema metadata screen. Identifier parameters
|
||||||
|
* are validated against the screen's closed table list before mapper calls.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SchemaMetadataMapper {
|
||||||
|
|
||||||
|
String findTableComment(@Param("owner") String owner, @Param("tableName") String tableName);
|
||||||
|
|
||||||
|
List<SchemaMetadataColumnRow> findColumns(
|
||||||
|
@Param("owner") String owner,
|
||||||
|
@Param("tableName") String tableName
|
||||||
|
);
|
||||||
|
|
||||||
|
List<SchemaMetadataAnnotationRow> findAnnotations(@Param("tableName") String tableName);
|
||||||
|
|
||||||
|
int countColumn(
|
||||||
|
@Param("owner") String owner,
|
||||||
|
@Param("tableName") String tableName,
|
||||||
|
@Param("columnName") String columnName
|
||||||
|
);
|
||||||
|
|
||||||
|
int countTableAnnotation(
|
||||||
|
@Param("tableName") String tableName,
|
||||||
|
@Param("annotationName") String annotationName
|
||||||
|
);
|
||||||
|
|
||||||
|
int countColumnAnnotation(
|
||||||
|
@Param("tableName") String tableName,
|
||||||
|
@Param("columnName") String columnName,
|
||||||
|
@Param("annotationName") String annotationName
|
||||||
|
);
|
||||||
|
|
||||||
|
void updateTableComment(
|
||||||
|
@Param("owner") String owner,
|
||||||
|
@Param("tableName") String tableName,
|
||||||
|
@Param("commentLiteral") String commentLiteral
|
||||||
|
);
|
||||||
|
|
||||||
|
void updateColumnComment(
|
||||||
|
@Param("owner") String owner,
|
||||||
|
@Param("tableName") String tableName,
|
||||||
|
@Param("columnName") String columnName,
|
||||||
|
@Param("commentLiteral") String commentLiteral
|
||||||
|
);
|
||||||
|
|
||||||
|
void dropTableAnnotation(
|
||||||
|
@Param("owner") String owner,
|
||||||
|
@Param("tableName") String tableName,
|
||||||
|
@Param("annotationName") String annotationName
|
||||||
|
);
|
||||||
|
|
||||||
|
void addTableAnnotation(
|
||||||
|
@Param("owner") String owner,
|
||||||
|
@Param("tableName") String tableName,
|
||||||
|
@Param("annotationName") String annotationName,
|
||||||
|
@Param("annotationValueLiteral") String annotationValueLiteral
|
||||||
|
);
|
||||||
|
|
||||||
|
void dropColumnAnnotation(
|
||||||
|
@Param("owner") String owner,
|
||||||
|
@Param("tableName") String tableName,
|
||||||
|
@Param("columnName") String columnName,
|
||||||
|
@Param("annotationName") String annotationName
|
||||||
|
);
|
||||||
|
|
||||||
|
void addColumnAnnotation(
|
||||||
|
@Param("owner") String owner,
|
||||||
|
@Param("tableName") String tableName,
|
||||||
|
@Param("columnName") String columnName,
|
||||||
|
@Param("annotationName") String annotationName,
|
||||||
|
@Param("annotationValueLiteral") String annotationValueLiteral
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -258,8 +258,8 @@ public class BackofficeSchemaService {
|
|||||||
"문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"),
|
"문자형은 공백, 숫자형은 0으로 반환하는 전체 마스킹 방식"),
|
||||||
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
|
new MaskingRuleSeed("MASK_TEXT_PARTIAL", "문자열 일부 마스킹", "TEXT_PARTIAL",
|
||||||
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
|
"첫 글자만 보이고 나머지는 가리는 문자열 마스킹 방식"),
|
||||||
new MaskingRuleSeed("MASK_RRN_PARTIAL", "주민등록번호 부분 마스킹", "RRN_PARTIAL",
|
new MaskingRuleSeed("MASK_IDENTIFIER_PARTIAL", "식별번호 부분 마스킹", "RRN_PARTIAL",
|
||||||
"앞 6자리만 보이고 나머지는 가리는 식별번호 마스킹 방식")
|
"앞 6자리만 보이고 나머지는 가리는 게임 사용자 식별번호 마스킹 방식")
|
||||||
);
|
);
|
||||||
|
|
||||||
private static final String MASKING_RULE_SEED_SQL = """
|
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/17_agent_ords_security_local_vpd_setup.sql
|
||||||
@sql/adb/25_agent_ords_security_backoffice_support.sql
|
@sql/adb/25_agent_ords_security_backoffice_support.sql
|
||||||
@sql/adb/26_agent_ords_security_dynamic_vpd_filter.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
|
@sql/adb/21_agent_ords_security_ords_enable_schema.sql
|
||||||
|
|
||||||
-- 2. ORDS parsing schema로 접속
|
-- 2. ORDS parsing schema로 접속
|
||||||
@@ -717,9 +717,8 @@ public class BackofficeSchemaService {
|
|||||||
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
|
GRANT EXECUTE ON cb_agent_ctx_pkg TO cb_ords;
|
||||||
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
|
GRANT SELECT ON <owner>.<table_or_view> TO cb_ords;
|
||||||
|
|
||||||
-- 4. 마스킹 규칙을 UI에서 컬럼에 연결한 뒤 실행
|
-- 4. 마스킹 규칙을 UI에서 게임 데이터 컬럼에 연결
|
||||||
@sql/adb/64_kb_aso_masking_default_column_rules.sql
|
-- DBMS_REDACT 정책은 백오피스가 SGMP_POC 대상에 자동 동기화합니다.
|
||||||
@sql/adb/63_kb_aso_masking_rule_runtime.sql
|
|
||||||
""".formatted(owner.toLowerCase());
|
""".formatted(owner.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class MaskingPolicySynchronizer {
|
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 Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||||
private static final Map<String, String> MANAGED_POLICIES = managedPolicyMap();
|
private static final Map<String, String> MANAGED_POLICIES = managedPolicyMap();
|
||||||
|
|
||||||
@@ -38,10 +38,10 @@ public class MaskingPolicySynchronizer {
|
|||||||
|
|
||||||
private static Map<String, String> managedPolicyMap() {
|
private static Map<String, String> managedPolicyMap() {
|
||||||
Map<String, String> policies = new LinkedHashMap<>();
|
Map<String, String> policies = new LinkedHashMap<>();
|
||||||
policies.put("KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT");
|
policies.put("CZN_COMN_USER_MST", "SG_CZN_USER_REDACT");
|
||||||
policies.put("KB_CLAIMS", "KB_CLAIM_AMOUNT_REDACT");
|
policies.put("COMN_SALES_USER_MST", "SG_SALES_USER_REDACT");
|
||||||
policies.put("KB_CONTRACTS", "KB_CONTRACT_PREMIUM_REDACT");
|
policies.put("COMN_SALES_TXN", "SG_SALES_TXN_REDACT");
|
||||||
policies.put("KB_EXTERNAL_HOLDINGS", "KB_EXT_HOLDING_REDACT");
|
policies.put("COMN_REFUND_TXN", "SG_REFUND_TXN_REDACT");
|
||||||
return Collections.unmodifiableMap(policies);
|
return Collections.unmodifiableMap(policies);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public class MaskingRuleService {
|
|||||||
return mapper.findColumnRules();
|
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<MaskingPolicyStatus> findPolicyStatuses() {
|
public List<MaskingPolicyStatus> findPolicyStatuses() {
|
||||||
return mapper.findPolicyStatuses();
|
return mapper.findPolicyStatuses();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,30 +8,29 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Service;
|
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 generation and read-only execution tool. */
|
||||||
@Service
|
@Service
|
||||||
public class McpSseService {
|
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_TOOL = "oracle.select_ai.smilegate_game_text2sql";
|
||||||
private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query";
|
private static final String SELECT_AI_VPD_QUERY_PATH = "/mcp (tools/call)";
|
||||||
private static final String SELECT_AI_VPD_QUERY_PROFILE =
|
private static final String SELECT_AI_VPD_QUERY_PROFILE = "SGMP_POC_HAIKU45";
|
||||||
"KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1";
|
|
||||||
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
|
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
|
||||||
SELECT_AI_VPD_QUERY_TOOL,
|
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과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.",
|
||||||
-1L,
|
-1L,
|
||||||
"KB Select AI 행 접근 자연어 조회",
|
"Smilegate 게임 데이터 Text2SQL",
|
||||||
SELECT_AI_VPD_QUERY_PATH
|
SELECT_AI_VPD_QUERY_PATH
|
||||||
);
|
);
|
||||||
|
|
||||||
private final SelectAiAgentOrdsService selectAiAgentOrdsService;
|
private final SmilegateSelectAiService smilegateSelectAiService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public McpSseService(
|
public McpSseService(
|
||||||
SelectAiAgentOrdsService selectAiAgentOrdsService,
|
SmilegateSelectAiService smilegateSelectAiService,
|
||||||
ObjectMapper objectMapper
|
ObjectMapper objectMapper
|
||||||
) {
|
) {
|
||||||
this.selectAiAgentOrdsService = selectAiAgentOrdsService;
|
this.smilegateSelectAiService = smilegateSelectAiService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,17 +105,10 @@ public class McpSseService {
|
|||||||
|
|
||||||
ObjectNode prompt = objectMapper.createObjectNode();
|
ObjectNode prompt = objectMapper.createObjectNode();
|
||||||
prompt.put("type", "string");
|
prompt.put("type", "string");
|
||||||
prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다.");
|
prompt.put("description", "Smilegate 게임 로그·서비스 데이터에 대해 조회할 내용을 자연어로 입력합니다.");
|
||||||
prompt.put("maxLength", 4000);
|
prompt.put("maxLength", 4000);
|
||||||
properties.set("prompt", prompt);
|
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);
|
schema.set("properties", properties);
|
||||||
ArrayNode required = objectMapper.createArrayNode();
|
ArrayNode required = objectMapper.createArrayNode();
|
||||||
required.add("prompt");
|
required.add("prompt");
|
||||||
@@ -139,11 +131,7 @@ public class McpSseService {
|
|||||||
}
|
}
|
||||||
JsonNode response;
|
JsonNode response;
|
||||||
try {
|
try {
|
||||||
response = selectAiAgentOrdsService.run(
|
response = smilegateSelectAiService.generateAndExecute(token, arguments.path("prompt").asText(""));
|
||||||
token,
|
|
||||||
arguments.path("prompt").asText(""),
|
|
||||||
normalizeLimit(arguments.path("limit").asInt(50))
|
|
||||||
);
|
|
||||||
} catch (VpdTokenAccessDeniedException ignored) {
|
} catch (VpdTokenAccessDeniedException ignored) {
|
||||||
return tokenAccessDeniedResult();
|
return tokenAccessDeniedResult();
|
||||||
}
|
}
|
||||||
@@ -181,13 +169,6 @@ public class McpSseService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int normalizeLimit(int limit) {
|
|
||||||
if (limit < 1) {
|
|
||||||
return 50;
|
|
||||||
}
|
|
||||||
return Math.min(limit, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String pretty(Object value) {
|
private String pretty(Object value) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
||||||
|
|||||||
@@ -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
|
// object handlers always select every registered protected column, so
|
||||||
// a sensitive column missing from every JSON row is also a NULL result
|
// a sensitive column missing from every JSON row is also a NULL result
|
||||||
// and must be reported as masked to the MCP/UI caller.
|
// and must be reported as masked to the MCP/UI caller.
|
||||||
|
|||||||
@@ -1,33 +1,41 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.service;
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||||
|
import com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import org.springframework.dao.DataAccessException;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen-level orchestration for schema metadata. Database reads and DDL are
|
||||||
|
* intentionally delegated only to {@link SchemaMetadataMapper}.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class SchemaMetadataService {
|
public class SchemaMetadataService {
|
||||||
|
|
||||||
private static final String OWNER = "POC_2";
|
private static final String OWNER = "SGMP_POC";
|
||||||
private static final int MAX_COMMENT_LENGTH = 4000;
|
private static final int MAX_COMMENT_LENGTH = 4000;
|
||||||
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
|
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
|
||||||
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final SchemaMetadataMapper mapper;
|
||||||
private final StructuredDataService structuredDataService;
|
private final StructuredDataService structuredDataService;
|
||||||
|
|
||||||
public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) {
|
public SchemaMetadataService(
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
SchemaMetadataMapper mapper,
|
||||||
|
StructuredDataService structuredDataService
|
||||||
|
) {
|
||||||
|
this.mapper = mapper;
|
||||||
this.structuredDataService = structuredDataService;
|
this.structuredDataService = structuredDataService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,13 +49,12 @@ public class SchemaMetadataService {
|
|||||||
|
|
||||||
public SchemaMetadataView find(String tableKey) {
|
public SchemaMetadataView find(String tableKey) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
String tableName = table.tableName();
|
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||||
String tableComment = tableComment(tableName);
|
|
||||||
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
|
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
|
||||||
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
|
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
|
||||||
return new SchemaMetadataView(
|
return new SchemaMetadataView(
|
||||||
table,
|
table,
|
||||||
nullToEmpty(tableComment),
|
nullToEmpty(mapper.findTableComment(OWNER, tableName)),
|
||||||
annotations.getOrDefault(tableTargetKey(), List.of()),
|
annotations.getOrDefault(tableTargetKey(), List.of()),
|
||||||
columns
|
columns
|
||||||
);
|
);
|
||||||
@@ -56,24 +63,24 @@ public class SchemaMetadataService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void updateTableComment(String tableKey, String comment) {
|
public void updateTableComment(String tableKey, String comment) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
|
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
|
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
|
||||||
jdbcTemplate.execute("COMMENT ON TABLE " + qualifiedTable(table.tableName())
|
mapper.updateTableComment(OWNER, tableName, quoteLiteral(normalizedComment));
|
||||||
+ " IS " + quoteLiteral(normalizedComment));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateColumnComment(String tableKey, String columnName, String comment) {
|
public void updateColumnComment(String tableKey, String columnName, String comment) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
String column = requireColumn(table.tableName(), columnName);
|
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||||
|
String column = requireColumn(tableName, columnName);
|
||||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
|
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
|
||||||
jdbcTemplate.execute("COMMENT ON COLUMN " + qualifiedTable(table.tableName()) + "."
|
mapper.updateColumnComment(OWNER, tableName, column, quoteLiteral(normalizedComment));
|
||||||
+ quoteName(column) + " IS " + quoteLiteral(normalizedComment));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
|
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
updateAnnotation(table.tableName(), null, annotationName, annotationValue);
|
updateAnnotation(requireSimpleName(table.tableName(), "table name"), null, annotationName, annotationValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -84,8 +91,9 @@ public class SchemaMetadataService {
|
|||||||
String annotationValue
|
String annotationValue
|
||||||
) {
|
) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
String column = requireColumn(table.tableName(), columnName);
|
String tableName = requireSimpleName(table.tableName(), "table name");
|
||||||
updateAnnotation(table.tableName(), column, annotationName, annotationValue);
|
String column = requireColumn(tableName, columnName);
|
||||||
|
updateAnnotation(tableName, column, annotationName, annotationValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateAnnotation(
|
private void updateAnnotation(
|
||||||
@@ -97,75 +105,55 @@ public class SchemaMetadataService {
|
|||||||
String key = requireSimpleName(annotationName, "annotation name");
|
String key = requireSimpleName(annotationName, "annotation name");
|
||||||
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
|
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
|
||||||
if (annotationExists(tableName, columnName, key)) {
|
if (annotationExists(tableName, columnName, key)) {
|
||||||
jdbcTemplate.execute(annotationSql(tableName, columnName, "DROP " + quoteName(key)));
|
if (columnName == null) {
|
||||||
|
mapper.dropTableAnnotation(OWNER, tableName, key);
|
||||||
|
} else {
|
||||||
|
mapper.dropColumnAnnotation(OWNER, tableName, columnName, key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!value.isBlank()) {
|
if (!value.isBlank()) {
|
||||||
jdbcTemplate.execute(annotationSql(tableName, columnName,
|
if (columnName == null) {
|
||||||
"ADD " + quoteName(key) + " " + quoteLiteral(value)));
|
mapper.addTableAnnotation(OWNER, tableName, key, quoteLiteral(value));
|
||||||
|
} else {
|
||||||
|
mapper.addColumnAnnotation(OWNER, tableName, columnName, key, quoteLiteral(value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String tableComment(String tableName) {
|
|
||||||
List<String> values = jdbcTemplate.query("""
|
|
||||||
SELECT comments
|
|
||||||
FROM all_tab_comments
|
|
||||||
WHERE owner = ?
|
|
||||||
AND table_name = ?
|
|
||||||
""", (rs, rowNum) -> rs.getString(1), OWNER, tableName);
|
|
||||||
return values.isEmpty() ? "" : values.getFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<SchemaMetadataColumn> columns(
|
private List<SchemaMetadataColumn> columns(
|
||||||
String tableName,
|
String tableName,
|
||||||
Map<String, List<SchemaAnnotation>> annotations
|
Map<String, List<SchemaAnnotation>> annotations
|
||||||
) {
|
) {
|
||||||
return jdbcTemplate.query("""
|
return mapper.findColumns(OWNER, tableName).stream()
|
||||||
SELECT c.column_name,
|
.map(row -> toColumn(row, annotations))
|
||||||
CASE
|
.toList();
|
||||||
WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR')
|
}
|
||||||
THEN c.data_type || '(' || c.char_length || ')'
|
|
||||||
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL
|
private SchemaMetadataColumn toColumn(
|
||||||
THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')'
|
SchemaMetadataColumnRow row,
|
||||||
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL
|
Map<String, List<SchemaAnnotation>> annotations
|
||||||
THEN c.data_type || '(' || c.data_precision || ')'
|
) {
|
||||||
ELSE c.data_type
|
String columnName = requireSimpleName(row.columnName(), "column name");
|
||||||
END AS display_type,
|
return new SchemaMetadataColumn(
|
||||||
c.nullable,
|
columnName,
|
||||||
cc.comments
|
row.dataType(),
|
||||||
FROM all_tab_columns c
|
"Y".equalsIgnoreCase(row.nullable()),
|
||||||
LEFT JOIN all_col_comments cc
|
nullToEmpty(row.comment()),
|
||||||
ON cc.owner = c.owner
|
annotations.getOrDefault(columnTargetKey(columnName), List.of())
|
||||||
AND cc.table_name = c.table_name
|
);
|
||||||
AND cc.column_name = c.column_name
|
|
||||||
WHERE c.owner = ?
|
|
||||||
AND c.table_name = ?
|
|
||||||
ORDER BY c.column_id
|
|
||||||
""", (rs, rowNum) -> new SchemaMetadataColumn(
|
|
||||||
rs.getString("column_name"),
|
|
||||||
rs.getString("display_type"),
|
|
||||||
"Y".equalsIgnoreCase(rs.getString("nullable")),
|
|
||||||
nullToEmpty(rs.getString("comments")),
|
|
||||||
annotations.getOrDefault(columnTargetKey(rs.getString("column_name")), List.of())
|
|
||||||
), OWNER, tableName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
|
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
|
||||||
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
|
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
|
||||||
jdbcTemplate.query("""
|
for (SchemaMetadataAnnotationRow row : mapper.findAnnotations(tableName)) {
|
||||||
SELECT column_name, annotation_name, annotation_value
|
String target = row.columnName() == null
|
||||||
FROM all_annotations_usage
|
? tableTargetKey()
|
||||||
WHERE object_name = ?
|
: columnTargetKey(row.columnName());
|
||||||
AND object_type = 'TABLE'
|
grouped
|
||||||
ORDER BY column_name NULLS FIRST, annotation_name, annotation_value
|
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
|
||||||
""", rs -> {
|
.computeIfAbsent(row.annotationName(), ignored -> new ArrayList<>())
|
||||||
String target = rs.getString("column_name") == null
|
.add(nullToEmpty(row.annotationValue()));
|
||||||
? tableTargetKey()
|
}
|
||||||
: columnTargetKey(rs.getString("column_name"));
|
|
||||||
grouped
|
|
||||||
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
|
|
||||||
.computeIfAbsent(rs.getString("annotation_name"), ignored -> new ArrayList<>())
|
|
||||||
.add(nullToEmpty(rs.getString("annotation_value")));
|
|
||||||
}, tableName);
|
|
||||||
|
|
||||||
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
|
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
|
||||||
grouped.forEach((target, valuesByName) -> {
|
grouped.forEach((target, valuesByName) -> {
|
||||||
@@ -180,44 +168,14 @@ public class SchemaMetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean annotationExists(String tableName, String columnName, String annotationName) {
|
private boolean annotationExists(String tableName, String columnName, String annotationName) {
|
||||||
Integer count = columnName == null
|
return columnName == null
|
||||||
? jdbcTemplate.queryForObject("""
|
? mapper.countTableAnnotation(tableName, annotationName) > 0
|
||||||
SELECT COUNT(*)
|
: mapper.countColumnAnnotation(tableName, columnName, annotationName) > 0;
|
||||||
FROM all_annotations_usage
|
|
||||||
WHERE object_name = ?
|
|
||||||
AND object_type = 'TABLE'
|
|
||||||
AND annotation_name = ?
|
|
||||||
AND column_name IS NULL
|
|
||||||
""", Integer.class, tableName, annotationName)
|
|
||||||
: jdbcTemplate.queryForObject("""
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM all_annotations_usage
|
|
||||||
WHERE object_name = ?
|
|
||||||
AND object_type = 'TABLE'
|
|
||||||
AND annotation_name = ?
|
|
||||||
AND column_name = ?
|
|
||||||
""", Integer.class, tableName, annotationName, columnName);
|
|
||||||
return count != null && count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String annotationSql(String tableName, String columnName, String operation) {
|
|
||||||
if (columnName == null) {
|
|
||||||
return "ALTER TABLE " + qualifiedTable(tableName) + " ANNOTATIONS (" + operation + ")";
|
|
||||||
}
|
|
||||||
return "ALTER TABLE " + qualifiedTable(tableName) + " MODIFY " + quoteName(columnName)
|
|
||||||
+ " ANNOTATIONS (" + operation + ")";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String requireColumn(String tableName, String columnName) {
|
private String requireColumn(String tableName, String columnName) {
|
||||||
String column = requireSimpleName(columnName, "column name");
|
String column = requireSimpleName(columnName, "column name");
|
||||||
Integer count = jdbcTemplate.queryForObject("""
|
if (mapper.countColumn(OWNER, tableName, column) == 0) {
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM all_tab_columns
|
|
||||||
WHERE owner = ?
|
|
||||||
AND table_name = ?
|
|
||||||
AND column_name = ?
|
|
||||||
""", Integer.class, OWNER, tableName, column);
|
|
||||||
if (count == null || count == 0) {
|
|
||||||
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
|
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
|
||||||
}
|
}
|
||||||
return column;
|
return column;
|
||||||
@@ -242,14 +200,6 @@ public class SchemaMetadataService {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String qualifiedTable(String tableName) {
|
|
||||||
return quoteName(OWNER) + "." + quoteName(requireSimpleName(tableName, "table name"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String quoteName(String value) {
|
|
||||||
return "\"" + value.replace("\"", "\"\"") + "\"";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String quoteLiteral(String value) {
|
private String quoteLiteral(String value) {
|
||||||
return "'" + value.replace("'", "''") + "'";
|
return "'" + value.replace("'", "''") + "'";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,39 +19,18 @@ public class SecuritySqlScriptService {
|
|||||||
|
|
||||||
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
|
private static final List<ScriptDefinition> CURATED_SCRIPTS = List.of(
|
||||||
new ScriptDefinition(
|
new ScriptDefinition(
|
||||||
"aso-masking-metadata",
|
"smilegate-tool-users",
|
||||||
"ASO / 마스킹",
|
"Smilegate 사용자",
|
||||||
"62_kb_aso_masking_backoffice_metadata.sql",
|
"70_sg_tool_user.sql",
|
||||||
"컬럼 마스킹 규칙 메타데이터",
|
"PoC 도구 사용자 초기 데이터",
|
||||||
"ASO 컬럼 마스킹 규칙·컬럼 연결·사용자 예외를 관리하는 백오피스 메타데이터를 생성합니다."
|
"Data & AI TF 팀장·팀원 데모 사용자와 역할을 생성합니다. 게임 서비스 사용자가 아닌 PoC 도구 운영 사용자입니다."
|
||||||
),
|
),
|
||||||
new ScriptDefinition(
|
new ScriptDefinition(
|
||||||
"aso-masking-runtime",
|
"smilegate-identity-administration",
|
||||||
"ASO / 마스킹",
|
"Smilegate 권한",
|
||||||
"63_kb_aso_masking_rule_runtime.sql",
|
"71_sg_identity_administration.sql",
|
||||||
"ASO 마스킹 런타임 적용",
|
"사용자·그룹·역할 관리 모델",
|
||||||
"백오피스 컬럼 마스킹 규칙을 Oracle Data Redaction 정책과 신뢰 컨텍스트에 반영합니다."
|
"Smilegate PoC 운영 사용자, 그룹, 역할, 권한 메타데이터와 백오피스 호환 뷰를 생성합니다."
|
||||||
),
|
|
||||||
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를 노출합니다."
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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<String> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.ResultSetMetaData;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates and executes bounded read-only SQL through the schema-owned Smilegate Select AI profile.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SmilegateSelectAiService {
|
||||||
|
|
||||||
|
private static final int MAX_PROMPT_LENGTH = 4_000;
|
||||||
|
private static final int MAX_RESULT_ROWS = 100;
|
||||||
|
private static final int QUERY_TIMEOUT_SECONDS = 30;
|
||||||
|
private static final Pattern UNSAFE_SQL = Pattern.compile(
|
||||||
|
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
||||||
|
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|"
|
||||||
|
+ "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b"
|
||||||
|
);
|
||||||
|
|
||||||
|
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 generateAndExecute(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);
|
||||||
|
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
|
||||||
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
|
response.put("status", "SHOWSQL_AND_EXECUTED");
|
||||||
|
response.put("profile", selectAi.profile());
|
||||||
|
response.put("generatedSql", normalizedSql);
|
||||||
|
response.put("execution", "READ_ONLY_EXECUTED");
|
||||||
|
response.put("rowCount", execution.items().size());
|
||||||
|
response.put("truncated", execution.truncated());
|
||||||
|
response.set("items", execution.items());
|
||||||
|
response.put("nextStep", execution.truncated()
|
||||||
|
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다. 생성 SQL로 전체 결과를 확인할 수 있습니다."
|
||||||
|
: "생성 SQL을 읽기 전용으로 실행한 결과입니다.");
|
||||||
|
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 QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) {
|
||||||
|
ArrayNode items = objectMapper.createArrayNode();
|
||||||
|
boolean truncated = false;
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
||||||
|
Statement transaction = connection.createStatement()) {
|
||||||
|
connection.setAutoCommit(false);
|
||||||
|
connection.setReadOnly(true);
|
||||||
|
transaction.execute("SET TRANSACTION READ ONLY");
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(generatedSql)) {
|
||||||
|
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||||
|
statement.setFetchSize(MAX_RESULT_ROWS + 1);
|
||||||
|
statement.setMaxRows(MAX_RESULT_ROWS + 1);
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
ResultSetMetaData metadata = resultSet.getMetaData();
|
||||||
|
while (resultSet.next()) {
|
||||||
|
if (items.size() >= MAX_RESULT_ROWS) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ObjectNode row = items.addObject();
|
||||||
|
for (int columnIndex = 1; columnIndex <= metadata.getColumnCount(); columnIndex++) {
|
||||||
|
String column = metadata.getColumnLabel(columnIndex);
|
||||||
|
if (column == null || column.isBlank()) {
|
||||||
|
column = metadata.getColumnName(columnIndex);
|
||||||
|
}
|
||||||
|
putResultValue(row, column, resultSet.getObject(columnIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection.rollback();
|
||||||
|
}
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AppException("Smilegate Select AI 생성 SQL 실행 실패: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
return new QueryExecution(items, truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void putResultValue(ObjectNode row, String column, Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
row.putNull(column);
|
||||||
|
} else if (value instanceof BigDecimal number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof BigInteger number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Integer number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Long number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Short number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Float number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Double number) {
|
||||||
|
row.put(column, number);
|
||||||
|
} else if (value instanceof Boolean bool) {
|
||||||
|
row.put(column, bool);
|
||||||
|
} else {
|
||||||
|
row.put(column, String.valueOf(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 문장이 포함되어 있어 반환하지 않습니다.");
|
||||||
|
}
|
||||||
|
if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|
||||||
|
|| UNSAFE_SQL.matcher(normalized).find()) {
|
||||||
|
throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record QueryExecution(ArrayNode items, boolean truncated) {}
|
||||||
|
}
|
||||||
@@ -11,16 +11,16 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class StructuredDataService {
|
public class StructuredDataService {
|
||||||
|
|
||||||
private static final String OWNER = "POC_2";
|
private static final String OWNER = "SGMP_POC";
|
||||||
private static final int ROW_LIMIT = 50;
|
private static final int ROW_LIMIT = 50;
|
||||||
private static final List<StructuredDataTable> TABLES = List.of(
|
private static final List<StructuredDataTable> TABLES = List.of(
|
||||||
new StructuredDataTable("customers", "KB_CUSTOMERS", "고객원장", "고객 기본정보"),
|
new StructuredDataTable("game-users", "CZN_COMN_USER_MST", "게임 사용자", "카제나 게임 사용자 마스터"),
|
||||||
new StructuredDataTable("products", "KB_PRODUCTS", "상품원장", "보험상품 마스터"),
|
new StructuredDataTable("characters", "CZN_COMN_CHARACTER_MST", "캐릭터", "카제나 캐릭터 마스터"),
|
||||||
new StructuredDataTable("contracts", "KB_CONTRACTS", "계약원장", "보험계약 정보"),
|
new StructuredDataTable("sales", "COMN_SALES_TXN", "판매 거래", "게임 상품 판매 거래"),
|
||||||
new StructuredDataTable("coverages", "KB_COVERAGES", "담보원장", "보장·특약 정보"),
|
new StructuredDataTable("refunds", "COMN_REFUND_TXN", "환불 거래", "게임 상품 환불 거래"),
|
||||||
new StructuredDataTable("claims", "KB_CLAIMS", "청구원장", "보험금 청구·지급 정보"),
|
new StructuredDataTable("products", "COMN_SALES_PRODUCT_DISP_BAS", "상품", "판매 상품 전시 기준"),
|
||||||
new StructuredDataTable("external-holdings", "KB_EXTERNAL_HOLDINGS", "외부보유정보 원장", "타사·외부 가입·보유 정보"),
|
new StructuredDataTable("game-servers", "COMN_GAME_SERVER_BAS", "게임 서버", "게임 서버 기준 정보"),
|
||||||
new StructuredDataTable("stakeholders", "KB_STAKEHOLDERS", "이해관계자 원장", "역할·담당자 매핑"));
|
new StructuredDataTable("game-aliases", "COMN_GAME_ALIAS_BAS", "게임 별칭", "게임명·별칭·prefix 매핑"));
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ public class StructuredDataService {
|
|||||||
previewSql(table), ROW_LIMIT);
|
previewSql(table), ROW_LIMIT);
|
||||||
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
|
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
|
||||||
} catch (DataAccessException exception) {
|
} catch (DataAccessException exception) {
|
||||||
throw new AppException("정형 데이터를 조회할 수 없습니다. POC_2 조회 권한과 대상 테이블 상태를 확인하세요.");
|
throw new AppException("게임 데이터를 조회할 수 없습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,13 +73,13 @@ public class StructuredDataService {
|
|||||||
*/
|
*/
|
||||||
private String previewSql(StructuredDataTable table) {
|
private String previewSql(StructuredDataTable table) {
|
||||||
return switch (table.key()) {
|
return switch (table.key()) {
|
||||||
case "customers" -> "SELECT * FROM POC_2.KB_CUSTOMERS WHERE ROWNUM <= ?";
|
case "game-users" -> "SELECT * FROM SGMP_POC.CZN_COMN_USER_MST WHERE ROWNUM <= ?";
|
||||||
case "products" -> "SELECT * FROM POC_2.KB_PRODUCTS WHERE ROWNUM <= ?";
|
case "characters" -> "SELECT * FROM SGMP_POC.CZN_COMN_CHARACTER_MST WHERE ROWNUM <= ?";
|
||||||
case "contracts" -> "SELECT * FROM POC_2.KB_CONTRACTS WHERE ROWNUM <= ?";
|
case "sales" -> "SELECT * FROM SGMP_POC.COMN_SALES_TXN WHERE ROWNUM <= ?";
|
||||||
case "coverages" -> "SELECT * FROM POC_2.KB_COVERAGES WHERE ROWNUM <= ?";
|
case "refunds" -> "SELECT * FROM SGMP_POC.COMN_REFUND_TXN WHERE ROWNUM <= ?";
|
||||||
case "claims" -> "SELECT * FROM POC_2.KB_CLAIMS WHERE ROWNUM <= ?";
|
case "products" -> "SELECT * FROM SGMP_POC.COMN_SALES_PRODUCT_DISP_BAS WHERE ROWNUM <= ?";
|
||||||
case "external-holdings" -> "SELECT * FROM POC_2.KB_EXTERNAL_HOLDINGS WHERE ROWNUM <= ?";
|
case "game-servers" -> "SELECT * FROM SGMP_POC.COMN_GAME_SERVER_BAS WHERE ROWNUM <= ?";
|
||||||
case "stakeholders" -> "SELECT * FROM POC_2.KB_STAKEHOLDERS WHERE ROWNUM <= ?";
|
case "game-aliases" -> "SELECT * FROM SGMP_POC.COMN_GAME_ALIAS_BAS WHERE ROWNUM <= ?";
|
||||||
default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다.");
|
default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다.");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ public class VectorKnowledgeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public VectorKnowledgeSummary summary() {
|
public VectorKnowledgeSummary summary() {
|
||||||
int documents = count("SELECT COUNT(DISTINCT document_id) FROM cb_vector_document_chunk");
|
int documents = count("SELECT COUNT(DISTINCT document_id) FROM sg_vector_document_chunk");
|
||||||
int chunks = count("SELECT COUNT(*) FROM cb_vector_document_chunk");
|
int chunks = count("SELECT COUNT(*) FROM sg_vector_document_chunk");
|
||||||
int tags = count("SELECT COUNT(*) FROM cb_vector_document_tag");
|
int tags = count("SELECT COUNT(*) FROM sg_vector_document_tag");
|
||||||
boolean registered = protectedObjectService.findEnabled().stream()
|
boolean registered = protectedObjectService.findEnabled().stream()
|
||||||
.anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName()));
|
.anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName()));
|
||||||
return new VectorKnowledgeSummary(
|
return new VectorKnowledgeSummary(
|
||||||
@@ -107,13 +107,13 @@ public class VectorKnowledgeService {
|
|||||||
String embeddingJson = json(embedding);
|
String embeddingJson = json(embedding);
|
||||||
long chunkId = nextChunkId++;
|
long chunkId = nextChunkId++;
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO cb_vector_document_chunk
|
INSERT INTO sg_vector_document_chunk
|
||||||
(chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
|
(chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?))
|
VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?))
|
||||||
""", chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson);
|
""", chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson);
|
||||||
for (String tag : tags) {
|
for (String tag : tags) {
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO cb_vector_document_tag (chunk_id, tech_tag)
|
INSERT INTO sg_vector_document_tag (chunk_id, tech_tag)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?)
|
||||||
""", chunkId, tag);
|
""", chunkId, tag);
|
||||||
tagCount++;
|
tagCount++;
|
||||||
@@ -143,18 +143,18 @@ public class VectorKnowledgeService {
|
|||||||
|
|
||||||
private void replaceDocument(String documentId) {
|
private void replaceDocument(String documentId) {
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
DELETE FROM cb_vector_document_tag
|
DELETE FROM sg_vector_document_tag
|
||||||
WHERE chunk_id IN (
|
WHERE chunk_id IN (
|
||||||
SELECT chunk_id FROM cb_vector_document_chunk WHERE document_id = ?
|
SELECT chunk_id FROM sg_vector_document_chunk WHERE document_id = ?
|
||||||
)
|
)
|
||||||
""", documentId);
|
""", documentId);
|
||||||
jdbcTemplate.update("DELETE FROM cb_vector_document_chunk WHERE document_id = ?", documentId);
|
jdbcTemplate.update("DELETE FROM sg_vector_document_chunk WHERE document_id = ?", documentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private long nextChunkId(int chunkCount) {
|
private long nextChunkId(int chunkCount) {
|
||||||
try {
|
try {
|
||||||
Long sequenceValue = jdbcTemplate.queryForObject(
|
Long sequenceValue = jdbcTemplate.queryForObject(
|
||||||
"SELECT cb_vector_chunk_seq.NEXTVAL FROM dual", Long.class);
|
"SELECT sg_vector_chunk_seq.NEXTVAL FROM dual", Long.class);
|
||||||
if (sequenceValue != null) {
|
if (sequenceValue != null) {
|
||||||
return sequenceValue;
|
return sequenceValue;
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ public class VectorKnowledgeService {
|
|||||||
// fallback keeps the demonstration usable; the setup SQL creates it.
|
// fallback keeps the demonstration usable; the setup SQL creates it.
|
||||||
}
|
}
|
||||||
Long max = jdbcTemplate.queryForObject(
|
Long max = jdbcTemplate.queryForObject(
|
||||||
"SELECT NVL(MAX(chunk_id), 28000) FROM cb_vector_document_chunk", Long.class);
|
"SELECT NVL(MAX(chunk_id), 28000) FROM sg_vector_document_chunk", Long.class);
|
||||||
return (max == null ? 28000 : max) + 1;
|
return (max == null ? 28000 : max) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.cloudhandson.vpdbackoffice.web;
|
|||||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||||
import com.cloudhandson.vpdbackoffice.service.SchemaMetadataService;
|
import com.cloudhandson.vpdbackoffice.service.SchemaMetadataService;
|
||||||
import org.springframework.dao.DataAccessException;
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -13,6 +15,8 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
|||||||
@Controller
|
@Controller
|
||||||
public class SchemaMetadataController {
|
public class SchemaMetadataController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SchemaMetadataController.class);
|
||||||
|
|
||||||
private final SchemaMetadataService schemaMetadataService;
|
private final SchemaMetadataService schemaMetadataService;
|
||||||
|
|
||||||
public SchemaMetadataController(SchemaMetadataService schemaMetadataService) {
|
public SchemaMetadataController(SchemaMetadataService schemaMetadataService) {
|
||||||
@@ -27,7 +31,8 @@ public class SchemaMetadataController {
|
|||||||
try {
|
try {
|
||||||
model.addAttribute("metadata", schemaMetadataService.find(selectedKey));
|
model.addAttribute("metadata", schemaMetadataService.find(selectedKey));
|
||||||
} catch (AppException | DataAccessException exception) {
|
} catch (AppException | DataAccessException exception) {
|
||||||
model.addAttribute("errorMessage", safeMessage(exception));
|
log.warn("Schema metadata lookup failed for table key={}", selectedKey, exception);
|
||||||
|
model.addAttribute("errorMessage", readMessage(exception));
|
||||||
}
|
}
|
||||||
return "schema-metadata";
|
return "schema-metadata";
|
||||||
}
|
}
|
||||||
@@ -105,4 +110,10 @@ public class SchemaMetadataController {
|
|||||||
? exception.getMessage()
|
? exception.getMessage()
|
||||||
: "DB 메타데이터를 저장하지 못했습니다. 권한, 식별자, Oracle annotation 문법을 확인하세요.";
|
: "DB 메타데이터를 저장하지 못했습니다. 권한, 식별자, Oracle annotation 문법을 확인하세요.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String readMessage(Exception exception) {
|
||||||
|
return exception instanceof AppException
|
||||||
|
? exception.getMessage()
|
||||||
|
: "DB 메타데이터를 조회하지 못했습니다. SGMP_POC 조회 권한과 대상 테이블 상태를 확인하세요.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,3 +68,10 @@ backoffice:
|
|||||||
oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}}
|
oci-profile: ${BACKOFFICE_AI_OCI_PROFILE:${OCI_PROFILE:DEFAULT}}
|
||||||
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
|
oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
|
||||||
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
|
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}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper">
|
||||||
<insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent">
|
<insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent">
|
||||||
INSERT INTO hmm_access_audit (
|
INSERT INTO sg_audit_event (
|
||||||
event_type, key_id, object_id, status, row_count, error_code, message, created_at
|
audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
|
sg_audit_event_seq.NEXTVAL,
|
||||||
#{eventType,jdbcType=VARCHAR},
|
#{eventType,jdbcType=VARCHAR},
|
||||||
#{keyId,jdbcType=NUMERIC},
|
#{keyId,jdbcType=NUMERIC},
|
||||||
#{objectId,jdbcType=NUMERIC},
|
#{objectId,jdbcType=NUMERIC},
|
||||||
|
|||||||
@@ -3,47 +3,44 @@
|
|||||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper">
|
||||||
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
||||||
SELECT k.key_id, k.employee_id AS user_id, u.full_name AS username,
|
SELECT k.key_id, k.user_id, u.user_name AS username,
|
||||||
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
|
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
|
||||||
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
|
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
|
||||||
k.key_prefix, k.key_hash,
|
k.key_prefix, k.key_hash,
|
||||||
k.expires_at, k.revoked_at, k.description
|
k.expires_at, k.revoked_at, k.description
|
||||||
FROM hmm_access_bearer_tokens k
|
FROM sg_access_bearer_token k JOIN sg_app_user u ON u.user_id = k.user_id
|
||||||
JOIN hmm_hr_employees u ON u.employee_id = k.employee_id
|
|
||||||
WHERE #{includeInactive} = 1
|
WHERE #{includeInactive} = 1
|
||||||
OR (k.revoked_at IS NULL AND k.expires_at > SYSTIMESTAMP)
|
OR (k.revoked_at IS NULL AND k.expires_at > SYSTIMESTAMP)
|
||||||
ORDER BY k.key_id DESC
|
ORDER BY k.key_id DESC
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
||||||
SELECT k.key_id, k.employee_id AS user_id, u.full_name AS username,
|
SELECT k.key_id, k.user_id, u.user_name AS username,
|
||||||
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
|
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
|
||||||
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
|
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
|
||||||
k.key_prefix, k.key_hash,
|
k.key_prefix, k.key_hash,
|
||||||
k.expires_at, k.revoked_at, k.description
|
k.expires_at, k.revoked_at, k.description
|
||||||
FROM hmm_access_bearer_tokens k
|
FROM sg_access_bearer_token k JOIN sg_app_user u ON u.user_id = k.user_id
|
||||||
JOIN hmm_hr_employees u ON u.employee_id = k.employee_id
|
|
||||||
WHERE k.key_id = #{keyId}
|
WHERE k.key_id = #{keyId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findByHash" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
<select id="findByHash" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
||||||
SELECT k.key_id, k.employee_id AS user_id, u.full_name AS username,
|
SELECT k.key_id, k.user_id, u.user_name AS username,
|
||||||
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
|
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
|
||||||
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
|
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) AS stakeholder_channel,
|
||||||
k.key_prefix, k.key_hash,
|
k.key_prefix, k.key_hash,
|
||||||
k.expires_at, k.revoked_at, k.description
|
k.expires_at, k.revoked_at, k.description
|
||||||
FROM hmm_access_bearer_tokens k
|
FROM sg_access_bearer_token k JOIN sg_app_user u ON u.user_id = k.user_id
|
||||||
JOIN hmm_hr_employees u ON u.employee_id = k.employee_id
|
|
||||||
WHERE k.key_hash = #{keyHash,jdbcType=VARCHAR}
|
WHERE k.key_hash = #{keyHash,jdbcType=VARCHAR}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="nextKeyId" resultType="long">
|
<select id="nextKeyId" resultType="long">
|
||||||
SELECT NVL(MAX(key_id), 0) + 1 FROM hmm_access_bearer_tokens
|
SELECT NVL(MAX(key_id), 0) + 1 FROM sg_access_bearer_token
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insertToken" parameterType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
<insert id="insertToken" parameterType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
||||||
INSERT INTO hmm_access_bearer_tokens (
|
INSERT INTO sg_access_bearer_token (
|
||||||
key_id, employee_id, key_prefix, key_hash, expires_at, revoked_at, description
|
key_id, user_id, key_prefix, key_hash, expires_at, revoked_at, description
|
||||||
) VALUES (
|
) VALUES (
|
||||||
#{keyId,jdbcType=NUMERIC},
|
#{keyId,jdbcType=NUMERIC},
|
||||||
#{userId,jdbcType=NUMERIC},
|
#{userId,jdbcType=NUMERIC},
|
||||||
@@ -56,7 +53,7 @@
|
|||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="revokeToken">
|
<update id="revokeToken">
|
||||||
UPDATE hmm_access_bearer_tokens
|
UPDATE sg_access_bearer_token
|
||||||
SET revoked_at = #{revokedAt,jdbcType=TIMESTAMP},
|
SET revoked_at = #{revokedAt,jdbcType=TIMESTAMP},
|
||||||
description = CASE
|
description = CASE
|
||||||
WHEN #{reason,jdbcType=VARCHAR} IS NULL THEN description
|
WHEN #{reason,jdbcType=VARCHAR} IS NULL THEN description
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
group_name,
|
group_name,
|
||||||
description,
|
description,
|
||||||
active_yn
|
active_yn
|
||||||
FROM hmm_access_groups
|
FROM sg_app_group
|
||||||
ORDER BY group_code
|
ORDER BY group_code
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -16,12 +16,12 @@
|
|||||||
SELECT g.group_id,
|
SELECT g.group_id,
|
||||||
g.group_code,
|
g.group_code,
|
||||||
g.group_name,
|
g.group_name,
|
||||||
u.employee_id AS user_id,
|
u.user_id,
|
||||||
u.full_name AS username
|
u.user_name AS username
|
||||||
FROM hmm_access_group_members ug
|
FROM sg_user_group ug
|
||||||
JOIN hmm_access_groups g ON g.group_id = ug.group_id
|
JOIN sg_app_group g ON g.group_id = ug.group_id
|
||||||
JOIN hmm_hr_employees u ON u.employee_id = ug.employee_id
|
JOIN sg_app_user u ON u.user_id = ug.user_id
|
||||||
ORDER BY g.group_code, u.full_name
|
ORDER BY g.group_code, u.user_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findGroupRoles" resultType="com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView">
|
<select id="findGroupRoles" resultType="com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView">
|
||||||
@@ -30,18 +30,18 @@
|
|||||||
g.group_name,
|
g.group_name,
|
||||||
r.role_id,
|
r.role_id,
|
||||||
r.role_name
|
r.role_name
|
||||||
FROM hmm_group_access_roles gr
|
FROM sg_group_role gr
|
||||||
JOIN hmm_access_groups g ON g.group_id = gr.group_id
|
JOIN sg_app_group g ON g.group_id = gr.group_id
|
||||||
JOIN hmm_access_roles r ON r.role_id = gr.role_id
|
JOIN sg_app_role r ON r.role_id = gr.role_id
|
||||||
ORDER BY g.group_code, r.role_name
|
ORDER BY g.group_code, r.role_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="nextGroupId" resultType="long">
|
<select id="nextGroupId" resultType="long">
|
||||||
SELECT NVL(MAX(group_id), 0) + 1 FROM hmm_access_groups
|
SELECT NVL(MAX(group_id), 0) + 1 FROM sg_app_group
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insertGroup">
|
<insert id="insertGroup">
|
||||||
INSERT INTO hmm_access_groups (
|
INSERT INTO sg_app_group (
|
||||||
group_id, group_code, group_name, description, active_yn
|
group_id, group_code, group_name, description, active_yn
|
||||||
) VALUES (
|
) VALUES (
|
||||||
#{groupId,jdbcType=NUMERIC},
|
#{groupId,jdbcType=NUMERIC},
|
||||||
@@ -53,29 +53,29 @@
|
|||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="updateActive">
|
<update id="updateActive">
|
||||||
UPDATE hmm_access_groups
|
UPDATE sg_app_group
|
||||||
SET active_yn = #{activeYn,jdbcType=VARCHAR}
|
SET active_yn = #{activeYn,jdbcType=VARCHAR}
|
||||||
WHERE group_id = #{groupId,jdbcType=NUMERIC}
|
WHERE group_id = #{groupId,jdbcType=NUMERIC}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<insert id="insertGroupUser">
|
<insert id="insertGroupUser">
|
||||||
INSERT INTO hmm_access_group_members (group_id, employee_id)
|
INSERT INTO sg_user_group (group_id, user_id)
|
||||||
VALUES (#{groupId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC})
|
VALUES (#{groupId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC})
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<delete id="deleteGroupUser">
|
<delete id="deleteGroupUser">
|
||||||
DELETE FROM hmm_access_group_members
|
DELETE FROM sg_user_group
|
||||||
WHERE group_id = #{groupId,jdbcType=NUMERIC}
|
WHERE group_id = #{groupId,jdbcType=NUMERIC}
|
||||||
AND employee_id = #{userId,jdbcType=NUMERIC}
|
AND user_id = #{userId,jdbcType=NUMERIC}
|
||||||
</delete>
|
</delete>
|
||||||
|
|
||||||
<insert id="insertGroupRole">
|
<insert id="insertGroupRole">
|
||||||
INSERT INTO hmm_group_access_roles (group_id, role_id)
|
INSERT INTO sg_group_role (group_id, role_id)
|
||||||
VALUES (#{groupId,jdbcType=NUMERIC}, #{roleId,jdbcType=NUMERIC})
|
VALUES (#{groupId,jdbcType=NUMERIC}, #{roleId,jdbcType=NUMERIC})
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<delete id="deleteGroupRole">
|
<delete id="deleteGroupRole">
|
||||||
DELETE FROM hmm_group_access_roles
|
DELETE FROM sg_group_role
|
||||||
WHERE group_id = #{groupId,jdbcType=NUMERIC}
|
WHERE group_id = #{groupId,jdbcType=NUMERIC}
|
||||||
AND role_id = #{roleId,jdbcType=NUMERIC}
|
AND role_id = #{roleId,jdbcType=NUMERIC}
|
||||||
</delete>
|
</delete>
|
||||||
|
|||||||
@@ -4,41 +4,41 @@
|
|||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper">
|
||||||
<select id="findAllRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
<select id="findAllRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
||||||
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
||||||
FROM cb_masking_rule
|
FROM sg_masking_rule
|
||||||
ORDER BY enabled_yn DESC, rule_name, rule_id
|
ORDER BY enabled_yn DESC, rule_name, rule_id
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findEnabledRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
<select id="findEnabledRules" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
||||||
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
||||||
FROM cb_masking_rule
|
FROM sg_masking_rule
|
||||||
WHERE enabled_yn = 'Y'
|
WHERE enabled_yn = 'Y'
|
||||||
ORDER BY rule_name, rule_id
|
ORDER BY rule_name, rule_id
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findRuleById" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
<select id="findRuleById" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
||||||
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
||||||
FROM cb_masking_rule
|
FROM sg_masking_rule
|
||||||
WHERE rule_id = #{ruleId}
|
WHERE rule_id = #{ruleId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findRuleByCode" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
<select id="findRuleByCode" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule">
|
||||||
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
SELECT rule_id, rule_code, rule_name, template_code, description, enabled_yn
|
||||||
FROM cb_masking_rule
|
FROM sg_masking_rule
|
||||||
WHERE rule_code = #{ruleCode}
|
WHERE rule_code = #{ruleCode}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="nextRuleId" resultType="long">
|
<select id="nextRuleId" resultType="long">
|
||||||
SELECT NVL(MAX(rule_id), 0) + 1 FROM cb_masking_rule
|
SELECT NVL(MAX(rule_id), 0) + 1 FROM sg_masking_rule
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insertRule">
|
<insert id="insertRule">
|
||||||
INSERT INTO cb_masking_rule (rule_id, rule_code, rule_name, template_code, description, enabled_yn)
|
INSERT INTO sg_masking_rule (rule_id, rule_code, rule_name, template_code, description, enabled_yn)
|
||||||
VALUES (#{ruleId}, #{command.ruleCode}, #{command.ruleName}, #{command.templateCode},
|
VALUES (#{ruleId}, #{command.ruleCode}, #{command.ruleName}, #{command.templateCode},
|
||||||
#{command.description}, 'Y')
|
#{command.description}, 'Y')
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="updateRuleActive">
|
<update id="updateRuleActive">
|
||||||
UPDATE cb_masking_rule
|
UPDATE sg_masking_rule
|
||||||
SET enabled_yn = #{enabledYn}
|
SET enabled_yn = #{enabledYn}
|
||||||
WHERE rule_id = #{ruleId}
|
WHERE rule_id = #{ruleId}
|
||||||
</update>
|
</update>
|
||||||
@@ -54,10 +54,10 @@
|
|||||||
r.rule_name,
|
r.rule_name,
|
||||||
r.template_code,
|
r.template_code,
|
||||||
r.enabled_yn AS rule_enabled_yn
|
r.enabled_yn AS rule_enabled_yn
|
||||||
FROM cb_column_masking_rule link
|
FROM sg_column_masking_rule link
|
||||||
JOIN cb_protected_column c ON c.column_id = link.column_id
|
JOIN sg_protected_column c ON c.column_id = link.column_id
|
||||||
JOIN cb_protected_object o ON o.object_id = c.object_id
|
JOIN sg_protected_object o ON o.object_id = c.object_id
|
||||||
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
|
JOIN sg_masking_rule r ON r.rule_id = link.rule_id
|
||||||
ORDER BY o.owner, o.object_name, c.column_name
|
ORDER BY o.owner, o.object_name, c.column_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -68,19 +68,19 @@
|
|||||||
-->
|
-->
|
||||||
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
|
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
|
||||||
WITH managed_policy AS (
|
WITH managed_policy AS (
|
||||||
SELECT 'KB_CUSTOMERS' AS object_name, 'KB_CUSTOMER_PII_REDACT' AS policy_name FROM dual
|
SELECT 'CZN_COMN_USER_MST' AS object_name, 'SG_CZN_USER_REDACT' AS policy_name FROM dual
|
||||||
UNION ALL SELECT 'KB_CLAIMS', 'KB_CLAIM_AMOUNT_REDACT' FROM dual
|
UNION ALL SELECT 'COMN_SALES_USER_MST', 'SG_SALES_USER_REDACT' FROM dual
|
||||||
UNION ALL SELECT 'KB_CONTRACTS', 'KB_CONTRACT_PREMIUM_REDACT' FROM dual
|
UNION ALL SELECT 'COMN_SALES_TXN', 'SG_SALES_TXN_REDACT' FROM dual
|
||||||
UNION ALL SELECT 'KB_EXTERNAL_HOLDINGS', 'KB_EXT_HOLDING_REDACT' FROM dual
|
UNION ALL SELECT 'COMN_REFUND_TXN', 'SG_REFUND_TXN_REDACT' FROM dual
|
||||||
),
|
),
|
||||||
configured AS (
|
configured AS (
|
||||||
SELECT protected_object.object_name,
|
SELECT protected_object.object_name,
|
||||||
COUNT(*) AS configured_column_count
|
COUNT(*) AS configured_column_count
|
||||||
FROM cb_column_masking_rule link
|
FROM sg_column_masking_rule link
|
||||||
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
|
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
|
||||||
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
|
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
|
||||||
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
||||||
WHERE protected_object.owner = 'POC_2'
|
WHERE protected_object.owner = 'SGMP_POC'
|
||||||
AND rule.enabled_yn = 'Y'
|
AND rule.enabled_yn = 'Y'
|
||||||
GROUP BY protected_object.object_name
|
GROUP BY protected_object.object_name
|
||||||
),
|
),
|
||||||
@@ -96,21 +96,21 @@
|
|||||||
LEFT JOIN redaction_columns policy_column
|
LEFT JOIN redaction_columns policy_column
|
||||||
ON policy_column.object_owner = policy.object_owner
|
ON policy_column.object_owner = policy.object_owner
|
||||||
AND policy_column.object_name = policy.object_name
|
AND policy_column.object_name = policy.object_name
|
||||||
WHERE policy.object_owner = 'POC_2'
|
WHERE policy.object_owner = 'SGMP_POC'
|
||||||
GROUP BY policy.object_name, policy.policy_name, policy.enable
|
GROUP BY policy.object_name, policy.policy_name, policy.enable
|
||||||
),
|
),
|
||||||
missing_columns AS (
|
missing_columns AS (
|
||||||
SELECT protected_object.object_name,
|
SELECT protected_object.object_name,
|
||||||
COUNT(*) AS missing_column_count
|
COUNT(*) AS missing_column_count
|
||||||
FROM cb_column_masking_rule link
|
FROM sg_column_masking_rule link
|
||||||
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
|
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
|
||||||
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
|
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
|
||||||
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
||||||
LEFT JOIN redaction_columns policy_column
|
LEFT JOIN redaction_columns policy_column
|
||||||
ON policy_column.object_owner = protected_object.owner
|
ON policy_column.object_owner = protected_object.owner
|
||||||
AND policy_column.object_name = protected_object.object_name
|
AND policy_column.object_name = protected_object.object_name
|
||||||
AND policy_column.column_name = protected_column.column_name
|
AND policy_column.column_name = protected_column.column_name
|
||||||
WHERE protected_object.owner = 'POC_2'
|
WHERE protected_object.owner = 'SGMP_POC'
|
||||||
AND rule.enabled_yn = 'Y'
|
AND rule.enabled_yn = 'Y'
|
||||||
AND policy_column.column_name IS NULL
|
AND policy_column.column_name IS NULL
|
||||||
GROUP BY protected_object.object_name
|
GROUP BY protected_object.object_name
|
||||||
@@ -120,14 +120,14 @@
|
|||||||
COUNT(*) AS extra_column_count
|
COUNT(*) AS extra_column_count
|
||||||
FROM redaction_columns policy_column
|
FROM redaction_columns policy_column
|
||||||
JOIN managed_policy managed ON managed.object_name = policy_column.object_name
|
JOIN managed_policy managed ON managed.object_name = policy_column.object_name
|
||||||
WHERE policy_column.object_owner = 'POC_2'
|
WHERE policy_column.object_owner = 'SGMP_POC'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM cb_column_masking_rule link
|
FROM sg_column_masking_rule link
|
||||||
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
|
JOIN sg_masking_rule rule ON rule.rule_id = link.rule_id
|
||||||
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
|
JOIN sg_protected_column protected_column ON protected_column.column_id = link.column_id
|
||||||
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
JOIN sg_protected_object protected_object ON protected_object.object_id = protected_column.object_id
|
||||||
WHERE protected_object.owner = 'POC_2'
|
WHERE protected_object.owner = 'SGMP_POC'
|
||||||
AND protected_object.object_name = policy_column.object_name
|
AND protected_object.object_name = policy_column.object_name
|
||||||
AND protected_column.column_name = policy_column.column_name
|
AND protected_column.column_name = policy_column.column_name
|
||||||
AND rule.enabled_yn = 'Y'
|
AND rule.enabled_yn = 'Y'
|
||||||
@@ -138,18 +138,15 @@
|
|||||||
SELECT policy.object_name,
|
SELECT policy.object_name,
|
||||||
COUNT(*) AS legacy_vpd_column_policy_count
|
COUNT(*) AS legacy_vpd_column_policy_count
|
||||||
FROM all_policies policy
|
FROM all_policies policy
|
||||||
WHERE policy.object_owner = 'POC_2'
|
WHERE policy.object_owner = 'SGMP_POC'
|
||||||
AND policy.enable = 'YES'
|
AND policy.enable = 'YES'
|
||||||
AND policy.policy_name IN (
|
AND policy.policy_name IN (
|
||||||
'KB_CUST_NM_CLS_POLICY', 'KB_RRN_CLS_POLICY',
|
'SG_CZN_USER_REDACT', 'SG_SALES_USER_REDACT',
|
||||||
'KB_PREMIUM_CLS_POLICY',
|
'SG_SALES_TXN_REDACT', 'SG_REFUND_TXN_REDACT'
|
||||||
'KB_CLAIM_AMT_CLS_POLICY', 'KB_PAID_AMT_CLS_POLICY',
|
|
||||||
'KB_EXT_INSURER_CLS_POLICY', 'KB_EXT_PRODUCT_GRP_CLS_POLICY',
|
|
||||||
'KB_EXT_PRODUCT_TYPE_CLS_POLICY'
|
|
||||||
)
|
)
|
||||||
GROUP BY policy.object_name
|
GROUP BY policy.object_name
|
||||||
)
|
)
|
||||||
SELECT 'POC_2' AS owner,
|
SELECT 'SGMP_POC' AS owner,
|
||||||
managed.object_name,
|
managed.object_name,
|
||||||
managed.policy_name,
|
managed.policy_name,
|
||||||
database_policy.enable AS enabled,
|
database_policy.enable AS enabled,
|
||||||
@@ -180,15 +177,15 @@
|
|||||||
r.rule_name,
|
r.rule_name,
|
||||||
r.template_code,
|
r.template_code,
|
||||||
r.enabled_yn AS rule_enabled_yn
|
r.enabled_yn AS rule_enabled_yn
|
||||||
FROM cb_column_masking_rule link
|
FROM sg_column_masking_rule link
|
||||||
JOIN cb_protected_column c ON c.column_id = link.column_id
|
JOIN sg_protected_column c ON c.column_id = link.column_id
|
||||||
JOIN cb_protected_object o ON o.object_id = c.object_id
|
JOIN sg_protected_object o ON o.object_id = c.object_id
|
||||||
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
|
JOIN sg_masking_rule r ON r.rule_id = link.rule_id
|
||||||
WHERE c.column_id = #{columnId}
|
WHERE c.column_id = #{columnId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="upsertColumnRule">
|
<insert id="upsertColumnRule">
|
||||||
MERGE INTO cb_column_masking_rule dst
|
MERGE INTO sg_column_masking_rule dst
|
||||||
USING (SELECT #{columnId} column_id, #{ruleId} rule_id FROM dual) src
|
USING (SELECT #{columnId} column_id, #{ruleId} rule_id FROM dual) src
|
||||||
ON (dst.column_id = src.column_id)
|
ON (dst.column_id = src.column_id)
|
||||||
WHEN MATCHED THEN UPDATE SET dst.rule_id = src.rule_id, dst.updated_at = SYSTIMESTAMP
|
WHEN MATCHED THEN UPDATE SET dst.rule_id = src.rule_id, dst.updated_at = SYSTIMESTAMP
|
||||||
@@ -197,12 +194,12 @@
|
|||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<delete id="deleteColumnRule">
|
<delete id="deleteColumnRule">
|
||||||
DELETE FROM cb_column_masking_rule
|
DELETE FROM sg_column_masking_rule
|
||||||
WHERE column_id = #{columnId}
|
WHERE column_id = #{columnId}
|
||||||
</delete>
|
</delete>
|
||||||
|
|
||||||
<delete id="deleteUserRulesForColumn">
|
<delete id="deleteUserRulesForColumn">
|
||||||
DELETE FROM cb_user_masking_rule
|
DELETE FROM sg_user_masking_rule
|
||||||
WHERE column_id = #{columnId}
|
WHERE column_id = #{columnId}
|
||||||
</delete>
|
</delete>
|
||||||
|
|
||||||
@@ -217,12 +214,12 @@
|
|||||||
r.template_code,
|
r.template_code,
|
||||||
assignment.decision,
|
assignment.decision,
|
||||||
assignment.active_yn
|
assignment.active_yn
|
||||||
FROM cb_user_masking_rule assignment
|
FROM sg_user_masking_rule assignment
|
||||||
JOIN cb_app_user u ON u.user_id = assignment.user_id
|
JOIN sg_app_user u ON u.user_id = assignment.user_id
|
||||||
JOIN cb_protected_column c ON c.column_id = assignment.column_id
|
JOIN sg_protected_column c ON c.column_id = assignment.column_id
|
||||||
JOIN cb_protected_object o ON o.object_id = c.object_id
|
JOIN sg_protected_object o ON o.object_id = c.object_id
|
||||||
JOIN cb_column_masking_rule link ON link.column_id = c.column_id
|
JOIN sg_column_masking_rule link ON link.column_id = c.column_id
|
||||||
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
|
JOIN sg_masking_rule r ON r.rule_id = link.rule_id
|
||||||
ORDER BY u.user_name, o.owner, o.object_name, c.column_name
|
ORDER BY u.user_name, o.owner, o.object_name, c.column_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -237,18 +234,18 @@
|
|||||||
r.template_code,
|
r.template_code,
|
||||||
assignment.decision,
|
assignment.decision,
|
||||||
assignment.active_yn
|
assignment.active_yn
|
||||||
FROM cb_user_masking_rule assignment
|
FROM sg_user_masking_rule assignment
|
||||||
JOIN cb_app_user u ON u.user_id = assignment.user_id
|
JOIN sg_app_user u ON u.user_id = assignment.user_id
|
||||||
JOIN cb_protected_column c ON c.column_id = assignment.column_id
|
JOIN sg_protected_column c ON c.column_id = assignment.column_id
|
||||||
JOIN cb_protected_object o ON o.object_id = c.object_id
|
JOIN sg_protected_object o ON o.object_id = c.object_id
|
||||||
JOIN cb_column_masking_rule link ON link.column_id = c.column_id
|
JOIN sg_column_masking_rule link ON link.column_id = c.column_id
|
||||||
JOIN cb_masking_rule r ON r.rule_id = link.rule_id
|
JOIN sg_masking_rule r ON r.rule_id = link.rule_id
|
||||||
WHERE assignment.user_id = #{userId}
|
WHERE assignment.user_id = #{userId}
|
||||||
AND assignment.column_id = #{columnId}
|
AND assignment.column_id = #{columnId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="upsertUserRule">
|
<insert id="upsertUserRule">
|
||||||
MERGE INTO cb_user_masking_rule dst
|
MERGE INTO sg_user_masking_rule dst
|
||||||
USING (SELECT #{userId} user_id, #{columnId} column_id, #{decision} decision FROM dual) src
|
USING (SELECT #{userId} user_id, #{columnId} column_id, #{decision} decision FROM dual) src
|
||||||
ON (dst.user_id = src.user_id AND dst.column_id = src.column_id)
|
ON (dst.user_id = src.user_id AND dst.column_id = src.column_id)
|
||||||
WHEN MATCHED THEN UPDATE SET dst.decision = src.decision, dst.active_yn = 'Y', dst.updated_at = SYSTIMESTAMP
|
WHEN MATCHED THEN UPDATE SET dst.decision = src.decision, dst.active_yn = 'Y', dst.updated_at = SYSTIMESTAMP
|
||||||
@@ -257,7 +254,7 @@
|
|||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<delete id="deleteUserRule">
|
<delete id="deleteUserRule">
|
||||||
DELETE FROM cb_user_masking_rule
|
DELETE FROM sg_user_masking_rule
|
||||||
WHERE user_id = #{userId}
|
WHERE user_id = #{userId}
|
||||||
AND column_id = #{columnId}
|
AND column_id = #{columnId}
|
||||||
</delete>
|
</delete>
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
role_name,
|
role_name,
|
||||||
description,
|
description,
|
||||||
max_sensitivity_level
|
max_sensitivity_level
|
||||||
FROM hmm_access_roles
|
FROM sg_app_role
|
||||||
WHERE active_yn = 'Y'
|
|
||||||
ORDER BY role_name
|
ORDER BY role_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -17,45 +16,44 @@
|
|||||||
role_name,
|
role_name,
|
||||||
description,
|
description,
|
||||||
max_sensitivity_level
|
max_sensitivity_level
|
||||||
FROM hmm_access_roles
|
FROM sg_app_role
|
||||||
WHERE role_id = #{roleId}
|
WHERE role_id = #{roleId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="nextRoleId" resultType="long">
|
<select id="nextRoleId" resultType="long">
|
||||||
SELECT NVL(MAX(role_id), 0) + 1 FROM hmm_access_roles
|
SELECT NVL(MAX(role_id), 0) + 1 FROM sg_app_role
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insertRole">
|
<insert id="insertRole">
|
||||||
INSERT INTO hmm_access_roles (role_id, role_name, description, max_sensitivity_level, active_yn)
|
INSERT INTO sg_app_role (role_id, role_name, description, max_sensitivity_level)
|
||||||
VALUES (
|
VALUES (
|
||||||
#{roleId,jdbcType=NUMERIC},
|
#{roleId,jdbcType=NUMERIC},
|
||||||
UPPER(#{roleName,jdbcType=VARCHAR}),
|
UPPER(#{roleName,jdbcType=VARCHAR}),
|
||||||
#{description,jdbcType=VARCHAR},
|
#{description,jdbcType=VARCHAR},
|
||||||
#{maxSensitivityLevel,jdbcType=VARCHAR},
|
#{maxSensitivityLevel,jdbcType=VARCHAR}
|
||||||
'Y'
|
|
||||||
)
|
)
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="updateRoleMaxSensitivity">
|
<update id="updateRoleMaxSensitivity">
|
||||||
UPDATE hmm_access_roles
|
UPDATE sg_app_role
|
||||||
SET max_sensitivity_level = #{maxSensitivityLevel,jdbcType=VARCHAR}
|
SET max_sensitivity_level = #{maxSensitivityLevel,jdbcType=VARCHAR}
|
||||||
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<delete id="deleteRole">
|
<delete id="deleteRole">
|
||||||
DELETE FROM hmm_access_roles
|
DELETE FROM sg_app_role
|
||||||
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
||||||
</delete>
|
</delete>
|
||||||
|
|
||||||
<select id="countUserRolesByRoleId" resultType="int">
|
<select id="countUserRolesByRoleId" resultType="int">
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM hmm_employee_access_roles
|
FROM sg_user_role
|
||||||
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="countGroupRolesByRoleId" resultType="int">
|
<select id="countGroupRolesByRoleId" resultType="int">
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM hmm_group_access_roles
|
FROM sg_group_role
|
||||||
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
WHERE role_id = #{roleId,jdbcType=NUMERIC}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
100
src/main/resources/mapper/SchemaMetadataMapper.xml
Normal file
100
src/main/resources/mapper/SchemaMetadataMapper.xml
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper">
|
||||||
|
<select id="findTableComment" resultType="string">
|
||||||
|
SELECT comments
|
||||||
|
FROM all_tab_comments
|
||||||
|
WHERE owner = #{owner,jdbcType=VARCHAR}
|
||||||
|
AND table_name = #{tableName,jdbcType=VARCHAR}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findColumns"
|
||||||
|
resultType="com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow">
|
||||||
|
SELECT c.column_name AS column_name,
|
||||||
|
CASE
|
||||||
|
WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR')
|
||||||
|
THEN c.data_type || '(' || c.char_length || ')'
|
||||||
|
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL
|
||||||
|
THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')'
|
||||||
|
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL
|
||||||
|
THEN c.data_type || '(' || c.data_precision || ')'
|
||||||
|
ELSE c.data_type
|
||||||
|
END AS data_type,
|
||||||
|
c.nullable,
|
||||||
|
cc.comments
|
||||||
|
FROM all_tab_columns c
|
||||||
|
LEFT JOIN all_col_comments cc
|
||||||
|
ON cc.owner = c.owner
|
||||||
|
AND cc.table_name = c.table_name
|
||||||
|
AND cc.column_name = c.column_name
|
||||||
|
WHERE c.owner = #{owner,jdbcType=VARCHAR}
|
||||||
|
AND c.table_name = #{tableName,jdbcType=VARCHAR}
|
||||||
|
ORDER BY c.column_id
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findAnnotations"
|
||||||
|
resultType="com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow">
|
||||||
|
SELECT column_name,
|
||||||
|
annotation_name,
|
||||||
|
annotation_value
|
||||||
|
FROM all_annotations_usage
|
||||||
|
WHERE object_name = #{tableName,jdbcType=VARCHAR}
|
||||||
|
AND object_type = 'TABLE'
|
||||||
|
ORDER BY column_name NULLS FIRST, annotation_name, annotation_value
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countColumn" resultType="int">
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM all_tab_columns
|
||||||
|
WHERE owner = #{owner,jdbcType=VARCHAR}
|
||||||
|
AND table_name = #{tableName,jdbcType=VARCHAR}
|
||||||
|
AND column_name = #{columnName,jdbcType=VARCHAR}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countTableAnnotation" resultType="int">
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM all_annotations_usage
|
||||||
|
WHERE object_name = #{tableName,jdbcType=VARCHAR}
|
||||||
|
AND object_type = 'TABLE'
|
||||||
|
AND annotation_name = #{annotationName,jdbcType=VARCHAR}
|
||||||
|
AND column_name IS NULL
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countColumnAnnotation" resultType="int">
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM all_annotations_usage
|
||||||
|
WHERE object_name = #{tableName,jdbcType=VARCHAR}
|
||||||
|
AND object_type = 'TABLE'
|
||||||
|
AND annotation_name = #{annotationName,jdbcType=VARCHAR}
|
||||||
|
AND column_name = #{columnName,jdbcType=VARCHAR}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Identifiers are server-side validated names. Oracle DDL comments require a literal. -->
|
||||||
|
<update id="updateTableComment">
|
||||||
|
COMMENT ON TABLE "${owner}"."${tableName}" IS ${commentLiteral}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="updateColumnComment">
|
||||||
|
COMMENT ON COLUMN "${owner}"."${tableName}"."${columnName}" IS ${commentLiteral}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="dropTableAnnotation">
|
||||||
|
ALTER TABLE "${owner}"."${tableName}" ANNOTATIONS (DROP "${annotationName}")
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="addTableAnnotation">
|
||||||
|
ALTER TABLE "${owner}"."${tableName}"
|
||||||
|
ANNOTATIONS (ADD "${annotationName}" ${annotationValueLiteral})
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="dropColumnAnnotation">
|
||||||
|
ALTER TABLE "${owner}"."${tableName}" MODIFY "${columnName}"
|
||||||
|
ANNOTATIONS (DROP "${annotationName}")
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="addColumnAnnotation">
|
||||||
|
ALTER TABLE "${owner}"."${tableName}" MODIFY "${columnName}"
|
||||||
|
ANNOTATIONS (ADD "${annotationName}" ${annotationValueLiteral})
|
||||||
|
</update>
|
||||||
|
</mapper>
|
||||||
@@ -4,12 +4,12 @@
|
|||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.SettingMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.SettingMapper">
|
||||||
<select id="findByKey" resultType="com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting">
|
<select id="findByKey" resultType="com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting">
|
||||||
SELECT setting_key, setting_value
|
SELECT setting_key, setting_value
|
||||||
FROM cb_backoffice_setting
|
FROM sg_backoffice_setting
|
||||||
WHERE setting_key = #{settingKey,jdbcType=VARCHAR}
|
WHERE setting_key = #{settingKey,jdbcType=VARCHAR}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<update id="upsert">
|
<update id="upsert">
|
||||||
MERGE INTO cb_backoffice_setting dst
|
MERGE INTO sg_backoffice_setting dst
|
||||||
USING (
|
USING (
|
||||||
SELECT #{settingKey,jdbcType=VARCHAR} setting_key,
|
SELECT #{settingKey,jdbcType=VARCHAR} setting_key,
|
||||||
#{settingValue,jdbcType=VARCHAR} setting_value
|
#{settingValue,jdbcType=VARCHAR} setting_value
|
||||||
|
|||||||
@@ -3,31 +3,36 @@
|
|||||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.StakeholderMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.StakeholderMapper">
|
||||||
<select id="findTokenSubjects" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
|
<select id="findTokenSubjects" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
|
||||||
SELECT s.user_id AS stakeholder_user_id,
|
SELECT TO_CHAR(u.user_id) AS stakeholder_user_id,
|
||||||
s.user_nm AS username,
|
u.user_name AS username,
|
||||||
s.role,
|
COALESCE((
|
||||||
s.channel,
|
SELECT MIN(r.role_name)
|
||||||
s.access_scope,
|
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
|
u.user_id AS app_user_id
|
||||||
FROM poc_2.kb_stakeholders s
|
FROM sg_app_user u
|
||||||
JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id
|
|
||||||
WHERE u.active = 'Y'
|
WHERE u.active = 'Y'
|
||||||
ORDER BY CASE s.role WHEN '지점장' THEN 1 WHEN '설계사' THEN 2 ELSE 3 END,
|
ORDER BY role, u.dept_code, u.user_name, u.user_id
|
||||||
s.channel,
|
|
||||||
s.user_nm,
|
|
||||||
s.user_id
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findTokenSubject" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
|
<select id="findTokenSubject" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
|
||||||
SELECT s.user_id AS stakeholder_user_id,
|
SELECT TO_CHAR(u.user_id) AS stakeholder_user_id,
|
||||||
s.user_nm AS username,
|
u.user_name AS username,
|
||||||
s.role,
|
COALESCE((
|
||||||
s.channel,
|
SELECT MIN(r.role_name)
|
||||||
s.access_scope,
|
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
|
u.user_id AS app_user_id
|
||||||
FROM poc_2.kb_stakeholders s
|
FROM sg_app_user u
|
||||||
JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id
|
WHERE TO_CHAR(u.user_id) = #{stakeholderUserId,jdbcType=VARCHAR}
|
||||||
WHERE s.user_id = #{stakeholderUserId,jdbcType=VARCHAR}
|
|
||||||
AND u.active = 'Y'
|
AND u.active = 'Y'
|
||||||
</select>
|
</select>
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -3,27 +3,16 @@
|
|||||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.UserMapper">
|
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.UserMapper">
|
||||||
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
|
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
|
||||||
SELECT e.employee_id AS user_id,
|
SELECT user_id, user_name AS username, employee_no AS emp_no, dept_code,
|
||||||
e.full_name AS username,
|
can_read_contents, active AS active_yn
|
||||||
e.employee_code AS emp_no,
|
FROM sg_app_user
|
||||||
t.team_code AS dept_code,
|
|
||||||
'N' AS can_read_contents,
|
|
||||||
CASE WHEN e.employment_status = 'ACTIVE' THEN 'Y' ELSE 'N' END AS active_yn
|
|
||||||
FROM hmm_hr_employees e
|
|
||||||
JOIN hmm_org_teams t ON t.team_id = e.team_id
|
|
||||||
ORDER BY username
|
ORDER BY username
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
|
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
|
||||||
SELECT e.employee_id AS user_id,
|
SELECT user_id, user_name AS username, employee_no AS emp_no, dept_code,
|
||||||
e.full_name AS username,
|
can_read_contents, active AS active_yn
|
||||||
e.employee_code AS emp_no,
|
FROM sg_app_user WHERE user_id = #{userId}
|
||||||
t.team_code AS dept_code,
|
|
||||||
'N' AS can_read_contents,
|
|
||||||
CASE WHEN e.employment_status = 'ACTIVE' THEN 'Y' ELSE 'N' END AS active_yn
|
|
||||||
FROM hmm_hr_employees e
|
|
||||||
JOIN hmm_org_teams t ON t.team_id = e.team_id
|
|
||||||
WHERE e.employee_id = #{userId}
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findUserRoles" resultType="com.cloudhandson.vpdbackoffice.domain.user.UserRoleView">
|
<select id="findUserRoles" resultType="com.cloudhandson.vpdbackoffice.domain.user.UserRoleView">
|
||||||
@@ -31,46 +20,32 @@
|
|||||||
u.user_name AS username,
|
u.user_name AS username,
|
||||||
r.role_id,
|
r.role_id,
|
||||||
r.role_name
|
r.role_name
|
||||||
FROM hmm_employee_access_roles ur
|
FROM sg_user_role ur
|
||||||
JOIN hmm_hr_employees u ON u.employee_id = ur.employee_id
|
JOIN sg_app_user u ON u.user_id = ur.user_id
|
||||||
JOIN hmm_access_roles r ON r.role_id = ur.role_id
|
JOIN sg_app_role r ON r.role_id = ur.role_id
|
||||||
ORDER BY u.full_name, r.role_name
|
ORDER BY u.user_name, r.role_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="nextUserId" resultType="long">
|
<select id="nextUserId" resultType="long">
|
||||||
SELECT NVL(MAX(employee_id), 0) + 1 FROM hmm_hr_employees
|
SELECT NVL(MAX(user_id), 0) + 1 FROM sg_app_user
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insertUser">
|
<insert id="insertUser">
|
||||||
INSERT INTO hmm_hr_employees (
|
INSERT INTO sg_app_user (user_id, login_id, user_name, employee_no, dept_code, can_read_contents, active)
|
||||||
employee_id, employee_code, full_name, email, job_title, team_id, hire_date, employment_status
|
VALUES (#{userId}, LOWER(#{command.empNo}), #{command.username}, #{command.empNo},
|
||||||
)
|
#{command.deptCode}, 'N', 'Y')
|
||||||
SELECT #{userId},
|
|
||||||
#{command.empNo},
|
|
||||||
#{command.username},
|
|
||||||
LOWER(#{command.empNo}) || '@example.test',
|
|
||||||
'HMM Demo Employee',
|
|
||||||
t.team_id,
|
|
||||||
TRUNC(SYSDATE),
|
|
||||||
'ACTIVE'
|
|
||||||
FROM hmm_org_teams t
|
|
||||||
WHERE t.team_code = #{command.deptCode}
|
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="updateActive">
|
<update id="updateActive">
|
||||||
UPDATE hmm_hr_employees
|
UPDATE sg_app_user SET active = #{activeYn} WHERE user_id = #{userId}
|
||||||
SET employment_status = CASE WHEN #{activeYn} = 'Y' THEN 'ACTIVE' ELSE 'INACTIVE' END
|
|
||||||
WHERE employee_id = #{userId}
|
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<insert id="insertUserRole">
|
<insert id="insertUserRole">
|
||||||
INSERT INTO hmm_employee_access_roles (employee_id, role_id)
|
INSERT INTO sg_user_role (user_id, role_id) VALUES (#{userId}, #{roleId})
|
||||||
VALUES (#{userId}, #{roleId})
|
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<delete id="deleteUserRole">
|
<delete id="deleteUserRole">
|
||||||
DELETE FROM hmm_employee_access_roles
|
DELETE FROM sg_user_role WHERE user_id = #{userId}
|
||||||
WHERE employee_id = #{userId}
|
|
||||||
AND role_id = #{roleId}
|
AND role_id = #{roleId}
|
||||||
</delete>
|
</delete>
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -728,16 +728,16 @@ function permissionRuleBusinessLabel(type, column, value) {
|
|||||||
return `토큰 이해관계자 본인 행 (${displayColumn})`;
|
return `토큰 이해관계자 본인 행 (${displayColumn})`;
|
||||||
}
|
}
|
||||||
if (type === 'OWN_CONTRACT') {
|
if (type === 'OWN_CONTRACT') {
|
||||||
return `담당 설계사 본인 계약 (${displayColumn})`;
|
return `담당 게임 서비스 범위 (${displayColumn})`;
|
||||||
}
|
}
|
||||||
if (type === 'CHANNEL_CONTRACT') {
|
if (type === 'CHANNEL_CONTRACT') {
|
||||||
return `토큰 사용자 채널 계약 (${displayColumn})`;
|
return `토큰 채널 게임 서비스 범위 (${displayColumn})`;
|
||||||
}
|
}
|
||||||
if (type === 'OWN_CUSTOMER') {
|
if (type === 'OWN_CUSTOMER') {
|
||||||
return `담당 설계사 본인 계약에 연결된 고객/청구/외부보유 (${displayColumn})`;
|
return `담당 게임 사용자·거래 데이터 범위 (${displayColumn})`;
|
||||||
}
|
}
|
||||||
if (type === 'CHANNEL_CUSTOMER') {
|
if (type === 'CHANNEL_CUSTOMER') {
|
||||||
return `토큰 사용자 채널 계약에 연결된 고객/청구/외부보유 (${displayColumn})`;
|
return `토큰 채널 게임 사용자·거래 데이터 범위 (${displayColumn})`;
|
||||||
}
|
}
|
||||||
if (type === 'STATIC_SQL') {
|
if (type === 'STATIC_SQL') {
|
||||||
return `정적 SQL 조건: ${value || '조건식 미입력'}`;
|
return `정적 SQL 조건: ${value || '조건식 미입력'}`;
|
||||||
@@ -813,10 +813,10 @@ function collectWizardPredicates(root) {
|
|||||||
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`;
|
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`;
|
||||||
}
|
}
|
||||||
if (type === 'STAKEHOLDER_SELF') {
|
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') {
|
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') {
|
if (type === 'TOKEN_SUBJECT') {
|
||||||
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')`;
|
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')`;
|
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')`;
|
||||||
}
|
}
|
||||||
if (type === 'OWN_CUSTOMER') {
|
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') {
|
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') {
|
if (type === 'DEPT' || type === 'EMP_NO') {
|
||||||
return `${displayColumn} = ${sqlLiteral(value)}`;
|
return `${displayColumn} = ${sqlLiteral(value)}`;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<p>업무 사용자와 데이터 접근 기준을 관리하고, DB가 적용한 결과까지 확인합니다.</p>
|
<p>업무 사용자와 데이터 접근 기준을 관리하고, DB가 적용한 결과까지 확인합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-hero-actions" aria-label="주요 작업">
|
<div class="dashboard-hero-actions" aria-label="주요 작업">
|
||||||
<a class="btn rw-btn-primary" href="/probe">접근 검증</a>
|
<a class="btn rw-btn-primary" href="/users">사용자 관리</a>
|
||||||
<a class="btn rw-btn-secondary" href="/permissions">행 접근 규칙</a>
|
<a class="btn rw-btn-secondary" href="/permissions">행 접근 규칙</a>
|
||||||
</div>
|
</div>
|
||||||
<details class="explanation-details dashboard-explanation">
|
<details class="explanation-details dashboard-explanation">
|
||||||
@@ -152,10 +152,10 @@
|
|||||||
<h2 id="menu-flow-title">설정은 접근 제어에서, 적용 확인은 접근 검증에서</h2>
|
<h2 id="menu-flow-title">설정은 접근 제어에서, 적용 확인은 접근 검증에서</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-flow-grid">
|
<div class="dashboard-flow-grid">
|
||||||
<a class="dashboard-flow-card" href="/permissions"><strong>행 접근 규칙</strong><span>역할별 객체·행 조건</span></a>
|
<a class="dashboard-flow-card" href="/users"><strong>사용자 관리</strong><span>PoC 운영 사용자를 관리</span></a>
|
||||||
<a class="dashboard-flow-card" href="/vpd-policies"><strong>보호·검증</strong><span>보호 연결·검증 세션·접근 확인</span></a>
|
<a class="dashboard-flow-card" href="/groups"><strong>그룹 관리</strong><span>Data & AI TF 구성</span></a>
|
||||||
<a class="dashboard-flow-card" href="/objects"><strong>연동 도구</strong><span>조회 대상·지식 검색·MCP 연동</span></a>
|
<a class="dashboard-flow-card" href="/roles"><strong>역할 관리</strong><span>운영 역할을 관리</span></a>
|
||||||
<a class="dashboard-flow-card" href="/operation-status"><strong>운영</strong><span>상태와 실행 결과 확인</span></a>
|
<a class="dashboard-flow-card" href="/permissions"><strong>행 접근 규칙</strong><span>게임 데이터 객체별 접근 규칙</span></a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
<title th:text="${title == 'SMILEGATE DATA & AI POC' ? title : title + ' · SMILEGATE DATA & AI POC'}">SMILEGATE DATA & AI POC</title>
|
<title th:text="${title == 'SMILEGATE DATA & AI POC' ? title : title + ' · SMILEGATE DATA & AI POC'}">SMILEGATE DATA & AI POC</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="/css/app.css" rel="stylesheet">
|
<link href="/css/app.css" rel="stylesheet">
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
<!-- External UI helpers must not block initial HTML rendering. In
|
||||||
|
particular, schema-metadata is a server-rendered management page and
|
||||||
|
remains usable while a client network delays the CDN. -->
|
||||||
|
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||||
<script defer src="/js/app.js"></script>
|
<script defer src="/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -108,8 +108,8 @@
|
|||||||
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>레거시 VPD 컬럼 제어</th><th>DB 정책 활성</th><th>상태 판단</th></tr></thead>
|
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>레거시 VPD 컬럼 제어</th><th>DB 정책 활성</th><th>상태 판단</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="status : ${policyStatuses}">
|
<tr th:each="status : ${policyStatuses}">
|
||||||
<td><code th:text="${status.targetLabel()}">POC_2.KB_CUSTOMERS</code></td>
|
<td><code th:text="${status.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST</code></td>
|
||||||
<td><code th:text="${status.policyName()}">KB_CUSTOMER_PII_REDACT</code></td>
|
<td><code th:text="${status.policyName()}">SG_GAME_USER_REDACT</code></td>
|
||||||
<td th:text="${status.configuredColumnCount()}">0</td>
|
<td th:text="${status.configuredColumnCount()}">0</td>
|
||||||
<td th:text="${status.appliedColumnCount()}">0</td>
|
<td th:text="${status.appliedColumnCount()}">0</td>
|
||||||
<td><span class="badge" th:classappend="${status.legacyVpdColumnPolicyCount() == 0} ? ' text-bg-secondary' : ' text-bg-danger'" th:text="${status.legacyVpdColumnPolicyCount() == 0} ? '없음' : ${status.legacyVpdColumnPolicyCount() + '건 활성'}">없음</span></td>
|
<td><span class="badge" th:classappend="${status.legacyVpdColumnPolicyCount() == 0} ? ' text-bg-secondary' : ' text-bg-danger'" th:text="${status.legacyVpdColumnPolicyCount() == 0} ? '없음' : ${status.legacyVpdColumnPolicyCount() + '건 활성'}">없음</span></td>
|
||||||
@@ -142,17 +142,17 @@
|
|||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<h2>컬럼 마스킹 규칙 등록</h2>
|
<h2>컬럼 마스킹 규칙 등록</h2>
|
||||||
<p class="section-subtitle">업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>KB_RRN_STANDARD</code>.</p>
|
<p class="section-subtitle">게임 데이터 기준의 업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>GAME_USER_ID_MASK</code>.</p>
|
||||||
<form method="post" action="/masking-rules" class="form-grid">
|
<form method="post" action="/masking-rules" class="form-grid">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<label>
|
<label>
|
||||||
규칙 코드
|
규칙 코드
|
||||||
<input class="form-control" name="ruleCode" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_]{2,63}" required placeholder="KB_RRN_STANDARD">
|
<input class="form-control" name="ruleCode" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_]{2,63}" required placeholder="GAME_USER_ID_MASK">
|
||||||
<span class="form-hint">영문·숫자·밑줄만 사용합니다.</span>
|
<span class="form-hint">영문·숫자·밑줄만 사용합니다.</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
규칙명
|
규칙명
|
||||||
<input class="form-control" name="ruleName" maxlength="100" required placeholder="주민번호 기본 마스킹">
|
<input class="form-control" name="ruleName" maxlength="100" required placeholder="게임 사용자 식별자 기본 마스킹">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
마스킹 템플릿
|
마스킹 템플릿
|
||||||
@@ -176,8 +176,8 @@
|
|||||||
<thead><tr><th>코드</th><th>규칙명</th><th>템플릿</th><th>설명</th><th>상태</th><th></th></tr></thead>
|
<thead><tr><th>코드</th><th>규칙명</th><th>템플릿</th><th>설명</th><th>상태</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="rule : ${rules}">
|
<tr th:each="rule : ${rules}">
|
||||||
<td><code th:text="${rule.ruleCode()}">KB_RRN_STANDARD</code></td>
|
<td><code th:text="${rule.ruleCode()}">GAME_USER_ID_MASK</code></td>
|
||||||
<td th:text="${rule.ruleName()}">주민번호 기본 마스킹</td>
|
<td th:text="${rule.ruleName()}">게임 사용자 식별자 기본 마스킹</td>
|
||||||
<td th:text="${rule.templateLabel()}">값 숨김(NULL)</td>
|
<td th:text="${rule.templateLabel()}">값 숨김(NULL)</td>
|
||||||
<td th:text="${rule.description() ?: '-'}">설명</td>
|
<td th:text="${rule.description() ?: '-'}">설명</td>
|
||||||
<td><span class="badge" th:classappend="${rule.enabled()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${rule.enabledYn()}">Y</span></td>
|
<td><span class="badge" th:classappend="${rule.enabled()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${rule.enabledYn()}">Y</span></td>
|
||||||
@@ -209,11 +209,11 @@
|
|||||||
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(availableMaskingColumnsByObject[object.objectId()])}">
|
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(availableMaskingColumnsByObject[object.objectId()])}">
|
||||||
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
|
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
|
||||||
th:value="|${object.objectId()}:${columnName}|"
|
th:value="|${object.objectId()}:${columnName}|"
|
||||||
th:text="${object.displayName() + '.' + columnName}">POC_2.KB_CONTRACTS.PREMIUM</option>
|
th:text="${object.displayName() + '.' + columnName}">SGMP_POC.COMN_SALES_TXN.GUID</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</th:block>
|
</th:block>
|
||||||
</select>
|
</select>
|
||||||
<span class="form-hint">현재 관리 대상 ASO 정책이 있는 객체만 표시됩니다. 예: <code>POC_2.KB_CONTRACTS.PREMIUM</code>.</span>
|
<span class="form-hint">현재 관리 대상 ASO 정책이 있는 게임 데이터 객체만 표시됩니다. 예: <code>SGMP_POC.COMN_SALES_TXN.GUID</code>.</span>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
|
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -253,9 +253,9 @@
|
|||||||
<thead><tr><th>대상 컬럼</th><th>규칙</th><th>템플릿</th><th>백오피스 설정</th><th>DB ASO 적용 상태</th><th></th></tr></thead>
|
<thead><tr><th>대상 컬럼</th><th>규칙</th><th>템플릿</th><th>백오피스 설정</th><th>DB ASO 적용 상태</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="columnRule : ${columnRules}">
|
<tr th:each="columnRule : ${columnRules}">
|
||||||
<td><code th:text="${columnRule.targetLabel()}">POC_2.KB_CUSTOMERS.RRN_MASKED</code></td>
|
<td><code th:text="${columnRule.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST.USER_ID</code></td>
|
||||||
<td th:text="${columnRule.ruleName()}">주민번호 기본 마스킹</td>
|
<td th:text="${columnRule.ruleName()}">게임 사용자 식별자 기본 마스킹</td>
|
||||||
<td th:text="${columnRule.template().label()}">주민등록번호 부분 마스킹</td>
|
<td th:text="${columnRule.template().label()}">식별자 부분 마스킹</td>
|
||||||
<td><span class="badge" th:classappend="${columnRule.ruleEnabled()} ? ' text-bg-success' : ' text-bg-warning'" th:text="${columnRule.ruleEnabled()} ? '기본 규칙 연결됨' : '규칙 비활성'">기본 규칙 연결됨</span></td>
|
<td><span class="badge" th:classappend="${columnRule.ruleEnabled()} ? ' text-bg-success' : ' text-bg-warning'" th:text="${columnRule.ruleEnabled()} ? '기본 규칙 연결됨' : '규칙 비활성'">기본 규칙 연결됨</span></td>
|
||||||
<td th:with="policyStatus=${policyStatusByObjectName[columnRule.objectName()]}">
|
<td th:with="policyStatus=${policyStatusByObjectName[columnRule.objectName()]}">
|
||||||
<span class="badge"
|
<span class="badge"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<h1>MCP 연동</h1>
|
<h1>MCP 연동</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>사용자 Bearer Token을 전달해 GPT-5.4-mini Select AI 자연어 조회를 실행하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하고, DB에서는 토큰 기준 행 접근 context가 적용됩니다.</p>
|
<p>사용자 Bearer Token을 검증한 뒤 <code>SGMP_POC_HAIKU45</code> Select AI 프로파일로 게임 데이터 Text2SQL을 생성하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하며, 생성 SQL은 자동 실행하지 않습니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
|
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
HTTP <code>Authorization</code> → 행 접근 컨텍스트 · <code>prompt</code> → GPT-5.4-mini Select AI 자연어 질의 · <code>limit</code> → 최대 반환 행 수
|
HTTP <code>Authorization</code> → 활성 PoC 사용자 토큰 검증 · <code>prompt</code> → 게임 데이터 Text2SQL 생성 · 결과 SQL은 검토 후 별도 실행
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -116,10 +116,9 @@
|
|||||||
<summary>tools/call parameter 예시 보기</summary>
|
<summary>tools/call parameter 예시 보기</summary>
|
||||||
<h2>tools/call Arguments</h2>
|
<h2>tools/call Arguments</h2>
|
||||||
<pre class="code-block">{
|
<pre class="code-block">{
|
||||||
"prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.",
|
"prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
|
||||||
"limit": 50
|
|
||||||
}</pre>
|
}</pre>
|
||||||
<p class="form-hint">등록 tool은 <code>ords.query.kb_select_ai_vpd</code> 하나입니다. HTTP Authorization의 사용자 Bearer Token으로 컨텍스트를 설정한 뒤 GPT-5.4-mini가 comment, annotation, constraint를 참고해 생성한 검증된 읽기 전용 KB 원장 SQL만 실행합니다.</p>
|
<p class="form-hint">등록 tool은 <code>oracle.select_ai.smilegate_game_text2sql</code> 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 <code>SGMP_POC_HAIKU45</code>가 comment, annotation, constraint를 참고해 읽기 전용 게임 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -153,10 +152,9 @@
|
|||||||
"id": 3,
|
"id": 3,
|
||||||
"method": "tools/call",
|
"method": "tools/call",
|
||||||
"params": {
|
"params": {
|
||||||
"name": "ords.query.kb_select_ai_vpd",
|
"name": "oracle.select_ai.smilegate_game_text2sql",
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.",
|
"prompt": "카제나의 최신 BASE_DT 기준 AU(활성 사용자 수)를 조회하는 SQL을 만들어줘."
|
||||||
"limit": 50
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}</pre>
|
}</pre>
|
||||||
|
|||||||
@@ -159,8 +159,8 @@
|
|||||||
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
|
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="status : ${maskingPolicyStatuses}">
|
<tr th:each="status : ${maskingPolicyStatuses}">
|
||||||
<td><code th:text="${status.targetLabel()}">POC_2.KB_CLAIMS</code></td>
|
<td><code th:text="${status.targetLabel()}">SGMP_POC.COMN_SALES_TXN</code></td>
|
||||||
<td><code th:text="${status.policyName()}">KB_CLAIM_AMOUNT_REDACT</code></td>
|
<td><code th:text="${status.policyName()}">SG_SALES_TXN_REDACT</code></td>
|
||||||
<td th:text="${status.configuredColumnCount()}">0</td>
|
<td th:text="${status.configuredColumnCount()}">0</td>
|
||||||
<td th:text="${status.appliedColumnCount()}">0</td>
|
<td th:text="${status.appliedColumnCount()}">0</td>
|
||||||
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>
|
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>이 화면은 <strong>행 접근 규칙</strong>만 저장합니다. 저장값은 최종 SQL이 아니라 <code>CB_AGENT_DOC_VPD_FILTER</code>가 읽어서 대상 테이블의 WHERE predicate로 바꾸는 매핑 데이터입니다.</p>
|
<p>이 화면은 <strong>행 접근 규칙</strong>만 저장합니다. 저장값은 최종 SQL이 아니라 <code>CB_AGENT_DOC_VPD_FILTER</code>가 읽어서 대상 테이블의 WHERE predicate로 바꾸는 매핑 데이터입니다.</p>
|
||||||
<p>컬럼 원문/마스킹은 이 화면에서 처리하지 않습니다. 주민번호, 보험료, 청구금액 같은 컬럼 표시는 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction 정책에서 관리합니다.</p>
|
<p>컬럼 원문/마스킹은 이 화면에서 처리하지 않습니다. 게임 사용자 식별자, 결제금액 같은 민감 컬럼 표시는 <a href="/masking-rules">컬럼 마스킹</a>의 ASO/Data Redaction 정책에서 관리합니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,15 +158,15 @@
|
|||||||
</select>
|
</select>
|
||||||
<select class="form-select rule-type-select" name="ruleType">
|
<select class="form-select rule-type-select" name="ruleType">
|
||||||
<option value="ALL">ALL</option>
|
<option value="ALL">ALL</option>
|
||||||
<optgroup label="KB · 본인 범위">
|
<optgroup label="게임 데이터 · 담당자 범위">
|
||||||
<option value="OWN_CONTRACT">본인 담당 계약</option>
|
<option value="OWN_CONTRACT">담당 게임 서비스</option>
|
||||||
<option value="OWN_CUSTOMER">본인 담당 고객</option>
|
<option value="OWN_CUSTOMER">담당 게임 사용자</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="KB · 채널 범위">
|
<optgroup label="게임 데이터 · 채널 범위">
|
||||||
<option value="CHANNEL_CONTRACT">토큰 채널 계약</option>
|
<option value="CHANNEL_CONTRACT">토큰 채널 게임 서비스</option>
|
||||||
<option value="CHANNEL_CUSTOMER">토큰 채널 고객</option>
|
<option value="CHANNEL_CUSTOMER">토큰 채널 게임 사용자</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="KB · 토큰 식별">
|
<optgroup label="게임 데이터 · 토큰 식별">
|
||||||
<option value="TOKEN_SUBJECT">토큰 사용자 본인</option>
|
<option value="TOKEN_SUBJECT">토큰 사용자 본인</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="SQL · 정적 조건">
|
<optgroup label="SQL · 정적 조건">
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>행 규칙의 두 가지 적용 방식 보기</summary>
|
<summary>행 규칙의 두 가지 적용 방식 보기</summary>
|
||||||
<p class="wizard-hint"><strong>조건 코드</strong>는 토큰 context로 치환됩니다. 예를 들어 <code>본인 담당 고객 / CUST_ID</code>는 계약원장에서 토큰 사용자 ID의 담당 고객을 찾아 현재 객체의 <code>CUST_ID</code>에 적용합니다. <strong>정적 SQL 조건식</strong>은 <code>CONTRACT_STATUS = '정상'</code>처럼 현재 객체 컬럼을 사용한 WHERE 절을 그대로 추가합니다. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다. 역할명은 최종 WHERE 절에 직접 들어가지 않습니다.</p>
|
<p class="wizard-hint"><strong>조건 코드</strong>는 토큰 context와 DB에 등록된 업무 관계를 바탕으로 행 접근 함수가 해석합니다. 관계가 없는 게임 데이터 객체에는 조건 코드를 억지로 적용하지 말고, <strong>정적 SQL 조건식</strong>으로 <code>GAME_ID = 'CZN'</code>처럼 실제 컬럼을 사용하세요. 한 권한 안의 규칙은 모두 AND로 좁혀지고, 서로 다른 역할의 ALLOW 권한은 OR로 합쳐집니다.</p>
|
||||||
<p class="wizard-hint"><strong>컬럼 원문/마스킹은 제외했습니다.</strong> 행 접근 필터는 행만 남기고, ASO/Data Redaction이 허용된 행 안에서 컬럼을 원문 또는 마스킹으로 반환합니다.</p>
|
<p class="wizard-hint"><strong>컬럼 원문/마스킹은 제외했습니다.</strong> 행 접근 필터는 행만 남기고, ASO/Data Redaction이 허용된 행 안에서 컬럼을 원문 또는 마스킹으로 반환합니다.</p>
|
||||||
</details>
|
</details>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
@@ -196,28 +196,28 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>TOKEN_SUBJECT</code></td>
|
<td><code>TOKEN_SUBJECT</code></td>
|
||||||
<td>토큰으로 식별된 이해관계자 본인 행</td>
|
<td>토큰으로 식별된 운영 사용자 범위</td>
|
||||||
<td><code>USER_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')</code></td>
|
<td>대상 객체가 토큰 주체 식별 컬럼을 가질 때만 DB 행 접근 함수가 생성</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>OWN_CONTRACT</code></td>
|
<td><code>OWN_CONTRACT</code></td>
|
||||||
<td>토큰 사용자가 담당 설계사인 계약</td>
|
<td>토큰 사용자가 담당하는 게임 서비스 범위</td>
|
||||||
<td><code>FC_ID = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID')</code></td>
|
<td>등록된 업무 관계가 있을 때만 DB 행 접근 함수가 생성</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>CHANNEL_CONTRACT</code></td>
|
<td><code>CHANNEL_CONTRACT</code></td>
|
||||||
<td>토큰 사용자의 채널에 속한 계약</td>
|
<td>토큰 채널에 속한 게임 서비스 범위</td>
|
||||||
<td><code>FC_CHANNEL = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL')</code></td>
|
<td>등록된 업무 관계가 있을 때만 DB 행 접근 함수가 생성</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>OWN_CUSTOMER</code> / <code>CHANNEL_CUSTOMER</code></td>
|
<td><code>OWN_CUSTOMER</code> / <code>CHANNEL_CUSTOMER</code></td>
|
||||||
<td>계약원장에서 담당자 또는 채널 기준으로 연결되는 고객·청구·담보·외부보유 행</td>
|
<td>게임 서비스·사용자 기준으로 연결되는 게임 로그·판매·환불 데이터 범위</td>
|
||||||
<td><code>EXISTS (SELECT 1 FROM POC_2.KB_CONTRACTS ...)</code></td>
|
<td>대상 테이블의 실제 키 관계를 DB 행 접근 함수가 검증한 뒤 생성</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>STATIC_SQL</code></td>
|
<td><code>STATIC_SQL</code></td>
|
||||||
<td>현재 객체 컬럼으로 표현한 고정 조건</td>
|
<td>현재 객체 컬럼으로 표현한 고정 조건</td>
|
||||||
<td><code>CONTRACT_STATUS = '정상'</code>처럼 검증된 현재 객체 컬럼 조건</td>
|
<td><code>GAME_ID = 'CZN'</code>처럼 검증된 현재 객체 컬럼 조건</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>
|
<p>
|
||||||
<code>POC_2</code> KB 업무 테이블의 table/column comment와 Oracle annotation을 조회·수정합니다.
|
<code>SGMP_POC</code> 게임 데이터 테이블의 table/column comment와 Oracle annotation을 조회·수정합니다.
|
||||||
Select AI profile의 <code>comments=true</code>, <code>annotations=true</code> 설정에서는 이 값들이 SQL 생성 근거로 들어갑니다.
|
Select AI profile의 <code>comments=true</code>, <code>annotations=true</code> 설정에서는 이 값들이 SQL 생성 근거로 들어갑니다.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-0">임의 스키마나 임의 테이블은 수정하지 않고, 백오피스가 승인한 7개 업무 테이블만 대상으로 합니다.</p>
|
<p class="mb-0">임의 스키마나 임의 테이블은 수정하지 않고, 백오피스가 승인한 7개 업무 테이블만 대상으로 합니다.</p>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>테이블 선택</h2>
|
<h2>테이블 선택</h2>
|
||||||
<p class="section-subtitle">정형 MCP/Select AI가 참조하는 KB 업무 원장 7개만 표시합니다.</p>
|
<p class="section-subtitle">정형 MCP/Select AI가 참조하는 게임 데이터 테이블만 표시합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge text-bg-secondary" th:text="${#lists.size(tables)}">7</span>
|
<span class="badge text-bg-secondary" th:text="${#lists.size(tables)}">7</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
class="structured-table-card"
|
class="structured-table-card"
|
||||||
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
||||||
th:href="@{/schema-metadata(table=${entry.key()})}">
|
th:href="@{/schema-metadata(table=${entry.key()})}">
|
||||||
<strong th:text="${entry.businessName()}">고객원장</strong>
|
<strong th:text="${entry.businessName()}">게임 사용자</strong>
|
||||||
<code th:text="${entry.tableName()}">KB_CUSTOMERS</code>
|
<code th:text="${entry.tableName()}">CZN_COMN_USER_MST</code>
|
||||||
<small th:text="${entry.description()}">고객 기본정보</small>
|
<small th:text="${entry.description()}">게임 사용자 마스터</small>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -44,9 +44,9 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="badge text-bg-secondary">TABLE</span>
|
<span class="badge text-bg-secondary">TABLE</span>
|
||||||
<h2 class="mt-2" th:text="${metadata.table().businessName()}">계약원장</h2>
|
<h2 class="mt-2" th:text="${metadata.table().businessName()}">게임 데이터</h2>
|
||||||
<p class="section-subtitle">
|
<p class="section-subtitle">
|
||||||
<code th:text="${'POC_2.' + metadata.table().tableName()}">POC_2.KB_CONTRACTS</code>
|
<code th:text="${'SGMP_POC.' + metadata.table().tableName()}">SGMP_POC.CZN_COMN_USER_MST</code>
|
||||||
<span th:text="${' · ' + metadata.table().description()}"> · 설명</span>
|
<span th:text="${' · ' + metadata.table().description()}"> · 설명</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,9 +29,9 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="item : ${scripts}" th:classappend="${selectedScript != null and item.scriptId() == selectedScript.scriptId()} ? ' table-primary'">
|
<tr th:each="item : ${scripts}" th:classappend="${selectedScript != null and item.scriptId() == selectedScript.scriptId()} ? ' table-primary'">
|
||||||
<td><span class="badge text-bg-light" th:text="${item.category()}">ASO / 마스킹</span></td>
|
<td><span class="badge text-bg-light" th:text="${item.category()}">ASO / 마스킹</span></td>
|
||||||
<td><code th:text="${item.fileName()}">62_kb_aso_masking_backoffice_metadata.sql</code></td>
|
<td><code th:text="${item.fileName()}">71_sg_identity_administration.sql</code></td>
|
||||||
<td>
|
<td>
|
||||||
<strong th:text="${item.title()}">컬럼 마스킹 규칙 메타데이터</strong>
|
<strong th:text="${item.title()}">사용자·그룹·역할 관리 모델</strong>
|
||||||
<div class="form-hint" th:text="${item.description()}">설명</div>
|
<div class="form-hint" th:text="${item.description()}">설명</div>
|
||||||
</td>
|
</td>
|
||||||
<td><a class="btn btn-sm rw-btn-secondary" th:href="@{/security-sql-scripts(script=${item.scriptId()})}">원문 보기</a></td>
|
<td><a class="btn btn-sm rw-btn-secondary" th:href="@{/security-sql-scripts(script=${item.scriptId()})}">원문 보기</a></td>
|
||||||
@@ -45,13 +45,13 @@
|
|||||||
<section class="content-band" th:if="${selectedScript}">
|
<section class="content-band" th:if="${selectedScript}">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="badge text-bg-secondary" th:text="${selectedScript.category()}">ASO / 마스킹</span>
|
<span class="badge text-bg-secondary" th:text="${selectedScript.category()}">Smilegate 권한</span>
|
||||||
<h2 class="mt-2" th:text="${selectedScript.title()}">컬럼 마스킹 규칙 메타데이터</h2>
|
<h2 class="mt-2" th:text="${selectedScript.title()}">사용자·그룹·역할 관리 모델</h2>
|
||||||
<p class="section-subtitle" th:text="${selectedScript.description()}">설명</p>
|
<p class="section-subtitle" th:text="${selectedScript.description()}">설명</p>
|
||||||
</div>
|
</div>
|
||||||
<code th:text="${selectedScript.fileName()}">62_kb_aso_masking_backoffice_metadata.sql</code>
|
<code th:text="${selectedScript.fileName()}">71_sg_identity_administration.sql</code>
|
||||||
</div>
|
</div>
|
||||||
<p class="form-hint">Git source: <code th:text="${'sql/adb/' + selectedScript.fileName()}">sql/adb/62_kb_aso_masking_backoffice_metadata.sql</code>. 실제 DB 배포본은 <a href="/vpd-filter-runtime">행 접근 필터 구조</a> 및 DB 배포 이력과 함께 확인하세요.</p>
|
<p class="form-hint">Git source: <code th:text="${'sql/adb/' + selectedScript.fileName()}">sql/adb/71_sg_identity_administration.sql</code>. 실제 DB 배포본은 <a href="/vpd-filter-runtime">행 접근 필터 구조</a> 및 DB 배포 이력과 함께 확인하세요.</p>
|
||||||
<form hx-post="/security-sql-scripts/explanation" hx-target="#security-sql-explanation" hx-swap="innerHTML" class="mb-3">
|
<form hx-post="/security-sql-scripts/explanation" hx-target="#security-sql-explanation" hx-swap="innerHTML" class="mb-3">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<input type="hidden" name="script" th:value="${selectedScript.scriptId()}">
|
<input type="hidden" name="script" th:value="${selectedScript.scriptId()}">
|
||||||
|
|||||||
@@ -8,19 +8,19 @@
|
|||||||
<h1>정형 데이터 조회</h1>
|
<h1>정형 데이터 조회</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>보험 원장 7개만 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.</p>
|
<p>스마일게이트 게임 데이터 테이블을 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
이 화면은 관리자용 원장 미리보기입니다. 사용자별 행 접근 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
|
이 화면은 관리자용 게임 데이터 미리보기입니다. 사용자별 행 접근 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>조회할 원장 선택</h2>
|
<h2>조회할 게임 데이터 선택</h2>
|
||||||
<p class="section-subtitle">KBAIPOC의 <code>POC_2</code> 스키마에서 승인된 정형 테이블만 표시합니다.</p>
|
<p class="section-subtitle"><code>SGMP_POC</code> 스키마에서 PoC 대상으로 등록한 게임 데이터 테이블만 표시합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="structured-table-grid">
|
<div class="structured-table-grid">
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
class="structured-table-card"
|
class="structured-table-card"
|
||||||
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
|
||||||
th:href="@{/structured-data(table=${entry.key()})}">
|
th:href="@{/structured-data(table=${entry.key()})}">
|
||||||
<strong th:text="${entry.businessName()}">고객원장</strong>
|
<strong th:text="${entry.businessName()}">게임 사용자</strong>
|
||||||
<code th:text="${entry.tableName()}">KB_CUSTOMERS</code>
|
<code th:text="${entry.tableName()}">CZN_COMN_USER_MST</code>
|
||||||
<small th:text="${entry.description()}">고객 기본정보</small>
|
<small th:text="${entry.description()}">게임 사용자 마스터</small>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -40,9 +40,9 @@
|
|||||||
<section class="content-band" th:if="${preview}">
|
<section class="content-band" th:if="${preview}">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2 th:text="${preview.table().businessName()}">고객원장</h2>
|
<h2 th:text="${preview.table().businessName()}">게임 사용자</h2>
|
||||||
<p class="section-subtitle">
|
<p class="section-subtitle">
|
||||||
<code th:text="${'POC_2.' + preview.table().tableName()}">POC_2.KB_CUSTOMERS</code>
|
<code th:text="${'SGMP_POC.' + preview.table().tableName()}">SGMP_POC.CZN_COMN_USER_MST</code>
|
||||||
<span th:text="${' · 최대 ' + preview.rowLimit() + '건'}"> · 최대 50건</span>
|
<span th:text="${' · 최대 ' + preview.rowLimit() + '건'}"> · 최대 50건</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head th:replace="~{fragments/layout :: head('HMM 액세스 토큰')}"></head>
|
<head th:replace="~{fragments/layout :: head('Smilegate 액세스 토큰')}"></head>
|
||||||
<body>
|
<body>
|
||||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||||
<main class="container py-4">
|
<main class="container py-4">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<h1>HMM 액세스 토큰</h1>
|
<h1>Smilegate 액세스 토큰</h1>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>도움말</summary>
|
<summary>도움말</summary>
|
||||||
<p>HMM HR 직원에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
|
<p>Data & AI PoC 도구 사용자에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -27,14 +27,14 @@
|
|||||||
<section class="content-band">
|
<section class="content-band">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>직원 토큰 발급</h2>
|
<h2>도구 사용자 토큰 발급</h2>
|
||||||
<p class="section-subtitle">활성 HMM 직원에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.</p>
|
<p class="section-subtitle">활성 Data & AI PoC 사용자에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/tokens" class="form-grid">
|
<form method="post" action="/tokens" class="form-grid">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<label>
|
<label>
|
||||||
HMM 직원
|
도구 사용자
|
||||||
<select class="form-select" name="userId" required>
|
<select class="form-select" name="userId" required>
|
||||||
<option value="" selected disabled>토큰을 발급할 직원 선택</option>
|
<option value="" selected disabled>토큰을 발급할 직원 선택</option>
|
||||||
<option th:each="user : ${users}"
|
<option th:each="user : ${users}"
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
용도 메모
|
용도 메모
|
||||||
<input class="form-control" name="description" maxlength="200" placeholder="예: HR 권한 확인">
|
<input class="form-control" name="description" maxlength="200" placeholder="예: 게임 데이터 AI 질의 검증">
|
||||||
</label>
|
</label>
|
||||||
<button class="btn rw-btn-primary" type="submit">검증 세션 발급</button>
|
<button class="btn rw-btn-primary" type="submit">검증 세션 발급</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<select class="form-select" name="userId" required>
|
<select class="form-select" name="userId" required>
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
<option th:each="user : ${users}" th:value="${user.userId()}" th:attr="data-user-label=${user.username()}"
|
<option th:each="user : ${users}" th:value="${user.userId()}" th:attr="data-user-label=${user.username()}"
|
||||||
th:text="${user.username() + ' (' + user.empNo() + ')'}">KB_VPD_ADMIN</option>
|
th:text="${user.username() + ' (' + user.empNo() + ')'}">sg-teamlead (SG-001)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
data-result=${columnRule.template().previewResult()},
|
data-result=${columnRule.template().previewResult()},
|
||||||
data-aso-function=${columnRule.template().asoFunction()},
|
data-aso-function=${columnRule.template().asoFunction()},
|
||||||
data-context=${'MR_' + columnRule.columnId()}"
|
data-context=${'MR_' + columnRule.columnId()}"
|
||||||
th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">KB_CUSTOMERS.RRN_MASKED</option>
|
th:text="${columnRule.targetLabel() + ' · ' + columnRule.ruleLabel()}">SGMP_POC.CZN_COMN_USER_MST.GUID</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
@@ -85,9 +85,9 @@
|
|||||||
<thead><tr><th>사용자</th><th>대상 컬럼</th><th>기본 규칙</th><th>원문 표시 상태</th><th>상태</th><th></th></tr></thead>
|
<thead><tr><th>사용자</th><th>대상 컬럼</th><th>기본 규칙</th><th>원문 표시 상태</th><th>상태</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="userRule : ${userRules}">
|
<tr th:each="userRule : ${userRules}">
|
||||||
<td th:text="${userRule.username()}">KB_VPD_ADMIN</td>
|
<td th:text="${userRule.username()}">sg-teamlead</td>
|
||||||
<td><code th:text="${userRule.targetLabel()}">POC_2.KB_CUSTOMERS.RRN_MASKED</code></td>
|
<td><code th:text="${userRule.targetLabel()}">SGMP_POC.CZN_COMN_USER_MST.GUID</code></td>
|
||||||
<td th:text="${userRule.ruleLabel()}">주민번호 기본 마스킹 · 주민등록번호 부분 마스킹</td>
|
<td th:text="${userRule.ruleLabel()}">게임 사용자 식별자 기본 마스킹 · 식별번호 부분 마스킹</td>
|
||||||
<td><span class="badge" th:classappend="${userRule.unmasked()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${userRule.decisionLabel()}">원문 표시 예외</span></td>
|
<td><span class="badge" th:classappend="${userRule.unmasked()} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${userRule.decisionLabel()}">원문 표시 예외</span></td>
|
||||||
<td th:text="${userRule.activeYn()}">Y</td>
|
<td th:text="${userRule.activeYn()}">Y</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -82,8 +82,8 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="policy : ${policies}">
|
<tr th:each="policy : ${policies}">
|
||||||
<td><code th:text="${policy.objectDisplayName()}">POC_2.KB_CONTRACTS</code></td>
|
<td><code th:text="${policy.objectDisplayName()}">SGMP_POC.CZN_COMN_USER_MST</code></td>
|
||||||
<td><code th:text="${policy.policyName()}">KB_KB_CONTRACTS_ROW_POLICY</code></td>
|
<td><code th:text="${policy.policyName()}">SG_CZN_USER_ROW_POLICY</code></td>
|
||||||
<td th:text="${policy.statementTypes()}">SELECT</td>
|
<td th:text="${policy.statementTypes()}">SELECT</td>
|
||||||
<td><span class="badge" th:classappend="${policy.enabled() == 'YES'} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${policy.enabled() == 'YES'} ? '적용됨' : '중지됨'">적용됨</span></td>
|
<td><span class="badge" th:classappend="${policy.enabled() == 'YES'} ? ' text-bg-success' : ' text-bg-secondary'" th:text="${policy.enabled() == 'YES'} ? '적용됨' : '중지됨'">적용됨</span></td>
|
||||||
<td><code th:text="${policy.functionDisplayName()}">ADMIN.CB_AGENT_DOC_VPD_FILTER</code></td>
|
<td><code th:text="${policy.functionDisplayName()}">ADMIN.CB_AGENT_DOC_VPD_FILTER</code></td>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
<tr><td>토큰 신뢰 경계</td><td>Bearer Token은 ORDS에서 검증하고 요청 종료 시 컨텍스트를 정리합니다.</td><td>ORDS Handler / <code>CB_AGENT_CTX_PKG</code></td></tr>
|
<tr><td>토큰 신뢰 경계</td><td>Bearer Token은 ORDS에서 검증하고 요청 종료 시 컨텍스트를 정리합니다.</td><td>ORDS Handler / <code>CB_AGENT_CTX_PKG</code></td></tr>
|
||||||
<tr><td>권한 결합</td><td>권한 내부 규칙은 AND, ALLOW 권한은 OR, DENY 조건은 최종적으로 제외합니다.</td><td><a href="/permissions">행 접근 규칙</a></td></tr>
|
<tr><td>권한 결합</td><td>권한 내부 규칙은 AND, ALLOW 권한은 OR, DENY 조건은 최종적으로 제외합니다.</td><td><a href="/permissions">행 접근 규칙</a></td></tr>
|
||||||
<tr><td>오류·미권한</td><td>유효한 컨텍스트나 ALLOW 권한이 없으면 행 접근은 차단돼야 합니다.</td><td><a href="/probe">접근 검증</a></td></tr>
|
<tr><td>오류·미권한</td><td>유효한 컨텍스트나 ALLOW 권한이 없으면 행 접근은 차단돼야 합니다.</td><td><a href="/probe">접근 검증</a></td></tr>
|
||||||
<tr><td>컬럼 보호</td><td>행 필터(VPD)와 보험료·주민번호 등의 ASO/Data Redaction 마스킹을 분리해 확인합니다.</td><td><a href="/masking-rules">컬럼 마스킹</a></td></tr>
|
<tr><td>컬럼 보호</td><td>행 필터(VPD)와 게임 사용자 식별자·거래 식별자의 ASO/Data Redaction 마스킹을 분리해 확인합니다.</td><td><a href="/masking-rules">컬럼 마스킹</a></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.mapper;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class SchemaMetadataMapperXmlTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void annotationUsageQueriesUseOnlyColumnsProvidedByTheAllView() throws IOException {
|
||||||
|
try (var input = getClass().getResourceAsStream("/mapper/SchemaMetadataMapper.xml")) {
|
||||||
|
String mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
assertThat(mapperXml)
|
||||||
|
.contains("FROM all_annotations_usage")
|
||||||
|
.contains("object_name = #{tableName,jdbcType=VARCHAR}")
|
||||||
|
.doesNotContain("object_owner");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,8 +23,8 @@ class MaskingPolicySynchronizerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void kbContractsPremiumPolicyIsManagedByBackoffice() {
|
void smilegateSalesTransactionPolicyIsManagedByBackoffice() {
|
||||||
assertThat(MaskingPolicySynchronizer.managedPolicyName("KB_CONTRACTS"))
|
assertThat(MaskingPolicySynchronizer.managedPolicyName("COMN_SALES_TXN"))
|
||||||
.isEqualTo("KB_CONTRACT_PREMIUM_REDACT");
|
.isEqualTo("SG_SALES_TXN_REDACT");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import org.junit.jupiter.api.Test;
|
|||||||
class McpSseServiceTest {
|
class McpSseServiceTest {
|
||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
private final SelectAiAgentOrdsService selectAiAgentOrdsService = new CapturingSelectAiAgentOrdsService();
|
private final SmilegateSelectAiService selectAiService = new CapturingSmilegateSelectAiService();
|
||||||
private final McpSseService service = new McpSseService(
|
private final McpSseService service = new McpSseService(
|
||||||
selectAiAgentOrdsService,
|
selectAiService,
|
||||||
objectMapper
|
objectMapper
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -22,12 +22,12 @@ class McpSseServiceTest {
|
|||||||
var tools = response.path("result").path("tools");
|
var tools = response.path("result").path("tools");
|
||||||
assertThat(tools).hasSize(1);
|
assertThat(tools).hasSize(1);
|
||||||
var selectAi = tools.get(0);
|
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"))
|
assertThat(selectAi.path("inputSchema").path("required"))
|
||||||
.extracting(node -> node.asText())
|
.extracting(node -> node.asText())
|
||||||
.contains("prompt");
|
.contains("prompt");
|
||||||
assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse();
|
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();
|
assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,31 +35,33 @@ class McpSseServiceTest {
|
|||||||
void callsVpdSelectAiThroughOrdsService() {
|
void callsVpdSelectAiThroughOrdsService() {
|
||||||
ObjectNode request = request(2, "tools/call");
|
ObjectNode request = request(2, "tools/call");
|
||||||
ObjectNode params = (ObjectNode) request.putObject("params");
|
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");
|
ObjectNode arguments = params.putObject("arguments");
|
||||||
arguments.put("prompt", "고객 수를 조회해 줘");
|
arguments.put("prompt", "카제나 AU를 조회해 줘");
|
||||||
arguments.put("limit", 25);
|
|
||||||
|
|
||||||
ObjectNode response = service.handle("default", request, "user-bearer");
|
ObjectNode response = service.handle("default", request, "user-bearer");
|
||||||
|
|
||||||
CapturingSelectAiAgentOrdsService agentService =
|
CapturingSmilegateSelectAiService agentService =
|
||||||
(CapturingSelectAiAgentOrdsService) selectAiAgentOrdsService;
|
(CapturingSmilegateSelectAiService) selectAiService;
|
||||||
assertThat(agentService.bearerToken).isEqualTo("user-bearer");
|
assertThat(agentService.bearerToken).isEqualTo("user-bearer");
|
||||||
assertThat(agentService.prompt).isEqualTo("고객 수를 조회해 줘");
|
assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘");
|
||||||
assertThat(agentService.limit).isEqualTo(25);
|
|
||||||
assertThat(response.path("error").isMissingNode()).isTrue();
|
assertThat(response.path("error").isMissingNode()).isTrue();
|
||||||
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
||||||
assertThat(response.path("result").path("content").get(0).path("text").asText())
|
String payload = response.path("result").path("content").get(0).path("text").asText();
|
||||||
.contains("KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1")
|
assertThat(payload)
|
||||||
.contains("SELECT COUNT(*) FROM KB_CUSTOMERS");
|
.contains("SGMP_POC_HAIKU45")
|
||||||
|
.contains("SELECT 1 FROM DUAL")
|
||||||
|
.contains("SHOWSQL_AND_EXECUTED")
|
||||||
|
.contains("READ_ONLY_EXECUTED")
|
||||||
|
.contains("RESULT");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() {
|
void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() {
|
||||||
ObjectNode request = request(3, "tools/call");
|
ObjectNode request = request(3, "tools/call");
|
||||||
ObjectNode params = (ObjectNode) request.putObject("params");
|
ObjectNode params = (ObjectNode) request.putObject("params");
|
||||||
params.put("name", "ords.query.kb_select_ai_vpd");
|
params.put("name", "oracle.select_ai.smilegate_game_text2sql");
|
||||||
params.putObject("arguments").put("prompt", "고객 수를 조회해 줘");
|
params.putObject("arguments").put("prompt", "카제나 AU를 조회해 줘");
|
||||||
|
|
||||||
ObjectNode response = service.handle("default", request, "");
|
ObjectNode response = service.handle("default", request, "");
|
||||||
|
|
||||||
@@ -78,24 +80,27 @@ class McpSseServiceTest {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class CapturingSelectAiAgentOrdsService extends SelectAiAgentOrdsService {
|
private static final class CapturingSmilegateSelectAiService extends SmilegateSelectAiService {
|
||||||
|
|
||||||
private String bearerToken;
|
private String bearerToken;
|
||||||
private String prompt;
|
private String prompt;
|
||||||
private int limit;
|
|
||||||
|
|
||||||
private CapturingSelectAiAgentOrdsService() {
|
private CapturingSmilegateSelectAiService() {
|
||||||
super(null, null, new ObjectMapper());
|
super(null, null, null, new ObjectMapper());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public JsonNode run(String bearerToken, String prompt, int limit) {
|
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
||||||
this.bearerToken = bearerToken;
|
this.bearerToken = bearerToken;
|
||||||
this.prompt = prompt;
|
this.prompt = prompt;
|
||||||
this.limit = limit;
|
ObjectNode response = new ObjectMapper().createObjectNode()
|
||||||
return new ObjectMapper().createObjectNode()
|
.put("profile", "SGMP_POC_HAIKU45")
|
||||||
.put("profile", "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1")
|
.put("generatedSql", "SELECT 1 FROM DUAL")
|
||||||
.put("generatedSql", "SELECT COUNT(*) FROM KB_CUSTOMERS");
|
.put("status", "SHOWSQL_AND_EXECUTED")
|
||||||
|
.put("execution", "READ_ONLY_EXECUTED")
|
||||||
|
.put("rowCount", 1);
|
||||||
|
response.putArray("items").addObject().put("RESULT", 1);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||||
|
import com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class SchemaMetadataServiceTest {
|
||||||
|
|
||||||
|
private static final StructuredDataTable GAME_USERS = new StructuredDataTable(
|
||||||
|
"game-users", "CZN_COMN_USER_MST", "게임 사용자", "카제나 게임 사용자 마스터");
|
||||||
|
|
||||||
|
private SchemaMetadataMapper mapper;
|
||||||
|
private SchemaMetadataService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mapper = mock(SchemaMetadataMapper.class);
|
||||||
|
StructuredDataService structuredDataService = mock(StructuredDataService.class);
|
||||||
|
when(structuredDataService.requireTable("game-users")).thenReturn(GAME_USERS);
|
||||||
|
when(structuredDataService.tables()).thenReturn(List.of(GAME_USERS));
|
||||||
|
when(structuredDataService.defaultKey()).thenReturn("game-users");
|
||||||
|
service = new SchemaMetadataService(mapper, structuredDataService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsOnlyTheSelectedSmilegateTableThroughMapper() {
|
||||||
|
when(mapper.findTableComment("SGMP_POC", "CZN_COMN_USER_MST"))
|
||||||
|
.thenReturn("게임별 사용자 기준 정보");
|
||||||
|
when(mapper.findAnnotations("CZN_COMN_USER_MST")).thenReturn(List.of(
|
||||||
|
new SchemaMetadataAnnotationRow(null, "BUSINESS_TERM", "게임 사용자"),
|
||||||
|
new SchemaMetadataAnnotationRow("GUID", "BUSINESS_TERM", "사용자 고유 식별자"),
|
||||||
|
new SchemaMetadataAnnotationRow("GUID", "BUSINESS_TERM", "고객 계정 식별자")
|
||||||
|
));
|
||||||
|
when(mapper.findColumns("SGMP_POC", "CZN_COMN_USER_MST")).thenReturn(List.of(
|
||||||
|
new SchemaMetadataColumnRow("GUID", "VARCHAR2(64)", "N", "사용자 GUID")
|
||||||
|
));
|
||||||
|
|
||||||
|
SchemaMetadataView view = service.find("game-users");
|
||||||
|
|
||||||
|
assertThat(view.tableComment()).isEqualTo("게임별 사용자 기준 정보");
|
||||||
|
assertThat(view.tableAnnotations()).singleElement()
|
||||||
|
.extracting(annotation -> annotation.name(), annotation -> annotation.value())
|
||||||
|
.containsExactly("BUSINESS_TERM", "게임 사용자");
|
||||||
|
assertThat(view.columns()).singleElement()
|
||||||
|
.satisfies(column -> {
|
||||||
|
assertThat(column.columnName()).isEqualTo("GUID");
|
||||||
|
assertThat(column.nullable()).isFalse();
|
||||||
|
assertThat(column.annotations()).singleElement()
|
||||||
|
.extracting(annotation -> annotation.value())
|
||||||
|
.isEqualTo("사용자 고유 식별자\n--- duplicate annotation value ---\n고객 계정 식별자");
|
||||||
|
});
|
||||||
|
verify(mapper).findAnnotations("CZN_COMN_USER_MST");
|
||||||
|
verify(mapper).findColumns("SGMP_POC", "CZN_COMN_USER_MST");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replacesAnExistingColumnAnnotationUsingMapperDdl() {
|
||||||
|
when(mapper.countColumn("SGMP_POC", "CZN_COMN_USER_MST", "GUID")).thenReturn(1);
|
||||||
|
when(mapper.countColumnAnnotation("CZN_COMN_USER_MST", "GUID", "BUSINESS_TERM"))
|
||||||
|
.thenReturn(1);
|
||||||
|
|
||||||
|
service.updateColumnAnnotation("game-users", "guid", "business_term", "사용자 고유 식별자");
|
||||||
|
|
||||||
|
verify(mapper).dropColumnAnnotation("SGMP_POC", "CZN_COMN_USER_MST", "GUID", "BUSINESS_TERM");
|
||||||
|
verify(mapper).addColumnAnnotation(
|
||||||
|
"SGMP_POC", "CZN_COMN_USER_MST", "GUID", "BUSINESS_TERM", "'사용자 고유 식별자'");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearsAnExistingTableAnnotationWithoutAddingAnEmptyValue() {
|
||||||
|
when(mapper.countTableAnnotation("CZN_COMN_USER_MST", "BUSINESS_TERM"))
|
||||||
|
.thenReturn(1);
|
||||||
|
|
||||||
|
service.updateTableAnnotation("game-users", "business_term", " ");
|
||||||
|
|
||||||
|
verify(mapper).dropTableAnnotation("SGMP_POC", "CZN_COMN_USER_MST", "BUSINESS_TERM");
|
||||||
|
verify(mapper, never()).addTableAnnotation(anyString(), anyString(), anyString(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAColumnThatIsNotPresentInTheSelectedTable() {
|
||||||
|
when(mapper.countColumn("SGMP_POC", "CZN_COMN_USER_MST", "UNKNOWN_COL")).thenReturn(0);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.updateColumnComment("game-users", "unknown_col", "설명"))
|
||||||
|
.isInstanceOf(AppException.class)
|
||||||
|
.hasMessage("선택한 테이블에 존재하지 않는 컬럼입니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,16 +14,13 @@ class SecuritySqlScriptServiceTest {
|
|||||||
assertThat(service.list())
|
assertThat(service.list())
|
||||||
.extracting(item -> item.fileName())
|
.extracting(item -> item.fileName())
|
||||||
.containsExactly(
|
.containsExactly(
|
||||||
"62_kb_aso_masking_backoffice_metadata.sql",
|
"70_sg_tool_user.sql",
|
||||||
"63_kb_aso_masking_rule_runtime.sql",
|
"71_sg_identity_administration.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"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assertThat(service.find("select-ai-vpd-ords").source())
|
assertThat(service.find("smilegate-identity-administration").source())
|
||||||
.contains("POST /ords/cb-ords/kb-select-ai-vpd/query")
|
.contains("create table sg_app_user")
|
||||||
.contains("Authorization: Bearer <VPD token>");
|
.contains("DATA_AI_TF");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -12,22 +12,22 @@ class StructuredDataServiceTest {
|
|||||||
private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class));
|
private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class));
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void exposesOnlyTheSevenApprovedStructuredTables() {
|
void exposesOnlyTheSevenApprovedSmilegateStructuredTables() {
|
||||||
assertThat(service.tables())
|
assertThat(service.tables())
|
||||||
.extracting(table -> table.tableName())
|
.extracting(table -> table.tableName())
|
||||||
.containsExactly(
|
.containsExactly(
|
||||||
"KB_CUSTOMERS",
|
"CZN_COMN_USER_MST",
|
||||||
"KB_PRODUCTS",
|
"CZN_COMN_CHARACTER_MST",
|
||||||
"KB_CONTRACTS",
|
"COMN_SALES_TXN",
|
||||||
"KB_COVERAGES",
|
"COMN_REFUND_TXN",
|
||||||
"KB_CLAIMS",
|
"COMN_SALES_PRODUCT_DISP_BAS",
|
||||||
"KB_EXTERNAL_HOLDINGS",
|
"COMN_GAME_SERVER_BAS",
|
||||||
"KB_STAKEHOLDERS");
|
"COMN_GAME_ALIAS_BAS");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsAnyTableOutsideTheServerSideAllowlist() {
|
void rejectsAnyTableOutsideTheServerSideAllowlist() {
|
||||||
assertThatThrownBy(() -> service.requireTable("KB_SECURITY_AUDIT_LOG"))
|
assertThatThrownBy(() -> service.requireTable("security-audit-log"))
|
||||||
.isInstanceOf(AppException.class)
|
.isInstanceOf(AppException.class)
|
||||||
.hasMessage("선택할 수 없는 정형 데이터 테이블입니다.");
|
.hasMessage("선택할 수 없는 정형 데이터 테이블입니다.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class GuidedFlowTemplateTest {
|
|||||||
.doesNotContain("dashboard-workflow")
|
.doesNotContain("dashboard-workflow")
|
||||||
.doesNotContain(">01<", ">02<");
|
.doesNotContain(">01<", ">02<");
|
||||||
assertThat(layout)
|
assertThat(layout)
|
||||||
.contains("데이터 접근 제어")
|
.contains("운영 사용자 관리")
|
||||||
.contains("접근 제어")
|
.contains("접근 제어")
|
||||||
.contains("보호·검증")
|
.contains("보호·검증")
|
||||||
.contains("연동 도구")
|
.contains("연동 도구")
|
||||||
@@ -122,7 +122,7 @@ class GuidedFlowTemplateTest {
|
|||||||
.contains("data-wizard-target=\"2\">대상")
|
.contains("data-wizard-target=\"2\">대상")
|
||||||
.doesNotContain(">1 역할<", ">2 객체<", "wizard-step-number");
|
.doesNotContain(">1 역할<", ">2 객체<", "wizard-step-number");
|
||||||
assertThat(tokens)
|
assertThat(tokens)
|
||||||
.contains("직원 토큰 발급", "검증 세션 발급")
|
.contains("도구 사용자 토큰 발급", "검증 세션 발급")
|
||||||
.doesNotContain("1. 검증할 사용자 선택", "2. 검증 세션 발급");
|
.doesNotContain("1. 검증할 사용자 선택", "2. 검증 세션 발급");
|
||||||
assertThat(probe)
|
assertThat(probe)
|
||||||
.contains("접근 검증 실행", "<strong>검증 결과</strong>")
|
.contains("접근 검증 실행", "<strong>검증 결과</strong>")
|
||||||
@@ -198,7 +198,7 @@ class GuidedFlowTemplateTest {
|
|||||||
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
|
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
|
||||||
|
|
||||||
assertThat(html)
|
assertThat(html)
|
||||||
.contains("value=\"OWN_CUSTOMER\">본인 담당 고객")
|
.contains("value=\"OWN_CUSTOMER\">담당 게임 사용자")
|
||||||
.contains("value=\"STATIC_SQL\">정적 SQL 조건식")
|
.contains("value=\"STATIC_SQL\">정적 SQL 조건식")
|
||||||
.contains("행 규칙의 두 가지 적용 방식 보기")
|
.contains("행 규칙의 두 가지 적용 방식 보기")
|
||||||
.contains("한 권한 안의 규칙은 모두 AND")
|
.contains("한 권한 안의 규칙은 모두 AND")
|
||||||
@@ -221,8 +221,8 @@ class GuidedFlowTemplateTest {
|
|||||||
String filters = template("vpd-filter-policies.html");
|
String filters = template("vpd-filter-policies.html");
|
||||||
|
|
||||||
assertThat(roles)
|
assertThat(roles)
|
||||||
.contains("표시 보호 등급 상한")
|
.contains("스마일게이트 Data & AI PoC 운영 역할")
|
||||||
.contains("행 접근 권한은 행 접근 규칙과 Oracle VPD 정책에서 결정합니다");
|
.contains("데이터 취급 등급");
|
||||||
assertThat(tokens)
|
assertThat(tokens)
|
||||||
.contains("회수·만료 포함")
|
.contains("회수·만료 포함")
|
||||||
.contains("현재 사용 가능한 토큰만");
|
.contains("현재 사용 가능한 토큰만");
|
||||||
|
|||||||
@@ -31,26 +31,26 @@ class MaskingRuleTemplateRenderTest {
|
|||||||
var engine = new SpringTemplateEngine();
|
var engine = new SpringTemplateEngine();
|
||||||
engine.setTemplateResolver(resolver);
|
engine.setTemplateResolver(resolver);
|
||||||
|
|
||||||
var object = new ProtectedObject(7L, "POC_2", "KB_CUSTOMERS", "kb/customers", "Y");
|
var object = new ProtectedObject(7L, "SGMP_POC", "CZN_COMN_USER_MST", "sgmp-poc/czn-users", "Y");
|
||||||
var contractObject = new ProtectedObject(8L, "POC_2", "KB_CONTRACTS", "kb/contracts", "Y");
|
var transactionObject = new ProtectedObject(8L, "SGMP_POC", "COMN_SALES_TXN", "sgmp-poc/sales", "Y");
|
||||||
var column = new ProtectedColumn(11L, 7L, "RRN_MASKED", "Y", null, "RESTRICTED", "NULLIFY");
|
var column = new ProtectedColumn(11L, 7L, "GUID", "Y", null, "RESTRICTED", "NULLIFY");
|
||||||
var rule = new MaskingRule(1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "테스트", "Y");
|
var rule = new MaskingRule(1L, "GAME_GUID_MASK", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "테스트", "Y");
|
||||||
var columnRule = new ColumnMaskingRule(11L, 7L, "POC_2", "KB_CUSTOMERS", "RRN_MASKED",
|
var columnRule = new ColumnMaskingRule(11L, 7L, "SGMP_POC", "CZN_COMN_USER_MST", "GUID",
|
||||||
1L, "KB_RRN_STANDARD", "주민번호 기본 마스킹", "RRN_PARTIAL", "Y");
|
1L, "GAME_GUID_MASK", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "Y");
|
||||||
var context = new Context(Locale.KOREAN);
|
var context = new Context(Locale.KOREAN);
|
||||||
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
|
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
|
||||||
context.setVariable("templates", List.of(MaskingTemplate.values()));
|
context.setVariable("templates", List.of(MaskingTemplate.values()));
|
||||||
context.setVariable("rules", List.of(rule));
|
context.setVariable("rules", List.of(rule));
|
||||||
context.setVariable("columnRules", List.of(columnRule));
|
context.setVariable("columnRules", List.of(columnRule));
|
||||||
var status = new MaskingPolicyStatus(
|
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("policyStatuses", List.of(status));
|
||||||
context.setVariable("policyStatusByObjectName", Map.of("KB_CUSTOMERS", status));
|
context.setVariable("policyStatusByObjectName", Map.of("CZN_COMN_USER_MST", status));
|
||||||
context.setVariable("objects", List.of(object, contractObject));
|
context.setVariable("objects", List.of(object, transactionObject));
|
||||||
context.setVariable("maskingTargetObjects", List.of(object, contractObject));
|
context.setVariable("maskingTargetObjects", List.of(object, transactionObject));
|
||||||
context.setVariable("sensitiveColumnsByObject", Map.of(7L, List.of(column), 8L, List.of()));
|
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);
|
String rulesPage = engine.process("masking-rules", context);
|
||||||
|
|
||||||
@@ -64,13 +64,13 @@ class MaskingRuleTemplateRenderTest {
|
|||||||
.contains("DB Redaction 컬럼")
|
.contains("DB Redaction 컬럼")
|
||||||
.contains("레거시 VPD 컬럼 제어")
|
.contains("레거시 VPD 컬럼 제어")
|
||||||
.contains("마스킹 대상 컬럼 추가")
|
.contains("마스킹 대상 컬럼 추가")
|
||||||
.contains("POC_2.KB_CONTRACTS.PREMIUM")
|
.contains("SGMP_POC.COMN_SALES_TXN.GUID")
|
||||||
.contains("KB_CUSTOMER_PII_REDACT")
|
.contains("SG_CZN_USER_REDACT")
|
||||||
.contains("적용됨");
|
.contains("적용됨");
|
||||||
|
|
||||||
context.setVariable("users", List.of(new AppUser(2L, "KB_VPD_ADMIN", "ADMIN", "SEC", "N", "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, "KB_VPD_ADMIN", 11L,
|
context.setVariable("userRules", List.of(new UserMaskingRule(2L, "sg-teamlead", 11L,
|
||||||
"POC_2", "KB_CUSTOMERS", "RRN_MASKED", "주민번호 기본 마스킹", "RRN_PARTIAL", "UNMASK", "Y")));
|
"SGMP_POC", "CZN_COMN_USER_MST", "GUID", "게임 사용자 식별자 기본 마스킹", "TEXT_PARTIAL", "UNMASK", "Y")));
|
||||||
String userPage = engine.process("user-masking-rules", context);
|
String userPage = engine.process("user-masking-rules", context);
|
||||||
|
|
||||||
assertThat(userPage)
|
assertThat(userPage)
|
||||||
|
|||||||
Reference in New Issue
Block a user