264 Commits

Author SHA1 Message Date
johngreen 5cd8e72bf0 fix(테이블타입): 탭 너비 컨텐츠에 맞춤 — shadcn TabsTrigger flex-1 기본값 해제
shadcn TabsTrigger 가 기본 flex-1 이라 두 탭이 가로 폭 절반씩 차지해서 너무
넓게 늘어남. flex-none 으로 override 해서 글자/아이콘 폭만큼만 차지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:16:08 +09:00
johngreen 387a1ae611 style(테이블타입): 탭 Chrome outline 스타일 (#33)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m37s
탭 스타일 v5-tab 톤 매칭
2026-05-22 09:49:53 +00:00
johngreen eeb130e3a8 style(테이블타입): 탭 스타일 Chrome 식 outline 으로 변경 — 페이지 탭(v5-tab)과 톤 일치
밑줄(underline) 스타일이 너무 평면이라 사용자가 페이지 상단 v5-tab(브라우저 탭처럼
border 두른 outline 카드 스타일) 과 톤을 맞추길 원함.

- TabsList: 좌측 정렬 + 하단 1px 구분선 + 좌우 padding
- TabsTrigger: 위 모서리만 둥글게 (rounded-t-md), 1px border 둘러, -mb-px 로
  컨테이너 하단선과 겹쳐 카드처럼 떠 보임
- 활성 탭: bg-card + primary 색 텍스트 + 굵게 + 위쪽 2px primary 강조선 (inset shadow)
  + 하단 border 를 카드색으로 덮어 본문과 seamless

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:49:14 +09:00
johngreen 3ffa5c8ff5 chore(테이블관리): 사소 3건 (PR-D) (#32)
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m34s
PR-D: 사소 3건 정리
2026-05-22 06:12:02 +00:00
johngreen acbab68a12 chore(테이블관리): 사소 3건 정리 (PR-D) — 디버그 로그 / dead code / 에러 메시지
1. 운영 console.log/warn 제거 (G15) — page.tsx 의 이모지 prefix 디버그 로그
   (🔍 🔄  🗑️ 📥 📊 📋) 일괄 제거. catch 블록의 console.error 는 추적용으로
   유지. CreateTableModal 의 컬럼 조회 디버그 로그도 정리.

2. useLogTable dead code 정리 (G16) — CreateTableModal 의 useLogTable state,
   handleCreateTable 분기, 주석 처리된 체크박스 UI 모두 제거. 시그니처 안 맞는
   createLogTable 호출 페이로드까지 같이 사라짐. Activity / Checkbox import 도
   필요 없어졌으므로 제거.

3. 에러 메시지 일관화 (G17) — DdlController 와 TableManagementController 의
   "최고 관리자 권한이 필요합니다." 메시지 모두 "최고 관리자(SUPER_ADMIN) 권한이
   필요합니다." 로 통일. 일반 ADMIN 권한 메시지는 그대로 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:11:21 +09:00
johngreen db63ba6901 fix(테이블관리): 중요 4건 (PR-C) (#31)
Build & Deploy to K8s / build-and-deploy (push) Successful in 10m8s
PR-C: 중요 4건 일괄
2026-05-22 06:04:41 +00:00
johngreen ff95c1950e fix(테이블관리): 중요 4건 일괄 수정 (PR-C)
1. getColumnGroup metaCols 확장 — 시스템 자동 생성 컬럼이 5개만 인식되어
   사용자 컬럼에 섞여 보이던 문제. objid / tenant_id / creator / modifier /
   created_at / updated_at 추가 (총 11개).

2. updateColumnWebType 회사코드 격리 — 모든 호출이 company_code='*' 로 저장돼
   회사 관리자가 자기 회사 전용 web_type 변경 시 모든 회사 공통 설정을 건드림.
   컨트롤러에서 @RequestAttribute("company_code") 받아 service 에 전달.

3. validateUniqueConstraints N+1 해소 — hasColumn 이 루프 안에서 매번 호출되어
   UNIQUE 컬럼 N 개일 때 N 번의 information_schema 조회. 루프 밖으로 빼서 1 번.

4. sanitize 강화 — 빈 문자열 / 숫자 시작 / 63자 초과 / SQL 예약어 모두
   IllegalArgumentException 으로 거부. 빈 식별자가 동적 SQL 에 끼어들어 500
   에러 노출되던 패턴 방지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:04:02 +09:00
hjjeong 8a9285f13e Merge pull request 'fix(배치관리): DB 커넥션 변경 시 테이블 목록이 안 바뀌는 버그' (#30) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m51s
Reviewed-on: #30
2026-05-22 05:57:24 +00:00
johngreen 88b0549a6d fix(테이블관리): 심각 5건 (PR-B) (#29)
Build & Deploy to K8s / build-and-deploy (push) Successful in 7m15s
PR-B: 심각 5건 일괄
2026-05-22 05:50:52 +00:00
johngreen 33f0647c61 fix(테이블관리): 심각 5건 일괄 수정 (PR-B)
1. setTimeout 1초 reload 제거 — saveAllSettings / handleSaveColumn 의
   setTimeout(loadColumnTypes, 1000) 두 곳을 await 직접 호출로. 1초 깜빡임
   + race 해소.

2. typeFilter 자동 해제 시 input_type 변경 — 도넛 차트 필터가 켜진 상태에서
   상세 패널로 input_type 을 다른 타입으로 바꾸면 그 행이 그리드에서 갑자기
   사라지는 혼란. 변경된 타입이 필터와 다르면 자동으로 필터 해제.

3. composite PK 다이얼로그 — 매 PK 토글마다 확인 다이얼로그 뜨던 답답함.
   "이번 세션 다시 묻지 않기" 체크박스 추가. 체크 시 applyPkChange 즉시 호출.

4. 카테고리 매핑 병렬화 — for-of await 직렬 호출을 Promise.allSettled 로
   변경. 메뉴 수십 개 × 컬럼 수 = 수십 초 멈춤 → 한 번의 병렬 호출.

5. 좁은 화면 상세 패널 stuck 해소 — 패널이 열린 상태에서 column=null 이 되면
   X 버튼이 사라져 닫을 수단이 없던 문제. 빈 상태 UI 에도 X 버튼 유지 +
   ESC 키 핸들러 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:50:12 +09:00
hjjeong 8606f0aaa3 Merge remote-tracking branch 'origin/main' into hjjeong 2026-05-22 14:49:50 +09:00
johngreen 24106929fa fix(테이블관리): 블로커 5건 (PR-A) (#28)
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m26s
PR-A: 블로커 5건 일괄 수정
2026-05-22 05:44:32 +00:00
johngreen f530b3cf31 fix(테이블관리): 블로커 5건 일괄 수정 (PR-A)
종합 감사 결과 발견된 블로커 5건:

1. AddColumnModal / CreateTableModal prop 미스매치 — 호출부 tableName/sourceTableName(camelCase)
   vs 인터페이스 table_name/source_table_name(snake_case). 결과적으로 컬럼 추가 기능이 실제로
   동작하지 않았음 (/api/ddl/tables/undefined/columns). 호출부를 인터페이스에 맞춰 통일.

2. IS_NULLABLE 강제 'Y' 덮어쓰기 (backend mapper) — upsertColumnSettings 의 VALUES 절이
   모든 케이스에서 'Y' 리터럴을 박음. NN 토글 후 "컬럼 설정 저장" 누르면 필수 입력 제약이
   조용히 사라짐. COALESCE(#{is_nullable}, 'Y') 로 변경 + ON CONFLICT 절에 IS_NULLABLE
   COALESCE 추가. Service 도 settings 에서 is_nullable 을 읽어 'Y'/'N' 으로 정규화 후 전달.

3. 저장 안 한 편집 침묵 손실 — 우측 패널에서 편집 후 저장 안 누르고 다른 테이블로 이동 시
   변경 사항 소실. hasUnsavedChanges memo 추가 (columns vs originalColumns JSON 비교).
   handleTableSelect 에서 dirty 면 confirm 다이얼로그.

4. PK / NN / IDX / UQ 위치별 비대칭 저장 — 그리드 행 칩은 즉시 저장하는데 우측 상세 패널
   토글은 메모리만 변경하고 저장 버튼 필요. 두 위치 모두 즉시 저장으로 통일 (상세 패널의
   onColumnChange 가 is_nullable / is_unique 필드를 받으면 handleNullableToggle /
   handleUniqueToggle 호출하도록).

5. SUPER_ADMIN role 검증 누락 (backend 보안) — DdlController 의 isSuperAdmin 이 company_code
   == "*" 만 보고 role 클레임 무시. 토큰 변조 또는 "*" 회사 소속만으로 운영 DB DROP / ALTER
   가능. company_code "*" AND role "SUPER_ADMIN" 둘 다 충족 시에만 통과로 변경. 11개 엔드포인트
   에 @RequestAttribute("role") 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:43:15 +09:00
johngreen 99487049fb style(테이블타입): 탭 스타일 Google 식 underline (#27)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m22s
johngreen → main: 탭 스타일 Google 식 underline 으로 변경
2026-05-22 05:04:26 +00:00
johngreen 6233877029 style(테이블타입): 탭 스타일 Google 식 underline 으로 변경 — 가시성 ↑
기존 shadcn 기본 Tabs 가 둥근 알약 (segmented control) 스타일이라 탭이 아니라 토글처럼
보임. 사용자가 "탭이 눈에 잘 안 띈다" 지적.

Gmail/Drive/GitHub/Vercel 공통 패턴인 underline 스타일로 변경:
- TabsList: 전체 폭 + 하단 1px 구분선, 배경/padding 제거
- TabsTrigger: 평면 + 활성 시 2px primary 밑줄 + 글자 색 강조, 비활성은 muted
- 글자 / 아이콘 크기 한 단계 ↑ (text-xs→text-sm, h-3.5→h-4)
- 비활성에도 transparent border-b-2 줘서 layout shift 방지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:03:52 +09:00
johngreen 4031fe8b60 chore: retrigger CI (gradle plugin download timed out)
Build & Deploy to K8s / build-and-deploy (push) Successful in 13m24s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:21:01 +09:00
johngreen a5288647c9 feat(테이블타입): 와이드 모니터 우측 패널 고정 표시 (#26)
Build & Deploy to K8s / build-and-deploy (push) Failing after 5m9s
johngreen → main: 우측 상세 패널 와이드 모니터에서 고정 표시 + 빈 상태 안내
2026-05-22 04:09:46 +00:00
johngreen 7dbeccc182 feat(테이블타입): 와이드 모니터에서 우측 상세 패널 고정 표시 — 슬라이드 in 제거
기존엔 컬럼 행 클릭해야 우측 패널이 슬라이드로 들어옴. 와이드 모니터에선 우측에 큰 빈 공간이
남는데도 패널이 숨어있어 화면이 흔들려 보임 → 일관성 ↓

와이드(xl 이상)에선 패널을 일반 flex child 로 고정해서 항상 표시. 좁은 화면은 기존
오버레이 슬라이드 유지 (가운데 영역 좁아지는 것 방지).

- page.tsx: 우측 패널 컨테이너에 xl: 상태로 relative + 슬라이드 무력화
- ColumnDetailPanel: 컬럼 선택 안 한 상태일 때 "컬럼을 선택해주세요" 빈 상태 안내 UI 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:09:08 +09:00
johngreen c857e4f715 fix(테이블타입): 컬럼 탭 그룹 정리 (#25)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m33s
johngreen → main: 컬럼 탭 그룹 헤더 사용자/시스템 2그룹으로 정리
2026-05-22 03:32:37 +00:00
johngreen cf5f7ef9af fix(테이블타입): 컬럼 탭 그룹 헤더 사용자/시스템 2그룹으로 정리 — 참조 탭과 중복 해소
PR #24 에서 컬럼/참조 탭은 분리했지만 ColumnGrid 의 기존 3그룹 (기본/참조/메타) 헤더가
그대로 남아있어 "참조 정보" 그룹이 컬럼 탭에서도 보이고 참조 탭에서도 보이는 시각적 중복
발생.

DBeaver 의 Columns 탭처럼 모든 컬럼은 컬럼 탭에서 보여야 함 (참조 컬럼 포함). 단,
"참조 정보" 그룹 헤더는 참조 탭이 같은 역할을 하므로 제거.

- "기본 정보" + "참조 정보" → "사용자 컬럼" 으로 합침
- "메타 정보" → "시스템 컬럼" 으로 이름만 평이하게
- 참조 컬럼은 평범한 한 행으로 표시 (타입 칩으로 종류는 식별)
- 미사용 Layers 아이콘 import 제거

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:31:59 +09:00
johngreen 7e71730015 feat(테이블타입): DBeaver 식 탭 분리 + 입력 타입 아이콘 lucide 통일 (#24)
Build & Deploy to K8s / build-and-deploy (push) Successful in 11m4s
johngreen → main: 컬럼 그리드 탭 분리 + 아이콘 통일 + CLAUDE.md 컨벤션
2026-05-21 10:22:54 +00:00
johngreen 2d39d17428 feat(테이블타입): 컬럼 그리드 DBeaver 식 탭 분리 — 컬럼 / 참조
ColumnGrid 의 "참조/설정" 컬럼이 두 가지 다른 역할 (entity/code/numbering 의 참조 대상 표시
vs 그 외 타입의 default_value 표시) 을 한 셀에 욱여넣고 있었음. text/number/date 행에선
대부분 — 빈 칸. 진단 결과는 architect 가 동의한 대로 컬럼 통째 제거가 답.

DBeaver 의 Columns / Foreign Keys 탭 분리 패턴 차용:
- 컬럼 탭 (기본 활성) — 컬럼 본연의 속성만 (라벨 / 타입 / PK NN IDX UQ / ⋯). 참조/설정 컬럼
  통째 제거, grid 6→5 컬럼으로 슬림화. 라벨 셀 폭 1fr 로 확보
- 참조 탭 — entity / code / category / numbering 컬럼만 모아 표 형태로. 종류별 그룹 헤더
  + (소스 컬럼 / 참조 종류 / 참조 대상) 3컬럼 그리드. code 행에서 누락되어있던 코드 그룹
  표시도 같이 채움

ReferenceListView 신규 컴포넌트로 분리. 사용자가 행 클릭하면 우측 상세 패널 슬라이드 in
하는 기존 동작은 양 탭 모두 동일.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:55 +09:00
johngreen 30ebb14023 fix(테이블타입): 입력 타입 아이콘 lucide 로 통일 — letter/symbol/emoji 혼재 정리
기존엔 iconChar 문자열에 'T'(글자) / '#'(기호) / '{}'(코드) / '📎'(emoji) 식으로 일관성 없게
표시. 카드 그리드에서 시각 노이즈 큼.

- TypeColorConfig.iconChar(string) → Icon(LucideIcon) 으로 교체
- 13개 입력 타입 전부 lucide 아이콘 매핑 (Type/Hash/Calendar/Braces/Link2 등)
- FALLBACK_TYPE_CONFIG export 해서 TypeOverviewStrip 의 legacy/unknown 도 같은 인터페이스 따름

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:30 +09:00
johngreen 895cb48ee0 docs(claude): UI/구조 변경 제안 시 ASCII Before/After 그림 의무화
기존 컨벤션에 § 💬 추가. 글로만 설명하면 사용자가 이해 못 함 — 변경 전/후 두 그림을 무조건
그려서 보여줘야 함. 코드 인용 (file:line, CSS class) 최소화하고 영문/SQL/전문용어 풀어쓰기.
3줄 패턴 (무슨 일 / 사용자 영향 / 어떻게 고치는지) 권장.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:13 +09:00
hjjeong 067193efa9 fix(배치관리): 대시보드 NaN 제거 + 24시간 차트 더미데이터 → 실데이터
- 백엔드: getBatchManagementGlobalSparklineData 쿼리 추가 (generate_series 로
  24개 슬롯 고정, 회사 전체 배치 LEFT JOIN 집계)
- 백엔드: GET /api/batch-management/sparkline 엔드포인트 추가
- 프론트: BatchStats/SparklineData 타입을 백엔드 mapper 의 snake_case 응답키와
  일치시킴 (today_count, today_failed_count, hour_slot, success_count, ...).
  키 미스매치로 stats 카드가 NaN 으로 표시되던 버그 해소
- 프론트: GlobalSparkline 컴포넌트의 Math.random() 더미 막대를 실데이터 prop 으로
  교체. row-level Sparkline 도 동일 키 정렬로 정상 렌더되도록 수정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:59:52 +09:00
DDD1542 318cac4f68 Merge remote-tracking branch 'origin/main' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m44s
2026-05-19 21:31:11 +09:00
DDD1542 2f398ae0b3 chore: 제어모드 IDE 작업 + v2/legacy 레지스트리 컴포넌트 폐기
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control
- 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합
- InvLegacyButtonConfigPanel cp 마이그레이션
- canonical data view cleanup 후속 노트
2026-05-19 21:31:03 +09:00
johngreen 58ede650ae fix(테이블타입): ⋯ 드롭다운 row click 누수 차단 (#23)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m30s
johngreen → main: Radix DropdownMenuTrigger onPointerDown 으로 인한 row click 누수 차단
2026-05-19 09:45:37 +00:00
johngreen 4c5b672f40 fix(테이블타입): ⋯ 드롭다운 클릭이 row 로 새서 상세 패널이 열리던 문제
Radix DropdownMenuTrigger 는 onPointerDown 으로 트리거되는데 기존엔 onClick
stopPropagation 만 있어서, 부모 row 의 onClick(=setSelectedColumn)이 같이
발화 → 상세 패널이 슬라이드 in → 중앙 ColumnGrid 가 오버레이에 가려져
squish 처럼 보이던 문제.

⋯ 버튼을 감싸는 div 에 onClick / onPointerDown / onMouseDown 세 가지에
모두 stopPropagation 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:44:55 +09:00
johngreen 904fdd33e7 feat(테이블타입): 컬럼 단건 DROP + CreateTableModal flex 레이아웃 수정 (#22)
Build & Deploy to K8s / build-and-deploy (push) Successful in 13m20s
johngreen → main: 컬럼 단건 DROP 기능 + CreateTableModal 다이얼로그 flex 레이아웃 수정
2026-05-19 06:02:51 +00:00
johngreen f73e468f66 feat(테이블타입): 컬럼 단건 DROP 기능 — ColumnGrid ⋯ 메뉴에 "컬럼 삭제" 추가
- DdlService.dropColumn: ALTER TABLE ... DROP COLUMN (CASCADE 미사용 → FK 참조 시 Postgres 거부, DBeaver 동일)
- 시스템 테이블 / 예약 컬럼(id/created_date/updated_date/company_code/writer) 보호
- 같은 트랜잭션에서 table_type_columns / column_labels 메타 청소 + ddl_execution_log 기록
- DdlController: DELETE /api/ddl/tables/{table}/columns/{column} (SUPER_ADMIN 전용)
- ddlApi.dropColumn 헬퍼
- ColumnGrid: ... 버튼을 DropdownMenu 로 교체, "컬럼 삭제" destructive 메뉴 아이템
- page.tsx: 컬럼 삭제 확인 다이얼로그 + 핸들러, FK 거부 시 토스트로 안내

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:49:07 +09:00
johngreen b25a6324f8 fix(테이블타입): CreateTableModal 다이얼로그 flex 레이아웃 — 스크롤 가능한 본문 + 고정 푸터
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:48:47 +09:00
hjjeong 8a10edd8e1 fix(배치관리): DB 커넥션 변경 시 테이블 목록이 안 바뀌는 버그
- 외부 DB id 비교를 strict === 에서 toString() 기반 string 비교로 변경 — number/string 어느 쪽으로 오든 매칭. find 실패로 toConnection=null 되면 auto-select useEffect 가 "내부 DB" 로 강제 복귀시키던 문제 해소
- 연결 변경 시 toTables/fromTables 즉시 초기화 — fetch 실패해도 직전 DB 의 테이블이 잔존하지 않도록
- 배치 파이프라인 / 외부커넥션 멀티 DB 작업 핸드오프 노트 함께 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:23:24 +09:00
hjjeong fc615a70be Merge pull request 'fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈' (#21) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 15m1s
Reviewed-on: #21
2026-05-19 02:25:10 +00:00
hjjeong 947b31eff5 Merge remote-tracking branch 'origin/main' into hjjeong 2026-05-19 11:10:04 +09:00
hjjeong 46707bd116 feat(admin): 외부 DB 커넥션 멀티 DB 테스트 + 프로비저닝 시퀀스 reset 보강
기존엔 PostgreSQL 만 테스트 가능했고, V001 (SERIAL→VARCHAR) 마이그레이션 이후 회사 프로비저닝 시 시퀀스가 max(id) 보다 작은 상태로 남아있어서 새 외부 커넥션 등록 시 duplicate key 가 재발하던 문제 해결.

backend
- build.gradle: MariaDB/MySQL/MSSQL/SQLite JDBC 드라이버 4종 runtimeOnly 추가
- ExternalDbConnectionService.executeConnectionTest: PostgreSQL-only 가드 제거, dbType 별 JDBC URL/props 분기 구현 (postgresql/mysql/mariadb/mssql/sqlite). defaultPort helper 추가
- mapper/externalDbConnection.xml: INSERT/UPDATE 의 port/connection_timeout/query_timeout/max_connections 에 ::varchar 캐스팅 추가 (V001 으로 VARCHAR 가 됐는데 클라가 숫자로 보내서 character varying = bigint 비교 불가로 500 나던 것)
- DataCopier.resetSequences: VARCHAR PK + 시퀀스 의존성이 남은 컬럼도 setval 대상에 포함. MAX(col::bigint) + col ~ '^[0-9]+$' 정규식으로 type mismatch 회피하면서 숫자형 VARCHAR PK 만 안전하게 reset

frontend
- ExternalDbConnectionModal: DialogContent 를 flex 컬럼으로, 본문에 자체 스크롤 + Footer shrink-0 → 길어진 폼에서도 취소/생성 버튼이 항상 보이도록

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:01:14 +09:00
DDD1542 467a41a3a8 Merge origin/main into gbpark-node — johngreen 11 commits 받음 2026-05-18 11:47:41 +09:00
johngreen 75f6883497 fix(테이블타입): 헤더 인라인 편집을 명시적 연필 아이콘 진입으로 + 컬럼명 폰트 축소
Build & Deploy to K8s / build-and-deploy (push) Successful in 16m6s
1) 테이블 헤더 (표시명/설명) 편집 진입 방식 변경
   - 기존: 텍스트 div 자체가 role="button" + onClick 이라 무심코 클릭 시 input 으로 전환
   - 변경: 텍스트는 단순 span, 옆에 작은 Pencil 아이콘 버튼 추가. 그 버튼 클릭해야 편집 모드 진입.
   - 연필 아이콘은 평소 muted-foreground/50 톤, hover 시 진해짐 (group-hover 의존 X — Tailwind variant 캐시 회피).
   - 편집 모드 동작 (Enter / Esc / blur 커밋) 은 그대로.

2) ColumnGrid: 컬럼 라벨 text-sm → text-xs (14px → 12px)
   - 가운데 본문 컬럼 행이 너무 커보이던 문제. 좌측 list 폰트(이전 commit) 와 비례 맞춤.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:06:07 +09:00
johngreen d306ac2865 fix(테이블타입): dropdown key 중복 + hook 순서 + 탭바 outline + 좌측 list 폰트 사이즈
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m43s
오늘 시리즈 후속 UX 다듬기 + 회귀 fix:

1) ColumnDetailPanel: dropdown key 중복 방어
   - codeInfoOptions 에 placeholder "none" + 데이터 "none" 중복 시 React 가 'two children
     with the same key, none' 으로 거부 → filter 로 사전 제거.
   - refTableOpts 도 referenceTableOptions/tables 어디서든 중복 들어오면 같은 증상 →
     Set 기반 dedupe.

2) ColumnDetailPanel: hook 순서 위반 수정
   - 기존 'if (!column) return null' 이 useMemo(refTableOpts) 앞에 있어서
     column null/존재 케이스마다 hook 호출 수가 달라짐 (Rules of Hooks 위반).
     overlay 패턴 도입 후 column null 케이스가 자주 들어오면서 드러남.
   - early return 을 모든 hook 뒤로 이동.

3) v5-layout.css 탭바: Chrome 식 outline 스타일
   - 비활성 탭도 각자 outline 보이게 (border:1px solid var(--v5-border))로 카드처럼 분리.
   - 활성 탭은 border + surface-hover 배경 + 위쪽 primary 1px inset 강조선.
   - 위 모서리 rounded, margin-bottom:-1px 로 탭바 하단 border 와 seamless 연결.

4) 좌측 테이블 list 폰트 사이즈 축소
   - 한글명 16px → 13px, 영문명 12px → 10.5px, 행 padding 7px → 6px.
   - 280px 좁은 패널에 맞는 컴팩트 비율로 v5 컨벤션 정렬.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:42:45 +09:00
johngreen 78c5e3e358 fix(테이블타입): 좌측 일괄선택 영역 layout shift + 우측 디테일 패널 overlay/slide-in 으로 전환
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m53s
기존 UX 이슈 3가지:
- 좌측 테이블 목록에서 체크박스 첫 선택 시 삭제 버튼이 등장하면서
  헤더 영역 높이가 변해 아래 리스트가 살짝 밀림 (layout shift).
- 우측 디테일 패널이 conditional render 라 컬럼 클릭 시 가운데 본문이
  380px 만큼 좁아져 컬럼명/라벨이 truncate 되며 "찌그러진" 느낌.
- 닫는 방법이 X 버튼뿐이라 토글 직관성 부족.

변경:
- 좌측 헤더 영역에 min-h-9 고정 — 삭제 버튼 등장해도 높이 고정, 리스트 안 흔들림.
- 우측 디테일 패널을 overlay 로 전환: absolute right-0 z-20 + shadow-2xl.
  transition-transform + translate-x-{0|full} 로 300ms ease-out slide-in/out.
  pointer-events-none 으로 닫혀있을 때 클릭 차단.
- 가운데 본문 width 변동 0 — 컬럼 클릭해도 안 좁아짐.
- 컬럼 토글: 같은 컬럼 재클릭 시 디테일 패널 닫힘. X 버튼/외부 트리거도 그대로 동작.

invyone admin 다른 화면들과의 일관성보다 가운데 본문 공간 보존이 우선이라
overlay 패턴 채택. 다른 화면(screenMngList, deptMngList)은 detail 영역이
처음부터 펼쳐진 2-pane 구조라 별개.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:59:53 +09:00
johngreen 6b204806b6 fix(공통코드): 그룹 코드 중복 INSERT 시 친절한 400 메시지 (500 → IllegalArgumentException)
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m17s
기존: 중복 그룹 코드 등록 시 PK violation 으로 500 + "서버 내부 오류"
→ 사용자가 왜 안 되는지 알 수 없음.

변경: insertCodeInfo 진입 시 getCodeInfoInfo 로 사전 체크. 이미 존재하면
IllegalArgumentException 으로 던져 GlobalExceptionHandler 가 자동으로
400 + "이미 존재하는 그룹 코드입니다: {코드}" 메시지로 응답.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:34:41 +09:00
johngreen d8877b243a chore(테이블타입): legacy input_type 1,207 row 표준 8종으로 통합 (V026 / RUN_091)
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m33s
5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서 빠뜨린
운영 DB 데이터 정리. 90787d83 의 화이트리스트 확장 fix 는 회복용
보호막이었고, 본 PR 은 데이터를 표준으로 통합하는 후속 정리.

매핑:
  category/select/radio/checkbox/boolean → code
  textarea → text
  datetime → date

영향: 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 0 row.
WHERE input_type IN (...) 으로 멱등 (재실행 시 0 row).

화이트리스트 축소는 운영 안정 확인 후 별도 PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:30:39 +09:00
johngreen 90787d837f fix(테이블타입): USER_SELECTABLE_INPUT_TYPES 화이트리스트에 legacy input_type 7개 복원
Build & Deploy to K8s / build-and-deploy (push) Successful in 15m1s
5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서 운영 DB 의
옛 input_type 값들을 매핑/정리하는 마이그레이션을 빠뜨려, 컬럼 설정
저장 batch POST 가 한 row 라도 legacy 값(category/select/textarea/
checkbox/radio/datetime/boolean)을 포함하면 400 거부.

운영 메타 DB 실측: 화이트리스트 밖 row 1,207건 (category 886,
select 149, textarea 102, checkbox 55, radio 12, datetime 2, boolean 1).

운영 데이터/UI 의미를 보존하기 위해 매핑이 아닌 화이트리스트 확장
(legacy 7종 추가)으로 회복. legacy 정리는 별도 PR 에서 점진적으로.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:29:13 +09:00
johngreen 752e4fb644 fix(테이블타입): syncScreenLayoutsInputType SQL — SCREEN_LAYOUTS.PROPERTIES 가 varchar 라 JSONB 캐스팅 추가
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m19s
운영 DB 의 SCREEN_LAYOUTS.PROPERTIES 컬럼이 character varying 인데 mapper SQL 은
PROPERTIES->>'...' 와 JSONB_SET(PROPERTIES, ...) 를 그대로 사용해
PG 가 'operator does not exist: character varying ->> unknown' 으로 거부.
이로 인해 syncScreenLayouts 가 던지는 SQLException 이 try-catch 로
무시되긴 하지만 외부 @Transactional 이 이미 aborted 상태가 되어
후속 ensureTableInLabels (insertTableLabelIfNotExists) 가
'current transaction is aborted' 로 연쇄 실패 → 컬럼 설정 저장 500.

- SL.PROPERTIES::JSONB 캐스팅 (WHERE / SET 양쪽)
- JSONB_SET 결과를 ::TEXT 로 캐스팅해 varchar 컬럼에 안전 저장
- 운영 4 DB (invyone, siflex/test01/test02 _invyone) 전수 검증:
  invalid JSON row 0건 → 캐스팅 안전

mapper SQL 만 변경. DB 마이그레이션 불필요.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:55:48 +09:00
johngreen 14832a28ab fix(테이블타입): TABLE_TYPE_COLUMNS 에 ON CONFLICT 매칭용 UNIQUE INDEX 추가 + 중복 정리
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m27s
테이블 타입관리의 모든 쓰기 API (UNIQUE/NOT NULL 토글, 컬럼 설정 저장,
input-type upsert) 가 500 반환. 원인은 mapper SQL 의
ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 가 매칭할 unique
제약/인덱스가 운영 DB 에 존재하지 않아 PG 가
"there is no unique or exclusion constraint matching the ON CONFLICT
specification" 으로 거부.

- StartupSchemaMigrator MIGRATIONS 에 V025 / RUN_090 (1) (2) 추가:
  (1) ROW_NUMBER 로 (table, column, company) 중복 행 정리
      (운영 메타 DB 실측 2 그룹 / 4 row — 동일 데이터의 NULL updated_date
      옛 row 제거. 테넌트 DB 들은 중복 0건).
  (2) UX_TABLE_TYPE_COLUMNS_TCC UNIQUE INDEX 생성 (IF NOT EXISTS — 멱등).
- RUN_090_MIGRATION.md 신설.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:39:30 +09:00
johngreen a0a4dc3bf5 fix(테이블타입): TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO DB rename 누락 보완
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m24s
5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 6 군데 컬럼
참조를 CODE_INFO 로 바꾸면서 DB 컬럼 rename 마이그레이션을 빠뜨려,
모든 테넌트 사이트에서 테이블타입관리 > 테이블 클릭 시
GET /api/table-management/tables/{name}/columns 가 500
(column "code_info" does not exist) 을 반환.

- StartupSchemaMigrator MIGRATIONS 에 V024 항목 추가
  DO 블록으로 information_schema 확인 후 rename — 멱등.
- RUN_089_MIGRATION.md 신설 (V023 IS_SOLUTION_ONLY 운영 가이드도 합본).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:38 +09:00
johngreen 8fff53b165 Merge pull request 'fix(멀티테넌시): 테넌트 사이트 관리자 메뉴에서 솔루션 전용 메뉴 차단' (#21) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m3s
2026-05-15 18:36:42 +09:00
johngreen c530a67cee fix(멀티테넌시): 테넌트 사이트 관리자 메뉴에서 솔루션 전용 메뉴 차단
- AdminController.getAdminMenus 에 Host 헤더 기반 is_management_host 추가
  (기존엔 user-menus 만 필터, /admin/menus 는 미적용이라 관리자 모드 사이드바에서 노출됨)
- admin.xml selectAdminMenuList anchor + recursive 양쪽에 IS_SOLUTION_ONLY 필터 추가
- StartupSchemaMigrator: ALTER 외에 UPDATE 추가, 프로비저닝된 테넌트 DB 의
  회사관리/서브도메인관리/감사로그 메뉴 행을 부팅 시 IS_SOLUTION_ONLY=TRUE 로 마킹

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:35:33 +09:00
DDD1542 34060d9534 Merge branch 'gbpark-node'
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m32s
2026-05-15 16:55:31 +09:00
DDD1542 2348800e68 refactor(common-code): 마스터-디테일 재설계 — code_info(그룹) + code_detail(재귀 트리)
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
카테고리/캐스케이딩 시스템 (B/C/D) 전부 폐기:
- BE: mapper/Service/Controller 9세트 삭제 (cascading*, categoryTree, tableCategoryValue, categoryValueCascading, codeMerge)
- FE: 페이지 3 + API 8 + hooks 2 + 폐기 컴포넌트 6 삭제, 14곳 의존성 정리
- DB: 12 테이블 DROP, TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename

신설 commonCode 마스터-디테일:
- code_info: 1레벨 그룹 마스터
- code_detail: 2~∞ depth 재귀 트리 (parent_detail_id self-FK, depth 자동 계산)
- API: /api/common-codes/{info,detail}
- CodeCategoryFormModal/Panel → CodeInfoFormModal/Panel rename
- code_category 컬럼명 전부 code_info 로 치환 (mapper/Java/FE)
- 옛 commonCode API URL (/categories/...) → getCodeOptions 어댑터 + /detail?code_info=... 전환

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:50:50 +09:00
hjjeong d61777ab5f fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈
- mapper/externalDbConnection.xml: WHERE ID = #{id} 5곳 + ID != #{exclude_id} 1곳에 ::varchar 캐스팅 추가
  (EXTERNAL_DB_CONNECTIONS.ID 가 V001 마이그레이션으로 VARCHAR 인데 long 바인딩되어 character varying = bigint 비교 불가로 500 발생하던 것을 해결)
- exconList: 페이지 overflow-hidden + Tabs/TabsContent 가 flex 컨테이너, ResponsiveDataView scrollContainer 활성화로 테이블 안에서만 sticky header + 자체 스크롤
- exconList/RestApiConnectionList: text-3xl→text-lg/text-sm→text-xs/h-10→h-8 등 컴팩트 폰트로 통일 (배치관리/플로우관리와 톤 매칭)
- RestApiConnectionList: Table divClassName 으로 wrapper 자체에 스크롤 위임 + sticky TableHeader 적용
- ResponsiveDataView: compact 모드일 때 폰트/셀패딩/카드 폰트도 함께 축소, scrollContainer 모드에서 @3xl:block 이 flex 를 덮어쓰던 우선순위 충돌 해결, sticky header 알파 제거
- batchmngList: Pagination 컴포넌트 적용 (RPS batchmngList 참고, 페이지당 10/20/50/100 선택), 컨테이너를 h-full min-h-0 overflow-hidden + 리스트만 자체 스크롤로 변경

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:38:23 +09:00
johngreen d5f9814865 Merge remote-tracking branch 'origin/main' into johngreen
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m23s
2026-05-15 12:22:45 +09:00
johngreen 824a3100ce security(멀티테넌시): 관리 plane vs 테넌트 plane 격리 + 부서관리 후속
이번 PR 은 invyone 멀티테넌시 SaaS 의 "관리 plane vs 테넌트 plane" 격리를
4 영역(PR #A~D) 에서 강화하고, 별도로 진행 중이던 부서관리 후속 작업을 포함한다.

# 보안 (plane 격리)

PR #A — controller/CompanyManagementController 인증 누락 패치
  /api/company-management/* 가 JWT/role/host 체크 없이 외부에서 누구나 회사 삭제
  + 디스크 통계 호출 가능했던 critical 누수 막음. SuperAdminGuard.enforce() 적용.

PR #C — cross-tenant 컨트롤러 호스트 격리 + 감사 로그
  CrossTenantContext.requireManagementHost() 헬퍼 추가, 5 컨트롤러
  (CrossTenantContext/Controller/UserController/RoleController/DeptController) 모두
  테넌트 호스트에서 호출 시 403. CompanyAuditLogService 에 cross-tenant write 4종
  (USER_CREATE/DELETE, PW_RESET, ROLE_UPDATE) audit action 추가.
  SuperAdminGuard.isTenantHost 가시성 public static 으로 승격.

PR #B — 프론트 솔루션 전용 admin 페이지 가드
  admin/* 페이지 전수 분류 결과 솔루션 전용 3건 식별:
  subdomainList / companyList / audit-log. 각 페이지에 isManagementHost
  useEffect 가드 + redirect 추가. 사이드바도 같이 숨김.

PR #D — MENU_INFO.IS_SOLUTION_ONLY 컬럼 + DB-driven 메뉴 필터
  V023 마이그레이션으로 컬럼 추가 + 솔루션 메뉴 3개 마킹.
  admin.xml selectUserMenuList 에 호스트 기반 필터 추가, AdminController.getUserMenus
  가 Host 헤더로 is_management_host 결정. 프론트 MANAGEMENT_ONLY_MENU_URLS
  하드코딩 set 폐기 (DB 가 대신함). 페이지 자체 가드는 defense in depth 로 유지.
  StartupSchemaMigrator 에 V023 등록되어 모든 테넌트 DB 부팅 시 자동 적용.

# 부서관리 후속 (이전 PR #18/#19 follow-up)

DepartmentController/Service + frontend deptMngList/department.ts 의 추가 작업분.
이번 격리 작업과 무관하지만 같이 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:59:15 +09:00
DDD1542 387a5c2bd7 Merge remote-tracking branch 'origin/main' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 11m34s
2026-05-14 17:42:17 +09:00
DDD1542 3883031c0b feat(studio): Phase G — KPI stats / chart / cardList / groupedTable + canonical container tabs
INV Studio 데이터 뷰 시리즈. 솔루션 개발 단계라 backward-compat alias 없이 깔끔하게.

Backend:
- TableManagementController + Service: /aggregate, /aggregate-group, /select-rows endpoint 추가
  sanitize + hasColumn 검증 + buildAggregateWhere 공유 헬퍼

Frontend canonical view components (신규):
- stats: DB-first KPI editor (CPSegment 메타 chip, 컬럼 dropdown, 디자인 모드 debounce 350ms preview)
- chart: recharts (bar / horizontalBar / line / donut)
- card-list: title/subtitles/metrics 카드 카탈로그 (list / grid 레이아웃)
- grouped-table: 클라이언트 측 groupBy + 그룹 헤더 row

Canonical container (Phase G.2 / G.2.5 / G.2.6):
- containerType='tabs' 활성 탭만 mount, ChildSlot 으로 자식 렌더
- ScreenDesigner.handleComponentDrop 가 canonical container tabs 도 인식
- 우측 V2PropertiesPanel 4-way 분기: tab child / panel child / selected / empty
  nested path update + saveToHistory, delete handler 동기화

Shared utilities:
- useDbColumns hook (모듈 캐시), ColumnPicker (CPSelect 기반)
- OptionFilterRow 자연어 카드 형식 (컬럼 dropdown / 조건 select / 값 입력)
- _shared/use-table-rows.ts (cardList + groupedTable 공용 fetch)
- IconPicker: 한글 키워드 80+ alias, 휠 스크롤 fix, 360px 상한, 결과 80→300

stats DB-first UX (Phase G.4.x):
- DB / 정적 모드 이분법 제거 — 항상 dataSource 시작
- collapsed: 라벨 input + KpiMetaSegment chip (테이블 · 집계 · 컬럼 · 필터수)
- expanded: 데이터 / 필터 / 외형 / 고급 flat CP rows
- useSlideToggle hook 으로 펼침/닫힘 양방향 애니메이션
- 변화량 (delta) 수동 입력 UI 제거 — 향후 DB 자동 계산 영역
- 카드 fetch state 명시: loading / error / 대기 중 / 테이블 미설정

기타:
- ScreenDesigner.tsx → InvyoneStudio.tsx rename (활성 빌더 파일)
- 모든 hardcoded #6c5ce7 fallback 제거, hsl(var(--primary)) 토큰만 사용 (light/dark/테마 자동 적응)
- StatsDefinition default_config 도 DB-first placeholder (value: 0 박지 않음)

Docs:
- notes/gbpark/2026-05-14-studio-data-view-roadmap.md (G.0 ~ G.4.2 진행 기록)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:41:50 +09:00
johngreen 2f52d9587e Merge pull request 'fix(부서관리): 보안 + 운영 데이터 버그 8건 (PR #18/#19 후속)' (#20) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Failing after 10m45s
2026-05-14 08:26:16 +00:00
johngreen 4f13d2e440 fix(부서관리): 보안 + 운영 데이터 버그 8건 (PR #18/#19 후속)
- security: syncManagers 가 user_id 의 회사 격리·실존 검증 (cross-tenant injection 차단)
- bug: base_date 필터에 START_DATE IS NULL 조건 추가 — 옛날 데이터가 기준일 켜자마자 사라지는 문제 해결
- bug: PUT partial update — 매니저 키 없으면 sync skip (단일 필드 수정 시 매니저 보존)
- bug: 페이지 로드 시 단일 컬럼 backfill — PR #19 이전 데이터가 chip UI 에서 사라지는 문제 해결
- validation: Controller base_date YYYY-MM-DD regex (잘못된 형식 시 400)
- validation: 프론트 handleSave start_date > end_date 체크
- robustness: parseManagersJson 64KB max + log.warn (catch silent swallow 제거)
- ops: StartupSchemaMigrator 실패 테넌트 DB 명단 부팅 종료 시 log.error 집계
2026-05-14 17:25:31 +09:00
johngreen 1613fae8fb Merge pull request 'feat(부서관리): 다중 관리자 + 조직장 (DEPT_MANAGERS)' (#19) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m54s
2026-05-14 06:20:59 +00:00
johngreen c350ebe86a feat(부서관리): 다중 결재/부서 관리자 + 조직장 (DEPT_MANAGERS 매핑 테이블)
- 마이그레이션 V022/RUN_088: DEPT_MANAGERS 신규 (role: approval/dept/org_leader, PK 3-tuple, FK CASCADE)
- StartupSchemaMigrator 에 V022 idempotent CREATE 추가 → 테넌트 DB 자동 동기화
- mapper.xml: SELECT 에 3 json_agg ::TEXT 컬럼 추가, insertDeptManagers + deleteDeptManagersByDeptAndRole 신규
- service: parseManagersJson + syncManagers (delete-all + insert-all, 최대 10명, 역할 한글 메시지)
- frontend: types 3 필드, DeptDetailDraft 확장, ManagerChipsField (chip+UserSearchModal 재사용), 조직장 Row 신규

기존 DEPT_INFO.APPROVAL_MANAGER / DEPT_MANAGER 단일 컬럼은 호환을 위해 유지.
2026-05-14 15:19:50 +09:00
johngreen 5335dc78b0 Merge pull request 'feat(부서관리): 기준일 필터 + 시작일/종료일 UI 노출' (#18) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m57s
2026-05-14 05:16:12 +00:00
johngreen ecad2915ce feat(부서관리): 기준일 필터 + 시작일/종료일 UI 노출
- 백엔드: selectDepartments 에 base_date <if> 블록 추가 (start_date <= base_date AND (end_date IS NULL OR end_date >= base_date))
- 서비스/컨트롤러에 3-arg overload 와 @RequestParam("base_date") 추가
- 프론트: 사용기간 RadioGroup + 시작일/종료일 Row 의 {false &&} hide 제거
- loadDepartments 가 periodMode === "date" 일 때 baseDate 를 API 에 전달
2026-05-14 14:12:59 +09:00
johngreen 0552425f47 Merge pull request 'feat(테이블타입): 헤더 표시명/설명 inline click-to-edit (Google Docs 패턴)' (#17) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 7m1s
2026-05-14 01:53:05 +00:00
johngreen ca241c017d feat(테이블타입): 헤더 표시명/설명 inline click-to-edit (Google Docs 패턴)
기존 "헤딩 + 옆에 input box 2개 + 저장 버튼" 구조의 UX 문제:
- 같은 값을 헤딩과 input 양쪽에서 중복 표시
- 항상 폼처럼 보여 컬럼 그리드 시선을 뺏음
- 라벨만 바꾸려는 의도가 "전체 설정 저장" 에 묶여 흐려짐

변경: 헤딩 텍스트 자체를 클릭하면 그 자리에서 input 으로 변신
(Google Docs 문서 제목 / Notion 패턴).
- blur 또는 Enter → PUT /label 즉시 저장
- Esc → 취소
- hover 시 muted/60 배경, cursor: text, title tooltip 으로 affordance
- 설명이 비어 있으면 "+ 설명 추가" 힌트 표시
- 표시명은 비울 수 없게 가드 (toast.error)

"전체 설정 저장" → "컬럼 설정 저장" 으로 책임 분리:
- 헤더 라벨/설명: inline 즉시 저장
- 컬럼 input_type/web_type/detail_settings 등 일괄: 버튼

키보드 접근성:
- Tab 으로 헤딩에 focus → Enter/Space 로 편집 모드
- Esc 로 취소, Enter 로 커밋 (blur 트리거)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:52:29 +09:00
johngreen ec679ac640 Merge pull request 'fix(테이블타입): constraints SQL ARRAY_AGG → text 캐스트로 일원화' (#16) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m45s
2026-05-14 00:18:53 +00:00
johngreen 1e1b3e103c fix(테이블타입): constraints SQL ARRAY_AGG → text 캐스트로 일원화
이전 PR (#15) 의 Java parseColumnArray 분기 추가가 실제 운영 환경에서
빈 배열을 그대로 반환 — MyBatis ↔ PostgreSQL JDBC 의 array 타입 변환이
java.sql.Array 가 아닌 다른 경로로 도착하는 듯.

방식 변경: SQL 단에서 ARRAY_AGG(...)::text 캐스트 → PostgreSQL 가
"{email,phone}" String 으로 반환. parseColumnArray 의 기존 String 분기
(중괄호 제거 + 쉼표 split) 가 자연스럽게 처리.

장점:
- JDBC 드라이버 / MyBatis 변환 동작에 의존하지 않음
- parseColumnArray 코드 단순 복원 (List/String 2분기)
- 한 줄 SQL 변경으로 PK/IDX 두 쿼리 모두 해결

검증:
- gradle compileJava BUILD SUCCESSFUL
- solution.invyone.com 에서 customer_mng PK columns / email IDX columns
  비어있지 않음 확인 예정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 09:18:26 +09:00
johngreen 35d5a00b20 Merge pull request 'fix(테이블타입): constraints API PgArray 디코딩 + primary_key key 매칭' (#15) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m29s
2026-05-13 23:33:17 +00:00
johngreen 0365b743f5 fix(테이블타입): constraints API PgArray 디코딩 + primary_key key 매칭
테이블 타입 관리 페이지에서 PK/IDX 토글 후 UI 상태가 갱신되지 않던 버그.

1. Backend: TableManagementService.parseColumnArray
   - PostgreSQL ARRAY_AGG 가 java.sql.Array (PgArray) 로 오는데
     List/String 만 처리해서 항상 List.of() 반환 → columns 빈 배열
   - 조치: java.sql.Array 분기 추가, arr.getArray() → Object[] → List<String>

2. Frontend: loadConstraints
   - 백엔드는 result.put("primary_key", ...) 로 snake_case 반환
   - 프론트가 data.primaryKey 로 camelCase 로 읽어 undefined → 항상 빈 PK
   - 조치: data.primary_key 로 통일

이전 PR (#14) 의 IDX payload 수정과 합쳐, PK/IDX 토글이 API 호출/DB 적용/
UI 반영까지 한 사이클로 동작.

검증:
- gradle compileJava BUILD SUCCESSFUL
- solution.invyone.com 에서 IDX 토글 양방향 확인 예정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:32:48 +09:00
johngreen ff3d4c2cc5 Merge pull request 'fix(테이블타입): IDX/label API payload key snake_case 통일' (#14) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 7m1s
2026-05-13 23:20:22 +00:00
johngreen 44f5b134a5 fix(테이블타입): IDX/label API payload key snake_case 로 통일
테이블 타입 관리 페이지에서 IDX 토글 / 테이블 라벨 저장이 400 에러로
조용히 실패하던 버그. 백엔드는 body.get("column_name") / get("index_type")
/ get("display_name") 등 snake_case 로 읽는데 프론트가 camelCase 로 보내고
있었음 (CLAUDE.md Map key snake_case 컨벤션 위반).

- POST /table-management/tables/:t/indexes
  { columnName, indexType, action } → { column_name, index_type, action }
- PUT /table-management/tables/:t/label
  { displayName } → { display_name }

PK 는 다이얼로그 확인 흐름, NN/UQ 는 key 가 맞아 영향 없음.
SUPER_ADMIN 으로 테스트 시 IDX 만 안 되던 증상 일치.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:18:09 +09:00
johngreen ff4033b927 Merge pull request #13 - fix+security: bug hunt 6 + 인가/SQL 2
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m46s
johngreen -> main: 테이블 타입 관리 bug fix 6 + 보안 hardening 2
2026-05-13 09:08:11 +00:00
johngreen efea906ead security(테이블타입): TableManagementController 인가 + createLogTable SQL injection 강화
bug hunt security-reviewer 발견 2건 보안 fix:

1. TableManagementController 인가 누락 (OWASP A01 Broken Access Control)
   - 15개 write/DDL endpoint 가 admin role 검증 없이 JWT 만 있으면 호출 가능
   - 일반 사용자가 PK 재설정/index 변경/컬럼 수정 가능했음
   - 조치:
     - DepartmentController 의 isAdmin/isSuperAdmin helper 패턴 복사
     - SUPER_ADMIN 전용 (DDL 5건): primary-key, indexes, nullable, unique, log
     - admin (COMPANY_ADMIN+) (10건): updateColumnSettings, addTableData, editTableData, deleteTableData, multi-save 등
     - read 19건은 그대로 (일반 사용자 접근 유지, company_code 격리만)

2. createLogTable SQL injection (OWASP A03 Injection)
   - information_schema.data_type 을 raw concat 으로 DDL 생성
   - 조치:
     - ALLOWED_LOG_COLUMN_TYPES Set 으로 화이트리스트 (varchar/text/integer/numeric/boolean/date/timestamp/jsonb 등 21개)
     - sanitize 빈 식별자 차단 + 원본 테이블에 없는 컬럼 skip
     - colDefs empty 시 IllegalArgumentException
     - 알 수 없는 type 은 text 로 안전 대체

검증:
- gradle compileJava BUILD SUCCESSFUL
- mapper XML 0건 변경 (READ 경로 보호)
- 별도 미해결 보안 이슈 (k8s secrets git 노출, CORS 와일드카드 등) 는 본 PR scope 외

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:07:40 +09:00
johngreen 420b92bc7b fix(테이블타입): bug hunt 발견 6건 수정 (B1~B6)
review 4명 (debugger + security-reviewer + architect + code-reviewer) 발견:

- B1 [CRITICAL]: DdlService.convertToInputType 에 file/image/numbering case 추가
  - 사용자가 파일/이미지/채번 선택해도 silent text 저장되던 버그
  - 박창현 image 2 의 8개 중 3개가 운영에서 작동 안 함
- B2 [MAJOR]: TableManagementService.updateColumnSettings null check
  - settings 에 input_type 키만 있고 값 null 일 때 500 에러 방지
- B3 [MAJOR]: TableSettingModal.tsx 'direct' default → 'text'
  - 운영의 bom.status 같은 컬럼이 UI 에 'direct' 표시되던 원인 제거
- B4 [MINOR/UX]: TypeOverviewStrip 에 Legacy 합산 칩 추가
  - V0 의 legacy 1,209 row (category 888 외) 가 strip 에서 보이도록 amber 칩 + 도넛 호
- B5 [DRY]: USER_SELECTABLE_INPUT_TYPES 공통 상수 추출
  - TableManagementService:30 + DdlService:43 중복 → InputTypeConstants 신설
- B6 [type safety]: context flag enum 화
  - 'user-insert'/'user-update-type'/'user-update-other'/'system-normalize' string → InputTypeContext enum
  - typo silent fail 차단

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:07:19 +09:00
hjjeong 0328f618b9 Merge pull request 'style(rolesList): 다른 메뉴 톤에 맞춰 사이즈/글씨 축소' (#12) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m35s
Reviewed-on: #12
2026-05-13 08:23:51 +00:00
hjjeong f53307a72e Merge remote-tracking branch 'origin/main' into hjjeong 2026-05-13 17:20:59 +09:00
hjjeong cbf94dc90f feat(batch): TO DB 자동 선택 (internal) + Select 컴포넌트 controlled 화
새 배치 (REST API → DB) 진입 시 매번 사용자가 "데이터베이스 커넥션 선택" 셀렉트에서
"내부 DB" 를 직접 골라야 했음. 대부분의 배치가 internal 적재라 디폴트 채움이 자연스러움.

1) TO DB 자동 선택
   useEffect 로 batchType === "restapi-to-db" + connections 로드 + toConnection 비어있음
   조건 만족 시 handleToConnectionChange("internal") 자동 호출. 사용자가 외부 DB 로 변경하면
   toConnection != null 이 되어 더 이상 자동 동작 안 함.

2) Select controlled 화
   DB 커넥션/테이블 Select 가 value prop 없는 uncontrolled 상태였음.
   setToConnection/setToTable state 가 바뀌어도 Select UI 가 placeholder 그대로 →
   programmatic 자동 선택이 시각적으로 반영 안 됨.
   → value prop 추가:
     - DB 커넥션: toConnection.type === "internal" ? "internal" : String(toConnection.id)
     - 테이블: toTable

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:18:09 +09:00
johngreen aeddd7dc2a Merge pull request #11 - perf(CI): standalone output
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m41s
johngreen -> main: frontend Dockerfile standalone 활용. 빌드 21m -> ~9m
2026-05-13 07:45:22 +00:00
johngreen 5fdd1c67b1 perf(CI): frontend build runner stage 를 Next.js standalone output 활용으로 전환
배경:
- frontend build 의 가장 큰 시간 소비 = runner stage 의 COPY node_modules (12분 16초)
- 전체 21분 34초 중 57%
- next.config.mjs 의 output: "standalone" 가 prod 빌드에서 이미 활성 상태였으나, Dockerfile 의 runner stage 가 .next 통째 + node_modules 통째를 COPY 하느라 standalone 결과물 미활용

조치:
- runner stage 재작성:
  - .next 전체 → .next/standalone (server.js + 실제 사용 node_modules)
  - .next/static 별도 COPY (standalone 가 자동 포함 안 함)
  - public 별도 COPY (standalone 가 자동 포함 안 함)
  - node_modules 통째 COPY 제거 (standalone 가 알아서 포함)
  - package.json COPY 제거 (server.js 직접 실행)
  - CMD: npm start → node server.js

검증:
- frontend 에 dynamic require/import 0건 (정적 import 만) → standalone 의존성 추적 정확
- prisma 가 package.json 에 있으나 코드 import 0건 → 자연 제외, 추가 설정 불필요

예상 효과:
- 빌드 시간 21m 34s → 약 9분 (12분 단축, 57% 감소)
- 이미지 크기 약 1GB → 약 300MB (70% 감소)
- pull 시간 단축
- runtime memory footprint 감소

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:44:36 +09:00
hjjeong 54a8f97f78 fix(batch): 미리보기 → 매핑 카드 표시 흐름 정상화 + 매핑 카드 컴팩트화
배치 생성 흐름 검증 중 발견된 4가지 이슈 일괄 정정.

1) BatchManagementService.previewRestApiData — camelCase 키 명시 remap
   직전 커밋(b752de23)에서 convertCamelToSnake() 호출 추가했지만 그 함수의 실제 구현이
   batch_configs 전용 snake→snake remap 이라 사실상 no-op. 프론트의 apiUrl 등 camelCase
   가 변환되지 않아 isBlank(api_url)=true → 400.
   → previewRestApiData 진입부에 직접 remap (apiUrl/apiKey/requestBody/dataArrayPath/
     paramType/paramName/paramValue/paramSource/authServiceName 9개 키).

2) batchManagement.ts.previewRestApiData — 응답 totalCount 정규화
   백엔드는 total_count (snake_case) 로 응답하는데 프론트는 result.totalCount 로 읽음.
   토스트가 "2개 필드, undefined개 레코드" 로 표시됨.
   → 응답 normalize: total_count ?? totalCount ?? 0.

3) batch-management-new/page.tsx — root h-full overflow-y-auto
   페이지 root 가 overflow 처리가 없어 FROM/TO 카드 아래의 매핑 카드가 탭 컨테이너
   밖으로 잘려 사용자가 못 봄.
   → root div 에 h-full overflow-y-auto 추가.

4) RestApiToDbMappingCard — v5 컨벤션에 맞춘 컴팩트화
   다른 메뉴들과 톤 통일. CardHeader 패딩 축소, 폰트 size 일괄 다운,
   행 padding p-3 → p-2, Select/Input h-9 → h-7 text-xs, 순서 원형 h-6 → h-5,
   카드 내부 height 360 → 300px, 매핑 추가 버튼/삭제 버튼 컴팩트.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:32:41 +09:00
johngreen 0199d1624b Merge pull request #10 - fix(k8s): ConfigMap sslmode=disable
Build & Deploy to K8s / build-and-deploy (push) Successful in 26m24s
johngreen → main: ConfigMap SPRING_DATASOURCE_URL 에 sslmode=disable 영구 추가
2026-05-13 07:14:55 +00:00
johngreen b3f955d97d fix(k8s): ConfigMap SPRING_DATASOURCE_URL 에 sslmode=disable 추가
PR #9 deploy 단계 실패의 후속 처리.

배경:
- 운영 cluster 의 backend-spring deployment 에 누군가 직접 sslmode=disable 박은 value override 가 있었음 (env[2] SPRING_DATASOURCE_URL value + valueFrom 동시 존재 → k8s reject)
- PR #9 머지 후 수동으로 cluster 의 ConfigMap 에 sslmode=disable patch + deployment 재생성 + 새 image rollout 완료
- 다만 git 의 k8s/configmap.yaml 은 sslmode 없는 상태 → 다음 머지 시 workflow 가 ConfigMap 을 git 값으로 덮어쓰면 sslmode 다시 빠짐 → backend pod 가 SSL 시도 후 DB 연결 실패 가능

조치:
- k8s/configmap.yaml line 9 의 SPRING_DATASOURCE_URL 에 ?sslmode=disable 영구 추가
- 운영 postgres 가 SSL 강제 안 하는 환경이라 disable 가 안전
- cluster 와 git 의 state 일치

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:14:28 +09:00
johngreen ae899a3589 Merge pull request #9 — refactor(테이블타입): 3-layer 분리 + CI OOM fix
Build & Deploy to K8s / build-and-deploy (push) Failing after 24m30s
johngreen → main: 테이블 타입 관리 3-layer 분리 (DB 12 유지 / UI 8 한정 / widget variant) + backend 백스톱 + CI OOM fix
2026-05-13 06:32:16 +00:00
johngreen 43b0455364 fix(CI): frontend build OOM 방지 — NODE_OPTIONS=--max-old-space-size=4096
main 의 최근 8연속 build 실패 (run 113~120) 원인이 docker build 단계의 OOM Killed.

진단:
- wace 호스트 32GB / available 24GB 충분, 시스템 OOM 기록 없음
- act_runner systemd MemoryMax=infinity, config container.options=null (제한 없음)
- 그러나 Next.js build V8 heap spike (5-8GB+) 가 다른 30개 동시 가동 서비스 (k3s/mailu/nextcloud/mattermost 등) 와 충돌
- Committed_AS 21.6 GB / Limit 24.8 GB 한계 — page touch 시 oom-killer 가 build process kill
- 결과: 28분 빌드 후 Killed → 8연속 main 배포 실패

조치:
- builder stage 에 NODE_OPTIONS=--max-old-space-size=4096 명시 → V8 heap 4GB 로 cap
- build process 가 알아서 절제 → OOM killer 트리거 안 됨

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:24:25 +09:00
hjjeong b752de23a1 fix(batch): previewRestApiData 에 convertCamelToSnake 누락 보강 (400 원인)
데이터 불러오고 매핑하기 클릭 시 400 발생.
원인: 프론트는 camelCase (apiUrl/endpoint/method/apiKey/dataArrayPath/paramType/...)
로 body 를 보내는데 백엔드는 snake_case (api_url/endpoint/method/api_key/...) 키로 읽음.
다른 service 진입점 (updateBatchConfig / executeBatchConfig 등) 은 convertCamelToSnake
를 호출해서 자동 변환하는데 previewRestApiData 만 빠져있어 isBlank(apiUrl)=true 가
되며 IllegalArgumentException → 400.

수정: previewRestApiData 진입부에 convertCamelToSnake(body) 한 줄 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:11:33 +09:00
johngreen 574319811c refactor(테이블타입): backend INSERT/UPDATE 8개 validate 백스톱
- TableManagementService.normalizeInputType(value, context) 오버로드 — user-insert/user-update-type 만 8개 검증, user-update-other/system-normalize 는 skip
- TableManagementService.updateColumnSettings: payload 의 input_type 키 존재 여부로 context 분기 (input_type 자체 변경 vs 다른 속성 변경)
- DdlService.addColumn / saveColumnMetadata: convertToInputType 결과를 USER_SELECTABLE_INPUT_TYPES (8개) 와 대조, 외이면 IllegalArgumentException

mapper XML 5곳 (categoryTree / entityJoin / tableCategoryValue / screenManagement / tableManagement / entityReference) 무변경 — READ 경로 12개 그대로.

spec: .omc/specs/deep-dive-table-type-storage-ui-separation.md (v3.2 §6.3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:43:41 +09:00
johngreen 8f92fb2368 refactor(테이블타입): 3-layer 분리 — DB 12개 유지, UI 8개 한정, widget variant
- input-type-mapping.ts: BaseInputType 10개 → UserSelectableInputType 8개 (박창현 image 2). vexplor_rps INPUT_TYPE_DETAIL_TYPES 포팅, select/checkbox/radio variant 를 code base 로 흡수
- input-types.ts: USER_SELECTABLE_INPUT_TYPE_ORDER/LABELS re-export (InputType 12개는 그대로)
- getDetailType.ts (신규): getWidgetVariants / getDefaultWidgetVariant helper
- 드롭다운 호출처 7개 8개 제한: ColumnDetailPanel, AddColumnModal, ColumnDefinitionTable, tableMngList/page.tsx, TableSettingModal, TypeOverviewStrip, types.ts
- ColumnDetailPanel: Legacy row 드롭다운 disabled + v5-glow-sm Alert 배너
- backward shim: BaseInputType / BASE_INPUT_TYPE_OPTIONS / getBaseInputType 등 V2/Properties/DetailSettingsPanel 호환

운영 DB 96.6% 가 이미 8개 안 (V0, 35,316 row). DB zero touch, mapper 5곳 보호.

spec: .omc/specs/deep-dive-table-type-storage-ui-separation.md (v3.2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:43:26 +09:00
hjjeong 6fcb101f59 style(batch): API 파라미터 설정을 collapsible 로 변경 — 기본 접힘
이전: 페이지 진입 시 항상 펼쳐져 큰 공간 차지 → 매핑 영역이 화면 밖으로 밀림
신규: <details> + summary 로 기본 접힘. 클릭 시만 펼침. 토글 아이콘 함께.

특정 사용자/조건으로 API 조회할 때만 쓰는 옵션이라 기본 접힘이 자연스러움.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:34:26 +09:00
hjjeong 47eed68072 fix(external-rest-api): WHERE ID = #{id} 에 ::varchar 캐스팅 추가
외부 커넥션 관리에서 연결 테스트 시 500 발생.
원인: 운영 DB 의 external_rest_api_connections.id 가 V001 legacy 마이그레이션으로
character varying 인데 mapper 의 WHERE ID = #{id} 가 controller 의 int id 를
그대로 받아 PgJDBC 가 보내는 BIND 가 정수.
PostgreSQL 이 "operator does not exist: character varying = integer" 거부 →
SQLException → ApiResponse 500 ("서버 내부 오류").

수정: 4곳 모두 #{id}::varchar 캐스팅 추가.
  - getExternalRestApiConnectionInfo (SELECT)
  - updateExternalRestApiConnection (UPDATE)
  - deleteExternalRestApiConnection (DELETE)
  - updateExternalRestApiConnectionTestResult (UPDATE)

배치 작업의 batch_config_id 패턴과 동일. 같은 V001 영향을 받은 다른 mapper
(externalDbConnection.xml / externalCallConfig.xml / booking.xml / delivery.xml /
multiConnection.xml / taxInvoice.xml 등) 도 같은 수정이 필요할 가능성 — 별도 작업으로 분리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:25:17 +09:00
hjjeong d8f606ab00 style(batch): 기본 정보를 모드 토글과 한 행으로 통합 — 하단 매핑 영역 확보
사용자 보고: 기본 정보 카드가 별도 영역을 차지해서 한 화면에 매핑 영역(중요) 까지
스크롤 없이 안 들어옴. vexplor_rps 스크린샷처럼 상단을 한 줄로 컴팩트하게.

변경:
- 배치 타입 토글 grid + 기본 정보 카드를 단일 grid 로 통합
- xl(1280px+): grid-cols-[minmax(28rem,1.4fr)_1fr_1fr_1.5fr]
  → 모드토글그룹 + 배치명 + 스케줄 + 설명 한 줄
- xl 미만: grid-cols-1 stack
- 모드 토글 카드 컴팩트화: p-3 / h-9 아이콘 / text-[10px] description
- 설명: Textarea → Input (단일 행) 로 축소. 긴 설명은 어차피 메모 용도라 한 줄이면 충분.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:20:00 +09:00
hjjeong e8f517ed18 fix(batch): batch-management-new 도 풀폭 적용 — 이전 풀폭 커밋에서 누락된 파일
0bba1836 에서 batchmngList/page.tsx 와 edit/[id]/page.tsx 두 곳만 max-w 제거했고
batch-management-new 의 root mx-auto max-w-5xl 가 남아있어 가운데 1024px 컬럼으로
본문이 박혀있었음. 좌우 패널이 sparse 하게 보이는 원인.

mx-auto max-w-5xl → w-full

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:16:32 +09:00
hjjeong d02bc38f6c style(batch): FROM 카드 행 그룹화 + 컴팩트 폰트로 sparse 레이아웃 정리
이전: API 서버 URL / 인증 토큰 / 엔드포인트 / 메서드 / 데이터 배열 경로가 각각 단독 행
신규: 의미 단위로 그룹화 + 컴팩트화

- API 서버 URL (3fr) + HTTP 메서드 (1fr) 한 행
- 엔드포인트 + 데이터 배열 경로 50:50 한 행
- 인증 토큰: 라디오 → 세그먼티드 토글 버튼 그룹 + 입력을 한 행에 압축
- Label text-xs / Input h-9 text-sm 로 컴팩트 통일

vexplor_rps batch-management-new (L1417 부근) 의 컴팩트 레이아웃 패턴 참고.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:01:40 +09:00
hjjeong 0c9e22a679 feat(batch): 등록 REST API 연결 자동 호출 + 응답 필드 추출
vexplor_rps batch-management-new 의 applyRegisteredRestApi 핵심 흐름을 INVYONE 에
이식. 등록된 REST API 연결 선택 시:
  1. 폼(URL/엔드포인트/메서드/Body/인증 토큰) 자동 채움
  2. ExternalRestApiConnectionAPI.testConnectionById 로 자동 API 호출
  3. 응답 안에서 배열 자동 탐색 (depth ≤ 4)
  4. fromApiFields / fromApiData 채움 → 매핑 드롭다운에 필드 즉시 노출

UI: FROM 카드 최상단에 "🔗 등록된 연결" 셀렉터 추가.
로딩 중에는 셀렉터 라벨 옆에 스피너, 에러 시 destructive 톤 메시지.

vexplor_rps 와 다르게 제외한 부분:
  - Amaranth/Wehago 회사 전용 프리셋 (AMARANTH_TARGET_PRESETS) — 6종 ERP 동기화
    배치 전용 하드코딩이라 INVYONE 일반 사용자에겐 의미 없음. Phase 6 후속에서
    검토할 별도 영역.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:54:11 +09:00
hjjeong 570b3267ab feat(batch): batch-management-new 에 conditional 매핑 추가 (Phase 2 일관성)
vexplor_rps 와 비교 시 batch-management-new 에 조건 변환 모드가 빠져있어
batchmngList/edit/[id] 와 같은 패턴으로 정렬.

- MappingItem.sourceType 에 'conditional' 추가 + conditionalConfig 필드
- ConditionalConfig / ConditionalEditor / emptyConditionalConfig import
- Select 옵션에 "조건 변환" 추가
- conditional 일 때 ConditionalEditor 렌더
- validMappings 필터: conditional 은 평가 필드(apiField) + 룰 또는 default 필요
- apiMappings 변환: mapping_type='conditional' + mapping_config 객체 전송

note: 이 페이지는 신규 생성 전용이라 load(편집 복원) 로직은 없음.
편집은 batchmngList/edit/[id]/page.tsx 가 처리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:29:21 +09:00
hjjeong 0bba1836fb fix(batch): 빈 화면 원인이던 openTab 키명 정정 + 본문 풀폭 적용
문제 1: 배치 수정/생성 탭이 빈 화면
- Tab type 정의는 admin_url (snake_case) 인데 호출자가 adminUrl (camelCase)
  로 전달 → TabPageRenderer 매칭 실패 → null 렌더 → 빈 화면
- 콘솔 경고: "[TabPageRenderer] 렌더링 불가 — 매칭 조건 없음" (TabContent.tsx:268)
- 11곳 (4파일) 의 adminUrl → admin_url 정정

문제 2: 리스트/편집 본문이 좁은 가운데 컬럼에 박혀있음
- batchmngList/page.tsx: max-w-[720px] → w-full
- batchmngList/edit/[id]/page.tsx: max-w-[640px] → w-full

해당 파일:
- batchmngList/page.tsx
- batchmngList/edit/[id]/page.tsx
- batchmngList/create/page.tsx
- batch-management-new/page.tsx

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:15:51 +09:00
hjjeong f70719aecb fix(batch): batch_execution_logs 의 VARCHAR 숫자 컬럼에 명시적 String 전달
운영 DB 검증 결과 batch_execution_logs.duration_ms / total_records /
success_records / failed_records 가 모두 character varying 으로 정의됨
(V001 legacy 마이그레이션 흔적). PgJDBC 가 Long/Integer 를 VARCHAR 컬럼에
자동 변환하지 못할 위험이 있어 명시적으로 String.valueOf 로 변환 후 전달.

mapper 의 COALESCE default 가 '0' (문자열) 인 점과도 일관.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:56:40 +09:00
hjjeong 3ab7deb196 test(batch): Phase 3 — MappingTransformerTest (18 cases)
vexplor_rps 알고리즘 1:1 이식이 정상 동작하는지 검증.

검증 항목:
- evaluateConditional: 단순 매칭 / null cfg / 빈 rules
- parseConditionalConfig: Map / String(JSON) / null / 손상 JSON 모두 안전
- getValueByPath: 단순 키 / 중첩 경로 / 없는 경로 / null obj
- partitionFixed: fixed / non-fixed 분리
- transformRow: direct / conditional("1"→"Y") / conditional default 폴백 /
  fixed 적용 / 점 표기법 ("user.profile.name") / company_code 자동주입 조건

18 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:53:12 +09:00
hjjeong d592547242 fix(batch): writeTo @Transactional 제거 + end_time Timestamp 객체 전달
검증 전 선제 보강 2건.

1) BatchExecutor.writeTo 의 @Transactional 제거
   - 같은 클래스 내부 호출(execute → writeTo)이라 Spring AOP 가 우회되어 어차피 안 걸림
   - batch 의 정상 동작은 row 단위 독립 commit (일부 실패해도 다른 row 는 살아야 함).
     vexplor_rps 도 동일 패턴

2) BatchManagementService.executeBatchConfig 의 end_time 을 Timestamp 객체로 직접 전달
   - 기존: Timestamp.toString() 으로 변환 후 #{end_time}::timestamp 캐스트
   - 신규: Timestamp 객체 그대로 → PostgreSQL JDBC 가 timestamp 자동 변환,
     mapper 의 ::timestamp 캐스트는 noop

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:50:49 +09:00
hjjeong 6f8461a533 feat(batch): Phase 5 — executeBatchConfig 가 BatchExecutor 호출 + batch_execution_logs 기록
기존 stub(0 만 리턴) 였던 BatchManagementService.executeBatchConfig 를 실제 ETL 실행 + 로그 기록 흐름으로 교체.

흐름:
  1. RUNNING 상태로 batch_execution_logs INSERT (도중 비정상 종료 추적용)
  2. BatchExecutor.execute(batchConfig) 호출
     - 정상: failedRecords > 0 면 PARTIAL/FAILED, 아니면 SUCCESS
     - 예외: FAILED 로 마킹, error_message 기록
  3. 로그 UPDATE — execution_status, end_time, duration_ms, total/success/failed_records, error_message
  4. controller 응답에 execution_status + error_message 동봉

server_name 은 InetAddress.getLocalHost(), process_id 는 ProcessHandle.current().pid().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:31:44 +09:00
hjjeong 17172cf9b3 feat(batch): Phase 4 — BatchExecutor ETL 본체 (FROM → Transform → TO)
vexplor_rps batchSchedulerService.executeBatchMappings 의 1:1 이식.

흐름:
  1. mappings 를 fixed/non-fixed 로 partition
  2. non-fixed 를 (from_connection_type, from_connection_id, from_table_name) 으로 그룹화
  3. 그룹별 FROM 읽기 → MappingTransformer.transformRow → TO 저장
  4. (totalRecords, successRecords, failedRecords) 집계

FROM 소스:
  - internal     : sqlSession.getConnection() 의 동적 SELECT (식별자 화이트리스트 escape)
  - external_db  : ExternalDbConnectionService.executeQuery (SELECT-only)
  - restapi      : ExternalRestApiConnectionService.fetchData (등록된 연결 + dataArrayPath)

TO 대상:
  - internal     : INSERT / UPSERT(save_mode + conflict_key, ON CONFLICT DO UPDATE/NOTHING)
                   updated_date 컬럼 있으면 자동으로 NOW() 갱신
  - restapi      : 행 단위 POST/PUT/DELETE — testConnection 으로 호출
  - external_db  : 미지원 (보안 정책: ExternalDbConnectionService 가 SELECT-only)

vexplor_rps 대비 단순화 항목 (필요 시 후속 Phase 로 분리):
  - to_api_body 템플릿 기반 일괄 전송
  - URL_PATH_PARAM 컬럼 처리
  - auth_tokens 자동 조회 (inline-mode REST API: from_connection_id 없이 from_api_url 직접 호출)
  - row_filter_config

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:30:27 +09:00
hjjeong f9a9c67891 feat(batch): Phase 3 — MappingTransformer lookup 엔진
vexplor_rps batchSchedulerService L540~617 의 변환 로직 1:1 이식.
의존성 없는 정적 유틸 — BatchExecutor 가 FROM 읽기 결과를 TO 형태로 변환할 때 사용.

- transformRow: mapping_type 별 분기 (direct/conditional/fixed),
  멀티테넌시용 company_code 자동 주입, 점 표기법 path 평가
- evaluateConditional: ConditionalConfig.rules 의 when/then lookup + default 폴백.
  단순 문자열 동등 비교 (SpEL/JEXL 표현식 평가 안 함)
- getValueByPath: "response.access_token" 같은 중첩 키 지원
- parseConditionalConfig: JSONB 가 Map/String/null 셋 다 가능 — 안전 normalize
- partitionFixed: non-fixed / fixed 매핑 분리 헬퍼

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:26:28 +09:00
hjjeong f31a7f852f feat(batch): Phase 2 — 프런트 ConditionalEditor + 조건 변환 매핑 UI
- batch.ts: ConditionalRule / ConditionalConfig 타입 추가,
  BatchMapping 에 mapping_type ('direct'|'fixed'|'conditional') + mapping_config 필드
- ConditionalEditor.tsx: 평가 필드 선택 + when/then 룰 add/remove + default 입력 컴포넌트.
  emptyConditionalConfig / normalizeConditionalConfig 헬퍼 동봉. vexplor_rps 1:1 포팅
- batchmngList/edit/[id]/page.tsx:
  · MappingItem.sourceType 에 'conditional' 추가 + conditionalConfig 필드
  · 소스타입 Select 에 "조건 변환" 옵션
  · Load: mapping_type=conditional 인 매핑은 mapping_config JSON 파싱 후 복원
  · Save: sourceType=conditional 매핑은 mapping_config 객체와 함께 전송

저장된 룰: {"rules":[{"when":"1","then":"Y"}],"default":"?"} 형태.
Phase 1 의 BatchService 직렬화 경로로 JSONB 에 저장된다.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:25:08 +09:00
hjjeong 2675c82904 feat(batch): Phase 1 — BATCH_MAPPINGS.MAPPING_CONFIG JSONB 컬럼 + JSON 직렬화
- V021 Flyway: ALTER TABLE BATCH_MAPPINGS ADD COLUMN MAPPING_CONFIG JSONB
- StartupSchemaMigrator: 같은 ALTER 를 idempotent 항목으로 추가 (모든 활성 테넌트 DB 부팅 시 동기화)
- batch.xml: getBatchMappingsByConfigId SELECT 에 MAPPING_CONFIG::TEXT cast,
  insertBatchMapping VALUES 에 #{mapping_config,jdbcType=OTHER}::jsonb
- BatchService: ObjectMapper 주입, parseJsonField/stringifyJsonField 유틸,
  syncMappings 는 INSERT 전 직렬화, attachMappings 는 SELECT 후 Map 으로 역직렬화
- RUN_087_MIGRATION.md: 운영용 마이그레이션 runbook (사전 점검/사후 검증/롤백)

conditional 매핑(when/then/default) 룰을 행 단위 저장하는 컬럼.
direct/fixed 는 NULL. Phase 2~3 에서 프런트/엔진이 이 컬럼을 읽고 쓴다.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:24:56 +09:00
hjjeong dce665caea feat(batch): Phase 0 — batch_mappings CRUD path + 실행 로그/관리 SQL 정리
- BatchService: insertBatch/updateBatch 가 body.mappings 받아 replace-all 동기화,
  getBatchInfo 가 batch_mappings 리스트 attach (지금까지는 silently drop)
- batch.xml: getBatchMappingsByConfigId / insertBatchMapping / deleteBatchMappingsByConfigId 신규
- batchExecutionLog.xml / batchManagement.xml: batch_config_id 에 ::varchar 캐스팅,
  오타 'batch_execution_log' → 'batch_execution_logs' 정정
- batchmngList/page.tsx: 같은 batch ID 가 회사 간 중복될 때 React key 충돌 방지
- notes: vexplor_rps → INVYONE 배치 파이프라인 이식 분석 노트

vexplor_rps → INVYONE 파이프라인 이식의 Phase 0.
구체 분해는 notes/hjjeong/2026-05-12-batch-pipeline-current-state.md 참조.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:24:36 +09:00
johngreen 5c0dca004b Merge fix(반응형): 페이지네이션 잘림 픽스
Build & Deploy to K8s / build-and-deploy (push) Failing after 31m59s
2026-05-13 09:41:44 +09:00
johngreen acbd61e25a fix(반응형): 페이지네이션 잘림 — viewport 하드코딩 → flex-1 min-h-0
기존: scrollContainer 모드의 max-h-[calc(100vh-280px)] 가 viewport 기준 하드코딩이라 페이지 헤더+툴바 합산이 280px 보다 클 때 카드 영역이 viewport 끝까지 차지 → 페이지네이션이 부모 overflow-hidden 에 잘림.

신: outer wrapper 에 flex flex-col min-h-0 flex-1, 내부 테이블/카드에 min-h-0 flex-1 overflow-y-auto. 부모 flex-col 의 남는 공간만 차지 → 페이지네이션 등 형제는 자기 자연 height 유지.

로딩 스켈레톤 동일 패턴 적용.
2026-05-13 09:41:42 +09:00
johngreen dc77c07cc4 Merge refactor(반응형): container query 전환
Build & Deploy to K8s / build-and-deploy (push) Failing after 28m28s
2026-05-13 08:43:16 +09:00
johngreen 6d5ca2f23a refactor(반응형): ResponsiveDataView 를 container query 기반으로 전환
기존: viewport 기준 (lg:hidden, sm:grid-cols-2) — 사이드바 펼친 상태에서 콘텐츠 영역의 실제 width 와 무관하게 동작 → 좁은 영역에 2열 카드가 들어가 카드가 잘려보이는 문제

신: @container 기반 — 컴포넌트가 자기 부모 컨테이너 width 에 반응
- 컨테이너 < 32rem (512px): 카드 1열
- 32~48rem (512~768px): 카드 2열
- ≥ 48rem (768px): 데스크톱 테이블

Tailwind v4 의 first-class container query 활용 (별도 플러그인 불필요). 데스크톱 테이블의 viewport 기준 max-height 스크롤은 유지.

근거: 2026 베스트프랙티스 — page layout=media query, 컴포넌트=container query (LogRocket / NN-Group / Tailwind v4 가이드).
2026-05-13 08:43:13 +09:00
johngreen 1ba310236c Merge fix(반응형): 모바일 width 카드/이력 모달 스크롤
Build & Deploy to K8s / build-and-deploy (push) Failing after 29m24s
2026-05-13 08:09:01 +09:00
johngreen 0d5d1fe10d fix(반응형): 모바일 width 에서 카드 뷰 + 이력 모달 스크롤 누락
- ResponsiveDataView: 모바일 카드 뷰 (lg 미만) + 카드 스켈레톤에 scrollContainer 모드의 max-h + overflow-y-auto 적용. 데스크톱 테이블만 적용돼 있던 누락 픽스
- TableHistoryModal: timeline/detail 탭의 ScrollArea 고정 h-[500px] → 모바일 h-[300px] sm:h-[500px] 적응형. DialogContent max-h-[90vh] 와 충돌 방지

증상: 브라우저 width 좁아질 때 카드 그리드/이력 타임라인이 viewport 너머로 잘리고 스크롤 안 됨.
2026-05-13 08:08:58 +09:00
gbpark c3e04adb23 docs(notes): close input canonical cleanup 2026-05-13 02:50:30 +09:00
gbpark 7bd08dcf9d refactor(components): consolidate canonical input cleanup 2026-05-13 02:38:29 +09:00
johngreen 3dbc2107d8 Merge fix(비번초기화): 키 불일치 + 입력값 무시 픽스
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m15s
2026-05-12 19:28:02 +09:00
johngreen 3eeb0764bf fix(비번초기화): 키 불일치 + 입력값 무시 픽스
- Frontend: body 키를 snake_case (user_id/new_password) 로 변환
- Controller: new_password 도 추출해서 service 에 전달
- Service: 2-arg 오버로드 추가, newPassword 입력값 사용 (blank 일 때만 Welcome1! fallback), userId null/blank 시 IllegalArgumentException

증상: 사용자관리에서 비밀번호 초기화 modal 입력 → backend 가 user_id=null 로 SQL 실행 (0행) + newPassword 무시 후 항상 Welcome1! 로 덮어쓰기.
2026-05-12 19:27:44 +09:00
DDD1542 4a8413000b Consolidate canonical input migration
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m17s
Remove legacy v2 input/select and file/media runtimes, add canonical option/file loaders, and document Codex handoff.
2026-05-12 18:36:43 +09:00
johngreen 7706403caa Merge fix(대무자): insert date CAST
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m19s
2026-05-12 18:23:51 +09:00
johngreen 3c24956efd fix(대무자): insert SQL 의 start_date/end_date 도 CAST AS DATE 추가
insertSubstitute 공백 이 char varying 으로 전달되어 DATE 컴럼 에 ERROR. checkOverlap, updateSubstitute 처럼 CAST(#{...} AS DATE) 로 일관되게 수정.
2026-05-12 18:23:49 +09:00
johngreen a7683d4d0e Merge fix(대무자): Select candidates data shape
Build & Deploy to K8s / build-and-deploy (push) Failing after 18m41s
2026-05-12 18:10:09 +09:00
johngreen c3e5d7fc1b fix(대무자): Select 후보 목록 - getUserList 응답 shape 호환
SubstituteSection 의 loadCandidates 가 res.data.list 를 가정했지만
getUserList(/api/admin/users) 응답은 { data: [...] } 형태 (data 가 list 자체).
결과로 모든 select 가 '지정 가능한 사용자가 없습니다' 로 표시됐음.

Array.isArray(res.data) 와 res.data.list 둘 다 fallback 으로 처리.
2026-05-12 18:10:07 +09:00
johngreen 33a245e4e8 Merge pull request 'fix(대무자): COMPANY_ADMIN + SQL + UI select' from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Failing after 3m24s
2026-05-12 17:02:44 +09:00
johngreen c4a62b7e35 fix(대무자): COMPANY_ADMIN 권한 허용 + 결재함 SQL 컬럼 오타 fix + UI 셀렉트 개선
운영 QA 에서 발견된 3가지 결함을 한 번에 수정.

1. SubstituteController.java:56 / SubstituteService.java:242 (requireAdmin)
   - role 비교에서 "COMPANY_ADMIN" 누락 → 운영 admin 이 대무자 지정 시 항상 403.
   - 운영 회사 admin 의 user_type 은 COMPANY_ADMIN 이 표준 (AdminAccountCreator 가 그렇게 생성).
   - "ADMIN" / "SUPER_ADMIN" 외 "COMPANY_ADMIN" 도 허용.

2. mapper/approval.xml (selectMyRequests, selectMyPendingLines)
   - ORDER BY / SELECT 의 R.CREATED_DATE 가 잘못된 컬럼명 (APPROVAL_REQUESTS 실제: created_at).
   - 결재함 /api/approval/my-pending, /api/approval/requests 가 항상 500.
   - 3군데 R.CREATED_DATE → R.CREATED_AT.

3. SubstituteSection.tsx
   - 대무자 ID 를 직접 타이핑하던 input 을 Select 로 교체.
   - getUserList 로 같은 회사 활성 사용자 목록 로드, 본인 + SUPER_ADMIN + 비활성 자동 제외.
   - 다이얼로그 열 때 한 번만 load (openDialog 시 loadCandidates).
   - 빈 결과/로딩 placeholder 처리.
2026-05-12 17:02:15 +09:00
hjjeong 081feff51f style(rolesList): 다른 메뉴 톤에 맞춰 사이즈/글씨 축소
본문 텍스트 text-sm → text-xs, 헤더 보조 텍스트 text-xs →
text-[11px], 카드 헤더 p-3 → p-2.5, 좌측 권한 목록 그리드
260px → 220px. Input/Select h-8 → h-7 (메인) / h-7 → h-6 (서브),
메뉴 트리 row py-2 → py-1.5, 트리 들여쓰기 level*20+12 →
level*16+10, chevron h-3.5 → h-3. 4분할 직원 카드 영역
clamp(220, 32vh, 320) → clamp(200, 28vh, 280).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:17:39 +09:00
DDD1542 90035dd5c6 feat(numbering): 채번 관리 페이지 통짜 작업대 디자인 적용
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m4s
채번 = 독립 자원 (컬럼과 N:M 연결) 모델로 페이지 통째 리뉴얼.
카드 박스 폐기, 좌측 sidebar + 우측 통짜 main + ⌘K 명령 팔레트 구조.

- frontend/styles/v5-layout.css: v5-nrm-* 섹션 추가 (메뉴관리 v5-mm-* 동일 패턴)
- numberingRuleList/page.tsx: PageHead 안 쓰고 메뉴관리 스타일 단순 헤더 +
  사이드바 (검색·필터·섹션·list) + Hero·파이프라인·2-col split·sticky save bar +
  ⌘K 팔레트 (검색·프리셋·기존 채번)
- 기존 NumberingRuleDesigner/SequenceManagementPanel/CreateDialog 의 로직을
  page.tsx 내부 sub-component (PipelineBlock·PartInspector·UsageList·CommandPalette) 로 재구성
- mockup HTML 산출물 notes/gbpark/ 보관

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:06:28 +09:00
hjjeong ffa6799053 Merge pull request 'Merge branch 'main' into hjjeong' (#8) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m35s
Reviewed-on: #8
2026-05-12 02:48:19 +00:00
hjjeong 7315603f0f Merge branch 'main' into hjjeong
# Conflicts:
#	backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java
2026-05-12 11:42:17 +09:00
hjjeong 8bdc9a958f feat(favorites): 사용자별 메뉴 즐겨찾기
사이드바 leaf 메뉴 옆 별표로 토글하고, 사이드바 최상단 '즐겨찾기'
섹션에 등록된 메뉴를 모아 보여준다. 테넌트 DB 별로 격리.

- V020 + StartupSchemaMigrator: USER_MENU_FAVORITES (USER_ID,
  MENU_OBJID UNIQUE) 메타·테넌트 DB 동기 멱등 생성
- 백엔드: GET/POST /api/favorites/menus, DELETE
  /api/favorites/menus/{menuObjid} — FavoritesController/Service
  + mapper/favorites.xml (MENU_INFO JOIN)
- 프론트: favoritesAPI 클라이언트 + FavoritesContext (낙관적
  toggle/롤백) + (main)/layout 에 Provider 마운트
- AppLayout: leaf/sub-item 옆 Star 토글 (등록 시 primary 컬러,
  미등록 시 0.35 dimmed) + 사이드바 최상단 favorites 섹션. uiMenus
  의 leaf 평탄화 결과를 isFavorite 으로 필터링해 권한 필터/메뉴
  클릭 핸들러를 그대로 재사용

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:29:18 +09:00
johngreen 6561aad7ef Merge pull request 'feat(대무자): 대무자 관리 기능 — 결재 위임 + 컨텍스트 주입 + UI' (#7) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m38s
2026-05-11 23:29:33 +00:00
johngreen af23fd0316 feat(대무자): StartupSchemaMigrator 에 RUN_086 자동 적용 등록
invyone 의 정상 마이그레이션 패턴(StartupSchemaMigrator)에 RUN_086 의 7개 idempotent
DDL/DML 등록. 다음 backend 부팅 시 메타 DB + 모든 활성 테넌트 DB 에 자동 적용됨.

- btree_gist 확장 (CREATE EXTENSION IF NOT EXISTS)
- USER_SUBSTITUTES 테이블 (PK, CHECK 2, EXCLUDE 제약, 인덱스 2)
- SYSTEM_AUDIT_LOG ALTER (PROCESSOR_ID/PROCESSOR_NAME ADD COLUMN IF NOT EXISTS)
- APPROVAL_PROXY_SETTINGS → USER_SUBSTITUTES idempotent 데이터 복사

호환성 보정:
- EXCLUDE 의 daterange lower 에 COALESCE(.., CURRENT_DATE) 사용 불가 (IMMUTABLE 만 허용)
  → daterange(START_DATE, END_DATE, '[]') 단순 형식. NULL lower = -infinity 자연 처리.
- APPROVAL_PROXY_SETTINGS 의 START_DATE/END_DATE 가 varchar → DATE cast 추가
- 원본 메타데이터(created/updated) 컬럼명 환경별 차이 회피 → 'migration_086' + NOW() 고정

실측: meta+3 tenants 모두 OK (test01/test02/siflex)
2026-05-12 08:20:59 +09:00
johngreen 6a9fc06f0e feat(대무자): 프론트엔드 UI — UserFormModal 대무자 섹션 + ProfileModal 조회 + 결재 뱃지
- frontend/lib/api/substitute.ts: 7개 API 함수 (Record<string, any> 컨벤션)
- components/admin/SubstituteSection.tsx (신규): 관리자용 대무자 지정 섹션
  · 활성/예정 대무 관계 테이블, 사전 겹침 검증
  · v5 토큰 (--v5-surface-solid, --v5-glow-sm) 사용, blur 금지
- components/admin/UserFormModal.tsx: 수정 모드일 때 SubstituteSection 노출
- components/layout/MySubstituteView.tsx (신규): ProfileModal 용 read-only 조회
  · 내 대무자 + 내가 대무 중인 사람 양방향, D-day 카운트다운
- components/layout/ProfileModal.tsx: MySubstituteView 삽입
- app/(main)/approval/page.tsx: 대기함 행에 "대무 ← {원본 결재자}" 뱃지
  · currentUser.user_id !== line.approver_id 비교 (별도 타입 필드 X)
2026-05-12 08:07:15 +09:00
johngreen c0bd420c66 feat(대무자): SYSTEM_AUDIT_LOG processor 분리 기록 + USER_INFO lookup
- auditLog.xml insertAuditLog INSERT 절에 PROCESSOR_ID/PROCESSOR_NAME 컬럼 추가
- auditLog.xml selectUserNameById — 처리자 이름 lookup
- AuditLogService.insertAuditLog:
  · processor_id null → user_id 로 자동 채움 (평시 = 동일)
  · processor_id != user_id 이고 processor_name null → USER_INFO 단건 조회 (대무 이벤트만)
- notes: 도메인 테이블 CREATED_BY/UPDATED_BY broad scan — actual processor(B) 통일 자동 만족
2026-05-12 08:06:55 +09:00
johngreen 6ab7c3e780 feat(대무자): 결재 시스템 어댑터 통합 — USER_SUBSTITUTES read + IN(effective_user_ids)
- approval.xml selectActiveProxyForLine: APPROVAL_PROXY_SETTINGS → USER_SUBSTITUTES 참조
  (BOOLEAN IS_ACTIVE, START_DATE NULL 허용)
- approval.xml 3곳 (selectRequests/countRequests/selectMyPendingLines):
  APPROVER_ID = #{user_id} → APPROVER_ID IN (effective_user_ids) foreach
- ApprovalService.ensureEffectiveUserIds helper — mybatis 빈 IN() 방지
- ApprovalController: getRequests/getMyPendingLines 에 @RequestAttribute(effective_user_ids) 추가
- ApprovalService.processApproval 마지막에 AuditLogService.insertAuditLog 호출 추가
  (user_id=A, processor_id=B 분리 기록)
2026-05-12 08:06:39 +09:00
johngreen 4a83bfc8e8 feat(대무자): SubstituteContextFilter — JWT 후단계 effective_user_ids 주입
- TenantConsistencyGuard 뒤, ForcePasswordChangeGuard 앞에 등록
- /api/** 요청에 effective_user_ids = [user_id, ...active_original_ids] attribute 세팅
- SUPER_ADMIN (company_code='*') 은 short-circuit
- DB 조회 실패 시 본 요청 차단 안 함 (가용성 우선, warn 로그)
2026-05-12 08:06:21 +09:00
johngreen 6a7d261d23 feat(대무자): SubstituteService/Controller/mapper — CRUD + 검증 + Filter 핫패스
- substitute.xml (namespace=substitute): selectSubstituteList/Cnt, selectMySubstitutes,
  selectActiveOriginalUserIds (Filter 핫패스), selectActiveProxyForLine (결재 어댑터),
  countOverlap, countUserInCompany, countSuperAdmin, insert/update/delete
- SubstituteService extends BaseService: 관리자 권한 검증, self-위임 차단,
  cross-company 검증, SUPER_ADMIN 을 proxy 로 지정 거부, 사전 겹침 검증
- SubstituteController: /api/substitutes CRUD + /mine (read-only) + /check-overlap
2026-05-12 08:06:03 +09:00
johngreen e4856dcae5 feat(대무자): RUN_086 DB 마이그레이션 — USER_SUBSTITUTES + SYSTEM_AUDIT_LOG processor 추적
- USER_SUBSTITUTES 테이블 (PK, CHECK 2개, EXCLUDE 제약, 인덱스 2개)
- btree_gist 확장 hard prerequisite
- SYSTEM_AUDIT_LOG.PROCESSOR_ID/PROCESSOR_NAME 컬럼 추가
- APPROVAL_PROXY_SETTINGS → USER_SUBSTITUTES idempotent 데이터 복사
- 사전/사후 검증 SQL + 롤백 섹션 포함
2026-05-12 08:05:47 +09:00
gbpark baffd6affb Merge origin/main into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m45s
부서관리 V1 슬림 스코프 + UX 리디자인, 25개 버그 일괄 수정, admin/부서관리
탭 라벨 fallback, Windows dev HMR 복원 흡수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:51:34 +09:00
gbpark a5bbd1eb7c refactor(numbering-rule): NumberingRule → Input canonical 흡수 + 채번 관리 페이지 분리
- 옛 registry/numbering-rule, registry/v2-numbering-rule, V2NumberingRuleConfigPanel,
  NumberingRuleTemplate 폐기 — InvFieldConfigPanel + InputComponent 로 통합
- input 에 numbering-picker / select-pickers 추가, autonum 타입 흡수
- 채번 관리 전용 admin 페이지(systemMng/numberingRuleList) + CreateDialog +
  SequenceManagementPanel 신설
- backend NumberingRule controller/service/mapper 갱신 (시퀀스 관리 엔드포인트)
- input canonical 진행 노트 + 채번 관리 mockup 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:42:13 +09:00
hjjeong 1bd0fd8b80 fix(rolesList): 권한관리 viewport 안에 맞도록 높이 조정
페이지 외부 컨테이너를 h-screen 으로 가두고, 상단 4분할 카드는
viewport 비례(clamp 220~320px), 하단 메뉴 트리는 남은 공간을
flex-1 + min-h-0 으로 채우되 max-h(clamp 280~430px) 로 상한 제한.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:13:22 +09:00
hjjeong 4b97448467 Merge remote-tracking branch 'origin/main' into hjjeong 2026-05-11 14:06:21 +09:00
johngreen 63279296f8 Merge pull request 'fix(layout): 부서관리 탭 라벨 hardcoded 한글 fallback 추가' (#6) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m28s
2026-05-11 04:03:39 +00:00
johngreen 1b9604f66e fix(layout): 부서관리 탭 라벨 hardcoded 한글 fallback 추가
SUPER_ADMIN cross-tenant 모드에서 menu API (/api/admin/menus) 가 500 응답을
내어 uiMenus 가 비어있고, 그 결과 우리 effect 가 매칭할 데이터가 없어
sessionStorage 의 영어 fallback title (deptMngList) 이 갱신되지 않던 문제.

AppLayout 의 fallback 두 곳에 ADMIN_PATH_LABELS 맵 추가:
1. URL 직접 진입 시 첫 openTab 의 fallback title
2. uiMenus 매칭 실패 시 한글 라벨 보강

근본 원인 (menu API 500) 은 별도 backend 이슈 — 본 fix 는 우회.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:03:05 +09:00
johngreen b16439098a Merge pull request 'fix(layout): admin 탭 영어 fallback title 을 메뉴 한글명으로 갱신' (#5) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 10m59s
2026-05-11 02:11:23 +00:00
johngreen 36d93d91cf fix(layout): admin 탭 영어 fallback title 을 메뉴 한글명으로 갱신
URL 직접 진입 / sessionStorage 복원 시 AppLayout 의 fallback
(pathname.split('/').pop()) 이 path segment 를 그대로 탭 title 로
사용해서 '부서관리' 대신 'deptMngList' 같은 영어가 표시되던 문제.

- tabStore: updateTabTitle(tabId, title) 추가
- AppLayout: uiMenus 로드 후 admin 탭들의 admin_url 매칭하여
  menu_name_kor (tabTitle/label/name) 로 갱신

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:10:01 +09:00
hjjeong 77bd8cab75 Merge branch 'main' into hjjeong 2026-05-09 16:53:14 +09:00
johngreen f0781022de fix(�μ�����): 25�� ���� �ϰ� ���� + ������ ���Ἲ ��ȭ (#4)
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m31s
johngreen �귣ġ�� �μ����� ���� + �м� ��Ʈ�� main ���� ����. �ڵ� ���� Ʈ����.
2026-05-08 09:48:19 +00:00
hjjeong 0a8be2df1e Merge pull request 'hjjeong' (#3) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m38s
Reviewed-on: #3
2026-05-08 08:29:46 +00:00
johngreen 9c658ffd36 docs(notes): 부서관리 버그 헌팅 분석 리포트 (frontend/backend/sql/ux)
4개 도메인 병렬 분석 결과 + 통합 요약 + 시나리오 중심 정리.
총 25개 버그 (CRITICAL 3 / HIGH 11 / MEDIUM 7 / LOW 4) 식별.
실제 수정은 별도 커밋 (commit 68c1cb5b).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:08:23 +09:00
johngreen 68c1cb5b14 fix(부서관리): 25개 버그 일괄 수정 + 데이터 무결성 강화
CRITICAL:
- searchUsers 회사/role 격리 가드 추가 (멀티테넌시 침해 차단)
- setPrimaryDept 멤버십 검증 추가 (주부서 데이터 손상 방지)
- parent_dept_code cross-tenant 검증 (validateParent 헬퍼)

HIGH:
- updateDepartment SQL WHERE 에 DELETED_AT IS NULL 추가 (silent corruption 방지)
- update/restore 부서명 중복 검증 추가
- 글로벌 부서 (*) write 작업 SUPER_ADMIN 전용 가드
- 부서코드 자동 생성으로 강제 (사용자 입력 받지 않음)
- 회사 변경 시 상세 패널 초기화
- handleMove 부분 실패 시 화면 동기화
- 검색 시 부모 체인 자동 포함 (broken tree 수정)
- start_date 기본값 today 강제 제거

MEDIUM:
- 멤버 fetch cancellation flag
- 삭제 다이얼로그 dept_code 클로저 캡처
- isDirty 시 X 버튼 폼 초기화 경고
- 변경이력 버튼 disabled (백엔드 API 미구현)
- 일괄등록 실패 상세 모달 (라인 + 사유)
- LIKE 와일드카드 ESCAPE 적용
- nullIfBlank 에 trim 통합

LOW + 새 기능:
- 부서원 추가/제거 UI 신규 구현 (UserSearchModal)
- selectDeptMembers LEFT JOIN 으로 변경
- DepartmentPicker allowRoot 옵션 (최상위로 이동)
- expandAll 전체 departments 사용
- dead code 정리

DB:
- RUN_085 마이그레이션: DEPT_INFO partial UNIQUE + USER_DEPT UNIQUE
- 모든 active 테넌트 DB (siflex/test01/test02_invyone) 적용 완료

Breaking changes:
- 일괄등록 CSV 4컬럼 → 3컬럼 (부서명,상위부서,유형)
- 부서코드 입력란 제거 (자동 부여 DEPT_n)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:08:03 +09:00
hjjeong 74ddc4936f Merge branch 'main' into hjjeong 2026-05-08 14:59:12 +09:00
johngreen 3d220373d8 Merge pull request 'feat(부서관리): V1 슬림 스코프 + UX 리디자인 + Windows dev HMR' (#2) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m30s
2026-05-08 02:12:04 +00:00
johngreen 3d5b2a4911 docs(notes): 부서 vs SRM 갭 분석 (johngreen)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:35:02 +09:00
johngreen d2b77d348b chore(dev): Windows Docker Desktop 자동 HMR 복원
Docker Desktop on Windows 의 bind mount 가 host inotify 이벤트를
컨테이너로 전파하지 못해 Turbopack file watcher 가 host 편집을 감지 못 함.
webpack 은 WATCHPACK_POLLING=true 폴백을 지원하므로 Windows 에서만
Turbopack 을 끄고 webpack 으로 폴백 → 자동 HMR 복원.

- frontend/package.json: dev:docker:nopack 스크립트 추가 (next dev, no turbopack)
- docker/dev/docker-compose.windows.yml: Windows 전용 frontend command override
- scripts/start/invyone-start-docker-all.bat: windows.yml 자동 merge

Mac/Linux 진입점은 영향 없음 (start.bat 만 windows override 활성).
첫 컴파일은 약간 느려지지만 (~10-30%) 수정→반영 시간이 80s → 1~3s 로 단축.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:34:46 +09:00
johngreen 0e895a90fa feat(부서관리): V1 슬림 스코프 + 트리 컨텍스트 메뉴 UX 리디자인
백엔드:
- V018 soft-delete (deleted_at 컬럼) + 휴지통/복구 흐름
- V019 미사용 컬럼 cleanup (V1 슬림 스코프)
- DepartmentService.updateDepartment 에 parent_dept_code 사이클 가드
  (자기 자신/자손을 부모로 지정 시도 차단)
- DepartmentController, mapper 갱신

프론트:
- 부서관리 페이지(deptMngList) UX 리디자인
  - 트리 노드 ⋮ 컨텍스트 메뉴 (하위 추가, 다른 부서 아래로 이동, 정렬 4단계, 삭제)
  - 헤더 breadcrumb 으로 부서 위치 상시 표시
  - 폼의 상위부서 row 제거 (트리 ⋮ 로 진입점 일원화)
  - 빈 상태 placeholder + X 닫기 동작
  - 토글 버튼 토스 스타일 (아이콘 + 툴팁, 일정한 위치)
  - 부서유형 row 좁은 화면 가로 오버플로 fix
- DepartmentPicker 신규 재사용 컴포넌트 (자손 자동 exclude, 사이클 차단)
- 회사관리/프로비저닝 폼 개선 (Step1Basic, fields, CompanyTable, AdminPageRenderer)
- companyList/[companyCode]/departments 구버전 페이지 삭제

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:34:23 +09:00
DDD1542 59f5cf22f0 Merge remote-tracking branch 'origin/gbpark-node' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m4s
2026-05-07 17:09:57 +09:00
DDD1542 c4631efbd2 중간저장 2026-05-07 17:06:26 +09:00
hjjeong 84b9060e4e Merge branch 'hjjeong' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m36s
2026-05-07 17:04:57 +09:00
hjjeong 3280be8bd4 fix(rolesList): cross-tenant row 식별 + 메뉴 트리 스크롤 보강
cross-tenant fan-out 결과에서 회사 A·B 의 동일 objid 가 합본에 들어와
React key 중복 경고 발생 + isSelected 가 회사 구분 못 하던 문제.

- li key: role.objid → \`\${company_code}-\${objid}\` 조합으로 unique
- isSelected 비교: objid + company_code 둘 다 매칭
- selectedRole 유효성 체크(useEffect)에도 company_code 매칭 추가

추가:
- 메뉴 전체 트리구조에 자체 스크롤 (maxHeight: calc(100vh - 32rem))
- thead sticky top-0 + bg-muted (투명도 제거) → 스크롤 시 헤더 가려짐 해소

SUPER_ADMIN cross-tenant 정책 변경 없음 (모든 회사 합본 표시 유지),
React 식별만 정확해지는 fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:51:16 +09:00
hjjeong b782bb298f merge: origin/gbpark-node → hjjeong (60 commits, 5 conflicts resolved)
충돌 해결 5개 파일:
- .gitignore: .envrc/.direnv (hjjeong direnv 셋업) + .omc/ (gbpark) 양쪽 보존
- docs/MULTI_TENANCY_ARCHITECTURE.md: *.localhost dev 분기 + *.invyone.com/solution.invyone.com 통합
- frontend/lib/api/client.ts: 1-b *.localhost:8081 dev + 1-c DEV_TENANT_HOST(nip.io):8083 + invyone.com 신 도메인
- frontend/lib/tenant/subdomain.ts: IPv4 차단 + *.invyone.com + DEV_TENANT_HOST + *.localhost 모두 처리
- frontend/app/(auth)/login/page.tsx: B안 채택 — buttons 항상 렌더, className 만 mounted 가드 (next-themes 표준 패턴)

검증:
- backend: ./gradlew compileJava 성공 (Java 21)
- frontend: 머지된 4개 파일 관련 타입 에러 0개

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:51:06 +09:00
DDD1542 798fdf18b3 merge: gbpark-node → main (화재 알람 SCADA 경고 인디케이터)
Build & Deploy to K8s / build-and-deploy (push) Failing after 6m51s
- zone 중앙에 SCADA 스타일 경고 비콘 (펄스 링 + 빨간 배지 + 경고 삼각형)
- WARN(노란/정적) / ALARM(빨간/깜빡임) 색상·효과 분리
- ZONE 17 라벨/manual-call 겹침 해소

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:36:14 +09:00
DDD1542 8eb4e8c9a2 feat: 화재 알람 데모 SCADA 스타일 경고 인디케이터 + ZONE 17 콘텐츠 정렬
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m29s
- 각 zone 중앙에 SCADA 스타일 경고 비콘 자동 삽입 (펄스 링 + 빨간 배지 + 노란 경고 삼각형)
- WARN/ALARM 별 색상 분리 (CSS 변수 --b-* 로 SVG <use> shadow DOM 통과)
  - WARN: 노란 톤 + 정적 표시
  - ALARM: 빨간 톤 + drop-shadow + brightness 깜빡임
- zone-area 점선 테두리: warn(노란/얇음), alarm(빨간/굵음+pulse)
- ZONE 17 콘텐츠를 ZONE 18 비례에 맞춰 재배치 (label y 453→396) — manual-call 과 라벨 겹침 해소

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:34:20 +09:00
DDD1542 48d74170fc merge: gbpark-node → main (SCADA/화재알람 데모 가독성 개선)
Build & Deploy to K8s / build-and-deploy (push) Failing after 6m31s
- SCADA 데모: 게이지 1.5x 확대, 설비 라벨 폰트 일괄 키움, 컴포넌트 위치 미세 조정
- SCADA 데모: dev-drag.js 추가 (Shift+D 임시 드래그 모드 + via/끝점 핸들)
- 화재 알람 데모: 건물 4종 색상 분류, 라벨/아이콘 폰트 확대
- chore: @anthropic-ai/claude-code dependency 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:07:54 +09:00
DDD1542 e8ba13f52b chore: @anthropic-ai/claude-code 패키지 dependencies 추가
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m3s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:05:46 +09:00
DDD1542 5d2283cb47 feat: 화재 알람 데모 건물별 색상 분류 + 라벨/아이콘 가독성 개선
- 건물 4종(utility/service/factory1/office)에 배경 그라디언트 분기
- zone 영역도 같은 톤 색상 클래스 (za-utility/service/factory1/office)
- room-label 폰트 5.7 → 7.5, zone-label 10 → 13.5 키움
- 센서/manual-call 아이콘 26 → 34, 18 → 24 로 확대 + 위치 보정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:05:42 +09:00
DDD1542 de0bfc1af4 feat: SCADA 데모 가독성 개선 + 컴포넌트 좌표 조정 도구 추가
- 압력 게이지 1.5x 확대, PRESS/판독값/라벨 폰트 강조
- 모든 설비(탱크/펌프/시스템/MBR/필터/카본) 라벨 폰트 +2~4 단계 키움
- 센서/타일 박스 및 폰트 확대, 위치 간격 보정
- 위쪽 처리 라인 30px 위로 + TSS-RAW 자기 자리 유지하도록 dy 보정
- AIR BLOWER 를 CIP 패널 내려간 만큼 같이 내림
- 게이지/밸브/모듈/탱크 및 파이프 라우팅 미세 조정 (드래그로 잡은 좌표 일괄 반영)
- dev-drag.js: Shift+D 임시 드래그 모드. 컴포넌트/파이프 via/절대 끝점 핸들로 좌표 인스펙션 후 토폴로지 재빌드, 변경 이력 누적 패널 + 전체 복사
2026-05-07 10:42:33 +09:00
gbpark f3c3087393 merge: gbpark-node → main (방재 화재 알람 데모 + SCADA 다중경고 dock 분할)
Build & Deploy to K8s / build-and-deploy (push) Failing after 6m29s
2026-05-06 22:52:59 +09:00
gbpark da77de58ac fix: SCADA 데모 다중 경고 모드에서 dock 잘림 해소 (emergency-stack)
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m14s
- 메인 모달 + alarm-list-dock 을 .emergency-stack 으로 묶어 viewport 안에서
  flex 자동 분할 (어떤 해상도/줌에서도 dock 이 잘리지 않게)
- with-multi 진입 시 JS 가 stack 으로 동적 wrapping, 해제 시 원위치 복귀
- mini-modal 본문도 line-clamp 3 + min-height:0 로 길이 폭주 차단

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:48:37 +09:00
gbpark 59fbbd95fa feat: 방재 시스템 화재 알람 모니터링 데모 페이지 추가
Build & Deploy to K8s / build-and-deploy (push) Has been cancelled
- /fire-alarm 라우트에 invyone 브랜딩 적용된 모니터링 데모 추가
- CCTV 화면은 화재 감지 → 스프링쿨러 작동 2개 영상을 순차 재생
  (단일 합본 mp4 의 ended 이벤트 불안정 + 루프 이슈로 분리)
- v1 종료 시점에 자동 phase2 전환: 스프링쿨러/방화셔터 상태 활성화
- 정보 패널 스프링쿨러 상태 텍스트에 시안 글로우 펄스로 작동 표시
- 종료 검출 3중 방어 (timeupdate / ended / hard-stop timer)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:47:03 +09:00
johngreen e1be30f8ca fix: 메뉴 순서 V017 을 모든 활성 테넌트 DB 에도 적용
Build & Deploy to K8s / build-and-deploy (push) Failing after 6m54s
Flyway V017 은 메타 DB 만 갱신하지만 MENU_INFO 의 슈퍼관리자 메뉴
(COMPANY_CODE='*') 는 회사 프로비저닝 때 각 테넌트 DB 로 복사되어
박혀있다. StartupSchemaMigrator 에 동일 UPDATE 를 넣어 부팅 시 메타 +
모든 활성 테넌트에 자동 동기화. SEQ 만 갱신하므로 멱등.
2026-05-04 13:16:10 +09:00
johngreen 47a2f97da5 chore: 회사 관리 그룹 하위 관리자 메뉴 순서 재배열 (V017)
Build & Deploy to K8s / build-and-deploy (push) Failing after 6m56s
조직 계층(회사→부서→사용자) + 권한 체계(메뉴→권한→권한그룹) 으로 묶어
부서가 맨 끝에 빠지고 권한그룹이 권한관리 뒤에 오던 어색한 순서를 정리.
2026-05-04 11:23:21 +09:00
johngreen d6cfa9973f merge: main → gbpark-node (혼합 모드 병렬 그루핑 UI)
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m5s
2026-05-04 08:06:09 +09:00
johngreen a6b66ac0d1 feat(ai-workspace): 혼합 모드 병렬 그루핑 UI + 카드별 분리
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m3s
- 스텝 사이 화살표에 "병렬로 합치기" 버튼 추가 → 두 스텝을 같은 execution_order 로 묶음
- 병렬 스텝 카드별 분리 버튼 (trash 왼쪽) → 해당 카드만 다음 스텝으로 빼냄
- 병렬 스텝 헤더 amber 배경 +  "병렬 실행" 배지로 시각 차별화
- (순차)/(병렬) 부가 라벨 제거, 깔끔한 "Step N" 표기로 통일
- execution_order 정규화 헬퍼(renormalizeOrders) + 일괄 저장(persistOrders) 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:05:19 +09:00
gbpark 4514275347 chore: SCADA 데모 CCTV 의 TARGET 라벨 박스 제거
Build & Deploy to K8s / build-and-deploy (push) Successful in 6s
실제 영상에서 BW-A1 위에 박스 정렬 어렵고 시연 임팩트 없음 — 그냥 영상 그대로 노출.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:38:45 +09:00
gbpark ec9ef74204 feat: 모바일 페이지 PWA 풀스크린 메타 추가
Build & Deploy to K8s / build-and-deploy (push) Successful in 7s
- apple-mobile-web-app-capable + status-bar-style: iOS Safari 의 홈 화면 추가 시 standalone (주소창 + 하단바 제거)
- viewportFit:cover: 노치/홈바 영역까지 사용
- userScalable:false + maximumScale:1: 시연 중 핀치 줌 방지
- themeColor: 알람 모드 배경(#0d0202)과 일치

iOS PT 폰: Safari 공유 → 홈 화면에 추가 → 풀스크린 standalone.
Android PT 폰일 경우: manifest.json + icon 추가 별도 필요 (다음 단계).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:21:25 +09:00
gbpark d0dd86371b chore: SCADA 데모의 알람 target 작업자를 siflex_user 로 기본 박음
Build & Deploy to K8s / build-and-deploy (push) Has been cancelled
데스크톱이 /scada?worker=siflex_user query 매번 안 박아도 자동으로 작업자 폰(siflex_user)에 push.
다른 작업자 시험할 일 있으면 ?worker=<id> 로 override 여전히 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:16:11 +09:00
gbpark 11d46c98bf chore: db/migration 의 legacy V001/V002 SQL 을 notes/ archive 로 이동
Build & Deploy to K8s / build-and-deploy (push) Successful in 7s
Flyway 가 V001 두 개 (기존 V001__create_ai_llm_providers + 새로 들어온 V001__varchar_migration) 를 충돌로 거부 → backend pod CrashLoopBackOff.
운영 schema 와 호환 검토 전까지 db/migration/ 밖으로 빼서 archive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:04:09 +09:00
gbpark 80cd2b2d07 fix: e70267f73 에서 누락된 untracked 파일 추가
Build & Deploy to K8s / build-and-deploy (push) Failing after 3m8s
- frontend/components/layout/MenuItemActions.tsx — AppLayout/TopNavBar 가 import 하는데 빠져서 webpack 빌드 fail
- backend-spring db migration V001 (varchar_migration) + V002 (create_missing_tables) 같이 누락분 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 06:22:02 +09:00
gbpark e70267f738 feat: SCADA 데모 음성 인식 + 경고 버튼 디자인 통일
Build & Deploy to K8s / build-and-deploy (push) Failing after 1m14s
- 음성 인식 (scada-demo/js/voice.js) — 한국어 발화 → 키워드 매핑 → INVYONE_UI.select()
  · 사이드바 마이크 버튼 + transcript 라벨, 매칭 시 청록 펄스
  · Chrome/Edge HTTPS 환경 (운영 siflex.invyone.com OK)
- 경고시스템/다중경고 버튼을 음성 인식과 동일 톤
  · 🚨 emoji → SVG 삼각형 아이콘, voice-btn 패턴 (다크 솔리드 + 컬러 액센트)
  · 정적 (반짝 펄스 애니메이션 제거)
- client.ts stash pop conflict 정리 (DEV_TENANT_HOST + 도메인 정리 통합)
- ui.js 다중 경고 시연 wiring + scada 작업 노트 2건
- 기타 syncthing 보류분 batch (대시보드/레이아웃/로그인 layout 정리)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:39:43 +09:00
gbpark 3a0ab10ee6 Merge branch 'gbpark-node' of https://git.junggomoa.com/gbpark/invyone into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m51s
2026-05-03 01:29:07 +09:00
gbpark 7635412b7b feat: SCADA 시연용 모바일 알람 동반 화면 + Spring WebSocket
- 작업자 폰(/mobile)을 SCADA 데모와 ws 로 연결, 알람 발생 시 풀스크린 푸시
  · v5 솔리드+글로우 톤, 진동/Web Audio 비프/Wake Lock/auto reconnect
  · 시연 안전망: ?test=1 자동 발동, 우상단 hidden 트리거
- backend: com.erp.alarm 신규 패키지 (WebSocketConfig + Handshake + Handler + Controller)
  · JWT 토큰 핸드셰이크 검증, userId 기반 채널 매핑 (멀티 디바이스 지원)
  · spring-boot-starter-websocket 의존성 추가
  · path 를 /api/demo/* 안에 두어 Traefik 라우트 추가 불필요 + 정식 알람과 분리
- SCADA scenario.js 의 emergency 시퀀스(2700ms)에 fetch('/api/demo/alarm/trigger') 배선
  · /scada?worker=<user_id> query 로 target user 지정 (iframe src 로 전달)
- 운영 시연 URL: siflex.invyone.com/mobile (siflex_user) ↔ /scada?worker=siflex_user

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:18:12 +09:00
johngreen b999b425cb Merge pull request 'Sync main → gbpark-node: AI 모듈 JSONB 파싱 + audit-log fix' (#1) from main into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m31s
Reviewed-on: #1
2026-05-02 10:20:35 +00:00
johngreen 19f7615367 docs(notes): 그렘린 카오스 테스트 + JSONB autopilot 작업 노트
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m34s
- 2026-05-02-agents-gremlins-report.md — AI 에이전트 페이지 그렘린 1차 결과
- 2026-05-02-gremlins-jsonb-autopilot-report.md — 종합 리포트 (audit-log fix +
  AI 모듈 JSONB 일괄 적용 + Phase 4 검증 결과 + follow-up 항목)

향후 follow-up (별도 PR 권장):
- H1: AiAgentApiKey listAll cross-tenant 노출 (security)
- M1: AiLlmProvider.config 평문 노출 가능성
- M3: AiAgentProviderController create/update model 직접 노출
- M4: workspace/page.tsx 잔여 connectors 직접 접근 5곳

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:12:06 +09:00
johngreen 04cea72f33 fix(ai-modules): JSONB ::text 응답 자동 파싱 + workspace 카드 깨짐 수정
백엔드:
- 6개 AI Service (group/apiKey/provider/conversation/agent/scheduler) 가 응답 메서드에서
  `parseJsonField` 헬퍼로 JSONB(::text) 컬럼 (connectors / config / permissions /
  metadata / tool_calls / notification / tools) 을 String → Object 자동 변환.
- 모범 패턴 (`AuditLogService.processChanges`, `BusinessRuleService.parseJsonField`,
  `DataflowDiagramService.parseJsonbFields`) 동일하게 적용.
- model 의 String getter 는 그대로 유지 — `MultiAgentExecutionEngine` 등
  내부 LLM 호출 chain 영향 없음 (`getEntityById` 분리).
- 컨트롤러 시그니처 generic 만 변경 (return type Map).

프론트엔드:
- `safeArray<T>` / `safeObject<T>` 헬퍼 (`lib/utils.ts`) — 백엔드가 미파싱 String 으로
  올 때 graceful fallback. 빈 배열/객체 반환.
- `workspace/page.tsx` 멤버 카드:
  - `safeArray(member.connectors)` 적용 → `.map()` 폭발 차단.
  - 좁은 viewport 에서 한글 텍스트 한 글자씩 세로로 깨지던 문제 해결
    (`flex-wrap` + `truncate` + `whitespace-nowrap` + `max-w` + `title`).

그렘린 1000마리 폭격 + architect 자문으로 발견. workspace `Application error`,
`memberConnectors.map is not a function` 모두 해결.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:11:50 +09:00
johngreen 53f2638b82 fix(audit-log): URL 단/복수 + DB 컬럼명 + 응답 키 case 일괄 정정
- frontend `/audit-log` → 백엔드 `/audit-logs` (단/복수 mismatch)
- mapper auditLog.xml: `CREATED_DATE` → `CREATED_AT` (PostgreSQL 실제 컬럼명)
- AuditLogStats interface camelCase → snake_case (백엔드 응답 키 일치)

그렘린 카오스 테스트 + fetch hook 으로 발견. Application error / 500 / 404 모두 해결.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:11:26 +09:00
johngreen db06c95724 docs: REST API 아키텍처 문서 추가
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m8s
backend-architecture-summary.md 는 옛 Node 백엔드(VEX 1세대) 기준이라 현재 Spring 백엔드의 컨벤션이 문서화돼있지 않음. 신규 작업자가 합의된 패턴(3-layer, Map<String,Object>, 네이밍 규칙, 표준 응답, common SQL 단편) 을 한 곳에서 빠르게 파악할 수 있게 정리.

다른 아키텍처 문서(MULTI_TENANCY_ARCHITECTURE / DOMAIN_MAPPING) 와 상호 링크.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:46:24 +09:00
johngreen a76b85f1e5 fix(ai-agents): 카드 내 설정 버튼/모델 배지 위치 정렬
Build & Deploy to K8s / build-and-deploy (push) Has been cancelled
description 유무에 따라 카드 내부 컨텐츠 높이가 달라져 모델 배지와 설정 버튼이 카드마다 다른 높이에 노출되는 문제.

- 카드를 flex column 으로 변환하고 버튼 행에 mt-auto 적용 → 항상 카드 바닥 고정.
- description 영역을 항상 렌더링하고 min-h-[28px] (~2줄) 로 고정 → 빈 description 카드도 동일한 공간 차지 → 모델 배지 위치도 일치.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:42:47 +09:00
johngreen 81637b64a0 chore(scripts): obsolete .bat 정리 + invyone 전용 start/reload 추가
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m4s
옛 PLM 시절 Windows 스크립트가 현재 invyone 셋업 (compose: docker/dev/docker-compose.invyone.yml, 포트 9772/8083)과 안 맞아 정리.

삭제:
- 루트: start-windows-simple.bat / start-all-separated.bat / stop-all-separated.bat / test-backend-build.bat / run-windows.bat
- 루트: docker-compose.backend.win.yml / docker-compose.frontend.win.yml (옛 PLM 컨테이너명/포트, hardcoded credentials, 위 .bat 외엔 참조 없음)
- scripts/dev/: start-all-parallel.{bat,ps1} / stop-all.{bat,ps1} (모두 위 .yml 참조)
- ※ Mac 스택 (docker-compose.{backend,frontend}.mac.yml + scripts/dev/*.sh) 은 별도 시스템이라 건드리지 않음

신규:
- start.bat: scripts/start/invyone-start-docker-all.bat 으로 위임 (단일 진실의 원천)
- reload.bat: 프론트 컨테이너 재시작 + 백엔드 'sh ./gradlew classes' 로 재컴파일 (Spring DevTools 가 자동 리로드). Docker Desktop bind mount 가 호스트 변경을 컨테이너 inotify 로 안 넘겨서 자동 핫리로드가 안 되는 환경용.

업데이트:
- docs/DOMAIN_MAPPING.md: 개발 환경 표를 현재 포트/compose 로 갱신 + 테넌트 서브도메인 행 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:58:31 +09:00
johngreen 5cc255d8df build(docker-dev): Windows 바인드마운트에서 gradlew 실행 보장
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m6s
- backend-spring.Dockerfile: CMD에 sh ./gradlew 명시 호출 — 호스트가 Windows일 때 바인드마운트가 빌드 시점 chmod +x를 덮어쓰면서 ./gradlew가 실행 비트 없이 매핑되는 문제 해결. Linux/Mac 환경에도 동일하게 동작.
- .gitattributes 신규: gradlew와 *.sh 를 eol=lf 로 고정해 Windows core.autocrlf=true 환경에서 CRLF 변환으로 컨테이너 내 sh 파싱이 깨지는 문제 재발 방지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:09:21 +09:00
johngreen 8804b9fbfa fix(ai-workspace): 실행 모드 토글 한글 라벨 세로 깨짐 수정
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m3s
- 토글 컨테이너/버튼에 shrink-0 + whitespace-nowrap 추가
- 헤더 행을 flex-wrap 으로 보강해 좁은 폭에서 우아하게 줄바꿈
- raw <button> 이라 shadcn Button 의 nowrap 보호 막이 없어
  CJK 음절 단위로 "병/렬", "순/차", "혼/합" 으로 끊기던 문제 해결

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:38:54 +09:00
johngreen 9755869754 feat(template-thumb): 라이브 미니 프리뷰 (실제 TemplateRenderer scale-down)
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m5s
기존 와이어프레임 박스(테두리 + 투명 fill) 대신 실제 TemplateRenderer
를 mock empty context 로 띄워 transform: scale 로 축소 → 사용자가
빌더에서 그린 그대로의 레이아웃이 카드에서 그대로 읽힘.

- TemplateMiniPreview 컴포넌트 신설:
  - DEFAULT empty context (data:[], callbacks no-op) 로 데이터 fetch 0회
  - BASE_WIDTH=1200, 16:10 stage → ResizeObserver 로 카드 폭 변화 자동 추종
  - pointer-events: none / user-select: none / overflow: hidden
  - views 가 비어있으면 기존 TemplateThumbnail (와이어프레임) 폴백
- TemplateLibraryModal 카드 아이콘 자리 교체
- dashboard.css 에 .dash-lib-card-thumb--live / -stage 추가

향후 템플릿 50+ 로 늘어 모달 첫 오픈이 무거워지면 lazy mount(
intersection observer) 또는 background 스크린샷 캐싱으로 전환.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:31:34 +09:00
johngreen b7ebc69755 style(template-thumb): solid 컬러 → 와이어프레임 (테두리+투명 fill)
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m6s
기존: 컴포넌트마다 .55 alpha 솔리드 박스 → 화면 스크린샷처럼 보임
변경: 5px inset 패딩 + 각 블록은 .55 테두리 + .14 fill 의 와이어프레임 톤

- KIND_COLOR (rgba 문자열) → KIND_RGB (RGB 트리플 문자열) 로 변경
  → 같은 색을 테두리/배경 다른 알파로 동시 사용 가능
- thumb 안에 dash-lib-card-thumb-canvas wrapper 추가해 padding 적용
- block 은 border-box + border 1px transparent base, 색상은 inline 으로

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:16:37 +09:00
johngreen bad1010621 fix(template-thumb): 실제 스튜디오 저장 포맷(list:Array + screenResolution) 지원
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m7s
기존 fromV2 / fromV1 은 BlockV2.xPct 또는 TemplateComponent.order/row
가정이었는데, 실 운영 빌더(templateAdapter.saveTemplate) 는
list 가 컴포넌트 배열이고 각 요소가 position{x,y} + size{w,h} 절대 px,
공통 screenResolution 으로 정규화되는 구조였음.

fromStudio 추가:
- list 를 배열 / v2.1 layers / { components } 어느 모양이든 평탄화
- screenResolution 우선, 없으면 컴포넌트 bounding box 폴백
- url(v2-table-list 등)/componentType/widgetType 어느 키든 inferKind 로 분류

호출 순서: fromStudio → fromV2 → fromV1 (안전망).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:09:14 +09:00
johngreen df9a539017 feat(template): 라이브러리 카드에 wireframe 썸네일 표시
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m26s
템플릿 목록 카드의 정적 📋 아이콘을 실제 view 구조 기반의
미니 와이어프레임으로 교체. 사용자가 카드만 보고도 템플릿이
어떤 화면인지(테이블 위주 / 폼 위주 / 단순 버튼 등) 파악 가능.

- backend: getTemplateList SQL 에 VIEWS 컬럼 추가, list 응답 각
  row 의 views jsonb 를 객체로 파싱
- frontend: TemplateThumbnail 컴포넌트 신설 — v2(BlockV2.xPct/yPct
  /wPct/hPct) 정규화 좌표 우선, v1(order/row) 폴백, 컴포넌트
  종류별 색상(table=primary, form=cyan, button=pink)
- TemplateLibraryModal 카드 아이콘 자리 교체
- dashboard.css 에 .dash-lib-card-thumb / -block 스타일 추가
  (v5 토큰 준수 — solid + glow, blur 없음)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:00:10 +09:00
johngreen 59f230187d feat(layout): Tweaks 버튼 토글 동작 — 다시 누르면 닫힘
setSettingsOpen(true) → setSettingsOpen(v => !v) 로 변경.
열려 있을 때 한 번 더 누르면 SettingsModal 이 닫히고,
v5-hdr-icon.on 활성 상태도 자동 토글됨.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:59:51 +09:00
johngreen 9d11616761 fix(sidebar): 두 메뉴가 동시에 active 표시되던 버그 해결
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m5s
증상: AI 어시스턴트 → '에이전트 관리' 클릭 후 '멀티에이전트 워크스페이스'
이동하면 사이드바에 두 메뉴가 동시에 보라색 active 로 표시됨.

원인: isMenuActive 가 pathname 매칭과 activeTab 매칭을 OR 결합해서
이전 URL 메뉴 + 현재 활성 탭 메뉴 둘 다 true 반환.

수정: activeTab 이 있을 때는 그 탭 기준으로만 매칭. activeTab 이 없는
경우(/main 첫 진입 등)에만 pathname 으로 fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:30:00 +09:00
hjjeong 205428533d fix(rolesList): 직원 체크박스 클릭 시 이중 토글로 해제 안 되던 버그
증상: 권한있는/없는 직원 영역의 체크박스를 클릭하면 즉시 다시 해제됨
처럼 보이고 (사실은 두 번 토글됨), 체크 상태 자체가 유지 안 됨.
전체선택 버튼 (별개 핸들러) 만 정상 동작.

원인: <li onClick={토글}><Checkbox onCheckedChange={토글} /></li> 구조에서
체크박스 클릭 → onCheckedChange 트리거 (토글 1회) → 이벤트가 li 로 버블 →
li onClick 트리거 (토글 1회 더) → 합쳐서 2회 토글 = 원상복귀.

수정: Checkbox 에 onClick={(e) => e.stopPropagation()} 추가해서 li 로
버블 차단. li 빈 영역 클릭 시는 li onClick 만 동작, 체크박스 직접 클릭
시는 onCheckedChange 만 동작 — 둘 다 정확히 1회 토글.

권한있는 직원 + 권한없는 직원 두 곳 모두 수정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:11:39 +09:00
hjjeong b5a60d1c8b fix(role): getRoleNonMemberList OGNL test 의 inner-quote 컨벤션 위반 수정
증상: SUPER_ADMIN cross-tenant 모드에서 권한 그룹 클릭 시 workspace
500 — NumberFormatException: For input string: "TEST02"

원인: role.xml getRoleNonMemberList 의 if 가
    test="company_code != null and company_code != '' and company_code != '*'"
형태로 single-quote 안에 single-quote 가 박혀, OGNL 이 '*' 를 number 로
변환 시도하다 NumberFormatException. 단일 모드 (test01.localhost) 에서는
group.company_code 가 시드 잔여 '*' 라 if 가 false 로 fall-through 해
안 터졌고, cross-tenant 에서 명시적 "TEST02" 가 들어가니 평가 시도
→ 깨짐.

CLAUDE.md "OGNL test: 바깥 작은따옴표" 컨벤션대로 수정:
    test='company_code != null and company_code != "" and company_code != "*"'

검증: TEST02 의 '관리자' 그룹 workspace 호출 → HTTP 200, members: 0,
nonMembers: 2 (test02_admin, test02), menus: 0 정상 반환.

22, 334번 라인의 다른 if 문은 != '*' 가 없어 OGNL 평가가 numeric
coercion 까지 안 가므로 무수정 (회귀 위험 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:06:19 +09:00
johngreen 1d37b8d2ea fix(ai-agent): list 쿼리가 archived(soft-delete) 행을 기본 제외하도록
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m24s
증상: 에이전트 삭제 후에도 목록에 그대로 표시.
원인: softDelete 가 status='archived' 로만 변경하는데 list 쿼리가
status 필터 미지정 시 archived 도 모두 반환.

수정: status 명시 X 시 status != 'archived' 기본 적용. status 명시 시
그 값으로 정확 매칭.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 06:53:22 +09:00
johngreen 016442973e fix: ObjectMapper 에 JavaTimeModule 등록 (OffsetDateTime 직렬화)
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m26s
증상: POST /api/ai-agents 가 500 으로 응답.
원인: 커스텀 @Bean ObjectMapper 가 Spring Boot 자동 모듈 등록을 가로
막아 java.time.OffsetDateTime (AiAgent.created_at/updated_at 등) 직렬화
실패. INSERT 자체는 성공했으나 응답 변환에서 깨짐.

- JacksonConfig: registerModule(new JavaTimeModule()) +
  WRITE_DATES_AS_TIMESTAMPS=false (ISO-8601 문자열 출력)
- GlobalExceptionHandler: 직전 디버그 메시지 노출 원복

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 06:41:15 +09:00
johngreen faa77160af debug: GlobalExceptionHandler 에 root cause 임시 노출
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m31s
POST /api/ai-agents 가 500 만 반환하고 backend pod logs 직접 접근이
불가하여 진단 불가. 임시로 응답 message 에 [DEBUG] prefix + 예외
클래스명/메시지/cause 를 포함시켜 ULTRAQA 사이클로 root cause 파악.

⚠️ TEMPORARY — 안정화 후 다음 commit 에서 즉시 원복.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 06:34:48 +09:00
hjjeong 42f7ae35db fix(cross-tenant): rolesList 회사 dropdown 이 cross-tenant 모드에서 무시되던 문제
Phase 2 검증 중 발견. SUPER_ADMIN 이 admin 도메인에서 회사 필터를 "시연용회사2"
선택해도 TEST01 의 권한 그룹이 그대로 보이던 UI 버그.

원인: roleAPI.getList 의 cross-tenant 분기는 fan-out 으로 모든 회사 그룹을
모아 응답에 company_code 박아 돌려줌. 화면이 그 결과를 그대로 렌더하면서
selectedCompany dropdown 으로 client-side 필터링을 안 했음.

수정: rolesList 의 filteredRoleGroups 가 isSuperAdmin && selectedCompany!="all"
조건일 때 company_code 일치 행만 통과시키도록 한 줄 추가.

알려진 이슈 (별개, Phase 2 후속):
- GET /api/admin/cross-tenant/roles/{id}/workspace 가 500 떨어짐.
  단일 모드 GET /api/roles/{id}/workspace 도 group=null 빈 결과 — 사전
  존재 시드/스키마 mismatch 추정 (RoleService.getRoleWorkspace 의 5개 mapper
  중 하나가 깨짐). cross-tenant 분기 자체 (runInCompany) 는 정상.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:48:48 +09:00
johngreen 552cb50bbd chore(db): 중복 'AI 어시스턴트' 자식 메뉴 비활성 (V016)
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m28s
V014 가 AI_ASSISTANT_ROOT(부모) + AI_MENU_ASSISTANT(자식) 둘 다 동일
URL(/admin/aiAssistant)로 등록해 사이드바에 같은 이름 메뉴가 두 번
표시되던 증상 fix. V014 자체는 Flyway 체크섬 보호 때문에 수정하지
않고 후행 마이그레이션으로 soft-delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:19:28 +09:00
johngreen dc4508d5ea fix: AI 어시스턴트 entry — WorkspacePage 직접 렌더
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m5s
invyone SPA 탭 시스템은 AdminPageRenderer 가 cleanUrl 로 컴포넌트를
매칭하는 구조라, redirect/router.replace 로는 _URL 만 바뀌고 본문은
빈 컴포넌트를 그대로_ 렌더하는 문제가 있었다.

WorkspacePage 를 직접 import 해서 첫 자식 메뉴 클릭 시에도 워크스페이스
화면이 즉시 표시되도록 변경.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:04:31 +09:00
johngreen ecca02fbbf fix: AI 어시스턴트 entry page client-side redirect 로 변경
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m21s
기존 server-side redirect("/workspace") 가 invyone SPA 탭 시스템과
충돌해서 사이드바 'AI 어시스턴트' 자식 클릭 시 화이트 스크린 발생.

useRouter().replace() 의 client-side 라우팅으로 변경 — AdminPageRenderer
가 dynamic import 한 컴포넌트가 정상으로 mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:38:43 +09:00
hjjeong 7d9ec39b5d feat(cross-tenant): SUPER_ADMIN 의 회사별 권한관리 WRITE (Phase 2)
Phase 1(사용자관리) 패턴을 권한관리에 동일 적용. 권한 그룹 CRUD,
멤버 토글, 메뉴 권한 토글 모두 회사 컨텍스트 임시 전환 후 처리.

신규 백엔드
- crosstenant/CrossTenantRoleController.java
  /api/admin/cross-tenant/roles/** — 8개 endpoint
  · POST       — 권한 그룹 생성 (body.company_code 필수)
  · PUT  /{id} — 권한 그룹 수정 (body.company_code 필수)
  · DELETE /{id}?company_code= — 삭제
  · GET  /{id}/workspace?company_code= — 그룹 + 멤버 + 메뉴 통합 로드
  · GET  /menus/all?company_code= — 회사 메뉴 트리 (권한 설정용)
  · POST   /{id}/members/{userId}?company_code= — 멤버 1명 추가
  · DELETE /{id}/members/{userId}?company_code= — 멤버 1명 제거
  · PATCH  /{id}/menu-permissions/{menuObjid} — 토글
  CrossTenantExecutor 재사용. 기존 RoleController 무수정 (회귀 0).

  중요: @RequestAttribute("user_id") 가 토큰 없을 때 missing 에러로 500
  떨어지는 문제 — required=false 로 가드까지 안전하게 도달하도록.

프론트
- lib/api/role.ts — 7개 메서드(create/update/delete/getWorkspace/
  getAllMenus/addSingleMember/removeSingleMember/toggleMenuPermission)에
  isCrossTenantMode() 분기 + companyCode 인자 추가
- RoleFormModal — update 시 editingRole.company_code 같이 전달
- RoleDeleteModal — delete 시 role.company_code 같이 전달
- rolesList/page.tsx — loadWorkspace / addSingleMember / removeSingleMember /
  toggleMenuPermission 호출 시 selectedRole.company_code 전달

검증 (curl, SUPER_ADMIN 토큰):
- 토큰 없음 → 403 super_admin_required
- POST 권한 그룹 (TEST02) → 201, /roles fan-out 에 by={TEST01:1, TEST02:1}
- DELETE → 200, fan-out by={TEST01:1} 로 복귀

미구현 (Phase 2 후속, 별도 작업):
- 일괄 멤버 추가/제거/diff (PUT/POST /members)
- 메뉴 권한 일괄 설정 (PUT /menu-permissions)
- 사용자별 권한 그룹 조회

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:45:55 +09:00
hjjeong 4d19c31440 feat(cross-tenant): 부서 endpoint + UserFormModal 회사-우선 reorder
직전 Phase 1 의 후속 폴리시.

신규 백엔드
- crosstenant/CrossTenantDeptController.java
  GET /api/admin/cross-tenant/departments?company_code=TEST02
  단일 모드 GET /admin/departments 와 응답 형태 동일. company_code query param
  으로 명시된 회사 DB 컨텍스트로 임시 전환해서 부서 트리 반환.
  버그 수정: 메타 DB DEPT_INFO 시드 (qnc/COMPANY_7 등 다른 회사 부서) 가
  TEST02 선택 시에도 dropdown 에 섞여 보이던 문제 해결.

프론트
- lib/api/user.ts — getDepartmentList(companyCode) 가 isCrossTenantMode() 면
  /admin/cross-tenant/departments?company_code= 호출.
  cross-tenant 모드 + companyCode 미지정 → 빈 배열 반환 (회사 안 골랐는데
  메타 부서 보여주는 것 방지).

UserFormModal
- 회사 dropdown 을 폼 가장 위로 이동 — 사용자 ID 중복확인·부서 선택이
  모두 회사에 의존하므로 자연스러운 입력 순서
- SUPER_ADMIN 인데 회사 미선택 상태에선 사용자 ID input + 중복확인 버튼
  disable + placeholder "회사 먼저 선택"
- checkUserIdDuplicate 가드: 회사 미선택이면 "회사를 먼저 선택해주세요"
  (백엔드의 400 "company_code 가 비어있음" 보다 친절)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:38:30 +09:00
hjjeong a41f99c579 feat(cross-tenant): SUPER_ADMIN 의 회사별 사용자 WRITE (Phase 1)
지금까지 cross-tenant 는 READ 전용. admin 도메인에서 사용자 등록하면
JWT.company_code='*' 가 그대로 박혀 메타 DB 에 INSERT 되던 버그 해결.
이제 SUPER_ADMIN 이 폼의 "회사" 드롭다운에서 TEST01/TEST02 등 선택하면
그 회사 DB 에 정확히 INSERT.

신규 백엔드
- crosstenant/CrossTenantExecutor.java  — 회사 컨텍스트 임시 전환 헬퍼
  (company_code → db_name → ensureTenantPool → set → run → restore)
- crosstenant/CrossTenantUserController.java  — /api/admin/cross-tenant/users
  9개 endpoint (POST/PUT/DELETE/PATCH/with-dept/check-duplicate/단건/이력)
- mapper/provisioning.xml — resolveDbNameByCompanyCode (active 회사만)

기존 단일 회사 모드 (POST /admin/users 등) 무수정 — 회사 도메인
컨텍스트에서 회귀 0.

프론트
- lib/api/user.ts — createUser/updateUser/updateUserStatus/checkDuplicateUserId/
  saveUserWithDept 가 isCrossTenantMode() 면 새 endpoint + body.company_code 로 분기
- UserFormModal — checkDuplicateId 호출 시 formData.company_code 같이 전달
- useUserManagement — status toggle 시 row 의 company_code 같이 전달

검증 (curl, SUPER_ADMIN 토큰):
- 토큰 없음 → 403 super_admin_required
- company_code 없음 → 400 "company_code 가 비어있음"
- 잘못된 company_code → 400 "등록되지 않았거나 비활성 회사"
- check-duplicate: TEST01.test02_admin → not_dup, TEST02.test02_admin → dup ✓
- POST 사용자 → TEST02 USER_INFO +1, TEST01·메타 격리 ✓
- /users fan-out: by={'*':8, 'TEST01':1, 'TEST02':2}, hjtest_ct_001 in TEST02만 ✓
- DELETE → status=inactive (soft) ✓

미구현 (Phase 1 후속):
- 부서 dropdown (cross-tenant department endpoint 별도 필요)
- 비밀번호 초기화 모달의 cross-tenant 분기 (UserPasswordResetModal)
- Phase 2 권한관리 (별도 커밋)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:22:52 +09:00
hjjeong cdc55dfd48 feat(cross-tenant): truncated/failed 안내 배너 (READ 트랙 마무리)
SUPER_ADMIN cross-tenant 모드에서 회사당 cap 200 에 걸리거나 한 회사
조회 실패 시 화면 상단에 안내 배너 노출. 아무 메타 없으면 자리 안 잡음.

신규
- components/common/CrossTenantBanner.tsx — amber(truncated) + red(failed)
  v5 토큰 (surface-solid + glow-sm) 기반 솔리드 배너. blur 안 씀

API 클라이언트 4개에 cross_tenant_meta 노출
- lib/api/user.ts        — userAPI.getList 응답에 cross_tenant_meta 추가
- lib/api/role.ts        — roleAPI.getList 동일
- lib/api/batch.ts       — BatchAPI.getBatchConfigs 동일
- lib/api/multilang.ts   — getLangKeys 동일 (i18nList 페이지는 아직 직접
  호출 패턴이라 자동 적용 X — 후속에서 페이지를 getLangKeys 로 통일하면 동작)

페이지 마운트 (3개)
- userMng/userMngList — useUserManagement hook 에 crossTenantMeta state 추가
- userMng/rolesList   — loadRoleGroups 에서 메타 set
- automaticMng/batchmngList — loadBatchConfigs 에서 메타 set
- systemMng/i18nList — 스킵 (cross-tenant aggregation 미적용 상태, 별도 작업)

설계서 §11 검증 (직전 §11.2 부분 실패 시뮬) 결과: failed 배너가
header X-CrossTenant-Failed 와 동일 정보로 화면에 노출됨.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:14:48 +09:00
DDD1542 517c42b5cb fix: 피벗 column fields 필터 아이콘 가로 정렬 (세로 일렬 → flex-row + wrap)
PivotView line 1694 의 column header 좌측 셀 안 column fields 필터 아이콘
4개가 'flex flex-col' 로 세로 일렬로 표시되던 문제. 옛 PivotGridComponent
디자인을 통째 흡수한 결과 (T3b) — 가로로 변경.

flex flex-col gap-0.5 → flex flex-row flex-wrap items-center justify-center
gap-1

column fields 가 많아지면 자동 wrap, 한 줄에 모이지 않으면 다음 줄.
세로 길게 늘어지는 회귀 차단.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:58:09 +09:00
DDD1542 2d11d222db fix: 피벗 ConfigPanel — FeatureChipGrid 로 교체 + PivotView 빈 그리드 회피
이전 commit (3ed53a670, 57ffbcbbc) 의 두 잘못 수정.

1. ConfigPanel — CPSwitch 8개 → FeatureChipGrid 1개

   CP 시스템에 다중 boolean 토글 묶음용 FeatureChipGrid 가 이미 있는데
   (CPExtras.tsx:208, InvLegacyDivider/Button/Text/InvRepeater 가 사용),
   CPRow + CPSwitch 8개로 따로 만든 게 잘못. cp 시스템 본래 패턴 따름.

   - 8 토글 (chartEnabled / fieldChooserEnabled / rowGrandTotals /
     columnGrandTotals / mergeCells / alternateRowColors / exportExcel /
     exportPdf) 을 평면 key 로 정의
   - source = nested config 에서 평면 boolean 객체 변환
   - onToggle = 평면 key 받아 nested patch (switch 분기)
   - 각 chip 에 desc 추가 (hover tooltip, FeatureChipGrid 가 portal 로 표시)

2. PivotView — data 영역 0개면 안내 (빈 0 그리드 회피)

   hasActiveFields 분기를 강화. 기존: row/column/data 중 하나만 있어도
   true → row 영역에만 컬럼이 들어간 옛 잘못된 매핑이 빈 0 그리드를 표시
   하는 회귀 (Image #6).

   변경: data 영역 컬럼 ≥1 이어야 의미있는 피벗. data 0개면 "필드를
   배치하세요" 안내 + FieldChooser 버튼 fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:54:30 +09:00
DDD1542 57ffbcbbc7 fix: 피벗 ConfigPanel default 값 모두 false (안일한 true → 빈 그리드 회귀 방지)
직전 commit (3ed53a670) 의 메타 토글 4종이 default true 로 잡혀 있었음.
빈 row/column 영역에서 행/열 총계만 활성화되어 사용자가 새 피벗 배치 시
무의미한 0 그리드가 보이는 회귀 발생.

default true → false 변경:
- 필드 선택기 (pivotFieldChooser.enabled)
- 행 총계 (pivotTotals.showRowGrandTotals)
- 열 총계 (pivotTotals.showColumnGrandTotals)
- 행 교대 색 (pivotStyle.alternateRowColors)

PivotView 본체 (line 1048~1085) 가 활성 필드 0 일 때 "필드를 배치하세요"
안내 + FieldChooser 버튼을 자연스럽게 표시함. 신규 피벗 배치 시 사용자가
명시적으로 옵션을 켜야 효과 발현 (솔루션 정의 단계 패턴).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:44:54 +09:00
DDD1542 3ed53a6708 fix: 피벗 ConfigPanel — 컬럼별 area dropdown 폐기, 메타 옵션 8종으로 교체
T4 (3e6bce70d) 의 ④ 피벗 설정이 빌더에서 row/column/data/filter 를 컬럼별
dropdown 으로 미리 정해버려 피벗의 본질 (사용자 자유 분석) 을 흐림. 본체
(FieldPanel / FieldChooser) 가 영역 배치 담당하도록 일관성 정리 (Excel 피벗
패턴).

삭제 — "사번 [row▼] / 사용자ID [row▼] ..." 컬럼별 area dropdown 매핑 67줄

신규 ④ 피벗 설정 — 메타 토글 8종 (CPRow + CPSwitch)
- 차트 표시 (pivotChart.enabled)
- 필드 선택기 (pivotFieldChooser.enabled)
- 행 총계 (pivotTotals.showRowGrandTotals)
- 열 총계 (pivotTotals.showColumnGrandTotals)
- 셀 병합 (pivotStyle.mergeCells)
- 행 교대 색 (pivotStyle.alternateRowColors)
- 엑셀 내보내기 (pivotExportConfig.excel)
- PDF 내보내기 (pivotExportConfig.pdf)

빌더 = "피벗에 어떤 도구/표시를 켤지" 만 결정.
본체 = row/column/data/filter 영역 배치 + drag-and-drop + drilldown + filter
+ chart 등 분석 인터랙션 담당 (이미 T3b 통째 흡수됨).

다른 viewMode (grouped: groupBy / card: cardColumnMapping) 는 본체 분석 UI
가 없으므로 ConfigPanel 이 핵심 — pivot 만 메타 패턴으로 가는 게 맞다
(Codex GO 판정).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:33:13 +09:00
DDD1542 3003a056d9 refactor: v2-card-display CSS rule + 'card-display' alias 제거 (T5 잔여)
Codex 재검증에서 발견된 2건 정리.

withContainerQuery.css: '@container v2-card-display' 미디어 쿼리 룰 (10줄)
삭제. v2-card-display 컴포넌트 폐기 (e44ba2953) 시 빠진 css 정리.

templateMigrate.ts: 'card-display': 'stats' 매핑 라인 삭제. v2-card-display
의 v2 prefix 없는 legacy alias. 폐기 컴포넌트의 마이그 매핑이라 dead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:43:09 +09:00
DDD1542 e44ba2953a refactor: v2-table-grouped/v2-pivot-grid/v2-card-display 일괄 폐기 (T5)
T3 (본체) + T4 (ConfigPanel) 흡수 완료에 따라 옛 별도 컴포넌트 폐기.
v2-split-panel-layout 은 별도 컨테이너로 유지 (Codex 권고).

폴더 5개 삭제
- v2-table-grouped/ (537 + ConfigPanel)
- v2-pivot-grid/ (1963 + utils + components + hooks + ConfigPanel)
- v2-card-display/ (1314 + ConfigPanel)
- pivot-grid/ (legacy)
- card-display/ (legacy)

ConfigPanel 3개 삭제
- V2TableGroupedConfigPanel.tsx
- V2PivotGridConfigPanel.tsx
- V2CardDisplayConfigPanel.tsx

레지스트리 / alias / hidden 정리
- lib/registry/components/index.ts: 5개 import 라인 제거 (renderer 자동 등록 폐기)
- ComponentsPanel.tsx: hidden 목록의 v2-card-display ("→ stats" 잘못된 매핑) /
  v2-table-grouped / v2-pivot-grid / pivot-grid / card-display 모두 제거
- DynamicComponentRenderer.tsx LEGACY_TO_UNIFIED: v2-card-display "→stats"
  (잘못) / v2-table-grouped / v2-pivot-grid / card-display alias 제거
- getComponentConfigPanel.tsx: 4개 dynamic import + alias 제거
- templateMigrate.ts: 3 매핑 제거
- componentConfig.ts: v2PivotGridOverridesSchema, v2CardDisplayOverridesSchema
  + 등록 + defaults 제거

이벤트 dead code
- types/component-events.ts: RefreshCardDisplayDetail / REFRESH_CARD_DISPLAY
  이벤트 (사용처 0건 — v2-card-display 전용) 제거

ScreenDesigner
- 4058 의 isCardDisplay 분기 (66.67% 그리드 특수 처리) 제거
- 4098 의 gridColumnsRatioMap 의 "card-display" 항목 제거

검증
- npx tsc --noEmit 우리 작업 파일 새 에러 0
- v2-table-grouped/v2-pivot-grid/v2-card-display/RefreshCardDisplay 잔존
  grep — 의도적 주석 5곳만 (폐기 흔적 설명)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:38:05 +09:00
DDD1542 3e6bce70d1 feat: InvTableConfigPanel pivot/card 옵션 UI (T4)
displayMode 분기 옵션 채움. T3b 에서 흡수한 PivotView + CardView 가
실제로 동작하도록 ConfigPanel UI 노출. cp 프리미티브만 사용.

pivot 분기 (placeholder 제거)
- columns 별 영역 매핑 (none/row/column/data/filter) — CPSelect
- data 영역인 경우 집계 함수 (sum/count/avg/min/max/countDistinct) — CPSelect
- pivotFields[] 배열로 자동 변환 (area="none" 선택 시 항목 제거)
- 컬럼 미로드 시 안내 Hint

card 분기 (신규)
- 그리드: cardsPerRow (1~10), cardSpacing (0~64px) — CPNumber
- 표시 영역 (CPGroup defaultOpen): 제목/부제/설명/이미지 표시 토글 +
  이미지 위치 (top/left/right) + 이미지 크기 (small/medium/large)
- 컬럼 매핑 (CPGroup defaultOpen): titleColumn/subtitleColumn/
  descriptionColumn/imageColumn — CPSelect from columns
- 액션 버튼 (CPGroup defaultOpen=false): showActions 토글 + showView/Edit/
  DeleteButton 조건부 노출

cp-panel-standard 룰 준수 (CPSection > CPGroup > CPRow > CPSelect/Switch
/Segment/Number, 3-depth 미만, 카드형 외곽 X).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:31:45 +09:00
DDD1542 49b4cdf562 feat: 테이블 pivot 모드 본체 통째 흡수 (T3b)
5 viewMode 통합 세번째 단계 — Codex 권고 (Pivot 통째 흡수, 회귀 위험
차단) 그대로 적용. v2-pivot-grid 의 본체 + utils + components + hooks +
보조 타입을 모두 table/ 하위로 이전. 리팩토링 X (V2Date picker 패턴 동일).

table/types.ts 보조 타입 흡수
- PivotResult, PivotHeaderNode, PivotFlatRow, PivotFlatColumn, PivotCellValue,
  PivotCellData, PivotGridState, PivotGridProps
- PivotDataSourceType, PivotFilterCondition, PivotJoinConfig,
  PivotDataSourceConfig, PivotTotalsConfig, PivotFieldChooserConfig,
  PivotChartConfig, PivotConditionalFormatRule, PivotStyleConfig,
  PivotExportConfig
- PivotFieldConfig 에 filterValues / filterType / isCalculated /
  calculateFormula 누락 속성 추가
- TableConfig 에 pivot 보조 키 (pivotTotals / pivotStyle /
  pivotFieldChooser / pivotChart / pivotExportConfig)

table/utils/pivot/ 4개 파일 이전 (1505줄)
- pivotEngine.ts (812) — processPivotData / pathToKey / 헤더 트리 / 매트릭스 /
  10종 displayMode (runningTotal, percentDifferenceFromPrevious 등)
- aggregation.ts (180) — sum/count/avg/min/max/countDistinct + 포맷
- conditionalFormat.ts (311) — colorScale/dataBar/iconSet/cellValue 4 종
- exportExcel.ts (202) — Excel 내보내기 (xlsx)
- 옛 prefix(AggregationType 등) → Pivot prefix 일괄 정리

table/internals/pivot/components/ 7개 파일 이전 (2347줄)
- ContextMenu / DrillDownModal / FieldChooser / FieldPanel / FilterPopup /
  PivotChart / index

table/internals/pivot/hooks/ 3개 파일 이전 (570줄)
- usePivotState / useVirtualScroll / index

table/views/PivotView.tsx 신규 (PivotGridComponent.tsx 1963줄 통째 흡수)
- import 경로 일괄 정정 (../../types → ../types, ./utils → ../utils/pivot,
  ./components → ../internals/pivot/components, ./hooks →
  ../internals/pivot/hooks)
- 컴포넌트 이름 PivotGridComponent → PivotView
- 본체 로직 그대로 (리팩토링 X)

TableComponent.switch
- pivot 분기 placeholder 제거 → PivotView 호출
- DOM filter 에 pivotTotals/pivotStyle/pivotFieldChooser/pivotChart/
  pivotExportConfig 추가

13 files, +5400+ insertions. v2-pivot-grid/ 폴더 자체는 Phase T5 dead code
일괄 삭제에서 정리 예정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:23:39 +09:00
johngreen 7c57c69f84 fix: AiKnowledgeController 추가 (Epic C 누락분)
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m33s
frontend 가 /api/ai-knowledge 호출하는데 backend 에 컨트롤러가 없어
"지식 파일 목록 로드 실패" 토스트가 나던 증상 fix.

서비스/매퍼/모델/DTO 는 이미 있고 컨트롤러만 누락이었음. 다른
ai 컨트롤러 (AiAgentController) 와 동일 패턴으로 GET/POST/PUT/DELETE
+ 멀티테넌시(company_code 필터, SUPER_ADMIN 예외) 적용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:52:20 +09:00
DDD1542 a74dff4fa2 feat: 테이블 grouped/card 모드 본체 통합 (T3a)
5 viewMode 통합 두번째 단계 — TableComponent.switch 에 grouped/card/pivot
case 분기 추가. 별도 v2-* 컴포넌트 호출 X. table/views/* 분리.

GroupedView (신규, table/views/GroupedView.tsx)
- config.groupBy 기준으로 데이터를 그룹화해 펼침/접힘 단위로 렌더
- 그룹 헤더에 그룹 키 + 행 개수 표시. ChevronRight/Down 토글
- groupBy 미설정 시 안내 메시지

CardView (신규, table/views/CardView.tsx)
- config.cardsPerRow 으로 그리드, cardSpacing 으로 간격
- cardColumnMapping (titleColumn / subtitleColumn / descriptionColumn /
  imageColumn / displayColumns / actionColumns) 으로 데이터 → 카드 매핑
- cardStyle (showTitle/Subtitle/Description/Image, imagePosition,
  imageSize, showActions, showView/Edit/DeleteButton)

PivotView (placeholder, T3b 에서 통째 흡수 예정)
- v2-pivot-grid/PivotGridComponent (1963) + utils/pivotEngine.ts (700) +
  보조 타입 통째 흡수가 다음 단계
- 현재는 설정된 필드/행 수만 표시하는 placeholder

TableComponent.switch
- case "grouped" / "card" / "pivot" 신규 분기. case "split"/"table"/default 유지
- DOM filter 에 cardsPerRow/cardSpacing/cardStyle/cardColumnMapping/pivotFields 추가

빌드 OK. grouped/card 모드 사용자 선택 시 정상 렌더 (config 옵션 없으면 안내).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:49:32 +09:00
DDD1542 442f641305 feat: 테이블 card 모드 옵션 + pivot 풍부한 필드 타입 추가 (T2 인프라)
5 viewMode 통합 (table/split/grouped/pivot/card) 의 첫 단계. types.ts 만.
다음 단계에서 본체 흡수 + ConfigPanel UI + dead code 정리.

card 모드 옵션 (TableConfig 신규)
- cardsPerRow, cardSpacing
- cardStyle: showTitle/Subtitle/Description/Image/imagePosition/Size/showActions
- cardColumnMapping: titleColumn/subtitleColumn/descriptionColumn/imageColumn/
  displayColumns/actionColumns

pivot 모드 풍부한 필드 (TableConfig 의 pivotFields, PivotFieldConfig 신규)
- area: row/column/data/filter
- summaryType (sum/count/avg/min/max/countDistinct)
- summaryDisplayMode 10종 (absoluteValue / percentOfColumnTotal / runningTotal 등)
- groupInterval (year/quarter/month/week/day)
- 기존 단순 pivotRows/pivotColumns/pivotValues 도 호환 유지

Codex 솔루션 정의 단계 GO + 단계별 commit 권고.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:44:39 +09:00
DDD1542 5f945363b2 refactor: V2Date 일괄 폐기 + InvField type=date 통일
V2DateConfigPanel(262줄) + V2Date 본체(1046줄) + V2DateConfig 타입
+ lib/registry/components/v2-date/ + DynamicComponentRenderer 의
LEGACY alias 모두 삭제. 솔루션 정의 단계 정확한 1안.

데이터 모델
- FieldType / InputFieldType union 에 time, daterange 2종 추가하여 12종 확장
- canonical 키: dateFormat / minDate / maxDate / showToday / weekStart /
  dateDefault / rangePresets / maxRangeDays
- 옛 v2-date 의 snake_case (min_date / max_date / show_today) 와 dateType /
  type / range / format 키 충돌 종결

런타임 (InputComponent + pickers.tsx)
- V2Date 본체에 있던 SingleDatePicker / DateTimePicker / TimePicker /
  RangeDatePicker 4 picker 를 input/pickers.tsx 로 통합
- InputComponent 의 case "date"/"datetime" 의 native input 분기를 datepicker
  로 교체하고 case "time"/"daterange" 신규 추가
- type 결정 로직에 inputType prop 인식 (DB input_type 매핑) → date/time
  입력이 text 로 fallback 되던 silent breakage 해결

FC 계층
- FieldRenderer / CellRenderer / FcSearch 에 time / daterange 분기 추가
- TimeField, DateRangeField 신규 컴포넌트
- adapters.normalizeType allowed 배열 확장

ConfigPanel
- InvFieldConfigPanel.DateOptions 에 showToday CPRow + CPSwitch 신규
- 옛 호환 코드 (showSeconds:ss 보정 / dateType-format 격상 등) 모두 제거
- InvInputConfigPanel.TYPE_OPTIONS / 날짜 옵션 분기에 time/daterange 추가

dead code 삭제 14곳 + 잔존 정리
- V2Date / V2DateConfigPanel / V2DateConfig / lib/registry/v2-date/ 폴더
- LEGACY_TO_UNIFIED / CONFIG_PANEL_MAP / CONFIG_PANEL_ALIAS / register /
  V2PropertiesPanel hardcoded require / config-panels barrel / hidden 목록
- componentConfig 스키마, templateMigrate, webTypeMapping, DynamicConfigPanel
- withContainerQuery.css 의 v2-date 컨테이너 룰
- db/migrations/so_modal_layout(_kr).json 의 v2-date → input + type=date

37 files, +287 / -854.

Codex GO 판정 기준 (2회 NO-GO 후 FC 계층 / inputType prop / FieldType union /
잔존 v2-date / console.log 모두 처리 후 GO).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:22:55 +09:00
johngreen 2d938ea45b ci: frontend cache-bust + 진단에 deploy 이미지 표시
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m52s
빌드 컨텍스트 변경에도 운영 frontend chunk 에 신규 라우트가 들어가지
않는 증상 발견. Kaniko/Docker layer cache 가 npm run build 단계를
잘못된 시점에 hit 하는 것으로 의심. GIT_SHA build-arg 를 npm run build
직전에 주입해 매 commit 마다 그 layer 부터 강제 invalidate.

또 진단 step 에 deploy 이미지 tag 표시 추가 — 새 image 로 갱신
됐는지 즉시 확인 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:47:17 +09:00
hjjeong 280e25a4df notes(hjjeong): cross-tenant §11.2 부분 실패 시뮬레이션 검증 (2026-04-29)
테넌트 DB 만 만져도 됨 — 메타 DB 무수정.
TEST02 의 USER_INFO 를 임시 RENAME 해서 SELECT 실패 유도 → fan-out
호출 → 즉시 롤백. 메타 DB·다른 테이블 일체 영향 없음.

결과:
- RENAME 후 /users → HTTP 200, header X-CrossTenant-Failed: TEST02
  total=9 q=2 failed=1 by={'*':8, 'TEST01':1}  (TEST02 0)
- 롤백 후 /users → total=10 failed=0 by 원복

검증된 항목:
- fail-open: 한 회사 실패해도 전체 응답 200
- 회사 격리: TEST01·메타 행 영향 없음
- companies_failed: 1 + failed_company_codes: ["TEST02"]
- 응답 헤더 X-CrossTenant-Failed: TEST02

문서 갱신:
- 설계 27.md §11.2:  + 실행로그 §9.4 링크
- 실행 28.md §9.4 신설 — 시뮬레이션 SQL + 결과 + 검증 항목 5개
- 실행 28.md §9.5 — 남은 시나리오 (§11.4 락 비획득 / §11.5 캐시 N/A)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:42:12 +09:00
hjjeong 568eb14503 notes(hjjeong): cross-tenant fan-out 2회사 머지 검증 결과 (2026-04-29)
설계서 §11.1 (행복 경로) 의 첫 다회사 실증.
SUPER_ADMIN 토큰으로 /_active-companies + 4개 fan-out 엔드포인트 직접 curl.

검증 결과 (TEST01 + TEST02 둘 다 active):
- /users      total=10  q=2  failed=0  by={'*':8, 'TEST01':1, 'TEST02':1}
- /roles      total=1   q=2  failed=0  by={'TEST01':1}
- /batches    total=14  q=2  failed=0  by={'TEST01':7, 'TEST02':7}  ← 균등 머지
- /lang-keys  total=0   q=2  failed=0  by={}                          ← 회사 DB 시드 없음

발견:
- /users 의 '*' 8행은 버그 아님 — listUsers 만 includeMeta=true 호출,
  메타 DB SUPER_ADMIN 들을 company_code='*' 로 prepend (의도된 설계).
- runFanOut 의 마지막 boolean 은 wrapSearchWithPercent 일 뿐 includeMeta 아님
  (시그니처 분리). roles/batches/lang-keys 는 모두 includeMeta=false.
- /lang-keys 0건은 회사 DB MULTI_LANG_KEY_MASTER 가 비어있는 것 (failed=0).
  이전 §3 의 "TEST01: 646건" 은 시점/컨텍스트 다른 측정으로 추정.

문서 갱신:
- 설계 27.md §11: 11.1 , 11.3 fan-out 검증 추가 인용
- 실행 28.md §6 #3, §8 활성 회사 TEST02 추가
- 실행 28.md §9 신설 — 결과 표 + 해석 + 검증된 항목 + 미검증 + 재현용 명령

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:27:53 +09:00
DDD1542 e347a75953 refactor: ConfigPanel hook/helper 추출 + IconPicker cp+Portal
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m32s
- useDbTables: search/table/stats 의 DB 테이블 로드 hook (3 패널 중복 제거)
- TableConnectSection + AutoLoadButton: search/table 의 테이블 연결 섹션 + 자동 로드 버튼
- row-helpers: RowNumberBadge / RowExpandChevron / RowDeleteBtn (4 패널 dense list helper)
- IconPicker: shadcn 톤 -> cp 톤 (28px 트리거, focus glow, cp 변수)
- IconPicker popover: React Portal + position:fixed (부모 overflow:hidden 우회)
- input X버튼은 hoverBg={false} 로 silent visual change 원복

Codex (GPT-5.5) 와 매 단계 교차검증 후 진행
미완 후속 사항 (auto-flip / 외부 클릭 닫기 / z-index 표준화 등) 노트에 기록

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:25:06 +09:00
hjjeong a3c74f926c notes(hjjeong): cross-tenant 27/28.md 현재 상태 반영 (2026-04-29)
설계 27.md
- 상단 최종 갱신 + 실행 로그 링크 + Phase A/B/C 완료 배너
- §10 단계별 체크리스트: A 4/5, B 3/4, C 3/14, 페이지네이션 cap ✓, D 미진행, E 일부
- §11 검증 시나리오: 5개 시나리오 상태 표 + TEST01 실 검증 결과 인용
- §14 다음 세션 진입 시: 즉시 가능한 4개 작업 우선순위 (TEST02 fan-out 검증 1순위)
- gbpark/ 의 2026-04-24 두 문서 참조를 ../gbpark/ 로 보정

실행 로그 28.md
- 상단 최종 갱신 + 핸드오프 문서 참조
- TL;DR: 푸시·커밋 안 함 → 3 커밋 푸시 완료, TEST02 활성 명기
- §4.4 신규 — *.localhost 라우팅이 같은 커밋 배치에 묶인 사실 + 2026-04-29 검증 결과
- §5: 워킹트리 → 3개 커밋 분배 의도 표
- §6: 권장 단계 8개 → 9개 (Gitea PR 추가), 각 항목 /🟡// 표기

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:50:41 +09:00
hjjeong 36d6ad508d notes: hjjeong 작성 MD 3개를 notes/hjjeong/ 으로 이동 + 현재 상태 반영
CLAUDE.md "notes/{git-user}/{date}-{slug}.md" 규약대로 정리.
git config user.name=hjjeong 인데 gbpark/ 에 쌓이고 있던 3개 분리.

- 2026-04-27-cross-tenant-admin-aggregation.md (설계, 내용 갱신: Phase A/B/C 체크리스트, §11 검증 시나리오 실 결과 병기, §14 다음 단계)
- 2026-04-28-cross-tenant-execution-log.md (실행 로그, 내용 갱신: 커밋·푸시 상태, §4.4 *.localhost 같은 배치 묶음 기재, §5 커밋 분배 의도, §6 권장 단계 /🟡// 표시)
- 2026-04-28-localhost-tenant-routing-handoff.md (순수 이동)

cross-tenant 27.md 의 같은 폴더 참조였던 2026-04-24 두 문서는
gbpark/ 에 그대로 남아 ../gbpark/ 로 경로 보정. 외부 참조(../../docs,
../../backend-spring 등) 는 깊이 동일해 무수정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:50:08 +09:00
johngreen 22d073f563 SecurityConfig: Spring Security 6 호환 필터 등록 순서 수정
Build & Deploy to K8s / build-and-deploy (push) Successful in 1m0s
기존 코드는 addFilterBefore(AiApi, JwtAuthenticationFilter.class) 가
jwt 등록 전에 호출되어 'JwtAuthenticationFilter does not have a
registered order' 예외 발생. Spring Security 6 부터 anchor 는 _이미
등록된_ 필터여야 함.

수정: jwt 를 UsernamePasswordAuthenticationFilter 기준으로 가장 먼저
등록 → JwtAuthenticationFilter.class 가 known map 에 추가됨 →
이후 SubdomainResolver/AiApiKey/TenantConsistency/ForcePw 가 jwt 를
anchor 로 정상 참조.

실행 순서는 동일: SubdomainResolver → AiApiKey → Jwt → TenantConsistency
→ ForcePw → UsernamePasswordAuthenticationFilter

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:35:08 +09:00
johngreen 7e3e4078d0 flyway: out-of-order=true 안전망 추가
Build & Deploy to K8s / build-and-deploy (push) Failing after 3m34s
운영 DB flyway_schema_history 가 baseline + V015 만 가진 상태에서
V001~V014 가 누락되어 있던 사고를 정리한 후, 향후 비슷한 누락이
있어도 자동 복구 가능하도록 out-of-order 허용.

전제: 운영 DB 의 V015 row 는 별도로 DELETE 처리하여 baseline 만
남겨둔 상태. 다음 부팅에서 V001~V015 가 차례로 적용된다.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:28:05 +09:00
johngreen b2ac4a08bf ci: 진단 step 에서 모든 backend-spring pod 로그 순회
Build & Deploy to K8s / build-and-deploy (push) Failing after 3m8s
deployment/<name> selector 는 활성 ReplicaSet 만 봐서 새 ReplicaSet 의
CrashLoopBackOff pod 로그를 놓친다. label selector 로 모든 pod 를
순회하며 current + previous 로그 출력.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:05:05 +09:00
johngreen 6965dfdd57 ci: 배포 실패 시 pod 로그 / k8s events 자동 캡처
Build & Deploy to K8s / build-and-deploy (push) Failing after 3m22s
backend-spring rollout 이 180초 timeout 으로 실패할 때, Gitea Actions
로그에는 timeout 메시지만 나오고 정작 Spring Boot 부팅 단계의 진짜
에러는 pod 안 stdout 에 갇혀 있어서 디버깅 불가.

- if: failure() 조건으로 마지막에 Diagnose step 추가
- kubectl get pods, describe, logs (current + previous), events 출력
- frontend 도 참고용 200줄 출력
- 모든 명령 || true 로 감싸서 진단 자체가 실패해도 다음 단계 진행

이 step 은 진짜 원인 파악되고 안정화되면 제거 예정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:55:17 +09:00
DDD1542 d0978f9398 docs: 다음 세션 todos — 리팩토링 본체 + 잔여 V2 마이그 가이드
useDbTables hook / TableConnectSection / DenseListRow 추출 우선순위와
주의사항 (Codex 권장) + dead code 정리 방식 + IconPicker 공용 마이그.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:58:42 +09:00
DDD1542 a8ded6455d refactor: ConfigPanel Inv 네이밍 통합 + legacy 패널 분리 + input cp 마이그
11 패널 일괄 Inv* prefix 통일:
- 통합 (lib/registry/components/X/): button / container / divider / search /
  stats / table / title / input → Inv*ConfigPanel
- frontend/components/v2/config-panels/V2FieldConfigPanel → InvFieldConfigPanel
- 옛 v2-* hidden 호환 → InvLegacy{Divider,Text,Button}ConfigPanel

input 통합 컴포넌트 cp 톤 신규 작성 (InvInputConfigPanel):
- 277줄 옛 디자인 → CPVisualGrid 10칸 type 카드 + 타입별 옵션 + FeatureChipGrid

getComponentConfigPanel.tsx 버그 수정 (Codex 검토):
- "stats" key 중복 제거 (옛 StatsCardConfigPanel 이 통합 stats 덮던 silent bug)
- ALIAS 에서 v2-button-primary/v2-divider-line/v2-text-display 제외
  (옵션 B 일관성 — 옛 hidden 컴포넌트는 InvLegacy 패널 사용)
- MAP 의 해당 키를 InvLegacy* 로 직접 매핑

호출처 일괄 갱신:
- 각 통합 컴포넌트의 index.ts 7개 (import / config_panel / re-export)
- v2-input/v2-select/v2-divider-line/v2-text-display/v2-button-primary
  의 index.ts (config_panel 매핑)
- V2PropertiesPanel.tsx 의 require pattern (v2-input/v2-select)

검증: tsc 우리 영역 0건 / V2FieldConfigPanel 잔재 0건 / 기존 path 잔재 0건

다음 세션: useDbTables hook 추출 + 잔여 V2* cp 마이그 + dead code 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:57:57 +09:00
hjjeong a6be4f2efe 사용자관리 테이블 자체 스크롤 — viewport 기반 max-height 로 강제
scrollContainer 모드의 flex 기반 height 계산이 shadcn Table 의 내부 wrapper
(data-slot="table-container", overflow-x-auto) 와 충돌해 잘림 발생.
flex-1/min-h-0 대신 max-h-[calc(100vh-280px)] + overflow-y-auto 로 강제.
hack 성이지만 안정적으로 동작.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:52:30 +09:00
hjjeong 383b837a60 dev *.localhost 테넌트 라우팅 + direnv/Java 21 dev 환경 정비
SubdomainResolverFilter.extractSubdomain() 가 2파트 {sub}.localhost 호스트도
첫 파트로 파싱 (RFC 6761). 운영 3파트 경로 무변경. 단위 테스트 8건 추가.

frontend/lib/api/client.ts 에 *.localhost (bare 제외) 직접 호출 분기 1-b 추가.
8081 로 직결해 Host 헤더 보존. subdomain.ts 도 동일 규칙 적용.

application.yml CORS 디폴트에 http://*.localhost:[*] 패턴 추가.
docs/MULTI_TENANCY_ARCHITECTURE.md §4.2 (실행 모드 A/B) + §6 (1-b 분기) 갱신.
.gitignore 에 .envrc/.direnv 추가, .java-version=21 명시 (jenv 호환).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:52:30 +09:00
hjjeong e16fb16987 어드민 cross-tenant 집계 (SUPER_ADMIN) + 사용자관리 자체 스크롤
SUPER_ADMIN 토큰(company_code=*)이면 등록 회사들 DB 를 순회해 결과를
집계해 돌려주는 CrossTenantAggregator/Controller 추가. 사용자/권한그룹/
배치/다국어 키 4개 도메인의 list API 가 cross-tenant 모드 지원.

UserTable + ResponsiveDataView 에 compact/scrollContainer prop 추가.
페이지 헤더/툴바/페이지네이션은 고정, 테이블만 자체 스크롤.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:52:30 +09:00
DDD1542 8b8186d1c0 INVYONE Studio Config Panel — 좌측 팔레트 10 컴포넌트 cp 톤 1차 마이그
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m7s
신규 / 마이그된 패널:
- 데이터 조회/선택: InvDataConfigPanel (wrapper) + InvRepeaterConfigPanel (V2Repeater 2029줄 폐기 → cp 신규 본체)
- 통합 컴포넌트 7개 in-place cp: button / container / divider / search / stats / table / title
- 옛 v2-* hidden 호환: InvDividerConfigPanel / InvTextConfigPanel / InvButtonConfigPanel

cp 인프라:
- _shared/cp/CPExtras.tsx 신규 — 8 공용 컴포넌트
  (Hint / DimText / InlineLoader / SectionLabel / CpChip / ChipPickerBox /
   FeatureChipGrid / CPVisualGrid)
- FeatureChipGrid: portal tooltip + Stripe 패턴 group cooldown (500ms delay → 즉시 전환)
- CPVisualGrid: 시각 미리보기 카드 (두께/색/사이즈/시맨틱 등 진짜 의미 preview)
- cp.css 다크 luminance 분리 + 키프레임 / CPPrimitives CPSelect 자동 검색·정렬

옛 V2 패널 4개 삭제:
- V2RepeaterConfigPanel (2029) / V2ButtonConfigPanel (2212) /
  V2DividerLineConfigPanel (236) / V2TextDisplayConfigPanel (304)

설계 노트 5개 추가 (notes/gbpark/2026-04-28-*.md):
- cp-panel-standard / cp-panel-day2-html-v4-match / invdata-inventory /
  inv-repeater-redesign / inv-naming-consolidation

다음: Inv* 일괄 네이밍 통합 + dead code 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:50 +09:00
johngreen 12454a79d4 test: commit author 매핑 검증 (johngreen)
Build & Deploy to K8s / build-and-deploy (push) Failing after 3m6s
2026-04-28 08:14:38 +09:00
Johngreen 2b954db854 프로필 모달에서 차량/운전자 정보 섹션 제거
Build & Deploy to K8s / build-and-deploy (push) Failing after 6m29s
ProfileModal 의 isDriver 토글 섹션 + 새 차량 등록 모달 + DriverInfo
/DriverFormData 타입 + 관련 props 모두 제거. 호출부(AppLayout) 와
useProfile 훅의 driver/vehicle 상태 관리 로직도 함께 정리.

차량/운전자 메뉴 화면(components/vehicle/*) 과 대시보드 위젯
(VehicleListWidget, DriverManagementWidget 등) 은 그대로 유지.

3 files changed, 525 deletions(-).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 07:52:38 +09:00
Johngreen 57c6c1b472 .omc/ 디렉토리 git 추적 해제 (실수 노출 회수)
Build & Deploy to K8s / build-and-deploy (push) Failing after 6m30s
직전 commit 52386efb 에서 .omc/ 가 .gitignore 에 명시 안 된 상태로
git add -A 에 의해 함께 추적되어 push 되었습니다. .omc/ 는 OMC 의
세션 상태/agent transcript/planning 결과 등 작업용 임시 파일이므로
저장소에서 제거하고 .gitignore 에 명시합니다.

- .gitignore 에 .omc/ 패턴 추가
- git rm -r --cached .omc/ 로 추적 해제 (워킹트리 보존)
- 직전 commit 의 .omc/ 콘텐츠는 git history 에는 남으나,
  최신 트리에서는 사라집니다.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 07:40:18 +09:00
Johngreen 52386efb83 도메인 정리: invion.com → invyone.com 전체 일괄 치환 + 매핑 문서화
Build & Deploy to K8s / build-and-deploy (push) Has been cancelled
운영 도메인이 실제로는 v1.invyone.com / solution.invyone.com / api.invyone.com 인데
코드/문서 곳곳에 v1.invion.com / api.invion.com 등 미존재 도메인이 박혀 있어 정리.

변경 파일 (21):
- frontend lib/api/client.ts, lib/utils/apiUrl.ts: hostname 체크 endsWith(\".invyone.com\") 일반화
- frontend lib/api/dashboard.ts, file.ts, flow.ts, FileViewerModal*2.tsx: 도메인 치환
- frontend invion-layout-v5.html: 시안 내 placeholder 도메인 정리
- backend-spring SecurityConfig.java: CORS 주석 예시 정리
- docker/deploy/docker-compose.yml, k8s/traefik-dynamic.yaml: traefik Host 라벨 정리
- scripts/prod/deploy.sh: 안내 메시지 정리
- .cursor/rules/api-client-usage.mdc, project-conventions.mdc: AI 가이드 정리
- docs/* 4개: 아키텍처/플로우 문서 도메인 정리
- notes/gbpark/* 3개: 과거 메모 정리

신규:
- docs/DOMAIN_MAPPING.md: 운영/개발/폐기 도메인 영구 기록.
  AI 에이전트와 신규 개발자가 헷갈리지 않도록 단일 진실 출처.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 07:39:04 +09:00
Johngreen eed70014c2 dev 환경 OpenClaw 의존성 옵셔널화
depends_on: openclaw service_healthy 제거 + OPENCLAW_ENABLED 기본값을
false로 변경. OpenClaw 이미지가 placeholder 상태에서 backend 컨테이너가
못 뜨던 문제 해결. OpenClaw 사용 시 별도 docker-compose.openclaw.yml
또는 환경변수 override 로 활성화.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 07:29:56 +09:00
Johngreen 4306fa6f4b AI 메뉴 슈퍼관리자 권한 자동 매핑 (V015)
Build & Deploy to K8s / build-and-deploy (push) Failing after 3m30s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 05:44:38 +09:00
gbpark 4b31fe1b27 사용 안 하는 performanceOptimizer 유틸 제거
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m7s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 01:01:42 +09:00
gbpark 29682e5b63 INVYONE Studio Config Panel — CP 프리미티브 신설 + V2FieldConfigPanel HTML V4 스펙 풀 매칭
- _shared/cp 프리미티브 14종 (CPSection/CPRow/CPSelect 커스텀 popover/CPSegment 평면/CPCrumb 통합 팝오버 등)
- V2FieldConfigPanel: 4 kinds × 10 types × format별 옵션 패널로 재작성 (panel-input-new.jsx 매칭)
  - resolveTriple/applyTriple 양방향 매핑 — 기존 fieldType/source/autoGeneration 호환
  - 미구현(formula/audit/금액 외화/주민·주소·카드/다중 entity) 영역은 Hint 로 명시
- V2PropertiesPanel cp 톤 + 중복 Separator 정리, ScreenDesigner 우측 패널 너비 드래그
- 다크 luminance 분리 / 섹션 헤더 gradient underline / brumb 좌·우 transparent 영역

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 01:00:46 +09:00
Johngreen 229b09b895 AI관리 시스템 교체: ai-assistant 제거 + 멀티 에이전트 오케스트레이션 이식
Build & Deploy to K8s / build-and-deploy (push) Failing after 7m14s
Epic A: ai-assistant 디렉토리/Spring 프록시/프론트엔드 메뉴 완전 제거
Epic B: Flyway 도입 + 13 신규 테이블 마이그레이션
Epic C: 9 서비스 + 7 컨트롤러 + LlmClient 추상화 (Java 21/Spring/MyBatis)
Epic D: ApiKey 인증 필터 (sk-pipe-* 키 SHA-256 검증)
Epic E: OpenClaw 외부 엔진 docker-compose 통합
Epic F: Next.js 7 페이지 + lib/api/aiAgent.ts 이식
Epic G: 화면 그룹/메뉴 등록 마이그레이션 (V014)
Epic H: 통합 빌드 검증

- DB: invyone PostgreSQL에 ai_agents/ai_agent_groups/... 13 테이블 + Quartz
- 멀티테넌시: 모든 테이블에 company_code 강제 필터
- LLM: Anthropic/OpenAI/Google/Ollama 직접 클라이언트 (Spring AI 미도입)
- 스케줄러: Quartz JDBC JobStore (cron 기반)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:49:43 +09:00
chpark b81794a2a5 테이블 타입 관리 / 제어 관리(dataflow) 풀HD 하단 잘림 수정
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m4s
- tableMngList: h-screen → h-full min-h-0 (탭 헤더 차지분 누적되어 하단 잘림 해소)
- automaticMng/flowMgmtList: min-h-screen → h-full min-h-0, max-w 제거, 헤더 컴팩트
- systemMng/dataflow: max-w-[1400px] 제거, 전체 폭 사용

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:27:36 +09:00
chpark 30ab45a3c6 admin/userMng 전체폭 사용 + 부서관리 dept_info 스키마 풀 매핑
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m27s
화면 폭:
- 4개 페이지(사용자/권한 그룹/권한/부서) max-w 제거 → 화면 전체 사용
- 헤더 텍스트 컴팩트화 (text-xl + 작은 설명문)

부서관리 dept_info 스키마 1:1 매핑:
- 누락 필드 6개 추가: master_sabun, master_user_id, location, location_name, data_type, sales_yn
- frontend types/department.ts: Department / DepartmentFormData 확장
- frontend deptMngList: 폼 추가 + handleSelectDepartment / payload 매핑
- backend mapper: INSERT/UPDATE에 6개 컬럼 추가
- backend DepartmentService: insertParams/updateParams에 6개 필드 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:20:46 +09:00
chpark b1971d9107 admin/userMng 디자인 개선 및 부서관리 버튼 동작 추가
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m4s
- 사용자/권한 그룹/권한/부서 관리 4개 페이지 풀HD 기준 max-w 제한, 헤더 컴팩트화
- 권한 그룹 관리: 그리드 4~5열로 카드 컴팩트화, 검색 추가, 호버/액션 디자인 개선
- 부서관리: '일괄등록' 모달(CSV 라인 입력) / '변경이력' 모달 onClick 핸들러 연결

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:02:23 +09:00
gbpark 2431451cef 예약 서브도메인에서 dev 제거 — 본섭 테스트 테넌트 용도 허용
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m30s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:11:48 +09:00
gbpark 5af633d251 체크
Build & Deploy to K8s / build-and-deploy (push) Successful in 6s
2026-04-25 15:54:16 +09:00
gbpark 1761b5d599 추가수정분
Build & Deploy to K8s / build-and-deploy (push) Successful in 7s
2026-04-25 00:41:37 +09:00
gbpark 68f85f3736 회사 관리 기능 확장 + 테넌트/비번 보안 하드닝
- 첫 로그인 비번 강제 변경 (RUN_082): FORCE_PASSWORD_CHANGE 컬럼,
  ForcePasswordChangeGuardFilter, /auth/change-password API + 페이지
- 테넌트 일관성 가드: TenantConsistencyGuardFilter 로 JWT.company_code
  ↔ 서브도메인 company_code 대조, CompanyResolver 가 (db_name, company_code)
  동시 반환
- 회사 관리 확장 (RUN_083 audit log, RUN_084 lifecycle 컬럼):
  CompanyAdmin/Members/Templates/Lifecycle/AuditLog 서비스 +
  CompanyMgmtController + SuperAdminGuard
- 회사 관리 UI: CompanyAccordionRow 탭화 + 모달 4종
  (AdminInfo/Deactivate/Delete/RecopyTemplates) + AuditLogDrawer + csvExport
- 프로비저닝 마법사: force_password_change 토글 반영
- 프론트 인증: storage 이벤트 멀티탭 동기화, 403 errorCode
  (PASSWORD_CHANGE_REQUIRED / CROSS_TENANT_REJECTED / TENANT_NOT_RESOLVED)
  전역 리다이렉트
- 기타: StartupSchemaMigrator, OS별 도커 기동 스크립트, CLAUDE.md 트래킹

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:36:05 +09:00
gbpark 06998cd2a5 문서 최신화: Traefik 와일드카드 실운영 / TenantGuard / K8s Secret 주의 2026-04-24 20:29:53 +09:00
gbpark 6d4f486e35 DB 비밀번호 회전
Build & Deploy to K8s / build-and-deploy (push) Successful in 6s
2026-04-24 19:40:17 +09:00
gbpark 5812925929 테넌트 서브도메인 존재 검증 Guard 추가
Build & Deploy to K8s / build-and-deploy (push) Failing after 3m6s
- 백엔드 TenantController: GET /api/tenant/check?subdomain=xxx
  (메타 DB 강제 라우팅 + CompanyResolver 로 존재 여부 반환)
- frontend/lib/tenant/subdomain.ts: 호스트 파싱 + 예약어(solution/www/admin 등) 제외
- TenantGuard 클라이언트 컴포넌트: layout.tsx 에서 wrap,
  sessionStorage 로 같은 서브도메인 재체크 방지
- /tenant-not-found 페이지: v5 solid+glow 스타일

등록되지 않은 서브도메인 접속 시 즉시 /tenant-not-found 로 리다이렉트.
2026-04-24 19:27:52 +09:00
gbpark 76f43cea9b DB 이름 vexplor → invyone 전환
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m30s
- application.yml, k8s configmap, docker-compose 4종: SPRING_DATASOURCE_URL path
- provisioning 코드: {prefix}_vexplor → {prefix}_invyone 테넌트 DB 네이밍 규칙
- 프론트 마법사: Step1Basic, Step4Run 미리보기 라벨
- CompanyAccordionRow: 기본 DB 이름 포맷
- 마이그레이션/멀티테넌시 문서 동기화
- Traefik 와일드카드 설정 산출물 보관 (notes/)

비밀번호(vexplor0909!!) 및 역사 기록 문서(INVYONE_CONCEPT,
DDD1542, test-output, dashboard-runtime-fixes) 는 의도적으로 미변경.
2026-04-24 19:15:06 +09:00
gbpark 8c861144dc 서브도메인 배포 작업
Build & Deploy to K8s / build-and-deploy (push) Successful in 6s
2026-04-24 17:49:16 +09:00
gbpark 8be7e16e56 서브도메인설정
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m28s
2026-04-24 04:56:40 +09:00
gbpark 94c9b4b602 중간저장 2026-04-24 04:56:30 +09:00
DDD1542 563aef6490 admin 홈 리빌딩 + 폼팝업 전용 렌더러 + 테이블 선택 emit 강화
Build & Deploy to K8s / build-and-deploy (push) Successful in 6s
- /admin 홈을 단순 링크 카드 → Welcome/Stats/SystemStatus/회사활동/최근변경/빠른진입 구성의 관리자 대시보드로 교체 (mock 데이터, 실데이터 배선은 후속)
- PopupTemplateRenderer 신규 — 팝업은 canvas 1:1 유지가 필요하므로 반응형 TemplateRenderer 와 경로 분리, absolute 좌표 + transform scale 로 창을 꽉 채움. form-popup 이 templateId 로 자체 fetch 해서 stale views(screenResolutions 누락) 문제 해소
- DashboardCard: 팝업 outer 크기를 canvas + 브라우저 크롬 보정치로 계산, 부모가 template 객체 직접 전달하던 것 제거
- TemplateRenderer: BlockRenderer export, formRow 기반 columnName/value/onChange/onRowSelect 등 바인딩 props 전달 강화
- TableComponent: 행 클릭/체크박스 선택 시 onRowSelect / onSelectedRowsChange emit, 헤더 전체 선택 동작 구현, 싱글/멀티 선택 분기 정리
- AppLayout: 모드 전환 연출 강화(burst ring + particles / 헤더 sweep / breadcrumb swap / glow flash), horizontal nav + 데스크톱에서 햄버거 자동 접힘, 헤더 도구군 래핑 구조 정리
- DashboardEmpty 삭제 → 공통 EmptyDashboard 로 통합
- dashboard.css: ud-htools stagger(오른쪽 → 왼쪽 슬라이드) 애니메이션 적용

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:02:17 +09:00
DDD1542 991b3aa831 Merge remote-tracking branch 'origin/main' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 6s
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-04-23 11:25:38 +09:00
DDD1542 4508766d06 수정본 업데이트
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m0s
2026-04-23 09:32:39 +09:00
Claude (gbpark) 44c7313deb responsive: 사이드바 토글 항상 노출 + breakpoint 정렬
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m0s
- JS isMobile breakpoint 1024→768 (CSS @media 와 정렬)
  기존에 768~1023px 구간에서 사이드바는 숨고 햄버거는 display:none 이라
  사이드바를 열 방법이 없던 버그 수정.
- v5-mobile-toggle 기본 display:flex (모든 화면에서 표시)
  borderless 헤더 아이콘 스타일(32px)과 정합
- 햄버거 onClick 분기: 모바일=sidebarOpen / 데스크톱=sidebarCollapsed
  desktop 에서는 narrow(56px) ↔ full(220px) 토글로 동작
- title/aria-label 다국어 (열기/닫기/펼치기/접기)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 06:54:41 +09:00
Claude (gbpark) 1a4586e126 UI Phase B — shadcn을 SSoT로, 컴포넌트 디자인 정리
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m2s
- Badge: variant를 semantic 토큰화 (bg-green-500 하드코딩 제거 → bg-success 등)
  - border/hover 제거 (클릭 불가 요소에 hover 불필요)
  - focus-visible ring (focus → focus-visible)
  - info variant 추가 + solid (filled primary) 추가
  - whitespace-nowrap, gap-1 (아이콘 간격 표준)

- Button: success/warning variant 제거 (사용처 0건, 상태는 Badge로)

- Dialog: size prop 추가 (sm:420 / md:560[default] / lg:720 / xl:960)
  - 기존 max-w-lg(512) → md(560). className의 max-w-* 가 오면 우선.
  - 편집 모달의 좁은 폭 문제 해결

- Input: h-10 → h-9 (Button default 높이와 정합)

- v5-atomics.css: DEPRECATION NOTICE 주석 추가. 신규 코드는 shadcn <Button>, <Badge> 등 사용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:16:22 +09:00
Claude (gbpark) 3f481acd8e UI Phase A — 컴포넌트 크기/폰트 상향, UPPERCASE 제거
Build & Deploy to K8s / build-and-deploy (push) Successful in 3m59s
- v5-btn 30→34px, sm 26→28px, lg 40px 추가. 폰트 11.2→13px
- v5-bdg UPPERCASE/letter-spacing 제거 (한글 자간 왜곡 해결), 9.28→11.5px
- v5-tbl 헤더 UPPERCASE 제거, font-weight 600, 폰트 9.6→13px, 행 padding 증가
- v5-kpi-num 1.85→2rem (display 토큰 정렬), delta/sub 9.9→12px
- v5-card-title UPPERCASE 제거, 10.5→13px
- v5-crumbs 9.6→11.5px, 몬오스페이스 제거
- v5-page-sub 10.9→13px
- v5-modal max-width 420→560, size 변형 추가 (sm 420, lg 720, xl 960)
- v5-modal-body body-sm→body 토큰
- 사이드바 .v5-si 폰트 12.3→13px, 여백 증가
- 헤더 breadcrumb 11.5→13px

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:02:45 +09:00
Claude (gbpark) 407da15e6d UI 디자인 패스 — 타이포/헤더/빈화면/로그인 정돈
- 타이포 스케일: body 12→14px, caption 9.6→12px, display 25.6→32px, 위계 강화
- 헤더 우측 3그룹화 (대시보드액션 | 테마/알림/설정 | 모드+프로필), v5-hdr-sep 구분자 추가
- 사이드바 SUPER_ADMIN 회사 카드 borderless slim 라벨로 압축
- 메뉴명 빈 텍스트 방어 + title 속성 추가
- 빈 대시보드(EmptyDashboard) 리디자인: 탭없음/위젯없음 2상태 분리, 2-CTA 카드
- 로그인 코스믹 공연 축소: 별 150→30, 파티클 20→0, 카피 한글화 (로그인 버튼/서브타이틀)
- 모드 전환 burst/sweep/badge-zoom 제거, sidebar stagger morph만 유지 (handleModeSwitch 100→25줄)
- View transitions duration 1800ms → 500ms

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:42:46 +09:00
DDD1542 3eda684787 사용자 대시보드 기능강화 및 인비온 스튜디오 메뉴관리 자잘한수정
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m22s
2026-04-22 18:27:06 +09:00
DDD1542 a5de92de65 정리 완료
Build & Deploy to K8s / build-and-deploy (push) Successful in 39s
2026-04-22 09:45:55 +09:00
1527 changed files with 122891 additions and 275016 deletions
+5 -5
View File
@@ -11,7 +11,7 @@ description: API 요청 시 항상 전용 API 클라이언트를 사용하도록
## 이유
1. **환경별 URL 자동 처리**: 프로덕션(`v1.invion.com`)과 개발(`localhost`) 환경에서 올바른 백엔드 서버로 요청
1. **환경별 URL 자동 처리**: 프로덕션(`v1.invyone.com`)과 개발(`localhost`) 환경에서 올바른 백엔드 서버로 요청
2. **일관된 에러 처리**: 모든 API 호출에서 동일한 에러 핸들링
3. **인증 토큰 자동 포함**: Authorization 헤더 자동 추가
4. **유지보수성**: API 변경 시 한 곳에서만 수정
@@ -116,9 +116,9 @@ const getApiBaseUrl = (): string => {
if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
// 프로덕션: v1.invion.com → api.invion.com
if (currentHost === "v1.invion.com") {
return "https://api.invion.com/api";
// 프로덕션: v1.invyone.com → api.invyone.com
if (currentHost === "v1.invyone.com") {
return "https://api.invyone.com/api";
}
// 로컬 개발
@@ -155,7 +155,7 @@ API 클라이언트는 자동으로 환경을 감지합니다:
| 현재 호스트 | 백엔드 API URL |
| ---------------- | ----------------------------- |
| `v1.invion.com` | `https://api.invion.com/api` |
| `v1.invyone.com` | `https://api.invyone.com/api` |
| `localhost:9771` | `http://localhost:8080/api` |
| `localhost:3000` | `http://localhost:8080/api` |
+1 -1
View File
@@ -661,7 +661,7 @@ if (req.user && req.user.companyCode !== "*") {
| 환경 | 프론트엔드 | 백엔드 API |
|------|-----------|-----------|
| 프로덕션 | `v1.invion.com` | `https://api.invion.com/api` |
| 프로덕션 | `v1.invyone.com` | `https://api.invyone.com/api` |
| 개발 (로컬) | `localhost:9771` 또는 `localhost:3000` | `http://localhost:8080/api` |
- 프론트엔드 `apiClient`가 `window.location.hostname` 기반으로 자동 판별
+4
View File
@@ -0,0 +1,4 @@
# Force LF for shell scripts and Gradle wrapper so they work in Linux containers
# regardless of host autocrlf settings.
*.sh text eol=lf
gradlew text eol=lf
+54
View File
@@ -31,6 +31,7 @@ jobs:
- name: Build frontend
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.PROJECT }}/frontend:${{ env.SHORT_SHA }} \
--build-arg GIT_SHA=${{ env.SHORT_SHA }} \
-f docker/deploy/frontend.Dockerfile \
frontend/
@@ -70,3 +71,56 @@ jobs:
# Rollout 상태 확인
kubectl rollout status deployment/backend-spring -n invyone --timeout=180s
kubectl rollout status deployment/frontend -n invyone --timeout=120s
# ---- 실패 시 진단 (pod stdout / events 캡처) ----
- name: Diagnose on failure
if: failure()
run: |
export KUBECONFIG=/home/chpark/.kube/config
echo "============================================"
echo "=== Pods (-n invyone) ==="
echo "============================================"
kubectl get pods -n invyone -o wide || true
echo
echo "============================================"
echo "=== Deployment images (현재 어떤 tag 가 떠있는지) ==="
echo "============================================"
kubectl get deploy -n invyone -o jsonpath='{range .items[*]}{.metadata.name}{": "}{.spec.template.spec.containers[*].image}{"\n"}{end}' || true
echo
echo "============================================"
echo "=== backend-spring describe ==="
echo "============================================"
kubectl describe deployment backend-spring -n invyone || true
kubectl describe pods -n invyone -l app=backend-spring | tail -120 || true
echo
echo "============================================"
echo "=== backend-spring ALL pod logs (per pod) ==="
echo "============================================"
# deployment/<name> selector 는 active ReplicaSet 한 개만 봐서
# 새로 뜨다 죽은 ReplicaSet 의 pod 를 놓침.
# label selector 로 모든 backend-spring pod 순회.
for p in $(kubectl get pods -n invyone -l app=backend-spring -o name 2>/dev/null); do
echo "------------------------------"
echo "--- $p (current, tail 500) ---"
echo "------------------------------"
kubectl logs -n invyone $p --all-containers=true --tail=500 2>&1 || true
echo
echo "--- $p (previous, if exists, tail 500) ---"
kubectl logs -n invyone $p --all-containers=true --tail=500 --previous 2>&1 || true
echo
done
echo
echo "============================================"
echo "=== frontend logs (참고, tail 200) ==="
echo "============================================"
kubectl logs -n invyone deployment/frontend --tail=200 --all-containers=true || true
echo
echo "============================================"
echo "=== Recent Warning events ==="
echo "============================================"
kubectl get events -n invyone --sort-by='.lastTimestamp' --field-selector type=Warning 2>/dev/null | tail -30 || true
+10 -1
View File
@@ -2,6 +2,14 @@
.claude/
CLAUDE.local.md
# direnv (per-developer JAVA_HOME / shell env)
.envrc
.direnv/
# OMC (oh-my-claudecode) 작업용 임시 상태 — 절대 추적 금지
# planning, autopilot state, agent transcript, project memory 등 포함
.omc/
# Syncthing local stub (each machine has its own; real patterns in .stignore-shared)
.stignore
.stfolder/
@@ -40,6 +48,7 @@ bin/
# Gradle
.gradle/
.gradle-cache/
**/backend/.gradle/
# Cache
@@ -122,6 +131,7 @@ tokens.json
# 데이터베이스 덤프
*.sql
!**/db/migration/*.sql
*.dump
db/dump/
db/backup/
@@ -173,7 +183,6 @@ uploads/
*.hwpx
# ===== 기타 =====
claude.md
# Agent Pipeline 로컬 파일
_local/
+1
View File
@@ -0,0 +1 @@
21
+400
View File
@@ -0,0 +1,400 @@
<!-- User customizations -->
# 절대 규칙: 검증 없는 주장 금지
내가 출력하는 모든 발언은 근거가 있어야 한다. 근거가 없으면 그 말을 하지 않는다. 위로·추정·일반론·"보통 그렇다"로 채우지 않는다.
## 위반 사례 (절대 하지 말 것)
- "100명 중 5명도 안 된다" 같은 통계를 출처 없이 만들어내기
- "통과 확률 70~80%" 같은 수치를 추정으로 제시하기
- "보통", "일반적으로", "대부분" 으로 시작하는 일반론
- 본인이 검증 안 한 SDK/API 동작을 단정적으로 설명하기
- 위로·격려를 위해 사실이 아닌 것을 끼워넣기
## 발화 전 자기 검증
한 문장이라도 출력하기 전에 다음을 확인:
1. **출처가 있는가?** — 코드(파일:라인), 명령 결과, 공식 문서, 사용자가 준 정보, 도구 호출 결과 중 하나
2. **출처가 없다면 추정인가?** — 추정이면 명시적으로 "추정이지만…" 또는 "확인 안 됐지만…" 으로 시작
3. **추정도 근거가 없으면?** — 말하지 않는다. "모릅니다" 또는 "확인이 필요합니다" 라고 한다
## 모를 때의 정답
- 검색·문서 조회·코드 읽기로 확인 가능하면 확인부터 한다
- 확인이 불가능하면 "모릅니다" 가 정답. 그럴듯한 답을 만들지 않는다
- 사용자 의사결정에 영향을 주는 사실일수록 더 엄격하게 적용
## 어겼을 때
사용자가 "그 근거 뭐야" 라고 묻거나 잘못된 사실을 지적하면:
- 즉시 인정. "맞습니다. 그 수치 제가 지어냈습니다." 같이 명시적으로 시인
- 변명·재포장 금지
- 무엇이 검증된 사실이고 무엇이 추정/날조였는지 다시 분리해서 제시
# 💬 사용자에게 설명할 때 — 그림으로 (★ 중요)
UI 변경 제안, 디자인 토론, 코드 구조 설명 등을 할 때는 **반드시 변경 전/후를 ASCII 표나 도식으로 그려서** 보여준다. 글로만 설명하면 사용자가 이해 못 한다.
## 원칙
1. **변경 제안은 무조건 Before / After 두 그림**
2. **코드 인용 (file:line, 변수명, CSS class) 최소화** — 결론과 시각적 영향 위주
3. **평어, 한국어, 짧은 문장**
4. **영문/SQL/전문용어 풀어쓰기** — "grid template" 대신 "표 컬럼 배치", "stopPropagation" 대신 "클릭이 위로 새는 거 막기"
5. **3줄 패턴 권장** — 무슨 일 / 사용자한테 보이는 영향 / 어떻게 고치는지
## 나쁜 예시 ❌
> "ColumnGrid.tsx:93-103 의 `grid-cols-[4px_140px_1fr_100px_160px_40px]` 를 5컬럼으로 축소하고, 라벨 셀에 sub-line 을 추가하면 entity/code/numbering 의 메타가 inline 으로..."
(사용자: "뭐라는지 모르겠어")
## 좋은 예시 ⭕
> **지금 모양:**
> ```
> 라벨·컬럼명 │ 참조/설정 │ 타입
> 거래처명 │ — │ 텍스트 ← 빈 칸
> 거래처ID │ customer_mng → ... │ 테이블참조
> ```
>
> **바꿔서:**
> ```
> 라벨·컬럼명 │ 타입
> 거래처명 │ 텍스트
> 거래처ID │ 테이블참조
> → customer_mng.id ← 정보 있을 때만 작게 밑에
> ```
## 옵션 제시할 땐 표로
```
| 옵션 | 핵심 | 단점 |
| A안 | 이름만 바꾸기 | 가장 가벼움 |
| B안 | 그룹을 잘게 쪼개기 | 그룹 수 늘어남 |
```
## 우선 순위
- 첫 시도에 글만 쓰지 말 것. 그림부터 그리고 글은 짧게 보충.
- 사용자가 "무슨 말인지 모르겠어" 하면 → 더 분해해서 다시 그림 그리기. 글 길어지면 더 헷갈림.
---
# INVYONE — Claude 작업 컨벤션
이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다.
(개인용 셋업은 `CLAUDE.local.md` — git 추적 제외, syncthing 동기화)
---
## 📝 분석 / 리포트 / 메모 MD 파일 저장 규칙 (★필수)
Claude 가 코드 분석, 보안 감사, 리팩토링 검토, 설계 문서, 회의록 등 **새 MD 파일을 작성**할 때는 다음 위치에 저장합니다.
### 저장 경로
```
notes/{git-user-name}/{YYYY-MM-DD}-{slug}.md
```
- `notes/` — 프로젝트 루트의 메모/리포트 모음 폴더 (이 폴더로 통일)
- `{git-user-name}``git config user.name` 으로 자동 결정 (예: `gbpark`, `park`)
- `{YYYY-MM-DD}-{slug}.md` — 날짜 prefix + 짧은 제목 slug (kebab-case)
**예시:**
```
notes/gbpark/2026-04-08-auth-security-audit.md
notes/gbpark/2026-04-12-component-v2-migration-plan.md
notes/park/2026-04-15-docker-port-conflict-resolution.md
```
### 규칙
1. **사용자 폴더가 이미 있으면 그 안에 넣는다** — 없으면 `mkdir -p notes/{git-user}` 로 생성
2. **파일명은 항상 날짜 + slug 조합** — 시간순 정렬되어 추적 용이
3. **README 나 docs/ 와는 분리**`README.md`, `docs/` 는 사용자/개발자용 공식 문서. `notes/` 는 작업 기록·분석·메모용
4. **MD 외 다른 산출물 (스크립트, JSON 등) 도 같이 둘 수 있음** — 필요하면 `notes/{git-user}/{slug}/` 식 하위 폴더 사용
5. **새 폴더/파일 작성 후엔 git add 권장** — syncthing 도 자동 동기화 (`notes/``.stignore-shared` 에 없음)
### 어디에 안 넣는가
- `_local/`, `_backup/`, `_pipeline/` — syncthing ignore. 머신 로컬용
- `docs/` — 공식 개발 문서. 작업 기록 아님
- 프로젝트 루트 직접 (`./STATUS.md`, `./PLAN.MD` 등) — 이미 기존에 있는 것 외에 새로 만들지 말 것
---
## 컨벤션이 적용되는 시나리오
| 사용자 요청 | 저장 위치 |
|---|---|
| "이 코드 분석해서 md 로 정리해줘" | `notes/{git-user}/{date}-{topic}.md` |
| "보안 감사 리포트 만들어줘" | `notes/{git-user}/{date}-security-audit.md` |
| "리팩토링 플랜 md 로 뽑아줘" | `notes/{git-user}/{date}-refactor-plan.md` |
| "회의 노트 정리해줘" | `notes/{git-user}/{date}-meeting-notes.md` |
| "마이그레이션 가이드 작성" | `notes/{git-user}/{date}-migration-guide.md` |
---
## Claude 사용 시 추가 주의사항
- **이 컨벤션은 사용자 명시 요청 없이도 자동 적용** — 사용자가 "md 만들어줘" 라고만 해도 위 경로에 저장
- **현재 git user 확인이 필요하면** `git config user.name` 실행
- **사용자 폴더가 처음이면** 만들면서 `.gitkeep` 정도만 두지 말고 바로 첫 노트 작성
---
## 🎨 공통 디자인 시스템 / CSS 참조 규칙 (★★★ 무조건 적용)
UI 작업(컴포넌트 작성, HTML 목업, 새 페이지/화면, 디자인 리빌딩, 스타일 수정 등)을 할 때는 **반드시** 아래 공통 CSS 파일들을 먼저 읽고 그 안의 토큰/클래스 컨벤션을 100% 따라야 합니다. 절대 새 색상/간격/라운드/그림자 값을 즉흥으로 만들지 말 것.
### "v5" 의 정체 (★ 헷갈리지 말 것)
**INVION v5 = 디자인 시안 5번째 = 최종 채택본. 현재 컨셉은 "Solid + Glow" (2026-04-21 개정)**
- 디자이너가 v1~v5 까지 5번 시안을 만들고 그 중 **v5 가 확정**되어 React 로 포팅됨
- 시안 원본 HTML (참고용, 여기엔 아직 glassmorphism 이 남아있음):
- `frontend/invion-layout-v5.html` (973줄, 풀 레이아웃 셸)
- `frontend/invion-preview-v5.html` (1049줄, 미리보기/모션 데모)
- 폐기된 시안: `frontend/invion-preview-v1~v4.html` (참고만, 적용 금지)
- **현재 적용 컨셉 (2026-04-21 개정)**:
- **로그인 페이지**: 우주(별/성운/별똥별/입자) 배경 + 글래스 카드 **유지**
- **메인 화면 이후 전부**: **반투명/blur/cosmic 배경 폐기**. 불투명 솔리드 카드 + primary-color 글로우 + 보라(`#6c5ce7`)/시안(`#00cec9`)/핑크(`#fd79a8`) 액센트
- v5 토큰이 옮겨진 곳: `frontend/styles/v5-layout.css`, `frontend/app/(auth)/login/login.css`
⚠️ **POP 디자이너의 "v5 그리드 시스템"** (`PopRenderer.tsx`, `pop-layout.ts` 등) 은 **별개 의미** — POP 화면 데이터 포맷의 5번째 버전. UI 디자인 v5 와 무관. 혼동 금지.
### 항상 먼저 읽어야 하는 파일
| 파일 | 역할 |
|---|---|
| `frontend/invion-layout-v5.html` | **v5 디자인 원본 (시안)** — 모든 v5 토큰/클래스의 진실의 원천. 포팅된 css 와 다르면 이게 정답 |
| `frontend/styles/v5-layout.css` | **INVION v5 React 포팅 메인**`--v5-*` CSS 변수, 헤더/사이드바/탭/모달 등 모든 v5- 컴포넌트 클래스 정의. UI 작업 전 무조건 먼저 읽기 |
| `frontend/app/globals.css` | shadcn/Tailwind 토큰 (`--background`, `--primary`, `--foreground` 등 HSL), 다크모드 변수, 전역 reset |
| `frontend/app/(auth)/login/login.css` | **로그인 전용** 코스믹 배경(별/성운/입자) + 글래스 카드. 이 컨셉은 **로그인에만** 적용 — 메인 화면에 옮기지 말 것 |
| `frontend/components/layout/AppLayout.tsx` | v5 클래스가 실제로 어떻게 조립되는지 — 헤더/사이드바/탭/플라이아웃 사용 예 |
### 필수 준수 사항
1. **디자인 토큰은 무조건 변수 사용**`--v5-primary`, `--v5-cyan`, `--v5-surface-solid`, `--v5-glow-sm/md/lg`, `hsl(var(--primary))` 등. 즉흥 hex/rgb 금지
2. **클래스명은 v5- 접두사 컨벤션 따르기** — 새 컴포넌트도 `.v5-card`, `.v5-btn`, `.v5-bdg` 처럼 같은 네이밍. shadcn 컴포넌트 사용 시 그대로 사용
3. **반투명/블러 금지 (★2026-04-21 신규)** — 메인 화면 이후 전 영역에서 `backdrop-filter: blur(...)`, `var(--v5-glass)`, `var(--v5-glass-strong)` 사용 **금지**. 카드/모달/사이드바/헤더 배경은 `var(--v5-surface-solid)` (라이트 `#ffffff` / 다크 `#11102a`) 를 쓰고, 테두리는 `border-border` 또는 `var(--v5-border)`. 예외: `frontend/app/(auth)/login/``frontend/styles/builder-ide.css` 는 별도 스코프라 기존 유지
4. **글로우는 유지** — 그림자는 검은 drop-shadow 대신 `var(--v5-glow-sm/md/lg)` (primary-color glow) 사용. 모달/강조 카드에 liberal 하게 사용 가능
5. **다크/라이트 모드는 둘 다 동작**`.dark` 변형 잊지 말 것. 다크에서 `--v5-surface-solid``#11102a`, 라이트는 `#ffffff`. 별/입자/별똥별/성운은 **로그인에만** 존재, 메인은 평범한 단색 배경
6. **컴팩트 폰트 사이즈 유지** — v5 는 0.55~0.85rem 의 컴팩트 UI. 새로 만들 때도 같은 스케일 따를 것
7. **새 UI 패턴은 v5-layout.css 에 합치는 것을 기본 방향으로** — 일회성 inline `<style>` 보다 공통화 우선
### 작업 순서 (UI 작업 시 반드시)
```
1) frontend/styles/v5-layout.css 읽기
2) 필요하면 globals.css, login.css 도 함께 읽기
3) 기존 v5- 클래스 중 재사용 가능한 것 찾기
4) 모자라는 부분만 같은 토큰/네이밍으로 추가
5) 작업 결과를 사용자에게 보여줄 때 "어떤 v5 토큰/클래스를 따랐는지" 명시
```
### 예외
- 단발성 디버그/실험 페이지(`debug-*`, `test-*`)는 임시 스타일 허용
- `notes/` 안의 1회성 HTML 목업은 v5 토큰을 inline 으로 가져와 standalone 이어도 됨 (단 토큰 값은 반드시 v5-layout.css 와 동일해야 함)
이 규칙은 사용자가 명시 요청하지 않아도 모든 UI 작업에 자동 적용됩니다.
---
## 🔧 백엔드 코딩 규칙 — 스타일 (★★★ 절대 규칙)
### 아키텍처: 3레이어 (Mapper Interface 금지)
```
Controller (@RestController)
Service (extends BaseService) — sqlSession 직접 사용
XML (resources/mapper/[module].xml) — 소문자 파일명
```
- **@Mapper 인터페이스 생성 금지** — `sqlSession.selectList("namespace.queryId", params)` 직접 호출
- Service는 반드시 `BaseService` 상속, `@Slf4j`, `@Autowired CommonService`
### 데이터: Map<String, Object> — DTO/엔티티 클래스 금지
로우코드 ERP: 테이블/컬럼이 런타임에 결정됨. DTO 클래스 사전 생성 불가.
- 모든 파라미터: `Map<String, Object>`
- 모든 응답: `Map<String, Object>` 또는 `List<Map<String, Object>>`
- `ApiResponse`만 유일한 DTO
### 네이밍 컨벤션 (절대 규칙)
| 위치 | 컨벤션 | 예시 |
|---|---|---|
| **Java 코드** | camelCase | `getOrderList()`, `String companyCode` |
| **Map 키 (params.put, row.get)** | snake_case | `params.put("company_code", ...)`, `row.get("table_name")` |
| **#{파라미터}** | snake_case | `#{company_code}`, `#{table_name}` |
| **SQL (키워드/테이블/컬럼)** | UPPER_SNAKE | `SELECT COMPANY_CODE FROM TEMPLATES` |
| **SELECT 쉼표** | 앞에 | `, COLUMN_NAME` |
| **XML 파일명** | 소문자, Mapper 안 붙임 | `meta.xml`, `template.xml` |
| **XML namespace** | 파일명과 동일 | `namespace="meta"` |
| **OGNL test** | 바깥 작은따옴표 | `test='company_code != "*"'` |
### 메서드명 패턴
| 조작 | 패턴 | 예시 |
|---|---|---|
| 목록 | `get[Module]List` | `getTemplateList` |
| 카운트 | `get[Module]ListCnt` | `getTemplateListCnt` |
| 단건 | `get[Module]Info` | `getTemplateInfo` |
| 등록 | `insert[Module]` | `insertTemplate` |
| 수정 | `update[Module]` | `updateTemplate` |
| 삭제 | `delete[Module]` | `deleteTemplate` |
**List API는 반드시 Count 쿼리 동반** (`getXxxList` + `getXxxListCnt` = 한 세트)
### common 레이어 필수
```xml
<include refid="common.companyCodeFilter"/>
<include refid="common.dynamicOrderBy"/>
<include refid="common.pagination"/>
```
---
## 🌐 프론트엔드 데이터 타입 규칙 (★★★ 절대 규칙)
### Record<string, any> — 별도 인터페이스 정의 금지
백엔드가 `Map<String, Object>`이므로 프론트도 `Record<string, any>`.
```typescript
// ❌ 금지 — 불필요한 인터페이스 정의
interface TableInfo {
table_name: string;
column_count: number;
}
// ✅ 올바름
const tables: Record<string, any>[] = await getTableList();
```
### 유일한 예외: invyone-component.ts 규격 타입
`frontend/types/invyone-component.ts`에 확정된 타입만 예외:
- `FieldConfig`, `FieldType`, `FieldRef`
- `Component`, `ComponentType`, `Position`
- `DataPort`, `Connection`
- `Template`, `ViewConfig`
-`ComponentTypeConfig` (TableConfig, FormConfig 등)
이것들은 시스템의 핵심 계약이므로 타입 유지. **그 외 전부 `Record<string, any>`.**
---
## 📐 INVYONE 로우코드 핵심 구조 (★ 모든 세션이 알아야 할 것)
### VEX → INVYONE 관계
INVYONE은 이미 운영 중인 로우코드 플랫폼 VEX(Node.js)의 2세대 리뉴얼(Java/Spring).
**핵심 원칙: 더 단순한 구조로 재설계하되, VEX 운영 기능은 전부 계승. 단순화 ≠ 기능 삭제.**
### FieldConfig 단일 규격
VEX의 ColumnConfig(354줄) + FilterConfig + FormField → **FieldConfig 하나(~30줄)로 통합**.
테이블/폼/검색 전부 같은 FieldConfig를 공유하며, 렌더러가 type을 보고 각자 다르게 렌더.
### 대시보드 = 메뉴
사이드바 메뉴 항목 = 대시보드. 별도 대시보드 UI가 아님.
`대시보드 생성("수주관리") → 사이드바 메뉴에 자동 등록 → 템플릿 카드 배치 → 화면 완성`
### 구현 순서
1. DB 메타 읽기 → FieldConfig 변환
2. 규격 기반 컴포넌트 (FcTable/FcForm/FcSearch)
3. 개발자 빌더 (수동 템플릿 구성)
4. 대시보드(=메뉴) 시스템
5. 제어 모드 (비즈니스 룰)
6. 자동생성/프리셋 (맨 마지막)
### 설계 문서 위치
| 문서 | 위치 |
|---|---|
| 컴포넌트 규격 v1.0 | `notes/gbpark/2026-04-08-invyone-component-spec.md` |
| 아키텍처 결정 | `notes/gbpark/2026-04-09-invyone-architecture.md` |
| 로우코드 플랫폼 SPEC | `notes/gbpark/2026-04-08-lowcode-platform-spec.md` |
| Phase 1~5 구현 설계 | `notes/gbpark/2026-04-10-phase{1~5}-*.md` |
| mockup (시각적 진실의 원천) | `notes/gbpark/2026-04-08-invyone-mockup/` |
| FieldConfig TS 타입 | `frontend/types/invyone-component.ts` |
---
## 🏢 멀티테넌시 · 서브도메인 라우팅 (★★★ 아키텍처 핵심)
INVYONE 은 **DB-per-tenant** 구조. 회사 하나 = 전용 PostgreSQL DB 하나 = 전용 서브도메인 하나.
`qnc.invyone.com` 접속 → `SubdomainResolverFilter` 가 Host 헤더 파싱 → `TenantRoutingDataSource``qnc_invyone` DB 로 자동 라우팅.
**전체 설명·플로우·배포 가이드는 반드시 읽을 것:**
- **`docs/MULTI_TENANCY_ARCHITECTURE.md`** ← 이거 하나만 읽으면 끝 (환경별 도메인 전략, CORS 패턴, 배포 체크리스트 포함)
### 절대 건드리면 안 되는 3가지
1. **테넌트 도메인에서 `NEXT_PUBLIC_API_URL=/api` 같은 Next rewrite 쓰지 말 것.**
Rewrite 가 Host 헤더를 변조해 `*.invyone.com` 서브도메인 파싱이 실패함.
`frontend/lib/api/client.ts` 의 "`.invyone.com` 이면 직접 `:8083/api`" 분기가 NEXT_PUBLIC_API_URL 체크보다 **앞에** 와야 함.
2. **CORS 는 `setAllowedOriginPatterns`** (`setAllowedOrigins` 아님). 와일드카드 매칭 필요.
**YAML 의 `[*]` 는 반드시 따옴표로 감쌀 것** (sequence 로 해석됨).
`CORS_ALLOWED_ORIGINS``.env` 에 있고 `.gitignore` 대상 → **서버마다 수동 세팅**.
3. **회사별 Hikari 풀은 반드시 `minIdle=0`.**
회사 N개 × minIdle=2 하면 Postgres `max_connections` 금방 초과.
`TenantDataSourceFactory.createTenant(...)` 만 사용할 것.
### 회사 생성 = API 호출 한 번
```
POST /api/admin/provisioning/companies
→ 6단계 자동 실행 (DB 생성 → pg_dump 스키마 복제 → 템플릿 데이터 → 관리자 → 메타 등록)
→ 13~15초 내 완료, 바로 서브도메인 접속 가능
```
필수 필드 4개만: `company_code`, `company_name`, `subdomain`, `db_prefix`.
나머지(사업자번호·대표자·이메일 등) 전부 optional.
### 환경별 도메인 — 요약
| 환경 | 접속 | 추가 설정 |
|---|---|---|
| **운영** | 실 `*.invyone.com` (+ 메인 `solution.invyone.com`) | **세팅 완료 (2026-04-24)** — Porkbun 와일드카드 DNS + Traefik v2.11 + Let's Encrypt DNS-01 와일드카드 TLS |
| **로컬 (직접 `docker up`)** | `*.localhost` 자동 | 0 (RFC 6761, Chrome/Firefox 기본 지원) |
| **원격 공유 개발서버** | `nip.io` or `hosts` 편집 | 경우 따라 |
### 새 환경 붙일 때 체크리스트
- [ ] `.env``CORS_ALLOWED_ORIGINS` 에 해당 도메인 패턴 포함 (`http://*.invyone.com:[*]` 등)
- [ ] DB 마이그레이션 `RUN_079/080/081.md` 운영 DB 에서 1회 실행
- [ ] `docker compose build --no-cache backend-spring` (`postgresql16-client` 포함)
- [ ] 프로덕션은 `tenant.provisioning.require-super-admin=true`
### 관련 코드 맵
```
backend-spring/src/main/java/com/erp/
├── tenant/ # 라우팅 (Filter / Holder / CompanyResolver / RoutingDataSource / Factory / DataSourceConfig / TenantController)
└── provisioning/ # 회사 생성 (Service / Registry / DatabaseCreator / SchemaCopier / DataCopier / AdminAccountCreator / Controller / StatsService)
backend-spring/src/main/resources/mapper/
├── tenant.xml # resolveDbNameBySubdomain
└── provisioning.xml # exists / insertCompanyWithTenant / listCompaniesForUi / updateDbStatus
frontend/
├── lib/api/provisioning.ts
├── lib/tenant/subdomain.ts # 호스트 파싱 + 예약어 제외
├── components/TenantGuard.tsx # 미등록 서브도메인 차단 (layout wrap)
├── components/admin/provisioning/ # 메인 화면 (accordion / stats strip / status dot / sparkline)
│ └── wizard/ # 4-step 마법사 (force_password_change 옵션 포함)
├── app/tenant-not-found/page.tsx # v5 solid+glow 에러 페이지
└── app/(main)/admin/sysMng/subdomainList/ # 회사 프로비저닝 페이지 엔트리
db/migrations/RUN_079/080/081_MIGRATION.md
notes/gbpark/2026-04-24-traefik-wildcard/ # 운영 Traefik 설정 산출물 (2026-04-24)
```
-1
View File
@@ -1 +0,0 @@
/Users/gbpark/agent-pipeline/test-vex
-25
View File
@@ -1,25 +0,0 @@
# AI Assistant API (INVION 내장) - 환경 변수
# 이 파일을 .env 로 복사한 뒤 값 설정
NODE_ENV=development
PORT=3100
# PostgreSQL (AI 어시스턴트 전용 DB)
DB_HOST=localhost
DB_PORT=5432
DB_USER=ai_assistant
DB_PASSWORD=ai_assistant_password
DB_NAME=ai_assistant_db
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
JWT_REFRESH_EXPIRES_IN=30d
# LLM (구글 키 등)
GEMINI_API_KEY=your-gemini-api-key
GEMINI_MODEL=gemini-2.0-flash
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100
-17
View File
@@ -1,17 +0,0 @@
# AI 어시스턴트 API - Docker (Windows 개발용)
FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=development
EXPOSE 3100
CMD ["node", "src/app.js"]
-43
View File
@@ -1,43 +0,0 @@
# AI 어시스턴트 API (INVION 내장)
INVION와 **같은 서비스**로 동작하도록 이 API는 포트 3100에서 구동되고, backend-node가 `/api/ai/v1` 요청을 여기로 프록시합니다.
## 동작 방식
- **프론트(9771)** → `/api/ai/v1/*` 호출
- **Next.js** → `8080/api/ai/v1/*` 로 rewrite
- **backend-node(8080)** → `3100/api/v1/*` 로 프록시 → **이 서비스**
따라서 사용자는 **다른 포트를 쓰지 않고** INVION만 켜도 AI 기능을 사용할 수 있습니다.
## 서비스 올리는 순서 (한 번에 동작하게)
1. **AI 어시스턴트 API (이 폴더, 포트 3100)**
```bash
cd ai-assistant
npm install
cp .env.example .env # 필요 시 DB, JWT, GEMINI_API_KEY 등 수정
npm start
```
2. **backend-node (포트 8080)**
```bash
cd backend-node
npm run dev
```
3. **프론트 (포트 9771)**
```bash
cd frontend
npm run dev
```
브라우저에서는 `http://localhost:9771` 만 사용하면 되고, AI API는 같은 오리진의 `/api/ai/v1` 로 호출됩니다.
## 환경 변수
- `.env.example` 을 `.env` 로 복사 후 수정
- `PORT=3100` (기본값)
- PostgreSQL: `DB_*`
- JWT: `JWT_SECRET`, `JWT_REFRESH_SECRET`
- LLM: `GEMINI_API_KEY` 등
-3455
View File
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
{
"name": "ai-assistant-api",
"version": "1.0.0",
"description": "AI Assistant API (INVION 내장) - 포트 3100에서 구동, backend-node가 /api/ai/v1 로 프록시",
"private": true,
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js"
},
"dependencies": {
"@google/genai": "^1.0.0",
"axios": "^1.6.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"pg-hstore": "^2.3.4",
"sequelize": "^6.35.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"zod": "^3.22.4"
},
"devDependencies": {
"nodemon": "^3.0.3"
},
"engines": {
"node": ">=18.0.0"
}
}
-186
View File
@@ -1,186 +0,0 @@
// src/app.js
// AI Assistant API 서버 메인 엔트리포인트
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./config/swagger.config');
const logger = require('./config/logger.config');
const { sequelize } = require('./models');
const routes = require('./routes');
const errorHandler = require('./middlewares/error-handler.middleware');
const app = express();
// INVION 내장 시 backend-node가 이 포트로 프록시하므로 기본 3100 사용
const PORT = process.env.PORT || 3100;
// ===========================================
// 미들웨어 설정
// ===========================================
// Trust proxy (Docker/Nginx 환경)
app.set('trust proxy', 1);
// CORS 설정 (helmet보다 먼저 설정)
app.use(cors({
origin: true, // 모든 origin 허용
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
}));
// Preflight 요청 처리
app.options('*', cors());
// 보안 헤더 (CORS 이후에 설정)
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
crossOriginOpenerPolicy: { policy: 'unsafe-none' },
}));
// 요청 본문 파싱
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// 압축
app.use(compression());
// Rate Limiting (전역)
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000,
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100,
message: {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: '요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
// 요청 로깅
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
});
next();
});
// ===========================================
// 헬스 체크
// ===========================================
app.get('/health', (req, res) => {
res.json({
success: true,
data: {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
},
});
});
// ===========================================
// Swagger API 문서
// ===========================================
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
explorer: true,
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'AI Assistant API 문서',
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
},
}));
// Swagger JSON
app.get('/api-docs.json', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
// ===========================================
// API 라우트
// ===========================================
app.use('/api/v1', routes);
// ===========================================
// 404 처리
// ===========================================
app.use((req, res) => {
res.status(404).json({
success: false,
error: {
code: 'NOT_FOUND',
message: `요청한 리소스를 찾을 수 없습니다: ${req.method} ${req.originalUrl}`,
},
});
});
// ===========================================
// 에러 핸들러
// ===========================================
app.use(errorHandler);
// ===========================================
// 서버 시작
// ===========================================
async function startServer() {
try {
// 데이터베이스 연결
await sequelize.authenticate();
logger.info('✅ 데이터베이스 연결 성공');
// 테이블 동기화 (테이블이 없으면 생성)
await sequelize.sync();
logger.info('✅ 데이터베이스 스키마 동기화 완료');
// 초기 데이터 설정 (관리자 계정, LLM 프로바이더)
const initService = require('./services/init.service');
await initService.initialize();
// 서버 시작
app.listen(PORT, () => {
logger.info(`🚀 AI Assistant API 서버가 포트 ${PORT}에서 실행 중입니다`);
logger.info(`📚 API 문서 (Swagger): http://localhost:${PORT}/api-docs`);
logger.info(`📚 API 엔드포인트: http://localhost:${PORT}/api/v1`);
});
} catch (error) {
logger.error('❌ 서버 시작 실패:', error);
process.exit(1);
}
}
// 프로세스 종료 처리
process.on('SIGTERM', async () => {
logger.info('SIGTERM 신호 수신, 서버 종료 중...');
await sequelize.close();
process.exit(0);
});
process.on('SIGINT', async () => {
logger.info('SIGINT 신호 수신, 서버 종료 중...');
await sequelize.close();
process.exit(0);
});
startServer();
module.exports = app;
@@ -1,474 +0,0 @@
// src/controllers/admin.controller.js
// 관리자 컨트롤러
const { LLMProvider, User, UsageLog, ApiKey } = require('../models');
const { Op } = require('sequelize');
const logger = require('../config/logger.config');
// ===== LLM 프로바이더 관리 =====
/**
* LLM 프로바이더 목록 조회
*/
exports.getProviders = async (req, res, next) => {
try {
const providers = await LLMProvider.findAll({
order: [['priority', 'ASC']],
attributes: [
'id',
'name',
'displayName',
'endpoint',
'modelName',
'priority',
'maxTokens',
'temperature',
'timeoutMs',
'costPer1kInputTokens',
'costPer1kOutputTokens',
'isActive',
'isHealthy',
'lastHealthCheck',
'createdAt',
'updatedAt',
// API 키는 마스킹해서 반환
'apiKey',
],
});
// API 키 마스킹
const maskedProviders = providers.map((p) => {
const data = p.toJSON();
if (data.apiKey) {
// 앞 8자만 보여주고 나머지는 마스킹
data.apiKey = data.apiKey.substring(0, 8) + '****' + data.apiKey.slice(-4);
data.hasApiKey = true;
} else {
data.hasApiKey = false;
}
return data;
});
return res.json({
success: true,
data: maskedProviders,
});
} catch (error) {
return next(error);
}
};
/**
* LLM 프로바이더 추가
*/
exports.createProvider = async (req, res, next) => {
try {
const {
name,
displayName,
endpoint,
apiKey,
modelName,
priority = 50,
maxTokens = 4096,
temperature = 0.7,
timeoutMs = 60000,
costPer1kInputTokens = 0,
costPer1kOutputTokens = 0,
} = req.body;
// 중복 이름 확인
const existing = await LLMProvider.findOne({ where: { name } });
if (existing) {
return res.status(409).json({
success: false,
error: {
code: 'PROVIDER_EXISTS',
message: '이미 존재하는 프로바이더 이름입니다.',
},
});
}
const provider = await LLMProvider.create({
name,
displayName,
endpoint,
apiKey,
modelName,
priority,
maxTokens,
temperature,
timeoutMs,
costPer1kInputTokens,
costPer1kOutputTokens,
isActive: true,
isHealthy: true,
});
logger.info(`LLM 프로바이더 추가: ${name} (${modelName})`);
return res.status(201).json({
success: true,
data: {
id: provider.id,
name: provider.name,
displayName: provider.displayName,
modelName: provider.modelName,
priority: provider.priority,
isActive: provider.isActive,
message: 'LLM 프로바이더가 추가되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
/**
* LLM 프로바이더 수정
*/
exports.updateProvider = async (req, res, next) => {
try {
const { id } = req.params;
const updates = req.body;
const provider = await LLMProvider.findByPk(id);
if (!provider) {
return res.status(404).json({
success: false,
error: {
code: 'PROVIDER_NOT_FOUND',
message: 'LLM 프로바이더를 찾을 수 없습니다.',
},
});
}
// 허용된 필드만 업데이트
const allowedFields = [
'displayName',
'endpoint',
'apiKey',
'modelName',
'priority',
'maxTokens',
'temperature',
'timeoutMs',
'costPer1kInputTokens',
'costPer1kOutputTokens',
'isActive',
'isHealthy',
];
allowedFields.forEach((field) => {
if (updates[field] !== undefined) {
provider[field] = updates[field];
}
});
await provider.save();
logger.info(`LLM 프로바이더 수정: ${provider.name}`);
return res.json({
success: true,
data: {
id: provider.id,
name: provider.name,
displayName: provider.displayName,
modelName: provider.modelName,
isActive: provider.isActive,
message: 'LLM 프로바이더가 수정되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
/**
* LLM 프로바이더 삭제
*/
exports.deleteProvider = async (req, res, next) => {
try {
const { id } = req.params;
const provider = await LLMProvider.findByPk(id);
if (!provider) {
return res.status(404).json({
success: false,
error: {
code: 'PROVIDER_NOT_FOUND',
message: 'LLM 프로바이더를 찾을 수 없습니다.',
},
});
}
const providerName = provider.name;
await provider.destroy();
logger.info(`LLM 프로바이더 삭제: ${providerName}`);
return res.json({
success: true,
data: {
message: 'LLM 프로바이더가 삭제되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
// ===== 사용자 관리 =====
/**
* 사용자 목록 조회
*/
exports.getUsers = async (req, res, next) => {
try {
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 100;
const offset = (page - 1) * limit;
const { count, rows: users } = await User.findAndCountAll({
attributes: [
'id',
'email',
'name',
'role',
'status',
'plan',
'monthlyTokenLimit',
'lastLoginAt',
'createdAt',
],
order: [['createdAt', 'DESC']],
limit,
offset,
});
// 페이지네이션 없이 간단한 배열로 반환 (프론트엔드 호환)
return res.json({
success: true,
data: users,
});
} catch (error) {
return next(error);
}
};
/**
* 사용자 정보 수정
*/
exports.updateUser = async (req, res, next) => {
try {
const { id } = req.params;
const { role, status, plan, monthlyTokenLimit } = req.body;
const user = await User.findByPk(id);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
if (role) user.role = role;
if (status) user.status = status;
if (plan) user.plan = plan;
if (monthlyTokenLimit !== undefined) user.monthlyTokenLimit = monthlyTokenLimit;
await user.save();
logger.info(`사용자 정보 수정: ${user.email} (role: ${user.role}, status: ${user.status})`);
return res.json({
success: true,
data: user.toSafeJSON(),
});
} catch (error) {
return next(error);
}
};
// ===== 시스템 통계 =====
/**
* 사용자별 사용량 통계
*/
exports.getUsageByUser = async (req, res, next) => {
try {
const days = parseInt(req.query.days, 10) || 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
startDate.setHours(0, 0, 0, 0);
// 사용자별 집계 (raw SQL 사용)
const userStats = await UsageLog.sequelize.query(`
SELECT
u.id as "userId",
u.email,
u.name,
COALESCE(SUM(ul.total_tokens), 0) as "totalTokens",
COALESCE(SUM(ul.cost_usd), 0) as "totalCost",
COUNT(ul.id) as "requestCount"
FROM users u
LEFT JOIN usage_logs ul ON u.id = ul.user_id AND ul.created_at >= :startDate
GROUP BY u.id, u.email, u.name
HAVING COUNT(ul.id) > 0
ORDER BY SUM(ul.total_tokens) DESC NULLS LAST
`, {
replacements: { startDate },
type: UsageLog.sequelize.QueryTypes.SELECT,
});
// 데이터 정리
const result = userStats.map((stat) => ({
userId: stat.userId,
email: stat.email || 'Unknown',
name: stat.name || '',
totalTokens: parseInt(stat.totalTokens, 10) || 0,
totalCost: parseFloat(stat.totalCost) || 0,
requestCount: parseInt(stat.requestCount, 10) || 0,
}));
return res.json({
success: true,
data: result,
});
} catch (error) {
return next(error);
}
};
/**
* 프로바이더별 사용량 통계
*/
exports.getUsageByProvider = async (req, res, next) => {
try {
const days = parseInt(req.query.days, 10) || 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
startDate.setHours(0, 0, 0, 0);
// 프로바이더별 집계 (컬럼명 수정: providerName, modelName)
const providerStats = await UsageLog.findAll({
where: {
createdAt: { [Op.gte]: startDate },
},
attributes: [
'providerName',
'modelName',
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('prompt_tokens')), 'promptTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('completion_tokens')), 'completionTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
[UsageLog.sequelize.fn('AVG', UsageLog.sequelize.col('response_time_ms')), 'avgResponseTime'],
],
group: ['providerName', 'modelName'],
order: [[UsageLog.sequelize.literal('"totalTokens"'), 'DESC']],
raw: true,
});
// 데이터 정리
const result = providerStats.map((stat) => ({
provider: stat.providerName || 'Unknown',
model: stat.modelName || 'Unknown',
totalTokens: parseInt(stat.totalTokens, 10) || 0,
promptTokens: parseInt(stat.promptTokens, 10) || 0,
completionTokens: parseInt(stat.completionTokens, 10) || 0,
totalCost: parseFloat(stat.totalCost) || 0,
requestCount: parseInt(stat.requestCount, 10) || 0,
avgResponseTime: Math.round(parseFloat(stat.avgResponseTime) || 0),
}));
return res.json({
success: true,
data: result,
});
} catch (error) {
return next(error);
}
};
/**
* 시스템 통계 조회
*/
exports.getStats = async (req, res, next) => {
try {
// 전체 사용자 수
const totalUsers = await User.count();
const activeUsers = await User.count({ where: { status: 'active' } });
// 전체 API 키 수
const totalApiKeys = await ApiKey.count();
const activeApiKeys = await ApiKey.count({ where: { status: 'active' } });
// 오늘 사용량
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayUsage = await UsageLog.findOne({
where: {
createdAt: { [Op.gte]: today },
},
attributes: [
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
],
raw: true,
});
// 이번 달 사용량
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const monthlyUsage = await UsageLog.findOne({
where: {
createdAt: { [Op.gte]: monthStart },
},
attributes: [
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
],
raw: true,
});
// 활성 프로바이더 수
const activeProviders = await LLMProvider.count({ where: { isActive: true, isHealthy: true } });
return res.json({
success: true,
data: {
users: {
total: totalUsers,
active: activeUsers,
},
apiKeys: {
total: totalApiKeys,
active: activeApiKeys,
},
providers: {
active: activeProviders,
},
usage: {
today: {
tokens: parseInt(todayUsage?.totalTokens, 10) || 0,
cost: parseFloat(todayUsage?.totalCost) || 0,
requests: parseInt(todayUsage?.requestCount, 10) || 0,
},
monthly: {
tokens: parseInt(monthlyUsage?.totalTokens, 10) || 0,
cost: parseFloat(monthlyUsage?.totalCost) || 0,
requests: parseInt(monthlyUsage?.requestCount, 10) || 0,
},
},
},
});
} catch (error) {
return next(error);
}
};
@@ -1,215 +0,0 @@
// src/controllers/api-key.controller.js
// API 키 컨트롤러
const { ApiKey } = require('../models');
const logger = require('../config/logger.config');
/**
* API 키 발급
*/
exports.create = async (req, res, next) => {
try {
const { name, expiresInDays, permissions } = req.body;
const userId = req.user.userId;
// API 키 생성
const rawKey = ApiKey.generateKey();
const keyHash = ApiKey.hashKey(rawKey);
const keyPrefix = rawKey.substring(0, 12);
// 만료 일시 계산
let expiresAt = null;
if (expiresInDays) {
expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
}
const apiKey = await ApiKey.create({
userId,
name,
keyPrefix,
keyHash,
permissions: permissions || ['chat:read', 'chat:write'],
expiresAt,
});
logger.info(`API 키 발급: ${name} (user: ${userId})`);
// 주의: 원본 키는 이 응답에서만 반환됨 (다시 조회 불가)
return res.status(201).json({
success: true,
data: {
id: apiKey.id,
name: apiKey.name,
key: rawKey, // 원본 키 (한 번만 표시)
keyPrefix: apiKey.keyPrefix,
permissions: apiKey.permissions,
expiresAt: apiKey.expiresAt,
createdAt: apiKey.createdAt,
message: '⚠️ API 키는 이 응답에서만 확인할 수 있습니다. 안전한 곳에 저장하세요.',
},
});
} catch (error) {
return next(error);
}
};
/**
* API 키 목록 조회
*/
exports.list = async (req, res, next) => {
try {
const userId = req.user.userId;
const apiKeys = await ApiKey.findAll({
where: { userId },
attributes: [
'id',
'name',
'keyPrefix',
'permissions',
'rateLimit',
'status',
'expiresAt',
'lastUsedAt',
'totalRequests',
'createdAt',
],
order: [['createdAt', 'DESC']],
});
return res.json({
success: true,
data: apiKeys,
});
} catch (error) {
return next(error);
}
};
/**
* API 키 상세 조회
*/
exports.get = async (req, res, next) => {
try {
const { id } = req.params;
const userId = req.user.userId;
const apiKey = await ApiKey.findOne({
where: { id, userId },
attributes: [
'id',
'name',
'keyPrefix',
'permissions',
'rateLimit',
'status',
'expiresAt',
'lastUsedAt',
'totalRequests',
'createdAt',
'updatedAt',
],
});
if (!apiKey) {
return res.status(404).json({
success: false,
error: {
code: 'API_KEY_NOT_FOUND',
message: 'API 키를 찾을 수 없습니다.',
},
});
}
return res.json({
success: true,
data: apiKey,
});
} catch (error) {
return next(error);
}
};
/**
* API 키 수정
*/
exports.update = async (req, res, next) => {
try {
const { id } = req.params;
const { name, status } = req.body;
const userId = req.user.userId;
const apiKey = await ApiKey.findOne({
where: { id, userId },
});
if (!apiKey) {
return res.status(404).json({
success: false,
error: {
code: 'API_KEY_NOT_FOUND',
message: 'API 키를 찾을 수 없습니다.',
},
});
}
if (name) apiKey.name = name;
if (status) apiKey.status = status;
await apiKey.save();
logger.info(`API 키 수정: ${apiKey.name} (id: ${id})`);
return res.json({
success: true,
data: {
id: apiKey.id,
name: apiKey.name,
keyPrefix: apiKey.keyPrefix,
status: apiKey.status,
updatedAt: apiKey.updatedAt,
},
});
} catch (error) {
return next(error);
}
};
/**
* API 키 폐기
*/
exports.revoke = async (req, res, next) => {
try {
const { id } = req.params;
const userId = req.user.userId;
const apiKey = await ApiKey.findOne({
where: { id, userId },
});
if (!apiKey) {
return res.status(404).json({
success: false,
error: {
code: 'API_KEY_NOT_FOUND',
message: 'API 키를 찾을 수 없습니다.',
},
});
}
apiKey.status = 'revoked';
await apiKey.save();
logger.info(`API 키 폐기: ${apiKey.name} (id: ${id})`);
return res.json({
success: true,
data: {
message: 'API 키가 폐기되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
@@ -1,195 +0,0 @@
// src/controllers/auth.controller.js
// 인증 컨트롤러
const jwt = require('jsonwebtoken');
const { User } = require('../models');
const logger = require('../config/logger.config');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-refresh-secret';
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '30d';
/**
* JWT 토큰 생성
*/
function generateTokens(user) {
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
const refreshToken = jwt.sign(
{ userId: user.id },
JWT_REFRESH_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
);
return { accessToken, refreshToken };
}
/**
* 회원가입
*/
exports.register = async (req, res, next) => {
try {
const { email, password, name } = req.body;
// 이메일 중복 확인
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
return res.status(409).json({
success: false,
error: {
code: 'EMAIL_ALREADY_EXISTS',
message: '이미 등록된 이메일입니다.',
},
});
}
// 사용자 생성
const user = await User.create({
email,
password,
name,
});
// 토큰 생성
const tokens = generateTokens(user);
logger.info(`새 사용자 가입: ${email}`);
return res.status(201).json({
success: true,
data: {
user: user.toSafeJSON(),
...tokens,
},
});
} catch (error) {
return next(error);
}
};
/**
* 로그인
*/
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
// 사용자 조회
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_CREDENTIALS',
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
},
});
}
// 비밀번호 검증
const isValidPassword = await user.validatePassword(password);
if (!isValidPassword) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_CREDENTIALS',
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
},
});
}
// 계정 상태 확인
if (user.status !== 'active') {
return res.status(403).json({
success: false,
error: {
code: 'ACCOUNT_INACTIVE',
message: '계정이 비활성화되었습니다. 관리자에게 문의하세요.',
},
});
}
// 마지막 로그인 시간 업데이트
user.lastLoginAt = new Date();
await user.save();
// 토큰 생성
const tokens = generateTokens(user);
logger.info(`사용자 로그인: ${email}`);
return res.json({
success: true,
data: {
user: user.toSafeJSON(),
...tokens,
},
});
} catch (error) {
return next(error);
}
};
/**
* 토큰 갱신
*/
exports.refresh = async (req, res, next) => {
try {
const { refreshToken } = req.body;
// 리프레시 토큰 검증
let decoded;
try {
decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
} catch (error) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_REFRESH_TOKEN',
message: '유효하지 않은 리프레시 토큰입니다.',
},
});
}
// 사용자 조회
const user = await User.findByPk(decoded.userId);
if (!user || user.status !== 'active') {
return res.status(401).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 새 토큰 생성
const tokens = generateTokens(user);
return res.json({
success: true,
data: tokens,
});
} catch (error) {
return next(error);
}
};
/**
* 로그아웃
*/
exports.logout = async (req, res) => {
// 클라이언트에서 토큰 삭제 처리
// 서버에서는 특별한 처리 없음 (필요시 블랙리스트 구현)
return res.json({
success: true,
data: {
message: '로그아웃되었습니다.',
},
});
};
@@ -1,152 +0,0 @@
// src/controllers/chat.controller.js
// 채팅 컨트롤러 (OpenAI 호환 API)
const llmService = require('../services/llm.service');
const logger = require('../config/logger.config');
/**
* 채팅 완성 API (OpenAI 호환)
* POST /api/v1/chat/completions
*/
exports.completions = async (req, res, next) => {
try {
const {
model = 'gemini-2.0-flash',
messages,
temperature = 0.7,
max_tokens = 4096,
stream = false,
} = req.body;
const startTime = Date.now();
// 스트리밍 응답 처리
if (stream) {
return handleStreamingResponse(req, res, {
model,
messages,
temperature,
maxTokens: max_tokens,
});
}
// 일반 응답 처리
const result = await llmService.chat({
model,
messages,
temperature,
maxTokens: max_tokens,
userId: req.user.id,
apiKeyId: req.apiKey?.id,
});
const responseTime = Date.now() - startTime;
// 사용량 정보 저장 (미들웨어에서 처리)
req.usageData = {
providerId: result.providerId,
providerName: result.provider,
modelName: result.model,
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
costUsd: result.cost,
responseTimeMs: responseTime,
success: true,
};
// OpenAI 호환 응답 형식
return res.json({
id: `chatcmpl-${Date.now()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: result.model,
choices: [
{
index: 0,
message: {
role: 'assistant',
content: result.text,
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: result.usage.promptTokens,
completion_tokens: result.usage.completionTokens,
total_tokens: result.usage.totalTokens,
},
});
} catch (error) {
logger.error('채팅 완성 오류:', error);
// 사용량 정보 저장 (실패)
req.usageData = {
success: false,
errorMessage: error.message,
};
return next(error);
}
};
/**
* 스트리밍 응답 처리
*/
async function handleStreamingResponse(req, res, params) {
const { model, messages, temperature, maxTokens } = params;
// SSE 헤더 설정
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
// 스트리밍 응답 생성
const stream = await llmService.chatStream({
model,
messages,
temperature,
maxTokens,
userId: req.user.id,
apiKeyId: req.apiKey?.id,
});
// 스트림 이벤트 처리
for await (const chunk of stream) {
const data = {
id: `chatcmpl-${Date.now()}`,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
delta: {
content: chunk.text,
},
finish_reason: chunk.done ? 'stop' : null,
},
],
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
// 스트림 종료
res.write('data: [DONE]\n\n');
res.end();
} catch (error) {
logger.error('스트리밍 오류:', error);
const errorData = {
error: {
message: error.message,
type: 'server_error',
},
};
res.write(`data: ${JSON.stringify(errorData)}\n\n`);
res.end();
}
}
@@ -1,67 +0,0 @@
// src/controllers/model.controller.js
// 모델 컨트롤러
const { LLMProvider } = require('../models');
/**
* 사용 가능한 모델 목록 조회
*/
exports.list = async (req, res, next) => {
try {
const providers = await LLMProvider.getActiveProviders();
// OpenAI 호환 형식으로 변환
const models = providers.map((provider) => ({
id: provider.modelName,
object: 'model',
created: Math.floor(new Date(provider.createdAt).getTime() / 1000),
owned_by: provider.name,
permission: [],
root: provider.modelName,
parent: null,
}));
return res.json({
object: 'list',
data: models,
});
} catch (error) {
return next(error);
}
};
/**
* 모델 상세 정보 조회
*/
exports.get = async (req, res, next) => {
try {
const { id } = req.params;
const provider = await LLMProvider.findOne({
where: { modelName: id, isActive: true },
});
if (!provider) {
return res.status(404).json({
error: {
message: `모델 '${id}'을(를) 찾을 수 없습니다.`,
type: 'invalid_request_error',
param: 'model',
code: 'model_not_found',
},
});
}
return res.json({
id: provider.modelName,
object: 'model',
created: Math.floor(new Date(provider.createdAt).getTime() / 1000),
owned_by: provider.name,
permission: [],
root: provider.modelName,
parent: null,
});
} catch (error) {
return next(error);
}
};
@@ -1,177 +0,0 @@
// src/controllers/usage.controller.js
// 사용량 컨트롤러
const { UsageLog, User } = require('../models');
const { Op } = require('sequelize');
/**
* 사용량 요약 조회
*/
exports.getSummary = async (req, res, next) => {
try {
const userId = req.user.userId;
// 사용자 정보 조회
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 이번 달 사용량
const now = new Date();
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(
userId,
now.getFullYear(),
now.getMonth() + 1
);
// 오늘 사용량
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(todayStart);
todayEnd.setDate(todayEnd.getDate() + 1);
const todayUsage = await UsageLog.findOne({
where: {
userId,
createdAt: {
[Op.between]: [todayStart, todayEnd],
},
},
attributes: [
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
],
raw: true,
});
return res.json({
success: true,
data: {
plan: user.plan,
limit: {
monthly: user.monthlyTokenLimit,
remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens),
},
usage: {
today: {
tokens: parseInt(todayUsage?.totalTokens, 10) || 0,
cost: parseFloat(todayUsage?.totalCost) || 0,
requests: parseInt(todayUsage?.requestCount, 10) || 0,
},
monthly: monthlyUsage,
},
},
});
} catch (error) {
return next(error);
}
};
/**
* 일별 사용량 조회
*/
exports.getDailyUsage = async (req, res, next) => {
try {
const userId = req.user.userId;
const { startDate, endDate } = req.query;
// 기본값: 최근 30일
const end = endDate ? new Date(endDate) : new Date();
const start = startDate ? new Date(startDate) : new Date(end);
if (!startDate) {
start.setDate(start.getDate() - 30);
}
const dailyUsage = await UsageLog.getDailyUsageByUser(userId, start, end);
return res.json({
success: true,
data: {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
usage: dailyUsage,
},
});
} catch (error) {
return next(error);
}
};
/**
* 월별 사용량 조회
*/
exports.getMonthlyUsage = async (req, res, next) => {
try {
const userId = req.user.userId;
const now = new Date();
const year = parseInt(req.query.year, 10) || now.getFullYear();
const month = parseInt(req.query.month, 10) || (now.getMonth() + 1);
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(userId, year, month);
return res.json({
success: true,
data: {
year,
month,
usage: monthlyUsage,
},
});
} catch (error) {
return next(error);
}
};
/**
* 사용량 로그 목록 조회
*/
exports.getLogs = async (req, res, next) => {
try {
const userId = req.user.userId;
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 20;
const offset = (page - 1) * limit;
const { count, rows: logs } = await UsageLog.findAndCountAll({
where: { userId },
attributes: [
'id',
'providerName',
'modelName',
'promptTokens',
'completionTokens',
'totalTokens',
'costUsd',
'responseTimeMs',
'success',
'errorMessage',
'createdAt',
],
order: [['createdAt', 'DESC']],
limit,
offset,
});
return res.json({
success: true,
data: {
logs,
pagination: {
total: count,
page,
limit,
totalPages: Math.ceil(count / limit),
},
},
});
} catch (error) {
return next(error);
}
};
@@ -1,113 +0,0 @@
// src/controllers/user.controller.js
// 사용자 컨트롤러
const { User, UsageLog } = require('../models');
const logger = require('../config/logger.config');
/**
* 내 정보 조회
*/
exports.getMe = async (req, res, next) => {
try {
const user = await User.findByPk(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 이번 달 사용량 조회
const now = new Date();
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(
user.id,
now.getFullYear(),
now.getMonth() + 1
);
return res.json({
success: true,
data: {
...user.toSafeJSON(),
usage: {
monthly: monthlyUsage,
limit: user.monthlyTokenLimit,
remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens),
},
},
});
} catch (error) {
return next(error);
}
};
/**
* 내 정보 수정
*/
exports.updateMe = async (req, res, next) => {
try {
const { name, password } = req.body;
const user = await User.findByPk(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 업데이트할 필드만 설정
if (name) user.name = name;
if (password) user.password = password;
await user.save();
logger.info(`사용자 정보 수정: ${user.email}`);
return res.json({
success: true,
data: user.toSafeJSON(),
});
} catch (error) {
return next(error);
}
};
/**
* 계정 삭제
*/
exports.deleteMe = async (req, res, next) => {
try {
const user = await User.findByPk(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 소프트 삭제 (상태 변경)
user.status = 'inactive';
await user.save();
logger.info(`사용자 계정 삭제: ${user.email}`);
return res.json({
success: true,
data: {
message: '계정이 삭제되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
@@ -1,257 +0,0 @@
// src/middlewares/auth.middleware.js
// 인증 미들웨어
const jwt = require('jsonwebtoken');
const { ApiKey, User } = require('../models');
const logger = require('../config/logger.config');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
/**
* JWT 토큰 인증 미들웨어
* Authorization: Bearer <JWT_TOKEN>
*/
exports.authenticateJWT = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: {
code: 'UNAUTHORIZED',
message: '인증 토큰이 필요합니다.',
},
});
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
return next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: {
code: 'TOKEN_EXPIRED',
message: '토큰이 만료되었습니다.',
},
});
}
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.',
},
});
}
} catch (error) {
return next(error);
}
};
/**
* API 키 인증 미들웨어
* Authorization: Bearer <API_KEY>
*/
exports.authenticateApiKey = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: {
message: 'API 키가 필요합니다.',
type: 'invalid_request_error',
code: 'missing_api_key',
},
});
}
const apiKeyValue = authHeader.substring(7);
// API 키 접두사 확인
const prefix = process.env.API_KEY_PREFIX || 'sk-';
if (!apiKeyValue.startsWith(prefix)) {
return res.status(401).json({
error: {
message: '유효하지 않은 API 키 형식입니다.',
type: 'invalid_request_error',
code: 'invalid_api_key',
},
});
}
// API 키 조회
const apiKey = await ApiKey.findByKey(apiKeyValue);
if (!apiKey) {
return res.status(401).json({
error: {
message: '유효하지 않은 API 키입니다.',
type: 'invalid_request_error',
code: 'invalid_api_key',
},
});
}
// 만료 확인
if (apiKey.isExpired()) {
return res.status(401).json({
error: {
message: 'API 키가 만료되었습니다.',
type: 'invalid_request_error',
code: 'expired_api_key',
},
});
}
// 사용자 상태 확인
if (apiKey.user.status !== 'active') {
return res.status(403).json({
error: {
message: '계정이 비활성화되었습니다.',
type: 'invalid_request_error',
code: 'account_inactive',
},
});
}
// 사용 기록 업데이트
await apiKey.recordUsage();
// 요청 객체에 사용자 및 API 키 정보 추가
req.user = {
id: apiKey.user.id,
userId: apiKey.user.id,
email: apiKey.user.email,
role: apiKey.user.role,
plan: apiKey.user.plan,
};
req.apiKey = apiKey;
return next();
} catch (error) {
logger.error('API 키 인증 오류:', error);
return next(error);
}
};
/**
* 관리자 권한 확인 미들웨어
*/
exports.requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
error: {
code: 'FORBIDDEN',
message: '관리자 권한이 필요합니다.',
},
});
}
return next();
};
/**
* JWT 또는 API 키 인증 미들웨어
* JWT 토큰과 API 키 모두 허용
*/
exports.authenticateAny = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: {
code: 'UNAUTHORIZED',
message: '인증이 필요합니다.',
},
});
}
const token = authHeader.substring(7);
const prefix = process.env.API_KEY_PREFIX || 'sk-';
// API 키인 경우
if (token.startsWith(prefix)) {
const apiKey = await ApiKey.findByKey(token);
if (!apiKey) {
return res.status(401).json({
error: {
message: '유효하지 않은 API 키입니다.',
type: 'invalid_request_error',
code: 'invalid_api_key',
},
});
}
if (apiKey.isExpired()) {
return res.status(401).json({
error: {
message: 'API 키가 만료되었습니다.',
type: 'invalid_request_error',
code: 'expired_api_key',
},
});
}
if (apiKey.user.status !== 'active') {
return res.status(403).json({
error: {
message: '계정이 비활성화되었습니다.',
type: 'invalid_request_error',
code: 'account_inactive',
},
});
}
await apiKey.recordUsage();
req.user = {
id: apiKey.user.id,
userId: apiKey.user.id,
email: apiKey.user.email,
role: apiKey.user.role,
plan: apiKey.user.plan,
};
req.apiKey = apiKey;
return next();
}
// JWT 토큰인 경우
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
return next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: {
code: 'TOKEN_EXPIRED',
message: '토큰이 만료되었습니다.',
},
});
}
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.',
},
});
}
} catch (error) {
return next(error);
}
};
@@ -1,80 +0,0 @@
// src/middlewares/error-handler.middleware.js
// 에러 핸들러 미들웨어
const logger = require('../config/logger.config');
/**
* 전역 에러 핸들러
*/
module.exports = (err, req, res, _next) => {
// 에러 로깅
logger.error('에러 발생:', {
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
// Sequelize 유효성 검사 에러
if (err.name === 'SequelizeValidationError') {
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: '데이터 유효성 검사 실패',
details: err.errors.map((e) => ({
field: e.path,
message: e.message,
})),
},
});
}
// Sequelize 고유 제약 조건 에러
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(409).json({
success: false,
error: {
code: 'DUPLICATE_ENTRY',
message: '중복된 데이터가 존재합니다.',
details: err.errors.map((e) => ({
field: e.path,
message: e.message,
})),
},
});
}
// JWT 에러
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.',
},
});
}
// 기본 에러 응답
const statusCode = err.statusCode || 500;
const message = err.message || '서버 오류가 발생했습니다.';
// 프로덕션 환경에서는 상세 에러 숨김
const response = {
success: false,
error: {
code: err.code || 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production' && statusCode === 500
? '서버 오류가 발생했습니다.'
: message,
},
};
// 개발 환경에서는 스택 트레이스 포함
if (process.env.NODE_ENV === 'development') {
response.error.stack = err.stack;
}
return res.status(statusCode).json(response);
};
@@ -1,50 +0,0 @@
// src/middlewares/usage-logger.middleware.js
// 사용량 로깅 미들웨어
const { UsageLog } = require('../models');
const logger = require('../config/logger.config');
/**
* 사용량 로깅 미들웨어
* 응답 완료 후 사용량 정보를 데이터베이스에 저장
*/
exports.usageLogger = (req, res, next) => {
// 응답 완료 후 처리
res.on('finish', async () => {
try {
// 사용량 데이터가 없으면 스킵
if (!req.usageData) {
return;
}
const usageData = {
userId: req.user?.id || req.user?.userId,
apiKeyId: req.apiKey?.id || null,
providerId: req.usageData.providerId || null,
providerName: req.usageData.providerName || null,
modelName: req.usageData.modelName || null,
promptTokens: req.usageData.promptTokens || 0,
completionTokens: req.usageData.completionTokens || 0,
totalTokens: req.usageData.totalTokens || 0,
costUsd: req.usageData.costUsd || 0,
responseTimeMs: req.usageData.responseTimeMs || null,
success: req.usageData.success !== false,
errorMessage: req.usageData.errorMessage || null,
requestIp: req.ip || req.connection?.remoteAddress,
userAgent: req.headers['user-agent'] || null,
};
await UsageLog.create(usageData);
logger.debug('사용량 로그 저장:', {
userId: usageData.userId,
tokens: usageData.totalTokens,
cost: usageData.costUsd,
});
} catch (error) {
logger.error('사용량 로그 저장 실패:', error);
}
});
next();
};
@@ -1,30 +0,0 @@
// src/middlewares/validation.middleware.js
// 유효성 검사 미들웨어
const { validationResult } = require('express-validator');
/**
* 요청 유효성 검사 결과 처리
*/
exports.validateRequest = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const formattedErrors = errors.array().map((error) => ({
field: error.path,
message: error.msg,
value: error.value,
}));
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: '입력값이 올바르지 않습니다.',
details: formattedErrors,
},
});
}
return next();
};
-130
View File
@@ -1,130 +0,0 @@
// src/models/api-key.model.js
// API 키 모델
const { DataTypes } = require('sequelize');
const crypto = require('crypto');
module.exports = (sequelize) => {
const ApiKey = sequelize.define('ApiKey', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
comment: '소유자 사용자 ID',
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
comment: 'API 키 이름 (사용자 지정)',
},
keyPrefix: {
type: DataTypes.STRING(12),
allowNull: false,
comment: 'API 키 접두사 (표시용)',
},
keyHash: {
type: DataTypes.STRING(64),
allowNull: false,
unique: true,
comment: 'API 키 해시 (SHA-256)',
},
permissions: {
type: DataTypes.JSONB,
defaultValue: ['chat:read', 'chat:write'],
comment: '권한 목록',
},
rateLimit: {
type: DataTypes.INTEGER,
defaultValue: 60, // 분당 60회
comment: '분당 요청 제한',
},
status: {
type: DataTypes.ENUM('active', 'revoked', 'expired'),
defaultValue: 'active',
comment: 'API 키 상태',
},
expiresAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '만료 일시 (null이면 무기한)',
},
lastUsedAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '마지막 사용 시간',
},
totalRequests: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '총 요청 수',
},
}, {
tableName: 'api_keys',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['key_hash'],
unique: true,
},
{
fields: ['user_id'],
},
{
fields: ['status'],
},
],
});
// 클래스 메서드: API 키 생성
ApiKey.generateKey = function() {
const prefix = process.env.API_KEY_PREFIX || 'sk-';
const length = parseInt(process.env.API_KEY_LENGTH, 10) || 48;
const randomPart = crypto.randomBytes(length).toString('base64url').slice(0, length);
return `${prefix}${randomPart}`;
};
// 클래스 메서드: API 키 해시 생성
ApiKey.hashKey = function(key) {
return crypto.createHash('sha256').update(key).digest('hex');
};
// 클래스 메서드: API 키로 조회
ApiKey.findByKey = async function(key) {
const keyHash = this.hashKey(key);
const apiKey = await this.findOne({
where: { keyHash, status: 'active' },
});
if (apiKey) {
// 사용자 정보 별도 조회
const { User } = require('./index');
apiKey.user = await User.findByPk(apiKey.userId);
}
return apiKey;
};
// 인스턴스 메서드: 사용 기록 업데이트
ApiKey.prototype.recordUsage = async function() {
this.lastUsedAt = new Date();
this.totalRequests += 1;
await this.save();
};
// 인스턴스 메서드: 만료 여부 확인
ApiKey.prototype.isExpired = function() {
if (!this.expiresAt) return false;
return new Date() > this.expiresAt;
};
return ApiKey;
};
-55
View File
@@ -1,55 +0,0 @@
// src/models/index.js
// Sequelize 모델 인덱스
const { Sequelize } = require('sequelize');
const config = require('../config/database.config');
const env = process.env.NODE_ENV || 'development';
const dbConfig = config[env];
// Sequelize 인스턴스 생성
const sequelize = new Sequelize(
dbConfig.database,
dbConfig.username,
dbConfig.password,
{
host: dbConfig.host,
port: dbConfig.port,
dialect: dbConfig.dialect,
logging: dbConfig.logging,
pool: dbConfig.pool,
dialectOptions: dbConfig.dialectOptions,
}
);
// 모델 임포트
const User = require('./user.model')(sequelize);
const ApiKey = require('./api-key.model')(sequelize);
const UsageLog = require('./usage-log.model')(sequelize);
const LLMProvider = require('./llm-provider.model')(sequelize);
// 관계 설정
// User - ApiKey (1:N)
User.hasMany(ApiKey, { foreignKey: 'userId', as: 'apiKeys' });
ApiKey.belongsTo(User, { foreignKey: 'userId', as: 'user' });
// User - UsageLog (1:N)
User.hasMany(UsageLog, { foreignKey: 'userId', as: 'usageLogs' });
UsageLog.belongsTo(User, { foreignKey: 'userId', as: 'user' });
// ApiKey - UsageLog (1:N)
ApiKey.hasMany(UsageLog, { foreignKey: 'apiKeyId', as: 'usageLogs' });
UsageLog.belongsTo(ApiKey, { foreignKey: 'apiKeyId', as: 'apiKey' });
// LLMProvider - UsageLog (1:N)
LLMProvider.hasMany(UsageLog, { foreignKey: 'providerId', as: 'usageLogs' });
UsageLog.belongsTo(LLMProvider, { foreignKey: 'providerId', as: 'provider' });
module.exports = {
sequelize,
Sequelize,
User,
ApiKey,
UsageLog,
LLMProvider,
};
@@ -1,143 +0,0 @@
// src/models/llm-provider.model.js
// LLM 프로바이더 모델
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const LLMProvider = sequelize.define('LLMProvider', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
comment: '프로바이더 이름 (gemini, openai, claude 등)',
},
displayName: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '표시 이름',
},
endpoint: {
type: DataTypes.STRING(500),
allowNull: true,
comment: 'API 엔드포인트 URL',
},
apiKey: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'API 키 (암호화 저장 권장)',
},
modelName: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '기본 모델 이름',
},
priority: {
type: DataTypes.INTEGER,
defaultValue: 100,
comment: '우선순위 (낮을수록 우선)',
},
maxTokens: {
type: DataTypes.INTEGER,
defaultValue: 4096,
comment: '최대 토큰 수',
},
temperature: {
type: DataTypes.FLOAT,
defaultValue: 0.7,
comment: '기본 온도',
},
timeoutMs: {
type: DataTypes.INTEGER,
defaultValue: 60000,
comment: '타임아웃 (밀리초)',
},
costPer1kInputTokens: {
type: DataTypes.DECIMAL(10, 6),
defaultValue: 0,
comment: '입력 토큰 1K당 비용 (USD)',
},
costPer1kOutputTokens: {
type: DataTypes.DECIMAL(10, 6),
defaultValue: 0,
comment: '출력 토큰 1K당 비용 (USD)',
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '활성화 여부',
},
isHealthy: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '건강 상태',
},
lastHealthCheck: {
type: DataTypes.DATE,
allowNull: true,
comment: '마지막 헬스 체크 시간',
},
healthCheckUrl: {
type: DataTypes.STRING(500),
allowNull: true,
comment: '헬스 체크 URL',
},
config: {
type: DataTypes.JSONB,
defaultValue: {},
comment: '추가 설정',
},
}, {
tableName: 'llm_providers',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['name'],
unique: true,
},
{
fields: ['priority'],
},
{
fields: ['is_active', 'is_healthy'],
},
],
});
// 클래스 메서드: 활성 프로바이더 목록 조회 (우선순위 순)
LLMProvider.getActiveProviders = async function() {
return this.findAll({
where: { isActive: true },
order: [['priority', 'ASC']],
});
};
// 클래스 메서드: 건강한 프로바이더 목록 조회
LLMProvider.getHealthyProviders = async function() {
return this.findAll({
where: { isActive: true, isHealthy: true },
order: [['priority', 'ASC']],
});
};
// 인스턴스 메서드: 헬스 상태 업데이트
LLMProvider.prototype.updateHealth = async function(isHealthy) {
this.isHealthy = isHealthy;
this.lastHealthCheck = new Date();
await this.save();
};
// 인스턴스 메서드: 비용 계산
LLMProvider.prototype.calculateCost = function(promptTokens, completionTokens) {
const inputCost = (promptTokens / 1000) * parseFloat(this.costPer1kInputTokens || 0);
const outputCost = (completionTokens / 1000) * parseFloat(this.costPer1kOutputTokens || 0);
return inputCost + outputCost;
};
return LLMProvider;
};
-164
View File
@@ -1,164 +0,0 @@
// src/models/usage-log.model.js
// 사용량 로그 모델
const { DataTypes, Op } = require('sequelize');
module.exports = (sequelize) => {
const UsageLog = sequelize.define('UsageLog', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
comment: '사용자 ID',
},
apiKeyId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'api_keys',
key: 'id',
},
comment: 'API 키 ID',
},
providerId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'llm_providers',
key: 'id',
},
comment: 'LLM 프로바이더 ID',
},
providerName: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'LLM 프로바이더 이름',
},
modelName: {
type: DataTypes.STRING(100),
allowNull: true,
comment: '사용된 모델 이름',
},
promptTokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '프롬프트 토큰 수',
},
completionTokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '완성 토큰 수',
},
totalTokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '총 토큰 수',
},
costUsd: {
type: DataTypes.DECIMAL(10, 6),
defaultValue: 0,
comment: '비용 (USD)',
},
responseTimeMs: {
type: DataTypes.INTEGER,
allowNull: true,
comment: '응답 시간 (밀리초)',
},
success: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '성공 여부',
},
errorMessage: {
type: DataTypes.TEXT,
allowNull: true,
comment: '에러 메시지',
},
requestIp: {
type: DataTypes.STRING(45),
allowNull: true,
comment: '요청 IP 주소',
},
userAgent: {
type: DataTypes.STRING(500),
allowNull: true,
comment: 'User-Agent',
},
}, {
tableName: 'usage_logs',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['user_id'],
},
{
fields: ['api_key_id'],
},
{
fields: ['created_at'],
},
{
fields: ['provider_name'],
},
],
});
// 클래스 메서드: 사용자별 일별 사용량 조회
UsageLog.getDailyUsageByUser = async function(userId, startDate, endDate) {
return this.findAll({
where: {
userId,
createdAt: {
[Op.between]: [startDate, endDate],
},
},
attributes: [
[sequelize.fn('DATE', sequelize.col('created_at')), 'date'],
[sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'],
[sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'],
[sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'],
],
group: [sequelize.fn('DATE', sequelize.col('created_at'))],
order: [[sequelize.fn('DATE', sequelize.col('created_at')), 'ASC']],
raw: true,
});
};
// 클래스 메서드: 사용자별 월간 총 사용량 조회
UsageLog.getMonthlyTotalByUser = async function(userId, year, month) {
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0, 23, 59, 59);
const result = await this.findOne({
where: {
userId,
createdAt: {
[Op.between]: [startDate, endDate],
},
},
attributes: [
[sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'],
[sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'],
[sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'],
],
raw: true,
});
return {
totalTokens: parseInt(result.totalTokens, 10) || 0,
totalCost: parseFloat(result.totalCost) || 0,
requestCount: parseInt(result.requestCount, 10) || 0,
};
};
return UsageLog;
};
-92
View File
@@ -1,92 +0,0 @@
// src/models/user.model.js
// 사용자 모델
const { DataTypes } = require('sequelize');
const bcrypt = require('bcryptjs');
module.exports = (sequelize) => {
const User = sequelize.define('User', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
email: {
type: DataTypes.STRING(255),
allowNull: false,
unique: true,
validate: {
isEmail: true,
},
comment: '이메일 (로그인 ID)',
},
password: {
type: DataTypes.STRING(255),
allowNull: false,
comment: '비밀번호 (해시)',
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '사용자 이름',
},
role: {
type: DataTypes.ENUM('user', 'admin'),
defaultValue: 'user',
comment: '역할 (user: 일반 사용자, admin: 관리자)',
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'suspended'),
defaultValue: 'active',
comment: '계정 상태',
},
plan: {
type: DataTypes.ENUM('free', 'basic', 'pro', 'enterprise'),
defaultValue: 'free',
comment: '요금제 플랜',
},
monthlyTokenLimit: {
type: DataTypes.INTEGER,
defaultValue: 100000, // 무료 플랜 기본 10만 토큰
comment: '월간 토큰 한도',
},
lastLoginAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '마지막 로그인 시간',
},
}, {
tableName: 'users',
timestamps: true,
underscored: true,
hooks: {
// 비밀번호 해싱
beforeCreate: async (user) => {
if (user.password) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
},
beforeUpdate: async (user) => {
if (user.changed('password')) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
},
},
});
// 인스턴스 메서드: 비밀번호 검증
User.prototype.validatePassword = async function(password) {
return bcrypt.compare(password, this.password);
};
// 인스턴스 메서드: 안전한 JSON 변환 (비밀번호 제외)
User.prototype.toSafeJSON = function() {
const values = { ...this.get() };
delete values.password;
return values;
};
return User;
};
-151
View File
@@ -1,151 +0,0 @@
// src/routes/admin.routes.js
// 관리자 라우트
const express = require('express');
const { body, param } = require('express-validator');
const adminController = require('../controllers/admin.controller');
const { authenticateJWT, requireAdmin } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 + 관리자 권한 필요
router.use(authenticateJWT);
router.use(requireAdmin);
// ===== LLM 프로바이더 관리 =====
/**
* GET /api/v1/admin/providers
* LLM 프로바이더 목록 조회
*/
router.get('/providers', adminController.getProviders);
/**
* POST /api/v1/admin/providers
* LLM 프로바이더 추가
*/
router.post(
'/providers',
[
body('name')
.trim()
.isLength({ min: 1, max: 50 })
.withMessage('프로바이더 이름은 1-50자 사이여야 합니다'),
body('displayName')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('표시 이름은 1-100자 사이여야 합니다'),
body('modelName')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('모델 이름은 1-100자 사이여야 합니다'),
body('apiKey')
.optional()
.isString(),
body('priority')
.optional()
.isInt({ min: 1, max: 100 }),
validateRequest,
],
adminController.createProvider
);
/**
* PATCH /api/v1/admin/providers/:id
* LLM 프로바이더 수정 (API 키 설정 포함)
*/
router.patch(
'/providers/:id',
[
param('id')
.isUUID()
.withMessage('유효한 프로바이더 ID가 아닙니다'),
body('apiKey')
.optional()
.isString(),
body('modelName')
.optional()
.isString(),
body('isActive')
.optional()
.isBoolean(),
body('priority')
.optional()
.isInt({ min: 1, max: 100 }),
validateRequest,
],
adminController.updateProvider
);
/**
* DELETE /api/v1/admin/providers/:id
* LLM 프로바이더 삭제
*/
router.delete(
'/providers/:id',
[
param('id')
.isUUID()
.withMessage('유효한 프로바이더 ID가 아닙니다'),
validateRequest,
],
adminController.deleteProvider
);
// ===== 사용자 관리 =====
/**
* GET /api/v1/admin/users
* 사용자 목록 조회
*/
router.get('/users', adminController.getUsers);
/**
* PATCH /api/v1/admin/users/:id
* 사용자 정보 수정 (역할, 상태, 플랜 등)
*/
router.patch(
'/users/:id',
[
param('id')
.isUUID()
.withMessage('유효한 사용자 ID가 아닙니다'),
body('role')
.optional()
.isIn(['user', 'admin']),
body('status')
.optional()
.isIn(['active', 'inactive', 'suspended']),
body('plan')
.optional()
.isIn(['free', 'basic', 'pro', 'enterprise']),
body('monthlyTokenLimit')
.optional()
.isInt({ min: 0 }),
validateRequest,
],
adminController.updateUser
);
// ===== 시스템 통계 =====
/**
* GET /api/v1/admin/stats
* 시스템 통계 조회
*/
router.get('/stats', adminController.getStats);
/**
* GET /api/v1/admin/usage/by-user
* 사용자별 사용량 통계
*/
router.get('/usage/by-user', adminController.getUsageByUser);
/**
* GET /api/v1/admin/usage/by-provider
* 프로바이더별 사용량 통계
*/
router.get('/usage/by-provider', adminController.getUsageByProvider);
module.exports = router;
-99
View File
@@ -1,99 +0,0 @@
// src/routes/api-key.routes.js
// API 키 라우트
const express = require('express');
const { body, param } = require('express-validator');
const apiKeyController = require('../controllers/api-key.controller');
const { authenticateJWT } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 적용
router.use(authenticateJWT);
/**
* POST /api/v1/api-keys
* API 키 발급
*/
router.post(
'/',
[
body('name')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('API 키 이름은 1-100자 사이여야 합니다'),
body('expiresInDays')
.optional()
.isInt({ min: 1, max: 365 })
.withMessage('만료 기간은 1-365일 사이여야 합니다'),
body('permissions')
.optional()
.isArray()
.withMessage('권한은 배열이어야 합니다'),
validateRequest,
],
apiKeyController.create
);
/**
* GET /api/v1/api-keys
* API 키 목록 조회
*/
router.get('/', apiKeyController.list);
/**
* GET /api/v1/api-keys/:id
* API 키 상세 조회
*/
router.get(
'/:id',
[
param('id')
.isUUID()
.withMessage('유효한 API 키 ID가 아닙니다'),
validateRequest,
],
apiKeyController.get
);
/**
* PATCH /api/v1/api-keys/:id
* API 키 수정
*/
router.patch(
'/:id',
[
param('id')
.isUUID()
.withMessage('유효한 API 키 ID가 아닙니다'),
body('name')
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('API 키 이름은 1-100자 사이여야 합니다'),
body('status')
.optional()
.isIn(['active', 'revoked'])
.withMessage('상태는 active 또는 revoked여야 합니다'),
validateRequest,
],
apiKeyController.update
);
/**
* DELETE /api/v1/api-keys/:id
* API 키 폐기
*/
router.delete(
'/:id',
[
param('id')
.isUUID()
.withMessage('유효한 API 키 ID가 아닙니다'),
validateRequest,
],
apiKeyController.revoke
);
module.exports = router;
-76
View File
@@ -1,76 +0,0 @@
// src/routes/auth.routes.js
// 인증 라우트
const express = require('express');
const { body } = require('express-validator');
const authController = require('../controllers/auth.controller');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
/**
* POST /api/v1/auth/register
* 회원가입
*/
router.post(
'/register',
[
body('email')
.isEmail()
.normalizeEmail()
.withMessage('유효한 이메일 주소를 입력해주세요'),
body('password')
.isLength({ min: 8 })
.withMessage('비밀번호는 최소 8자 이상이어야 합니다')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'),
body('name')
.trim()
.isLength({ min: 2, max: 100 })
.withMessage('이름은 2-100자 사이여야 합니다'),
validateRequest,
],
authController.register
);
/**
* POST /api/v1/auth/login
* 로그인
*/
router.post(
'/login',
[
body('email')
.isEmail()
.normalizeEmail()
.withMessage('유효한 이메일 주소를 입력해주세요'),
body('password')
.notEmpty()
.withMessage('비밀번호를 입력해주세요'),
validateRequest,
],
authController.login
);
/**
* POST /api/v1/auth/refresh
* 토큰 갱신
*/
router.post(
'/refresh',
[
body('refreshToken')
.notEmpty()
.withMessage('리프레시 토큰을 입력해주세요'),
validateRequest,
],
authController.refresh
);
/**
* POST /api/v1/auth/logout
* 로그아웃
*/
router.post('/logout', authController.logout);
module.exports = router;
-55
View File
@@ -1,55 +0,0 @@
// src/routes/chat.routes.js
// 채팅 API 라우트 (OpenAI 호환)
const express = require('express');
const { body } = require('express-validator');
const chatController = require('../controllers/chat.controller');
const { authenticateAny } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const { usageLogger } = require('../middlewares/usage-logger.middleware');
const router = express.Router();
/**
* POST /api/v1/chat/completions
* 채팅 완성 API (OpenAI 호환)
*
* 인증: Bearer API_KEY 또는 JWT 토큰
*/
router.post(
'/completions',
authenticateAny,
[
body('model')
.optional()
.isString()
.withMessage('모델은 문자열이어야 합니다'),
body('messages')
.isArray({ min: 1 })
.withMessage('메시지 배열이 필요합니다'),
body('messages.*.role')
.isIn(['system', 'user', 'assistant'])
.withMessage('메시지 역할은 system, user, assistant 중 하나여야 합니다'),
body('messages.*.content')
.isString()
.notEmpty()
.withMessage('메시지 내용이 필요합니다'),
body('temperature')
.optional()
.isFloat({ min: 0, max: 2 })
.withMessage('온도는 0-2 사이여야 합니다'),
body('max_tokens')
.optional()
.isInt({ min: 1, max: 128000 })
.withMessage('최대 토큰은 1-128000 사이여야 합니다'),
body('stream')
.optional()
.isBoolean()
.withMessage('스트림은 불리언이어야 합니다'),
validateRequest,
],
usageLogger,
chatController.completions
);
module.exports = router;
-45
View File
@@ -1,45 +0,0 @@
// src/routes/index.js
// API 라우트 인덱스
const express = require('express');
const authRoutes = require('./auth.routes');
const userRoutes = require('./user.routes');
const apiKeyRoutes = require('./api-key.routes');
const chatRoutes = require('./chat.routes');
const usageRoutes = require('./usage.routes');
const modelRoutes = require('./model.routes');
const adminRoutes = require('./admin.routes');
const router = express.Router();
// API 정보
router.get('/', (req, res) => {
res.json({
success: true,
data: {
name: 'AI Assistant API',
version: '1.0.0',
description: 'LLM API Platform - OpenAI 호환 API',
endpoints: {
auth: '/api/v1/auth',
users: '/api/v1/users',
apiKeys: '/api/v1/api-keys',
chat: '/api/v1/chat',
models: '/api/v1/models',
usage: '/api/v1/usage',
},
documentation: 'https://docs.example.com',
},
});
});
// 라우트 등록
router.use('/auth', authRoutes);
router.use('/users', userRoutes);
router.use('/api-keys', apiKeyRoutes);
router.use('/chat', chatRoutes);
router.use('/models', modelRoutes);
router.use('/usage', usageRoutes);
router.use('/admin', adminRoutes);
module.exports = router;
-24
View File
@@ -1,24 +0,0 @@
// src/routes/model.routes.js
// 모델 라우트
const express = require('express');
const modelController = require('../controllers/model.controller');
const { authenticateAny } = require('../middlewares/auth.middleware');
const router = express.Router();
/**
* GET /api/v1/models
* 사용 가능한 모델 목록 조회
* JWT 토큰 또는 API 키로 인증
*/
router.get('/', authenticateAny, modelController.list);
/**
* GET /api/v1/models/:id
* 모델 상세 정보 조회
* JWT 토큰 또는 API 키로 인증
*/
router.get('/:id', authenticateAny, modelController.get);
module.exports = router;
-81
View File
@@ -1,81 +0,0 @@
// src/routes/usage.routes.js
// 사용량 라우트
const express = require('express');
const { query } = require('express-validator');
const usageController = require('../controllers/usage.controller');
const { authenticateJWT } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 적용
router.use(authenticateJWT);
/**
* GET /api/v1/usage
* 사용량 요약 조회
*/
router.get('/', usageController.getSummary);
/**
* GET /api/v1/usage/daily
* 일별 사용량 조회
*/
router.get(
'/daily',
[
query('startDate')
.optional()
.isISO8601()
.withMessage('시작 날짜는 ISO 8601 형식이어야 합니다'),
query('endDate')
.optional()
.isISO8601()
.withMessage('종료 날짜는 ISO 8601 형식이어야 합니다'),
validateRequest,
],
usageController.getDailyUsage
);
/**
* GET /api/v1/usage/monthly
* 월별 사용량 조회
*/
router.get(
'/monthly',
[
query('year')
.optional()
.isInt({ min: 2020, max: 2100 })
.withMessage('연도는 2020-2100 사이여야 합니다'),
query('month')
.optional()
.isInt({ min: 1, max: 12 })
.withMessage('월은 1-12 사이여야 합니다'),
validateRequest,
],
usageController.getMonthlyUsage
);
/**
* GET /api/v1/usage/logs
* 사용량 로그 목록 조회
*/
router.get(
'/logs',
[
query('page')
.optional()
.isInt({ min: 1 })
.withMessage('페이지는 1 이상이어야 합니다'),
query('limit')
.optional()
.isInt({ min: 1, max: 100 })
.withMessage('한도는 1-100 사이여야 합니다'),
validateRequest,
],
usageController.getLogs
);
module.exports = router;
-50
View File
@@ -1,50 +0,0 @@
// src/routes/user.routes.js
// 사용자 라우트
const express = require('express');
const { body } = require('express-validator');
const userController = require('../controllers/user.controller');
const { authenticateJWT } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 적용
router.use(authenticateJWT);
/**
* GET /api/v1/users/me
* 내 정보 조회
*/
router.get('/me', userController.getMe);
/**
* PATCH /api/v1/users/me
* 내 정보 수정
*/
router.patch(
'/me',
[
body('name')
.optional()
.trim()
.isLength({ min: 2, max: 100 })
.withMessage('이름은 2-100자 사이여야 합니다'),
body('password')
.optional()
.isLength({ min: 8 })
.withMessage('비밀번호는 최소 8자 이상이어야 합니다')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'),
validateRequest,
],
userController.updateMe
);
/**
* DELETE /api/v1/users/me
* 계정 삭제
*/
router.delete('/me', userController.deleteMe);
module.exports = router;
@@ -1,74 +0,0 @@
// src/seeders/001-llm-providers.js
// LLM 프로바이더 시드 데이터
const { v4: uuidv4 } = require('uuid');
module.exports = {
up: async (queryInterface, Sequelize) => {
const now = new Date();
await queryInterface.bulkInsert('llm_providers', [
{
id: uuidv4(),
name: 'gemini',
display_name: 'Google Gemini',
endpoint: null, // SDK 사용
api_key: process.env.GEMINI_API_KEY || '',
model_name: 'gemini-2.0-flash',
priority: 1,
max_tokens: 8192,
temperature: 0.7,
timeout_ms: 60000,
cost_per_1k_input_tokens: 0.00025,
cost_per_1k_output_tokens: 0.001,
is_active: true,
is_healthy: true,
config: JSON.stringify({}),
created_at: now,
updated_at: now,
},
{
id: uuidv4(),
name: 'openai',
display_name: 'OpenAI GPT',
endpoint: 'https://api.openai.com/v1/chat/completions',
api_key: process.env.OPENAI_API_KEY || '',
model_name: 'gpt-4o-mini',
priority: 2,
max_tokens: 4096,
temperature: 0.7,
timeout_ms: 60000,
cost_per_1k_input_tokens: 0.00015,
cost_per_1k_output_tokens: 0.0006,
is_active: true,
is_healthy: true,
config: JSON.stringify({}),
created_at: now,
updated_at: now,
},
{
id: uuidv4(),
name: 'claude',
display_name: 'Anthropic Claude',
endpoint: 'https://api.anthropic.com/v1/messages',
api_key: process.env.CLAUDE_API_KEY || '',
model_name: 'claude-3-haiku-20240307',
priority: 3,
max_tokens: 4096,
temperature: 0.7,
timeout_ms: 60000,
cost_per_1k_input_tokens: 0.00025,
cost_per_1k_output_tokens: 0.00125,
is_active: true,
is_healthy: true,
config: JSON.stringify({}),
created_at: now,
updated_at: now,
},
]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('llm_providers', null, {});
},
};
-128
View File
@@ -1,128 +0,0 @@
// src/services/init.service.js
// 초기 데이터 설정 서비스
const { User, LLMProvider } = require('../models');
const logger = require('../config/logger.config');
/**
* 초기 관리자 계정 생성
*/
async function createDefaultAdmin() {
try {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@admin.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'Admin123!';
const existing = await User.findOne({ where: { email: adminEmail } });
if (existing) {
logger.info(`관리자 계정 이미 존재: ${adminEmail}`);
return existing;
}
const admin = await User.create({
email: adminEmail,
password: adminPassword,
name: '관리자',
role: 'admin',
status: 'active',
plan: 'enterprise',
monthlyTokenLimit: 10000000, // 1000만 토큰
});
logger.info(`✅ 기본 관리자 계정 생성: ${adminEmail}`);
return admin;
} catch (error) {
logger.error('관리자 계정 생성 실패:', error);
throw error;
}
}
/**
* 기본 LLM 프로바이더 생성
*/
async function createDefaultProviders() {
try {
const providers = [
{
name: 'gemini',
displayName: 'Google Gemini',
endpoint: null,
apiKey: process.env.GEMINI_API_KEY || '',
modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
priority: 1,
maxTokens: 8192,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.001,
},
{
name: 'openai',
displayName: 'OpenAI GPT',
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: process.env.OPENAI_API_KEY || '',
modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini',
priority: 2,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00015,
costPer1kOutputTokens: 0.0006,
},
{
name: 'claude',
displayName: 'Anthropic Claude',
endpoint: 'https://api.anthropic.com/v1/messages',
apiKey: process.env.CLAUDE_API_KEY || '',
modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307',
priority: 3,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.00125,
},
];
for (const providerData of providers) {
const existing = await LLMProvider.findOne({ where: { name: providerData.name } });
if (existing) {
// API 키가 환경변수에 설정되어 있고 DB에는 없으면 업데이트
if (providerData.apiKey && !existing.apiKey) {
existing.apiKey = providerData.apiKey;
existing.modelName = providerData.modelName;
await existing.save();
logger.info(`LLM 프로바이더 API 키 업데이트: ${providerData.name}`);
}
continue;
}
await LLMProvider.create({
...providerData,
isActive: true,
isHealthy: !!providerData.apiKey, // API 키가 있으면 healthy
});
logger.info(`✅ LLM 프로바이더 생성: ${providerData.name} (${providerData.modelName})`);
}
} catch (error) {
logger.error('LLM 프로바이더 생성 실패:', error);
throw error;
}
}
/**
* 초기화 실행
*/
async function initialize() {
logger.info('🔧 초기 데이터 설정 시작...');
await createDefaultAdmin();
await createDefaultProviders();
logger.info('✅ 초기 데이터 설정 완료');
}
module.exports = {
initialize,
createDefaultAdmin,
createDefaultProviders,
};
-385
View File
@@ -1,385 +0,0 @@
// src/services/llm.service.js
// LLM 서비스 - 멀티 프로바이더 지원
const axios = require('axios');
const { LLMProvider } = require('../models');
const logger = require('../config/logger.config');
class LLMService {
constructor() {
this.providers = [];
this.initialized = false;
}
/**
* 서비스 초기화
*/
async initialize() {
if (this.initialized) return;
try {
await this.loadProviders();
this.initialized = true;
logger.info('✅ LLM 서비스 초기화 완료');
} catch (error) {
logger.error('❌ LLM 서비스 초기화 실패:', error);
// 초기화 실패 시 기본 프로바이더 사용
this.providers = this.getDefaultProviders();
this.initialized = true;
}
}
/**
* 데이터베이스에서 프로바이더 로드
*/
async loadProviders() {
try {
const providers = await LLMProvider.getHealthyProviders();
if (providers.length === 0) {
logger.warn('⚠️ 활성 프로바이더가 없습니다. 기본 프로바이더 사용');
this.providers = this.getDefaultProviders();
} else {
this.providers = providers.map((p) => ({
id: p.id,
name: p.name,
endpoint: p.endpoint,
apiKey: p.apiKey,
modelName: p.modelName,
priority: p.priority,
maxTokens: p.maxTokens,
temperature: p.temperature,
timeoutMs: p.timeoutMs,
costPer1kInputTokens: parseFloat(p.costPer1kInputTokens) || 0,
costPer1kOutputTokens: parseFloat(p.costPer1kOutputTokens) || 0,
isHealthy: p.isHealthy,
config: p.config,
}));
}
logger.info(`📥 ${this.providers.length}개 프로바이더 로드됨`);
} catch (error) {
logger.error('프로바이더 로드 실패:', error);
throw error;
}
}
/**
* 기본 프로바이더 설정 (환경 변수 기반)
*/
getDefaultProviders() {
const providers = [];
// Gemini
if (process.env.GEMINI_API_KEY) {
providers.push({
id: 'default-gemini',
name: 'gemini',
apiKey: process.env.GEMINI_API_KEY,
modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
priority: 1,
maxTokens: 8192,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.001,
isHealthy: true,
});
}
// OpenAI
if (process.env.OPENAI_API_KEY) {
providers.push({
id: 'default-openai',
name: 'openai',
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: process.env.OPENAI_API_KEY,
modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini',
priority: 2,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00015,
costPer1kOutputTokens: 0.0006,
isHealthy: true,
});
}
// Claude
if (process.env.CLAUDE_API_KEY) {
providers.push({
id: 'default-claude',
name: 'claude',
endpoint: 'https://api.anthropic.com/v1/messages',
apiKey: process.env.CLAUDE_API_KEY,
modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307',
priority: 3,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.00125,
isHealthy: true,
});
}
return providers;
}
/**
* 채팅 API 호출 (자동 fallback)
*/
async chat(params) {
const {
model,
messages,
temperature = 0.7,
maxTokens = 4096,
userId,
apiKeyId,
} = params;
// 초기화 확인
if (!this.initialized) {
await this.initialize();
}
const startTime = Date.now();
let lastError = null;
// 요청된 모델에 맞는 프로바이더 찾기
const requestedProvider = this.providers.find(
(p) => p.modelName === model || p.name === model
);
// 우선순위 순으로 프로바이더 정렬
const sortedProviders = requestedProvider
? [requestedProvider, ...this.providers.filter((p) => p !== requestedProvider)]
: this.providers;
// 프로바이더 순회 (fallback)
for (const provider of sortedProviders) {
if (!provider.isHealthy) {
logger.warn(`⚠️ ${provider.name} 건강하지 않음, 건너뜀`);
continue;
}
try {
logger.info(`🚀 ${provider.name} (${provider.modelName}) 시도 중...`);
const result = await this.callProvider(provider, {
messages,
maxTokens: maxTokens || provider.maxTokens,
temperature: temperature || provider.temperature,
});
const responseTime = Date.now() - startTime;
// 비용 계산
const cost = this.calculateCost(
result.usage.promptTokens,
result.usage.completionTokens,
provider.costPer1kInputTokens,
provider.costPer1kOutputTokens
);
logger.info(
`${provider.name} 성공 (${responseTime}ms, ${result.usage.totalTokens} tokens)`
);
return {
text: result.text,
provider: provider.name,
providerId: provider.id,
model: provider.modelName,
usage: result.usage,
responseTime,
cost,
};
} catch (error) {
logger.error(`${provider.name} 실패:`, error.message);
lastError = error;
// 다음 프로바이더로 fallback
continue;
}
}
// 모든 프로바이더 실패
throw new Error(
`모든 LLM 프로바이더가 실패했습니다: ${lastError?.message || '알 수 없는 오류'}`
);
}
/**
* 개별 프로바이더 호출
*/
async callProvider(provider, { messages, maxTokens, temperature }) {
const timeout = provider.timeoutMs || 60000;
switch (provider.name) {
case 'gemini':
return this.callGemini(provider, { messages, maxTokens, temperature });
case 'openai':
return this.callOpenAI(provider, { messages, maxTokens, temperature, timeout });
case 'claude':
return this.callClaude(provider, { messages, maxTokens, temperature, timeout });
default:
throw new Error(`지원하지 않는 프로바이더: ${provider.name}`);
}
}
/**
* Gemini API 호출
*/
async callGemini(provider, { messages, maxTokens, temperature }) {
const { GoogleGenAI } = require('@google/genai');
const ai = new GoogleGenAI({ apiKey: provider.apiKey });
// 메시지 변환 (OpenAI 형식 -> Gemini 형식)
const contents = messages.map((msg) => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
}));
// system 메시지 처리
const systemMessage = messages.find((m) => m.role === 'system');
const systemInstruction = systemMessage ? systemMessage.content : undefined;
const config = {
maxOutputTokens: maxTokens,
temperature,
};
const result = await ai.models.generateContent({
model: provider.modelName,
contents: contents.filter((c) => c.role !== 'system'),
systemInstruction,
config,
});
// 응답 텍스트 추출
let text = '';
if (result.candidates?.[0]?.content?.parts) {
text = result.candidates[0].content.parts
.filter((p) => p.text)
.map((p) => p.text)
.join('\n');
}
const usage = result.usageMetadata || {};
const promptTokens = usage.promptTokenCount ?? 0;
const completionTokens = usage.candidatesTokenCount ?? 0;
return {
text,
usage: {
promptTokens,
completionTokens,
totalTokens: promptTokens + completionTokens,
},
};
}
/**
* OpenAI API 호출
*/
async callOpenAI(provider, { messages, maxTokens, temperature, timeout }) {
const response = await axios.post(
provider.endpoint,
{
model: provider.modelName,
messages,
max_tokens: maxTokens,
temperature,
},
{
timeout,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${provider.apiKey}`,
},
}
);
return {
text: response.data.choices[0].message.content,
usage: {
promptTokens: response.data.usage.prompt_tokens,
completionTokens: response.data.usage.completion_tokens,
totalTokens: response.data.usage.total_tokens,
},
};
}
/**
* Claude API 호출
*/
async callClaude(provider, { messages, maxTokens, temperature, timeout }) {
// system 메시지 분리
const systemMessage = messages.find((m) => m.role === 'system');
const otherMessages = messages.filter((m) => m.role !== 'system');
const response = await axios.post(
provider.endpoint,
{
model: provider.modelName,
messages: otherMessages,
system: systemMessage?.content,
max_tokens: maxTokens,
temperature,
},
{
timeout,
headers: {
'Content-Type': 'application/json',
'x-api-key': provider.apiKey,
'anthropic-version': '2023-06-01',
},
}
);
return {
text: response.data.content[0].text,
usage: {
promptTokens: response.data.usage.input_tokens,
completionTokens: response.data.usage.output_tokens,
totalTokens:
response.data.usage.input_tokens + response.data.usage.output_tokens,
},
};
}
/**
* 스트리밍 채팅 (제너레이터)
*/
async *chatStream(params) {
// 현재는 간단한 구현 (전체 응답 후 청크로 분할)
// 실제 스트리밍은 각 프로바이더의 스트리밍 API 사용 필요
const result = await this.chat(params);
// 텍스트를 청크로 분할하여 전송
const chunkSize = 10;
for (let i = 0; i < result.text.length; i += chunkSize) {
yield {
text: result.text.slice(i, i + chunkSize),
done: i + chunkSize >= result.text.length,
};
}
}
/**
* 비용 계산
*/
calculateCost(promptTokens, completionTokens, inputCost, outputCost) {
const inputTotal = (promptTokens / 1000) * inputCost;
const outputTotal = (completionTokens / 1000) * outputCost;
return parseFloat((inputTotal + outputTotal).toFixed(6));
}
}
// 싱글톤 인스턴스
const llmService = new LLMService();
module.exports = llmService;
-359
View File
@@ -1,359 +0,0 @@
// src/swagger/api-docs.js
// Swagger API 문서 정의
/**
* @swagger
* /auth/register:
* post:
* tags: [Auth]
* summary: 회원가입
* description: 새 계정을 생성합니다.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, password, name]
* properties:
* email:
* type: string
* format: email
* example: user@example.com
* password:
* type: string
* minLength: 8
* example: Password123!
* description: 8자 이상, 영문/숫자/특수문자 포함
* name:
* type: string
* example: 홍길동
* responses:
* 201:
* description: 회원가입 성공
* 400:
* description: 유효성 검사 실패
* 409:
* description: 이미 존재하는 이메일
*/
/**
* @swagger
* /auth/login:
* post:
* tags: [Auth]
* summary: 로그인
* description: 이메일과 비밀번호로 로그인합니다.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, password]
* properties:
* email:
* type: string
* format: email
* example: admin@admin.com
* password:
* type: string
* example: Admin123!
* responses:
* 200:
* description: 로그인 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: object
* properties:
* user:
* type: object
* accessToken:
* type: string
* description: JWT 액세스 토큰
* refreshToken:
* type: string
* description: JWT 리프레시 토큰
* 401:
* description: 인증 실패
*/
/**
* @swagger
* /chat/completions:
* post:
* tags: [Chat]
* summary: 채팅 완성 (OpenAI 호환)
* description: |
* AI 모델에 메시지를 보내고 응답을 받습니다.
* OpenAI API와 호환되는 형식입니다.
*
* **인증**: JWT 토큰 또는 API 키 (sk-xxx)
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ChatCompletionRequest'
* examples:
* simple:
* summary: 간단한 질문
* value:
* model: gemini-2.0-flash
* messages:
* - role: user
* content: 안녕하세요!
* with_system:
* summary: 시스템 프롬프트 포함
* value:
* model: gemini-2.0-flash
* messages:
* - role: system
* content: 당신은 친절한 AI 어시스턴트입니다.
* - role: user
* content: 파이썬으로 Hello World 출력하는 코드 알려줘
* temperature: 0.7
* max_tokens: 1000
* responses:
* 200:
* description: 성공
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ChatCompletionResponse'
* 401:
* description: 인증 실패
* 429:
* description: 요청 한도 초과
*/
/**
* @swagger
* /models:
* get:
* tags: [Models]
* summary: 모델 목록 조회
* description: 사용 가능한 AI 모델 목록을 조회합니다.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* object:
* type: string
* example: list
* data:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* example: gemini-2.0-flash
* object:
* type: string
* example: model
* owned_by:
* type: string
* example: google
*/
/**
* @swagger
* /api-keys:
* get:
* tags: [API Keys]
* summary: API 키 목록 조회
* description: 발급받은 API 키 목록을 조회합니다.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* post:
* tags: [API Keys]
* summary: API 키 발급
* description: 새 API 키를 발급받습니다. 키는 한 번만 표시됩니다.
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name]
* properties:
* name:
* type: string
* example: My API Key
* description: API 키 이름
* responses:
* 201:
* description: 발급 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* key:
* type: string
* description: 발급된 API 키 (한 번만 표시)
* example: sk-abc123def456...
*/
/**
* @swagger
* /api-keys/{id}:
* delete:
* tags: [API Keys]
* summary: API 키 폐기
* description: API 키를 폐기합니다.
* security:
* - BearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: API 키 ID
* responses:
* 200:
* description: 폐기 성공
* 404:
* description: API 키를 찾을 수 없음
*/
/**
* @swagger
* /usage:
* get:
* tags: [Usage]
* summary: 사용량 요약 조회
* description: 오늘/이번 달 사용량 요약을 조회합니다.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* plan:
* type: string
* example: free
* limit:
* type: object
* properties:
* monthly:
* type: integer
* remaining:
* type: integer
* usage:
* type: object
* properties:
* today:
* type: object
* monthly:
* type: object
*/
/**
* @swagger
* /usage/logs:
* get:
* tags: [Usage]
* summary: 사용 로그 조회
* description: API 호출 로그를 조회합니다.
* security:
* - BearerAuth: []
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* default: 1
* - in: query
* name: limit
* schema:
* type: integer
* default: 20
* responses:
* 200:
* description: 성공
*/
/**
* @swagger
* /admin/users:
* get:
* tags: [Admin]
* summary: 사용자 목록 조회 (관리자)
* description: 모든 사용자 목록을 조회합니다. 관리자 권한 필요.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* 403:
* description: 권한 없음
*/
/**
* @swagger
* /admin/providers:
* get:
* tags: [Admin]
* summary: LLM 프로바이더 목록 (관리자)
* description: LLM 프로바이더 설정을 조회합니다. 관리자 권한 필요.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* 403:
* description: 권한 없음
*/
/**
* @swagger
* /admin/stats:
* get:
* tags: [Admin]
* summary: 시스템 통계 (관리자)
* description: 시스템 전체 통계를 조회합니다. 관리자 권한 필요.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* 403:
* description: 권한 없음
*/
-17
View File
@@ -1,17 +0,0 @@
# ==================== 운영/작업 지원 위젯 데이터 소스 설정 ====================
# 옵션: file | database | memory
# - file: 파일 기반 (빠른 개발/테스트)
# - database: PostgreSQL DB (실제 운영)
# - memory: 메모리 목 데이터 (테스트)
TODO_DATA_SOURCE=file
BOOKING_DATA_SOURCE=file
MAINTENANCE_DATA_SOURCE=memory
DOCUMENT_DATA_SOURCE=memory
# OpenWeatherMap API 키 추가 (실시간 날씨)
# https://openweathermap.org/api 에서 무료 가입 후 발급
OPENWEATHER_API_KEY=your_openweathermap_api_key_here
-44
View File
@@ -1,44 +0,0 @@
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🔑 공유 API 키 (팀 전체 사용)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#
# ⚠️ 주의: 이 파일은 Git에 커밋됩니다!
# 팀원들이 동일한 API 키를 사용합니다.
#
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 한국은행 환율 API 키
# 발급: https://www.bok.or.kr/portal/openapi/OpenApiGuide.do
BOK_API_KEY=OXIGPQXH68NUKVKL5KT9
# 기상청 API Hub 키
# 발급: https://apihub.kma.go.kr/
KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
# ITS 국가교통정보센터 API 키
# 발급: https://www.its.go.kr/
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
# 한국도로공사 OpenOASIS API 키
# 발급: https://data.ex.co.kr/ (OpenOASIS 신청)
EXWAY_API_KEY=7820214492
# ExchangeRate API 키 (백업용, 선택사항)
# 발급: https://www.exchangerate-api.com/
# EXCHANGERATE_API_KEY=your_exchangerate_api_key_here
# Kakao API 키 (Geocoding용, 선택사항)
# 발급: https://developers.kakao.com/
# KAKAO_API_KEY=your_kakao_api_key_here
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 📝 사용 방법
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#
# 1. 이 파일을 복사하여 .env 파일 생성:
# $ cp .env.shared .env
#
# 2. 그대로 사용하면 됩니다!
# (팀 전체가 동일한 키 사용)
#
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-174
View File
@@ -1,174 +0,0 @@
# 🔌 API 연동 가이드
## 📊 현재 상태
### ✅ 작동 중인 API
1. **기상청 특보 API** (완벽 작동!)
- API 키: `ogdXr2e9T4iHV69nvV-IwA`
- 상태: ✅ 14건 실시간 특보 수신 중
- 제공 데이터: 대설/강풍/한파/태풍/폭염 특보
2. **한국은행 환율 API** (완벽 작동!)
- API 키: `OXIGPQXH68NUKVKL5KT9`
- 상태: ✅ 환율 위젯 작동 중
### ⚠️ 더미 데이터 사용 중
3. **교통사고 정보**
- 한국도로공사 API: ❌ 서버 호출 차단
- 현재 상태: 더미 데이터 (2건)
4. **도로공사 정보**
- 한국도로공사 API: ❌ 서버 호출 차단
- 현재 상태: 더미 데이터 (2건)
---
## 🚀 실시간 교통정보 연동하기
### 📌 국토교통부 ITS API (추천!)
#### 1단계: API 신청
1. https://www.data.go.kr/ 접속
2. 검색: **"ITS 돌발정보"** 또는 **"실시간 교통정보"**
3. **활용신청** 클릭
4. **승인 대기 (1~2일)**
#### 2단계: API 키 추가
승인 완료되면 `.env` 파일에 추가:
```env
# 국토교통부 ITS API 키
ITS_API_KEY=발급받은_API_키
```
#### 3단계: 서버 재시작
```bash
docker restart pms-backend-mac
```
#### 4단계: 확인
- 로그에서 `✅ 국토교통부 ITS 교통사고 API 응답 수신 완료` 확인
- 더미 데이터 대신 실제 데이터가 표시됨!
---
## 🔍 한국도로공사 API 문제
### 발급된 키
```
EXWAY_API_KEY=7820214492
```
### 문제 상황
- ❌ 서버/백엔드에서 호출 시: `Request Blocked` (400)
- ❌ curl 명령어: `Request Blocked`
- ❌ 모든 엔드포인트 차단됨
### 가능한 원인
1. **브라우저에서만 접근 허용**
- Referer 헤더 검증
- User-Agent 검증
2. **IP 화이트리스트**
- 특정 IP에서만 접근 가능
- 서버 IP 등록 필요
3. **API 키 활성화 대기**
- 발급 후 승인 대기 중
- 몇 시간~1일 소요
### 해결 방법
1. 한국도로공사 담당자 문의 (054-811-4533)
2. 국토교통부 ITS API 사용 (더 안정적)
---
## 📝 코드 구조
### 다중 API 폴백 시스템
```typescript
// 1순위: 국토교통부 ITS API
if (process.env.ITS_API_KEY) {
try {
// ITS API 호출
return itsData;
} catch {
console.log('2순위 API로 전환');
}
}
// 2순위: 한국도로공사 API
try {
// 한국도로공사 API 호출
return exwayData;
} catch {
console.log('더미 데이터 사용');
}
// 3순위: 더미 데이터
return dummyData;
```
### 파일 위치
- 서비스: `backend-node/src/services/riskAlertService.ts`
- 컨트롤러: `backend-node/src/controllers/riskAlertController.ts`
- 라우트: `backend-node/src/routes/riskAlertRoutes.ts`
---
## 💡 현재 대시보드 위젯 데이터
### 리스크/알림 위젯
```
✅ 날씨특보: 14건 (실제 기상청 데이터)
⚠️ 교통사고: 2건 (더미 데이터)
⚠️ 도로공사: 2건 (더미 데이터)
─────────────────────────
총 18건의 알림
```
### 개선 후 (ITS API 연동 시)
```
✅ 날씨특보: 14건 (실제 기상청 데이터)
✅ 교통사고: N건 (실제 ITS 데이터)
✅ 도로공사: N건 (실제 ITS 데이터)
─────────────────────────
총 N건의 알림 (모두 실시간!)
```
---
## 🎯 다음 단계
### 단기 (지금)
- [x] 기상청 특보 API 연동 완료
- [x] 한국은행 환율 API 연동 완료
- [x] 다중 API 폴백 시스템 구축
- [ ] 국토교통부 ITS API 신청
### 장기 (향후)
- [ ] 서울시 TOPIS API 추가 (서울시 교통정보)
- [ ] 경찰청 교통사고 정보 API (승인 필요)
- [ ] 기상청 단기예보 API 추가
---
## 📞 문의
### 한국도로공사
- 전화: 054-811-4533 (컨텐츠 문의)
- 전화: 070-8656-8771 (시스템 장애)
### 공공데이터포털
- 웹사이트: https://www.data.go.kr/
- 고객센터: 1661-0423
---
**작성일**: 2025-10-14
**작성자**: AI Assistant
**상태**: ✅ 기상청 특보 작동 중, ITS API 연동 준비 완료
-140
View File
@@ -1,140 +0,0 @@
# 🔑 API 키 현황 및 연동 상태
## ✅ 완벽 작동 중
### 1. 기상청 API Hub
- **API 키**: `ogdXr2e9T4iHV69nvV-IwA`
- **상태**: ✅ 14건 실시간 특보 수신 중
- **제공 데이터**: 대설/강풍/한파/태풍/폭염 특보
- **코드 위치**: `backend-node/src/services/riskAlertService.ts`
### 2. 한국은행 환율 API
- **API 키**: `OXIGPQXH68NUKVKL5KT9`
- **상태**: ✅ 환율 위젯 작동 중
- **제공 데이터**: USD/EUR/JPY/CNY 환율
---
## ⚠️ 연동 대기 중
### 3. 한국도로공사 OpenOASIS API
- **API 키**: `7820214492`
- **상태**: ❌ 엔드포인트 URL 불명
- **문제**:
- 발급 이메일에 사용법 없음
- 매뉴얼에 상세 정보 없음
- 테스트한 URL 모두 실패
**해결 방법**:
```
📞 한국도로공사 고객센터 문의
컨텐츠 문의: 054-811-4533
시스템 장애: 070-8656-8771
문의 내용:
"OpenOASIS API 인증키(7820214492)를 발급받았는데
사용 방법과 엔드포인트 URL을 알려주세요.
- 돌발상황정보 API
- 교통사고 정보
- 도로공사 정보"
```
### 4. 국토교통부 ITS API
- **API 키**: `d6b9befec3114d648284674b8fddcc32`
- **상태**: ❌ 엔드포인트 URL 불명
- **승인 API**:
- 교통소통정보
- 돌발상황정보
- CCTV 화상자료
- 교통예측정보
- 차량검지정보
- 도로전광표지(VMS)
- 주의운전구간
- 가변형 속도제한표지(VSL)
- 위험물질 운송차량 사고정보
**해결 방법**:
```
📞 ITS 국가교통정보센터 문의
전화: 1577-6782
이메일: its@ex.co.kr
문의 내용:
"ITS API 인증키(d6b9befec3114d648284674b8fddcc32)를
발급받았는데 매뉴얼에 엔드포인트 URL이 없습니다.
돌발상황정보 API의 정확한 URL과 파라미터를
알려주세요."
```
---
## 🔧 백엔드 연동 준비 완료
### 파일 위치
- **서비스**: `backend-node/src/services/riskAlertService.ts`
- **컨트롤러**: `backend-node/src/controllers/riskAlertController.ts`
- **라우트**: `backend-node/src/routes/riskAlertRoutes.ts`
### 다중 API 폴백 시스템
```typescript
1순위: 국토교통부 ITS API (process.env.ITS_API_KEY)
2순위: 한국도로공사 API (process.env.EXWAY_API_KEY)
3순위: 더미 ( )
```
### 연동 방법
```bash
# .env 파일에 추가
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
EXWAY_API_KEY=7820214492
# 백엔드 재시작
docker restart pms-backend-mac
# 로그 확인
docker logs pms-backend-mac --tail 50
```
---
## 📊 현재 리스크/알림 시스템
```
✅ 기상특보: 14건 (실시간 기상청 데이터)
⚠️ 교통사고: 2건 (더미 데이터)
⚠️ 도로공사: 2건 (더미 데이터)
────────────────────────────
총 18건의 알림
```
---
## 🚀 다음 단계
### 단기 (지금)
- [x] 기상청 특보 API 연동 완료
- [x] 한국은행 환율 API 연동 완료
- [x] ITS/한국도로공사 API 키 발급 완료
- [x] 다중 API 폴백 시스템 구축
- [ ] **API 엔드포인트 URL 확인 (고객센터 문의)**
### 중기 (API URL 확인 후)
- [ ] ITS API 연동 (즉시 가능)
- [ ] 한국도로공사 API 연동 (즉시 가능)
- [ ] 실시간 교통사고 데이터 표시
- [ ] 실시간 도로공사 데이터 표시
### 장기 (추가 기능)
- [ ] 서울시 TOPIS API 추가
- [ ] CCTV 화상 자료 연동
- [ ] 도로전광표지(VMS) 정보
- [ ] 교통예측정보
---
**작성일**: 2025-10-14
**상태**: 기상청 특보 작동 중, 교통정보 API URL 확인 필요
-418
View File
@@ -1,418 +0,0 @@
# Phase 1: Raw Query 기반 구조 사용 가이드
## 📋 개요
Phase 1에서 구현한 Raw Query 기반 데이터베이스 아키텍처 사용 방법입니다.
---
## 🏗️ 구현된 모듈
### 1. **DatabaseManager** (`src/database/db.ts`)
PostgreSQL 연결 풀 기반 핵심 모듈
**주요 함수:**
- `query<T>(sql, params)` - 기본 쿼리 실행
- `queryOne<T>(sql, params)` - 단일 행 조회
- `transaction(callback)` - 트랜잭션 실행
- `getPool()` - 연결 풀 가져오기
- `getPoolStatus()` - 연결 풀 상태 확인
### 2. **QueryBuilder** (`src/utils/queryBuilder.ts`)
동적 쿼리 생성 유틸리티
**주요 메서드:**
- `QueryBuilder.select(tableName, options)` - SELECT 쿼리
- `QueryBuilder.insert(tableName, data, options)` - INSERT 쿼리
- `QueryBuilder.update(tableName, data, where, options)` - UPDATE 쿼리
- `QueryBuilder.delete(tableName, where, options)` - DELETE 쿼리
- `QueryBuilder.count(tableName, where)` - COUNT 쿼리
- `QueryBuilder.exists(tableName, where)` - EXISTS 쿼리
### 3. **DatabaseValidator** (`src/utils/databaseValidator.ts`)
SQL Injection 방지 및 입력 검증
**주요 메서드:**
- `validateTableName(tableName)` - 테이블명 검증
- `validateColumnName(columnName)` - 컬럼명 검증
- `validateWhereClause(where)` - WHERE 조건 검증
- `sanitizeInput(input)` - 입력 값 Sanitize
### 4. **타입 정의** (`src/types/database.ts`)
TypeScript 타입 안전성 보장
---
## 🚀 사용 예제
### 1. 기본 쿼리 실행
```typescript
import { query, queryOne } from '../database/db';
// 여러 행 조회
const users = await query<User>(
'SELECT * FROM users WHERE status = $1',
['active']
);
// 단일 행 조회
const user = await queryOne<User>(
'SELECT * FROM users WHERE user_id = $1',
['user123']
);
if (!user) {
throw new Error('사용자를 찾을 수 없습니다.');
}
```
### 2. QueryBuilder 사용
#### SELECT
```typescript
import { query } from '../database/db';
import { QueryBuilder } from '../utils/queryBuilder';
// 기본 SELECT
const { query: sql, params } = QueryBuilder.select('users', {
where: { status: 'active' },
orderBy: 'created_at DESC',
limit: 10,
});
const users = await query(sql, params);
// 복잡한 SELECT (JOIN, WHERE, ORDER BY)
const { query: sql2, params: params2 } = QueryBuilder.select('users', {
columns: ['users.user_id', 'users.username', 'departments.dept_name'],
joins: [
{
type: 'LEFT',
table: 'departments',
on: 'users.dept_id = departments.dept_id',
},
],
where: { 'users.status': 'active' },
orderBy: ['users.created_at DESC', 'users.username ASC'],
limit: 20,
offset: 0,
});
const result = await query(sql2, params2);
```
#### INSERT
```typescript
import { query } from '../database/db';
import { QueryBuilder } from '../utils/queryBuilder';
// 기본 INSERT
const { query: sql, params } = QueryBuilder.insert(
'users',
{
user_id: 'new_user',
username: 'John Doe',
email: 'john@example.com',
status: 'active',
},
{
returning: ['id', 'user_id'],
}
);
const [newUser] = await query(sql, params);
console.log('생성된 사용자 ID:', newUser.id);
// UPSERT (INSERT ... ON CONFLICT)
const { query: sql2, params: params2 } = QueryBuilder.insert(
'users',
{
user_id: 'user123',
username: 'Jane',
email: 'jane@example.com',
},
{
onConflict: {
columns: ['user_id'],
action: 'DO UPDATE',
updateSet: ['username', 'email'],
},
returning: ['*'],
}
);
const [upsertedUser] = await query(sql2, params2);
```
#### UPDATE
```typescript
import { query } from '../database/db';
import { QueryBuilder } from '../utils/queryBuilder';
const { query: sql, params } = QueryBuilder.update(
'users',
{
username: 'Updated Name',
email: 'updated@example.com',
updated_at: new Date(),
},
{
user_id: 'user123',
},
{
returning: ['*'],
}
);
const [updatedUser] = await query(sql, params);
```
#### DELETE
```typescript
import { query } from '../database/db';
import { QueryBuilder } from '../utils/queryBuilder';
const { query: sql, params } = QueryBuilder.delete(
'users',
{
user_id: 'user_to_delete',
},
{
returning: ['user_id', 'username'],
}
);
const [deletedUser] = await query(sql, params);
console.log('삭제된 사용자:', deletedUser.username);
```
### 3. 트랜잭션 사용
```typescript
import { transaction } from '../database/db';
// 복잡한 트랜잭션 처리
const result = await transaction(async (client) => {
// 1. 사용자 생성
const userResult = await client.query(
'INSERT INTO users (user_id, username, email) VALUES ($1, $2, $3) RETURNING id',
['new_user', 'John', 'john@example.com']
);
const userId = userResult.rows[0].id;
// 2. 역할 할당
await client.query(
'INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)',
[userId, 'admin']
);
// 3. 로그 생성
await client.query(
'INSERT INTO audit_logs (action, user_id, details) VALUES ($1, $2, $3)',
['USER_CREATED', userId, JSON.stringify({ username: 'John' })]
);
return { success: true, userId };
});
console.log('트랜잭션 완료:', result);
```
### 4. JSON 필드 쿼리 (JSONB)
```typescript
import { query } from '../database/db';
import { QueryBuilder } from '../utils/queryBuilder';
// JSON 필드 쿼리 (config->>'type' = 'form')
const { query: sql, params } = QueryBuilder.select('screen_management', {
columns: ['*'],
where: {
company_code: 'COMPANY_001',
"config->>'type'": 'form',
},
});
const screens = await query(sql, params);
```
### 5. 동적 테이블 쿼리
```typescript
import { query } from '../database/db';
import { DatabaseValidator } from '../utils/databaseValidator';
async function queryDynamicTable(tableName: string, filters: Record<string, any>) {
// 테이블명 검증 (SQL Injection 방지)
if (!DatabaseValidator.validateTableName(tableName)) {
throw new Error('유효하지 않은 테이블명입니다.');
}
// WHERE 조건 검증
if (!DatabaseValidator.validateWhereClause(filters)) {
throw new Error('유효하지 않은 WHERE 조건입니다.');
}
const { query: sql, params } = QueryBuilder.select(tableName, {
where: filters,
});
return await query(sql, params);
}
// 사용 예
const data = await queryDynamicTable('company_data_001', {
status: 'active',
region: 'Seoul',
});
```
---
## 🔐 보안 고려사항
### 1. **항상 Parameterized Query 사용**
```typescript
// ❌ 위험: SQL Injection 취약
const userId = req.params.userId;
const sql = `SELECT * FROM users WHERE user_id = '${userId}'`;
const users = await query(sql);
// ✅ 안전: Parameterized Query
const userId = req.params.userId;
const users = await query('SELECT * FROM users WHERE user_id = $1', [userId]);
```
### 2. **식별자 검증**
```typescript
import { DatabaseValidator } from '../utils/databaseValidator';
// 테이블명/컬럼명 검증
if (!DatabaseValidator.validateTableName(tableName)) {
throw new Error('유효하지 않은 테이블명입니다.');
}
if (!DatabaseValidator.validateColumnName(columnName)) {
throw new Error('유효하지 않은 컬럼명입니다.');
}
```
### 3. **입력 값 Sanitize**
```typescript
import { DatabaseValidator } from '../utils/databaseValidator';
const sanitizedData = DatabaseValidator.sanitizeInput(userInput);
```
---
## 📊 성능 최적화 팁
### 1. **연결 풀 모니터링**
```typescript
import { getPoolStatus } from '../database/db';
const status = getPoolStatus();
console.log('연결 풀 상태:', {
total: status.totalCount,
idle: status.idleCount,
waiting: status.waitingCount,
});
```
### 2. **배치 INSERT**
```typescript
import { transaction } from '../database/db';
// 대량 데이터 삽입 시 트랜잭션 사용
await transaction(async (client) => {
for (const item of largeDataset) {
await client.query('INSERT INTO items (name, value) VALUES ($1, $2)', [
item.name,
item.value,
]);
}
});
```
### 3. **인덱스 활용 쿼리**
```typescript
// WHERE 절에 인덱스 컬럼 사용
const { query: sql, params } = QueryBuilder.select('users', {
where: {
user_id: 'user123', // 인덱스 컬럼
},
});
```
---
## 🧪 테스트 실행
```bash
# 테스트 실행
npm test -- database.test.ts
# 특정 테스트만 실행
npm test -- database.test.ts -t "QueryBuilder"
```
---
## 🚨 에러 핸들링
```typescript
import { query } from '../database/db';
try {
const users = await query('SELECT * FROM users WHERE status = $1', ['active']);
return users;
} catch (error: any) {
console.error('쿼리 실행 실패:', error.message);
// PostgreSQL 에러 코드 확인
if (error.code === '23505') {
throw new Error('중복된 값이 존재합니다.');
}
if (error.code === '23503') {
throw new Error('외래 키 제약 조건 위반입니다.');
}
throw error;
}
```
---
## 📝 다음 단계 (Phase 2)
Phase 1 기반 구조가 완성되었으므로, Phase 2에서는:
1. **screenManagementService.ts** 전환 (46개 호출)
2. **tableManagementService.ts** 전환 (35개 호출)
3. **dataflowService.ts** 전환 (31개 호출)
등 핵심 서비스를 Raw Query로 전환합니다.
---
**작성일**: 2025-09-30
**버전**: 1.0.0
**담당**: Backend Development Team
-208
View File
@@ -1,208 +0,0 @@
re# PLM System Backend - Node.js + TypeScript
Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백엔드입니다.
## 🚀 기술 스택
- **Runtime**: Node.js ^20.10.0
- **Framework**: Express ^4.18.2
- **Language**: TypeScript ^5.3.3
- **Database**: PostgreSQL ^8.11.3 (Raw Query with `pg`)
- **Authentication**: JWT + Passport
- **Testing**: Jest + Supertest
## 📋 프로젝트 구조
```
backend-node/
├── src/
│ ├── database/ # 데이터베이스 유틸리티
│ │ ├── db.ts # PostgreSQL Raw Query 헬퍼
│ │ └── ...
│ ├── controllers/ # HTTP 요청 처리
│ ├── services/ # 비즈니스 로직
│ ├── middleware/ # Express 미들웨어
│ │ └── errorHandler.ts
│ ├── utils/ # 유틸리티 함수
│ │ └── logger.ts
│ ├── types/ # TypeScript 타입 정의
│ │ └── common.ts
│ ├── validators/ # 입력 검증 스키마
│ └── app.ts # 애플리케이션 진입점
├── logs/ # 로그 파일
├── package.json
├── tsconfig.json
└── README.md
```
## 🛠️ 설치 및 실행
### 1. 의존성 설치
```bash
npm install
```
### 2. 환경 변수 설정
`.env` 파일을 생성하고 다음 내용을 추가하세요:
```env
DATABASE_URL="postgresql://postgres:ph0909!!@39.117.244.52:11132/plm"
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
JWT_EXPIRES_IN="24h"
PORT=8080
NODE_ENV=development
```
### 3. 개발 서버 실행
```bash
npm run dev
```
### 5. 프로덕션 빌드
```bash
npm run build
npm start
```
## 📊 데이터베이스 스키마
PostgreSQL 데이터베이스를 직접 Raw Query로 사용합니다.
### 핵심 테이블
- `user_info` - 사용자 정보
- `dept_info` - 부서 정보
- `menu_info` - 메뉴 정보
- `comm_code` - 공통 코드
- `multi_lang_key_master` - 다국어 키 마스터
- `multi_lang_text` - 다국어 텍스트
자세한 스키마 정보는 `docs/Database_Schema_Collection.md`를 참조하세요.
## 🔐 인증 시스템
JWT 기반 인증 시스템을 구현했습니다:
- Access Token (24시간)
- Refresh Token (7일)
- 토큰 자동 갱신
## 📝 API 문서
### 헬스 체크
```
GET http://localhost:8080/health
```
### 사용자 관리 (예정)
```
GET http://localhost:8080/api/users # 사용자 목록 조회
GET http://localhost:8080/api/users/:id # 특정 사용자 조회
POST http://localhost:8080/api/users # 사용자 생성
PUT http://localhost:8080/api/users/:id # 사용자 수정
DELETE http://localhost:8080/api/users/:id # 사용자 삭제
```
### 메뉴 관리 (예정)
```
GET http://localhost:8080/api/menus # 메뉴 목록 조회
POST http://localhost:8080/api/menus # 메뉴 생성
PUT http://localhost:8080/api/menus/:id # 메뉴 수정
DELETE http://localhost:8080/api/menus/:id # 메뉴 삭제
```
## 🧪 테스트
```bash
# 전체 테스트 실행
npm test
# 테스트 감시 모드
npm run test:watch
```
## 📦 스크립트
- `npm run dev` - 개발 서버 실행 (nodemon)
- `npm run build` - TypeScript 컴파일
- `npm start` - 프로덕션 서버 실행
- `npm test` - 테스트 실행
- `npm run lint` - ESLint 검사
- `npm run format` - Prettier 포맷팅
## 🔧 개발 가이드
### 새로운 API 추가
1. `src/controllers/`에 컨트롤러 생성
2. `src/services/`에 서비스 로직 생성
3. `src/types/`에 타입 정의 추가
4. `src/validators/`에 검증 스키마 추가
5. `src/app.ts`에 라우터 등록
### 데이터베이스 스키마 변경
1. SQL 마이그레이션 파일 작성 (`db/` 디렉토리)
2. PostgreSQL에서 직접 실행
3. 필요 시 TypeScript 타입 정의 업데이트 (`src/types/`)
## 📋 마이그레이션 체크리스트
### ✅ Phase 1: 기반 구축 (완료)
- [x] Node.js + TypeScript 프로젝트 설정
- [x] 기존 데이터베이스 스키마 분석
- [x] PostgreSQL Raw Query 시스템 구축
- [x] 기본 인증 시스템 구현
- [x] 에러 처리 및 로깅 설정
### 🔄 Phase 2: 핵심 API 개발 (진행 중)
- [ ] 사용자 관리 API (`user_info` 테이블 기반)
- [ ] 부서 관리 API (`dept_info` 테이블 기반)
- [ ] 메뉴 관리 API (`menu_info` 테이블 기반)
- [ ] 권한 관리 API (`authority_master`, `rel_menu_auth` 테이블 기반)
- [ ] 다국어 관리 API (`multi_lang_key_master`, `multi_lang_text` 테이블 기반)
- [ ] 공통 코드 관리 API (`comm_code` 테이블 기반)
### ⏳ Phase 3: 비즈니스 로직 API (예정)
- [ ] 회사 관리 API (`company_mng` 테이블 기반)
- [ ] 계약 관리 API (`contract_mgmt` 테이블 기반)
- [ ] 주문 관리 API (`order_mgmt` 테이블 기반)
- [ ] 재고 관리 API (`inventory_mgmt` 테이블 기반)
- [ ] 부품 관리 API (`part_mgmt` 테이블 기반)
## 🚀 배포
### Docker 배포
```bash
# Docker 이미지 빌드
docker build -t pms-backend-node .
# 컨테이너 실행
docker run -p 8080:8080 pms-backend-node
```
### 환경별 설정
- **Development**: `NODE_ENV=development`
- **Production**: `NODE_ENV=production`
- **Test**: `NODE_ENV=test`
## 📞 지원
프로젝트 관련 문의사항이 있으시면 개발팀에 연락해주세요.
---
**버전**: 1.0.0
**마지막 업데이트**: 2024년 12월
-87
View File
@@ -1,87 +0,0 @@
# 🔑 API 키 설정 가이드
## 빠른 시작 (신규 팀원용)
### 1. API 키 파일 복사
```bash
cd backend-node
cp .env.shared .env
```
### 2. 끝!
- `.env.shared` 파일에 **팀 공유 API 키**가 이미 들어있습니다
- 그대로 복사해서 사용하면 됩니다
- 추가 발급 필요 없음!
---
## 📋 포함된 API 키
### ✅ 한국은행 환율 API
- 용도: 환율 정보 조회
- 키: `OXIGPQXH68NUKVKL5KT9`
### ✅ 기상청 API Hub
- 용도: 날씨특보, 기상정보
- 키: `ogdXr2e9T4iHV69nvV-IwA`
### ✅ ITS 국가교통정보센터
- 용도: 교통사고, 도로공사 정보
- 키: `d6b9befec3114d648284674b8fddcc32`
### ✅ 한국도로공사 OpenOASIS
- 용도: 고속도로 교통정보
- 키: `7820214492`
---
## ⚠️ 주의사항
### Git 관리
```bash
✅ .env.shared → Git에 커밋됨 (팀 공유용)
❌ .env → Git에 커밋 안 됨 (개인 설정)
```
### 보안
- **팀 내부 프로젝트**이므로 키 공유가 안전합니다
- 외부 공개 프로젝트라면 각자 발급받아야 합니다
---
## 🚀 서버 시작
```bash
# 1. API 키 설정 (최초 1회만)
cp .env.shared .env
# 2. 서버 시작
npm run dev
# 또는 Docker
docker-compose up -d
```
---
## 💡 트러블슈팅
### `.env` 파일이 없다는 오류
```bash
# 해결: .env.shared를 복사
cp .env.shared .env
```
### API 호출이 실패함
```bash
# 1. .env 파일 확인
cat .env
# 2. API 키가 제대로 복사되었는지 확인
# 3. 서버 재시작
npm run dev
```
---
**팀원 여러분, `.env.shared`를 복사해서 사용하세요!** 👍
-35
View File
@@ -1,35 +0,0 @@
[
{
"id": "773568c7-0fc8-403d-ace2-01a11fae7189",
"customerName": "김철수",
"customerPhone": "010-1234-5678",
"pickupLocation": "서울시 강남구 역삼동 123",
"dropoffLocation": "경기도 성남시 분당구 정자동 456",
"scheduledTime": "2025-10-14T10:03:32.556Z",
"vehicleType": "truck",
"cargoType": "전자제품",
"weight": 500,
"status": "accepted",
"priority": "urgent",
"createdAt": "2025-10-14T08:03:32.556Z",
"updatedAt": "2025-10-14T08:06:45.073Z",
"estimatedCost": 150000,
"acceptedAt": "2025-10-14T08:06:45.073Z"
},
{
"id": "0751b297-18df-42c0-871c-85cded1f6dae",
"customerName": "이영희",
"customerPhone": "010-9876-5432",
"pickupLocation": "서울시 송파구 잠실동 789",
"dropoffLocation": "인천시 남동구 구월동 321",
"scheduledTime": "2025-10-14T12:03:32.556Z",
"vehicleType": "van",
"cargoType": "가구",
"weight": 300,
"status": "pending",
"priority": "normal",
"createdAt": "2025-10-14T07:53:32.556Z",
"updatedAt": "2025-10-14T07:53:32.556Z",
"estimatedCost": 80000
}
]
File diff suppressed because one or more lines are too long
-80
View File
@@ -1,80 +0,0 @@
[
{
"id": "58d2b26f-5197-4df1-b5d4-724a72ee1d05",
"title": "연동되어주려무니",
"description": "ㅁㄴㅇㄹ",
"priority": "normal",
"status": "in_progress",
"assignedTo": "",
"dueDate": "2025-10-21T15:21",
"createdAt": "2025-10-20T06:21:19.817Z",
"updatedAt": "2025-10-20T09:00:26.948Z",
"isUrgent": false,
"order": 3
},
{
"id": "c8292b4d-bb45-487c-aa29-55b78580b837",
"title": "오늘의 힐일",
"description": "이거 데이터베이스랑 연결하기",
"priority": "normal",
"status": "pending",
"assignedTo": "",
"dueDate": "2025-10-23T14:04",
"createdAt": "2025-10-23T05:04:50.249Z",
"updatedAt": "2025-10-23T05:04:50.249Z",
"isUrgent": false,
"order": 4
},
{
"id": "2c7f90a3-947c-4693-8525-7a2a707172c0",
"title": "테스트용 일정",
"description": "ㅁㄴㅇㄹ",
"priority": "low",
"status": "pending",
"assignedTo": "",
"dueDate": "2025-10-16T18:16",
"createdAt": "2025-10-23T05:13:14.076Z",
"updatedAt": "2025-10-23T05:13:14.076Z",
"isUrgent": false,
"order": 5
},
{
"id": "499feff6-92c7-45a9-91fa-ca727edf90f2",
"title": "ㅁSdf",
"description": "asdfsdfs",
"priority": "normal",
"status": "pending",
"assignedTo": "",
"dueDate": "",
"createdAt": "2025-10-23T05:15:38.430Z",
"updatedAt": "2025-10-23T05:15:38.430Z",
"isUrgent": false,
"order": 6
},
{
"id": "166c3910-9908-457f-8c72-8d0183f12e2f",
"title": "ㅎㄹㅇㄴ",
"description": "ㅎㄹㅇㄴ",
"priority": "normal",
"status": "pending",
"assignedTo": "",
"dueDate": "",
"createdAt": "2025-10-23T05:21:01.515Z",
"updatedAt": "2025-10-23T05:21:01.515Z",
"isUrgent": false,
"order": 7
},
{
"id": "bfa9d476-bb98-41d5-9d74-b016be011bba",
"title": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ",
"description": "ㅁㄴㅇㄹㄴㅇㄹ",
"priority": "normal",
"status": "pending",
"assignedTo": "",
"dueDate": "",
"createdAt": "2025-10-23T05:21:25.781Z",
"updatedAt": "2025-10-23T05:21:25.781Z",
"isUrgent": false,
"order": 8
}
]
-18
View File
@@ -1,18 +0,0 @@
// multer 패키지 설치 스크립트
const { exec } = require("child_process");
console.log("📦 multer 패키지 설치 중...");
exec("npm install multer @types/multer", (error, stdout, stderr) => {
if (error) {
console.error("❌ 설치 실패:", error);
return;
}
if (stderr) {
console.log("⚠️ 경고:", stderr);
}
console.log("✅ multer 설치 완료");
console.log(stdout);
});
-17
View File
@@ -1,17 +0,0 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/src"],
testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"],
transform: {
"^.+\\.ts$": "ts-jest",
},
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts", "!src/tests/**"],
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov", "html"],
setupFilesAfterEnv: ["<rootDir>/src/tests/setup.ts"],
testTimeout: 30000,
verbose: true,
// 환경 변수 설정
setupFiles: ["<rootDir>/src/tests/env.setup.ts"],
};
-6
View File
@@ -1,6 +0,0 @@
{
"watch": ["src"],
"ext": "ts,json",
"ignore": ["src/**/*.spec.ts"],
"exec": "node -r ts-node/register/transpile-only src/app.ts"
}
-11637
View File
File diff suppressed because it is too large Load Diff
-96
View File
@@ -1,96 +0,0 @@
{
"name": "pms-backend-node",
"version": "1.0.0",
"description": "PLM System Backend - Node.js + TypeScript",
"main": "dist/app.js",
"scripts": {
"start": "node dist/app.js",
"dev": "nodemon src/app.ts",
"build": "tsc",
"test": "jest",
"test:watch": "jest --watch",
"lint": "eslint src/ --ext .ts",
"lint:fix": "eslint src/ --ext .ts --fix",
"format": "prettier --write src/"
},
"keywords": [
"plm",
"nodejs",
"typescript",
"express",
"postgresql"
],
"author": "",
"license": "ISC",
"dependencies": {
"@types/mssql": "^9.1.8",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"bwip-js": "^4.8.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
"docx": "^9.5.1",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"http-proxy-middleware": "^3.0.5",
"iconv-lite": "^0.7.0",
"imap": "^0.8.19",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.7.5",
"mssql": "^11.0.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.15.0",
"node-cron": "^4.2.1",
"node-fetch": "^2.7.0",
"nodemailer": "^6.10.1",
"oracledb": "^6.9.0",
"pg": "^8.16.3",
"quill": "^2.0.3",
"react-quill": "^2.0.0",
"redis": "^4.6.10",
"uuid": "^13.0.0",
"winston": "^3.11.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/bwip-js": "^3.2.3",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/fs-extra": "^11.0.4",
"@types/imap": "^0.8.42",
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"@types/mailparser": "^3.4.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.13",
"@types/node": "^20.10.5",
"@types/node-cron": "^3.0.11",
"@types/node-fetch": "^2.6.13",
"@types/nodemailer": "^6.4.20",
"@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5",
"@types/sanitize-html": "^2.9.5",
"@types/supertest": "^6.0.3",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"eslint": "^8.55.0",
"jest": "^29.7.0",
"nodemon": "^3.1.10",
"prettier": "^3.1.0",
"supertest": "^6.3.4",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=20.10.0",
"npm": ">=10.0.0"
}
}
-183
View File
@@ -1,183 +0,0 @@
/**
* 테이블 타입관리 성능 테스트 스크립트
* 최적화 전후 성능 비교용
*/
const axios = require("axios");
const BASE_URL = "http://localhost:3001/api";
const TEST_TABLE = "user_info"; // 테스트할 테이블명
// 성능 측정 함수
async function measurePerformance(name, fn) {
const start = Date.now();
try {
const result = await fn();
const end = Date.now();
const duration = end - start;
console.log(`${name}: ${duration}ms`);
return { success: true, duration, result };
} catch (error) {
const end = Date.now();
const duration = end - start;
console.log(`${name}: ${duration}ms (실패: ${error.message})`);
return { success: false, duration, error: error.message };
}
}
// 테스트 함수들
const tests = {
// 1. 테이블 목록 조회 성능
async testTableList() {
return await axios.get(`${BASE_URL}/table-management/tables`);
},
// 2. 컬럼 목록 조회 성능 (첫 페이지)
async testColumnListFirstPage() {
return await axios.get(
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=1&size=50`
);
},
// 3. 컬럼 목록 조회 성능 (큰 페이지)
async testColumnListLargePage() {
return await axios.get(
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=1&size=200`
);
},
// 4. 캐시 효과 테스트 (동일한 요청 반복)
async testCacheEffect() {
// 첫 번째 요청 (캐시 미스)
await axios.get(
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=1&size=50`
);
// 두 번째 요청 (캐시 히트)
return await axios.get(
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=1&size=50`
);
},
// 5. 동시 요청 처리 성능
async testConcurrentRequests() {
const requests = Array(10)
.fill()
.map((_, i) =>
axios.get(
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=${i + 1}&size=20`
)
);
return await Promise.all(requests);
},
};
// 메인 테스트 실행
async function runPerformanceTests() {
console.log("🚀 테이블 타입관리 성능 테스트 시작\n");
console.log(`📊 테스트 대상: ${BASE_URL}`);
console.log(`📋 테스트 테이블: ${TEST_TABLE}\n`);
const results = {};
// 각 테스트 실행
for (const [testName, testFn] of Object.entries(tests)) {
console.log(`\n--- ${testName} ---`);
// 각 테스트를 3번 실행하여 평균 계산
const runs = [];
for (let i = 0; i < 3; i++) {
const result = await measurePerformance(`실행 ${i + 1}`, testFn);
runs.push(result);
// 테스트 간 간격
await new Promise((resolve) => setTimeout(resolve, 100));
}
// 성공한 실행들의 평균 시간 계산
const successfulRuns = runs.filter((r) => r.success);
if (successfulRuns.length > 0) {
const avgDuration =
successfulRuns.reduce((sum, r) => sum + r.duration, 0) /
successfulRuns.length;
const minDuration = Math.min(...successfulRuns.map((r) => r.duration));
const maxDuration = Math.max(...successfulRuns.map((r) => r.duration));
results[testName] = {
average: Math.round(avgDuration),
min: minDuration,
max: maxDuration,
successRate: (successfulRuns.length / runs.length) * 100,
};
console.log(
`📈 평균: ${Math.round(avgDuration)}ms, 최소: ${minDuration}ms, 최대: ${maxDuration}ms`
);
} else {
results[testName] = { error: "모든 테스트 실패" };
console.log("❌ 모든 테스트 실패");
}
}
// 결과 요약
console.log("\n" + "=".repeat(50));
console.log("📊 성능 테스트 결과 요약");
console.log("=".repeat(50));
for (const [testName, result] of Object.entries(results)) {
if (result.error) {
console.log(`${testName}: ${result.error}`);
} else {
console.log(
`${testName}: ${result.average}ms (${result.min}-${result.max}ms, 성공률: ${result.successRate}%)`
);
}
}
// 성능 기준 평가
console.log("\n" + "=".repeat(50));
console.log("🎯 성능 기준 평가");
console.log("=".repeat(50));
const benchmarks = {
testTableList: { good: 200, acceptable: 500 },
testColumnListFirstPage: { good: 300, acceptable: 800 },
testColumnListLargePage: { good: 500, acceptable: 1200 },
testCacheEffect: { good: 50, acceptable: 150 },
testConcurrentRequests: { good: 1000, acceptable: 3000 },
};
for (const [testName, result] of Object.entries(results)) {
if (result.error) continue;
const benchmark = benchmarks[testName];
if (!benchmark) continue;
let status = "🔴 느림";
if (result.average <= benchmark.good) {
status = "🟢 우수";
} else if (result.average <= benchmark.acceptable) {
status = "🟡 양호";
}
console.log(`${status} ${testName}: ${result.average}ms`);
}
console.log("\n✨ 성능 테스트 완료!");
}
// 에러 핸들링
process.on("unhandledRejection", (error) => {
console.error("❌ 처리되지 않은 에러:", error.message);
process.exit(1);
});
// 테스트 실행
if (require.main === module) {
runPerformanceTests().catch(console.error);
}
module.exports = { runPerformanceTests, measurePerformance };
@@ -1,52 +0,0 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function addButtonWebType() {
try {
console.log("🔍 버튼 웹타입 확인 중...");
// 기존 button 웹타입 확인
const existingButton = await prisma.web_type_standards.findUnique({
where: { web_type: "button" },
});
if (existingButton) {
console.log("✅ 버튼 웹타입이 이미 존재합니다.");
console.log("📄 기존 설정:", JSON.stringify(existingButton, null, 2));
return;
}
console.log(" 버튼 웹타입 추가 중...");
// 버튼 웹타입 추가
const buttonWebType = await prisma.web_type_standards.create({
data: {
web_type: "button",
type_name: "버튼",
type_name_eng: "Button",
description: "클릭 가능한 버튼 컴포넌트",
category: "action",
component_name: "ButtonWidget",
config_panel: "ButtonConfigPanel",
default_config: {
actionType: "custom",
variant: "default",
},
sort_order: 100,
is_active: "Y",
created_by: "system",
updated_by: "system",
},
});
console.log("✅ 버튼 웹타입이 성공적으로 추가되었습니다!");
console.log("📄 추가된 설정:", JSON.stringify(buttonWebType, null, 2));
} catch (error) {
console.error("❌ 버튼 웹타입 추가 실패:", error);
} finally {
await prisma.$disconnect();
}
}
addButtonWebType();
@@ -1,34 +0,0 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function addDataMappingColumn() {
try {
console.log(
"🔄 external_call_configs 테이블에 data_mapping_config 컬럼 추가 중..."
);
// data_mapping_config JSONB 컬럼 추가
await prisma.$executeRaw`
ALTER TABLE external_call_configs
ADD COLUMN IF NOT EXISTS data_mapping_config JSONB
`;
console.log("✅ data_mapping_config 컬럼이 성공적으로 추가되었습니다.");
// 기존 레코드에 기본값 설정
await prisma.$executeRaw`
UPDATE external_call_configs
SET data_mapping_config = '{"direction": "none"}'::jsonb
WHERE data_mapping_config IS NULL
`;
console.log("✅ 기존 레코드에 기본값이 설정되었습니다.");
} catch (error) {
console.error("❌ 컬럼 추가 실패:", error);
} finally {
await prisma.$disconnect();
}
}
addDataMappingColumn();
@@ -1,174 +0,0 @@
/**
* 외부 DB 연결 정보 추가 스크립트
* 비밀번호를 암호화하여 안전하게 저장
*/
import { Pool } from "pg";
import { CredentialEncryption } from "../src/utils/credentialEncryption";
async function addExternalDbConnection() {
const pool = new Pool({
host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || "5432"),
database: process.env.DB_NAME || "plm",
user: process.env.DB_USER || "postgres",
password: process.env.DB_PASSWORD || "ph0909!!",
});
// 환경 변수에서 암호화 키 가져오기 (없으면 기본값 사용)
const encryptionKey =
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
const encryption = new CredentialEncryption(encryptionKey);
try {
// 외부 DB 연결 정보 (실제 사용할 외부 DB 정보를 여기에 입력)
const externalDbConnections = [
{
name: "운영_외부_PostgreSQL",
description: "운영용 외부 PostgreSQL 데이터베이스",
dbType: "postgresql",
host: "39.117.244.52",
port: 11132,
databaseName: "plm",
username: "postgres",
password: "ph0909!!", // 이 값은 암호화되어 저장됩니다
sslEnabled: false,
isActive: true,
},
// 필요한 경우 추가 외부 DB 연결 정보를 여기에 추가
// {
// name: "테스트_MySQL",
// description: "테스트용 MySQL 데이터베이스",
// dbType: "mysql",
// host: "test-mysql.example.com",
// port: 3306,
// databaseName: "testdb",
// username: "testuser",
// password: "testpass",
// sslEnabled: true,
// isActive: true,
// },
];
for (const conn of externalDbConnections) {
// 비밀번호 암호화
const encryptedPassword = encryption.encrypt(conn.password);
// 중복 체크 (이름 기준)
const existingResult = await pool.query(
"SELECT id FROM flow_external_db_connection WHERE name = $1",
[conn.name]
);
if (existingResult.rows.length > 0) {
console.log(
`⚠️ 이미 존재하는 연결: ${conn.name} (ID: ${existingResult.rows[0].id})`
);
// 기존 연결 업데이트
await pool.query(
`UPDATE flow_external_db_connection
SET description = $1,
db_type = $2,
host = $3,
port = $4,
database_name = $5,
username = $6,
password_encrypted = $7,
ssl_enabled = $8,
is_active = $9,
updated_at = NOW(),
updated_by = 'system'
WHERE name = $10`,
[
conn.description,
conn.dbType,
conn.host,
conn.port,
conn.databaseName,
conn.username,
encryptedPassword,
conn.sslEnabled,
conn.isActive,
conn.name,
]
);
console.log(`✅ 연결 정보 업데이트 완료: ${conn.name}`);
} else {
// 새 연결 추가
const result = await pool.query(
`INSERT INTO flow_external_db_connection (
name,
description,
db_type,
host,
port,
database_name,
username,
password_encrypted,
ssl_enabled,
is_active,
created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
RETURNING id`,
[
conn.name,
conn.description,
conn.dbType,
conn.host,
conn.port,
conn.databaseName,
conn.username,
encryptedPassword,
conn.sslEnabled,
conn.isActive,
]
);
console.log(
`✅ 새 연결 추가 완료: ${conn.name} (ID: ${result.rows[0].id})`
);
}
// 연결 테스트
console.log(`🔍 연결 테스트 중: ${conn.name}...`);
const testPool = new Pool({
host: conn.host,
port: conn.port,
database: conn.databaseName,
user: conn.username,
password: conn.password,
ssl: conn.sslEnabled,
connectionTimeoutMillis: 5000,
});
try {
const client = await testPool.connect();
await client.query("SELECT 1");
client.release();
console.log(`✅ 연결 테스트 성공: ${conn.name}`);
} catch (testError: any) {
console.error(`❌ 연결 테스트 실패: ${conn.name}`, testError.message);
} finally {
await testPool.end();
}
}
console.log("\n✅ 모든 외부 DB 연결 정보 처리 완료");
} catch (error) {
console.error("❌ 외부 DB 연결 정보 추가 오류:", error);
throw error;
} finally {
await pool.end();
}
}
// 스크립트 실행
addExternalDbConnection()
.then(() => {
console.log("✅ 스크립트 완료");
process.exit(0);
})
.catch((error) => {
console.error("❌ 스크립트 실패:", error);
process.exit(1);
});
-105
View File
@@ -1,105 +0,0 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function addMissingColumns() {
try {
console.log("🔄 누락된 컬럼들을 screen_layouts 테이블에 추가 중...");
// layout_type 컬럼 추가
try {
await prisma.$executeRaw`
ALTER TABLE screen_layouts
ADD COLUMN IF NOT EXISTS layout_type VARCHAR(50);
`;
console.log("✅ layout_type 컬럼 추가 완료");
} catch (error) {
console.log(
"️ layout_type 컬럼이 이미 존재하거나 추가 중 오류:",
error.message
);
}
// layout_config 컬럼 추가
try {
await prisma.$executeRaw`
ALTER TABLE screen_layouts
ADD COLUMN IF NOT EXISTS layout_config JSONB;
`;
console.log("✅ layout_config 컬럼 추가 완료");
} catch (error) {
console.log(
"️ layout_config 컬럼이 이미 존재하거나 추가 중 오류:",
error.message
);
}
// zones_config 컬럼 추가
try {
await prisma.$executeRaw`
ALTER TABLE screen_layouts
ADD COLUMN IF NOT EXISTS zones_config JSONB;
`;
console.log("✅ zones_config 컬럼 추가 완료");
} catch (error) {
console.log(
"️ zones_config 컬럼이 이미 존재하거나 추가 중 오류:",
error.message
);
}
// zone_id 컬럼 추가
try {
await prisma.$executeRaw`
ALTER TABLE screen_layouts
ADD COLUMN IF NOT EXISTS zone_id VARCHAR(100);
`;
console.log("✅ zone_id 컬럼 추가 완료");
} catch (error) {
console.log(
"️ zone_id 컬럼이 이미 존재하거나 추가 중 오류:",
error.message
);
}
// 인덱스 생성 (성능 향상)
try {
await prisma.$executeRaw`
CREATE INDEX IF NOT EXISTS idx_screen_layouts_layout_type
ON screen_layouts(layout_type);
`;
console.log("✅ layout_type 인덱스 생성 완료");
} catch (error) {
console.log("️ layout_type 인덱스 생성 중 오류:", error.message);
}
try {
await prisma.$executeRaw`
CREATE INDEX IF NOT EXISTS idx_screen_layouts_zone_id
ON screen_layouts(zone_id);
`;
console.log("✅ zone_id 인덱스 생성 완료");
} catch (error) {
console.log("️ zone_id 인덱스 생성 중 오류:", error.message);
}
// 최종 테이블 구조 확인
const columns = await prisma.$queryRaw`
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'screen_layouts'
ORDER BY ordinal_position
`;
console.log("\n📋 screen_layouts 테이블 최종 구조:");
console.table(columns);
console.log("\n🎉 모든 누락된 컬럼 추가 작업이 완료되었습니다!");
} catch (error) {
console.error("❌ 컬럼 추가 중 오류 발생:", error);
} finally {
await prisma.$disconnect();
}
}
addMissingColumns();
@@ -1,318 +0,0 @@
/**
* 탑씰(company_7) 버튼 스타일 일괄 변경 스크립트
*
* 사용법:
* npx ts-node scripts/btn-bulk-update-company7.ts --test # 1건만 테스트 (ROLLBACK)
* npx ts-node scripts/btn-bulk-update-company7.ts --run # 전체 실행 (COMMIT)
* npx ts-node scripts/btn-bulk-update-company7.ts --backup # 백업 테이블만 생성
* npx ts-node scripts/btn-bulk-update-company7.ts --restore # 백업에서 원복
*/
import { Pool } from "pg";
// ── 배포 DB 연결 ──
const pool = new Pool({
connectionString:
"postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor",
});
const COMPANY_CODE = "COMPANY_7";
const BACKUP_TABLE = "screen_layouts_v2_backup_20260313";
// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ──
const actionIconMap: Record<string, string> = {
save: "Check",
delete: "Trash2",
edit: "Pencil",
navigate: "ArrowRight",
modal: "Maximize2",
transferData: "SendHorizontal",
excel_download: "Download",
excel_upload: "Upload",
quickInsert: "Zap",
control: "Settings",
barcode_scan: "ScanLine",
operation_control: "Truck",
event: "Send",
copy: "Copy",
};
const FALLBACK_ICON = "SquareMousePointer";
function getIconForAction(actionType?: string): string {
if (actionType && actionIconMap[actionType]) {
return actionIconMap[actionType];
}
return FALLBACK_ICON;
}
// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ──
function isTopLevelButton(comp: any): boolean {
return (
comp.url?.includes("v2-button-primary") ||
comp.overrides?.type === "v2-button-primary"
);
}
function isTabChildButton(comp: any): boolean {
return comp.componentType === "v2-button-primary";
}
function isButtonComponent(comp: any): boolean {
return isTopLevelButton(comp) || isTabChildButton(comp);
}
// ── 탭 위젯인지 판별 ──
function isTabsWidget(comp: any): boolean {
return (
comp.url?.includes("v2-tabs-widget") ||
comp.overrides?.type === "v2-tabs-widget"
);
}
// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ──
function applyButtonStyle(config: any, actionType: string | undefined) {
const iconName = getIconForAction(actionType);
config.displayMode = "icon-text";
config.icon = {
name: iconName,
type: "lucide",
size: "보통",
...(config.icon?.color ? { color: config.icon.color } : {}),
};
config.iconTextPosition = "right";
config.iconGap = 6;
if (!config.style) config.style = {};
delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용)
config.style.borderRadius = "8px";
config.style.labelColor = "#FFFFFF";
config.style.fontSize = "12px";
config.style.fontWeight = "normal";
config.style.labelTextAlign = "left";
if (actionType === "delete") {
config.style.backgroundColor = "#F04544";
} else if (actionType === "excel_upload" || actionType === "excel_download") {
config.style.backgroundColor = "#212121";
} else {
config.style.backgroundColor = "#3B83F6";
}
}
function updateButtonStyle(comp: any): boolean {
if (isTopLevelButton(comp)) {
const overrides = comp.overrides || {};
const actionType = overrides.action?.type;
if (!comp.size) comp.size = {};
comp.size.height = 40;
applyButtonStyle(overrides, actionType);
comp.overrides = overrides;
return true;
}
if (isTabChildButton(comp)) {
const config = comp.componentConfig || {};
const actionType = config.action?.type;
if (!comp.size) comp.size = {};
comp.size.height = 40;
applyButtonStyle(config, actionType);
comp.componentConfig = config;
// 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음
if (!comp.style) comp.style = {};
comp.style.borderRadius = "8px";
comp.style.labelColor = "#FFFFFF";
comp.style.fontSize = "12px";
comp.style.fontWeight = "normal";
comp.style.labelTextAlign = "left";
comp.style.backgroundColor = config.style.backgroundColor;
return true;
}
return false;
}
// ── 백업 테이블 생성 ──
async function createBackup() {
console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`);
const exists = await pool.query(
`SELECT to_regclass($1) AS tbl`,
[BACKUP_TABLE],
);
if (exists.rows[0].tbl) {
console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`);
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
console.log(`기존 백업 레코드 수: ${count.rows[0].count}`);
return;
}
await pool.query(
`CREATE TABLE ${BACKUP_TABLE} AS
SELECT * FROM screen_layouts_v2
WHERE company_code = $1`,
[COMPANY_CODE],
);
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`);
}
// ── 백업에서 원복 ──
async function restoreFromBackup() {
console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`);
const result = await pool.query(
`UPDATE screen_layouts_v2 AS target
SET layout_data = backup.layout_data,
updated_at = backup.updated_at
FROM ${BACKUP_TABLE} AS backup
WHERE target.screen_id = backup.screen_id
AND target.company_code = backup.company_code
AND target.layer_id = backup.layer_id`,
);
console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`);
}
// ── 메인: 버튼 일괄 변경 ──
async function updateButtons(testMode: boolean) {
const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)";
console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`);
// company_7 레코드 조회
const rows = await pool.query(
`SELECT screen_id, layer_id, company_code, layout_data
FROM screen_layouts_v2
WHERE company_code = $1
ORDER BY screen_id, layer_id`,
[COMPANY_CODE],
);
console.log(`대상 레코드 수: ${rows.rowCount}`);
if (!rows.rowCount) {
console.log("변경할 레코드가 없습니다.");
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
let totalUpdated = 0;
let totalButtons = 0;
const targetRows = testMode ? [rows.rows[0]] : rows.rows;
for (const row of targetRows) {
const layoutData = row.layout_data;
if (!layoutData?.components || !Array.isArray(layoutData.components)) {
continue;
}
let buttonsInRow = 0;
for (const comp of layoutData.components) {
// 최상위 버튼 처리
if (updateButtonStyle(comp)) {
buttonsInRow++;
}
// 탭 위젯 내부 버튼 처리
if (isTabsWidget(comp)) {
const tabs = comp.overrides?.tabs || [];
for (const tab of tabs) {
const tabComps = tab.components || [];
for (const tabComp of tabComps) {
if (updateButtonStyle(tabComp)) {
buttonsInRow++;
}
}
}
}
}
if (buttonsInRow > 0) {
await client.query(
`UPDATE screen_layouts_v2
SET layout_data = $1, updated_at = NOW()
WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`,
[JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id],
);
totalUpdated++;
totalButtons += buttonsInRow;
console.log(
` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`,
);
// 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력
if (testMode) {
const sampleBtn = layoutData.components.find(isButtonComponent);
if (sampleBtn) {
console.log("\n--- 변경 후 샘플 버튼 ---");
console.log(JSON.stringify(sampleBtn, null, 2));
}
}
}
}
console.log(`\n--- 결과 ---`);
console.log(`변경된 레코드: ${totalUpdated}`);
console.log(`변경된 버튼: ${totalButtons}`);
if (testMode) {
await client.query("ROLLBACK");
console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음.");
} else {
await client.query("COMMIT");
console.log("\nCOMMIT 완료.");
}
} catch (err) {
await client.query("ROLLBACK");
console.error("\n에러 발생. ROLLBACK 완료.", err);
throw err;
} finally {
client.release();
}
}
// ── CLI 진입점 ──
async function main() {
const arg = process.argv[2];
if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) {
console.log("사용법:");
console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)");
console.log(" --run : 전체 실행 (COMMIT)");
console.log(" --backup : 백업 테이블 생성");
console.log(" --restore : 백업에서 원복");
process.exit(1);
}
try {
if (arg === "--backup") {
await createBackup();
} else if (arg === "--restore") {
await restoreFromBackup();
} else if (arg === "--test") {
await createBackup();
await updateButtons(true);
} else if (arg === "--run") {
await createBackup();
await updateButtons(false);
}
} catch (err) {
console.error("스크립트 실행 실패:", err);
process.exit(1);
} finally {
await pool.end();
}
}
main();
@@ -1,75 +0,0 @@
/**
* dashboards 테이블 구조 확인 스크립트
*/
const { Pool } = require('pg');
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
const pool = new Pool({
connectionString: databaseUrl,
});
async function checkDashboardStructure() {
const client = await pool.connect();
try {
console.log('🔍 dashboards 테이블 구조 확인 중...\n');
// 컬럼 정보 조회
const columns = await client.query(`
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'dashboards'
ORDER BY ordinal_position
`);
console.log('📋 dashboards 테이블 컬럼:\n');
columns.rows.forEach((col, index) => {
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
});
// 샘플 데이터 조회
console.log('\n📊 샘플 데이터 (첫 1개):');
const sample = await client.query(`
SELECT * FROM dashboards LIMIT 1
`);
if (sample.rows.length > 0) {
console.log(JSON.stringify(sample.rows[0], null, 2));
} else {
console.log('❌ 데이터가 없습니다.');
}
// dashboard_elements 테이블도 확인
console.log('\n🔍 dashboard_elements 테이블 구조 확인 중...\n');
const elemColumns = await client.query(`
SELECT
column_name,
data_type,
is_nullable
FROM information_schema.columns
WHERE table_name = 'dashboard_elements'
ORDER BY ordinal_position
`);
console.log('📋 dashboard_elements 테이블 컬럼:\n');
elemColumns.rows.forEach((col, index) => {
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
});
} catch (error) {
console.error('❌ 오류 발생:', error.message);
} finally {
client.release();
await pool.end();
}
}
checkDashboardStructure();
-55
View File
@@ -1,55 +0,0 @@
/**
* 데이터베이스 테이블 확인 스크립트
*/
const { Pool } = require('pg');
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
const pool = new Pool({
connectionString: databaseUrl,
});
async function checkTables() {
const client = await pool.connect();
try {
console.log('🔍 데이터베이스 테이블 확인 중...\n');
// 테이블 목록 조회
const result = await client.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name
`);
console.log(`📊 총 ${result.rows.length}개의 테이블 발견:\n`);
result.rows.forEach((row, index) => {
console.log(`${index + 1}. ${row.table_name}`);
});
// dashboard 관련 테이블 검색
console.log('\n🔎 dashboard 관련 테이블:');
const dashboardTables = result.rows.filter(row =>
row.table_name.toLowerCase().includes('dashboard')
);
if (dashboardTables.length === 0) {
console.log('❌ dashboard 관련 테이블을 찾을 수 없습니다.');
} else {
dashboardTables.forEach(row => {
console.log(`${row.table_name}`);
});
}
} catch (error) {
console.error('❌ 오류 발생:', error.message);
} finally {
client.release();
await pool.end();
}
}
checkTables();
@@ -1,74 +0,0 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function createComponentTable() {
try {
console.log("🔧 component_standards 테이블 생성 중...");
// 테이블 생성 SQL
await prisma.$executeRaw`
CREATE TABLE IF NOT EXISTS component_standards (
component_code VARCHAR(50) PRIMARY KEY,
component_name VARCHAR(100) NOT NULL,
component_name_eng VARCHAR(100),
description TEXT,
category VARCHAR(50) NOT NULL,
icon_name VARCHAR(50),
default_size JSON,
component_config JSON NOT NULL,
preview_image VARCHAR(255),
sort_order INTEGER DEFAULT 0,
is_active CHAR(1) DEFAULT 'Y',
is_public CHAR(1) DEFAULT 'Y',
company_code VARCHAR(50) NOT NULL,
created_date TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_date TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50)
)
`;
console.log("✅ component_standards 테이블 생성 완료");
// 인덱스 생성
await prisma.$executeRaw`
CREATE INDEX IF NOT EXISTS idx_component_standards_category
ON component_standards (category)
`;
await prisma.$executeRaw`
CREATE INDEX IF NOT EXISTS idx_component_standards_company
ON component_standards (company_code)
`;
console.log("✅ 인덱스 생성 완료");
// 테이블 코멘트 추가
await prisma.$executeRaw`
COMMENT ON TABLE component_standards IS 'UI 컴포넌트 표준 정보를 저장하는 테이블'
`;
console.log("✅ 테이블 코멘트 추가 완료");
} catch (error) {
console.error("❌ 테이블 생성 실패:", error);
throw error;
} finally {
await prisma.$disconnect();
}
}
// 실행
if (require.main === module) {
createComponentTable()
.then(() => {
console.log("🎉 테이블 생성 완료!");
process.exit(0);
})
.catch((error) => {
console.error("💥 테이블 생성 실패:", error);
process.exit(1);
});
}
module.exports = { createComponentTable };
-16
View File
@@ -1,16 +0,0 @@
/**
* 비밀번호 암호화 유틸리티
*/
import { CredentialEncryption } from "../src/utils/credentialEncryption";
const encryptionKey =
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
const encryption = new CredentialEncryption(encryptionKey);
const password = process.argv[2] || "ph0909!!";
const encrypted = encryption.encrypt(password);
console.log("\n원본 비밀번호:", password);
console.log("암호화된 비밀번호:", encrypted);
console.log("\n복호화 테스트:", encryption.decrypt(encrypted));
console.log("✅ 암호화/복호화 성공\n");
@@ -1,309 +0,0 @@
/**
* 레이아웃 표준 데이터 초기화 스크립트
* 기본 레이아웃들을 layout_standards 테이블에 삽입합니다.
*/
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// 기본 레이아웃 데이터
const PREDEFINED_LAYOUTS = [
{
layout_code: "GRID_2X2_001",
layout_name: "2x2 그리드",
layout_name_eng: "2x2 Grid",
description: "2행 2열의 균등한 그리드 레이아웃입니다.",
layout_type: "grid",
category: "basic",
icon_name: "grid",
default_size: { width: 800, height: 600 },
layout_config: {
grid: { rows: 2, columns: 2, gap: 16 },
},
zones_config: [
{
id: "zone1",
name: "상단 좌측",
position: { row: 0, column: 0 },
size: { width: "50%", height: "50%" },
},
{
id: "zone2",
name: "상단 우측",
position: { row: 0, column: 1 },
size: { width: "50%", height: "50%" },
},
{
id: "zone3",
name: "하단 좌측",
position: { row: 1, column: 0 },
size: { width: "50%", height: "50%" },
},
{
id: "zone4",
name: "하단 우측",
position: { row: 1, column: 1 },
size: { width: "50%", height: "50%" },
},
],
sort_order: 1,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "FORM_TWO_COLUMN_001",
layout_name: "2단 폼 레이아웃",
layout_name_eng: "Two Column Form",
description: "좌우 2단으로 구성된 폼 레이아웃입니다.",
layout_type: "grid",
category: "form",
icon_name: "columns",
default_size: { width: 800, height: 400 },
layout_config: {
grid: { rows: 1, columns: 2, gap: 24 },
},
zones_config: [
{
id: "left",
name: "좌측 입력 영역",
position: { row: 0, column: 0 },
size: { width: "50%", height: "100%" },
},
{
id: "right",
name: "우측 입력 영역",
position: { row: 0, column: 1 },
size: { width: "50%", height: "100%" },
},
],
sort_order: 2,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "FLEXBOX_ROW_001",
layout_name: "가로 플렉스박스",
layout_name_eng: "Horizontal Flexbox",
description: "가로 방향으로 배치되는 플렉스박스 레이아웃입니다.",
layout_type: "flexbox",
category: "basic",
icon_name: "flex",
default_size: { width: 800, height: 300 },
layout_config: {
flexbox: {
direction: "row",
justify: "flex-start",
align: "stretch",
wrap: "nowrap",
gap: 16,
},
},
zones_config: [
{
id: "left",
name: "좌측 영역",
position: {},
size: { width: "50%", height: "100%" },
},
{
id: "right",
name: "우측 영역",
position: {},
size: { width: "50%", height: "100%" },
},
],
sort_order: 3,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "SPLIT_HORIZONTAL_001",
layout_name: "수평 분할",
layout_name_eng: "Horizontal Split",
description: "크기 조절이 가능한 수평 분할 레이아웃입니다.",
layout_type: "split",
category: "basic",
icon_name: "separator-horizontal",
default_size: { width: 800, height: 400 },
layout_config: {
split: {
direction: "horizontal",
ratio: [50, 50],
minSize: [200, 200],
resizable: true,
splitterSize: 4,
},
},
zones_config: [
{
id: "left",
name: "좌측 패널",
position: {},
size: { width: "50%", height: "100%" },
isResizable: true,
},
{
id: "right",
name: "우측 패널",
position: {},
size: { width: "50%", height: "100%" },
isResizable: true,
},
],
sort_order: 4,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "TABS_HORIZONTAL_001",
layout_name: "수평 탭",
layout_name_eng: "Horizontal Tabs",
description: "상단에 탭이 있는 탭 레이아웃입니다.",
layout_type: "tabs",
category: "navigation",
icon_name: "tabs",
default_size: { width: 800, height: 500 },
layout_config: {
tabs: {
position: "top",
variant: "default",
size: "md",
defaultTab: "tab1",
closable: false,
},
},
zones_config: [
{
id: "tab1",
name: "첫 번째 탭",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "tab2",
name: "두 번째 탭",
position: {},
size: { width: "100%", height: "100%" },
},
{
id: "tab3",
name: "세 번째 탭",
position: {},
size: { width: "100%", height: "100%" },
},
],
sort_order: 5,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
{
layout_code: "TABLE_WITH_FILTERS_001",
layout_name: "필터가 있는 테이블",
layout_name_eng: "Table with Filters",
description: "상단에 필터가 있고 하단에 테이블이 있는 레이아웃입니다.",
layout_type: "flexbox",
category: "table",
icon_name: "table",
default_size: { width: 1000, height: 600 },
layout_config: {
flexbox: {
direction: "column",
justify: "flex-start",
align: "stretch",
wrap: "nowrap",
gap: 16,
},
},
zones_config: [
{
id: "filters",
name: "검색 필터",
position: {},
size: { width: "100%", height: "auto" },
},
{
id: "table",
name: "데이터 테이블",
position: {},
size: { width: "100%", height: "1fr" },
},
],
sort_order: 6,
is_active: "Y",
is_public: "Y",
company_code: "DEFAULT",
},
];
async function initializeLayoutStandards() {
try {
console.log("🏗️ 레이아웃 표준 데이터 초기화 시작...");
// 기존 데이터 확인
const existingLayouts = await prisma.layout_standards.count();
if (existingLayouts > 0) {
console.log(`⚠️ 이미 ${existingLayouts}개의 레이아웃이 존재합니다.`);
console.log(
"기존 데이터를 삭제하고 새로 생성하시겠습니까? (기본값: 건너뛰기)"
);
// 기존 데이터가 있으면 건너뛰기 (안전을 위해)
console.log("💡 기존 데이터를 유지하고 건너뜁니다.");
return;
}
// 데이터 삽입
let insertedCount = 0;
for (const layoutData of PREDEFINED_LAYOUTS) {
try {
await prisma.layout_standards.create({
data: {
...layoutData,
created_date: new Date(),
updated_date: new Date(),
created_by: "SYSTEM",
updated_by: "SYSTEM",
},
});
console.log(`${layoutData.layout_name} 생성 완료`);
insertedCount++;
} catch (error) {
console.error(`${layoutData.layout_name} 생성 실패:`, error.message);
}
}
console.log(
`🎉 레이아웃 표준 데이터 초기화 완료! (${insertedCount}/${PREDEFINED_LAYOUTS.length})`
);
} catch (error) {
console.error("❌ 레이아웃 표준 데이터 초기화 실패:", error);
throw error;
}
}
// 스크립트 실행
if (require.main === module) {
initializeLayoutStandards()
.then(() => {
console.log("✨ 스크립트 실행 완료");
process.exit(0);
})
.catch((error) => {
console.error("💥 스크립트 실행 실패:", error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
}
module.exports = { initializeLayoutStandards };
@@ -1,200 +0,0 @@
/**
* 🔥 버튼 제어관리 성능 최적화 인덱스 설치 스크립트
*
* 사용법:
* node scripts/install-dataflow-indexes.js
*/
const { PrismaClient } = require("@prisma/client");
const fs = require("fs");
const path = require("path");
const prisma = new PrismaClient();
async function installDataflowIndexes() {
try {
console.log("🔥 Starting Button Dataflow Performance Optimization...\n");
// SQL 파일 읽기
const sqlFilePath = path.join(
__dirname,
"../database/migrations/add_button_dataflow_indexes.sql"
);
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
console.log("📖 Reading SQL migration file...");
console.log(`📁 File: ${sqlFilePath}\n`);
// 데이터베이스 연결 확인
console.log("🔍 Checking database connection...");
await prisma.$queryRaw`SELECT 1`;
console.log("✅ Database connection OK\n");
// 기존 인덱스 상태 확인
console.log("🔍 Checking existing indexes...");
const existingIndexes = await prisma.$queryRaw`
SELECT indexname, tablename
FROM pg_indexes
WHERE tablename = 'dataflow_diagrams'
AND indexname LIKE 'idx_dataflow%'
ORDER BY indexname;
`;
if (existingIndexes.length > 0) {
console.log("📋 Existing dataflow indexes:");
existingIndexes.forEach((idx) => {
console.log(` - ${idx.indexname}`);
});
} else {
console.log("📋 No existing dataflow indexes found");
}
console.log("");
// 테이블 상태 확인
console.log("🔍 Checking dataflow_diagrams table stats...");
const tableStats = await prisma.$queryRaw`
SELECT
COUNT(*) as total_rows,
COUNT(*) FILTER (WHERE control IS NOT NULL) as with_control,
COUNT(*) FILTER (WHERE plan IS NOT NULL) as with_plan,
COUNT(*) FILTER (WHERE category IS NOT NULL) as with_category,
COUNT(DISTINCT company_code) as companies
FROM dataflow_diagrams;
`;
if (tableStats.length > 0) {
const stats = tableStats[0];
console.log(`📊 Table Statistics:`);
console.log(` - Total rows: ${stats.total_rows}`);
console.log(` - With control: ${stats.with_control}`);
console.log(` - With plan: ${stats.with_plan}`);
console.log(` - With category: ${stats.with_category}`);
console.log(` - Companies: ${stats.companies}`);
}
console.log("");
// SQL 실행
console.log("🚀 Installing performance indexes...");
console.log("⏳ This may take a few minutes for large datasets...\n");
const startTime = Date.now();
// SQL을 문장별로 나누어 실행 (PostgreSQL 함수 때문에)
const sqlStatements = sqlContent
.split(/;\s*(?=\n|$)/)
.filter(
(stmt) =>
stmt.trim().length > 0 &&
!stmt.trim().startsWith("--") &&
!stmt.trim().startsWith("/*")
);
for (let i = 0; i < sqlStatements.length; i++) {
const statement = sqlStatements[i].trim();
if (statement.length === 0) continue;
try {
// DO 블록이나 복합 문장 처리
if (
statement.includes("DO $$") ||
statement.includes("CREATE OR REPLACE VIEW")
) {
console.log(
`⚡ Executing statement ${i + 1}/${sqlStatements.length}...`
);
await prisma.$executeRawUnsafe(statement + ";");
} else if (statement.startsWith("CREATE INDEX")) {
const indexName =
statement.match(/CREATE INDEX[^"]*"?([^"\s]+)"?/)?.[1] || "unknown";
console.log(`🔧 Creating index: ${indexName}...`);
await prisma.$executeRawUnsafe(statement + ";");
} else if (statement.startsWith("ANALYZE")) {
console.log(`📊 Analyzing table statistics...`);
await prisma.$executeRawUnsafe(statement + ";");
} else {
await prisma.$executeRawUnsafe(statement + ";");
}
} catch (error) {
// 이미 존재하는 인덱스 에러는 무시
if (error.message.includes("already exists")) {
console.log(`⚠️ Index already exists, skipping...`);
} else {
console.error(`❌ Error executing statement: ${error.message}`);
console.error(`📝 Statement: ${statement.substring(0, 100)}...`);
}
}
}
const endTime = Date.now();
const executionTime = (endTime - startTime) / 1000;
console.log(
`\n✅ Index installation completed in ${executionTime.toFixed(2)} seconds!`
);
// 설치된 인덱스 확인
console.log("\n🔍 Verifying installed indexes...");
const newIndexes = await prisma.$queryRaw`
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) as size
FROM pg_stat_user_indexes
WHERE tablename = 'dataflow_diagrams'
AND indexname LIKE 'idx_dataflow%'
ORDER BY indexname;
`;
if (newIndexes.length > 0) {
console.log("📋 Installed indexes:");
newIndexes.forEach((idx) => {
console.log(`${idx.indexname} (${idx.size})`);
});
}
// 성능 통계 조회
console.log("\n📊 Performance statistics:");
try {
const perfStats =
await prisma.$queryRaw`SELECT * FROM dataflow_performance_stats;`;
if (perfStats.length > 0) {
const stats = perfStats[0];
console.log(` - Table size: ${stats.table_size}`);
console.log(` - Total diagrams: ${stats.total_rows}`);
console.log(` - With control: ${stats.with_control}`);
console.log(` - Companies: ${stats.companies}`);
}
} catch (error) {
console.log(" ⚠️ Performance view not available yet");
}
console.log("\n🎯 Performance Optimization Complete!");
console.log("Expected improvements:");
console.log(" - Button dataflow lookup: 500ms+ → 10-50ms ⚡");
console.log(" - Category filtering: 200ms+ → 5-20ms ⚡");
console.log(" - Company queries: 100ms+ → 5-15ms ⚡");
console.log("\n💡 Monitor performance with:");
console.log(" SELECT * FROM dataflow_performance_stats;");
console.log(" SELECT * FROM dataflow_index_efficiency;");
} catch (error) {
console.error("\n❌ Error installing dataflow indexes:", error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
// 실행
if (require.main === module) {
installDataflowIndexes()
.then(() => {
console.log("\n🎉 Installation completed successfully!");
process.exit(0);
})
.catch((error) => {
console.error("\n💥 Installation failed:", error);
process.exit(1);
});
}
module.exports = { installDataflowIndexes };
-46
View File
@@ -1,46 +0,0 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function getComponents() {
try {
const components = await prisma.component_standards.findMany({
where: { is_active: "Y" },
select: {
component_code: true,
component_name: true,
category: true,
component_config: true,
},
orderBy: [{ category: "asc" }, { sort_order: "asc" }],
});
console.log("📋 데이터베이스 컴포넌트 목록:");
console.log("=".repeat(60));
const grouped = components.reduce((acc, comp) => {
if (!acc[comp.category]) {
acc[comp.category] = [];
}
acc[comp.category].push(comp);
return acc;
}, {});
Object.entries(grouped).forEach(([category, comps]) => {
console.log(`\n🏷️ ${category.toUpperCase()} 카테고리:`);
comps.forEach((comp) => {
const type = comp.component_config?.type || "unknown";
console.log(
` - ${comp.component_code}: ${comp.component_name} (type: ${type})`
);
});
});
console.log(`\n${components.length}개 컴포넌트 발견`);
} catch (error) {
console.error("Error:", error);
} finally {
await prisma.$disconnect();
}
}
getComponents();
@@ -1,168 +0,0 @@
import { query } from "../src/database/db";
import { logger } from "../src/utils/logger";
/**
* input_type을 web_type으로 마이그레이션하는 스크립트
*
* 목적:
* - column_labels 테이블의 input_type 값을 읽어서
* - 해당하는 기본 web_type 값으로 변환
* - web_type이 null인 경우에만 업데이트
*/
// input_type → 기본 web_type 매핑
const INPUT_TYPE_TO_WEB_TYPE: Record<string, string> = {
text: "text", // 일반 텍스트
number: "number", // 정수
date: "date", // 날짜
code: "code", // 코드 선택박스
entity: "entity", // 엔티티 참조
select: "select", // 선택박스
checkbox: "checkbox", // 체크박스
radio: "radio", // 라디오버튼
direct: "text", // direct는 text로 매핑
};
async function migrateInputTypeToWebType() {
try {
logger.info("=".repeat(60));
logger.info("input_type → web_type 마이그레이션 시작");
logger.info("=".repeat(60));
// 1. 현재 상태 확인
const stats = await query<{
total: string;
has_input_type: string;
has_web_type: string;
needs_migration: string;
}>(
`SELECT
COUNT(*) as total,
COUNT(input_type) FILTER (WHERE input_type IS NOT NULL) as has_input_type,
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type,
COUNT(*) FILTER (WHERE input_type IS NOT NULL AND web_type IS NULL) as needs_migration
FROM column_labels`
);
const stat = stats[0];
logger.info("\n📊 현재 상태:");
logger.info(` - 전체 컬럼: ${stat.total}`);
logger.info(` - input_type 있음: ${stat.has_input_type}`);
logger.info(` - web_type 있음: ${stat.has_web_type}`);
logger.info(` - 마이그레이션 필요: ${stat.needs_migration}`);
if (parseInt(stat.needs_migration) === 0) {
logger.info("\n✅ 마이그레이션이 필요한 데이터가 없습니다.");
return;
}
// 2. input_type별 분포 확인
const distribution = await query<{
input_type: string;
count: string;
}>(
`SELECT
input_type,
COUNT(*) as count
FROM column_labels
WHERE input_type IS NOT NULL AND web_type IS NULL
GROUP BY input_type
ORDER BY input_type`
);
logger.info("\n📋 input_type별 분포:");
distribution.forEach((item) => {
const webType =
INPUT_TYPE_TO_WEB_TYPE[item.input_type] || item.input_type;
logger.info(` - ${item.input_type}${webType}: ${item.count}`);
});
// 3. 마이그레이션 실행
logger.info("\n🔄 마이그레이션 실행 중...");
let totalUpdated = 0;
for (const [inputType, webType] of Object.entries(INPUT_TYPE_TO_WEB_TYPE)) {
const result = await query(
`UPDATE column_labels
SET
web_type = $1,
updated_date = NOW()
WHERE input_type = $2
AND web_type IS NULL
RETURNING id, table_name, column_name`,
[webType, inputType]
);
if (result.length > 0) {
logger.info(
`${inputType}${webType}: ${result.length}개 업데이트`
);
totalUpdated += result.length;
// 처음 5개만 출력
result.slice(0, 5).forEach((row: any) => {
logger.info(` - ${row.table_name}.${row.column_name}`);
});
if (result.length > 5) {
logger.info(` ... 외 ${result.length - 5}`);
}
}
}
// 4. 결과 확인
const afterStats = await query<{
total: string;
has_web_type: string;
}>(
`SELECT
COUNT(*) as total,
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type
FROM column_labels`
);
const afterStat = afterStats[0];
logger.info("\n" + "=".repeat(60));
logger.info("✅ 마이그레이션 완료!");
logger.info("=".repeat(60));
logger.info(`📊 최종 통계:`);
logger.info(` - 전체 컬럼: ${afterStat.total}`);
logger.info(` - web_type 설정됨: ${afterStat.has_web_type}`);
logger.info(` - 업데이트된 컬럼: ${totalUpdated}`);
logger.info("=".repeat(60));
// 5. 샘플 데이터 출력
logger.info("\n📝 샘플 데이터 (check_report_mng 테이블):");
const samples = await query<{
column_name: string;
input_type: string;
web_type: string;
detail_settings: string;
}>(
`SELECT
column_name,
input_type,
web_type,
detail_settings
FROM column_labels
WHERE table_name = 'check_report_mng'
ORDER BY column_name
LIMIT 10`
);
samples.forEach((sample) => {
logger.info(
` ${sample.column_name}: ${sample.input_type}${sample.web_type}`
);
});
process.exit(0);
} catch (error) {
logger.error("❌ 마이그레이션 실패:", error);
process.exit(1);
}
}
// 스크립트 실행
migrateInputTypeToWebType();
@@ -1,35 +0,0 @@
/**
* system_notice 테이블 생성 마이그레이션 실행
*/
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm',
ssl: false,
});
async function run() {
const client = await pool.connect();
try {
const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql');
const sql = fs.readFileSync(sqlPath, 'utf8');
await client.query(sql);
console.log('OK: system_notice 테이블 생성 완료');
// 검증
const result = await client.query(
"SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position"
);
console.log('컬럼:', result.rows.map(r => r.column_name).join(', '));
} catch (e) {
console.error('ERROR:', e.message);
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
run();
-53
View File
@@ -1,53 +0,0 @@
/**
* SQL 마이그레이션 실행 스크립트
* 사용법: node scripts/run-migration.js
*/
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
// DATABASE_URL에서 연결 정보 파싱
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
// 데이터베이스 연결 설정
const pool = new Pool({
connectionString: databaseUrl,
});
async function runMigration() {
const client = await pool.connect();
try {
console.log('🔄 마이그레이션 시작...\n');
// SQL 파일 읽기 (Docker 컨테이너 내부 경로)
const sqlPath = '/tmp/migration.sql';
const sql = fs.readFileSync(sqlPath, 'utf8');
console.log('📄 SQL 파일 로드 완료');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
// SQL 실행
await client.query(sql);
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✅ 마이그레이션 성공적으로 완료되었습니다!');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
} catch (error) {
console.error('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.error('❌ 마이그레이션 실패:');
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.error(error);
console.error('\n💡 롤백이 필요한 경우 롤백 스크립트를 실행하세요.');
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
// 실행
runMigration();
@@ -1,38 +0,0 @@
/**
* system_notice 마이그레이션 실행 스크립트
* 사용법: node scripts/run-notice-migration.js
*/
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm',
ssl: false,
});
async function run() {
const client = await pool.connect();
try {
const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql');
const sql = fs.readFileSync(sqlPath, 'utf8');
console.log('마이그레이션 실행 중...');
await client.query(sql);
console.log('마이그레이션 완료');
// 컬럼 확인
const check = await client.query(
"SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position"
);
console.log('테이블 컬럼:', check.rows.map(r => r.column_name).join(', '));
} catch (e) {
console.error('오류:', e.message);
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
run();
-294
View File
@@ -1,294 +0,0 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// 기본 템플릿 데이터 정의
const defaultTemplates = [
{
template_code: "advanced-data-table-v2",
template_name: "고급 데이터 테이블 v2",
template_name_eng: "Advanced Data Table v2",
description:
"검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트",
category: "table",
icon_name: "table",
default_size: {
width: 1000,
height: 680,
},
layout_config: {
components: [
{
type: "datatable",
label: "고급 데이터 테이블",
position: { x: 0, y: 0 },
size: { width: 1000, height: 680 },
style: {
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: "#ffffff",
padding: "0",
},
columns: [
{
id: "id",
label: "ID",
type: "number",
visible: true,
sortable: true,
filterable: false,
width: 80,
},
{
id: "name",
label: "이름",
type: "text",
visible: true,
sortable: true,
filterable: true,
width: 150,
},
{
id: "email",
label: "이메일",
type: "email",
visible: true,
sortable: true,
filterable: true,
width: 200,
},
{
id: "status",
label: "상태",
type: "select",
visible: true,
sortable: true,
filterable: true,
width: 100,
},
{
id: "created_date",
label: "생성일",
type: "date",
visible: true,
sortable: true,
filterable: true,
width: 120,
},
],
filters: [
{
id: "status",
label: "상태",
type: "select",
options: [
{ label: "전체", value: "" },
{ label: "활성", value: "active" },
{ label: "비활성", value: "inactive" },
],
},
{ id: "name", label: "이름", type: "text" },
{ id: "email", label: "이메일", type: "text" },
],
pagination: {
enabled: true,
pageSize: 10,
pageSizeOptions: [5, 10, 20, 50, 100],
showPageSizeSelector: true,
showPageInfo: true,
showFirstLast: true,
},
actions: {
showSearchButton: true,
searchButtonText: "검색",
enableExport: true,
enableRefresh: true,
enableAdd: true,
enableEdit: true,
enableDelete: true,
addButtonText: "추가",
editButtonText: "수정",
deleteButtonText: "삭제",
},
addModalConfig: {
title: "새 데이터 추가",
description: "테이블에 새로운 데이터를 추가합니다.",
width: "lg",
layout: "two-column",
gridColumns: 2,
fieldOrder: ["name", "email", "status"],
requiredFields: ["name", "email"],
hiddenFields: ["id", "created_date"],
advancedFieldConfigs: {
status: {
type: "select",
options: [
{ label: "활성", value: "active" },
{ label: "비활성", value: "inactive" },
],
},
},
submitButtonText: "추가",
cancelButtonText: "취소",
},
},
],
},
sort_order: 1,
is_active: "Y",
is_public: "Y",
company_code: "*",
created_by: "system",
updated_by: "system",
},
{
template_code: "universal-button",
template_name: "범용 버튼",
template_name_eng: "Universal Button",
description:
"다양한 기능을 설정할 수 있는 범용 버튼. 상세설정에서 기능을 선택하세요.",
category: "button",
icon_name: "mouse-pointer",
default_size: {
width: 80,
height: 36,
},
layout_config: {
components: [
{
type: "widget",
widgetType: "button",
label: "버튼",
position: { x: 0, y: 0 },
size: { width: 80, height: 36 },
style: {
backgroundColor: "#3b82f6",
color: "#ffffff",
border: "none",
borderRadius: "6px",
fontSize: "14px",
fontWeight: "500",
},
},
],
},
sort_order: 2,
is_active: "Y",
is_public: "Y",
company_code: "*",
created_by: "system",
updated_by: "system",
},
{
template_code: "file-upload",
template_name: "파일 첨부",
template_name_eng: "File Upload",
description: "드래그앤드롭 파일 업로드 영역",
category: "file",
icon_name: "upload",
default_size: {
width: 300,
height: 120,
},
layout_config: {
components: [
{
type: "widget",
widgetType: "file",
label: "파일 첨부",
position: { x: 0, y: 0 },
size: { width: 300, height: 120 },
style: {
border: "2px dashed #d1d5db",
borderRadius: "8px",
backgroundColor: "#f9fafb",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: "#6b7280",
},
},
],
},
sort_order: 3,
is_active: "Y",
is_public: "Y",
company_code: "*",
created_by: "system",
updated_by: "system",
},
{
template_code: "form-container",
template_name: "폼 컨테이너",
template_name_eng: "Form Container",
description: "입력 폼을 위한 기본 컨테이너 레이아웃",
category: "form",
icon_name: "form",
default_size: {
width: 400,
height: 300,
},
layout_config: {
components: [
{
type: "container",
label: "폼 컨테이너",
position: { x: 0, y: 0 },
size: { width: 400, height: 300 },
style: {
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: "#ffffff",
padding: "16px",
},
},
],
},
sort_order: 4,
is_active: "Y",
is_public: "Y",
company_code: "*",
created_by: "system",
updated_by: "system",
},
];
async function seedTemplates() {
console.log("🌱 템플릿 시드 데이터 삽입 시작...");
try {
// 기존 템플릿이 있는지 확인하고 없는 경우에만 삽입
for (const template of defaultTemplates) {
const existing = await prisma.template_standards.findUnique({
where: { template_code: template.template_code },
});
if (!existing) {
await prisma.template_standards.create({
data: template,
});
console.log(`✅ 템플릿 '${template.template_name}' 생성됨`);
} else {
console.log(`⏭️ 템플릿 '${template.template_name}' 이미 존재함`);
}
}
console.log("🎉 템플릿 시드 데이터 삽입 완료!");
} catch (error) {
console.error("❌ 템플릿 시드 데이터 삽입 실패:", error);
throw error;
} finally {
await prisma.$disconnect();
}
}
// 스크립트가 직접 실행될 때만 시드 함수 실행
if (require.main === module) {
seedTemplates().catch((error) => {
console.error(error);
process.exit(1);
});
}
module.exports = { seedTemplates };
-411
View File
@@ -1,411 +0,0 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// 실제 UI 구성에 필요한 컴포넌트들
const uiComponents = [
// === 액션 컴포넌트 ===
{
component_code: "button-primary",
component_name: "기본 버튼",
component_name_eng: "Primary Button",
description: "일반적인 액션을 위한 기본 버튼 컴포넌트",
category: "action",
icon_name: "MousePointer",
default_size: { width: 100, height: 36 },
component_config: {
type: "button",
variant: "primary",
text: "버튼",
action: "custom",
style: {
backgroundColor: "#3b82f6",
color: "#ffffff",
borderRadius: "6px",
fontSize: "14px",
fontWeight: "500",
},
},
sort_order: 10,
},
{
component_code: "button-secondary",
component_name: "보조 버튼",
component_name_eng: "Secondary Button",
description: "보조 액션을 위한 버튼 컴포넌트",
category: "action",
icon_name: "MousePointer",
default_size: { width: 100, height: 36 },
component_config: {
type: "button",
variant: "secondary",
text: "취소",
action: "cancel",
style: {
backgroundColor: "#f1f5f9",
color: "#475569",
borderRadius: "6px",
fontSize: "14px",
},
},
sort_order: 11,
},
// === 레이아웃 컴포넌트 ===
{
component_code: "card-basic",
component_name: "기본 카드",
component_name_eng: "Basic Card",
description: "정보를 그룹화하는 기본 카드 컴포넌트",
category: "layout",
icon_name: "Square",
default_size: { width: 400, height: 300 },
component_config: {
type: "card",
title: "카드 제목",
showHeader: true,
showFooter: false,
style: {
backgroundColor: "#ffffff",
border: "1px solid #e5e7eb",
borderRadius: "8px",
padding: "16px",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)",
},
},
sort_order: 20,
},
{
component_code: "dashboard-grid",
component_name: "대시보드 그리드",
component_name_eng: "Dashboard Grid",
description: "대시보드를 위한 그리드 레이아웃 컴포넌트",
category: "layout",
icon_name: "LayoutGrid",
default_size: { width: 800, height: 600 },
component_config: {
type: "dashboard",
columns: 3,
gap: 16,
items: [],
style: {
backgroundColor: "#f8fafc",
padding: "20px",
borderRadius: "8px",
},
},
sort_order: 21,
},
{
component_code: "panel-collapsible",
component_name: "접을 수 있는 패널",
component_name_eng: "Collapsible Panel",
description: "접고 펼칠 수 있는 패널 컴포넌트",
category: "layout",
icon_name: "ChevronDown",
default_size: { width: 500, height: 200 },
component_config: {
type: "panel",
title: "패널 제목",
collapsible: true,
defaultExpanded: true,
style: {
backgroundColor: "#ffffff",
border: "1px solid #e5e7eb",
borderRadius: "8px",
},
},
sort_order: 22,
},
// === 데이터 표시 컴포넌트 ===
{
component_code: "stats-card",
component_name: "통계 카드",
component_name_eng: "Statistics Card",
description: "수치와 통계를 표시하는 카드 컴포넌트",
category: "data",
icon_name: "BarChart3",
default_size: { width: 250, height: 120 },
component_config: {
type: "stats",
title: "총 판매량",
value: "1,234",
unit: "개",
trend: "up",
percentage: "+12.5%",
style: {
backgroundColor: "#ffffff",
border: "1px solid #e5e7eb",
borderRadius: "8px",
padding: "20px",
},
},
sort_order: 30,
},
{
component_code: "progress-bar",
component_name: "진행률 표시",
component_name_eng: "Progress Bar",
description: "작업 진행률을 표시하는 컴포넌트",
category: "data",
icon_name: "BarChart2",
default_size: { width: 300, height: 60 },
component_config: {
type: "progress",
label: "진행률",
value: 65,
max: 100,
showPercentage: true,
style: {
backgroundColor: "#f1f5f9",
borderRadius: "4px",
height: "8px",
},
},
sort_order: 31,
},
{
component_code: "chart-basic",
component_name: "기본 차트",
component_name_eng: "Basic Chart",
description: "데이터를 시각화하는 기본 차트 컴포넌트",
category: "data",
icon_name: "TrendingUp",
default_size: { width: 500, height: 300 },
component_config: {
type: "chart",
chartType: "line",
title: "차트 제목",
data: [],
options: {
responsive: true,
plugins: {
legend: { position: "top" },
},
},
},
sort_order: 32,
},
// === 네비게이션 컴포넌트 ===
{
component_code: "breadcrumb",
component_name: "브레드크럼",
component_name_eng: "Breadcrumb",
description: "현재 위치를 표시하는 네비게이션 컴포넌트",
category: "navigation",
icon_name: "ChevronRight",
default_size: { width: 400, height: 32 },
component_config: {
type: "breadcrumb",
items: [
{ label: "홈", href: "/" },
{ label: "관리자", href: "/admin" },
{ label: "현재 페이지" },
],
separator: ">",
},
sort_order: 40,
},
{
component_code: "tabs-horizontal",
component_name: "가로 탭",
component_name_eng: "Horizontal Tabs",
description: "컨텐츠를 탭으로 구분하는 네비게이션 컴포넌트",
category: "navigation",
icon_name: "Tabs",
default_size: { width: 500, height: 300 },
component_config: {
type: "tabs",
orientation: "horizontal",
tabs: [
{ id: "tab1", label: "탭 1", content: "첫 번째 탭 내용" },
{ id: "tab2", label: "탭 2", content: "두 번째 탭 내용" },
],
defaultTab: "tab1",
},
sort_order: 41,
},
{
component_code: "pagination",
component_name: "페이지네이션",
component_name_eng: "Pagination",
description: "페이지를 나눠서 표시하는 네비게이션 컴포넌트",
category: "navigation",
icon_name: "ChevronLeft",
default_size: { width: 300, height: 40 },
component_config: {
type: "pagination",
currentPage: 1,
totalPages: 10,
showFirst: true,
showLast: true,
showPrevNext: true,
},
sort_order: 42,
},
// === 피드백 컴포넌트 ===
{
component_code: "alert-info",
component_name: "정보 알림",
component_name_eng: "Info Alert",
description: "정보를 사용자에게 알리는 컴포넌트",
category: "feedback",
icon_name: "Info",
default_size: { width: 400, height: 60 },
component_config: {
type: "alert",
variant: "info",
title: "알림",
message: "중요한 정보를 확인해주세요.",
dismissible: true,
icon: true,
},
sort_order: 50,
},
{
component_code: "badge-status",
component_name: "상태 뱃지",
component_name_eng: "Status Badge",
description: "상태나 카테고리를 표시하는 뱃지 컴포넌트",
category: "feedback",
icon_name: "Tag",
default_size: { width: 80, height: 24 },
component_config: {
type: "badge",
text: "활성",
variant: "success",
size: "sm",
style: {
backgroundColor: "#10b981",
color: "#ffffff",
borderRadius: "12px",
fontSize: "12px",
},
},
sort_order: 51,
},
{
component_code: "loading-spinner",
component_name: "로딩 스피너",
component_name_eng: "Loading Spinner",
description: "로딩 상태를 표시하는 스피너 컴포넌트",
category: "feedback",
icon_name: "RefreshCw",
default_size: { width: 100, height: 100 },
component_config: {
type: "loading",
variant: "spinner",
size: "md",
message: "로딩 중...",
overlay: false,
},
sort_order: 52,
},
// === 입력 컴포넌트 ===
{
component_code: "search-box",
component_name: "검색 박스",
component_name_eng: "Search Box",
description: "검색 기능이 있는 입력 컴포넌트",
category: "input",
icon_name: "Search",
default_size: { width: 300, height: 40 },
component_config: {
type: "search",
placeholder: "검색어를 입력하세요...",
showButton: true,
debounce: 500,
style: {
borderRadius: "20px",
border: "1px solid #d1d5db",
},
},
sort_order: 60,
},
{
component_code: "filter-dropdown",
component_name: "필터 드롭다운",
component_name_eng: "Filter Dropdown",
description: "데이터 필터링을 위한 드롭다운 컴포넌트",
category: "input",
icon_name: "Filter",
default_size: { width: 200, height: 40 },
component_config: {
type: "filter",
label: "필터",
options: [
{ value: "all", label: "전체" },
{ value: "active", label: "활성" },
{ value: "inactive", label: "비활성" },
],
defaultValue: "all",
multiple: false,
},
sort_order: 61,
},
];
async function seedUIComponents() {
try {
console.log("🚀 UI 컴포넌트 시딩 시작...");
// 기존 데이터 삭제
console.log("📝 기존 컴포넌트 데이터 삭제 중...");
await prisma.$executeRaw`DELETE FROM component_standards`;
// 새 컴포넌트 데이터 삽입
console.log("📦 새로운 UI 컴포넌트 삽입 중...");
for (const component of uiComponents) {
await prisma.component_standards.create({
data: {
...component,
company_code: "DEFAULT",
created_by: "system",
updated_by: "system",
},
});
console.log(`${component.component_name} 컴포넌트 생성됨`);
}
console.log(
`\n🎉 총 ${uiComponents.length}개의 UI 컴포넌트가 성공적으로 생성되었습니다!`
);
// 카테고리별 통계
const categoryCounts = {};
uiComponents.forEach((component) => {
categoryCounts[component.category] =
(categoryCounts[component.category] || 0) + 1;
});
console.log("\n📊 카테고리별 컴포넌트 수:");
Object.entries(categoryCounts).forEach(([category, count]) => {
console.log(` ${category}: ${count}`);
});
} catch (error) {
console.error("❌ UI 컴포넌트 시딩 실패:", error);
throw error;
} finally {
await prisma.$disconnect();
}
}
// 실행
if (require.main === module) {
seedUIComponents()
.then(() => {
console.log("✨ UI 컴포넌트 시딩 완료!");
process.exit(0);
})
.catch((error) => {
console.error("💥 시딩 실패:", error);
process.exit(1);
});
}
module.exports = { seedUIComponents, uiComponents };
@@ -1,209 +0,0 @@
/**
* 디지털 트윈 외부 DB (DO_DY) 연결 및 쿼리 테스트 스크립트
* READ-ONLY: SELECT 쿼리만 실행
*/
import { Pool } from "pg";
import mysql from "mysql2/promise";
import { CredentialEncryption } from "../src/utils/credentialEncryption";
async function testDigitalTwinDb() {
// 내부 DB 연결 (연결 정보 저장용)
const internalPool = new Pool({
host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || "5432"),
database: process.env.DB_NAME || "plm",
user: process.env.DB_USER || "postgres",
password: process.env.DB_PASSWORD || "ph0909!!",
});
const encryptionKey =
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
const encryption = new CredentialEncryption(encryptionKey);
try {
console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n");
// 디지털 트윈 외부 DB 연결 정보
const digitalTwinConnection = {
name: "디지털트윈_DO_DY",
description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)",
dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용
host: "1.240.13.83",
port: 4307,
databaseName: "DO_DY",
username: "root",
password: "pohangms619!#",
sslEnabled: false,
isActive: true,
};
console.log("📝 연결 정보:");
console.log(` - 이름: ${digitalTwinConnection.name}`);
console.log(` - DB 타입: ${digitalTwinConnection.dbType}`);
console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`);
console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`);
// 1. 외부 DB 직접 연결 테스트
console.log("🔍 외부 DB 직접 연결 테스트 중...");
const externalConnection = await mysql.createConnection({
host: digitalTwinConnection.host,
port: digitalTwinConnection.port,
database: digitalTwinConnection.databaseName,
user: digitalTwinConnection.username,
password: digitalTwinConnection.password,
connectTimeout: 10000,
});
console.log("✅ 외부 DB 연결 성공!\n");
// 2. SELECT 쿼리 실행
console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n");
const query = `
SELECT
SKUMKEY -- 제품번호
, SKUDESC -- 자재명
, SKUTHIC -- 두께
, SKUWIDT -- 폭
, SKULENG -- 길이
, SKUWEIG -- 중량
, STOTQTY -- 수량
, SUOMKEY -- 단위
FROM DO_DY.WSTKKY
LIMIT 10
`;
const [rows] = await externalConnection.execute(query);
console.log("✅ 쿼리 실행 성공!\n");
console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}\n`);
if (Array.isArray(rows) && rows.length > 0) {
console.log("🔍 샘플 데이터 (첫 3건):\n");
rows.slice(0, 3).forEach((row: any, index: number) => {
console.log(`[${index + 1}]`);
console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`);
console.log(` 자재명(SKUDESC): ${row.SKUDESC}`);
console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`);
console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`);
console.log(` 길이(SKULENG): ${row.SKULENG}`);
console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`);
console.log(` 수량(STOTQTY): ${row.STOTQTY}`);
console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`);
});
// 전체 데이터 JSON 출력
console.log("📄 전체 데이터 (JSON):");
console.log(JSON.stringify(rows, null, 2));
console.log("\n");
}
await externalConnection.end();
// 3. 내부 DB에 연결 정보 저장
console.log("💾 내부 DB에 연결 정보 저장 중...");
const encryptedPassword = encryption.encrypt(digitalTwinConnection.password);
// 중복 체크
const existingResult = await internalPool.query(
"SELECT id FROM flow_external_db_connection WHERE name = $1",
[digitalTwinConnection.name]
);
let connectionId: number;
if (existingResult.rows.length > 0) {
connectionId = existingResult.rows[0].id;
console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`);
// 기존 연결 업데이트
await internalPool.query(
`UPDATE flow_external_db_connection
SET description = $1,
db_type = $2,
host = $3,
port = $4,
database_name = $5,
username = $6,
password_encrypted = $7,
ssl_enabled = $8,
is_active = $9,
updated_at = NOW(),
updated_by = 'system'
WHERE name = $10`,
[
digitalTwinConnection.description,
digitalTwinConnection.dbType,
digitalTwinConnection.host,
digitalTwinConnection.port,
digitalTwinConnection.databaseName,
digitalTwinConnection.username,
encryptedPassword,
digitalTwinConnection.sslEnabled,
digitalTwinConnection.isActive,
digitalTwinConnection.name,
]
);
console.log(`✅ 연결 정보 업데이트 완료`);
} else {
// 새 연결 추가
const result = await internalPool.query(
`INSERT INTO flow_external_db_connection (
name,
description,
db_type,
host,
port,
database_name,
username,
password_encrypted,
ssl_enabled,
is_active,
created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
RETURNING id`,
[
digitalTwinConnection.name,
digitalTwinConnection.description,
digitalTwinConnection.dbType,
digitalTwinConnection.host,
digitalTwinConnection.port,
digitalTwinConnection.databaseName,
digitalTwinConnection.username,
encryptedPassword,
digitalTwinConnection.sslEnabled,
digitalTwinConnection.isActive,
]
);
connectionId = result.rows[0].id;
console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`);
}
console.log("\n✅ 모든 테스트 완료!");
console.log(`\n📌 연결 ID: ${connectionId}`);
console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다.");
} catch (error: any) {
console.error("\n❌ 오류 발생:", error.message);
console.error("상세 정보:", error);
throw error;
} finally {
await internalPool.end();
}
}
// 스크립트 실행
testDigitalTwinDb()
.then(() => {
console.log("\n🎉 스크립트 완료");
process.exit(0);
})
.catch((error) => {
console.error("\n💥 스크립트 실패:", error);
process.exit(1);
});
@@ -1,121 +0,0 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function testTemplateCreation() {
console.log("🧪 템플릿 생성 테스트 시작...");
try {
// 1. 테이블 존재 여부 확인
console.log("1. 템플릿 테이블 존재 여부 확인 중...");
try {
const count = await prisma.template_standards.count();
console.log(`✅ template_standards 테이블 발견 (현재 ${count}개 레코드)`);
} catch (error) {
if (error.code === "P2021") {
console.log("❌ template_standards 테이블이 존재하지 않습니다.");
console.log("👉 데이터베이스 마이그레이션이 필요합니다.");
return;
}
throw error;
}
// 2. 샘플 템플릿 생성 테스트
console.log("2. 샘플 템플릿 생성 중...");
const sampleTemplate = {
template_code: "test-button-" + Date.now(),
template_name: "테스트 버튼",
template_name_eng: "Test Button",
description: "테스트용 버튼 템플릿",
category: "button",
icon_name: "mouse-pointer",
default_size: {
width: 80,
height: 36,
},
layout_config: {
components: [
{
type: "widget",
widgetType: "button",
label: "테스트 버튼",
position: { x: 0, y: 0 },
size: { width: 80, height: 36 },
style: {
backgroundColor: "#3b82f6",
color: "#ffffff",
border: "none",
borderRadius: "6px",
},
},
],
},
sort_order: 999,
is_active: "Y",
is_public: "Y",
company_code: "*",
created_by: "test",
updated_by: "test",
};
const created = await prisma.template_standards.create({
data: sampleTemplate,
});
console.log("✅ 샘플 템플릿 생성 성공:", created.template_code);
// 3. 생성된 템플릿 조회 테스트
console.log("3. 템플릿 조회 테스트 중...");
const retrieved = await prisma.template_standards.findUnique({
where: { template_code: created.template_code },
});
if (retrieved) {
console.log("✅ 템플릿 조회 성공:", retrieved.template_name);
console.log(
"📄 Layout Config:",
JSON.stringify(retrieved.layout_config, null, 2)
);
}
// 4. 카테고리 목록 조회 테스트
console.log("4. 카테고리 목록 조회 테스트 중...");
const categories = await prisma.template_standards.findMany({
where: { is_active: "Y" },
select: { category: true },
distinct: ["category"],
});
console.log(
"✅ 발견된 카테고리:",
categories.map((c) => c.category)
);
// 5. 테스트 데이터 정리
console.log("5. 테스트 데이터 정리 중...");
await prisma.template_standards.delete({
where: { template_code: created.template_code },
});
console.log("✅ 테스트 데이터 정리 완료");
console.log("🎉 모든 테스트 통과!");
} catch (error) {
console.error("❌ 테스트 실패:", error);
console.error("📋 상세 정보:", {
message: error.message,
code: error.code,
stack: error.stack?.split("\n").slice(0, 5),
});
} finally {
await prisma.$disconnect();
}
}
// 스크립트 실행
testTemplateCreation();
-86
View File
@@ -1,86 +0,0 @@
/**
* 마이그레이션 검증 스크립트
*/
const { Pool } = require('pg');
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
const pool = new Pool({
connectionString: databaseUrl,
});
async function verifyMigration() {
const client = await pool.connect();
try {
console.log('🔍 마이그레이션 결과 검증 중...\n');
// 전체 요소 수
const total = await client.query(`
SELECT COUNT(*) as count FROM dashboard_elements
`);
// 새로운 subtype별 개수
const mapV2 = await client.query(`
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'map-summary-v2'
`);
const chart = await client.query(`
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'chart'
`);
const listV2 = await client.query(`
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'list-v2'
`);
const metricV2 = await client.query(`
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'custom-metric-v2'
`);
const alertV2 = await client.query(`
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'risk-alert-v2'
`);
// 테스트 subtype 남아있는지 확인
const remaining = await client.query(`
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype LIKE '%-test%'
`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📊 마이그레이션 결과 요약');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`전체 요소 수: ${total.rows[0].count}`);
console.log(`map-summary-v2: ${mapV2.rows[0].count}`);
console.log(`chart: ${chart.rows[0].count}`);
console.log(`list-v2: ${listV2.rows[0].count}`);
console.log(`custom-metric-v2: ${metricV2.rows[0].count}`);
console.log(`risk-alert-v2: ${alertV2.rows[0].count}`);
console.log('');
if (parseInt(remaining.rows[0].count) > 0) {
console.log(`⚠️ 테스트 subtype이 ${remaining.rows[0].count}개 남아있습니다!`);
} else {
console.log('✅ 모든 테스트 subtype이 정상적으로 변경되었습니다!');
}
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('');
console.log('🎉 마이그레이션이 성공적으로 완료되었습니다!');
console.log('');
console.log('다음 단계:');
console.log('1. 프론트엔드 애플리케이션을 새로고침하세요');
console.log('2. 대시보드를 열어 위젯이 정상적으로 작동하는지 확인하세요');
console.log('3. 문제가 발생하면 백업에서 복원하세요');
console.log('');
} catch (error) {
console.error('❌ 오류 발생:', error.message);
} finally {
client.release();
await pool.end();
}
}
verifyMigration();
-448
View File
@@ -1,448 +0,0 @@
import "dotenv/config";
process.env.TZ = "Asia/Seoul";
import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달
import express from "express";
import cors from "cors";
import helmet from "helmet";
import compression from "compression";
import rateLimit from "express-rate-limit";
import path from "path";
import config from "./config/environment";
import { logger } from "./utils/logger";
import { errorHandler } from "./middleware/errorHandler";
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
// ============================================
// 프로세스 레벨 예외 처리 (서버 크래시 방지)
// ============================================
// 처리되지 않은 Promise 거부 핸들러
process.on(
"unhandledRejection",
(reason: Error | any, promise: Promise<any>) => {
logger.error("⚠️ Unhandled Promise Rejection:", {
reason: reason?.message || reason,
stack: reason?.stack,
});
// 프로세스를 종료하지 않고 로깅만 수행
// 심각한 에러의 경우 graceful shutdown 고려
},
);
// 처리되지 않은 예외 핸들러
process.on("uncaughtException", (error: Error) => {
logger.error("🔥 Uncaught Exception:", {
message: error.message,
stack: error.stack,
});
// 예외 발생 후에도 서버를 유지하되, 상태가 불안정할 수 있으므로 주의
// 심각한 에러의 경우 graceful shutdown 후 재시작 권장
});
// SIGTERM 시그널 처리 (Docker/Kubernetes 환경)
process.on("SIGTERM", () => {
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
const { stopAiAssistant } = require("./utils/startAiAssistant");
stopAiAssistant();
process.exit(0);
});
// SIGINT 시그널 처리 (Ctrl+C)
process.on("SIGINT", () => {
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
const { stopAiAssistant } = require("./utils/startAiAssistant");
stopAiAssistant();
process.exit(0);
});
// 라우터 임포트
import authRoutes from "./routes/authRoutes";
import adminRoutes from "./routes/adminRoutes";
import multilangRoutes from "./routes/multilangRoutes";
import tableManagementRoutes from "./routes/tableManagementRoutes";
import entityJoinRoutes from "./routes/entityJoinRoutes";
import screenManagementRoutes from "./routes/screenManagementRoutes";
import commonCodeRoutes from "./routes/commonCodeRoutes";
import dynamicFormRoutes from "./routes/dynamicFormRoutes";
import fileRoutes from "./routes/fileRoutes";
import companyManagementRoutes from "./routes/companyManagementRoutes";
import dataflowRoutes from "./routes/dataflowRoutes";
import dataflowDiagramRoutes from "./routes/dataflowDiagramRoutes";
import webTypeStandardRoutes from "./routes/webTypeStandardRoutes";
import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
import screenStandardRoutes from "./routes/screenStandardRoutes";
import templateStandardRoutes from "./routes/templateStandardRoutes";
import componentStandardRoutes from "./routes/componentStandardRoutes";
import layoutRoutes from "./routes/layoutRoutes";
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
import externalRestApiConnectionRoutes from "./routes/externalRestApiConnectionRoutes";
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
import screenFileRoutes from "./routes/screenFileRoutes";
//import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
import batchRoutes from "./routes/batchRoutes";
import batchManagementRoutes from "./routes/batchManagementRoutes";
import batchExecutionLogRoutes from "./routes/batchExecutionLogRoutes";
// import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; // 파일이 존재하지 않음
import ddlRoutes from "./routes/ddlRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
import dashboardRoutes from "./routes/dashboardRoutes";
import reportRoutes from "./routes/reportRoutes";
import barcodeLabelRoutes from "./routes/barcodeLabelRoutes";
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리
import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes, {
entityOptionsRouter,
} from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
import packagingRoutes from "./routes/packagingRoutes"; // 포장/적재정보 관리
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
// import userRoutes from './routes/userRoutes';
// import menuRoutes from './routes/menuRoutes';
const app = express();
app.set("trust proxy", true);
// 기본 미들웨어
app.use(
helmet({
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
"frame-ancestors": [
"'self'",
"http://localhost:9771",
"http://localhost:3000",
], // 프론트엔드 도메인 허용
},
},
}),
);
app.use(compression());
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// 정적 파일 서빙 전에 CORS 미들웨어 추가 (OPTIONS 요청 처리)
app.options("/uploads/*", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.sendStatus(200);
});
// 정적 파일 서빙 (업로드된 파일들)
app.use(
"/uploads",
(req, res, next) => {
// 모든 정적 파일 요청에 CORS 헤더 추가
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization",
);
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
res.setHeader("Cache-Control", "public, max-age=3600");
next();
},
express.static(path.join(process.cwd(), "uploads")),
);
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
app.use(
cors({
origin: config.cors.origin, // 이미 배열 또는 boolean으로 처리됨
credentials: config.cors.credentials,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"X-Requested-With",
"Accept",
"Origin",
"Access-Control-Request-Method",
"Access-Control-Request-Headers",
],
preflightContinue: false,
optionsSuccessStatus: 200,
}),
);
// Rate Limiting (개발 환경에서는 완화)
const limiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1분
max: config.nodeEnv === "development" ? 10000 : 10000, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
message: {
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
},
skip: (req) => {
// 헬스 체크와 자주 호출되는 API들은 Rate Limiting 완화
return (
req.path === "/health" ||
req.path.includes("/table-management/") ||
req.path.includes("/external-db-connections/") ||
req.path.includes("/screen-management/") ||
req.path.includes("/multi-connection/") ||
req.path.includes("/dataflow-diagrams/")
);
},
});
app.use("/api/", limiter);
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
app.use("/api/", refreshTokenIfNeeded);
// 헬스 체크 엔드포인트
app.get("/health", (req, res) => {
res.status(200).json({
status: "OK",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: config.nodeEnv,
});
});
// API 라우터
app.use("/api/auth", authRoutes);
app.use("/api/admin", adminRoutes);
app.use("/api/multilang", multilangRoutes);
app.use("/api/table-management", tableManagementRoutes);
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/pop", popActionRoutes); // POP 액션 실행
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes);
app.use("/api/company-management", companyManagementRoutes);
app.use("/api/dataflow", dataflowRoutes);
app.use("/api/dataflow-diagrams", dataflowDiagramRoutes);
app.use("/api/admin/web-types", webTypeStandardRoutes);
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
app.use("/api/admin/template-standards", templateStandardRoutes);
app.use("/api/admin/component-standards", componentStandardRoutes);
app.use("/api/layouts", layoutRoutes);
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
app.use("/api/mail/sent", mailSentHistoryRoutes); // 메일 발송 이력
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes);
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
app.use("/api/external-db-connections", externalDbConnectionRoutes);
app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/screen-files", screenFileRoutes);
app.use("/api/batch-configs", batchRoutes);
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
app.use("/api/batch-management", batchManagementRoutes);
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
app.use("/api/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes);
app.use("/api/external-calls", externalCallRoutes);
app.use("/api/external-call-configs", externalCallConfigRoutes);
app.use("/api/dataflow", dataflowExecutionRoutes);
app.use("/api/dashboards", dashboardRoutes);
app.use("/api/admin/reports", reportRoutes);
app.use("/api/admin/barcode-labels", barcodeLabelRoutes);
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리
app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
app.use("/api/todos", todoRoutes); // To-Do 관리
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/production", productionRoutes); // 생산계획 관리
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/entity", entityOptionsRouter); // 엔티티 옵션 (V2Select용)
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
app.use("/api/packaging", packagingRoutes); // 포장/적재정보 관리
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
app.use("/api/mold", moldRoutes); // 금형 관리
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
app.use("/api/design", designRoutes); // 설계 모듈
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
app.use("/api/approval", approvalRoutes); // 결재 시스템
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);
// app.use('/api/menus', menuRoutes);
// 404 핸들러
app.use("*", (req, res) => {
res.status(404).json({
success: false,
message: "요청한 리소스를 찾을 수 없습니다.",
path: req.originalUrl,
});
});
// 에러 핸들러
app.use(errorHandler);
// 서버 시작
const PORT = config.port;
const HOST = config.host;
app.listen(PORT, HOST, async () => {
logger.info(`🚀 Server is running on ${HOST}:${PORT}`);
logger.info(`📊 Environment: ${config.nodeEnv}`);
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
// 데이터베이스 마이그레이션 실행
try {
const {
runDashboardMigration,
runTableHistoryActionMigration,
runDtgManagementLogMigration,
runApprovalSystemMigration,
} = await import("./database/runMigration");
await runDashboardMigration();
await runTableHistoryActionMigration();
await runDtgManagementLogMigration();
await runApprovalSystemMigration();
} catch (error) {
logger.error(`❌ 마이그레이션 실패:`, error);
}
// 배치 스케줄러 초기화
try {
await BatchSchedulerService.initializeScheduler();
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
} catch (error) {
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
}
// 리스크/알림 자동 갱신 시작
try {
const { RiskAlertCacheService } = await import(
"./services/riskAlertCacheService"
);
const cacheService = RiskAlertCacheService.getInstance();
cacheService.startAutoRefresh();
logger.info(`⏰ 리스크/알림 자동 갱신이 시작되었습니다. (10분 간격)`);
} catch (error) {
logger.error(`❌ 리스크/알림 자동 갱신 시작 실패:`, error);
}
// 메일 자동 삭제 (30일 지난 삭제된 메일) - 매일 새벽 2시 실행
try {
const cron = await import("node-cron");
const { mailSentHistoryService } = await import(
"./services/mailSentHistoryService"
);
cron.schedule("0 2 * * *", async () => {
try {
logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...");
const deletedCount =
await mailSentHistoryService.cleanupOldDeletedMails();
logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`);
} catch (error) {
logger.error("❌ 메일 자동 삭제 실패:", error);
}
});
logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`);
} catch (error) {
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
}
// AI 어시스턴트 서비스 함께 기동 (한 번에 킬 가능)
try {
const { startAiAssistant } = await import("./utils/startAiAssistant");
startAiAssistant();
} catch (error) {
logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error);
}
});
export default app;
-139
View File
@@ -1,139 +0,0 @@
import dotenv from "dotenv";
import path from "path";
// .env 파일 로드
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
interface Config {
// 서버 설정
port: number;
host: string;
nodeEnv: string;
// 데이터베이스 설정
databaseUrl: string;
// JWT 설정
jwt: {
secret: string;
expiresIn: string;
refreshExpiresIn: string;
};
// 보안 설정
bcryptRounds: number;
sessionSecret: string;
// CORS 설정
cors: {
origin: string | string[] | boolean; // 타입을 확장하여 배열과 boolean도 허용
credentials: boolean;
};
// 로깅 설정
logging: {
level: string;
file: string;
};
// API 설정
apiPrefix: string;
apiVersion: string;
// 파일 업로드 설정
maxFileSize: number;
uploadDir: string;
// 이메일 설정
smtpHost: string;
smtpPort: number;
smtpUser: string;
smtpPass: string;
// Redis 설정
redisUrl: string;
// 개발 환경 설정
debug: boolean;
showErrorDetails: boolean;
}
// CORS origin 처리 함수
const getCorsOrigin = (): string[] | boolean => {
// 개발 환경에서는 모든 origin 허용
if (process.env.NODE_ENV === "development") {
return true;
}
// 환경변수가 있으면 쉼표로 구분하여 배열로 변환
if (process.env.CORS_ORIGIN) {
return process.env.CORS_ORIGIN.split(",").map((origin) => origin.trim());
}
// 기본값: 허용할 도메인들
return [
"http://localhost:9771", // 로컬 개발 환경
"http://192.168.0.70:5555", // 내부 네트워크 접근
"http://39.117.244.52:5555", // 외부 네트워크 접근
"https://v1.invion.com", // 운영 프론트엔드
"https://api.invion.com", // 운영 백엔드
];
};
const config: Config = {
// 서버 설정
port: parseInt(process.env.PORT || "8080", 10),
host: process.env.HOST || "0.0.0.0",
nodeEnv: process.env.NODE_ENV || "development",
// 데이터베이스 설정
databaseUrl:
process.env.DATABASE_URL ||
"postgresql://postgres:postgres@localhost:5432/ilshin",
// JWT 설정
jwt: {
secret: process.env.JWT_SECRET || "ilshin-plm-super-secret-jwt-key-2024",
expiresIn: process.env.JWT_EXPIRES_IN || "24h",
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
},
// 보안 설정
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || "12", 10),
sessionSecret: process.env.SESSION_SECRET || "ilshin-plm-session-secret-2024",
// CORS 설정
cors: {
origin: getCorsOrigin(),
credentials: true, // 쿠키 및 인증 정보 포함 허용
},
// 로깅 설정
logging: {
level: process.env.LOG_LEVEL || "info",
file: process.env.LOG_FILE || "logs/app.log",
},
// API 설정
apiPrefix: process.env.API_PREFIX || "/api",
apiVersion: process.env.API_VERSION || "v1",
// 파일 업로드 설정
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || "10485760", 10),
uploadDir: process.env.UPLOAD_DIR || "uploads",
// 이메일 설정
smtpHost: process.env.SMTP_HOST || "smtp.gmail.com",
smtpPort: parseInt(process.env.SMTP_PORT || "587", 10),
smtpUser: process.env.SMTP_USER || "",
smtpPass: process.env.SMTP_PASS || "",
// Redis 설정
redisUrl: process.env.REDIS_URL || "redis://localhost:6379",
// 개발 환경 설정
debug: process.env.DEBUG === "true",
showErrorDetails: process.env.SHOW_ERROR_DETAILS === "true",
};
export default config;
-118
View File
@@ -1,118 +0,0 @@
import multer from 'multer';
import path from 'path';
import fs from 'fs';
// 업로드 디렉토리 경로 (운영: /app/uploads/mail-attachments, 개발: 프로젝트 루트)
const UPLOAD_DIR = process.env.NODE_ENV === 'production'
? '/app/uploads/mail-attachments'
: path.join(process.cwd(), 'uploads', 'mail-attachments');
// 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
try {
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
} catch (error) {
console.error('메일 첨부파일 디렉토리 생성 실패:', error);
// 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행
}
// 간단한 파일명 정규화 함수 (한글-분석.txt 방식)
function normalizeFileName(filename: string): string {
if (!filename) return filename;
try {
// NFC 정규화만 수행 (복잡한 디코딩 제거)
return filename.normalize('NFC');
} catch (error) {
console.error(`Failed to normalize filename: ${filename}`, error);
return filename;
}
}
// 파일 저장 설정
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
try {
// 파일명 정규화 (한글-분석.txt 방식)
file.originalname = file.originalname.normalize('NFC');
console.log('File upload - Processing:', {
original: file.originalname,
originalHex: Buffer.from(file.originalname).toString('hex'),
});
// UUID + 확장자로 유니크한 파일명 생성
const uniqueId = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = path.extname(file.originalname);
const filename = `${uniqueId}${ext}`;
console.log('Generated filename:', {
original: file.originalname,
generated: filename,
});
cb(null, filename);
} catch (error) {
console.error('Filename processing error:', error);
const fallbackFilename = `${Date.now()}-${Math.round(Math.random() * 1e9)}_error.tmp`;
cb(null, fallbackFilename);
}
},
});
// 파일 필터 (허용할 파일 타입)
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
// 파일명 정규화 (fileFilter가 filename보다 먼저 실행되므로 여기서 먼저 처리)
try {
// NFD를 NFC로 정규화만 수행
file.originalname = file.originalname.normalize('NFC');
} catch (error) {
console.warn('Failed to normalize filename in fileFilter:', error);
}
// 위험한 파일 확장자 차단
const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.msi'];
const ext = path.extname(file.originalname).toLowerCase();
if (dangerousExtensions.includes(ext)) {
console.log(`❌ 차단된 파일 타입: ${ext}`);
cb(new Error(`보안상의 이유로 ${ext} 파일은 첨부할 수 없습니다.`));
return;
}
cb(null, true);
};
// Multer 설정
export const uploadMailAttachment = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB 제한
files: 5, // 최대 5개 파일
},
});
// 첨부파일 정보 추출 헬퍼
export interface AttachmentInfo {
filename: string;
originalName: string;
size: number;
path: string;
mimetype: string;
}
export const extractAttachmentInfo = (files: Express.Multer.File[]): AttachmentInfo[] => {
return files.map((file) => ({
filename: file.filename,
originalName: file.originalname,
size: file.size,
path: file.path,
mimetype: file.mimetype,
}));
};
@@ -1,884 +0,0 @@
import { Response } from "express";
import https from "https";
import axios, { AxiosRequestConfig } from "axios";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { DashboardService } from "../services/DashboardService";
import {
CreateDashboardRequest,
UpdateDashboardRequest,
DashboardListQuery,
} from "../types/dashboard";
import { PostgreSQLService } from "../database/PostgreSQLService";
import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
/**
* 대시보드 컨트롤러
* - REST API 엔드포인트 처리
* - 요청 검증 및 응답 포맷팅
*/
export class DashboardController {
/**
* 대시보드 생성
* POST /api/dashboards
*/
async createDashboard(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = req.user?.userId;
const companyCode = req.user?.companyCode;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
const {
title,
description,
elements,
isPublic = false,
tags,
category,
settings,
}: CreateDashboardRequest = req.body;
// 유효성 검증
if (!title || title.trim().length === 0) {
res.status(400).json({
success: false,
message: "대시보드 제목이 필요합니다.",
});
return;
}
if (!elements || !Array.isArray(elements)) {
res.status(400).json({
success: false,
message: "대시보드 요소 데이터가 필요합니다.",
});
return;
}
// 제목 길이 체크
if (title.length > 200) {
res.status(400).json({
success: false,
message: "제목은 200자를 초과할 수 없습니다.",
});
return;
}
// 설명 길이 체크
if (description && description.length > 1000) {
res.status(400).json({
success: false,
message: "설명은 1000자를 초과할 수 없습니다.",
});
return;
}
const dashboardData: CreateDashboardRequest = {
title: title.trim(),
description: description?.trim(),
isPublic,
elements,
tags,
category,
settings,
};
// console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length });
const savedDashboard = await DashboardService.createDashboard(
dashboardData,
userId,
companyCode
);
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
res.status(201).json({
success: true,
data: savedDashboard,
message: "대시보드가 성공적으로 생성되었습니다.",
});
} catch (error: any) {
// console.error('Dashboard creation error:', {
// message: error?.message,
// stack: error?.stack,
// error
// });
res.status(500).json({
success: false,
message: error?.message || "대시보드 생성 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development" ? error?.message : undefined,
});
}
}
/**
* 대시보드 목록 조회
* GET /api/dashboards
*/
async getDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
const companyCode = req.user?.companyCode;
const query: DashboardListQuery = {
page: parseInt(req.query.page as string) || 1,
limit: Math.min(parseInt(req.query.limit as string) || 20, 100), // 최대 100개
search: req.query.search as string,
category: req.query.category as string,
isPublic:
req.query.isPublic === "true"
? true
: req.query.isPublic === "false"
? false
: undefined,
createdBy: req.query.createdBy as string,
};
// 페이지 번호 유효성 검증
if (query.page! < 1) {
res.status(400).json({
success: false,
message: "페이지 번호는 1 이상이어야 합니다.",
});
return;
}
const result = await DashboardService.getDashboards(
query,
userId,
companyCode
);
res.json({
success: true,
data: result.dashboards,
pagination: result.pagination,
});
} catch (error) {
// console.error('Dashboard list error:', error);
res.status(500).json({
success: false,
message: "대시보드 목록 조회 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
? (error as Error).message
: undefined,
});
}
}
/**
* 대시보드 상세 조회
* GET /api/dashboards/:id
*/
async getDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const userId = req.user?.userId;
const companyCode = req.user?.companyCode;
if (!id) {
res.status(400).json({
success: false,
message: "대시보드 ID가 필요합니다.",
});
return;
}
const dashboard = await DashboardService.getDashboardById(
id,
userId,
companyCode
);
if (!dashboard) {
res.status(404).json({
success: false,
message: "대시보드를 찾을 수 없거나 접근 권한이 없습니다.",
});
return;
}
// 조회수 증가 (본인이 만든 대시보드가 아닌 경우에만)
if (userId && dashboard.createdBy !== userId) {
await DashboardService.incrementViewCount(id);
}
res.json({
success: true,
data: dashboard,
});
} catch (error) {
// console.error('Dashboard get error:', error);
res.status(500).json({
success: false,
message: "대시보드 조회 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
? (error as Error).message
: undefined,
});
}
}
/**
* 대시보드 수정
* PUT /api/dashboards/:id
*/
async updateDashboard(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { id } = req.params;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
if (!id) {
res.status(400).json({
success: false,
message: "대시보드 ID가 필요합니다.",
});
return;
}
const updateData: UpdateDashboardRequest = req.body;
// 유효성 검증
if (updateData.title !== undefined) {
if (
typeof updateData.title !== "string" ||
updateData.title.trim().length === 0
) {
res.status(400).json({
success: false,
message: "올바른 제목을 입력해주세요.",
});
return;
}
if (updateData.title.length > 200) {
res.status(400).json({
success: false,
message: "제목은 200자를 초과할 수 없습니다.",
});
return;
}
updateData.title = updateData.title.trim();
}
if (
updateData.description !== undefined &&
updateData.description &&
updateData.description.length > 1000
) {
res.status(400).json({
success: false,
message: "설명은 1000자를 초과할 수 없습니다.",
});
return;
}
const updatedDashboard = await DashboardService.updateDashboard(
id,
updateData,
userId
);
if (!updatedDashboard) {
res.status(404).json({
success: false,
message: "대시보드를 찾을 수 없거나 수정 권한이 없습니다.",
});
return;
}
res.json({
success: true,
data: updatedDashboard,
message: "대시보드가 성공적으로 수정되었습니다.",
});
} catch (error) {
// console.error('Dashboard update error:', error);
if ((error as Error).message.includes("권한이 없습니다")) {
res.status(403).json({
success: false,
message: (error as Error).message,
});
return;
}
res.status(500).json({
success: false,
message: "대시보드 수정 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
? (error as Error).message
: undefined,
});
}
}
/**
* 대시보드 삭제
* DELETE /api/dashboards/:id
*/
async deleteDashboard(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { id } = req.params;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
if (!id) {
res.status(400).json({
success: false,
message: "대시보드 ID가 필요합니다.",
});
return;
}
const deleted = await DashboardService.deleteDashboard(id, userId);
if (!deleted) {
res.status(404).json({
success: false,
message: "대시보드를 찾을 수 없거나 삭제 권한이 없습니다.",
});
return;
}
res.json({
success: true,
message: "대시보드가 성공적으로 삭제되었습니다.",
});
} catch (error) {
// console.error('Dashboard delete error:', error);
res.status(500).json({
success: false,
message: "대시보드 삭제 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
? (error as Error).message
: undefined,
});
}
}
/**
* 내 대시보드 목록 조회
* GET /api/dashboards/my
*/
async getMyDashboards(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
const companyCode = req.user?.companyCode;
const query: DashboardListQuery = {
page: parseInt(req.query.page as string) || 1,
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
search: req.query.search as string,
category: req.query.category as string,
// createdBy 제거 - 회사 대시보드 전체 표시
};
const result = await DashboardService.getDashboards(
query,
userId,
companyCode
);
res.json({
success: true,
data: result.dashboards,
pagination: result.pagination,
});
} catch (error) {
// console.error('My dashboards error:', error);
res.status(500).json({
success: false,
message: "내 대시보드 목록 조회 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
? (error as Error).message
: undefined,
});
}
}
/**
* 쿼리 실행 (SELECT만)
* POST /api/dashboards/execute-query
*/
async executeQuery(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
// 개발용으로 인증 체크 제거
// const userId = req.user?.userId;
// if (!userId) {
// res.status(401).json({
// success: false,
// message: '인증이 필요합니다.'
// });
// return;
// }
const { query } = req.body;
// 유효성 검증
if (!query || typeof query !== "string" || query.trim().length === 0) {
res.status(400).json({
success: false,
message: "쿼리가 필요합니다.",
});
return;
}
// SQL 인젝션 방지를 위한 기본적인 검증
const trimmedQuery = query.trim().toLowerCase();
if (!trimmedQuery.startsWith("select")) {
res.status(400).json({
success: false,
message: "SELECT 쿼리만 허용됩니다.",
});
return;
}
// 쿼리 실행
const result = await PostgreSQLService.query(query.trim());
// 결과 변환
const columns = result.fields?.map((field) => field.name) || [];
const rows = result.rows || [];
res.status(200).json({
success: true,
data: {
columns,
rows,
rowCount: rows.length,
},
message: "쿼리가 성공적으로 실행되었습니다.",
});
} catch (error) {
// console.error('Query execution error:', error);
res.status(500).json({
success: false,
message: "쿼리 실행 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
? (error as Error).message
: "쿼리 실행 오류",
});
}
}
/**
* DML 쿼리 실행 (INSERT, UPDATE, DELETE)
* POST /api/dashboards/execute-dml
*/
async executeDML(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { query } = req.body;
// 유효성 검증
if (!query || typeof query !== "string" || query.trim().length === 0) {
res.status(400).json({
success: false,
message: "쿼리가 필요합니다.",
});
return;
}
// SQL 인젝션 방지를 위한 기본적인 검증
const trimmedQuery = query.trim().toLowerCase();
const allowedCommands = ["insert", "update", "delete"];
const isAllowed = allowedCommands.some((cmd) =>
trimmedQuery.startsWith(cmd)
);
if (!isAllowed) {
res.status(400).json({
success: false,
message: "INSERT, UPDATE, DELETE 쿼리만 허용됩니다.",
});
return;
}
// 위험한 명령어 차단
const dangerousPatterns = [
/drop\s+table/i,
/drop\s+database/i,
/truncate/i,
/alter\s+table/i,
/create\s+table/i,
];
if (dangerousPatterns.some((pattern) => pattern.test(query))) {
res.status(403).json({
success: false,
message: "허용되지 않는 쿼리입니다.",
});
return;
}
// 쿼리 실행
const result = await PostgreSQLService.query(query.trim());
res.status(200).json({
success: true,
data: {
rowCount: result.rowCount || 0,
command: result.command,
},
message: "쿼리가 성공적으로 실행되었습니다.",
});
} catch (error) {
console.error("DML execution error:", error);
res.status(500).json({
success: false,
message: "쿼리 실행 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
? (error as Error).message
: "쿼리 실행 오류",
});
}
}
/**
* 외부 API 프록시 (CORS 우회용)
* POST /api/dashboards/fetch-external-api
*/
async fetchExternalApi(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const {
url,
method = "GET",
headers = {},
queryParams = {},
body,
externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
} = req.body;
if (!url || typeof url !== "string") {
res.status(400).json({
success: false,
message: "URL이 필요합니다.",
});
return;
}
// 쿼리 파라미터 추가
const urlObj = new URL(url);
Object.entries(queryParams).forEach(([key, value]) => {
if (key && value) {
urlObj.searchParams.append(key, String(value));
}
});
// Axios 요청 설정
const requestConfig: AxiosRequestConfig = {
url: urlObj.toString(),
method: method.toUpperCase(),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...headers,
},
timeout: 60000, // 60초 타임아웃
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
};
// 연결 정보 (응답에 포함용)
let connectionInfo: { saveToHistory?: boolean } | null = null;
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
if (externalConnectionId) {
try {
// 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
let companyCode = req.user?.companyCode;
if (!companyCode) {
companyCode = "*";
}
// 커넥션 로드
const connectionResult =
await ExternalRestApiConnectionService.getConnectionById(
Number(externalConnectionId),
companyCode
);
if (connectionResult.success && connectionResult.data) {
const connection = connectionResult.data;
// 연결 정보 저장 (응답에 포함)
connectionInfo = {
saveToHistory: connection.save_to_history === "Y",
};
// 인증 헤더 생성 (DB 토큰 등)
const authHeaders =
await ExternalRestApiConnectionService.getAuthHeaders(
connection.auth_type,
connection.auth_config,
connection.company_code
);
// 기존 헤더에 인증 헤더 병합
requestConfig.headers = {
...requestConfig.headers,
...authHeaders,
};
// API Key가 Query Param인 경우 처리
if (
connection.auth_type === "api-key" &&
connection.auth_config?.keyLocation === "query" &&
connection.auth_config?.keyName &&
connection.auth_config?.keyValue
) {
const currentUrl = new URL(requestConfig.url!);
currentUrl.searchParams.append(
connection.auth_config.keyName,
connection.auth_config.keyValue
);
requestConfig.url = currentUrl.toString();
}
}
} catch (connError) {
logger.error(
`외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
connError
);
}
}
// Body 처리
if (body) {
requestConfig.data = body;
}
// 디버깅 로그: 실제 요청 정보 출력
logger.info(`[fetchExternalApi] 요청 정보:`, {
url: requestConfig.url,
method: requestConfig.method,
headers: requestConfig.headers,
body: requestConfig.data,
externalConnectionId,
});
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
// ExternalRestApiConnectionService와 동일한 로직 적용
const bypassDomains = ["thiratis.com"];
const hostname = urlObj.hostname;
const shouldBypassTls = bypassDomains.some((domain) =>
hostname.includes(domain)
);
if (shouldBypassTls) {
requestConfig.httpsAgent = new https.Agent({
rejectUnauthorized: false,
});
}
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
const isKmaApi = urlObj.hostname.includes("kma.go.kr");
if (isKmaApi) {
requestConfig.responseType = "arraybuffer";
}
const response = await axios(requestConfig);
if (response.status >= 400) {
throw new Error(
`외부 API 오류: ${response.status} ${response.statusText}`
);
}
let data = response.data;
const contentType = response.headers["content-type"];
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
if (isKmaApi && Buffer.isBuffer(data)) {
const iconv = require("iconv-lite");
const buffer = Buffer.from(data);
const utf8Text = buffer.toString("utf-8");
// UTF-8로 정상 디코딩되었는지 확인
if (
utf8Text.includes("특보") ||
utf8Text.includes("경보") ||
utf8Text.includes("주의보") ||
(utf8Text.includes("#START7777") && !utf8Text.includes(""))
) {
data = { text: utf8Text, contentType, encoding: "utf-8" };
} else {
// EUC-KR로 디코딩
const eucKrText = iconv.decode(buffer, "EUC-KR");
data = { text: eucKrText, contentType, encoding: "euc-kr" };
}
}
// 텍스트 응답인 경우 포맷팅
else if (typeof data === "string") {
data = { text: data, contentType };
}
res.status(200).json({
success: true,
data,
connectionInfo, // 외부 연결 정보 (saveToHistory 등)
});
} catch (error: any) {
const status = error.response?.status || 500;
const message = error.response?.statusText || error.message;
logger.error("외부 API 호출 오류:", {
message,
status,
data: error.response?.data,
});
res.status(500).json({
success: false,
message: "외부 API 호출 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
? message
: "외부 API 호출 오류",
});
}
}
/**
* 테이블 스키마 조회 (날짜 컬럼 감지용)
* POST /api/dashboards/table-schema
*/
async getTableSchema(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.body;
if (!tableName || typeof tableName !== "string") {
res.status(400).json({
success: false,
message: "테이블명이 필요합니다.",
});
return;
}
// 테이블명 검증 (SQL 인젝션 방지)
if (!/^[a-z_][a-z0-9_]*$/i.test(tableName)) {
res.status(400).json({
success: false,
message: "유효하지 않은 테이블명입니다.",
});
return;
}
// PostgreSQL information_schema에서 컬럼 정보 조회
const query = `
SELECT
column_name,
data_type,
udt_name
FROM information_schema.columns
WHERE table_name = $1
ORDER BY ordinal_position
`;
const result = await PostgreSQLService.query(query, [
tableName.toLowerCase(),
]);
// 날짜/시간 타입 컬럼 필터링
const dateColumns = result.rows
.filter((row: any) => {
const dataType = row.data_type?.toLowerCase();
const udtName = row.udt_name?.toLowerCase();
return (
dataType === "timestamp" ||
dataType === "timestamp without time zone" ||
dataType === "timestamp with time zone" ||
dataType === "date" ||
dataType === "time" ||
dataType === "time without time zone" ||
dataType === "time with time zone" ||
udtName === "timestamp" ||
udtName === "timestamptz" ||
udtName === "date" ||
udtName === "time" ||
udtName === "timetz"
);
})
.map((row: any) => row.column_name);
res.status(200).json({
success: true,
data: {
tableName,
columns: result.rows.map((row: any) => ({
name: row.column_name,
type: row.data_type,
udtName: row.udt_name,
})),
dateColumns,
},
});
} catch (error) {
res.status(500).json({
success: false,
message: "테이블 스키마 조회 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
? (error as Error).message
: "스키마 조회 오류",
});
}
}
}
@@ -1,295 +0,0 @@
import { Request, Response } from "express";
import YardLayoutService from "../services/YardLayoutService";
export class YardLayoutController {
// 모든 야드 레이아웃 목록 조회
async getAllLayouts(req: Request, res: Response) {
try {
const layouts = await YardLayoutService.getAllLayouts();
res.json({ success: true, data: layouts });
} catch (error: any) {
console.error("Error fetching yard layouts:", error);
res.status(500).json({
success: false,
message: "야드 레이아웃 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 특정 야드 레이아웃 상세 조회
async getLayoutById(req: Request, res: Response) {
try {
const { id } = req.params;
const layout = await YardLayoutService.getLayoutById(parseInt(id));
if (!layout) {
return res.status(404).json({
success: false,
message: "야드 레이아웃을 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: layout });
} catch (error: any) {
console.error("Error fetching yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 새 야드 레이아웃 생성
async createLayout(req: Request, res: Response) {
try {
const { name, description } = req.body;
if (!name) {
return res.status(400).json({
success: false,
message: "야드 이름은 필수입니다.",
});
}
const created_by = (req as any).user?.userId || "system";
const layout = await YardLayoutService.createLayout({
name,
description,
created_by,
});
return res.status(201).json({ success: true, data: layout });
} catch (error: any) {
console.error("Error creating yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 생성 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 야드 레이아웃 수정
async updateLayout(req: Request, res: Response) {
try {
const { id } = req.params;
const { name, description } = req.body;
const layout = await YardLayoutService.updateLayout(parseInt(id), {
name,
description,
});
if (!layout) {
return res.status(404).json({
success: false,
message: "야드 레이아웃을 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: layout });
} catch (error: any) {
console.error("Error updating yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 야드 레이아웃 삭제
async deleteLayout(req: Request, res: Response) {
try {
const { id } = req.params;
const layout = await YardLayoutService.deleteLayout(parseInt(id));
if (!layout) {
return res.status(404).json({
success: false,
message: "야드 레이아웃을 찾을 수 없습니다.",
});
}
return res.json({
success: true,
message: "야드 레이아웃이 삭제되었습니다.",
});
} catch (error: any) {
console.error("Error deleting yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 특정 야드의 모든 배치 자재 조회
async getPlacementsByLayoutId(req: Request, res: Response) {
try {
const { id } = req.params;
const placements = await YardLayoutService.getPlacementsByLayoutId(
parseInt(id)
);
res.json({ success: true, data: placements });
} catch (error: any) {
console.error("Error fetching placements:", error);
res.status(500).json({
success: false,
message: "배치 자재 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 야드에 자재 배치 추가 (빈 요소 또는 설정된 요소)
async addMaterialPlacement(req: Request, res: Response) {
try {
const { id } = req.params;
const placementData = req.body;
// 데이터 바인딩 재설계 후 material_code와 external_material_id는 선택사항
// 빈 요소를 추가할 수 있어야 함
const placement = await YardLayoutService.addMaterialPlacement(
parseInt(id),
placementData
);
return res.status(201).json({ success: true, data: placement });
} catch (error: any) {
console.error("Error adding material placement:", error);
if (error.code === "23505") {
// 유니크 제약 조건 위반
return res.status(409).json({
success: false,
message: "이미 배치된 자재입니다.",
});
}
return res.status(500).json({
success: false,
message: "자재 배치 추가 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 배치 정보 수정
async updatePlacement(req: Request, res: Response) {
try {
const { id } = req.params;
const placementData = req.body;
const placement = await YardLayoutService.updatePlacement(
parseInt(id),
placementData
);
if (!placement) {
return res.status(404).json({
success: false,
message: "배치 정보를 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: placement });
} catch (error: any) {
console.error("Error updating placement:", error);
return res.status(500).json({
success: false,
message: "배치 정보 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 배치 해제
async removePlacement(req: Request, res: Response) {
try {
const { id } = req.params;
const placement = await YardLayoutService.removePlacement(parseInt(id));
if (!placement) {
return res.status(404).json({
success: false,
message: "배치 정보를 찾을 수 없습니다.",
});
}
return res.json({ success: true, message: "배치가 해제되었습니다." });
} catch (error: any) {
console.error("Error removing placement:", error);
return res.status(500).json({
success: false,
message: "배치 해제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 여러 배치 일괄 업데이트
async batchUpdatePlacements(req: Request, res: Response) {
try {
const { id } = req.params;
const { placements } = req.body;
if (!Array.isArray(placements) || placements.length === 0) {
return res.status(400).json({
success: false,
message: "배치 목록이 필요합니다.",
});
}
const updatedPlacements = await YardLayoutService.batchUpdatePlacements(
parseInt(id),
placements
);
return res.json({ success: true, data: updatedPlacements });
} catch (error: any) {
console.error("Error batch updating placements:", error);
return res.status(500).json({
success: false,
message: "배치 일괄 업데이트 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 야드 레이아웃 복제
async duplicateLayout(req: Request, res: Response) {
try {
const { id } = req.params;
const { name } = req.body;
if (!name) {
return res.status(400).json({
success: false,
message: "새 야드 이름은 필수입니다.",
});
}
const layout = await YardLayoutService.duplicateLayout(
parseInt(id),
name
);
return res.status(201).json({ success: true, data: layout });
} catch (error: any) {
console.error("Error duplicating yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 복제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
}
export default new YardLayoutController();
File diff suppressed because it is too large Load Diff
@@ -1,488 +0,0 @@
import { Response } from "express";
import { query } from "../database/db";
import { logger } from "../utils/logger";
function buildCompanyFilter(companyCode: string, alias: string, paramIdx: number) {
if (companyCode === "*") return { condition: "", params: [] as any[], nextIdx: paramIdx };
return {
condition: `${alias}.company_code = $${paramIdx}`,
params: [companyCode],
nextIdx: paramIdx + 1,
};
}
function buildDateFilter(startDate: string | undefined, endDate: string | undefined, dateExpr: string, paramIdx: number) {
const conditions: string[] = [];
const params: any[] = [];
let idx = paramIdx;
if (startDate) {
conditions.push(`${dateExpr} >= $${idx}`);
params.push(startDate);
idx++;
}
if (endDate) {
conditions.push(`${dateExpr} <= $${idx}`);
params.push(endDate);
idx++;
}
return { conditions, params, nextIdx: idx };
}
function buildWhereClause(conditions: string[]): string {
return conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
}
function extractFilterSet(rows: any[], field: string, labelField?: string): { value: string; label: string }[] {
const set = new Map<string, string>();
rows.forEach((r: any) => {
const val = r[field];
if (val && val !== "미지정") set.set(val, r[labelField || field] || val);
});
return [...set.entries()].map(([value, label]) => ({ value, label }));
}
// ============================================
// 생산 리포트
// ============================================
export async function getProductionReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const { startDate, endDate } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "wi", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx);
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(wi.start_date, wi.created_date::date::text) as date,
COALESCE(wi.routing, '미지정') as process,
COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment,
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
COALESCE(wi.worker, '미지정') as worker,
CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty",
COALESCE(pr.production_qty, 0) as "prodQty",
COALESCE(pr.defect_qty, 0) as "defectQty",
0 as "runTime",
0 as "downTime",
wi.status,
wi.company_code
FROM work_instruction wi
LEFT JOIN (
SELECT wo_id, company_code,
SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty,
SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty
FROM production_record GROUP BY wo_id, company_code
) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code
LEFT JOIN (
SELECT DISTINCT ON (equipment_code, company_code)
equipment_code, equipment_name, equipment_type, company_code
FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC
) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.company_code
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
${whereClause}
ORDER BY date DESC NULLS LAST
`;
const dataRows = await query(dataQuery, params);
logger.info("생산 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
processes: extractFilterSet(dataRows, "process"),
equipment: extractFilterSet(dataRows, "equipment"),
items: extractFilterSet(dataRows, "item"),
workers: extractFilterSet(dataRows, "worker"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("생산 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "생산 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 재고 리포트
// ============================================
export async function getInventoryReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "ist", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(ist.updated_date, ist.created_date)::date::text as date,
ist.item_code,
COALESCE(ii.item_name, ist.item_code, '미지정') as item,
COALESCE(wi.warehouse_name, ist.warehouse_code, '미지정') as warehouse,
'일반' as category,
CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) as "currentQty",
CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric) as "safetyQty",
COALESCE(ih_in.in_qty, 0) as "inQty",
COALESCE(ih_out.out_qty, 0) as "outQty",
0 as "stockValue",
GREATEST(CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric)
- CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric), 0) as "shortageQty",
CASE WHEN CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) > 0
AND COALESCE(ih_out.out_qty, 0) > 0
THEN ROUND(COALESCE(ih_out.out_qty, 0)::numeric
/ CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '1') AS numeric), 2)
ELSE 0 END as "turnover",
ist.company_code
FROM inventory_stock ist
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON ist.item_code = ii.item_number AND ist.company_code = ii.company_code
LEFT JOIN warehouse_info wi ON ist.warehouse_code = wi.warehouse_code
AND ist.company_code = wi.company_code
LEFT JOIN (
SELECT item_code, company_code,
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as in_qty
FROM inventory_history WHERE transaction_type = 'IN'
GROUP BY item_code, company_code
) ih_in ON ist.item_code = ih_in.item_code AND ist.company_code = ih_in.company_code
LEFT JOIN (
SELECT item_code, company_code,
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as out_qty
FROM inventory_history WHERE transaction_type = 'OUT'
GROUP BY item_code, company_code
) ih_out ON ist.item_code = ih_out.item_code AND ist.company_code = ih_out.company_code
${whereClause}
ORDER BY date DESC NULLS LAST
`;
const dataRows = await query(dataQuery, params);
logger.info("재고 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
items: extractFilterSet(dataRows, "item"),
warehouses: extractFilterSet(dataRows, "warehouse"),
categories: [
{ value: "원자재", label: "원자재" }, { value: "부자재", label: "부자재" },
{ value: "반제품", label: "반제품" }, { value: "완제품", label: "완제품" },
{ value: "일반", label: "일반" },
],
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("재고 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "재고 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 구매 리포트
// ============================================
export async function getPurchaseReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const { startDate, endDate } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "po", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const df = buildDateFilter(startDate, endDate, "COALESCE(po.order_date, po.created_date::date::text)", idx);
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(po.order_date, po.created_date::date::text) as date,
po.purchase_no,
COALESCE(po.supplier_name, po.supplier_code, '미지정') as supplier,
COALESCE(po.item_name, po.item_code, '미지정') as item,
po.item_code,
COALESCE(po.manager, '미지정') as manager,
po.status,
CAST(COALESCE(NULLIF(po.order_qty, ''), '0') AS numeric) as "orderQty",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "unitPrice",
CAST(COALESCE(NULLIF(po.amount, ''), '0') AS numeric) as "orderAmt",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric)
* CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "receiveAmt",
1 as "orderCnt",
po.company_code
FROM purchase_order_mng po
${whereClause}
ORDER BY date DESC NULLS LAST
`;
const dataRows = await query(dataQuery, params);
logger.info("구매 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
suppliers: extractFilterSet(dataRows, "supplier"),
items: extractFilterSet(dataRows, "item"),
managers: extractFilterSet(dataRows, "manager"),
statuses: extractFilterSet(dataRows, "status"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("구매 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "구매 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 품질 리포트
// ============================================
export async function getQualityReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const { startDate, endDate } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "pr", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const df = buildDateFilter(startDate, endDate, "COALESCE(pr.production_date, pr.created_date::date::text)", idx);
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(pr.production_date, pr.created_date::date::text) as date,
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
'일반검사' as "defectType",
COALESCE(wi.routing, '미지정') as process,
COALESCE(pr.worker_name, '미지정') as inspector,
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric) as "inspQty",
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric)
- CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "passQty",
CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "defectQty",
0 as "reworkQty",
0 as "scrapQty",
0 as "claimCnt",
pr.company_code
FROM production_record pr
LEFT JOIN work_instruction wi ON pr.wo_id = wi.id AND pr.company_code = wi.company_code
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
${whereClause}
ORDER BY date DESC NULLS LAST
`;
const dataRows = await query(dataQuery, params);
logger.info("품질 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
items: extractFilterSet(dataRows, "item"),
defectTypes: [
{ value: "외관불량", label: "외관불량" }, { value: "치수불량", label: "치수불량" },
{ value: "기능불량", label: "기능불량" }, { value: "재질불량", label: "재질불량" },
{ value: "일반검사", label: "일반검사" },
],
processes: extractFilterSet(dataRows, "process"),
inspectors: extractFilterSet(dataRows, "inspector"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("품질 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "품질 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 설비 리포트
// ============================================
export async function getEquipmentReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "ei", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(ei.updated_date, ei.created_date)::date::text as date,
ei.equipment_code,
COALESCE(ei.equipment_name, ei.equipment_code) as equipment,
COALESCE(ei.equipment_type, '미지정') as "equipType",
COALESCE(ei.location, '미지정') as line,
COALESCE(ui.user_name, ei.manager_id, '미지정') as manager,
ei.status,
CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "runTime",
0 as "downTime",
100 as "opRate",
0 as "faultCnt",
0 as "mtbf",
0 as "mttr",
0 as "maintCost",
CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "prodQty",
ei.company_code
FROM equipment_info ei
LEFT JOIN (
SELECT DISTINCT ON (user_id) user_id, user_name FROM user_info
) ui ON ei.manager_id = ui.user_id
${whereClause}
ORDER BY equipment ASC
`;
const dataRows = await query(dataQuery, params);
logger.info("설비 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
equipment: extractFilterSet(dataRows, "equipment"),
equipTypes: extractFilterSet(dataRows, "equipType"),
lines: extractFilterSet(dataRows, "line"),
managers: extractFilterSet(dataRows, "manager"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("설비 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "설비 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 금형 리포트
// ============================================
export async function getMoldReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "mm", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(mm.updated_date, mm.created_date)::date::text as date,
mm.mold_code,
COALESCE(mm.mold_name, mm.mold_code) as mold,
COALESCE(mm.mold_type, mm.category, '미지정') as "moldType",
COALESCE(ii.item_name, '미지정') as item,
COALESCE(mm.manufacturer, '미지정') as maker,
mm.operation_status as status,
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) as "shotCnt",
CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) as "guaranteeShot",
CASE WHEN CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) > 0
THEN ROUND(
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) * 100.0
/ CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '1') AS numeric), 1)
ELSE 0 END as "lifeRate",
0 as "repairCnt",
0 as "repairCost",
0 as "prodQty",
0 as "defectRate",
CAST(COALESCE(NULLIF(mm.cavity_count::text, ''), '0') AS numeric) as "cavityUse",
mm.company_code
FROM mold_mng mm
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON mm.mold_code = ii.item_number AND mm.company_code = ii.company_code
${whereClause}
ORDER BY mold ASC
`;
const dataRows = await query(dataQuery, params);
logger.info("금형 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
molds: extractFilterSet(dataRows, "mold"),
moldTypes: extractFilterSet(dataRows, "moldType"),
items: extractFilterSet(dataRows, "item"),
makers: extractFilterSet(dataRows, "maker"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("금형 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "금형 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
File diff suppressed because it is too large Load Diff
@@ -1,212 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
// ============================================================
// 대결 위임 설정 (Approval Proxy Settings) CRUD
// ============================================================
export class ApprovalProxyController {
// 대결 위임 목록 조회 (user_info JOIN으로 이름/부서 포함)
static async getProxySettings(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const rows = await query<any>(
`SELECT ps.*,
u1.user_name AS original_user_name, u1.dept_name AS original_dept_name,
u2.user_name AS proxy_user_name, u2.dept_name AS proxy_dept_name
FROM approval_proxy_settings ps
LEFT JOIN user_info u1 ON ps.original_user_id = u1.user_id AND ps.company_code = u1.company_code
LEFT JOIN user_info u2 ON ps.proxy_user_id = u2.user_id AND ps.company_code = u2.company_code
WHERE ps.company_code = $1
ORDER BY ps.created_at DESC`,
[companyCode]
);
return res.json({ success: true, data: rows });
} catch (error) {
console.error("대결 위임 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "대결 위임 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 대결 위임 생성 (기간 중복 체크 포함)
static async createProxySetting(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { original_user_id, proxy_user_id, start_date, end_date, reason, is_active = "Y" } = req.body;
if (!original_user_id || !proxy_user_id) {
return res.status(400).json({ success: false, message: "위임자와 대결자는 필수입니다." });
}
if (!start_date || !end_date) {
return res.status(400).json({ success: false, message: "시작일과 종료일은 필수입니다." });
}
if (original_user_id === proxy_user_id) {
return res.status(400).json({ success: false, message: "위임자와 대결자가 동일할 수 없습니다." });
}
// 같은 기간 중복 체크 (daterange 오버랩)
const overlap = await queryOne<any>(
`SELECT COUNT(*) AS cnt FROM approval_proxy_settings
WHERE original_user_id = $1 AND is_active = 'Y'
AND daterange(start_date, end_date, '[]') && daterange($2::date, $3::date, '[]')
AND company_code = $4`,
[original_user_id, start_date, end_date, companyCode]
);
if (overlap && parseInt(overlap.cnt) > 0) {
return res.status(400).json({ success: false, message: "해당 기간에 이미 대결 설정이 존재합니다." });
}
const [row] = await query<any>(
`INSERT INTO approval_proxy_settings
(original_user_id, proxy_user_id, start_date, end_date, reason, is_active, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[original_user_id, proxy_user_id, start_date, end_date, reason || null, is_active, companyCode]
);
return res.status(201).json({ success: true, data: row, message: "대결 위임이 생성되었습니다." });
} catch (error) {
console.error("대결 위임 생성 오류:", error);
return res.status(500).json({
success: false,
message: "대결 위임 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 대결 위임 수정
static async updateProxySetting(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT id FROM approval_proxy_settings WHERE id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." });
}
const { proxy_user_id, start_date, end_date, reason, is_active } = req.body;
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (proxy_user_id !== undefined) { fields.push(`proxy_user_id = $${idx++}`); params.push(proxy_user_id); }
if (start_date !== undefined) { fields.push(`start_date = $${idx++}`); params.push(start_date); }
if (end_date !== undefined) { fields.push(`end_date = $${idx++}`); params.push(end_date); }
if (reason !== undefined) { fields.push(`reason = $${idx++}`); params.push(reason); }
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
if (fields.length === 0) {
return res.status(400).json({ success: false, message: "수정할 필드가 없습니다." });
}
fields.push(`updated_at = NOW()`);
params.push(id, companyCode);
const [row] = await query<any>(
`UPDATE approval_proxy_settings SET ${fields.join(", ")}
WHERE id = $${idx++} AND company_code = $${idx++}
RETURNING *`,
params
);
return res.json({ success: true, data: row, message: "대결 위임이 수정되었습니다." });
} catch (error) {
console.error("대결 위임 수정 오류:", error);
return res.status(500).json({
success: false,
message: "대결 위임 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 대결 위임 삭제
static async deleteProxySetting(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const result = await query<any>(
"DELETE FROM approval_proxy_settings WHERE id = $1 AND company_code = $2 RETURNING id",
[id, companyCode]
);
if (result.length === 0) {
return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." });
}
return res.json({ success: true, message: "대결 위임이 삭제되었습니다." });
} catch (error) {
console.error("대결 위임 삭제 오류:", error);
return res.status(500).json({
success: false,
message: "대결 위임 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 특정 사용자의 현재 활성 대결자 조회
static async checkActiveProxy(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { userId } = req.params;
if (!userId) {
return res.status(400).json({ success: false, message: "userId 파라미터는 필수입니다." });
}
const rows = await query<any>(
`SELECT ps.*, u.user_name AS proxy_user_name
FROM approval_proxy_settings ps
LEFT JOIN user_info u ON ps.proxy_user_id = u.user_id AND ps.company_code = u.company_code
WHERE ps.original_user_id = $1 AND ps.is_active = 'Y'
AND ps.start_date <= CURRENT_DATE AND ps.end_date >= CURRENT_DATE
AND ps.company_code = $2`,
[userId, companyCode]
);
return res.json({ success: true, data: rows });
} catch (error) {
console.error("활성 대결자 조회 오류:", error);
return res.status(500).json({
success: false,
message: "활성 대결자 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
@@ -1,176 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService";
import { query } from "../database/db";
import logger from "../utils/logger";
export const getAuditLogs = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const userCompanyCode = req.user?.companyCode;
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
const {
companyCode,
userId,
resourceType,
action,
tableName,
dateFrom,
dateTo,
search,
page,
limit,
} = req.query;
const result = await auditLogService.queryLogs(
{
companyCode: (companyCode as string) || (isSuperAdmin ? undefined : userCompanyCode),
userId: userId as string,
resourceType: resourceType as string,
action: action as string,
tableName: tableName as string,
dateFrom: dateFrom as string,
dateTo: dateTo as string,
search: search as string,
page: page ? parseInt(page as string, 10) : 1,
limit: limit ? parseInt(limit as string, 10) : 50,
},
isSuperAdmin
);
res.json({
success: true,
data: result.data,
total: result.total,
page: page ? parseInt(page as string, 10) : 1,
limit: limit ? parseInt(limit as string, 10) : 50,
});
} catch (error: any) {
logger.error("감사 로그 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "감사 로그 조회 중 오류가 발생했습니다.",
});
}
};
export const getAuditLogStats = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const userCompanyCode = req.user?.companyCode;
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
const { companyCode, days } = req.query;
const targetCompany = isSuperAdmin
? (companyCode as string) || undefined
: userCompanyCode;
const stats = await auditLogService.getStats(
targetCompany,
days ? parseInt(days as string, 10) : 30
);
res.json({ success: true, data: stats });
} catch (error: any) {
logger.error("감사 로그 통계 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "감사 로그 통계 조회 중 오류가 발생했습니다.",
});
}
};
export const getAuditLogUsers = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const userCompanyCode = req.user?.companyCode;
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
const { companyCode } = req.query;
const conditions: string[] = ["LOWER(u.status) = 'active'"];
const params: any[] = [];
let paramIndex = 1;
if (!isSuperAdmin) {
conditions.push(`u.company_code = $${paramIndex++}`);
params.push(userCompanyCode);
} else if (companyCode) {
conditions.push(`u.company_code = $${paramIndex++}`);
params.push(companyCode);
}
if (!isSuperAdmin) {
conditions.push(`u.company_code != '*'`);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const users = await query<{ user_id: string; user_name: string; count: number }>(
`SELECT
u.user_id,
u.user_name,
COALESCE(sal.log_count, 0)::int as count
FROM user_info u
LEFT JOIN (
SELECT user_id, COUNT(*) as log_count
FROM system_audit_log
GROUP BY user_id
) sal ON u.user_id = sal.user_id
${whereClause}
ORDER BY count DESC, u.user_name ASC`,
params
);
res.json({ success: true, data: users });
} catch (error: any) {
logger.error("감사 로그 사용자 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "사용자 목록 조회 중 오류가 발생했습니다.",
});
}
};
/**
* 프론트엔드에서 직접 감사 로그 기록 (그룹 복제 등 프론트 오케스트레이션 작업용)
*/
export const createAuditLog = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { action, resourceType, resourceId, resourceName, tableName, summary, changes } = req.body;
if (!action || !resourceType) {
res.status(400).json({ success: false, message: "action, resourceType은 필수입니다." });
return;
}
await auditLogService.log({
companyCode: req.user?.companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: action as AuditAction,
resourceType: resourceType as AuditResourceType,
resourceId: resourceId || undefined,
resourceName: resourceName || undefined,
tableName: tableName || undefined,
summary: summary || undefined,
changes: changes || undefined,
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({ success: true });
} catch (error: any) {
logger.error("감사 로그 기록 실패", { error: error.message });
res.status(500).json({ success: false, message: "감사 로그 기록 실패" });
}
};
@@ -1,561 +0,0 @@
// 인증 컨트롤러
// 기존 Java ApiLoginController를 Node.js로 포팅
import { Request, Response } from "express";
import { AuthService } from "../services/authService";
import { JwtUtils } from "../utils/jwtUtils";
import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth";
import { logger } from "../utils/logger";
import { sendSmartFactoryLog } from "../utils/smartFactoryLog";
export class AuthController {
/**
* POST /api/auth/login
* Java ApiLoginController.login()
*/
static async login(req: Request, res: Response): Promise<void> {
try {
const { userId, password }: LoginRequest = req.body;
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
logger.debug(`로그인 요청: ${userId}`);
// 입력값 검증
if (!userId || !password) {
res.status(400).json({
success: false,
message: "사용자 ID와 비밀번호를 입력해주세요.",
error: {
code: "INVALID_INPUT",
details: "필수 입력값이 누락되었습니다.",
},
});
return;
}
// 로그인 프로세스 실행
const loginResult = await AuthService.processLogin(
userId,
password,
remoteAddr
);
if (loginResult.success && loginResult.userInfo && loginResult.token) {
// 로그인 성공
const userInfo: UserInfo = {
userId: loginResult.userInfo.userId,
userName: loginResult.userInfo.userName || "",
deptName: loginResult.userInfo.deptName || "",
companyCode: loginResult.userInfo.companyCode || "ILSHIN",
};
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
// 메뉴 조회를 위한 공통 파라미터
const { AdminService } = await import("../services/adminService");
const paramMap = {
userId: loginResult.userInfo.userId,
userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN",
userType: loginResult.userInfo.userType,
userLang: "ko",
};
// 사용자의 첫 번째 접근 가능한 메뉴 조회
let firstMenuPath: string | null = null;
try {
const menuList = await AdminService.getUserMenuList(paramMap);
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
const firstMenu = menuList.find((menu: any) => {
const level = menu.lev || menu.level;
const url = menu.menu_url || menu.url;
return level >= 2 && url && url.trim() !== "" && url !== "#";
});
if (firstMenu) {
firstMenuPath = firstMenu.menu_url || firstMenu.url;
logger.debug(`첫 번째 메뉴: ${firstMenuPath}`);
} else {
logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동");
}
} catch (menuError) {
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
}
// 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함)
sendSmartFactoryLog({
userId: userInfo.userId,
remoteAddr,
useType: "접속",
}).catch(() => {});
// POP 랜딩 경로 조회
let popLandingPath: string | null = null;
try {
const popResult = await AdminService.getPopMenuList(paramMap);
if (popResult.landingMenu?.menu_url) {
popLandingPath = popResult.landingMenu.menu_url;
} else if (popResult.childMenus.length === 1) {
popLandingPath = popResult.childMenus[0].menu_url;
} else if (popResult.childMenus.length > 1) {
popLandingPath = "/pop";
}
logger.debug(`POP 랜딩 경로: ${popLandingPath}`);
} catch (popError) {
logger.warn("POP 메뉴 조회 중 오류 (무시):", popError);
}
res.status(200).json({
success: true,
message: "로그인 성공",
data: {
userInfo,
token: loginResult.token,
firstMenuPath,
popLandingPath,
},
});
} else {
// 로그인 실패
res.status(401).json({
success: false,
message: "로그인 실패",
error: {
code: "LOGIN_FAILED",
details:
loginResult.errorReason || "알 수 없는 오류가 발생했습니다.",
},
});
}
} catch (error) {
logger.error(
`로그인 API 오류: ${error instanceof Error ? error.message : error}`
);
res.status(500).json({
success: false,
message: "서버 오류가 발생했습니다.",
error: {
code: "SERVER_ERROR",
details:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
},
});
}
}
/**
* POST /api/auth/switch-company
* WACE 전용: 다른
*/
static async switchCompany(req: Request, res: Response): Promise<void> {
try {
const { companyCode } = req.body;
const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
res.status(401).json({
success: false,
message: "인증 토큰이 필요합니다.",
error: { code: "TOKEN_MISSING" },
});
return;
}
// 현재 사용자 정보 확인
const currentUser = JwtUtils.verifyToken(token);
// WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인)
// 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함
if (currentUser.userType !== "SUPER_ADMIN") {
logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`);
res.status(403).json({
success: false,
message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.",
error: { code: "FORBIDDEN" },
});
return;
}
// 전환할 회사 코드 검증
if (!companyCode || companyCode.trim() === "") {
res.status(400).json({
success: false,
message: "전환할 회사 코드가 필요합니다.",
error: { code: "INVALID_INPUT" },
});
return;
}
logger.info(`=== WACE 관리자 회사 전환 ===`, {
userId: currentUser.userId,
originalCompanyCode: currentUser.companyCode,
targetCompanyCode: companyCode,
});
// 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만)
if (companyCode !== "*") {
const { query } = await import("../database/db");
const companies = await query<any>(
"SELECT company_code, company_name FROM company_mng WHERE company_code = $1",
[companyCode]
);
if (companies.length === 0) {
res.status(404).json({
success: false,
message: "존재하지 않는 회사 코드입니다.",
error: { code: "COMPANY_NOT_FOUND" },
});
return;
}
}
// 새로운 JWT 토큰 발급 (company_code만 변경)
const newPersonBean: PersonBean = {
...currentUser,
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
};
const newToken = JwtUtils.generateToken(newPersonBean);
logger.info(`✅ 회사 전환 성공: ${currentUser.userId}${companyCode}`);
res.status(200).json({
success: true,
message: "회사 전환 완료",
data: {
token: newToken,
companyCode: companyCode.trim(),
},
});
} catch (error) {
logger.error(
`회사 전환 API 오류: ${error instanceof Error ? error.message : error}`
);
res.status(500).json({
success: false,
message: "회사 전환 중 오류가 발생했습니다.",
error: {
code: "SERVER_ERROR",
details:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
},
});
}
}
/**
* POST /api/auth/logout
* Java ApiLoginController.logout()
*/
static async logout(req: Request, res: Response): Promise<void> {
try {
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
// JWT 토큰에서 사용자 정보 추출
const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1];
if (token) {
try {
const userInfo = JwtUtils.verifyToken(token);
await AuthService.processLogout(userInfo.userId, remoteAddr);
} catch (tokenError) {
logger.warn(
`로그아웃 시 토큰 검증 실패: ${tokenError instanceof Error ? tokenError.message : tokenError}`
);
}
}
res.status(200).json({
success: true,
message: "로그아웃되었습니다.",
data: null,
});
} catch (error) {
logger.error(
`로그아웃 API 오류: ${error instanceof Error ? error.message : error}`
);
res.status(500).json({
success: false,
message: "로그아웃 처리 중 오류가 발생했습니다.",
error: {
code: "LOGOUT_ERROR",
details:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
},
});
}
}
/**
* GET /api/auth/me
* Java ApiLoginController.getCurrentUser()
*/
static async getCurrentUser(req: Request, res: Response): Promise<void> {
try {
const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
res.status(401).json({
success: false,
message: "인증되지 않은 사용자입니다.",
error: {
code: "NOT_AUTHENTICATED",
details: "세션이 만료되었거나 로그인이 필요합니다.",
},
});
return;
}
const userInfo = JwtUtils.verifyToken(token);
// DB에서 최신 사용자 정보 조회 (locale 포함)
const dbUserInfo = await AuthService.getUserInfo(userInfo.userId);
if (!dbUserInfo) {
res.status(401).json({
success: false,
message: "사용자 정보를 찾을 수 없습니다.",
error: {
code: "USER_NOT_FOUND",
details: "사용자 정보가 삭제되었거나 존재하지 않습니다.",
},
});
return;
}
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
// ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
const userInfoResponse: any = {
userId: dbUserInfo.userId,
userName: dbUserInfo.userName || "",
deptName: dbUserInfo.deptName || "",
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
userTypeName: dbUserInfo.userTypeName || "일반사용자",
email: dbUserInfo.email || "",
photo: dbUserInfo.photo,
locale: dbUserInfo.locale || "KR", // locale 정보 추가
deptCode: dbUserInfo.deptCode, // 추가 필드
isAdmin:
dbUserInfo.userType === "ADMIN" || dbUserInfo.userId === "plm_admin",
};
res.status(200).json({
success: true,
message: "사용자 정보 조회 성공",
data: userInfoResponse,
});
} catch (error) {
logger.error(
`사용자 정보 조회 API 오류: ${error instanceof Error ? error.message : error}`
);
res.status(401).json({
success: false,
message: "인증되지 않은 사용자입니다.",
error: {
code: "NOT_AUTHENTICATED",
details: "세션이 만료되었거나 로그인이 필요합니다.",
},
});
}
}
/**
* GET /api/auth/status
* Java ApiLoginController.checkAuthStatus()
*/
static async checkAuthStatus(req: Request, res: Response): Promise<void> {
try {
const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
res.status(200).json({
success: true,
message: "세션 상태 확인",
data: {
isLoggedIn: false,
isAdmin: false,
},
});
return;
}
const validation = JwtUtils.validateToken(token);
if (!validation.isValid) {
res.status(200).json({
success: true,
message: "세션 상태 확인",
data: {
isLoggedIn: false,
isAdmin: false,
error: validation.error,
},
});
return;
}
// 토큰에서 사용자 정보 추출하여 관리자 권한 확인
let isAdmin = false;
try {
const userInfo = JwtUtils.verifyToken(token);
// 기존 Java 로직과 동일: plm_admin 사용자만 관리자로 인식
isAdmin =
userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN";
logger.info(`인증 상태 확인: ${userInfo.userId}, 관리자: ${isAdmin}`);
} catch (error) {
logger.error(`토큰에서 사용자 정보 추출 실패: ${error}`);
}
res.status(200).json({
success: true,
message: "세션 상태 확인",
data: {
isLoggedIn: true,
isAdmin: isAdmin,
},
});
} catch (error) {
logger.error(
`세션 상태 확인 API 오류: ${error instanceof Error ? error.message : error}`
);
res.status(500).json({
success: false,
message: "세션 상태 확인 중 오류가 발생했습니다.",
error: {
code: "SESSION_CHECK_ERROR",
details:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
},
});
}
}
/**
* POST /api/auth/refresh
* JWT API
*/
static async refreshToken(req: Request, res: Response): Promise<void> {
try {
const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
res.status(401).json({
success: false,
message: "토큰이 필요합니다.",
error: {
code: "TOKEN_MISSING",
details: "인증 토큰이 필요합니다.",
},
});
return;
}
const newToken = JwtUtils.refreshToken(token);
res.status(200).json({
success: true,
message: "토큰 갱신 성공",
data: {
token: newToken,
},
});
} catch (error) {
logger.error(
`토큰 갱신 API 오류: ${error instanceof Error ? error.message : error}`
);
res.status(401).json({
success: false,
message: "토큰 갱신에 실패했습니다.",
error: {
code: "TOKEN_REFRESH_ERROR",
details:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
},
});
}
}
/**
* POST /api/auth/signup
* API
*/
static async signup(req: Request, res: Response): Promise<void> {
try {
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
logger.info(`=== 공차중계 회원가입 API 호출 ===`);
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`);
// 입력값 검증
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
res.status(400).json({
success: false,
message: "필수 입력값이 누락되었습니다.",
error: {
code: "INVALID_INPUT",
details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.",
},
});
return;
}
// 회원가입 처리
const signupResult = await AuthService.signupDriver({
userId,
password,
userName,
phoneNumber,
licenseNumber,
vehicleNumber,
vehicleType,
});
if (signupResult.success) {
logger.info(`공차중계 회원가입 성공: ${userId}`);
res.status(201).json({
success: true,
message: "회원가입이 완료되었습니다.",
});
} else {
logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`);
res.status(400).json({
success: false,
message: signupResult.message || "회원가입에 실패했습니다.",
error: {
code: "SIGNUP_FAILED",
details: signupResult.message,
},
});
}
} catch (error) {
logger.error("공차중계 회원가입 API 오류:", error);
res.status(500).json({
success: false,
message: "회원가입 처리 중 오류가 발생했습니다.",
error: {
code: "SIGNUP_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
});
}
}
}
@@ -1,218 +0,0 @@
/**
*
* ZD421 CRUD /릿
*/
import { Request, Response, NextFunction } from "express";
import barcodeLabelService from "../services/barcodeLabelService";
function getUserId(req: Request): string {
return (req as any).user?.userId || "SYSTEM";
}
export class BarcodeLabelController {
async getLabels(req: Request, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt((req.query.page as string) || "1", 10));
const limit = Math.min(100, Math.max(1, parseInt((req.query.limit as string) || "20", 10)));
const searchText = (req.query.searchText as string) || "";
const useYn = (req.query.useYn as string) || "Y";
const sortBy = (req.query.sortBy as string) || "created_at";
const sortOrder = (req.query.sortOrder as "ASC" | "DESC") || "DESC";
const data = await barcodeLabelService.getLabels({
page,
limit,
searchText,
useYn,
sortBy,
sortOrder,
});
return res.json({ success: true, data });
} catch (error) {
return next(error);
}
}
async getLabelById(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const label = await barcodeLabelService.getLabelById(labelId);
if (!label) {
return res.status(404).json({
success: false,
message: "바코드 라벨을 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: label });
} catch (error) {
return next(error);
}
}
async getLayout(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const layout = await barcodeLabelService.getLayout(labelId);
if (!layout) {
return res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: layout });
} catch (error) {
return next(error);
}
}
async createLabel(req: Request, res: Response, next: NextFunction) {
try {
const body = req.body as {
labelNameKor?: string;
labelNameEng?: string;
description?: string;
templateId?: string;
};
if (!body?.labelNameKor?.trim()) {
return res.status(400).json({
success: false,
message: "라벨명(한글)은 필수입니다.",
});
}
const labelId = await barcodeLabelService.createLabel(
{
labelNameKor: body.labelNameKor.trim(),
labelNameEng: body.labelNameEng?.trim(),
description: body.description?.trim(),
templateId: body.templateId?.trim(),
},
getUserId(req)
);
return res.status(201).json({
success: true,
data: { labelId },
message: "바코드 라벨이 생성되었습니다.",
});
} catch (error) {
return next(error);
}
}
async updateLabel(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const body = req.body as {
labelNameKor?: string;
labelNameEng?: string;
description?: string;
useYn?: string;
};
const success = await barcodeLabelService.updateLabel(
labelId,
{
labelNameKor: body.labelNameKor?.trim(),
labelNameEng: body.labelNameEng?.trim(),
description: body.description !== undefined ? body.description : undefined,
useYn: body.useYn,
},
getUserId(req)
);
if (!success) {
return res.status(404).json({
success: false,
message: "바코드 라벨을 찾을 수 없습니다.",
});
}
return res.json({ success: true, message: "수정되었습니다." });
} catch (error) {
return next(error);
}
}
async saveLayout(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const layout = req.body as { width_mm: number; height_mm: number; components: any[] };
if (!layout || typeof layout.width_mm !== "number" || typeof layout.height_mm !== "number" || !Array.isArray(layout.components)) {
return res.status(400).json({
success: false,
message: "width_mm, height_mm, components 배열이 필요합니다.",
});
}
await barcodeLabelService.saveLayout(
labelId,
{ width_mm: layout.width_mm, height_mm: layout.height_mm, components: layout.components },
getUserId(req)
);
return res.json({ success: true, message: "레이아웃이 저장되었습니다." });
} catch (error) {
return next(error);
}
}
async deleteLabel(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const success = await barcodeLabelService.deleteLabel(labelId);
if (!success) {
return res.status(404).json({
success: false,
message: "바코드 라벨을 찾을 수 없습니다.",
});
}
return res.json({ success: true, message: "삭제되었습니다." });
} catch (error) {
return next(error);
}
}
async copyLabel(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const newId = await barcodeLabelService.copyLabel(labelId, getUserId(req));
if (!newId) {
return res.status(404).json({
success: false,
message: "바코드 라벨을 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: { labelId: newId },
message: "복사되었습니다.",
});
} catch (error) {
return next(error);
}
}
async getTemplates(req: Request, res: Response, next: NextFunction) {
try {
const templates = await barcodeLabelService.getTemplates();
return res.json({ success: true, data: templates });
} catch (error) {
return next(error);
}
}
async getTemplateById(req: Request, res: Response, next: NextFunction) {
try {
const { templateId } = req.params;
const template = await barcodeLabelService.getTemplateById(templateId);
if (!template) {
return res.status(404).json({
success: false,
message: "템플릿을 찾을 수 없습니다.",
});
}
const layout = JSON.parse(template.layout_json);
return res.json({ success: true, data: { ...template, layout } });
} catch (error) {
return next(error);
}
}
}
export default new BarcodeLabelController();
@@ -1,339 +0,0 @@
// 배치관리 컨트롤러
// 작성일: 2024-12-24
import { Request, Response } from "express";
import { BatchService } from "../services/batchService";
import { BatchSchedulerService } from "../services/batchSchedulerService";
import { BatchExternalDbService } from "../services/batchExternalDbService";
import {
BatchConfigFilter,
CreateBatchConfigRequest,
UpdateBatchConfigRequest,
} from "../types/batchTypes";
export interface AuthenticatedRequest extends Request {
user?: {
userId: string;
username: string;
companyCode: string;
};
}
export class BatchController {
/**
* ()
* GET /api/batch-configs
*/
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
try {
const { page = 1, limit = 10, search, isActive } = req.query;
const userCompanyCode = req.user?.companyCode;
const filter: BatchConfigFilter = {
page: Number(page),
limit: Number(limit),
search: search as string,
is_active: isActive as string,
};
const result = await BatchService.getBatchConfigs(
filter,
userCompanyCode
);
res.json({
success: true,
data: result.data,
pagination: result.pagination,
});
} catch (error) {
console.error("배치 설정 목록 조회 오류:", error);
res.status(500).json({
success: false,
message: "배치 설정 목록 조회에 실패했습니다.",
});
}
}
/**
*
* GET /api/batch-configs/connections
*/
static async getAvailableConnections(
req: AuthenticatedRequest,
res: Response
) {
try {
const result = await BatchExternalDbService.getAvailableConnections();
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("커넥션 목록 조회 오류:", error);
res.status(500).json({
success: false,
message: "커넥션 목록 조회에 실패했습니다.",
});
}
}
/**
* (/ DB)
* GET /api/batch-configs/connections/:type/tables
* GET /api/batch-configs/connections/:type/:id/tables
*/
static async getTablesFromConnection(
req: AuthenticatedRequest,
res: Response
) {
try {
const { type, id } = req.params;
if (!type || (type !== "internal" && type !== "external")) {
return res.status(400).json({
success: false,
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)",
});
}
const connectionId = type === "external" ? Number(id) : undefined;
const result = await BatchService.getTables(
type as "internal" | "external",
connectionId
);
if (result.success) {
return res.json(result);
} else {
return res.status(500).json(result);
}
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "테이블 목록 조회에 실패했습니다.",
});
}
}
/**
* (/ DB)
* GET /api/batch-configs/connections/:type/tables/:tableName/columns
* GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns
*/
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
try {
const { type, id, tableName } = req.params;
if (!type || !tableName) {
return res.status(400).json({
success: false,
message: "연결 타입과 테이블명을 모두 지정해주세요.",
});
}
if (type !== "internal" && type !== "external") {
return res.status(400).json({
success: false,
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)",
});
}
const connectionId = type === "external" ? Number(id) : undefined;
const result = await BatchService.getColumns(
tableName,
type as "internal" | "external",
connectionId
);
if (result.success) {
return res.json(result);
} else {
return res.status(500).json(result);
}
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
return res.status(500).json({
success: false,
message: "컬럼 정보 조회에 실패했습니다.",
});
}
}
/**
* ()
* GET /api/batch-configs/:id
*/
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const result = await BatchService.getBatchConfigById(Number(id));
if (!result.success || !result.data) {
return res.status(404).json({
success: false,
message: result.message || "배치 설정을 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: result.data,
});
} catch (error) {
console.error("배치 설정 조회 오류:", error);
return res.status(500).json({
success: false,
message: "배치 설정 조회에 실패했습니다.",
});
}
}
/**
*
* POST /api/batch-configs
*/
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
try {
const { batchName, description, cronSchedule, mappings } = req.body;
if (
!batchName ||
!cronSchedule ||
!mappings ||
!Array.isArray(mappings)
) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)",
});
}
const batchConfig = await BatchService.createBatchConfig({
batchName,
description,
cronSchedule,
mappings,
} as CreateBatchConfigRequest);
// 생성된 배치가 활성화 상태라면 스케줄러에 등록 (즉시 실행 비활성화)
if (
batchConfig.data &&
batchConfig.data.is_active === "Y" &&
batchConfig.data.id
) {
await BatchSchedulerService.updateBatchSchedule(
batchConfig.data.id,
false
);
}
return res.status(201).json({
success: true,
data: batchConfig,
message: "배치 설정이 성공적으로 생성되었습니다.",
});
} catch (error) {
console.error("배치 설정 생성 오류:", error);
return res.status(500).json({
success: false,
message: "배치 설정 생성에 실패했습니다.",
});
}
}
/**
* ()
* PUT /api/batch-configs/:id
*/
static async updateBatchConfig(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const { batchName, description, cronSchedule, mappings, isActive } =
req.body;
const userId = req.user?.userId;
const userCompanyCode = req.user?.companyCode;
if (!batchName || !cronSchedule) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)",
});
}
const batchConfig = await BatchService.updateBatchConfig(
Number(id),
{
batchName,
description,
cronSchedule,
mappings,
isActive,
} as UpdateBatchConfigRequest,
userId,
userCompanyCode
);
if (!batchConfig) {
return res.status(404).json({
success: false,
message: "배치 설정을 찾을 수 없습니다.",
});
}
// 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화)
await BatchSchedulerService.updateBatchSchedule(Number(id), false);
return res.json({
success: true,
data: batchConfig,
message: "배치 설정이 성공적으로 수정되었습니다.",
});
} catch (error) {
console.error("배치 설정 수정 오류:", error);
return res.status(500).json({
success: false,
message: "배치 설정 수정에 실패했습니다.",
});
}
}
/**
* ( , )
* DELETE /api/batch-configs/:id
*/
static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const userId = req.user?.userId;
const userCompanyCode = req.user?.companyCode;
const result = await BatchService.deleteBatchConfig(
Number(id),
userId,
userCompanyCode
);
if (!result) {
return res.status(404).json({
success: false,
message: "배치 설정을 찾을 수 없습니다.",
});
}
return res.json({
success: true,
message: "배치 설정이 성공적으로 삭제되었습니다.",
});
} catch (error) {
console.error("배치 설정 삭제 오류:", error);
return res.status(500).json({
success: false,
message: "배치 설정 삭제에 실패했습니다.",
});
}
}
}
@@ -1,194 +0,0 @@
// 배치 실행 로그 컨트롤러
// 작성일: 2024-12-24
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { BatchExecutionLogService } from "../services/batchExecutionLogService";
import {
BatchExecutionLogFilter,
CreateBatchExecutionLogRequest,
UpdateBatchExecutionLogRequest,
} from "../types/batchExecutionLogTypes";
export class BatchExecutionLogController {
/**
*
*/
static async getExecutionLogs(req: AuthenticatedRequest, res: Response) {
try {
const {
batch_config_id,
execution_status,
start_date,
end_date,
page,
limit,
} = req.query;
const filter: BatchExecutionLogFilter = {
batch_config_id: batch_config_id ? Number(batch_config_id) : undefined,
execution_status: execution_status as string,
start_date: start_date ? new Date(start_date as string) : undefined,
end_date: end_date ? new Date(end_date as string) : undefined,
page: page ? Number(page) : undefined,
limit: limit ? Number(limit) : undefined,
};
const userCompanyCode = req.user?.companyCode;
const result = await BatchExecutionLogService.getExecutionLogs(
filter,
userCompanyCode
);
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("배치 실행 로그 조회 오류:", error);
res.status(500).json({
success: false,
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
*
*/
static async createExecutionLog(req: AuthenticatedRequest, res: Response) {
try {
const data: CreateBatchExecutionLogRequest = req.body;
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
if (!data.company_code) {
data.company_code = req.user?.companyCode || "*";
}
const result = await BatchExecutionLogService.createExecutionLog(data);
if (result.success) {
res.status(201).json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("배치 실행 로그 생성 오류:", error);
res.status(500).json({
success: false,
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
*
*/
static async updateExecutionLog(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const data: UpdateBatchExecutionLogRequest = req.body;
const result = await BatchExecutionLogService.updateExecutionLog(
Number(id),
data
);
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("배치 실행 로그 업데이트 오류:", error);
res.status(500).json({
success: false,
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
*
*/
static async deleteExecutionLog(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const result = await BatchExecutionLogService.deleteExecutionLog(
Number(id)
);
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("배치 실행 로그 삭제 오류:", error);
res.status(500).json({
success: false,
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
*
*/
static async getLatestExecutionLog(req: AuthenticatedRequest, res: Response) {
try {
const { batchConfigId } = req.params;
const result = await BatchExecutionLogService.getLatestExecutionLog(
Number(batchConfigId)
);
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("최신 배치 실행 로그 조회 오류:", error);
res.status(500).json({
success: false,
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
*
*/
static async getExecutionStats(req: AuthenticatedRequest, res: Response) {
try {
const { batch_config_id, start_date, end_date } = req.query;
const result = await BatchExecutionLogService.getExecutionStats(
batch_config_id ? Number(batch_config_id) : undefined,
start_date ? new Date(start_date as string) : undefined,
end_date ? new Date(end_date as string) : undefined
);
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("배치 실행 통계 조회 오류:", error);
res.status(500).json({
success: false,
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,226 +0,0 @@
/**
* BOM /
*/
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import * as bomService from "../services/bomService";
// ─── 이력 (History) ─────────────────────────────
export async function getBomHistory(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const tableName = (req.query.tableName as string) || undefined;
const data = await bomService.getBomHistory(bomId, companyCode, tableName);
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 이력 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function addBomHistory(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const changedBy = (req as any).user?.userName || (req as any).user?.userId || "";
const { change_type, change_description, revision, version, tableName } = req.body;
if (!change_type) {
res.status(400).json({ success: false, message: "change_type은 필수입니다" });
return;
}
const result = await bomService.addBomHistory(bomId, companyCode, {
change_type,
change_description,
revision,
version,
changed_by: changedBy,
}, tableName);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 이력 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ─── BOM 헤더 조회 (entity join 포함) ─────────────────────────
export async function getBomHeader(req: Request, res: Response) {
try {
const { bomId } = req.params;
const tableName = (req.query.tableName as string) || undefined;
const data = await bomService.getBomHeader(bomId, tableName);
if (!data) {
res.status(404).json({ success: false, message: "BOM을 찾을 수 없습니다" });
return;
}
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 헤더 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ─── 버전 (Version) ─────────────────────────────
export async function getBomVersions(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const tableName = (req.query.tableName as string) || undefined;
const result = await bomService.getBomVersions(bomId, companyCode, tableName);
res.json({
success: true,
data: result.versions,
currentVersionId: result.currentVersionId,
});
} catch (error: any) {
logger.error("BOM 버전 목록 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createBomVersion(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
const { tableName, detailTable, versionName } = req.body || {};
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable, versionName);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 생성 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function loadBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const { tableName, detailTable } = req.body || {};
const result = await bomService.loadBomVersion(bomId, versionId, companyCode, tableName, detailTable);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 불러오기 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function activateBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const { tableName } = req.body || {};
const result = await bomService.activateBomVersion(bomId, versionId, tableName);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 사용 확정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function initializeBomVersion(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
const result = await bomService.initializeBomVersion(bomId, companyCode, createdBy);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 초기 버전 생성 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ─── BOM 엑셀 업로드/다운로드 ─────────────────────────
export async function createBomFromExcel(req: Request, res: Response) {
try {
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomFromExcel(companyCode, userId, rows);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 엑셀 업로드 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createBomVersionFromExcel(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows, versionName } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomVersionFromExcel(bomId, companyCode, userId, rows, versionName);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 엑셀 업로드 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function downloadBomExcelData(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const data = await bomService.downloadBomExcelData(bomId, companyCode);
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 엑셀 다운로드 데이터 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const tableName = (req.query.tableName as string) || undefined;
const detailTable = (req.query.detailTable as string) || undefined;
const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName, detailTable);
if (!deleted) {
res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
return;
}
res.json({ success: true });
} catch (error: any) {
logger.error("BOM 버전 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
@@ -1,80 +0,0 @@
import { Request, Response } from "express";
import { BookingService } from "../services/bookingService";
import { logger } from "../utils/logger";
const bookingService = BookingService.getInstance();
/**
*
*/
export const getBookings = async (req: Request, res: Response): Promise<void> => {
try {
const { status, priority } = req.query;
const result = await bookingService.getAllBookings({
status: status as string,
priority: priority as string,
});
res.status(200).json({
success: true,
data: result.bookings,
newCount: result.newCount,
});
} catch (error) {
logger.error("❌ 예약 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "예약 목록 조회에 실패했습니다.",
error: error instanceof Error ? error.message : String(error),
});
}
};
/**
*
*/
export const acceptBooking = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const booking = await bookingService.acceptBooking(id);
res.status(200).json({
success: true,
data: booking,
message: "예약이 수락되었습니다.",
});
} catch (error) {
logger.error("❌ 예약 수락 실패:", error);
res.status(500).json({
success: false,
message: "예약 수락에 실패했습니다.",
error: error instanceof Error ? error.message : String(error),
});
}
};
/**
*
*/
export const rejectBooking = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { reason } = req.body;
const booking = await bookingService.rejectBooking(id, reason);
res.status(200).json({
success: true,
data: booking,
message: "예약이 거절되었습니다.",
});
} catch (error) {
logger.error("❌ 예약 거절 실패:", error);
res.status(500).json({
success: false,
message: "예약 거절에 실패했습니다.",
error: error instanceof Error ? error.message : String(error),
});
}
};
@@ -1,421 +0,0 @@
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne, transaction } from "../database/db";
export class ButtonActionStandardController {
// 버튼 액션 목록 조회
static async getButtonActions(req: Request, res: Response) {
try {
const { active, category, search } = req.query;
const whereConditions: string[] = [];
const queryParams: any[] = [];
let paramIndex = 1;
if (active) {
whereConditions.push(`is_active = $${paramIndex}`);
queryParams.push(active as string);
paramIndex++;
}
if (category) {
whereConditions.push(`category = $${paramIndex}`);
queryParams.push(category as string);
paramIndex++;
}
if (search) {
whereConditions.push(`(action_name ILIKE $${paramIndex} OR action_name_eng ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`);
queryParams.push(`%${search}%`);
paramIndex++;
}
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const buttonActions = await query<any>(
`SELECT * FROM button_action_standards ${whereClause} ORDER BY sort_order ASC, action_type ASC`,
queryParams
);
return res.json({
success: true,
data: buttonActions,
message: "버튼 액션 목록을 성공적으로 조회했습니다.",
});
} catch (error) {
console.error("버튼 액션 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "버튼 액션 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 버튼 액션 상세 조회
static async getButtonAction(req: Request, res: Response) {
try {
const { actionType } = req.params;
const buttonAction = await queryOne<any>(
"SELECT * FROM button_action_standards WHERE action_type = $1 LIMIT 1",
[actionType]
);
if (!buttonAction) {
return res.status(404).json({
success: false,
message: "해당 버튼 액션을 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: buttonAction,
message: "버튼 액션 정보를 성공적으로 조회했습니다.",
});
} catch (error) {
console.error("버튼 액션 상세 조회 오류:", error);
return res.status(500).json({
success: false,
message: "버튼 액션 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 버튼 액션 생성
static async createButtonAction(req: AuthenticatedRequest, res: Response) {
try {
const {
action_type,
action_name,
action_name_eng,
description,
category = "general",
default_text,
default_text_eng,
default_icon,
default_color,
default_variant = "default",
confirmation_required = false,
confirmation_message,
validation_rules,
action_config,
sort_order = 0,
is_active = "Y",
} = req.body;
// 필수 필드 검증
if (!action_type || !action_name) {
return res.status(400).json({
success: false,
message: "액션 타입과 이름은 필수입니다.",
});
}
// 중복 체크
const existingAction = await queryOne<any>(
"SELECT * FROM button_action_standards WHERE action_type = $1 LIMIT 1",
[action_type]
);
if (existingAction) {
return res.status(409).json({
success: false,
message: "이미 존재하는 액션 타입입니다.",
});
}
const [newButtonAction] = await query<any>(
`INSERT INTO button_action_standards (
action_type, action_name, action_name_eng, description, category,
default_text, default_text_eng, default_icon, default_color, default_variant,
confirmation_required, confirmation_message, validation_rules, action_config,
sort_order, is_active, created_by, updated_by, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, NOW(), NOW())
RETURNING *`,
[
action_type, action_name, action_name_eng, description, category,
default_text, default_text_eng, default_icon, default_color, default_variant,
confirmation_required, confirmation_message,
validation_rules ? JSON.stringify(validation_rules) : null,
action_config ? JSON.stringify(action_config) : null,
sort_order, is_active,
req.user?.userId || "system",
req.user?.userId || "system"
]
);
return res.status(201).json({
success: true,
data: newButtonAction,
message: "버튼 액션이 성공적으로 생성되었습니다.",
});
} catch (error) {
console.error("버튼 액션 생성 오류:", error);
return res.status(500).json({
success: false,
message: "버튼 액션 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 버튼 액션 수정
static async updateButtonAction(req: AuthenticatedRequest, res: Response) {
try {
const { actionType } = req.params;
const {
action_name,
action_name_eng,
description,
category,
default_text,
default_text_eng,
default_icon,
default_color,
default_variant,
confirmation_required,
confirmation_message,
validation_rules,
action_config,
sort_order,
is_active,
} = req.body;
// 존재 여부 확인
const existingAction = await queryOne<any>(
"SELECT * FROM button_action_standards WHERE action_type = $1 LIMIT 1",
[actionType]
);
if (!existingAction) {
return res.status(404).json({
success: false,
message: "해당 버튼 액션을 찾을 수 없습니다.",
});
}
const updateFields: string[] = [];
const updateParams: any[] = [];
let paramIndex = 1;
if (action_name !== undefined) {
updateFields.push(`action_name = $${paramIndex}`);
updateParams.push(action_name);
paramIndex++;
}
if (action_name_eng !== undefined) {
updateFields.push(`action_name_eng = $${paramIndex}`);
updateParams.push(action_name_eng);
paramIndex++;
}
if (description !== undefined) {
updateFields.push(`description = $${paramIndex}`);
updateParams.push(description);
paramIndex++;
}
if (category !== undefined) {
updateFields.push(`category = $${paramIndex}`);
updateParams.push(category);
paramIndex++;
}
if (default_text !== undefined) {
updateFields.push(`default_text = $${paramIndex}`);
updateParams.push(default_text);
paramIndex++;
}
if (default_text_eng !== undefined) {
updateFields.push(`default_text_eng = $${paramIndex}`);
updateParams.push(default_text_eng);
paramIndex++;
}
if (default_icon !== undefined) {
updateFields.push(`default_icon = $${paramIndex}`);
updateParams.push(default_icon);
paramIndex++;
}
if (default_color !== undefined) {
updateFields.push(`default_color = $${paramIndex}`);
updateParams.push(default_color);
paramIndex++;
}
if (default_variant !== undefined) {
updateFields.push(`default_variant = $${paramIndex}`);
updateParams.push(default_variant);
paramIndex++;
}
if (confirmation_required !== undefined) {
updateFields.push(`confirmation_required = $${paramIndex}`);
updateParams.push(confirmation_required);
paramIndex++;
}
if (confirmation_message !== undefined) {
updateFields.push(`confirmation_message = $${paramIndex}`);
updateParams.push(confirmation_message);
paramIndex++;
}
if (validation_rules !== undefined) {
updateFields.push(`validation_rules = $${paramIndex}`);
updateParams.push(validation_rules ? JSON.stringify(validation_rules) : null);
paramIndex++;
}
if (action_config !== undefined) {
updateFields.push(`action_config = $${paramIndex}`);
updateParams.push(action_config ? JSON.stringify(action_config) : null);
paramIndex++;
}
if (sort_order !== undefined) {
updateFields.push(`sort_order = $${paramIndex}`);
updateParams.push(sort_order);
paramIndex++;
}
if (is_active !== undefined) {
updateFields.push(`is_active = $${paramIndex}`);
updateParams.push(is_active);
paramIndex++;
}
updateFields.push(`updated_by = $${paramIndex}`);
updateParams.push(req.user?.userId || "system");
paramIndex++;
updateFields.push(`updated_date = $${paramIndex}`);
updateParams.push(new Date());
paramIndex++;
updateParams.push(actionType);
const [updatedButtonAction] = await query<any>(
`UPDATE button_action_standards SET ${updateFields.join(", ")}
WHERE action_type = $${paramIndex} RETURNING *`,
updateParams
);
return res.json({
success: true,
data: updatedButtonAction,
message: "버튼 액션이 성공적으로 수정되었습니다.",
});
} catch (error) {
console.error("버튼 액션 수정 오류:", error);
return res.status(500).json({
success: false,
message: "버튼 액션 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 버튼 액션 삭제
static async deleteButtonAction(req: Request, res: Response) {
try {
const { actionType } = req.params;
// 존재 여부 확인
const existingAction = await queryOne<any>(
"SELECT * FROM button_action_standards WHERE action_type = $1 LIMIT 1",
[actionType]
);
if (!existingAction) {
return res.status(404).json({
success: false,
message: "해당 버튼 액션을 찾을 수 없습니다.",
});
}
await query<any>(
"DELETE FROM button_action_standards WHERE action_type = $1",
[actionType]
);
return res.json({
success: true,
message: "버튼 액션이 성공적으로 삭제되었습니다.",
});
} catch (error) {
console.error("버튼 액션 삭제 오류:", error);
return res.status(500).json({
success: false,
message: "버튼 액션 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 버튼 액션 정렬 순서 업데이트
static async updateButtonActionSortOrder(
req: AuthenticatedRequest,
res: Response
) {
try {
const { buttonActions } = req.body; // [{ action_type: 'save', sort_order: 1 }, ...]
if (!Array.isArray(buttonActions)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 데이터 형식입니다.",
});
}
// 트랜잭션으로 일괄 업데이트
await transaction(async (client) => {
for (const item of buttonActions) {
await client.query(
`UPDATE button_action_standards
SET sort_order = $1, updated_by = $2, updated_date = NOW()
WHERE action_type = $3`,
[item.sort_order, req.user?.userId || "system", item.action_type]
);
}
});
return res.json({
success: true,
message: "버튼 액션 정렬 순서가 성공적으로 업데이트되었습니다.",
});
} catch (error) {
console.error("버튼 액션 정렬 순서 업데이트 오류:", error);
return res.status(500).json({
success: false,
message: "정렬 순서 업데이트 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 버튼 액션 카테고리 목록 조회
static async getButtonActionCategories(req: Request, res: Response) {
try {
const categories = await query<{ category: string; count: string }>(
`SELECT category, COUNT(*) as count
FROM button_action_standards
WHERE is_active = $1
GROUP BY category`,
["Y"]
);
const categoryList = categories.map((item) => ({
category: item.category,
count: parseInt(item.count),
}));
return res.json({
success: true,
data: categoryList,
message: "버튼 액션 카테고리 목록을 성공적으로 조회했습니다.",
});
} catch (error) {
console.error("버튼 액션 카테고리 조회 오류:", error);
return res.status(500).json({
success: false,
message: "카테고리 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
@@ -1,803 +0,0 @@
/**
* 🔥
*
* API :
* 1.
* 2.
* 3.
*/
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import EventTriggerService from "../services/eventTriggerService";
import * as dataflowDiagramService from "../services/dataflowDiagramService";
import logger from "../utils/logger";
/**
* 🔥 ( )
*/
export async function getButtonDataflowConfig(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { buttonId } = req.params;
const companyCode = req.user?.companyCode;
if (!buttonId) {
res.status(400).json({
success: false,
message: "버튼 ID가 필요합니다.",
});
return;
}
// 버튼별 제어관리 설정 조회
// TODO: 실제 버튼 설정 테이블에서 조회
// 현재는 mock 데이터 반환
const mockConfig = {
controlMode: "simple",
selectedDiagramId: 1,
selectedRelationshipId: "rel-123",
executionOptions: {
rollbackOnError: true,
enableLogging: true,
asyncExecution: true,
},
};
res.json({
success: true,
data: mockConfig,
});
} catch (error) {
logger.error("Failed to get button dataflow config:", error);
res.status(500).json({
success: false,
message: "버튼 설정 조회 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥
*/
export async function updateButtonDataflowConfig(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { buttonId } = req.params;
const config = req.body;
const companyCode = req.user?.companyCode;
if (!buttonId) {
res.status(400).json({
success: false,
message: "버튼 ID가 필요합니다.",
});
return;
}
// TODO: 실제 버튼 설정 테이블에 저장
logger.info(`Button dataflow config updated: ${buttonId}`, config);
res.json({
success: true,
message: "버튼 설정이 업데이트되었습니다.",
});
} catch (error) {
logger.error("Failed to update button dataflow config:", error);
res.status(500).json({
success: false,
message: "버튼 설정 업데이트 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥
*/
export async function getAvailableDiagrams(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(400).json({
success: false,
message: "회사 코드가 필요합니다.",
});
return;
}
const diagramsResult = await dataflowDiagramService.getDataflowDiagrams(
companyCode,
1,
100
);
const diagrams = diagramsResult.diagrams;
res.json({
success: true,
data: diagrams,
});
} catch (error) {
logger.error("Failed to get available diagrams:", error);
res.status(500).json({
success: false,
message: "관계도 목록 조회 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥
*/
export async function getDiagramRelationships(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { diagramId } = req.params;
const companyCode = req.user?.companyCode;
if (!diagramId || !companyCode) {
res.status(400).json({
success: false,
message: "관계도 ID와 회사 코드가 필요합니다.",
});
return;
}
const diagram = await dataflowDiagramService.getDataflowDiagramById(
parseInt(diagramId),
companyCode
);
if (!diagram) {
res.status(404).json({
success: false,
message: "관계도를 찾을 수 없습니다.",
});
return;
}
const relationships = (diagram.relationships as any)?.relationships || [];
console.log("🔍 백엔드 - 관계도 데이터:", {
diagramId: diagram.diagram_id,
diagramName: diagram.diagram_name,
relationshipsRaw: diagram.relationships,
relationshipsArray: relationships,
relationshipsCount: relationships.length,
});
// 각 관계의 구조도 로깅
relationships.forEach((rel: any, index: number) => {
console.log(`🔍 백엔드 - 관계 ${index + 1}:`, rel);
});
res.json({
success: true,
data: relationships,
});
} catch (error) {
logger.error("Failed to get diagram relationships:", error);
res.status(500).json({
success: false,
message: "관계 목록 조회 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥
*/
export async function getRelationshipPreview(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { diagramId, relationshipId } = req.params;
const companyCode = req.user?.companyCode;
if (!diagramId || !relationshipId || !companyCode) {
res.status(400).json({
success: false,
message: "관계도 ID, 관계 ID, 회사 코드가 필요합니다.",
});
return;
}
const diagram = await dataflowDiagramService.getDataflowDiagramById(
parseInt(diagramId),
companyCode
);
if (!diagram) {
res.status(404).json({
success: false,
message: "관계도를 찾을 수 없습니다.",
});
return;
}
// 관계 정보 찾기
console.log("🔍 관계 미리보기 요청:", {
diagramId,
relationshipId,
diagramRelationships: diagram.relationships,
relationshipsArray: (diagram.relationships as any)?.relationships,
});
const relationships = (diagram.relationships as any)?.relationships || [];
console.log(
"🔍 사용 가능한 관계 목록:",
relationships.map((rel: any) => ({
id: rel.id,
name: rel.relationshipName || rel.name, // relationshipName 사용
sourceTable: rel.fromTable || rel.sourceTable, // fromTable 사용
targetTable: rel.toTable || rel.targetTable, // toTable 사용
originalData: rel, // 디버깅용
}))
);
const relationship = relationships.find(
(rel: any) => rel.id === relationshipId
);
console.log("🔍 찾은 관계:", relationship);
if (!relationship) {
console.log("❌ 관계를 찾을 수 없음:", {
requestedId: relationshipId,
availableIds: relationships.map((rel: any) => rel.id),
});
// 🔧 임시 해결책: 첫 번째 관계를 사용하거나 기본 응답 반환
if (relationships.length > 0) {
console.log("🔧 첫 번째 관계를 대신 사용:", relationships[0].id);
const fallbackRelationship = relationships[0];
console.log("🔍 fallback 관계 선택:", fallbackRelationship);
console.log("🔍 diagram.control 전체 구조:", diagram.control);
console.log("🔍 diagram.plan 전체 구조:", diagram.plan);
const fallbackControl = Array.isArray(diagram.control)
? diagram.control.find((c: any) => c.id === fallbackRelationship.id)
: null;
const fallbackPlan = Array.isArray(diagram.plan)
? diagram.plan.find((p: any) => p.id === fallbackRelationship.id)
: null;
console.log("🔍 찾은 fallback control:", fallbackControl);
console.log("🔍 찾은 fallback plan:", fallbackPlan);
const fallbackPreviewData = {
relationship: fallbackRelationship,
control: fallbackControl,
plan: fallbackPlan,
conditionsCount: (fallbackControl as any)?.conditions?.length || 0,
actionsCount: (fallbackPlan as any)?.actions?.length || 0,
};
console.log("🔍 최종 fallback 응답 데이터:", fallbackPreviewData);
res.json({
success: true,
data: fallbackPreviewData,
});
return;
}
res.status(404).json({
success: false,
message: `관계를 찾을 수 없습니다. 요청된 ID: ${relationshipId}, 사용 가능한 ID: ${relationships.map((rel: any) => rel.id).join(", ")}`,
});
return;
}
// 제어 및 계획 정보 추출
const control = Array.isArray(diagram.control)
? diagram.control.find((c: any) => c.id === relationshipId)
: null;
const plan = Array.isArray(diagram.plan)
? diagram.plan.find((p: any) => p.id === relationshipId)
: null;
const previewData = {
relationship,
control,
plan,
conditionsCount: (control as any)?.conditions?.length || 0,
actionsCount: (plan as any)?.actions?.length || 0,
};
res.json({
success: true,
data: previewData,
});
} catch (error) {
logger.error("Failed to get relationship preview:", error);
res.status(500).json({
success: false,
message: "관계 미리보기 조회 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥 ( )
*/
export async function executeOptimizedButton(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const {
buttonId,
actionType,
buttonConfig,
contextData,
timing = "after",
} = req.body;
const companyCode = req.user?.companyCode;
if (!buttonId || !actionType || !companyCode) {
res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
return;
}
const startTime = Date.now();
// 🔥 타이밍에 따른 즉시 응답 처리
if (timing === "after") {
// After: 기존 액션 즉시 실행 + 백그라운드 제어관리
const immediateResult = await executeOriginalAction(
actionType,
contextData
);
// 제어관리는 백그라운드에서 처리 (실제로는 큐에 추가)
const jobId = `job_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
// TODO: 실제 작업 큐에 추가
processDataflowInBackground(
jobId,
buttonConfig,
contextData,
companyCode,
"normal"
);
const responseTime = Date.now() - startTime;
logger.info(`Button executed (after): ${responseTime}ms`);
res.json({
success: true,
data: {
jobId,
immediateResult,
isBackground: true,
timing: "after",
responseTime,
},
});
} else if (timing === "before") {
// Before: 간단한 검증 후 기존 액션
const isSimpleValidation = checkIfSimpleValidation(buttonConfig);
if (isSimpleValidation) {
// 간단한 검증: 즉시 처리
const validationResult = await validateQuickly(
buttonConfig,
contextData
);
if (!validationResult.success) {
res.json({
success: true,
data: {
jobId: "validation_failed",
immediateResult: validationResult,
timing: "before",
},
});
return;
}
// 검증 통과 시 기존 액션 실행
const actionResult = await executeOriginalAction(
actionType,
contextData
);
const responseTime = Date.now() - startTime;
logger.info(`Button executed (before-simple): ${responseTime}ms`);
res.json({
success: true,
data: {
jobId: "immediate",
immediateResult: actionResult,
timing: "before",
responseTime,
},
});
} else {
// 복잡한 검증: 백그라운드 처리
const jobId = `job_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
// TODO: 실제 작업 큐에 추가 (높은 우선순위)
processDataflowInBackground(
jobId,
buttonConfig,
contextData,
companyCode,
"high"
);
res.json({
success: true,
data: {
jobId,
immediateResult: {
success: true,
message: "검증 중입니다. 잠시만 기다려주세요.",
processing: true,
},
isBackground: true,
timing: "before",
},
});
}
} else if (timing === "replace") {
// Replace: 제어관리만 실행
const isSimpleControl = checkIfSimpleControl(buttonConfig);
if (isSimpleControl) {
// 간단한 제어: 즉시 실행
const result = await executeSimpleDataflowAction(
buttonConfig,
contextData,
companyCode
);
const responseTime = Date.now() - startTime;
logger.info(`Button executed (replace-simple): ${responseTime}ms`);
res.json({
success: true,
data: {
jobId: "immediate",
immediateResult: result,
timing: "replace",
responseTime,
},
});
} else {
// 복잡한 제어: 백그라운드 실행
const jobId = `job_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
// TODO: 실제 작업 큐에 추가
processDataflowInBackground(
jobId,
buttonConfig,
contextData,
companyCode,
"normal"
);
res.json({
success: true,
data: {
jobId,
immediateResult: {
success: true,
message: "사용자 정의 작업을 처리 중입니다...",
processing: true,
},
isBackground: true,
timing: "replace",
},
});
}
}
} catch (error) {
logger.error("Failed to execute optimized button:", error);
res.status(500).json({
success: false,
message: "버튼 실행 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥
*/
export async function executeSimpleDataflow(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { config, contextData } = req.body;
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(400).json({
success: false,
message: "회사 코드가 필요합니다.",
});
return;
}
const result = await executeSimpleDataflowAction(
config,
contextData,
companyCode
);
res.json({
success: true,
data: result,
});
} catch (error) {
logger.error("Failed to execute simple dataflow:", error);
res.status(500).json({
success: false,
message: "간단한 제어관리 실행 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥
*/
export async function getJobStatus(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { jobId } = req.params;
// TODO: 실제 작업 큐에서 상태 조회
// 현재는 mock 응답
const mockStatus = {
status: "completed",
result: {
success: true,
executedActions: 2,
message: "백그라운드 처리가 완료되었습니다.",
},
progress: 100,
};
res.json({
success: true,
data: mockStatus,
});
} catch (error) {
logger.error("Failed to get job status:", error);
res.status(500).json({
success: false,
message: "작업 상태 조회 중 오류가 발생했습니다.",
});
}
}
// ============================================================================
// 🔥 헬퍼 함수들
// ============================================================================
/**
* (mock)
*/
async function executeOriginalAction(
actionType: string,
contextData: Record<string, any>
): Promise<any> {
// 간단한 지연 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 50));
return {
success: true,
message: `${actionType} 액션이 완료되었습니다.`,
actionType,
timestamp: new Date().toISOString(),
data: contextData,
};
}
/**
*
*/
function checkIfSimpleValidation(buttonConfig: any): boolean {
if (buttonConfig?.dataflowConfig?.controlMode !== "advanced") {
return true;
}
const conditions =
buttonConfig?.dataflowConfig?.directControl?.conditions || [];
return (
conditions.length <= 5 &&
conditions.every(
(c: any) =>
c.type === "condition" &&
["=", "!=", ">", "<", ">=", "<="].includes(c.operator || "")
)
);
}
/**
*
*/
function checkIfSimpleControl(buttonConfig: any): boolean {
if (buttonConfig?.dataflowConfig?.controlMode === "simple") {
return true;
}
const actions = buttonConfig?.dataflowConfig?.directControl?.actions || [];
const conditions =
buttonConfig?.dataflowConfig?.directControl?.conditions || [];
return actions.length <= 3 && conditions.length <= 5;
}
/**
*
*/
async function validateQuickly(
buttonConfig: any,
contextData: Record<string, any>
): Promise<any> {
// 간단한 mock 검증
await new Promise((resolve) => setTimeout(resolve, 10));
return {
success: true,
message: "검증이 완료되었습니다.",
};
}
/**
*
*/
async function executeSimpleDataflowAction(
config: any,
contextData: Record<string, any>,
companyCode: string
): Promise<any> {
try {
// 실제로는 EventTriggerService 사용
const result = await EventTriggerService.executeEventTriggers(
"insert", // TODO: 동적으로 결정
"test_table", // TODO: 설정에서 가져오기
contextData,
companyCode
);
return {
success: true,
executedActions: result.length,
message: `${result.length}개의 액션이 실행되었습니다.`,
results: result,
};
} catch (error) {
logger.error("Simple dataflow execution failed:", error);
throw error;
}
}
/**
* ()
*/
function processDataflowInBackground(
jobId: string,
buttonConfig: any,
contextData: Record<string, any>,
companyCode: string,
priority: string = "normal"
): void {
// 실제로는 작업 큐에 추가
// 여기서는 간단한 setTimeout으로 시뮬레이션
setTimeout(async () => {
try {
logger.info(`Background job started: ${jobId}`);
// 실제 제어관리 로직 실행
const result = await executeSimpleDataflowAction(
buttonConfig.dataflowConfig,
contextData,
companyCode
);
logger.info(`Background job completed: ${jobId}`, result);
// 실제로는 WebSocket이나 polling으로 클라이언트에 알림
} catch (error) {
logger.error(`Background job failed: ${jobId}`, error);
}
}, 1000); // 1초 후 실행 시뮬레이션
}
/**
* 🔥 ( )
*/
export async function getAllRelationships(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode || "*";
logger.info(`전체 관계 목록 조회 요청 - companyCode: ${companyCode}`);
// 모든 관계도에서 관계 목록을 가져옴
const allRelationships = await dataflowDiagramService.getAllRelationshipsForButtonControl(companyCode);
logger.info(`전체 관계 ${allRelationships.length}개 조회 완료`);
res.json({
success: true,
data: allRelationships,
message: `전체 관계 ${allRelationships.length}개 조회 완료`,
});
} catch (error) {
logger.error("전체 관계 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "전체 관계 목록 조회 실패",
errorCode: "GET_ALL_RELATIONSHIPS_ERROR",
});
}
}
/**
* (- )
*/
export async function getJoinRelationship(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { mainTable, detailTable } = req.params;
const companyCode = req.user?.companyCode || "*";
if (!mainTable || !detailTable) {
res.status(400).json({
success: false,
message: "메인 테이블과 디테일 테이블이 필요합니다.",
});
return;
}
// DataflowService에서 조인 관계 조회
const { DataflowService } = await import("../services/dataflowService");
const dataflowService = new DataflowService();
const result = await dataflowService.getJoinRelationshipBetweenTables(
mainTable,
detailTable,
companyCode
);
res.json({
success: true,
data: result,
});
} catch (error) {
logger.error("조인 관계 조회 실패:", error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "조인 관계 조회 실패",
});
}
}
@@ -1,606 +0,0 @@
/**
* (Auto-Fill)
*
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 자동 입력 그룹 CRUD
// =====================================================
/**
*
*/
export const getAutoFillGroups = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive } = req.query;
let sql = `
SELECT
g.*,
COUNT(m.mapping_id) as mapping_count
FROM cascading_auto_fill_group g
LEFT JOIN cascading_auto_fill_mapping m
ON g.group_code = m.group_code AND g.company_code = m.company_code
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 회사 필터
if (companyCode !== "*") {
sql += ` AND g.company_code = $${paramIndex++}`;
params.push(companyCode);
}
// 활성 상태 필터
if (isActive) {
sql += ` AND g.is_active = $${paramIndex++}`;
params.push(isActive);
}
sql += ` GROUP BY g.group_id ORDER BY g.group_name`;
const result = await query(sql, params);
logger.info("자동 입력 그룹 목록 조회", {
count: result.length,
companyCode,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("자동 입력 그룹 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 목록 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
* ( )
*/
export const getAutoFillGroupDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 그룹 정보 조회
let groupSql = `
SELECT * FROM cascading_auto_fill_group
WHERE group_code = $1
`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const groupResult = await queryOne(groupSql, groupParams);
if (!groupResult) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 매핑 정보 조회
const mappingSql = `
SELECT * FROM cascading_auto_fill_mapping
WHERE group_code = $1 AND company_code = $2
ORDER BY sort_order, mapping_id
`;
const mappingResult = await query(mappingSql, [
groupCode,
groupResult.company_code,
]);
logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode });
res.json({
success: true,
data: {
...groupResult,
mappings: mappingResult,
},
});
} catch (error: any) {
logger.error("자동 입력 그룹 상세 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 상세 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
const generateAutoFillGroupCode = async (
companyCode: string
): Promise<string> => {
const prefix = "AF";
const result = await queryOne(
`SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`,
[companyCode]
);
const count = parseInt(result?.cnt || "0", 10) + 1;
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
};
/**
*
*/
export const createAutoFillGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
masterTable,
masterValueColumn,
masterLabelColumn,
mappings = [],
} = req.body;
// 필수 필드 검증
if (!groupName || !masterTable || !masterValueColumn) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
});
}
// 그룹 코드 자동 생성
const groupCode = await generateAutoFillGroupCode(companyCode);
// 그룹 생성
const insertGroupSql = `
INSERT INTO cascading_auto_fill_group (
group_code, group_name, description,
master_table, master_value_column, master_label_column,
company_code, is_active, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const groupResult = await queryOne(insertGroupSql, [
groupCode,
groupName,
description || null,
masterTable,
masterValueColumn,
masterLabelColumn || null,
companyCode,
]);
// 매핑 생성
if (mappings.length > 0) {
for (let i = 0; i < mappings.length; i++) {
const m = mappings[i];
await query(
`INSERT INTO cascading_auto_fill_mapping (
group_code, company_code, source_column, target_field, target_label,
is_editable, is_required, default_value, sort_order
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
groupCode,
companyCode,
m.sourceColumn,
m.targetField,
m.targetLabel || null,
m.isEditable || "Y",
m.isRequired || "N",
m.defaultValue || null,
m.sortOrder || i + 1,
]
);
}
}
logger.info("자동 입력 그룹 생성", { groupCode, companyCode, userId });
res.status(201).json({
success: true,
message: "자동 입력 그룹이 생성되었습니다.",
data: groupResult,
});
} catch (error: any) {
logger.error("자동 입력 그룹 생성 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateAutoFillGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
masterTable,
masterValueColumn,
masterLabelColumn,
isActive,
mappings,
} = req.body;
// 기존 그룹 확인
let checkSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1`;
const checkParams: any[] = [groupCode];
if (companyCode !== "*") {
checkSql += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await queryOne(checkSql, checkParams);
if (!existing) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 그룹 업데이트
const updateSql = `
UPDATE cascading_auto_fill_group SET
group_name = COALESCE($1, group_name),
description = COALESCE($2, description),
master_table = COALESCE($3, master_table),
master_value_column = COALESCE($4, master_value_column),
master_label_column = COALESCE($5, master_label_column),
is_active = COALESCE($6, is_active),
updated_date = CURRENT_TIMESTAMP
WHERE group_code = $7 AND company_code = $8
RETURNING *
`;
const updateResult = await queryOne(updateSql, [
groupName,
description,
masterTable,
masterValueColumn,
masterLabelColumn,
isActive,
groupCode,
existing.company_code,
]);
// 매핑 업데이트 (전체 교체 방식)
if (mappings !== undefined) {
// 기존 매핑 삭제
await query(
`DELETE FROM cascading_auto_fill_mapping WHERE group_code = $1 AND company_code = $2`,
[groupCode, existing.company_code]
);
// 새 매핑 추가
for (let i = 0; i < mappings.length; i++) {
const m = mappings[i];
await query(
`INSERT INTO cascading_auto_fill_mapping (
group_code, company_code, source_column, target_field, target_label,
is_editable, is_required, default_value, sort_order
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
groupCode,
existing.company_code,
m.sourceColumn,
m.targetField,
m.targetLabel || null,
m.isEditable || "Y",
m.isRequired || "N",
m.defaultValue || null,
m.sortOrder || i + 1,
]
);
}
}
logger.info("자동 입력 그룹 수정", { groupCode, companyCode, userId });
res.json({
success: true,
message: "자동 입력 그룹이 수정되었습니다.",
data: updateResult,
});
} catch (error: any) {
logger.error("자동 입력 그룹 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteAutoFillGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
let deleteSql = `DELETE FROM cascading_auto_fill_group WHERE group_code = $1`;
const deleteParams: any[] = [groupCode];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING group_code`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
logger.info("자동 입력 그룹 삭제", { groupCode, companyCode, userId });
res.json({
success: true,
message: "자동 입력 그룹이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("자동 입력 그룹 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 자동 입력 데이터 조회 (실제 사용)
// =====================================================
/**
*
*
*/
export const getAutoFillMasterOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 그룹 정보 조회
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const group = await queryOne(groupSql, groupParams);
if (!group) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 마스터 테이블에서 옵션 조회
const labelColumn = group.master_label_column || group.master_value_column;
let optionsSql = `
SELECT
${group.master_value_column} as value,
${labelColumn} as label
FROM ${group.master_table}
WHERE 1=1
`;
const optionsParams: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터 (테이블에 company_code가 있는 경우)
if (companyCode !== "*") {
// company_code 컬럼 존재 여부 확인
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[group.master_table]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${paramIndex++}`;
optionsParams.push(companyCode);
}
}
optionsSql += ` ORDER BY ${labelColumn}`;
const optionsResult = await query(optionsSql, optionsParams);
logger.info("자동 입력 마스터 옵션 조회", {
groupCode,
count: optionsResult.length,
});
res.json({
success: true,
data: optionsResult,
});
} catch (error: any) {
logger.error("자동 입력 마스터 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 마스터 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*
*/
export const getAutoFillData = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const { masterValue } = req.query;
const companyCode = req.user?.companyCode || "*";
if (!masterValue) {
return res.status(400).json({
success: false,
message: "masterValue 파라미터가 필요합니다.",
});
}
// 그룹 정보 조회
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const group = await queryOne(groupSql, groupParams);
if (!group) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 매핑 정보 조회
const mappingSql = `
SELECT * FROM cascading_auto_fill_mapping
WHERE group_code = $1 AND company_code = $2
ORDER BY sort_order
`;
const mappings = await query(mappingSql, [groupCode, group.company_code]);
if (mappings.length === 0) {
return res.json({
success: true,
data: {},
mappings: [],
});
}
// 마스터 테이블에서 데이터 조회
const sourceColumns = mappings.map((m: any) => m.source_column).join(", ");
let dataSql = `
SELECT ${sourceColumns}
FROM ${group.master_table}
WHERE ${group.master_value_column} = $1
`;
const dataParams: any[] = [masterValue];
let paramIndex = 2;
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[group.master_table]
);
if (columnCheck) {
dataSql += ` AND company_code = $${paramIndex++}`;
dataParams.push(companyCode);
}
}
const dataResult = await queryOne(dataSql, dataParams);
// 결과를 target_field 기준으로 변환
const autoFillData: Record<string, any> = {};
const mappingInfo: any[] = [];
for (const mapping of mappings) {
const sourceValue = dataResult?.[mapping.source_column];
const finalValue =
sourceValue !== null && sourceValue !== undefined
? sourceValue
: mapping.default_value;
autoFillData[mapping.target_field] = finalValue;
mappingInfo.push({
targetField: mapping.target_field,
targetLabel: mapping.target_label,
value: finalValue,
isEditable: mapping.is_editable === "Y",
isRequired: mapping.is_required === "Y",
});
}
logger.info("자동 입력 데이터 조회", {
groupCode,
masterValue,
fieldCount: mappingInfo.length,
});
res.json({
success: true,
data: autoFillData,
mappings: mappingInfo,
});
} catch (error: any) {
logger.error("자동 입력 데이터 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 데이터 조회에 실패했습니다.",
error: error.message,
});
}
};
@@ -1,562 +0,0 @@
/**
* (Conditional Cascading)
*
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 조건부 연쇄 규칙 CRUD
// =====================================================
/**
*
*/
export const getConditions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive, relationCode, relationType } = req.query;
let sql = `
SELECT * FROM cascading_condition
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 회사 필터
if (companyCode !== "*") {
sql += ` AND company_code = $${paramIndex++}`;
params.push(companyCode);
}
// 활성 상태 필터
if (isActive) {
sql += ` AND is_active = $${paramIndex++}`;
params.push(isActive);
}
// 관계 코드 필터
if (relationCode) {
sql += ` AND relation_code = $${paramIndex++}`;
params.push(relationCode);
}
// 관계 유형 필터 (RELATION / HIERARCHY)
if (relationType) {
sql += ` AND relation_type = $${paramIndex++}`;
params.push(relationType);
}
sql += ` ORDER BY relation_code, priority, condition_name`;
const result = await query(sql, params);
logger.info("조건부 연쇄 규칙 목록 조회", {
count: result.length,
companyCode,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 목록 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const getConditionDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { conditionId } = req.params;
const companyCode = req.user?.companyCode || "*";
let sql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
const params: any[] = [Number(conditionId)];
if (companyCode !== "*") {
sql += ` AND company_code = $2`;
params.push(companyCode);
}
const result = await queryOne(sql, params);
if (!result) {
return res.status(404).json({
success: false,
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
});
}
logger.info("조건부 연쇄 규칙 상세 조회", { conditionId, companyCode });
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("조건부 연쇄 규칙 상세 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 상세 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const createCondition = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const {
relationType = "RELATION",
relationCode,
conditionName,
conditionField,
conditionOperator = "EQ",
conditionValue,
filterColumn,
filterValues,
priority = 0,
} = req.body;
// 필수 필드 검증
if (
!relationCode ||
!conditionName ||
!conditionField ||
!conditionValue ||
!filterColumn ||
!filterValues
) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
});
}
const insertSql = `
INSERT INTO cascading_condition (
relation_type, relation_code, condition_name,
condition_field, condition_operator, condition_value,
filter_column, filter_values, priority,
company_code, is_active, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const result = await queryOne(insertSql, [
relationType,
relationCode,
conditionName,
conditionField,
conditionOperator,
conditionValue,
filterColumn,
filterValues,
priority,
companyCode,
]);
logger.info("조건부 연쇄 규칙 생성", {
conditionId: result?.condition_id,
relationCode,
companyCode,
});
res.status(201).json({
success: true,
message: "조건부 연쇄 규칙이 생성되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("조건부 연쇄 규칙 생성 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateCondition = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { conditionId } = req.params;
const companyCode = req.user?.companyCode || "*";
const {
conditionName,
conditionField,
conditionOperator,
conditionValue,
filterColumn,
filterValues,
priority,
isActive,
} = req.body;
// 기존 규칙 확인
let checkSql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
const checkParams: any[] = [Number(conditionId)];
if (companyCode !== "*") {
checkSql += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await queryOne(checkSql, checkParams);
if (!existing) {
return res.status(404).json({
success: false,
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
});
}
const updateSql = `
UPDATE cascading_condition SET
condition_name = COALESCE($1, condition_name),
condition_field = COALESCE($2, condition_field),
condition_operator = COALESCE($3, condition_operator),
condition_value = COALESCE($4, condition_value),
filter_column = COALESCE($5, filter_column),
filter_values = COALESCE($6, filter_values),
priority = COALESCE($7, priority),
is_active = COALESCE($8, is_active),
updated_date = CURRENT_TIMESTAMP
WHERE condition_id = $9
RETURNING *
`;
const result = await queryOne(updateSql, [
conditionName,
conditionField,
conditionOperator,
conditionValue,
filterColumn,
filterValues,
priority,
isActive,
Number(conditionId),
]);
logger.info("조건부 연쇄 규칙 수정", { conditionId, companyCode });
res.json({
success: true,
message: "조건부 연쇄 규칙이 수정되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("조건부 연쇄 규칙 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteCondition = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { conditionId } = req.params;
const companyCode = req.user?.companyCode || "*";
let deleteSql = `DELETE FROM cascading_condition WHERE condition_id = $1`;
const deleteParams: any[] = [Number(conditionId)];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING condition_id`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
});
}
logger.info("조건부 연쇄 규칙 삭제", { conditionId, companyCode });
res.json({
success: true,
message: "조건부 연쇄 규칙이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("조건부 연쇄 규칙 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 조건부 필터링 적용 API (실제 사용)
// =====================================================
/**
*
*
*/
export const getFilteredOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { relationCode } = req.params;
const { conditionFieldValue, parentValue } = req.query;
const companyCode = req.user?.companyCode || "*";
// 1. 기본 연쇄 관계 정보 조회
let relationSql = `SELECT * FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y'`;
const relationParams: any[] = [relationCode];
if (companyCode !== "*") {
relationSql += ` AND company_code = $2`;
relationParams.push(companyCode);
}
const relation = await queryOne(relationSql, relationParams);
if (!relation) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
// 2. 해당 관계에 적용되는 조건 규칙 조회
let conditionSql = `
SELECT * FROM cascading_condition
WHERE relation_code = $1 AND is_active = 'Y'
`;
const conditionParams: any[] = [relationCode];
let conditionParamIndex = 2;
if (companyCode !== "*") {
conditionSql += ` AND company_code = $${conditionParamIndex++}`;
conditionParams.push(companyCode);
}
conditionSql += ` ORDER BY priority DESC`;
const conditions = await query(conditionSql, conditionParams);
// 3. 조건에 맞는 규칙 찾기
let matchedCondition: any = null;
if (conditionFieldValue) {
for (const cond of conditions) {
const isMatch = evaluateCondition(
conditionFieldValue as string,
cond.condition_operator,
cond.condition_value
);
if (isMatch) {
matchedCondition = cond;
break; // 우선순위가 높은 첫 번째 매칭 규칙 사용
}
}
}
// 4. 옵션 조회 쿼리 생성
let optionsSql = `
SELECT
${relation.child_value_column} as value,
${relation.child_label_column} as label
FROM ${relation.child_table}
WHERE 1=1
`;
const optionsParams: any[] = [];
let optionsParamIndex = 1;
// 부모 값 필터 (기본 연쇄)
if (parentValue) {
optionsSql += ` AND ${relation.child_filter_column} = $${optionsParamIndex++}`;
optionsParams.push(parentValue);
}
// 조건부 필터 적용
if (matchedCondition) {
const filterValues = matchedCondition.filter_values
.split(",")
.map((v: string) => v.trim());
const placeholders = filterValues
.map((_: any, i: number) => `$${optionsParamIndex + i}`)
.join(",");
optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`;
optionsParams.push(...filterValues);
optionsParamIndex += filterValues.length;
}
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[relation.child_table]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
optionsParams.push(companyCode);
}
}
// 정렬
if (relation.child_order_column) {
optionsSql += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
} else {
optionsSql += ` ORDER BY ${relation.child_label_column}`;
}
const optionsResult = await query(optionsSql, optionsParams);
logger.info("조건부 필터링 옵션 조회", {
relationCode,
conditionFieldValue,
parentValue,
matchedCondition: matchedCondition?.condition_name,
optionCount: optionsResult.length,
});
res.json({
success: true,
data: optionsResult,
appliedCondition: matchedCondition
? {
conditionId: matchedCondition.condition_id,
conditionName: matchedCondition.condition_name,
}
: null,
});
} catch (error: any) {
logger.error("조건부 필터링 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 필터링 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
function evaluateCondition(
actualValue: string,
operator: string,
expectedValue: string
): boolean {
const actual = actualValue.toLowerCase().trim();
const expected = expectedValue.toLowerCase().trim();
switch (operator.toUpperCase()) {
case "EQ":
case "=":
case "EQUALS":
return actual === expected;
case "NEQ":
case "!=":
case "<>":
case "NOT_EQUALS":
return actual !== expected;
case "CONTAINS":
case "LIKE":
return actual.includes(expected);
case "NOT_CONTAINS":
case "NOT_LIKE":
return !actual.includes(expected);
case "STARTS_WITH":
return actual.startsWith(expected);
case "ENDS_WITH":
return actual.endsWith(expected);
case "IN":
const inValues = expected.split(",").map((v) => v.trim());
return inValues.includes(actual);
case "NOT_IN":
const notInValues = expected.split(",").map((v) => v.trim());
return !notInValues.includes(actual);
case "GT":
case ">":
return parseFloat(actual) > parseFloat(expected);
case "GTE":
case ">=":
return parseFloat(actual) >= parseFloat(expected);
case "LT":
case "<":
return parseFloat(actual) < parseFloat(expected);
case "LTE":
case "<=":
return parseFloat(actual) <= parseFloat(expected);
case "IS_NULL":
case "NULL":
return actual === "" || actual === "null" || actual === "undefined";
case "IS_NOT_NULL":
case "NOT_NULL":
return actual !== "" && actual !== "null" && actual !== "undefined";
default:
logger.warn(`알 수 없는 연산자: ${operator}`);
return false;
}
}

Some files were not shown because too many files have changed in this diff Show More