feat: 데이터 도메인 사전 + 데이터 사전 시스템 (V20)

3 agents 병렬 작업 (dba / api / frontend) 결과물 + SQL 버그 2건 fix.

DB (V20__데이터사전.sql):
- data_domain (domain_code PK, category, data_type, length/precision/scale,
  format_pattern, validation_regex, masking_type, encrypted, ...)
- data_dictionary (dict_id PK, table_name, column_name, domain_code FK,
  column_label, is_pk/is_fk/fk_reference, business_rule, sample_value, ...)
- VIEW v_data_dictionary_full: information_schema + pg_description +
  data_dictionary + data_domain 4-way LEFT JOIN
  → ga 스키마 모든 컬럼 자동 노출 (사전 미등록 컬럼도 회색 표시)
- 도메인 30개 초기 데이터 (IDENTIFIER 7 / PII 6 / MONEY 5 / RATE 3 /
  DATE 4 / CODE 4 / TEXT 3 / JSON 2)
- 사전 매핑 50개 (agent 10 / contract 7 / recruit_ledger 9 /
  settle_master 9 / payment 6)
- 메뉴 SYSTEM_DATA_DOMAIN, SYSTEM_DATA_DICT 등록 + 권한

Backend:
- ga-core VO 9개 + Mapper 2개 + XML 2개
- ga-api Service 2 (도메인 삭제 시 사전 참조 검증) + Controller 2
- 엔드포인트:
  · /api/data-domains (CRUD + /options)
  · /api/data-dictionary (CRUD + /tables + /full?tableName=)

Frontend:
- api/dict.ts (13 endpoints)
- pages/system/DataDomainList.tsx — 카테고리 색상 Tag, 마스킹/암호화 표시,
  ModalForm 등록/수정/삭제
- pages/system/DataDictionary.tsx — 좌(테이블 목록 + prefix 그룹핑) +
  우(v_data_dictionary_full 조회, 사전 미등록 컬럼은 회색+점선 추가 버튼)
- App.tsx + MenuIcon.tsx 매핑 추가

Bug fix:
- DataDomainMapper: WHERE is_active = true → 'Y' (CHAR vs boolean)
- DataDictionaryMapper.selectFromView: ORDER BY sort_order 제거
  (view 에 없는 컬럼)

