fix(upload): target_table 자유입력 → Select (whitelist) + V21 PROFILE/RESUME 도메인

[버그 fix]
- UploadTemplateManager 의 ModalForm 에서 target_table 이 ProFormText 자유입력
  이라 사용자가 잘못 입력 시 ("1" 등) 백엔드 whitelist 검증 실패
  ("허용되지 않은 target_table: 1") 발생
- ProFormSelect 로 변경 + request 로 /api/upload-templates/target-tables 호출
  → 9개 허용 테이블만 옵션 노출 (오타 / 잘못된 입력 원천 차단)

[V21__프로필_이력서_도메인.sql]
도메인 15개 추가:

PROFILE 카테고리 (8개):
- DOM_PROFILE_BIO (자기소개, TEXT)
- DOM_PROFILE_IMAGE (프로필 사진, BIGINT 파일ID)
- DOM_NICKNAME (닉네임, VARCHAR(50))
- DOM_BIRTH_DATE (생년월일, DATE)
- DOM_GENDER (성별, CHAR(1) M/F/U)
- DOM_ADDRESS (주소, VARCHAR(300))
- DOM_ZIP_CODE (우편번호, VARCHAR(10))
- DOM_NATIONALITY (국적, ISO 3166)

RESUME 카테고리 (7개):
- DOM_RESUME_FILE (이력서 파일, BIGINT)
- DOM_CAREER_HISTORY (경력 사항, JSONB 배열)
- DOM_EDUCATION (학력, JSONB 배열)
- DOM_LICENSE_LIST (자격증 목록, JSONB)
- DOM_SKILL_TAGS (스킬 태그, VARCHAR(500))
- DOM_INTRODUCTION (소개 영상 URL, VARCHAR(500))
- DOM_HIRE_TYPE (계약 형태, VARCHAR(20))

데이터 사전 매핑 28개 추가 (V20 누락 보강):
- agent: grade_id/bank_code/leave_date/created_at/updated_at
- users: 7컬럼
- organization: 6컬럼
- insurance_company: 4컬럼
- product: 5컬럼

