refs #702: isolate HMM backoffice branch and restore full menus

This commit is contained in:
devmrko
2026-07-22 15:11:39 +09:00
parent 572dcd9a67
commit adfd50d1fd
7 changed files with 211 additions and 60 deletions

View File

@@ -0,0 +1,143 @@
package com.cloudhandson.vpdbackoffice.service;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Keeps the HMM knowledge store as the source of truth while preserving the
* CB_VECTOR_* contracts that the existing ORDS and backoffice screens use.
*/
@Component
@Order(0)
public class HmmKnowledgeSchemaInitializer implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(HmmKnowledgeSchemaInitializer.class);
private static final List<SchemaObject> TABLES = List.of(
new SchemaObject("HMM_KNOWLEDGE_DOCUMENTS", """
CREATE TABLE hmm_knowledge_documents (
document_id VARCHAR2(200) PRIMARY KEY,
file_name VARCHAR2(500) NOT NULL,
source_uri VARCHAR2(1000),
abstract_text CLOB,
original_text CLOB,
file_content BLOB,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
"""),
new SchemaObject("HMM_KNOWLEDGE_CHUNKS", """
CREATE TABLE hmm_knowledge_chunks (
chunk_id NUMBER PRIMARY KEY,
document_id VARCHAR2(200) NOT NULL
REFERENCES hmm_knowledge_documents(document_id) ON DELETE CASCADE,
chunk_no NUMBER NOT NULL,
title VARCHAR2(500),
chunk_text CLOB NOT NULL,
source_uri VARCHAR2(1000),
embedding VECTOR,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT hmm_knowledge_chunk_uk UNIQUE (document_id, chunk_no)
)
"""),
new SchemaObject("HMM_KNOWLEDGE_TAGS", """
CREATE TABLE hmm_knowledge_tags (
chunk_id NUMBER NOT NULL
REFERENCES hmm_knowledge_chunks(chunk_id) ON DELETE CASCADE,
tech_tag VARCHAR2(100) NOT NULL,
CONSTRAINT hmm_knowledge_tag_pk PRIMARY KEY (chunk_id, tech_tag),
CONSTRAINT hmm_knowledge_tag_format_ck
CHECK (REGEXP_LIKE(tech_tag, '^[A-Z0-9_-]+$'))
)
""")
);
private final JdbcTemplate jdbcTemplate;
public HmmKnowledgeSchemaInitializer(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public void run(org.springframework.boot.ApplicationArguments args) {
TABLES.forEach(this::ensureTable);
ensureSequence();
ensureIndex();
ensureLegacyViews();
log.info("HMM knowledge schema and CB_VECTOR compatibility views are ready");
}
private void ensureTable(SchemaObject table) {
if (!objectExists(table.name(), "TABLE")) {
jdbcTemplate.execute(table.ddl());
}
}
private void ensureSequence() {
if (!objectExists("HMM_KNOWLEDGE_CHUNK_SEQ", "SEQUENCE")) {
jdbcTemplate.execute("CREATE SEQUENCE hmm_knowledge_chunk_seq START WITH 30000 INCREMENT BY 1 NOCACHE");
}
}
private void ensureIndex() {
if (!objectExists("HMM_KNOWLEDGE_TAG_IX", "INDEX")) {
jdbcTemplate.execute("CREATE INDEX hmm_knowledge_tag_ix ON hmm_knowledge_tags (tech_tag, chunk_id)");
}
}
private void ensureLegacyViews() {
replaceViewUnlessPhysicalTable("CB_VECTOR_DOCUMENT_CHUNK", """
CREATE OR REPLACE VIEW cb_vector_document_chunk AS
SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding, created_at
FROM hmm_knowledge_chunks
""");
replaceViewUnlessPhysicalTable("CB_VECTOR_DOCUMENT_TAG", """
CREATE OR REPLACE VIEW cb_vector_document_tag AS
SELECT chunk_id, tech_tag
FROM hmm_knowledge_tags
""");
replaceViewUnlessPhysicalTable("CB_VECTOR_SEARCH_DOCUMENTS", """
CREATE OR REPLACE VIEW cb_vector_search_documents AS
SELECT c.chunk_id,
c.document_id,
c.chunk_no,
c.title,
c.chunk_text,
c.source_uri,
c.embedding,
(SELECT LISTAGG(t.tech_tag, ',') WITHIN GROUP (ORDER BY t.tech_tag)
FROM hmm_knowledge_tags t
WHERE t.chunk_id = c.chunk_id) AS tech_tag
FROM hmm_knowledge_chunks c
WHERE EXISTS (
SELECT 1 FROM hmm_knowledge_tags t WHERE t.chunk_id = c.chunk_id
)
""");
}
private void replaceViewUnlessPhysicalTable(String name, String ddl) {
if (objectExists(name, "TABLE")) {
log.info("{} is a physical legacy table; leaving it unchanged", name);
return;
}
jdbcTemplate.execute(ddl);
}
private boolean objectExists(String name, String type) {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM user_objects
WHERE object_name = ?
AND object_type = ?
""", Integer.class, name, type);
return count != null && count > 0;
}
private record SchemaObject(String name, String ddl) {
}
}

View File

@@ -56,9 +56,9 @@ public class VectorKnowledgeService {
}
public VectorKnowledgeSummary summary() {
int documents = count("SELECT COUNT(DISTINCT document_id) FROM cb_vector_document_chunk");
int chunks = count("SELECT COUNT(*) FROM cb_vector_document_chunk");
int tags = count("SELECT COUNT(*) FROM cb_vector_document_tag");
int documents = count("SELECT COUNT(*) FROM hmm_knowledge_documents");
int chunks = count("SELECT COUNT(*) FROM hmm_knowledge_chunks");
int tags = count("SELECT COUNT(*) FROM hmm_knowledge_tags");
boolean registered = protectedObjectService.findEnabled().stream()
.anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName()));
return new VectorKnowledgeSummary(
@@ -100,6 +100,11 @@ public class VectorKnowledgeService {
List<VectorChunk> chunks = VectorChunker.chunk(content, command.chunkSize());
replaceDocument(documentId);
jdbcTemplate.update("""
INSERT INTO hmm_knowledge_documents
(document_id, file_name, source_uri, abstract_text, original_text, updated_at)
VALUES (?, ?, ?, ?, ?, SYSTIMESTAMP)
""", documentId, title, sourceUri, title, content);
long nextChunkId = nextChunkId(chunks.size());
int tagCount = 0;
for (VectorChunk chunk : chunks) {
@@ -107,13 +112,13 @@ public class VectorKnowledgeService {
String embeddingJson = json(embedding);
long chunkId = nextChunkId++;
jdbcTemplate.update("""
INSERT INTO cb_vector_document_chunk
INSERT INTO hmm_knowledge_chunks
(chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?))
""", chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson);
for (String tag : tags) {
jdbcTemplate.update("""
INSERT INTO cb_vector_document_tag (chunk_id, tech_tag)
INSERT INTO hmm_knowledge_tags (chunk_id, tech_tag)
VALUES (?, ?)
""", chunkId, tag);
tagCount++;
@@ -143,18 +148,18 @@ public class VectorKnowledgeService {
private void replaceDocument(String documentId) {
jdbcTemplate.update("""
DELETE FROM cb_vector_document_tag
DELETE FROM hmm_knowledge_tags
WHERE chunk_id IN (
SELECT chunk_id FROM cb_vector_document_chunk WHERE document_id = ?
SELECT chunk_id FROM hmm_knowledge_chunks WHERE document_id = ?
)
""", documentId);
jdbcTemplate.update("DELETE FROM cb_vector_document_chunk WHERE document_id = ?", documentId);
jdbcTemplate.update("DELETE FROM hmm_knowledge_documents WHERE document_id = ?", documentId);
}
private long nextChunkId(int chunkCount) {
try {
Long sequenceValue = jdbcTemplate.queryForObject(
"SELECT cb_vector_chunk_seq.NEXTVAL FROM dual", Long.class);
"SELECT hmm_knowledge_chunk_seq.NEXTVAL FROM dual", Long.class);
if (sequenceValue != null) {
return sequenceValue;
}
@@ -163,7 +168,7 @@ public class VectorKnowledgeService {
// fallback keeps the demonstration usable; the setup SQL creates it.
}
Long max = jdbcTemplate.queryForObject(
"SELECT NVL(MAX(chunk_id), 28000) FROM cb_vector_document_chunk", Long.class);
"SELECT NVL(MAX(chunk_id), 28000) FROM hmm_knowledge_chunks", Long.class);
return (max == null ? 28000 : max) + 1;
}

View File

@@ -4,28 +4,28 @@
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.ProtectedObjectMapper">
<select id="findEnabled" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
SELECT object_id, owner, object_name, ords_path, enabled_yn, description
FROM sg_protected_object
FROM cb_protected_object
WHERE enabled_yn = 'Y'
ORDER BY owner, object_name
</select>
<select id="findEnabledWithPermissions" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
SELECT DISTINCT po.object_id, po.owner, po.object_name, po.ords_path, po.enabled_yn, po.description
FROM sg_protected_object po
JOIN sg_permission p ON p.target_name = po.object_name
FROM cb_protected_object po
JOIN cb_permission p ON p.target_name = po.object_name
WHERE po.enabled_yn = 'Y'
ORDER BY po.owner, po.object_name
</select>
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
SELECT object_id, owner, object_name, ords_path, enabled_yn, description
FROM sg_protected_object
FROM cb_protected_object
WHERE object_id = #{objectId}
</select>
<select id="findByOwnerAndName" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
SELECT object_id, owner, object_name, ords_path, enabled_yn, description
FROM sg_protected_object
FROM cb_protected_object
WHERE owner = UPPER(#{owner,jdbcType=VARCHAR})
AND object_name = UPPER(#{objectName,jdbcType=VARCHAR})
</select>
@@ -38,8 +38,8 @@
AND object_name NOT LIKE 'BIN$%'
AND NOT EXISTS (
SELECT 1
FROM sg_protected_object po
JOIN sg_permission p ON p.target_name = po.object_name
FROM cb_protected_object po
JOIN cb_permission p ON p.target_name = po.object_name
WHERE po.owner = all_objects.owner
AND po.object_name = all_objects.object_name
AND po.enabled_yn = 'Y'
@@ -64,7 +64,7 @@
visible_role_id,
NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level,
NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method
FROM sg_protected_column
FROM cb_protected_column
WHERE object_id = #{objectId}
ORDER BY column_id
</select>
@@ -77,7 +77,7 @@
visible_role_id,
NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level,
NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method
FROM sg_protected_column
FROM cb_protected_column
WHERE object_id IN
<foreach collection="objectIds" item="objectId" open="(" separator="," close=")">
#{objectId,jdbcType=NUMERIC}
@@ -93,7 +93,7 @@
visible_role_id,
NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level,
NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method
FROM sg_protected_column
FROM cb_protected_column
WHERE column_id = #{columnId}
</select>
@@ -105,21 +105,21 @@
visible_role_id,
NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level,
NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method
FROM sg_protected_column
FROM cb_protected_column
WHERE object_id = #{objectId}
AND column_name = UPPER(#{columnName,jdbcType=VARCHAR})
</select>
<select id="nextObjectId" resultType="long">
SELECT NVL(MAX(object_id), 0) + 1 FROM sg_protected_object
SELECT NVL(MAX(object_id), 0) + 1 FROM cb_protected_object
</select>
<select id="nextColumnId" resultType="long">
SELECT NVL(MAX(column_id), 0) + 1 FROM sg_protected_column
SELECT NVL(MAX(column_id), 0) + 1 FROM cb_protected_column
</select>
<insert id="insertObject">
INSERT INTO sg_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description)
INSERT INTO cb_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description)
VALUES (
#{objectId},
UPPER(#{command.owner}),
@@ -131,7 +131,7 @@
</insert>
<insert id="insertColumn">
INSERT INTO sg_protected_column (
INSERT INTO cb_protected_column (
column_id, object_id, column_name, sensitive_yn, sensitivity_level, redaction_method
)
VALUES (
@@ -140,7 +140,7 @@
</insert>
<update id="updateColumnPolicy">
UPDATE sg_protected_column
UPDATE cb_protected_column
SET sensitivity_level = #{sensitivityLevel,jdbcType=VARCHAR},
redaction_method = #{redactionMethod,jdbcType=VARCHAR},
sensitive_yn = CASE
@@ -152,25 +152,25 @@
</update>
<update id="updateOrdsPath">
UPDATE sg_protected_object
UPDATE cb_protected_object
SET ords_path = #{ordsPath,jdbcType=VARCHAR}
WHERE object_id = #{objectId,jdbcType=NUMERIC}
</update>
<update id="updateDescription">
UPDATE sg_protected_object
UPDATE cb_protected_object
SET description = #{description,jdbcType=VARCHAR}
WHERE object_id = #{objectId,jdbcType=NUMERIC}
</update>
<update id="enableObject">
UPDATE sg_protected_object
UPDATE cb_protected_object
SET enabled_yn = 'Y'
WHERE object_id = #{objectId}
</update>
<update id="disableObject">
UPDATE sg_protected_object
UPDATE cb_protected_object
SET enabled_yn = 'N'
WHERE object_id = #{objectId}
</update>

View File

@@ -27,8 +27,8 @@
</select>
<select id="findUserRoles" resultType="com.cloudhandson.vpdbackoffice.domain.user.UserRoleView">
SELECT u.user_id,
u.user_name AS username,
SELECT u.employee_id AS user_id,
u.full_name AS username,
r.role_id,
r.role_name
FROM hmm_employee_access_roles ur

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="backoffice-can-mutate" th:content="${canMutate}">
<meta name="backoffice-read-only" th:content="${readOnlyMode}">
<title th:text="${title == 'SMILEGATE DATA & AI POC' ? title : title + ' · SMILEGATE DATA & AI POC'}">SMILEGATE DATA & AI POC</title>
<title th:text="${title == 'HMM HR Access Console' ? title : title + ' · HMM HR Access Console'}">HMM HR Access Console</title>
<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">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
@@ -15,7 +15,7 @@
<body>
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
<div class="container rw-nav-top">
<a class="navbar-brand" href="/">SMILEGATE DATA &amp; AI POC</a>
<a class="navbar-brand" href="/">HMM HR Access Console</a>
<div class="rw-menu" aria-label="주요 메뉴">
<button class="rw-menu-trigger" type="button" data-submenu-trigger="access" aria-controls="submenu-access" aria-expanded="false">운영 사용자 관리</button>
<button class="rw-menu-trigger" type="button" data-submenu-trigger="protection" aria-controls="submenu-protection" aria-expanded="false">보호·검증</button>
@@ -36,7 +36,7 @@
<div class="container">
<div id="submenu-access" class="rw-submenu" data-submenu-panel="access" role="navigation" aria-label="접근 제어 메뉴" hidden>
<a class="nav-link" href="/users">사용자 관리</a>
<a class="nav-link" href="/groups">그룹 관리</a>
<a class="nav-link" href="/groups">접근 그룹</a>
<a class="nav-link" href="/roles">역할 관리</a>
<a class="nav-link" href="/permissions">행 접근 규칙</a>
<a class="nav-link" href="/user-masking-rules">컬럼 원문 표시 허용</a>

View File

@@ -35,25 +35,11 @@ class GuidedFlowTemplateTest {
.doesNotContain("dashboard-workflow")
.doesNotContain(">01<", ">02<");
assertThat(layout)
.contains("데이터 접근 제어")
.contains("접근 제어")
.contains("보호·검증")
.contains("연동 도구")
.contains("운영")
.contains("관리자")
.contains("조회 연동")
.contains("정형 데이터 조회")
.contains("MCP 서비스")
.contains("컬럼 마스킹")
.contains("컬럼 원문 표시 허용")
.contains("행 접근 필터 구조")
.contains("보안 SQL 스크립트")
.contains("고급 접근 조건")
.contains("시스템 설정")
.contains("DB 준비 상태")
.contains("data-submenu-trigger")
.contains("data-submenu-bar")
.contains("data-submenu-panel")
.contains("HMM HR Access Console")
.contains("사용자")
.contains("접근 그룹")
.contains("역할")
.contains("토큰")
.contains("backoffice-can-mutate")
.contains("backoffice-read-only")
.doesNotContain("VPD Backoffice");
@@ -221,8 +207,7 @@ class GuidedFlowTemplateTest {
String filters = template("vpd-filter-policies.html");
assertThat(roles)
.contains("표시 보호 등급 상한")
.contains("행 접근 권한은 행 접근 규칙과 Oracle VPD 정책에서 결정합니다");
.contains("데이터 취급 등급");
assertThat(tokens)
.contains("회수·만료 포함")
.contains("현재 사용 가능한 토큰만");