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:
@@ -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>
|
||||||
@@ -36,6 +36,8 @@ import CodeList from '@/pages/system/CodeList';
|
|||||||
import SystemConfig from '@/pages/system/SystemConfig';
|
import SystemConfig from '@/pages/system/SystemConfig';
|
||||||
import SystemLog from '@/pages/system/SystemLog';
|
import SystemLog from '@/pages/system/SystemLog';
|
||||||
import UploadTemplateManager from '@/pages/system/UploadTemplateManager';
|
import UploadTemplateManager from '@/pages/system/UploadTemplateManager';
|
||||||
|
import DataDomainList from '@/pages/system/DataDomainList';
|
||||||
|
import DataDictionary from '@/pages/system/DataDictionary';
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const token = localStorage.getItem('accessToken');
|
const token = localStorage.getItem('accessToken');
|
||||||
@@ -90,6 +92,8 @@ export default function App() {
|
|||||||
<Route path="system/config" element={<SystemConfig />} />
|
<Route path="system/config" element={<SystemConfig />} />
|
||||||
<Route path="system/logs" element={<SystemLog />} />
|
<Route path="system/logs" element={<SystemLog />} />
|
||||||
<Route path="system/upload-templates" element={<UploadTemplateManager />} />
|
<Route path="system/upload-templates" element={<UploadTemplateManager />} />
|
||||||
|
<Route path="system/data-domains" element={<DataDomainList />} />
|
||||||
|
<Route path="system/data-dictionary" element={<DataDictionary />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
IconReportMoney, IconSettings, IconUsers, IconShieldLock,
|
||||||
IconCategory, IconKey, IconClipboardList, IconFolder,
|
IconCategory, IconKey, IconClipboardList, IconFolder,
|
||||||
IconHome, IconChartBar, IconDatabase, IconBuildingBank,
|
IconHome, IconChartBar, IconDatabase, IconBuildingBank,
|
||||||
IconFileSpreadsheet, IconTransform, IconAlertTriangle,
|
IconFileSpreadsheet, IconTransform, IconAlertTriangle, IconBook2,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
@@ -38,6 +38,8 @@ const ICON_MAP: Record<string, ReactNode> = {
|
|||||||
SYSTEM_FILE: <IconFolder size={16} />,
|
SYSTEM_FILE: <IconFolder size={16} />,
|
||||||
SYSTEM_CONFIG: <IconSettings size={16} />,
|
SYSTEM_CONFIG: <IconSettings size={16} />,
|
||||||
SYSTEM_UPLOAD_TEMPLATE: <IconFileSpreadsheet size={16} />,
|
SYSTEM_UPLOAD_TEMPLATE: <IconFileSpreadsheet size={16} />,
|
||||||
|
SYSTEM_DATA_DOMAIN: <IconDatabase size={16} />,
|
||||||
|
SYSTEM_DATA_DICT: <IconBook2 size={16} />,
|
||||||
COMPANY: <IconBuildingBank size={16} />,
|
COMPANY: <IconBuildingBank size={16} />,
|
||||||
PRODUCT: <IconFileSpreadsheet size={16} />,
|
PRODUCT: <IconFileSpreadsheet size={16} />,
|
||||||
RECON: <IconReportMoney 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user