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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user