검증:
- Flyway V21 적용 200ms
- /api/data-domains?domainCategory=PROFILE → 8건
- /api/data-domains?domainCategory=RESUME → 7건
- target-tables 9개 정상 응답
- 프론트 tsc -b 통과

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-11 00:34:47 +09:00
parent cece084631
commit 1c74e192ec
2 changed files with 136 additions and 1 deletions
@@ -0,0 +1,127 @@
-- V21: 프로필 / 이력서 / 인적사항 관련 도메인 추가 (data_domain)
-- + 기존 agent / users 테이블의 누락 컬럼들 데이터 사전 매핑 보강
-- ============================================================
-- 1) 프로필 / 이력서 카테고리 도메인 추가
-- ============================================================
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, is_active)
VALUES
-- 프로필 (PROFILE) — 사용자/설계사 프로필 정보
('DOM_PROFILE_BIO', '자기소개', 'PROFILE', 'TEXT', NULL, NULL, NULL,
NULL, NULL, '15년차 라이프 플래너...', 'NONE', 'N',
'설계사 / 사용자의 자기소개 글', 'Y'),
('DOM_PROFILE_IMAGE', '프로필 사진', 'PROFILE', 'BIGINT', NULL, NULL, NULL,
NULL, NULL, '12345', 'NONE', 'N',
'file_storage 테이블의 file_id (프로필 이미지)', 'Y'),
('DOM_NICKNAME', '닉네임', 'PROFILE', 'VARCHAR', 50, NULL, NULL,
NULL, '^[가-힣A-Za-z0-9_]{2,20}$', '베스트설계사', 'NONE', 'N',
'표시명 / 별명', 'Y'),
('DOM_BIRTH_DATE', '생년월일', 'PROFILE', 'DATE', NULL, NULL, NULL,
'yyyy-MM-dd', NULL, '1985-03-15', 'NONE', 'N',
'생년월일 (만 나이 계산용)', 'Y'),
('DOM_GENDER', '성별', 'PROFILE', 'CHAR', 1, NULL, NULL,
NULL, '^[MFU]$', 'M', 'NONE', 'N',
'성별 코드 — M(남) / F(여) / U(미지정)', 'Y'),
('DOM_ADDRESS', '주소', 'PROFILE', 'VARCHAR', 300, NULL, NULL,
NULL, NULL, '서울특별시 강남구 테헤란로 123', 'NONE', 'N',
'주소 (도로명 또는 지번)', 'Y'),
('DOM_ZIP_CODE', '우편번호', 'PROFILE', 'VARCHAR', 10, NULL, NULL,
NULL, '^\d{5,6}$', '06234', 'NONE', 'N',
'우편번호', 'Y'),
('DOM_NATIONALITY', '국적', 'PROFILE', 'VARCHAR', 10, NULL, NULL,
NULL, '^[A-Z]{2,3}$', 'KR', 'NONE', 'N',
'ISO 3166 국가 코드 (예: KR, US)', 'Y'),
-- 이력서 (RESUME)
('DOM_RESUME_FILE', '이력서 파일', 'RESUME', 'BIGINT', NULL, NULL, NULL,
NULL, NULL, '67890', 'NONE', 'N',
'file_storage 의 file_id (PDF/DOCX 이력서)', 'Y'),
('DOM_CAREER_HISTORY', '경력 사항', 'RESUME', 'JSONB', NULL, NULL, NULL,
NULL, NULL,
'[{"company":"삼성생명","period":"2015-01~2020-12","role":"FC"}]',
'NONE', 'N',
'경력 이력 JSONB 배열 (회사/기간/역할)', 'Y'),
('DOM_EDUCATION', '학력', 'RESUME', 'JSONB', NULL, NULL, NULL,
NULL, NULL,
'[{"school":"서울대학교","major":"경영학","year":2010,"degree":"학사"}]',
'NONE', 'N',
'학력 이력 JSONB 배열 (학교/전공/연도)', 'Y'),
('DOM_LICENSE_LIST', '자격증 목록', 'RESUME', 'JSONB', NULL, NULL, NULL,
NULL, NULL,
'[{"name":"생명보험설계사","issuedAt":"2015-03-01","number":"L-12345"}]',
'NONE', 'N',
'자격증 / 인증 JSONB 배열', 'Y'),
('DOM_SKILL_TAGS', '스킬 태그', 'RESUME', 'VARCHAR', 500, NULL, NULL,
NULL, NULL, '#종신보험,#건강보험,#연금', 'NONE', 'N',
'쉼표 / 해시태그 구분 스킬', 'Y'),
('DOM_INTRODUCTION', '소개 영상 URL', 'RESUME', 'VARCHAR', 500, NULL, NULL,
NULL, '^https?://.+', 'https://youtu.be/abc123', 'NONE', 'N',
'소개 영상 외부 URL', 'Y'),
('DOM_HIRE_TYPE', '계약 형태', 'RESUME', 'VARCHAR', 20, NULL, NULL,
NULL, NULL, 'FULL_TIME', 'NONE', 'N',
'FULL_TIME / PART_TIME / CONTRACT / FREELANCE', 'Y')
ON CONFLICT (domain_code) DO NOTHING;
-- ============================================================
-- 2) 기존 테이블에 도메인 매핑 보강 (V20 에서 누락된 부분)
-- ============================================================
INSERT INTO ga.data_dictionary
(table_name, column_name, domain_code, column_label, is_pk, is_fk, fk_reference, is_required, description)
VALUES
-- agent 추가 컬럼 (V20 에서 10개 매핑됐음 — 누락 보강)
('agent', 'grade_id', 'DOM_ID_BIGINT', '직급ID', 'N', 'Y', 'grade.grade_id', 'Y', '소속 직급'),
('agent', 'bank_code', 'DOM_BANK_CODE', '은행코드', 'N', 'N', NULL, 'N', '계좌의 은행 코드'),
('agent', 'leave_date', 'DOM_DATE', '퇴사일', 'N', 'N', NULL, 'N', '퇴사 시 입력'),
('agent', 'created_at', 'DOM_DATETIME', '등록일시', 'N', 'N', NULL, 'Y', '레코드 생성 시각'),
('agent', 'updated_at', 'DOM_DATETIME', '수정일시', 'N', 'N', NULL, 'N', '레코드 수정 시각'),
-- users 테이블 (시스템 사용자) — 신규 매핑
('users', 'user_id', 'DOM_ID_BIGINT', '사용자ID', 'Y', 'N', NULL, 'Y', NULL),
('users', 'login_id', 'DOM_LICENSE_NO', '로그인 ID', 'N', 'N', NULL, 'Y', '로그인 식별자 (UNIQUE)'),
('users', 'password', 'DOM_TEXT_SHORT', '비밀번호', 'N', 'N', NULL, 'Y', 'BCrypt 해시'),
('users', 'agent_id', 'DOM_ID_BIGINT', '연결 설계사', 'N', 'Y', 'agent.agent_id', 'N', NULL),
('users', 'role', 'DOM_STATUS', '역할', 'N', 'N', NULL, 'Y', 'SUPER_ADMIN / ADMIN / MANAGER / AGENT'),
('users', 'is_active', 'DOM_YN_FLAG', '활성여부', 'N', 'N', NULL, 'Y', NULL),
('users', 'last_login_at','DOM_DATETIME', '마지막 로그인일시', 'N', 'N', NULL, 'N', NULL),
-- organization 테이블
('organization', 'org_id', 'DOM_ID_BIGINT', '조직ID', 'Y', 'N', NULL, 'Y', NULL),
('organization', 'parent_org_id', 'DOM_ID_BIGINT', '상위조직ID', 'N', 'Y', 'organization.org_id', 'N', NULL),
('organization', 'org_name', 'DOM_NAME', '조직명', 'N', 'N', NULL, 'Y', NULL),
('organization', 'org_type', 'DOM_STATUS', '조직유형', 'N', 'N', NULL, 'Y', 'HQ / BR / TM'),
('organization', 'org_level', 'DOM_ID_BIGINT', '조직레벨', 'N', 'N', NULL, 'Y', NULL),
('organization', 'effective_from', 'DOM_DATE', '적용시작일', 'N', 'N', NULL, 'Y', NULL),
-- insurance_company 테이블
('insurance_company', 'company_id', 'DOM_ID_BIGINT', '보험사ID', 'Y', 'N', NULL, 'Y', NULL),
('insurance_company', 'company_code', 'DOM_COMPANY_CODE','보험사코드','N','N', NULL, 'Y', 'UNIQUE'),
('insurance_company', 'company_name', 'DOM_NAME', '회사명', 'N', 'N', NULL, 'Y', NULL),
('insurance_company', 'biz_no', 'DOM_LICENSE_NO', '사업자번호','N','N', NULL, 'N', NULL),
-- product 테이블
('product', 'product_id', 'DOM_ID_BIGINT', '상품ID', 'Y', 'N', NULL, 'Y', NULL),
('product', 'company_id', 'DOM_ID_BIGINT', '보험사ID', 'N', 'Y', 'insurance_company.company_id', 'Y', NULL),
('product', 'product_code', 'DOM_PRODUCT_CODE', '상품코드', 'N', 'N', NULL, 'Y', NULL),
('product', 'product_name', 'DOM_NAME', '상품명', 'N', 'N', NULL, 'Y', NULL),
('product', 'insurance_type', 'DOM_INSURANCE_TYPE','보험종류', 'N', 'N', NULL, 'Y', NULL)
ON CONFLICT (table_name, column_name) DO NOTHING;
@@ -691,7 +691,15 @@ function TemplateFormModal({ editTarget, onSuccess, trigger }: {
options={CATEGORIES} /> options={CATEGORIES} />
</Col> </Col>
<Col span={12}> <Col span={12}>
<ProFormText name="targetTable" label="대상 테이블" rules={[{ required: true }]} /> <ProFormSelect
name="targetTable" label="대상 테이블"
rules={[{ required: true, message: '필수입니다' }]}
request={async () => {
const tables = await uploadTemplateApi.targetTables();
return tables.map((t) => ({ value: t, label: t }));
}}
placeholder="recruit_ledger 등 선택"
/>
</Col> </Col>
<Col span={12}> <Col span={12}>
<ProFormText name="companyCode" label="보험사 코드" /> <ProFormText name="companyCode" label="보험사 코드" />