diff --git a/ga-api/src/main/java/com/ga/api/controller/dict/DataDictionaryController.java b/ga-api/src/main/java/com/ga/api/controller/dict/DataDictionaryController.java new file mode 100644 index 0000000..e236aef --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/dict/DataDictionaryController.java @@ -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> list(@ModelAttribute DataDictionarySearchParam param) { + return ApiResponse.ok(dataDictionaryService.list(param)); + } + + @GetMapping("/tables") + @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ") + public ApiResponse> tables() { + return ApiResponse.ok(dataDictionaryService.tables()); + } + + @GetMapping("/full") + @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ") + public ApiResponse> full( + @RequestParam(required = false) String tableName) { + return ApiResponse.ok(dataDictionaryService.full(tableName)); + } + + @GetMapping("/{dictId}") + @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ") + public ApiResponse 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 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 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 delete(@PathVariable Long dictId) { + dataDictionaryService.delete(dictId); + return ApiResponse.ok(); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/dict/DataDomainController.java b/ga-api/src/main/java/com/ga/api/controller/dict/DataDomainController.java new file mode 100644 index 0000000..ef504c4 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/dict/DataDomainController.java @@ -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> list(@ModelAttribute DataDomainSearchParam param) { + return ApiResponse.ok(dataDomainService.list(param)); + } + + @GetMapping("/options") + @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ") + public ApiResponse> options() { + return ApiResponse.ok(dataDomainService.options()); + } + + @GetMapping("/{domainCode}") + @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ") + public ApiResponse 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 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 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 delete(@PathVariable String domainCode) { + dataDomainService.delete(domainCode); + return ApiResponse.ok(); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/dict/DataDictionaryService.java b/ga-api/src/main/java/com/ga/api/service/dict/DataDictionaryService.java new file mode 100644 index 0000000..dc32194 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/dict/DataDictionaryService.java @@ -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 list(DataDictionarySearchParam param) { + param.startPage(); + return PageResponse.of(mapper.selectList(param)); + } + + public List tables() { + return mapper.selectTables(); + } + + public List 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); + } + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/dict/DataDomainService.java b/ga-api/src/main/java/com/ga/api/service/dict/DataDomainService.java new file mode 100644 index 0000000..760a93a --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/dict/DataDomainService.java @@ -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 list(DataDomainSearchParam param) { + param.startPage(); + return PageResponse.of(mapper.selectList(param)); + } + + public List 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); + } + } +} diff --git a/ga-common/src/main/resources/db/migration/V20__데이터사전.sql b/ga-common/src/main/resources/db/migration/V20__데이터사전.sql new file mode 100644 index 0000000..000228b --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V20__데이터사전.sql @@ -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; diff --git a/ga-core/src/main/java/com/ga/core/mapper/dict/DataDictionaryMapper.java b/ga-core/src/main/java/com/ga/core/mapper/dict/DataDictionaryMapper.java new file mode 100644 index 0000000..917c36c --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/dict/DataDictionaryMapper.java @@ -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 selectList(DataDictionarySearchParam param); + + /** 특정 테이블의 모든 컬럼 (sort_order 순, LEFT JOIN data_domain) */ + List selectByTable(@Param("tableName") String tableName); + + /** VIEW v_data_dictionary_full 조회. tableName null 이면 전체 */ + List selectFromView(@Param("tableName") String tableName); + + /** 사전에 등록된 distinct table_name 리스트 */ + List 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); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/dict/DataDomainMapper.java b/ga-core/src/main/java/com/ga/core/mapper/dict/DataDomainMapper.java new file mode 100644 index 0000000..1a43b48 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/dict/DataDomainMapper.java @@ -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 selectList(DataDomainSearchParam param); + + DataDomainResp selectByCode(@Param("domainCode") String domainCode); + + /** 드롭다운 Select 옵션용 전체 목록 (활성 도메인) */ + List selectAll(); + + int insert(DataDomainVO vo); + + int update(DataDomainVO vo); + + int delete(@Param("domainCode") String domainCode); + + /** data_dictionary 에서 해당 domainCode 를 참조하는 컬럼 수 */ + int countUsageByCode(@Param("domainCode") String domainCode); +} diff --git a/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionaryFullResp.java b/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionaryFullResp.java new file mode 100644 index 0000000..c3ad326 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionaryFullResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionaryResp.java b/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionaryResp.java new file mode 100644 index 0000000..b7e1a96 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionaryResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionarySaveReq.java b/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionarySaveReq.java new file mode 100644 index 0000000..96cf62f --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionarySaveReq.java @@ -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; + } +} diff --git a/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionarySearchParam.java b/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionarySearchParam.java new file mode 100644 index 0000000..08116a4 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionarySearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionaryVO.java b/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionaryVO.java new file mode 100644 index 0000000..ebc2342 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/dict/DataDictionaryVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainOptionResp.java b/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainOptionResp.java new file mode 100644 index 0000000..542c330 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainOptionResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainResp.java b/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainResp.java new file mode 100644 index 0000000..942a9f5 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainSaveReq.java new file mode 100644 index 0000000..d227b9d --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainSaveReq.java @@ -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; + } +} diff --git a/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainSearchParam.java new file mode 100644 index 0000000..3723d2c --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainSearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainVO.java b/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainVO.java new file mode 100644 index 0000000..3986dfb --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/dict/DataDomainVO.java @@ -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; +} diff --git a/ga-core/src/main/resources/mapper/dict/DataDictionaryMapper.xml b/ga-core/src/main/resources/mapper/dict/DataDictionaryMapper.xml new file mode 100644 index 0000000..8754f18 --- /dev/null +++ b/ga-core/src/main/resources/mapper/dict/DataDictionaryMapper.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + + + 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} + ) + + + + 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} + + + + DELETE FROM data_dictionary WHERE dict_id = #{dictId} + + + + + diff --git a/ga-core/src/main/resources/mapper/dict/DataDomainMapper.xml b/ga-core/src/main/resources/mapper/dict/DataDomainMapper.xml new file mode 100644 index 0000000..5859674 --- /dev/null +++ b/ga-core/src/main/resources/mapper/dict/DataDomainMapper.xml @@ -0,0 +1,89 @@ + + + + + + + + + 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 + + + + + + + + + + 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} + ) + + + + 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} + + + + DELETE FROM data_domain WHERE domain_code = #{domainCode} + + + + + diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index 601d348..c453e50 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -36,6 +36,8 @@ import CodeList from '@/pages/system/CodeList'; import SystemConfig from '@/pages/system/SystemConfig'; import SystemLog from '@/pages/system/SystemLog'; import UploadTemplateManager from '@/pages/system/UploadTemplateManager'; +import DataDomainList from '@/pages/system/DataDomainList'; +import DataDictionary from '@/pages/system/DataDictionary'; function RequireAuth({ children }: { children: React.ReactNode }) { const token = localStorage.getItem('accessToken'); @@ -90,6 +92,8 @@ export default function App() { } /> } /> } /> + } /> + } /> ); diff --git a/ga-frontend/src/api/dict.ts b/ga-frontend/src/api/dict.ts new file mode 100644 index 0000000..0acdba7 --- /dev/null +++ b/ga-frontend/src/api/dict.ts @@ -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>(api.get('/api/data-domains', { params })), + + options: () => + unwrap(api.get('/api/data-domains/options')), + + detail: (domainCode: string) => + unwrap(api.get(`/api/data-domains/${domainCode}`)), + + create: (req: DataDomainSaveReq) => + unwrap(api.post('/api/data-domains', req)), + + update: (domainCode: string, req: DataDomainSaveReq) => + unwrap(api.put(`/api/data-domains/${domainCode}`, req)), + + remove: (domainCode: string) => + unwrap(api.delete(`/api/data-domains/${domainCode}`)), +}; + +export const dataDictApi = { + list: (params: DataDictionaryQuery) => + unwrap>(api.get('/api/data-dictionary', { params })), + + tables: () => + unwrap(api.get('/api/data-dictionary/tables')), + + full: (tableName: string) => + unwrap(api.get('/api/data-dictionary/full', { params: { tableName } })), + + detail: (dictId: number) => + unwrap(api.get(`/api/data-dictionary/${dictId}`)), + + create: (req: DataDictionarySaveReq) => + unwrap(api.post('/api/data-dictionary', req)), + + update: (dictId: number, req: DataDictionarySaveReq) => + unwrap(api.put(`/api/data-dictionary/${dictId}`, req)), + + remove: (dictId: number) => + unwrap(api.delete(`/api/data-dictionary/${dictId}`)), +}; diff --git a/ga-frontend/src/components/common/MenuIcon.tsx b/ga-frontend/src/components/common/MenuIcon.tsx index ca764a5..cc8f972 100644 --- a/ga-frontend/src/components/common/MenuIcon.tsx +++ b/ga-frontend/src/components/common/MenuIcon.tsx @@ -4,7 +4,7 @@ import { IconReportMoney, IconSettings, IconUsers, IconShieldLock, IconCategory, IconKey, IconClipboardList, IconFolder, IconHome, IconChartBar, IconDatabase, IconBuildingBank, - IconFileSpreadsheet, IconTransform, IconAlertTriangle, + IconFileSpreadsheet, IconTransform, IconAlertTriangle, IconBook2, } from '@tabler/icons-react'; import type { ReactNode } from 'react'; @@ -38,6 +38,8 @@ const ICON_MAP: Record = { SYSTEM_FILE: , SYSTEM_CONFIG: , SYSTEM_UPLOAD_TEMPLATE: , + SYSTEM_DATA_DOMAIN: , + SYSTEM_DATA_DICT: , COMPANY: , PRODUCT: , RECON: , diff --git a/ga-frontend/src/pages/system/DataDictionary.tsx b/ga-frontend/src/pages/system/DataDictionary.tsx new file mode 100644 index 0000000..41bafc5 --- /dev/null +++ b/ga-frontend/src/pages/system/DataDictionary.tsx @@ -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 = { + 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 -; + const tag = ( + + {row.domainName ?? row.domainCode} + + ); + if (!row.formatPattern && !row.dictDescription) return tag; + return ( + +
코드: {row.domainCode}
+ {row.dictDescription &&
{row.dictDescription}
} + {row.formatPattern &&
포맷: {row.formatPattern}
} + + } + > + {tag} +
+ ); +} + +// ─── 사전 등록 / 수정 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 & { 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 ( + + 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; + }} + > + + ({ + value: o.domainCode, + label: `${o.domainName} (${o.domainCode}) — ${o.domainCategory}`, + }))} + fieldProps={{ allowClear: true }} + /> + + + + + + + + + ); +} + +// ─── 우측: 컬럼 사전 테이블 ────────────────────────────────────────────────── + +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[] = [ + { + title: '#', dataIndex: 'ordinalPosition', width: 50, align: 'right', search: false, + render: (v) => {v as number}, + }, + { + title: '컬럼명', dataIndex: 'columnName', width: 160, search: false, + render: (_, r) => ( + + {r.columnName} + {r.isPk === 'Y' && PK} + {r.isFk === 'Y' && FK} + + ), + }, + { + title: '한글명', dataIndex: 'columnLabel', width: 140, search: false, + render: (_, r) => + r.columnLabel + ? {r.columnLabel} + : 미등록, + }, + { + title: '도메인', dataIndex: 'domainCode', width: 140, search: false, + render: (_, 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 {label}; + }, + }, + { + title: 'Nullable', dataIndex: 'isNullable', width: 80, align: 'center', search: false, + render: (_, r) => + r.isNullable === 'NO' + ? NOT NULL + : NULL, + }, + { + title: '마스킹', dataIndex: 'maskingType', width: 80, search: false, + render: (_, r) => + r.maskingType && r.maskingType !== 'NONE' + ? {r.maskingType} + : -, + }, + { + title: '암호화', dataIndex: 'encrypted', width: 75, align: 'center', search: false, + render: (_, r) => + r.encrypted === 'Y' + ? 암호화 + : -, + }, + { + title: '샘플', dataIndex: 'sampleValue', width: 100, search: false, ellipsis: true, + render: (v) => v ?? -, + }, + { + title: '액션', width: 110, fixed: 'right', search: false, + render: (_, r) => { + const hasDictEntry = !!r.columnLabel || !!r.domainCode || !!r.businessRule; + if (!hasDictEntry) { + return ( + { qc.invalidateQueries({ queryKey: ['dataDict', 'full', tableName] }); refetch(); }} + trigger={ + } + style={{ color: '#aaa', borderColor: '#d9d9d9' }} + > + 사전 추가 + + } + /> + ); + } + return ( + + { qc.invalidateQueries({ queryKey: ['dataDict', 'full', tableName] }); refetch(); }} + trigger={ + } + > + 수정 + + } + /> + } + onClick={() => handleDelete(undefined)} + > + 삭제 + + + ); + }, + }, + ]; + + return ( + + 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 = {}; + for (const t of filtered) { + const cat = getTableCategory(t); + if (!map[cat]) map[cat] = []; + map[cat].push(t); + } + return map; + }, [filtered]); + + return ( +
+ } + placeholder="테이블 검색" + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + size="small" + style={{ marginBottom: 8 }} + allowClear + /> +
+ {isLoading ? ( +
로딩 중...
+ ) : filtered.length === 0 ? ( + + ) : ( + CATEGORY_ORDER.filter((cat) => grouped[cat]?.length).map((cat) => ( +
+
+ {cat} +
+ ( + onSelect(t)} + style={{ + cursor: 'pointer', + padding: '4px 12px', + background: t === selected ? '#E6F1FB' : undefined, + borderLeft: t === selected ? '3px solid #1677ff' : '3px solid transparent', + fontSize: 12, + }} + > + + {t} + + + )} + /> +
+ )) + )} +
+
+ ); +} + +// ─── 메인 ───────────────────────────────────────────────────────────────────── + +export default function DataDictionary() { + const [selectedTable, setSelectedTable] = useState(''); + + return ( + + + {/* 좌측: 테이블 목록 */} + + + 테이블 목록 + + } + headerBordered + style={{ overflow: 'hidden' }} + bodyStyle={{ padding: '8px 0', height: 'calc(100vh - 240px)', overflow: 'hidden', display: 'flex', flexDirection: 'column' }} + > +
+ +
+
+ + {/* 우측: 컬럼 사전 */} + 컬럼 사전 — {selectedTable} + : '컬럼 사전' + } + headerBordered + bodyStyle={{ padding: 0 }} + > + {selectedTable ? ( + + ) : ( +
+ +
+ )} +
+
+ + {/* 미등록 행 스타일 */} + +
+ ); +} diff --git a/ga-frontend/src/pages/system/DataDomainList.tsx b/ga-frontend/src/pages/system/DataDomainList.tsx new file mode 100644 index 0000000..4d4b03c --- /dev/null +++ b/ga-frontend/src/pages/system/DataDomainList.tsx @@ -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 = { + 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 = { + 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 ( + + 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; + }} + > + + + + + + + + + + + + + + + + ); +} + +// ─── 메인 ───────────────────────────────────────────────────────────────────── + +export default function DataDomainList() { + const qc = useQueryClient(); + const actionRef = useRef(); + + 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[] = [ + // 검색 전용 + { + 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) => ( + {r.domainCategory} + ), + }, + { title: '데이터 타입', dataIndex: 'dataType', width: 110, search: false }, + { + title: '길이 / 정밀도', width: 110, search: false, + render: (_, r) => { + if (r.precision != null) { + return {r.precision}{r.scale != null ? `,${r.scale}` : ''}; + } + return r.length != null ? {r.length} : -; + }, + }, + { + title: '포맷 패턴', dataIndex: 'formatPattern', width: 130, search: false, + render: (v) => v ?? -, + }, + { + title: '마스킹', dataIndex: 'maskingType', width: 90, search: false, + render: (_, r) => + r.maskingType && r.maskingType !== 'NONE' ? ( + {r.maskingType} + ) : ( + - + ), + }, + { + title: '암호화', dataIndex: 'encrypted', width: 80, align: 'center', search: false, + render: (_, r) => + r.encrypted === 'Y' ? 암호화 : -, + }, + { + title: '활성', dataIndex: 'isActive', width: 70, align: 'center', search: false, + render: (_, r) => ( + + ), + }, + { + title: '액션', width: 130, fixed: 'right', search: false, + render: (_, r) => ( + + } + > + 수정 + + } + /> + } + onClick={() => handleDelete(r.domainCode)} + > + 삭제 + + + ), + }, + ]; + + return ( + } + > + 도메인 등록 + + } + /> + } + > + + 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" + /> + + ); +}