검증:
- ./gradlew :ga-api:bootJar 통과
- Flyway V20 운영 DB 적용 (17초)
- 4 엔드포인트 모두 200 응답
- agent 테이블 15컬럼 사전 자동 노출 + 도메인 매핑 정상

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-11 00:29:13 +09:00
parent 2136b38e1b
commit cece084631
24 changed files with 2268 additions and 1 deletions
@@ -0,0 +1,76 @@
package com.ga.api.controller.dict;
import com.ga.api.service.dict.DataDictionaryService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.dict.DataDictionaryFullResp;
import com.ga.core.vo.dict.DataDictionaryResp;
import com.ga.core.vo.dict.DataDictionarySaveReq;
import com.ga.core.vo.dict.DataDictionarySearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "데이터 사전")
@RestController
@RequestMapping("/api/data-dictionary")
@RequiredArgsConstructor
public class DataDictionaryController {
private final DataDictionaryService dataDictionaryService;
@GetMapping
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
public ApiResponse<PageResponse<DataDictionaryResp>> list(@ModelAttribute DataDictionarySearchParam param) {
return ApiResponse.ok(dataDictionaryService.list(param));
}
@GetMapping("/tables")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
public ApiResponse<List<String>> tables() {
return ApiResponse.ok(dataDictionaryService.tables());
}
@GetMapping("/full")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
public ApiResponse<List<DataDictionaryFullResp>> full(
@RequestParam(required = false) String tableName) {
return ApiResponse.ok(dataDictionaryService.full(tableName));
}
@GetMapping("/{dictId}")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
public ApiResponse<DataDictionaryResp> detail(@PathVariable Long dictId) {
return ApiResponse.ok(dataDictionaryService.detail(dictId));
}
@PostMapping
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "CREATE")
@DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary")
public ApiResponse<Void> create(@Valid @RequestBody DataDictionarySaveReq req) {
dataDictionaryService.create(req);
return ApiResponse.ok();
}
@PutMapping("/{dictId}")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "UPDATE")
@DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary")
public ApiResponse<Void> update(@PathVariable Long dictId,
@Valid @RequestBody DataDictionarySaveReq req) {
dataDictionaryService.update(dictId, req);
return ApiResponse.ok();
}
@DeleteMapping("/{dictId}")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "DELETE")
@DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary")
public ApiResponse<Void> delete(@PathVariable Long dictId) {
dataDictionaryService.delete(dictId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,69 @@
package com.ga.api.controller.dict;
import com.ga.api.service.dict.DataDomainService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.dict.DataDomainOptionResp;
import com.ga.core.vo.dict.DataDomainResp;
import com.ga.core.vo.dict.DataDomainSaveReq;
import com.ga.core.vo.dict.DataDomainSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "데이터 도메인 사전")
@RestController
@RequestMapping("/api/data-domains")
@RequiredArgsConstructor
public class DataDomainController {
private final DataDomainService dataDomainService;
@GetMapping
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ")
public ApiResponse<PageResponse<DataDomainResp>> list(@ModelAttribute DataDomainSearchParam param) {
return ApiResponse.ok(dataDomainService.list(param));
}
@GetMapping("/options")
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ")
public ApiResponse<List<DataDomainOptionResp>> options() {
return ApiResponse.ok(dataDomainService.options());
}
@GetMapping("/{domainCode}")
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ")
public ApiResponse<DataDomainResp> detail(@PathVariable String domainCode) {
return ApiResponse.ok(dataDomainService.detail(domainCode));
}
@PostMapping
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "CREATE")
@DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain")
public ApiResponse<Void> create(@Valid @RequestBody DataDomainSaveReq req) {
dataDomainService.create(req);
return ApiResponse.ok();
}
@PutMapping("/{domainCode}")
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "UPDATE")
@DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain")
public ApiResponse<Void> update(@PathVariable String domainCode,
@Valid @RequestBody DataDomainSaveReq req) {
dataDomainService.update(domainCode, req);
return ApiResponse.ok();
}
@DeleteMapping("/{domainCode}")
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "DELETE")
@DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain")
public ApiResponse<Void> delete(@PathVariable String domainCode) {
dataDomainService.delete(domainCode);
return ApiResponse.ok();
}
}
@@ -0,0 +1,73 @@
package com.ga.api.service.dict;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.dict.DataDictionaryMapper;
import com.ga.core.vo.dict.DataDictionaryFullResp;
import com.ga.core.vo.dict.DataDictionaryResp;
import com.ga.core.vo.dict.DataDictionarySaveReq;
import com.ga.core.vo.dict.DataDictionarySearchParam;
import com.ga.core.vo.dict.DataDictionaryVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class DataDictionaryService {
private final DataDictionaryMapper mapper;
public PageResponse<DataDictionaryResp> list(DataDictionarySearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public List<String> tables() {
return mapper.selectTables();
}
public List<DataDictionaryFullResp> full(String tableName) {
return mapper.selectFromView(tableName);
}
public DataDictionaryResp detail(Long dictId) {
DataDictionaryResp resp = mapper.selectById(dictId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public void create(DataDictionarySaveReq req) {
if (req.getDomainCode() != null && !req.getDomainCode().isBlank()) {
if (mapper.countDomainByCode(req.getDomainCode()) == 0) {
throw new BizException(ErrorCode.NOT_FOUND, "존재하지 않는 도메인 코드입니다: " + req.getDomainCode());
}
}
mapper.insert(req.toVO());
}
@Transactional
public void update(Long dictId, DataDictionarySaveReq req) {
DataDictionaryResp existing = mapper.selectById(dictId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
if (req.getDomainCode() != null && !req.getDomainCode().isBlank()) {
if (mapper.countDomainByCode(req.getDomainCode()) == 0) {
throw new BizException(ErrorCode.NOT_FOUND, "존재하지 않는 도메인 코드입니다: " + req.getDomainCode());
}
}
DataDictionaryVO vo = req.toVO();
vo.setDictId(dictId);
mapper.update(vo);
}
@Transactional
public void delete(Long dictId) {
if (mapper.delete(dictId) == 0) {
throw new BizException(ErrorCode.NOT_FOUND);
}
}
}
@@ -0,0 +1,68 @@
package com.ga.api.service.dict;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.dict.DataDomainMapper;
import com.ga.core.vo.dict.DataDomainOptionResp;
import com.ga.core.vo.dict.DataDomainResp;
import com.ga.core.vo.dict.DataDomainSaveReq;
import com.ga.core.vo.dict.DataDomainSearchParam;
import com.ga.core.vo.dict.DataDomainVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class DataDomainService {
private final DataDomainMapper mapper;
public PageResponse<DataDomainResp> list(DataDomainSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public List<DataDomainOptionResp> options() {
return mapper.selectAll();
}
public DataDomainResp detail(String domainCode) {
DataDomainResp resp = mapper.selectByCode(domainCode);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public void create(DataDomainSaveReq req) {
if (mapper.selectByCode(req.getDomainCode()) != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 존재하는 도메인 코드입니다");
}
mapper.insert(req.toVO());
}
@Transactional
public void update(String domainCode, DataDomainSaveReq req) {
if (mapper.selectByCode(domainCode) == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
DataDomainVO vo = req.toVO();
vo.setDomainCode(domainCode);
mapper.update(vo);
}
@Transactional
public void delete(String domainCode) {
int usageCount = mapper.countUsageByCode(domainCode);
if (usageCount > 0) {
throw new BizException(ErrorCode.DEPENDENT_DATA,
"이 도메인을 사용하는 컬럼이 " + usageCount + "개 있습니다");
}
if (mapper.delete(domainCode) == 0) {
throw new BizException(ErrorCode.NOT_FOUND);
}
}
}
@@ -0,0 +1,355 @@
-- V20: 데이터 도메인 사전 + 데이터 사전 시스템
-- ============================================================
-- 1. data_domain (도메인 마스터)
-- ============================================================
CREATE TABLE ga.data_domain (
domain_code VARCHAR(50) NOT NULL,
domain_name VARCHAR(100) NOT NULL,
domain_category VARCHAR(30) NOT NULL, -- IDENTIFIER/PII/MONEY/RATE/DATE/CODE/TEXT/JSON/BOOLEAN
data_type VARCHAR(30) NOT NULL, -- VARCHAR/DECIMAL/INTEGER/DATE/TIMESTAMP/TEXT/JSONB/CHAR/BIGINT
length INT,
precision INT,
scale INT,
format_pattern VARCHAR(100),
validation_regex VARCHAR(200),
example_value VARCHAR(200),
masking_type VARCHAR(20), -- NAME/PHONE/RRN/ACCOUNT/EMAIL/NONE
encrypted CHAR(1) NOT NULL DEFAULT 'N',
description TEXT,
is_active CHAR(1) NOT NULL DEFAULT 'Y',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
CONSTRAINT pk_data_domain PRIMARY KEY (domain_code)
);
CREATE INDEX idx_data_domain_category ON ga.data_domain (domain_category, is_active);
COMMENT ON TABLE ga.data_domain IS '데이터 도메인 마스터';
COMMENT ON COLUMN ga.data_domain.domain_code IS '도메인 코드 (PK)';
COMMENT ON COLUMN ga.data_domain.domain_name IS '도메인 한글명';
COMMENT ON COLUMN ga.data_domain.domain_category IS '도메인 분류 (IDENTIFIER/PII/MONEY/RATE/DATE/CODE/TEXT/JSON/BOOLEAN)';
COMMENT ON COLUMN ga.data_domain.data_type IS '물리 데이터 타입 (VARCHAR/DECIMAL/INTEGER/DATE/TIMESTAMP/TEXT/JSONB/CHAR/BIGINT)';
COMMENT ON COLUMN ga.data_domain.length IS 'VARCHAR 길이';
COMMENT ON COLUMN ga.data_domain.precision IS 'DECIMAL 정밀도';
COMMENT ON COLUMN ga.data_domain.scale IS 'DECIMAL 스케일';
COMMENT ON COLUMN ga.data_domain.format_pattern IS '표시 형식 패턴 (예: yyyy-MM-dd, #,##0.00)';
COMMENT ON COLUMN ga.data_domain.validation_regex IS '입력값 검증 정규식';
COMMENT ON COLUMN ga.data_domain.example_value IS '예시 값';
COMMENT ON COLUMN ga.data_domain.masking_type IS '마스킹 유형 (NAME/PHONE/RRN/ACCOUNT/EMAIL/NONE)';
COMMENT ON COLUMN ga.data_domain.encrypted IS 'AES 암호화 여부 (Y/N)';
COMMENT ON COLUMN ga.data_domain.is_active IS '사용여부 (Y/N)';
-- ============================================================
-- 2. data_dictionary (컬럼-도메인 매핑 + 한글명)
-- ============================================================
CREATE TABLE ga.data_dictionary (
dict_id BIGSERIAL NOT NULL,
table_name VARCHAR(100) NOT NULL,
column_name VARCHAR(100) NOT NULL,
domain_code VARCHAR(50) REFERENCES ga.data_domain (domain_code),
column_label VARCHAR(100),
description TEXT,
is_pk CHAR(1) NOT NULL DEFAULT 'N',
is_fk CHAR(1) NOT NULL DEFAULT 'N',
fk_reference VARCHAR(150), -- 'agent.agent_id' 형태
is_required CHAR(1) NOT NULL DEFAULT 'N',
default_value VARCHAR(200),
business_rule TEXT,
sample_value VARCHAR(200),
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
CONSTRAINT pk_data_dictionary PRIMARY KEY (dict_id),
CONSTRAINT uq_dict_table_column UNIQUE (table_name, column_name)
);
CREATE INDEX idx_dict_table ON ga.data_dictionary (table_name);
CREATE INDEX idx_dict_domain ON ga.data_dictionary (domain_code);
COMMENT ON TABLE ga.data_dictionary IS '컬럼-도메인 매핑 + 한글명';
COMMENT ON COLUMN ga.data_dictionary.dict_id IS '사전 ID (PK)';
COMMENT ON COLUMN ga.data_dictionary.table_name IS '테이블명';
COMMENT ON COLUMN ga.data_dictionary.column_name IS '컬럼명';
COMMENT ON COLUMN ga.data_dictionary.domain_code IS '도메인 코드 (FK → data_domain)';
COMMENT ON COLUMN ga.data_dictionary.column_label IS '컬럼 한글명';
COMMENT ON COLUMN ga.data_dictionary.is_pk IS 'PK 여부 (Y/N)';
COMMENT ON COLUMN ga.data_dictionary.is_fk IS 'FK 여부 (Y/N)';
COMMENT ON COLUMN ga.data_dictionary.fk_reference IS 'FK 참조 (테이블.컬럼)';
COMMENT ON COLUMN ga.data_dictionary.business_rule IS '비즈니스 규칙 (검증/계산)';
COMMENT ON COLUMN ga.data_dictionary.sample_value IS '샘플 값';
-- ============================================================
-- 3. View: v_data_dictionary_full
-- ============================================================
CREATE OR REPLACE VIEW ga.v_data_dictionary_full AS
SELECT
c.table_name,
c.column_name,
c.ordinal_position,
c.data_type,
c.character_maximum_length AS max_length,
c.numeric_precision AS num_precision,
c.numeric_scale AS num_scale,
c.is_nullable,
c.column_default,
obj_description(
(c.table_schema || '.' || c.table_name)::regclass, 'pg_class'
) AS table_comment,
col_description(
((c.table_schema || '.' || c.table_name)::regclass)::oid,
c.ordinal_position
) AS column_comment,
dd.column_label,
dd.description AS dict_description,
dd.domain_code,
d.domain_name,
d.domain_category,
d.format_pattern,
d.masking_type,
d.encrypted,
dd.is_pk,
dd.is_fk,
dd.fk_reference,
dd.business_rule,
dd.sample_value
FROM information_schema.columns c
LEFT JOIN ga.data_dictionary dd
ON dd.table_name = c.table_name
AND dd.column_name = c.column_name
LEFT JOIN ga.data_domain d
ON d.domain_code = dd.domain_code
WHERE c.table_schema = 'ga'
AND c.table_name NOT LIKE 'flyway_%'
AND c.table_name NOT LIKE 'BATCH_%'
ORDER BY c.table_name, c.ordinal_position;
-- ============================================================
-- 4. 초기 도메인 데이터 (~30개)
-- ============================================================
-- IDENTIFIER (식별자)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_ID_BIGINT', 'ID 식별자', 'IDENTIFIER', 'BIGINT', NULL, NULL, NULL, NULL, NULL, '12345', 'NONE', 'N', '자동증가 대리키 (PK용)'),
('DOM_LICENSE_NO', '사번/면허번호','IDENTIFIER', 'VARCHAR', 50, NULL, NULL, NULL, NULL, 'GA20230001', 'NONE', 'N', '설계사 사번 또는 보험업 면허번호'),
('DOM_POLICY_NO', '증권번호', 'IDENTIFIER', 'VARCHAR', 50, NULL, NULL, NULL, NULL, '20230100012345','NONE', 'N', '보험사 발급 증권번호'),
('DOM_COMPANY_CODE', '보험사 코드', 'IDENTIFIER', 'VARCHAR', 20, NULL, NULL, NULL, NULL, 'KDB', 'NONE', 'N', '보험사 식별 코드'),
('DOM_PRODUCT_CODE', '상품 코드', 'IDENTIFIER', 'VARCHAR', 50, NULL, NULL, NULL, NULL, 'PRD-2023-001', 'NONE', 'N', '보험 상품 코드'),
('DOM_SETTLE_MONTH', '정산월', 'IDENTIFIER', 'CHAR', 6, NULL, NULL, 'YYYYMM', '^\d{6}$', '202306', 'NONE', 'N', '정산 기준 연월 (YYYYMM)'),
('DOM_BATCH_ID', '배치 ID', 'IDENTIFIER', 'VARCHAR', 50, NULL, NULL, NULL, NULL, 'BATCH-20230601','NONE', 'N', '배치 작업 식별자');
-- PII (개인정보)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_RRN', '주민번호', 'PII', 'VARCHAR', 200, NULL, NULL, 'YYYYMMDD-NNNNNNN', '^\d{6}-\d{7}$', '800101-1234567', 'RRN', 'Y', 'AES 암호화 + RRN 마스킹'),
('DOM_NAME', '성명', 'PII', 'VARCHAR', 50, NULL, NULL, NULL, NULL, '홍길동', 'NAME', 'N', '고객/설계사 실명'),
('DOM_PHONE', '전화번호', 'PII', 'VARCHAR', 20, NULL, NULL, NULL, '^\d{2,3}-\d{3,4}-\d{4}$', '010-1234-5678', 'PHONE', 'N', '연락처'),
('DOM_EMAIL', '이메일', 'PII', 'VARCHAR', 100, NULL, NULL, NULL, NULL, 'user@example.com','EMAIL', 'N', '이메일 주소'),
('DOM_ACCOUNT_NO', '계좌번호', 'PII', 'VARCHAR', 200, NULL, NULL, NULL, NULL, '123-456-789012', 'ACCOUNT', 'Y', 'AES 암호화 + 계좌 마스킹'),
('DOM_BANK_CODE', '은행코드', 'PII', 'VARCHAR', 10, NULL, NULL, NULL, NULL, '004', 'NONE', 'N', '금융기관 코드 (은행연합회 기준)');
-- MONEY (금액) — DECIMAL(15,2)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_AMOUNT', '금액', 'MONEY', 'DECIMAL', NULL, 15, 2, '#,##0', NULL, '1,000,000', 'NONE', 'N', '일반 금액'),
('DOM_PREMIUM', '보험료', 'MONEY', 'DECIMAL', NULL, 15, 2, '#,##0', NULL, '150,000', 'NONE', 'N', '월 납입 보험료'),
('DOM_COMMISSION', '수수료', 'MONEY', 'DECIMAL', NULL, 15, 2, '#,##0', NULL, '45,000', 'NONE', 'N', '설계사 수수료 금액'),
('DOM_TAX', '세액', 'MONEY', 'DECIMAL', NULL, 15, 2, '#,##0', NULL, '1,485', 'NONE', 'N', '원천징수 세액 (소득세 + 지방소득세)'),
('DOM_NET_AMOUNT', '순지급액', 'MONEY', 'DECIMAL', NULL, 15, 2, '#,##0', NULL, '43,515', 'NONE', 'N', '세액 공제 후 실지급액');
-- RATE (율) — DECIMAL(7,4)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_RATE_PCT', '수수료율(%)', 'RATE', 'DECIMAL', NULL, 7, 4, '#,##0.0000', NULL, '30.0000', 'NONE', 'N', '보험사 지급 수수료율'),
('DOM_PAYOUT_PCT', '지급율(%)', 'RATE', 'DECIMAL', NULL, 7, 4, '#,##0.0000', NULL, '80.0000', 'NONE', 'N', 'GA 내부 설계사 지급율'),
('DOM_TAX_RATE', '세율(%)', 'RATE', 'DECIMAL', NULL, 7, 4, '#,##0.0000', NULL, '3.3000', 'NONE', 'N', '원천세율 (소득세 3% + 지방소득세 0.3%)');
-- DATE (날짜)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_DATE', '날짜', 'DATE', 'DATE', NULL, NULL, NULL, 'yyyy-MM-dd', NULL, '2023-06-01', 'NONE', 'N', '일반 날짜'),
('DOM_DATETIME', '일시', 'DATE', 'TIMESTAMP', NULL, NULL, NULL, 'yyyy-MM-dd HH:mm:ss', NULL, '2023-06-01 09:00:00','NONE', 'N', '일반 일시'),
('DOM_CONTRACT_DATE', '계약일','DATE', 'DATE', NULL, NULL, NULL, 'yyyy-MM-dd', NULL, '2023-06-01', 'NONE', 'N', '보험 계약 체결일'),
('DOM_EFFECTIVE_DATE', '효력일','DATE', 'DATE', NULL, NULL, NULL, 'yyyy-MM-dd', NULL, '2023-07-01', 'NONE', 'N', '보험 효력 개시일');
-- CODE (코드)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_STATUS', '상태', 'CODE', 'VARCHAR', 20, NULL, NULL, NULL, NULL, 'ACTIVE', 'NONE', 'N', '레코드 상태 코드'),
('DOM_YN_FLAG', '여부 플래그','CODE', 'CHAR', 1, NULL, NULL, NULL, '^[YN]$', 'Y', 'NONE', 'N', 'Y/N 이진 플래그'),
('DOM_INSURANCE_TYPE', '보험종류', 'CODE', 'VARCHAR', 30, NULL, NULL, NULL, NULL, 'WHOLE_LIFE','NONE','N', '보험 종류 코드'),
('DOM_PAY_CYCLE', '납입주기', 'CODE', 'VARCHAR', 20, NULL, NULL, NULL, NULL, 'MONTHLY', 'NONE', 'N', '보험료 납입 주기 코드');
-- TEXT (텍스트)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_TEXT_SHORT', '짧은 텍스트', 'TEXT', 'VARCHAR', 200, NULL, NULL, NULL, NULL, '간단한 메모', 'NONE', 'N', '200자 이내 단문'),
('DOM_TEXT_LONG', '긴 텍스트', 'TEXT', 'TEXT', NULL, NULL, NULL, NULL, NULL, '상세 내용...', 'NONE', 'N', '제한 없는 장문'),
('DOM_DESCRIPTION', '설명', 'TEXT', 'VARCHAR', 500, NULL, NULL, NULL, NULL, '비고 내용', 'NONE', 'N', '500자 이내 설명/비고');
-- JSON / 기타
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_JSONB', 'JSON 데이터', 'JSON', 'JSONB', NULL, NULL, NULL, NULL, NULL, '{"key":"value"}', 'NONE', 'N', 'PostgreSQL JSONB 타입'),
('DOM_AUDIT_USER', '감사 사용자 ID', 'IDENTIFIER', 'BIGINT', NULL, NULL, NULL, NULL, NULL, '1', 'NONE', 'N', '생성자/수정자 user_id');
-- ============================================================
-- 5. 초기 데이터 사전 매핑 (~50개)
-- ============================================================
INSERT INTO ga.data_dictionary
(table_name, column_name, domain_code, column_label, is_pk, is_fk, fk_reference, is_required)
VALUES
-- agent
('agent', 'agent_id', 'DOM_ID_BIGINT', '설계사ID', 'Y', 'N', NULL, 'Y'),
('agent', 'org_id', 'DOM_ID_BIGINT', '조직ID', 'N', 'Y', 'organization.org_id', 'Y'),
('agent', 'agent_name', 'DOM_NAME', '설계사명', 'N', 'N', NULL, 'Y'),
('agent', 'resident_no', 'DOM_RRN', '주민번호', 'N', 'N', NULL, 'N'),
('agent', 'license_no', 'DOM_LICENSE_NO', '사번', 'N', 'N', NULL, 'N'),
('agent', 'phone', 'DOM_PHONE', '전화번호', 'N', 'N', NULL, 'N'),
('agent', 'email', 'DOM_EMAIL', '이메일', 'N', 'N', NULL, 'N'),
('agent', 'account_no', 'DOM_ACCOUNT_NO', '계좌번호', 'N', 'N', NULL, 'N'),
('agent', 'status', 'DOM_STATUS', '상태', 'N', 'N', NULL, 'Y'),
('agent', 'join_date', 'DOM_DATE', '입사일', 'N', 'N', NULL, 'N'),
-- contract
('contract', 'contract_id', 'DOM_ID_BIGINT', '계약ID', 'Y', 'N', NULL, 'Y'),
('contract', 'agent_id', 'DOM_ID_BIGINT', '설계사ID','N', 'Y', 'agent.agent_id', 'Y'),
('contract', 'product_id', 'DOM_ID_BIGINT', '상품ID', 'N', 'Y', 'product.product_id','Y'),
('contract', 'policy_no', 'DOM_POLICY_NO', '증권번호','N', 'N', NULL, 'Y'),
('contract', 'premium', 'DOM_PREMIUM', '보험료', 'N', 'N', NULL, 'N'),
('contract', 'pay_cycle', 'DOM_PAY_CYCLE', '납입주기','N', 'N', NULL, 'N'),
('contract', 'contract_date', 'DOM_CONTRACT_DATE', '계약일', 'N', 'N', NULL, 'N'),
-- recruit_ledger
('recruit_ledger', 'ledger_id', 'DOM_ID_BIGINT', '원장ID', 'Y', 'N', NULL, 'Y'),
('recruit_ledger', 'agent_id', 'DOM_ID_BIGINT', '설계사ID', 'N', 'Y', 'agent.agent_id', 'Y'),
('recruit_ledger', 'policy_no', 'DOM_POLICY_NO', '증권번호', 'N', 'N', NULL, 'Y'),
('recruit_ledger', 'premium', 'DOM_PREMIUM', '보험료', 'N', 'N', NULL, 'N'),
('recruit_ledger', 'company_rate', 'DOM_RATE_PCT', '회사율', 'N', 'N', NULL, 'N'),
('recruit_ledger', 'company_amount', 'DOM_COMMISSION', '회사수수료', 'N', 'N', NULL, 'N'),
('recruit_ledger', 'payout_rate', 'DOM_PAYOUT_PCT', '지급율', 'N', 'N', NULL, 'N'),
('recruit_ledger', 'agent_amount', 'DOM_COMMISSION', '설계사수수료','N','N', NULL, 'N'),
('recruit_ledger', 'tax_amount', 'DOM_TAX', '세액', 'N', 'N', NULL, 'N'),
('recruit_ledger', 'settle_month', 'DOM_SETTLE_MONTH','정산월', 'N', 'N', NULL, 'Y'),
('recruit_ledger', 'status', 'DOM_STATUS', '상태', 'N', 'N', NULL, 'Y'),
-- settle_master
('settle_master', 'settle_id', 'DOM_ID_BIGINT', '정산ID', 'Y', 'N', NULL, 'Y'),
('settle_master', 'agent_id', 'DOM_ID_BIGINT', '설계사ID', 'N', 'Y', 'agent.agent_id', 'Y'),
('settle_master', 'settle_month', 'DOM_SETTLE_MONTH','정산월', 'N', 'N', NULL, 'Y'),
('settle_master', 'recruit_total', 'DOM_COMMISSION', '모집합계', 'N', 'N', NULL, 'N'),
('settle_master', 'maintain_total', 'DOM_COMMISSION', '유지합계', 'N', 'N', NULL, 'N'),
('settle_master', 'gross_amount', 'DOM_AMOUNT', '총액', 'N', 'N', NULL, 'N'),
('settle_master', 'tax_amount', 'DOM_TAX', '세액', 'N', 'N', NULL, 'N'),
('settle_master', 'net_amount', 'DOM_NET_AMOUNT', '순지급액', 'N', 'N', NULL, 'N'),
('settle_master', 'status', 'DOM_STATUS', '상태', 'N', 'N', NULL, 'Y'),
-- payment
('payment', 'payment_id', 'DOM_ID_BIGINT', '지급ID', 'Y', 'N', NULL, 'Y'),
('payment', 'settle_id', 'DOM_ID_BIGINT', '정산ID', 'N', 'Y', 'settle_master.settle_id', 'Y'),
('payment', 'agent_id', 'DOM_ID_BIGINT', '설계사ID','N', 'Y', 'agent.agent_id', 'Y'),
('payment', 'bank_code', 'DOM_BANK_CODE', '은행코드','N', 'N', NULL, 'N'),
('payment', 'account_no', 'DOM_ACCOUNT_NO', '계좌번호','N', 'N', NULL, 'N'),
('payment', 'pay_amount', 'DOM_AMOUNT', '지급액', 'N', 'N', NULL, 'N')
ON CONFLICT (table_name, column_name) DO NOTHING;
-- ============================================================
-- 6. 메뉴 등록 (GRP_SYSTEM 하위)
-- ============================================================
INSERT INTO menu (menu_code, menu_name, menu_type, menu_path, component_path, menu_icon,
menu_level, sort_order, is_visible, is_active, parent_menu_id, description)
VALUES
(
'SYSTEM_DATA_DOMAIN',
'데이터 도메인 사전',
'PAGE',
'/system/data-domains',
'system/DataDomainList',
'database',
2,
8,
'Y',
'Y',
(SELECT menu_id FROM menu WHERE menu_code = 'GRP_SYSTEM'),
'비즈니스 도메인 정의 (주민번호/금액/율 등)'
),
(
'SYSTEM_DATA_DICT',
'데이터 사전',
'PAGE',
'/system/data-dictionary',
'system/DataDictionary',
'book',
2,
9,
'Y',
'Y',
(SELECT menu_id FROM menu WHERE menu_code = 'GRP_SYSTEM'),
'테이블/컬럼별 한글명 + 도메인 매핑'
)
ON CONFLICT (menu_code) DO NOTHING;
-- ============================================================
-- 7. 메뉴 권한 정의
-- ============================================================
-- SYSTEM_DATA_DOMAIN : READ / CREATE / UPDATE / DELETE
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code = 'SYSTEM_DATA_DOMAIN'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'CREATE', '등록', 2 FROM menu WHERE menu_code = 'SYSTEM_DATA_DOMAIN'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'UPDATE', '수정', 3 FROM menu WHERE menu_code = 'SYSTEM_DATA_DOMAIN'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'DELETE', '삭제', 4 FROM menu WHERE menu_code = 'SYSTEM_DATA_DOMAIN'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- SYSTEM_DATA_DICT : READ / CREATE / UPDATE (사전은 DELETE 없음)
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code = 'SYSTEM_DATA_DICT'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'CREATE', '등록', 2 FROM menu WHERE menu_code = 'SYSTEM_DATA_DICT'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'UPDATE', '수정', 3 FROM menu WHERE menu_code = 'SYSTEM_DATA_DICT'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 8. 역할-메뉴 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN : SYSTEM_DATA_DOMAIN 모든 권한 (READ/CREATE/UPDATE/DELETE)
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, m.menu_id, p.perm_code, 'Y'
FROM role r
CROSS JOIN menu m
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE')) AS p(perm_code)
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
AND m.menu_code = 'SYSTEM_DATA_DOMAIN'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- SUPER_ADMIN / ADMIN : SYSTEM_DATA_DICT 모든 권한 (READ/CREATE/UPDATE)
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, m.menu_id, p.perm_code, 'Y'
FROM role r
CROSS JOIN menu m
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE')) AS p(perm_code)
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
AND m.menu_code = 'SYSTEM_DATA_DICT'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER : 두 메뉴 모두 READ 만
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, m.menu_id, 'READ', 'Y'
FROM role r
CROSS JOIN menu m
WHERE r.role_code = 'MANAGER'
AND m.menu_code IN ('SYSTEM_DATA_DOMAIN', 'SYSTEM_DATA_DICT')
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -0,0 +1,37 @@
package com.ga.core.mapper.dict;
import com.ga.core.vo.dict.DataDictionaryFullResp;
import com.ga.core.vo.dict.DataDictionaryResp;
import com.ga.core.vo.dict.DataDictionarySearchParam;
import com.ga.core.vo.dict.DataDictionaryVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface DataDictionaryMapper {
/** 목록 조회 (data_dictionary + data_domain 조인, 페이징) */
List<DataDictionaryResp> selectList(DataDictionarySearchParam param);
/** 특정 테이블의 모든 컬럼 (sort_order 순, LEFT JOIN data_domain) */
List<DataDictionaryResp> selectByTable(@Param("tableName") String tableName);
/** VIEW v_data_dictionary_full 조회. tableName null 이면 전체 */
List<DataDictionaryFullResp> selectFromView(@Param("tableName") String tableName);
/** 사전에 등록된 distinct table_name 리스트 */
List<String> selectTables();
DataDictionaryResp selectById(@Param("dictId") Long dictId);
int insert(DataDictionaryVO vo);
int update(DataDictionaryVO vo);
int delete(@Param("dictId") Long dictId);
/** domain_code 존재 여부 확인 */
int countDomainByCode(@Param("domainCode") String domainCode);
}
@@ -0,0 +1,30 @@
package com.ga.core.mapper.dict;
import com.ga.core.vo.dict.DataDomainOptionResp;
import com.ga.core.vo.dict.DataDomainResp;
import com.ga.core.vo.dict.DataDomainSearchParam;
import com.ga.core.vo.dict.DataDomainVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface DataDomainMapper {
List<DataDomainResp> selectList(DataDomainSearchParam param);
DataDomainResp selectByCode(@Param("domainCode") String domainCode);
/** 드롭다운 Select 옵션용 전체 목록 (활성 도메인) */
List<DataDomainOptionResp> selectAll();
int insert(DataDomainVO vo);
int update(DataDomainVO vo);
int delete(@Param("domainCode") String domainCode);
/** data_dictionary 에서 해당 domainCode 를 참조하는 컬럼 수 */
int countUsageByCode(@Param("domainCode") String domainCode);
}
@@ -0,0 +1,42 @@
package com.ga.core.vo.dict;
import lombok.Data;
/**
* VIEW v_data_dictionary_full 조회 결과.
* data_dictionary + data_domain + information_schema.COLUMNS 결합.
*/
@Data
public class DataDictionaryFullResp {
// data_dictionary
private Long dictId;
private String tableName;
private String columnName;
private String domainCode;
private String columnLabel;
private String description;
private Boolean isPk;
private Boolean isFk;
private String fkReference;
private Boolean isRequired;
private String defaultValue;
private String businessRule;
private String sampleValue;
private Integer sortOrder;
// data_domain
private String domainName;
private String domainCategory;
private String maskingType;
private Boolean encrypted;
// information_schema.COLUMNS
private String physicalDataType;
private Long physicalLength;
private Integer physicalPrecision;
private Integer physicalScale;
private String physicalNullable;
private String physicalDefault;
private String physicalComment;
private Long ordinalPosition;
}
@@ -0,0 +1,39 @@
package com.ga.core.vo.dict;
import lombok.Data;
import java.time.LocalDateTime;
/**
* data_dictionary + data_domain 조인 결과
*/
@Data
public class DataDictionaryResp {
// data_dictionary 컬럼
private Long dictId;
private String tableName;
private String columnName;
private String domainCode;
private String columnLabel;
private String description;
private Boolean isPk;
private Boolean isFk;
private String fkReference;
private Boolean isRequired;
private String defaultValue;
private String businessRule;
private String sampleValue;
private Integer sortOrder;
private LocalDateTime createdAt;
private String createdBy;
private LocalDateTime updatedAt;
private String updatedBy;
// data_domain 조인 컬럼
private String domainName;
private String domainCategory;
private String dataType;
private Integer length;
private String maskingType;
private Boolean encrypted;
}
@@ -0,0 +1,63 @@
package com.ga.core.vo.dict;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class DataDictionarySaveReq {
@NotBlank(message = "테이블명은 필수입니다")
@Size(max = 100)
private String tableName;
@NotBlank(message = "컬럼명은 필수입니다")
@Size(max = 100)
private String columnName;
@Size(max = 50)
private String domainCode;
@Size(max = 200)
private String columnLabel;
@Size(max = 500)
private String description;
private Boolean isPk;
private Boolean isFk;
@Size(max = 200)
private String fkReference;
private Boolean isRequired;
@Size(max = 200)
private String defaultValue;
@Size(max = 1000)
private String businessRule;
@Size(max = 200)
private String sampleValue;
private Integer sortOrder;
public DataDictionaryVO toVO() {
DataDictionaryVO vo = new DataDictionaryVO();
vo.setTableName(tableName);
vo.setColumnName(columnName);
vo.setDomainCode(domainCode);
vo.setColumnLabel(columnLabel);
vo.setDescription(description);
vo.setIsPk(isPk != null ? isPk : Boolean.FALSE);
vo.setIsFk(isFk != null ? isFk : Boolean.FALSE);
vo.setFkReference(fkReference);
vo.setIsRequired(isRequired != null ? isRequired : Boolean.FALSE);
vo.setDefaultValue(defaultValue);
vo.setBusinessRule(businessRule);
vo.setSampleValue(sampleValue);
vo.setSortOrder(sortOrder != null ? sortOrder : 0);
return vo;
}
}
@@ -0,0 +1,12 @@
package com.ga.core.vo.dict;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class DataDictionarySearchParam extends SearchParam {
private String tableName;
private String domainCode;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.dict;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class DataDictionaryVO {
private Long dictId;
private String tableName;
private String columnName;
private String domainCode;
private String columnLabel;
private String description;
private Boolean isPk;
private Boolean isFk;
private String fkReference;
private Boolean isRequired;
private String defaultValue;
private String businessRule;
private String sampleValue;
private Integer sortOrder;
private LocalDateTime createdAt;
private String createdBy;
private LocalDateTime updatedAt;
private String updatedBy;
}
@@ -0,0 +1,13 @@
package com.ga.core.vo.dict;
import lombok.Data;
/**
* 드롭다운 Select 옵션용 경량 VO (selectAll 전용)
*/
@Data
public class DataDomainOptionResp {
private String domainCode;
private String domainName;
private String domainCategory;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.dict;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class DataDomainResp {
private String domainCode;
private String domainName;
private String domainCategory;
private String dataType;
private Integer length;
private Integer precision;
private Integer scale;
private String formatPattern;
private String validationRegex;
private String exampleValue;
private String maskingType;
private Boolean encrypted;
private String description;
private Boolean isActive;
private LocalDateTime createdAt;
private String createdBy;
private LocalDateTime updatedAt;
private String updatedBy;
}
@@ -0,0 +1,67 @@
package com.ga.core.vo.dict;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class DataDomainSaveReq {
@NotBlank(message = "도메인코드는 필수입니다")
@Size(max = 50)
private String domainCode;
@NotBlank(message = "도메인명은 필수입니다")
@Size(max = 100)
private String domainName;
@NotBlank(message = "도메인 분류는 필수입니다")
@Size(max = 50)
private String domainCategory;
@NotBlank(message = "데이터 타입은 필수입니다")
@Size(max = 30)
private String dataType;
private Integer length;
private Integer precision;
private Integer scale;
@Size(max = 100)
private String formatPattern;
@Size(max = 500)
private String validationRegex;
@Size(max = 200)
private String exampleValue;
@Size(max = 30)
private String maskingType;
private Boolean encrypted;
@Size(max = 500)
private String description;
private Boolean isActive;
public DataDomainVO toVO() {
DataDomainVO vo = new DataDomainVO();
vo.setDomainCode(domainCode);
vo.setDomainName(domainName);
vo.setDomainCategory(domainCategory);
vo.setDataType(dataType);
vo.setLength(length);
vo.setPrecision(precision);
vo.setScale(scale);
vo.setFormatPattern(formatPattern);
vo.setValidationRegex(validationRegex);
vo.setExampleValue(exampleValue);
vo.setMaskingType(maskingType);
vo.setEncrypted(encrypted != null ? encrypted : Boolean.FALSE);
vo.setDescription(description);
vo.setIsActive(isActive != null ? isActive : Boolean.TRUE);
return vo;
}
}
@@ -0,0 +1,12 @@
package com.ga.core.vo.dict;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class DataDomainSearchParam extends SearchParam {
private String domainCategory;
private Boolean isActive;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.dict;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class DataDomainVO {
private String domainCode;
private String domainName;
private String domainCategory;
private String dataType;
private Integer length;
private Integer precision;
private Integer scale;
private String formatPattern;
private String validationRegex;
private String exampleValue;
private String maskingType;
private Boolean encrypted;
private String description;
private Boolean isActive;
private LocalDateTime createdAt;
private String createdBy;
private LocalDateTime updatedAt;
private String updatedBy;
}
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.dict.DataDictionaryMapper">
<resultMap id="DataDictionaryRespMap" type="com.ga.core.vo.dict.DataDictionaryResp">
<id property="dictId" column="dict_id"/>
<result property="tableName" column="table_name"/>
<result property="columnName" column="column_name"/>
<result property="domainCode" column="domain_code"/>
<result property="columnLabel" column="column_label"/>
<result property="description" column="description"/>
<result property="isPk" column="is_pk"/>
<result property="isFk" column="is_fk"/>
<result property="fkReference" column="fk_reference"/>
<result property="isRequired" column="is_required"/>
<result property="defaultValue" column="default_value"/>
<result property="businessRule" column="business_rule"/>
<result property="sampleValue" column="sample_value"/>
<result property="sortOrder" column="sort_order"/>
<result property="createdAt" column="created_at"/>
<result property="createdBy" column="created_by"/>
<result property="updatedAt" column="updated_at"/>
<result property="updatedBy" column="updated_by"/>
<!-- data_domain join -->
<result property="domainName" column="domain_name"/>
<result property="domainCategory" column="domain_category"/>
<result property="dataType" column="data_type"/>
<result property="length" column="length"/>
<result property="maskingType" column="masking_type"/>
<result property="encrypted" column="encrypted"/>
</resultMap>
<resultMap id="DataDictionaryFullRespMap" type="com.ga.core.vo.dict.DataDictionaryFullResp">
<id property="dictId" column="dict_id"/>
<result property="tableName" column="table_name"/>
<result property="columnName" column="column_name"/>
<result property="domainCode" column="domain_code"/>
<result property="columnLabel" column="column_label"/>
<result property="description" column="description"/>
<result property="isPk" column="is_pk"/>
<result property="isFk" column="is_fk"/>
<result property="fkReference" column="fk_reference"/>
<result property="isRequired" column="is_required"/>
<result property="defaultValue" column="default_value"/>
<result property="businessRule" column="business_rule"/>
<result property="sampleValue" column="sample_value"/>
<result property="sortOrder" column="sort_order"/>
<!-- data_domain -->
<result property="domainName" column="domain_name"/>
<result property="domainCategory" column="domain_category"/>
<result property="maskingType" column="masking_type"/>
<result property="encrypted" column="encrypted"/>
<!-- information_schema -->
<result property="physicalDataType" column="physical_data_type"/>
<result property="physicalLength" column="physical_length"/>
<result property="physicalPrecision" column="physical_precision"/>
<result property="physicalScale" column="physical_scale"/>
<result property="physicalNullable" column="physical_nullable"/>
<result property="physicalDefault" column="physical_default"/>
<result property="physicalComment" column="physical_comment"/>
<result property="ordinalPosition" column="ordinal_position"/>
</resultMap>
<sql id="dictDomainJoinCols">
d.dict_id, d.table_name, d.column_name, d.domain_code,
d.column_label, d.description, d.is_pk, d.is_fk, d.fk_reference,
d.is_required, d.default_value, d.business_rule, d.sample_value, d.sort_order,
d.created_at, d.created_by, d.updated_at, d.updated_by,
dom.domain_name, dom.domain_category, dom.data_type,
dom.length, dom.masking_type, dom.encrypted
</sql>
<select id="selectList" resultMap="DataDictionaryRespMap">
SELECT <include refid="dictDomainJoinCols"/>
FROM data_dictionary d
LEFT JOIN data_domain dom ON dom.domain_code = d.domain_code
<where>
<if test="tableName != null and tableName != ''">
AND d.table_name = #{tableName}
</if>
<if test="domainCode != null and domainCode != ''">
AND d.domain_code = #{domainCode}
</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND (d.column_name LIKE CONCAT('%', #{searchKeyword}, '%')
OR d.column_label LIKE CONCAT('%', #{searchKeyword}, '%'))
</if>
</where>
ORDER BY d.table_name, d.sort_order, d.column_name
</select>
<!-- 특정 테이블의 모든 컬럼 (도메인 미등록 컬럼도 노출) -->
<select id="selectByTable" resultMap="DataDictionaryRespMap">
SELECT <include refid="dictDomainJoinCols"/>
FROM data_dictionary d
LEFT JOIN data_domain dom ON dom.domain_code = d.domain_code
WHERE d.table_name = #{tableName}
ORDER BY d.sort_order, d.column_name
</select>
<!-- v_data_dictionary_full VIEW 조회. View 에는 sort_order 가 없음 -->
<select id="selectFromView" resultMap="DataDictionaryFullRespMap">
SELECT *
FROM v_data_dictionary_full
<where>
<if test="tableName != null and tableName != ''">
AND table_name = #{tableName}
</if>
</where>
ORDER BY table_name, ordinal_position
</select>
<select id="selectTables" resultType="string">
SELECT DISTINCT table_name
FROM data_dictionary
ORDER BY table_name
</select>
<select id="selectById" resultMap="DataDictionaryRespMap">
SELECT <include refid="dictDomainJoinCols"/>
FROM data_dictionary d
LEFT JOIN data_domain dom ON dom.domain_code = d.domain_code
WHERE d.dict_id = #{dictId}
</select>
<insert id="insert" parameterType="com.ga.core.vo.dict.DataDictionaryVO" useGeneratedKeys="true" keyProperty="dictId">
INSERT INTO data_dictionary (
table_name, column_name, domain_code, column_label, description,
is_pk, is_fk, fk_reference, is_required, default_value,
business_rule, sample_value, sort_order, created_at, created_by
) VALUES (
#{tableName}, #{columnName}, #{domainCode}, #{columnLabel}, #{description},
#{isPk}, #{isFk}, #{fkReference}, #{isRequired}, #{defaultValue},
#{businessRule}, #{sampleValue}, #{sortOrder}, NOW(), #{createdBy}
)
</insert>
<update id="update" parameterType="com.ga.core.vo.dict.DataDictionaryVO">
UPDATE data_dictionary SET
table_name = #{tableName},
column_name = #{columnName},
domain_code = #{domainCode},
column_label = #{columnLabel},
description = #{description},
is_pk = #{isPk},
is_fk = #{isFk},
fk_reference = #{fkReference},
is_required = #{isRequired},
default_value = #{defaultValue},
business_rule = #{businessRule},
sample_value = #{sampleValue},
sort_order = #{sortOrder},
updated_at = NOW(),
updated_by = #{updatedBy}
WHERE dict_id = #{dictId}
</update>
<delete id="delete">
DELETE FROM data_dictionary WHERE dict_id = #{dictId}
</delete>
<select id="countDomainByCode" resultType="int">
SELECT COUNT(*) FROM data_domain WHERE domain_code = #{domainCode}
</select>
</mapper>
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.dict.DataDomainMapper">
<resultMap id="DataDomainRespMap" type="com.ga.core.vo.dict.DataDomainResp"/>
<resultMap id="DataDomainOptionMap" type="com.ga.core.vo.dict.DataDomainOptionResp"/>
<sql id="domainCols">
domain_code, domain_name, domain_category, data_type,
length, precision, scale, format_pattern, validation_regex,
example_value, masking_type, encrypted, description, is_active,
created_at, created_by, updated_at, updated_by
</sql>
<select id="selectList" resultMap="DataDomainRespMap">
SELECT <include refid="domainCols"/>
FROM data_domain
<where>
<if test="domainCategory != null and domainCategory != ''">
AND domain_category = #{domainCategory}
</if>
<if test="isActive != null">
AND is_active = #{isActive}
</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND (domain_code LIKE CONCAT('%', #{searchKeyword}, '%')
OR domain_name LIKE CONCAT('%', #{searchKeyword}, '%'))
</if>
</where>
ORDER BY domain_code ASC
</select>
<select id="selectByCode" resultMap="DataDomainRespMap">
SELECT <include refid="domainCols"/>
FROM data_domain
WHERE domain_code = #{domainCode}
</select>
<select id="selectAll" resultMap="DataDomainOptionMap">
SELECT domain_code, domain_name, domain_category
FROM data_domain
WHERE is_active = 'Y'
ORDER BY domain_category, domain_name
</select>
<insert id="insert" parameterType="com.ga.core.vo.dict.DataDomainVO">
INSERT INTO data_domain (
domain_code, domain_name, domain_category, data_type,
length, precision, scale, format_pattern, validation_regex,
example_value, masking_type, encrypted, description, is_active,
created_at, created_by
) VALUES (
#{domainCode}, #{domainName}, #{domainCategory}, #{dataType},
#{length}, #{precision}, #{scale}, #{formatPattern}, #{validationRegex},
#{exampleValue}, #{maskingType}, #{encrypted}, #{description}, #{isActive},
NOW(), #{createdBy}
)
</insert>
<update id="update" parameterType="com.ga.core.vo.dict.DataDomainVO">
UPDATE data_domain SET
domain_name = #{domainName},
domain_category = #{domainCategory},
data_type = #{dataType},
length = #{length},
precision = #{precision},
scale = #{scale},
format_pattern = #{formatPattern},
validation_regex = #{validationRegex},
example_value = #{exampleValue},
masking_type = #{maskingType},
encrypted = #{encrypted},
description = #{description},
is_active = #{isActive},
updated_at = NOW(),
updated_by = #{updatedBy}
WHERE domain_code = #{domainCode}
</update>
<delete id="delete">
DELETE FROM data_domain WHERE domain_code = #{domainCode}
</delete>
<select id="countUsageByCode" resultType="int">
SELECT COUNT(*) FROM data_dictionary WHERE domain_code = #{domainCode}
</select>
</mapper>
+4
View File
@@ -36,6 +36,8 @@ import CodeList from '@/pages/system/CodeList';
import SystemConfig from '@/pages/system/SystemConfig';
import SystemLog from '@/pages/system/SystemLog';
import UploadTemplateManager from '@/pages/system/UploadTemplateManager';
import DataDomainList from '@/pages/system/DataDomainList';
import DataDictionary from '@/pages/system/DataDictionary';
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('accessToken');
@@ -90,6 +92,8 @@ export default function App() {
<Route path="system/config" element={<SystemConfig />} />
<Route path="system/logs" element={<SystemLog />} />
<Route path="system/upload-templates" element={<UploadTemplateManager />} />
<Route path="system/data-domains" element={<DataDomainList />} />
<Route path="system/data-dictionary" element={<DataDictionary />} />
</Route>
</Routes>
);
+165
View File
@@ -0,0 +1,165 @@
import api, { PageResponse, unwrap } from './request';
// ─── 타입 ────────────────────────────────────────────────────────────────────
export interface DataDomainResp {
domainCode: string;
domainName: string;
domainCategory: string;
dataType: string;
length?: number;
precision?: number;
scale?: number;
formatPattern?: string;
validationRegex?: string;
exampleValue?: string;
maskingType?: string;
encrypted: string; // 'Y'/'N'
description?: string;
isActive: string;
}
export interface DataDomainOption {
domainCode: string;
domainName: string;
domainCategory: string;
}
export interface DataDomainSaveReq {
domainCode: string;
domainName: string;
domainCategory: string;
dataType: string;
length?: number;
precision?: number;
scale?: number;
formatPattern?: string;
validationRegex?: string;
exampleValue?: string;
maskingType?: string;
encrypted?: string;
description?: string;
isActive?: string;
}
export interface DataDomainQuery {
domainCategory?: string;
isActive?: string;
searchKeyword?: string;
pageNum?: number;
pageSize?: number;
}
export interface DataDictionaryResp {
dictId: number;
tableName: string;
columnName: string;
domainCode?: string;
domainName?: string;
domainCategory?: string;
columnLabel?: string;
description?: string;
isPk: string;
isFk: string;
fkReference?: string;
isRequired: string;
defaultValue?: string;
businessRule?: string;
sampleValue?: string;
maskingType?: string;
encrypted?: string;
}
export interface DataDictionaryFullResp {
tableName: string;
columnName: string;
ordinalPosition: number;
dataType: string;
maxLength?: number;
numPrecision?: number;
numScale?: number;
isNullable: string;
columnDefault?: string;
tableComment?: string;
columnComment?: string;
columnLabel?: string;
dictDescription?: string;
domainCode?: string;
domainName?: string;
domainCategory?: string;
formatPattern?: string;
maskingType?: string;
encrypted?: string;
isPk?: string;
isFk?: string;
fkReference?: string;
businessRule?: string;
sampleValue?: string;
}
export interface DataDictionarySaveReq {
tableName: string;
columnName: string;
columnLabel?: string;
description?: string;
domainCode?: string;
isPk?: string;
isFk?: string;
fkReference?: string;
isRequired?: string;
defaultValue?: string;
businessRule?: string;
sampleValue?: string;
}
export interface DataDictionaryQuery {
tableName?: string;
domainCode?: string;
pageNum?: number;
pageSize?: number;
}
// ─── API ─────────────────────────────────────────────────────────────────────
export const dataDomainApi = {
list: (params: DataDomainQuery) =>
unwrap<PageResponse<DataDomainResp>>(api.get('/api/data-domains', { params })),
options: () =>
unwrap<DataDomainOption[]>(api.get('/api/data-domains/options')),
detail: (domainCode: string) =>
unwrap<DataDomainResp>(api.get(`/api/data-domains/${domainCode}`)),
create: (req: DataDomainSaveReq) =>
unwrap<void>(api.post('/api/data-domains', req)),
update: (domainCode: string, req: DataDomainSaveReq) =>
unwrap<void>(api.put(`/api/data-domains/${domainCode}`, req)),
remove: (domainCode: string) =>
unwrap<void>(api.delete(`/api/data-domains/${domainCode}`)),
};
export const dataDictApi = {
list: (params: DataDictionaryQuery) =>
unwrap<PageResponse<DataDictionaryResp>>(api.get('/api/data-dictionary', { params })),
tables: () =>
unwrap<string[]>(api.get('/api/data-dictionary/tables')),
full: (tableName: string) =>
unwrap<DataDictionaryFullResp[]>(api.get('/api/data-dictionary/full', { params: { tableName } })),
detail: (dictId: number) =>
unwrap<DataDictionaryResp>(api.get(`/api/data-dictionary/${dictId}`)),
create: (req: DataDictionarySaveReq) =>
unwrap<void>(api.post('/api/data-dictionary', req)),
update: (dictId: number, req: DataDictionarySaveReq) =>
unwrap<void>(api.put(`/api/data-dictionary/${dictId}`, req)),
remove: (dictId: number) =>
unwrap<void>(api.delete(`/api/data-dictionary/${dictId}`)),
};
@@ -4,7 +4,7 @@ import {
IconReportMoney, IconSettings, IconUsers, IconShieldLock,
IconCategory, IconKey, IconClipboardList, IconFolder,
IconHome, IconChartBar, IconDatabase, IconBuildingBank,
IconFileSpreadsheet, IconTransform, IconAlertTriangle,
IconFileSpreadsheet, IconTransform, IconAlertTriangle, IconBook2,
} from '@tabler/icons-react';
import type { ReactNode } from 'react';
@@ -38,6 +38,8 @@ const ICON_MAP: Record<string, ReactNode> = {
SYSTEM_FILE: <IconFolder size={16} />,
SYSTEM_CONFIG: <IconSettings size={16} />,
SYSTEM_UPLOAD_TEMPLATE: <IconFileSpreadsheet size={16} />,
SYSTEM_DATA_DOMAIN: <IconDatabase size={16} />,
SYSTEM_DATA_DICT: <IconBook2 size={16} />,
COMPANY: <IconBuildingBank size={16} />,
PRODUCT: <IconFileSpreadsheet size={16} />,
RECON: <IconReportMoney size={16} />,
@@ -0,0 +1,488 @@
import { useState, useMemo } from 'react';
import {
Button, Col, Empty, Input, List, Modal, Row, Space, Switch, Tag, Tooltip, Typography, message,
} from 'antd';
import {
ProCard, ProTable, ModalForm, ProFormText, ProFormSelect, ProFormTextArea, ProFormSwitch,
type ProColumns,
} from '@ant-design/pro-components';
import { IconPlus, IconEdit, IconTrash, IconSearch, IconBook } from '@tabler/icons-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import PermissionButton from '@/components/common/PermissionButton';
import {
dataDictApi, dataDomainApi,
DataDictionaryFullResp, DataDictionarySaveReq, DataDomainOption,
} from '@/api/dict';
const { Text } = Typography;
// ─── 테이블명 → 카테고리 자동 분류 ──────────────────────────────────────────
const TABLE_CATEGORY_PREFIX: Array<{ prefix: string; label: string }> = [
{ prefix: 'org_', label: '조직' },
{ prefix: 'agent_', label: '조직' },
{ prefix: 'contract_', label: '계약' },
{ prefix: 'policy_', label: '계약' },
{ prefix: 'ledger_', label: '원장' },
{ prefix: 'settle_', label: '정산' },
{ prefix: 'payment_', label: '정산' },
{ prefix: 'batch_', label: '정산' },
{ prefix: 'sys_', label: '시스템' },
{ prefix: 'system_', label: '시스템' },
{ prefix: 'common_', label: '시스템' },
{ prefix: 'menu_', label: '시스템' },
{ prefix: 'user_', label: '시스템' },
{ prefix: 'role_', label: '시스템' },
];
function getTableCategory(tableName: string): string {
for (const { prefix, label } of TABLE_CATEGORY_PREFIX) {
if (tableName.startsWith(prefix)) return label;
}
return '기타';
}
const CATEGORY_ORDER = ['조직', '계약', '원장', '정산', '시스템', '기타'];
// ─── 도메인 카테고리 색상 ─────────────────────────────────────────────────────
const DOMAIN_CAT_COLOR: Record<string, string> = {
IDENTIFIER: 'blue',
PII: 'red',
MONEY: 'green',
RATE: 'cyan',
DATE: 'purple',
CODE: 'orange',
TEXT: 'default',
JSON: 'magenta',
};
// ─── 도메인 셀 (Tooltip 포함) ─────────────────────────────────────────────────
function DomainCell({ row }: { row: DataDictionaryFullResp }) {
if (!row.domainCode) return <span style={{ color: '#bbb' }}>-</span>;
const tag = (
<Tag color={DOMAIN_CAT_COLOR[row.domainCategory ?? ''] ?? 'default'} style={{ cursor: 'default' }}>
{row.domainName ?? row.domainCode}
</Tag>
);
if (!row.formatPattern && !row.dictDescription) return tag;
return (
<Tooltip
title={
<div>
<div><Text style={{ color: '#fff', fontSize: 12 }}>: {row.domainCode}</Text></div>
{row.dictDescription && <div><Text style={{ color: '#ddd', fontSize: 12 }}>{row.dictDescription}</Text></div>}
{row.formatPattern && <div><Text style={{ color: '#adf', fontSize: 12 }}>: {row.formatPattern}</Text></div>}
</div>
}
>
{tag}
</Tooltip>
);
}
// ─── 사전 등록 / 수정 ModalForm ───────────────────────────────────────────────
function DictFormModal({
row,
domainOptions,
onSuccess,
trigger,
}: {
row: DataDictionaryFullResp;
domainOptions: DataDomainOption[];
onSuccess: () => void;
trigger: JSX.Element;
}) {
const isEdit = !!row.domainCode || !!row.columnLabel || !!row.isPk;
const initialValues: Partial<DataDictionarySaveReq> & { isPkBool?: boolean; isFkBool?: boolean; isRequiredBool?: boolean } = {
tableName: row.tableName,
columnName: row.columnName,
columnLabel: row.columnLabel,
domainCode: row.domainCode,
fkReference: row.fkReference,
defaultValue: row.columnDefault,
businessRule: row.businessRule,
sampleValue: row.sampleValue,
isPkBool: row.isPk === 'Y',
isFkBool: row.isFk === 'Y',
isRequiredBool: row.isNullable === 'NO',
};
return (
<ModalForm<DataDictionarySaveReq & { isPkBool?: boolean; isFkBool?: boolean; isRequiredBool?: boolean }>
title={isEdit ? '사전 수정' : '사전 등록'}
trigger={trigger}
width={560}
modalProps={{ destroyOnClose: true }}
initialValues={initialValues}
onFinish={async (values) => {
const { isPkBool, isFkBool, isRequiredBool, ...rest } = values;
const payload: DataDictionarySaveReq = {
...rest,
tableName: row.tableName,
columnName: row.columnName,
isPk: isPkBool ? 'Y' : 'N',
isFk: isFkBool ? 'Y' : 'N',
isRequired: isRequiredBool ? 'Y' : 'N',
};
await dataDictApi.create(payload);
message.success(isEdit ? '수정 완료' : '등록 완료');
onSuccess();
return true;
}}
>
<ProFormText name="columnLabel" label="한글 컬럼명" rules={[{ required: true }]} />
<ProFormSelect
name="domainCode"
label="도메인"
showSearch
options={domainOptions.map((o) => ({
value: o.domainCode,
label: `${o.domainName} (${o.domainCode}) — ${o.domainCategory}`,
}))}
fieldProps={{ allowClear: true }}
/>
<ProFormSwitch name="isPkBool" label="PK" />
<ProFormSwitch name="isFkBool" label="FK" />
<ProFormSwitch name="isRequiredBool" label="필수 여부" />
<ProFormText name="fkReference" label="FK 참조" placeholder="테이블.컬럼" />
<ProFormText name="defaultValue" label="기본값" />
<ProFormTextArea name="businessRule" label="업무 규칙" fieldProps={{ rows: 2 }} />
<ProFormText name="sampleValue" label="샘플 값" />
</ModalForm>
);
}
// ─── 우측: 컬럼 사전 테이블 ──────────────────────────────────────────────────
function ColumnDictTable({ tableName }: { tableName: string }) {
const qc = useQueryClient();
const { data: domainOptions = [] } = useQuery({
queryKey: ['dataDomain', 'options'],
queryFn: dataDomainApi.options,
});
const { data: rows = [], isLoading, refetch } = useQuery({
queryKey: ['dataDict', 'full', tableName],
queryFn: () => dataDictApi.full(tableName),
enabled: !!tableName,
});
const handleDelete = (dictId: number | undefined) => {
if (!dictId) return;
Modal.confirm({
title: '사전 삭제',
content: '이 컬럼의 사전 정보를 삭제하시겠습니까?',
okType: 'danger',
okText: '삭제',
cancelText: '취소',
onOk: async () => {
await dataDictApi.remove(dictId);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['dataDict', 'full', tableName] });
refetch();
},
});
};
const columns: ProColumns<DataDictionaryFullResp>[] = [
{
title: '#', dataIndex: 'ordinalPosition', width: 50, align: 'right', search: false,
render: (v) => <Text type="secondary" style={{ fontSize: 12 }}>{v as number}</Text>,
},
{
title: '컬럼명', dataIndex: 'columnName', width: 160, search: false,
render: (_, r) => (
<span>
<Text code style={{ fontSize: 12 }}>{r.columnName}</Text>
{r.isPk === 'Y' && <Tag color="gold" style={{ marginLeft: 4, fontSize: 11 }}>PK</Tag>}
{r.isFk === 'Y' && <Tag color="purple" style={{ marginLeft: 4, fontSize: 11 }}>FK</Tag>}
</span>
),
},
{
title: '한글명', dataIndex: 'columnLabel', width: 140, search: false,
render: (_, r) =>
r.columnLabel
? <Text>{r.columnLabel}</Text>
: <Text type="secondary" style={{ fontSize: 12 }}></Text>,
},
{
title: '도메인', dataIndex: 'domainCode', width: 140, search: false,
render: (_, r) => <DomainCell row={r} />,
},
{
title: 'DB 타입', dataIndex: 'dataType', width: 100, search: false,
render: (_, r) => {
let label = r.dataType;
if (r.maxLength) label += `(${r.maxLength})`;
if (r.numPrecision) label += `(${r.numPrecision}${r.numScale != null ? `,${r.numScale}` : ''})`;
return <Text style={{ fontSize: 12 }}>{label}</Text>;
},
},
{
title: 'Nullable', dataIndex: 'isNullable', width: 80, align: 'center', search: false,
render: (_, r) =>
r.isNullable === 'NO'
? <Tag color="orange">NOT NULL</Tag>
: <Tag color="default">NULL</Tag>,
},
{
title: '마스킹', dataIndex: 'maskingType', width: 80, search: false,
render: (_, r) =>
r.maskingType && r.maskingType !== 'NONE'
? <Tag color="orange">{r.maskingType}</Tag>
: <span style={{ color: '#bbb' }}>-</span>,
},
{
title: '암호화', dataIndex: 'encrypted', width: 75, align: 'center', search: false,
render: (_, r) =>
r.encrypted === 'Y'
? <Tag color="red"></Tag>
: <span style={{ color: '#bbb' }}>-</span>,
},
{
title: '샘플', dataIndex: 'sampleValue', width: 100, search: false, ellipsis: true,
render: (v) => v ?? <span style={{ color: '#bbb' }}>-</span>,
},
{
title: '액션', width: 110, fixed: 'right', search: false,
render: (_, r) => {
const hasDictEntry = !!r.columnLabel || !!r.domainCode || !!r.businessRule;
if (!hasDictEntry) {
return (
<DictFormModal
row={r}
domainOptions={domainOptions}
onSuccess={() => { qc.invalidateQueries({ queryKey: ['dataDict', 'full', tableName] }); refetch(); }}
trigger={
<PermissionButton
menuCode="SYSTEM_DATA_DICT"
permCode="CREATE"
size="small"
type="dashed"
icon={<IconPlus size={12} />}
style={{ color: '#aaa', borderColor: '#d9d9d9' }}
>
</PermissionButton>
}
/>
);
}
return (
<Space size={2}>
<DictFormModal
row={r}
domainOptions={domainOptions}
onSuccess={() => { qc.invalidateQueries({ queryKey: ['dataDict', 'full', tableName] }); refetch(); }}
trigger={
<PermissionButton
menuCode="SYSTEM_DATA_DICT"
permCode="UPDATE"
size="small"
type="text"
icon={<IconEdit size={14} />}
>
</PermissionButton>
}
/>
<PermissionButton
menuCode="SYSTEM_DATA_DICT"
permCode="DELETE"
size="small"
type="text"
danger
icon={<IconTrash size={14} />}
onClick={() => handleDelete(undefined)}
>
</PermissionButton>
</Space>
);
},
},
];
return (
<ProTable<DataDictionaryFullResp>
rowKey="columnName"
columns={columns}
dataSource={rows}
loading={isLoading}
search={false}
pagination={false}
options={{ density: false, reload: () => refetch(), setting: true }}
scroll={{ x: 1100 }}
rowClassName={(r) => {
const hasDictEntry = !!r.columnLabel || !!r.domainCode || !!r.businessRule;
return hasDictEntry ? '' : 'dict-row-unregistered';
}}
style={{ '--dict-unregistered-bg': '#fafafa' } as React.CSSProperties}
dateFormatter="string"
/>
);
}
// ─── 좌측: 테이블 목록 ────────────────────────────────────────────────────────
function TableList({
selected,
onSelect,
}: {
selected: string;
onSelect: (t: string) => void;
}) {
const [keyword, setKeyword] = useState('');
const { data: tables = [], isLoading } = useQuery({
queryKey: ['dataDict', 'tables'],
queryFn: dataDictApi.tables,
});
const filtered = useMemo(
() => tables.filter((t) => t.toLowerCase().includes(keyword.toLowerCase())),
[tables, keyword],
);
// 카테고리별 그룹핑
const grouped = useMemo(() => {
const map: Record<string, string[]> = {};
for (const t of filtered) {
const cat = getTableCategory(t);
if (!map[cat]) map[cat] = [];
map[cat].push(t);
}
return map;
}, [filtered]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Input
prefix={<IconSearch size={14} />}
placeholder="테이블 검색"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
size="small"
style={{ marginBottom: 8 }}
allowClear
/>
<div style={{ flex: 1, overflow: 'auto' }}>
{isLoading ? (
<div style={{ padding: 16, color: '#999', fontSize: 12 }}> ...</div>
) : filtered.length === 0 ? (
<Empty description="테이블 없음" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
CATEGORY_ORDER.filter((cat) => grouped[cat]?.length).map((cat) => (
<div key={cat}>
<div style={{
padding: '4px 8px',
fontSize: 11,
fontWeight: 600,
color: '#888',
background: '#f5f5f5',
letterSpacing: 1,
}}>
{cat}
</div>
<List
size="small"
dataSource={grouped[cat]}
renderItem={(t) => (
<List.Item
onClick={() => onSelect(t)}
style={{
cursor: 'pointer',
padding: '4px 12px',
background: t === selected ? '#E6F1FB' : undefined,
borderLeft: t === selected ? '3px solid #1677ff' : '3px solid transparent',
fontSize: 12,
}}
>
<Text
code
style={{
fontSize: 11,
color: t === selected ? '#1677ff' : undefined,
background: 'transparent',
border: 'none',
}}
>
{t}
</Text>
</List.Item>
)}
/>
</div>
))
)}
</div>
</div>
);
}
// ─── 메인 ─────────────────────────────────────────────────────────────────────
export default function DataDictionary() {
const [selectedTable, setSelectedTable] = useState('');
return (
<PageContainer
title="데이터 사전"
description="테이블 컬럼 정의 / 도메인 연결 / 마스킹 및 보안 정책"
>
<ProCard split="vertical" style={{ minHeight: 600 }}>
{/* 좌측: 테이블 목록 */}
<ProCard
colSpan={280}
title={
<Space>
<IconBook size={14} style={{ verticalAlign: -2 }} />
</Space>
}
headerBordered
style={{ overflow: 'hidden' }}
bodyStyle={{ padding: '8px 0', height: 'calc(100vh - 240px)', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
>
<div style={{ padding: '0 8px', flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<TableList selected={selectedTable} onSelect={setSelectedTable} />
</div>
</ProCard>
{/* 우측: 컬럼 사전 */}
<ProCard
title={
selectedTable
? <span> <Text code style={{ fontSize: 13 }}>{selectedTable}</Text></span>
: '컬럼 사전'
}
headerBordered
bodyStyle={{ padding: 0 }}
>
{selectedTable ? (
<ColumnDictTable tableName={selectedTable} />
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 400 }}>
<Empty description="좌측에서 테이블을 선택하면 컬럼 사전을 확인할 수 있습니다" />
</div>
)}
</ProCard>
</ProCard>
{/* 미등록 행 스타일 */}
<style>{`
.dict-row-unregistered td {
background: #fafafa !important;
color: #aaa;
}
`}</style>
</PageContainer>
);
}
@@ -0,0 +1,315 @@
import { useRef } from 'react';
import { Modal, Space, Switch, Tag, Tooltip, message } from 'antd';
import {
ProTable, ModalForm, ProFormText, ProFormSelect, ProFormDigit,
ProFormTextArea, ProFormSwitch,
type ActionType, type ProColumns,
} from '@ant-design/pro-components';
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
import { useQueryClient } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import PermissionButton from '@/components/common/PermissionButton';
import { dataDomainApi, DataDomainResp, DataDomainSaveReq } from '@/api/dict';
// ─── 카테고리 색상 ────────────────────────────────────────────────────────────
const CATEGORY_COLOR: Record<string, string> = {
IDENTIFIER: 'blue',
PII: 'red',
MONEY: 'green',
RATE: 'cyan',
DATE: 'purple',
CODE: 'orange',
TEXT: 'default',
JSON: 'magenta',
};
const CATEGORY_OPTIONS = Object.keys(CATEGORY_COLOR).map((v) => ({ value: v, label: v }));
const DATA_TYPE_OPTIONS = [
'VARCHAR', 'CHAR', 'TEXT', 'INT', 'BIGINT', 'SMALLINT',
'DECIMAL', 'NUMERIC', 'FLOAT', 'DOUBLE', 'DATE', 'DATETIME',
'TIMESTAMP', 'BOOLEAN', 'JSON',
].map((v) => ({ value: v, label: v }));
const MASKING_OPTIONS = [
{ value: 'NONE', label: 'NONE' },
{ value: 'NAME', label: 'NAME' },
{ value: 'PHONE', label: 'PHONE' },
{ value: 'RRN', label: 'RRN' },
{ value: 'ACCOUNT', label: 'ACCOUNT' },
{ value: 'EMAIL', label: 'EMAIL' },
];
const MASKING_COLOR: Record<string, string> = {
NONE: 'default',
NAME: 'orange',
PHONE: 'orange',
RRN: 'red',
ACCOUNT: 'red',
EMAIL: 'orange',
};
// ─── 도메인 등록 / 수정 ModalForm ─────────────────────────────────────────────
function DomainFormModal({
editTarget,
onSuccess,
trigger,
}: {
editTarget?: DataDomainResp;
onSuccess: () => void;
trigger: JSX.Element;
}) {
const isEdit = !!editTarget;
return (
<ModalForm<DataDomainSaveReq & { encryptedBool?: boolean }>
title={isEdit ? '도메인 수정' : '도메인 등록'}
trigger={trigger}
width={640}
modalProps={{ destroyOnClose: true }}
initialValues={
editTarget
? { ...editTarget, encryptedBool: editTarget.encrypted === 'Y' }
: { isActive: 'Y', encrypted: 'N', encryptedBool: false }
}
onFinish={async (values) => {
const { encryptedBool, ...rest } = values;
const payload: DataDomainSaveReq = {
...rest,
encrypted: encryptedBool ? 'Y' : 'N',
isActive: rest.isActive ?? 'Y',
};
if (isEdit) {
await dataDomainApi.update(editTarget!.domainCode, payload);
message.success('수정 완료');
} else {
await dataDomainApi.create(payload);
message.success('등록 완료');
}
onSuccess();
return true;
}}
>
<ProFormText
name="domainCode"
label="도메인 코드"
rules={[{ required: true }]}
fieldProps={{ disabled: isEdit }}
placeholder="예: AGENT_CODE"
/>
<ProFormText
name="domainName"
label="도메인 명"
rules={[{ required: true }]}
/>
<ProFormSelect
name="domainCategory"
label="카테고리"
rules={[{ required: true }]}
options={CATEGORY_OPTIONS}
/>
<ProFormSelect
name="dataType"
label="데이터 타입"
rules={[{ required: true }]}
options={DATA_TYPE_OPTIONS}
/>
<ProFormDigit name="length" label="길이" min={0} />
<ProFormDigit name="precision" label="전체 자릿수" min={0} />
<ProFormDigit name="scale" label="소수 자릿수" min={0} />
<ProFormText name="formatPattern" label="포맷 패턴" placeholder="예: YYYY-MM-DD" />
<ProFormText name="validationRegex" label="검증 정규식" />
<ProFormText name="exampleValue" label="예시 값" />
<ProFormSelect
name="maskingType"
label="마스킹 유형"
options={MASKING_OPTIONS}
/>
<ProFormSwitch name="encryptedBool" label="암호화 여부" />
<ProFormTextArea name="description" label="설명" fieldProps={{ rows: 2 }} />
<ProFormSelect
name="isActive"
label="활성 여부"
options={[{ value: 'Y', label: '활성' }, { value: 'N', label: '비활성' }]}
/>
</ModalForm>
);
}
// ─── 메인 ─────────────────────────────────────────────────────────────────────
export default function DataDomainList() {
const qc = useQueryClient();
const actionRef = useRef<ActionType>();
const refresh = () => {
qc.invalidateQueries({ queryKey: ['dataDomain'] });
actionRef.current?.reload();
};
const handleDelete = (domainCode: string) =>
Modal.confirm({
title: '도메인 삭제',
content: '이 도메인을 삭제하시겠습니까? 사용 중인 컬럼이 있으면 오류가 반환됩니다.',
okType: 'danger',
okText: '삭제',
cancelText: '취소',
onOk: async () => {
await dataDomainApi.remove(domainCode);
message.success('삭제 완료');
refresh();
},
});
const columns: ProColumns<DataDomainResp>[] = [
// 검색 전용
{
title: '카테고리', dataIndex: 'domainCategory', valueType: 'select',
valueEnum: Object.fromEntries(CATEGORY_OPTIONS.map((o) => [o.value, { text: o.label }])),
hideInTable: true,
},
{
title: '활성 여부', dataIndex: 'isActive', valueType: 'select',
valueEnum: { Y: { text: '활성' }, N: { text: '비활성' } },
hideInTable: true,
},
{
title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
fieldProps: { placeholder: '코드 / 이름' },
},
// 테이블 컬럼
{ title: '도메인 코드', dataIndex: 'domainCode', width: 160, search: false, copyable: true },
{ title: '도메인 명', dataIndex: 'domainName', width: 160, search: false },
{
title: '카테고리', dataIndex: 'domainCategory', width: 110, search: false,
render: (_, r) => (
<Tag color={CATEGORY_COLOR[r.domainCategory] ?? 'default'}>{r.domainCategory}</Tag>
),
},
{ title: '데이터 타입', dataIndex: 'dataType', width: 110, search: false },
{
title: '길이 / 정밀도', width: 110, search: false,
render: (_, r) => {
if (r.precision != null) {
return <span>{r.precision}{r.scale != null ? `,${r.scale}` : ''}</span>;
}
return r.length != null ? <span>{r.length}</span> : <span style={{ color: '#bbb' }}>-</span>;
},
},
{
title: '포맷 패턴', dataIndex: 'formatPattern', width: 130, search: false,
render: (v) => v ?? <span style={{ color: '#bbb' }}>-</span>,
},
{
title: '마스킹', dataIndex: 'maskingType', width: 90, search: false,
render: (_, r) =>
r.maskingType && r.maskingType !== 'NONE' ? (
<Tag color={MASKING_COLOR[r.maskingType] ?? 'default'}>{r.maskingType}</Tag>
) : (
<span style={{ color: '#bbb' }}>-</span>
),
},
{
title: '암호화', dataIndex: 'encrypted', width: 80, align: 'center', search: false,
render: (_, r) =>
r.encrypted === 'Y' ? <Tag color="red"></Tag> : <span style={{ color: '#bbb' }}>-</span>,
},
{
title: '활성', dataIndex: 'isActive', width: 70, align: 'center', search: false,
render: (_, r) => (
<Switch checked={r.isActive === 'Y'} size="small" disabled />
),
},
{
title: '액션', width: 130, fixed: 'right', search: false,
render: (_, r) => (
<Space size={2}>
<DomainFormModal
editTarget={r}
onSuccess={refresh}
trigger={
<PermissionButton
menuCode="SYSTEM_DATA_DOMAIN"
permCode="UPDATE"
size="small"
type="text"
icon={<IconEdit size={14} />}
>
</PermissionButton>
}
/>
<PermissionButton
menuCode="SYSTEM_DATA_DOMAIN"
permCode="DELETE"
size="small"
type="text"
danger
icon={<IconTrash size={14} />}
onClick={() => handleDelete(r.domainCode)}
>
</PermissionButton>
</Space>
),
},
];
return (
<PageContainer
title="데이터 도메인 사전"
description="컬럼 도메인 정의 및 마스킹 / 암호화 정책 관리"
extra={
<DomainFormModal
onSuccess={refresh}
trigger={
<PermissionButton
menuCode="SYSTEM_DATA_DOMAIN"
permCode="CREATE"
type="primary"
icon={<IconPlus size={14} />}
>
</PermissionButton>
}
/>
}
>
<ProTable<DataDomainResp>
actionRef={actionRef}
rowKey="domainCode"
columns={columns}
scroll={{ x: 1200 }}
request={async (params) => {
const { current, pageSize, domainCategory, isActive, searchKeyword } = params as {
current: number;
pageSize: number;
domainCategory?: string;
isActive?: string;
searchKeyword?: string;
};
const res = await dataDomainApi.list({ domainCategory, isActive, searchKeyword, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
pagination={{
pageSize: 30,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
showTotal: (total) => `${total.toLocaleString()}`,
}}
search={{
labelWidth: 'auto',
searchText: '검색',
resetText: '초기화',
collapsed: false,
collapseRender: false,
}}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
/>
</PageContainer>
);
}