[Developer] #618 add structured data browser

This commit is contained in:
devmrko
2026-07-07 13:14:46 +09:00
parent 8e1e521a70
commit 6b60757721
10 changed files with 374 additions and 1 deletions

View File

@@ -0,0 +1,12 @@
package com.cloudhandson.vpdbackoffice.domain.structured;
import java.util.List;
import java.util.Map;
public record StructuredDataPreview(
StructuredDataTable table,
List<String> columns,
List<Map<String, Object>> rows,
int rowLimit
) {
}

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.vpdbackoffice.domain.structured;
public record StructuredDataTable(
String key,
String tableName,
String businessName,
String description
) {
}

View File

@@ -0,0 +1,69 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataPreview;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import java.util.List;
import java.util.Map;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@Service
public class StructuredDataService {
private static final String OWNER = "POC_2";
private static final int ROW_LIMIT = 50;
private static final List<StructuredDataTable> TABLES = List.of(
new StructuredDataTable("customers", "KB_CUSTOMERS", "고객원장", "고객 기본정보"),
new StructuredDataTable("products", "KB_PRODUCTS", "상품원장", "보험상품 마스터"),
new StructuredDataTable("contracts", "KB_CONTRACTS", "계약원장", "보험계약 정보"),
new StructuredDataTable("coverages", "KB_COVERAGES", "담보원장", "보장·특약 정보"),
new StructuredDataTable("claims", "KB_CLAIMS", "청구원장", "보험금 청구·지급 정보"),
new StructuredDataTable("external-holdings", "KB_EXTERNAL_HOLDINGS", "외부보유정보 원장", "타사·외부 가입·보유 정보"),
new StructuredDataTable("stakeholders", "KB_STAKEHOLDERS", "이해관계자 원장", "역할·담당자 매핑"));
private final JdbcTemplate jdbcTemplate;
public StructuredDataService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<StructuredDataTable> tables() {
return TABLES;
}
public String defaultKey() {
return TABLES.getFirst().key();
}
public StructuredDataTable requireTable(String key) {
return TABLES.stream()
.filter(table -> table.key().equals(key))
.findFirst()
.orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다."));
}
public StructuredDataPreview preview(String key) {
StructuredDataTable table = requireTable(key);
try {
List<String> columns = jdbcTemplate.query(
"""
SELECT column_name
FROM all_tab_columns
WHERE owner = ?
AND table_name = ?
ORDER BY column_id
""",
(resultSet, rowNum) -> resultSet.getString(1), OWNER, table.tableName());
if (columns.isEmpty()) {
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM " + OWNER + "." + table.tableName() + " WHERE ROWNUM <= ?", ROW_LIMIT);
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
} catch (DataAccessException exception) {
throw new AppException("정형 데이터를 조회할 수 없습니다. POC_2 조회 권한과 대상 테이블 상태를 확인하세요.");
}
}
}

View File

@@ -0,0 +1,34 @@
package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.StructuredDataService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class StructuredDataController {
private final StructuredDataService structuredDataService;
public StructuredDataController(StructuredDataService structuredDataService) {
this.structuredDataService = structuredDataService;
}
@GetMapping("/structured-data")
public String structuredData(
@RequestParam(required = false) String table,
Model model
) {
String selectedKey = table == null || table.isBlank() ? structuredDataService.defaultKey() : table;
model.addAttribute("tables", structuredDataService.tables());
model.addAttribute("selectedKey", selectedKey);
try {
model.addAttribute("preview", structuredDataService.preview(selectedKey));
} catch (AppException exception) {
model.addAttribute("errorMessage", exception.getMessage());
}
return "structured-data";
}
}

View File

@@ -2057,6 +2057,50 @@ body {
text-anchor: middle;
}
.structured-table-grid {
display: grid;
gap: .75rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.structured-table-card {
display: grid;
gap: .3rem;
min-height: 112px;
padding: .9rem;
color: var(--rw-text);
text-decoration: none;
background: var(--rw-surface);
border: 1px solid var(--rw-border);
border-radius: 8px;
}
.structured-table-card:hover {
color: var(--rw-text);
border-color: var(--rw-primary);
box-shadow: 0 2px 8px rgb(35 77 153 / 12%);
}
.structured-table-card.is-selected {
background: var(--rw-primary-soft);
border-color: var(--rw-primary);
box-shadow: inset 0 0 0 1px var(--rw-primary);
}
.structured-table-card code {
color: var(--rw-primary);
font-size: .8rem;
}
.structured-table-card small {
color: var(--rw-muted);
}
.structured-data-result td,
.structured-data-result th {
white-space: nowrap;
}
.product-help-sql {
background: var(--rw-primary-soft);
border: 1px solid color-mix(in srgb, var(--rw-primary) 26%, var(--rw-border));

View File

@@ -44,6 +44,7 @@
</div>
<div id="submenu-integration" class="rw-submenu" data-submenu-panel="integration" role="navigation" aria-label="연동 도구 메뉴" hidden>
<a class="nav-link" href="/objects">조회 대상</a>
<a class="nav-link" href="/structured-data">정형 데이터 조회</a>
<a class="nav-link" href="/ords-handlers">조회 연동</a>
<a class="nav-link" href="/vector-knowledge">지식자료 관리</a>
<a class="nav-link" href="/mcp-chatbot">대화형 검색</a>

View File

@@ -0,0 +1,69 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('정형 데이터 조회')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>정형 데이터 조회</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>보험 원장 7개만 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.</p>
</details>
</div>
<div class="alert alert-warning">
이 화면은 관리자용 원장 미리보기입니다. 사용자별 VPD 적용 결과는 <a href="/probe">접근 검증</a>에서 확인하세요.
</div>
<section class="content-band">
<div class="section-heading">
<div>
<h2>조회할 원장 선택</h2>
<p class="section-subtitle">KBAIPOC의 <code>POC_2</code> 스키마에서 승인된 정형 테이블만 표시합니다.</p>
</div>
</div>
<div class="structured-table-grid">
<a th:each="entry : ${tables}"
class="structured-table-card"
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
th:href="@{/structured-data(table=${entry.key()})}">
<strong th:text="${entry.businessName()}">고객원장</strong>
<code th:text="${entry.tableName()}">KB_CUSTOMERS</code>
<small th:text="${entry.description()}">고객 기본정보</small>
</a>
</div>
</section>
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
<section class="content-band" th:if="${preview}">
<div class="section-heading">
<div>
<h2 th:text="${preview.table().businessName()}">고객원장</h2>
<p class="section-subtitle">
<code th:text="${'POC_2.' + preview.table().tableName()}">POC_2.KB_CUSTOMERS</code>
<span th:text="${' · 최대 ' + preview.rowLimit() + '건'}"> · 최대 50건</span>
</p>
</div>
</div>
<div class="table-responsive structured-data-result">
<table class="table table-sm align-middle">
<thead>
<tr><th th:each="column : ${preview.columns()}" th:text="${column}">COLUMN</th></tr>
</thead>
<tbody>
<tr th:each="row : ${preview.rows()}">
<td th:each="column : ${preview.columns()}"
th:text="${row.get(column) == null ? '-' : row.get(column)}">value</td>
</tr>
<tr th:if="${#lists.isEmpty(preview.rows())}">
<td class="text-muted" th:attr="colspan=${#lists.size(preview.columns())}">표시할 데이터가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>