feat(v2.0): Phase A 아키텍처 + WISE 개발문서 + PM/PMO + DB 계층 + 프론트 착수

- 거버넌스: kintex-pm·dev-pm·pmo 에이전트
- Phase A 아키텍처 5종: docs/architecture/{app,system,tech,data,network}.md (성문화·NFR·SRID0 공간표준·M16 스타스키마·보안영역)
- WISE(UIWS) 참조 개발문서 6종: README·DEVELOPMENT_GUIDE·ENV_SETUP·COMMON_CODES·API_GUIDE·BUILD_DEPLOY
- DB 계층: PostGIS 스키마 V1~V6(홀·트렌치 가정그리드·GiST) + MyBatis 매퍼 5종(ST_* 공간쿼리) + Flyway. gradlew build SUCCESS
- 프론트 착수: React/Vite 스캐폴드 + design.md 토큰 + SCR-01 로그인·SCR-03 부스 에디터. tsc/vite build EXIT 0
- R-T1 수정: 나노바나나 워커 큐키 kintex:renderjob:queue 통일(백엔드 정합, silent no-op 방지)
- 도메인/게이트: G2 해소(dev kintex.zioinfo.kr·prod kintex.wise.ai.kr), CLAUDE.md 로스터 20종

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-11 18:10:01 +09:00
parent 9987d958db
commit eccbeb1337
74 changed files with 9189 additions and 3 deletions

View File

@ -0,0 +1,18 @@
---
name: kintex-dev-pm
description: 킨텍스 자동전시시스템 개발 PM. 개발 트랙의 WBS·개발 일정·스프린트·진척(번다운)·개발 리스크·에이전트 팀 조율·품질 게이트(QA 통과)를 관리할 때 사용한다. 다시 실행·업데이트·보완 포함.
tools: Read, Write, Edit, Glob, Grep
model: opus
---
당신은 킨텍스 자동전시시스템의 개발 PM이다. **개발 실행의 계획·추적·조율**을 책임진다(총괄은 kintex-pm, 실행 조율은 kintex-impl-orchestrator).
## 책임
- **개발 WBS**: IMPLEMENTATION_BACKLOG v2.0(Phase A~E·M2~M18·공통레이어)을 작업분해구조로 정련 — 작업·담당 에이전트·의존·산정·우선순위.
- **일정·스프린트**: Phase별 스프린트 계획·개발 일정·마일스톤 정렬(공통레이어 선행 §5B 준수).
- **진척 추적**: 에이전트 팀 산출물(`_workspace/`·`src/`) 기준 진척·번다운·병목 식별.
- **개발 리스크·품질 게이트**: 기술 리스크·경계면 이슈, kintex-qa 통과를 완료 기준으로 관리.
## 산출 / 협업
- `docs/pm/` 또는 `_workspace/`에 개발 WBS·스프린트 계획·진척 보고. kintex-pm(총괄)·kintex-pmo(표준)·오케스트레이터(실행)와 연계.
- 구현은 하지 않는다 — 개발 관리·조율. 재호출: WBS·진척 갱신.

View File

@ -0,0 +1,18 @@
---
name: kintex-pm
description: 킨텍스 자동전시시스템 프로젝트 관리자(총괄 PM). 전체 프로젝트의 범위·일정·마일스톤·리스크·이해관계자·의사소통·보고를 총괄 관리하고, 선행 게이트(G1 나노바나나 승인·G2 배포서버)와 Phase 진행을 통제할 때 사용한다. 다시 실행·업데이트·보완 포함.
tools: Read, Write, Edit, Glob, Grep
model: opus
---
당신은 킨텍스 자동전시시스템의 총괄 프로젝트 관리자(PM)다. 개별 구현이 아니라 **프로젝트 전체의 성공(범위·일정·품질·리스크)** 을 책임진다.
## 책임
- **프로젝트 관리 계획서**: 범위(PLANNING v2.0 M2~M18+공통레이어)·마스터 일정(Phase A~E)·마일스톤·게이트(G1 Gemini 승인·G2 배포서버) 관리.
- **리스크·이슈 관리**: 리스크 대장(PLANNING §10 R1~R12 연계)·이슈 트래킹·완화책·에스컬레이션.
- **이해관계자·의사소통**: 소유자 승인 게이트 관리(외부 API·배포), 진척·의사결정 보고.
- **거버넌스**: kintex-impl-orchestrator(실행)·kintex-dev-pm(개발)·kintex-pmo(관리표준)와 연계 — PM은 계획·통제, 오케스트레이터는 실행.
## 산출 / 협업
- `docs/pm/` 또는 `_workspace/`에 프로젝트 관리 계획서·마스터 일정·리스크/이슈 대장·진척 보고. PLANNING(planner)·아키텍처(Phase A)·백로그와 정합.
- 구현은 하지 않는다 — 계획·통제·보고. 재호출: 기존 관리 산출물 갱신(진척·리스크 반영).

View File

@ -0,0 +1,20 @@
---
name: kintex-pmo
description: 킨텍스 자동전시시스템 PMO(Project Management Office). 프로젝트 관리 표준·방법론, 산출물 목록/형상관리, 품질보증(QA 거버넌스), 변경관리, 통합 진척 보고를 관리할 때 사용한다. 다시 실행·업데이트·보완 포함.
tools: Read, Write, Edit, Glob, Grep
model: opus
---
당신은 킨텍스 자동전시시스템의 PMO다. **프로젝트 관리 표준·품질·산출물 거버넌스**를 책임진다.
## 책임
- **관리 표준·방법론**: WBS·산정·검토 절차·회의체·보고 양식 표준. PM/개발PM이 이 표준을 사용.
- **산출물 목록(Deliverables Register)**: 정보화사업 표준 산출물(기획·설계·개발·시험·이관 단계별) 목록화·상태 추적 — PLANNING·design·architecture·backlog·API 계약·매뉴얼 등 매핑.
- **형상관리(SCM)**: git 저장소(zio/kintex)·버전·브랜치·릴리즈 형상관리 계획, 문서 버전 일관성.
- **품질보증(QA 거버넌스)**: kintex-qa 활동 표준·품질 게이트·결함 관리 정책. 보안 불변(자격증명·PII·워터마크·RBAC) 준수 감사.
- **변경관리**: 범위·요구 변경 대장(이번 세션처럼 요구 확장 시)·영향분석·승인 절차.
- **통합 진척 보고**: PM/개발PM 데이터 취합 → 경영진·소유자 보고.
## 산출 / 협업
- `docs/pmo/` 또는 `_workspace/`에 산출물 목록·형상관리 계획·품질보증 계획·변경관리 대장·통합 진척 보고. kintex-pm·kintex-dev-pm·kintex-qa와 연계.
- 구현은 하지 않는다 — 표준·거버넌스·감사. 재호출: 표준·대장·보고 갱신.

View File

@ -69,9 +69,9 @@ GUARDiA 표준 프레임워크(Spring Boot 3.5 + React 19 + MyBatis + PostgreSQL
**트리거:** 킨텍스 구현·부스 배치/설계/배선/시각화·옥션/입찰/견적서·관람객/등록/배지/리드·경영분석/BI·CMS/공개 홍보 사이트·관리자 백오피스·공통기능/시스템관리/2FA·UIWS/WISE 이식·역할별 웹/모바일·AI(Claude)·아키텍처(AA/SA/TA/DA/NA)·src 구현·배포·다시 실행·특정 모듈만 요청 시 `kintex-impl-orchestrator` 스킬을 사용하라.
**에이전트(전문 17 + 범용):** 아키텍트 kintex-aa·sa·ta·da·na / 공통 kintex-common-dev / 코어 kintex-backend-dev·frontend-dev·db-engineer / 도메인 kintex-bidding-dev·visitor-dev·cms-dev·bi-dev·admin-dev / AI·시각화 kintex-ai-dev·visualizer / 품질·배포 kintex-qa·devops-dev + planner·designer·reviewer
**에이전트(전문 20 + 범용):** 거버넌스 kintex-pm·dev-pm·pmo / 아키텍트 kintex-aa·sa·ta·da·na / 공통 kintex-common-dev / 코어 kintex-backend-dev·frontend-dev·db-engineer / 도메인 kintex-bidding-dev·visitor-dev·cms-dev·bi-dev·admin-dev / AI·시각화 kintex-ai-dev·visualizer / 품질·배포 kintex-qa·devops-dev + planner·designer·reviewer
**선행 게이트:** G1 나노바나나(Gemini) 외부 호출 승인(PLANNING R12) · G2 배포 대상 서버·포트(GUARDiA 인프라와 별개 도메인).
**선행 게이트:** **G2 해소** — 개발 `kintex.zioinfo.kr`(등록 완료 → 서버 101.79.17.164, GUARDiA 공유 인프라 재사용) / 운영 `kintex.wise.ai.kr`. nginx vhost·포트는 devops 배정. **잔여: G1** 나노바나나(Gemini) 외부 호출 승인(PLANNING R12) — M5 실호출 전.
**확보 자산:** `docs/assets/floorplans/` — 홀별 평면도 JPG 15장 + CAD(제1전시장 "평면,트렌치.dwg" 포함) → PLANNING R4(트렌치·CAD) 해소. 평면도 입력 포맷 = **CAD(DWG) + JPG**. CAD zip은 gitignore(로컬 보존).

View File

@ -0,0 +1,58 @@
# 킨텍스 — DB 스키마·매퍼 계약 (Phase C / B-0 DB)
> 작성: kintex-db-engineer · 근거: PLANNING §7(ERD·마스터)·§8(공간 일원화), `_workspace/01_backend_contracts.md` §8 매퍼 인수 목록
> 스택: PostgreSQL + PostGIS · Flyway 순번 마이그레이션 · MyBatis 매퍼 XML
> **대조 대상**: kintex-backend-dev(서비스 조립)·kintex-qa. 본 문서가 컬럼·타입·파라미터 계약의 단일 출처.
## 1. 마이그레이션 (권위 = Flyway, `src/backend/src/main/resources/db/migration/`)
| 순번 | 파일 | 내용 |
|---|---|---|
| V1 | `V1__enable_extensions.sql` | postgis · pgcrypto extension |
| V2 | `V2__identity_and_master.sql` | app_user · company · event · hall · hall_assignment · event_member · booth_standard · master_data |
| V3 | `V3__spatial_core.sql` | trench(POINT) · hall_exit(POINT) · layout · booth(POLYGON) · design_plan(jsonb) · utility_order(MULTILINESTRING) · render_job + GiST 인덱스 |
| V4 | `V4__seed_hall_master.sql` | 홀 11행(H1~H10·OUTDOOR) 멱등 + 비상구 가정 4개소/홀 |
| V5 | `V5__seed_trench_grid.sql` | 트렌치 **가정 그리드**(6m 격자, is_assumed=true) |
| V6 | `V6__seed_master_and_demo.sql` | 부스 표준·요율/요금/규정 참조·등록업체 샘플·데모 행사(e-2026-smf→H7) |
- SRID **0** = 홀 로컬 평면 좌표계(미터). `ST_Area`→㎡, `ST_Distance`/`ST_Length`→m.
- 시드는 전부 멱등(ON CONFLICT). 재호출 시 **V7+ 순번으로 이어 추가**(기존 파괴 금지).
- `spring.flyway.baseline-on-migrate=true`. `FLYWAY_ENABLED=false`로 비활성 가능.
## 2. 보안 (계약 §0-3 유지)
- `app_user.password_hash`(BCrypt)·`otp_secret`(암호화)는 **API 응답 절대 제외**. `findAuthByEmail`만 인증 검증용으로 반환, 그 외 조회는 select 금지.
- 시크릿 하드코딩 없음. 데모 시드에 로그인 사용자 미포함(사용자·비번은 B-1 인증 트랙 admin env 재시드).
## 3. 매퍼 파라미터·반환 계약 (백엔드 조립 시 준수)
### UserMapper
- `findAuthByEmail(email)``{userId, displayName, passwordHash, hallManager}` | null
- `findEventRoles(userId)``[{eventId, roleCode, eventName, startDate, endDate, hallLabel, dday}]` (워크스페이스 카드 + eventRoles 동시 조립 가능)
### BoothMapper (M2 · PostGIS)
- `findLayout(eventId,hallId,version?)``{layoutId, version, name, status, updatedAt}` (version=null→최신)
- `findBooths(layoutId)``[{boothId, boothNo, type, polygonJson(GeoJSON), sizeW, sizeD, heightM, floorLoadTPerM2, assignedCompanyName, premium}]`
- `upsertLayout(params)` → layoutId. **params**: `{layoutId?, eventId, hallId, version, name, status}`
- `replaceBooths(layoutId, booths)`. **booths[i]**: `{boothId?, boothNo, type, wkt('POLYGON((x y,...))'), sizeW, sizeD, heightM, floorLoad, assignedCompanyName, premium}` (단일 CTE로 삭제→일괄 삽입, booths 비어있지 않아야 함)
- `sumSalesArea(layoutId)`→Double㎡ · `minAisleWidth(layoutId)`→Double m(10m내 인접쌍 최소이격) · `countExitsBlocked(layoutId,hallId)`→int
- 규정검증 metrics 키(01 계약 §3): `layout.min_aisle_width_m`, `layout.exits_blocked_count`, 부스별 `booth.height_m`/`booth.floor_load` 집계.
### DesignMapper (M3)
- `findDesign(boothId,version?)``{designId, boothId, version, status, specJson(문자열), updatedAt}` — 백엔드가 specJson→DesignSpec 역직렬화
- `upsertDesign(params)` → designId. **params**: `{designId?, boothId, version, status, specJson(JSON 문자열)}`
- `findEventIdByBooth(boothId)` → eventId (RBAC)
### WiringMapper (M4 · PostGIS)
- `findNearestTrenches(hallId,boothId,kind)``[{trenchId, x, y, distanceM, assumed}]` (KNN `<->` 상위 5)
- `shortestPath(hallId,fromX,fromY,kind)``{lineJson(GeoJSON LineString), lengthM, assumed}` — 백엔드가 lineJson→coords 파싱
- `isAssumedTrench(hallId)` → Boolean (WiringResult.assumedTrench 원천)
- kind: `power|network|plumbing|water|air|gas` → 트렌치 supply_* 컬럼 매핑.
### RenderJobMapper (M5)
- `insertJob(params)`. **params**: `{jobId, eventId, boothId, shotPreset, status, imageUrl?, schemaHash?, modelVersion?, errorMessage?}` (ON CONFLICT DO NOTHING)
- `updateStatus(jobId,status,imageUrl,errorMessage)` · `findByBooth(boothId,shotPreset?)`→갤러리 목록 · `countSucceededByEvent(eventId)`→int(status='DONE', 쿼터 정본)
## 4. 남은 TODO (경계·후속)
- **서비스 조립(backend-dev)**: 5개 ServiceImpl이 아직 `notImplemented` 반환. 위 계약대로 매퍼 호출 배선 시 M2~M5·인증 501 해소.
- **CAD 트렌치 실측 추출**: 현재 트렌치·비상구는 **가정 그리드**(is_assumed=true). `docs/assets/floorplans/cad/kintex1_cad_all_halls.zip`의 "평면,트렌치.dwg" 좌표 추출 → V7+ 마이그레이션으로 교체, hall.is_assumed_trench=false 갱신.
- booth 교체(replaceBooths)가 design_plan을 CASCADE 삭제 — 부스 ID 안정화(버전 간 유지)는 backend 배치 저장 로직에서 관리 필요.

View File

@ -0,0 +1,32 @@
# 킨텍스 프론트엔드 — 진행 기록 (kintex-frontend-dev)
> 작성: kintex-frontend-dev · 2026-07-11 · 근거: `docs/design.md` v1.1 · `_workspace/01_backend_contracts.md` v0.1 · Stitch(SCR-01·SCR-03)
> 범위: IMPLEMENTATION_BACKLOG B-0(프론트 스캐폴드) + C-C 일부(SCR-01) + C-M2 일부(SCR-03)
## 완료
- **스캐폴드**: `src/frontend` (React 18 + Vite 5 + TS). 디자인 토큰(design.md §1) → `src/styles/tokens.css` CSS 변수 단일 출처. 라우팅(react-router, 인증 가드), API 클라이언트(JWT 헤더·ApiResponse 봉투·오류코드), WebSocket(STOMP/SockJS, `/topic/render/{jobId}` 구독) 골격.
- **SCR-01** 로그인/워크스페이스 선택: 좌 55% 브랜드 패널 + 우 45% 로그인 카드 → 로그인 후 워크스페이스 카드 그리드. `login`·`workspaces`·`accept-invite` 배선. **미등록 장치업체 초대 차단**(`NOT_REGISTERED_COMPANY` → 차단 모달).
- **SCR-03** 부스 배치 에디터: 3열(좌 도구·중앙 SVG 다크캔버스·우 속성/검증) + 상단 툴바. **SVG 캔버스**(부스 폴리곤·트렌치 그리드·축척·선택·줌). `GET/validate/auto-generate` 배선. **AI 자동배치 1·2·3안 생성→선택/병합** 다이얼로그(S7 조감 RenderJob WebSocket 구독).
- **검증**: `tsc -b` EXIT 0, `vite build` EXIT 0(238 modules, rollup win32 크래시 없음). WCAG AA — 포커스 링·role/aria·switch·reduced-motion.
## 경계면 이슈 (백엔드/계약 대조 — kintex-qa/backend 확인 요청)
1. **[중] 위반 오버레이 좌표 부재** — `ComplianceReport.violations[]``pin`·`label`·`measured`는 있으나 **도면 하이라이트 위치(좌표/boothId)가 없다**. design.md §1-4·SCR-03은 "도면 위 같은 색 하이라이트 + 번호 핀으로 목록과 1:1 연결"을 요구. 현재 위반 목록의 "위치 보기"는 캔버스 하이라이트로 연결 불가. **요청: Violation에 `polygon`/`boothId`/`anchor[x,y]` 중 하나 추가**(pin 번호와 캔버스 오버레이 매핑용).
2. **[중] 홀 치수·트렌치 좌표 계약 부재** — 캔버스가 홀 크기(126×90m)와 트렌치 그리드를 하드코딩/가정 중. `LayoutDto`에 홀 dims·트렌치 좌표가 없다. 계약 §6 render의 `scene.hall.dims_m`엔 있음. **요청: `GET .../layout` 응답에 `hall:{dims_m, trenches?}` 포함** 또는 별도 `GET .../halls/{hallId}` 홀 마스터 엔드포인트(트렌치·기둥·비상구 포함).
3. **[하] auto-generate → 편집 반영 경로 불명** — `AutoLayoutOption``summary`+`s7RenderJobId`만 반환, **선택 안의 부스 배열이 없다**. "이 안으로 편집 시작"이 선택 안을 layout에 병합하려면 옵션의 booths 또는 서버측 확정 엔드포인트가 필요. 현재는 재조회(refetch)로 임시 처리. **요청: auto-generate 옵션에 booths 포함 또는 `POST .../layout/apply-option {optionId}`**.
4. **[하] 홀 매니저 SSO·이메일 인증 로그인** — SCR-01의 "킨텍스 내부 계정 로그인" 버튼은 계약에 대응 엔드포인트 없음(login 단일). 현재 UI만 배치, 미배선. Phase B-1(2FA/SSO) 시 배선 예정.
5. **[정보] degraded 폴백** — `GET layout`·`login`·`accept-invite`가 현재 **501(NOT_IMPLEMENTED)**. 프론트는 501을 감지해 로그인은 안내 메시지, 에디터는 **샘플 배치(`sampleLayout.ts`, 계약 shape 동일)** 로 캔버스 시연. 매퍼 구현 시 코드 변경 없이 실데이터로 대체됨.
## 계약 준수 확인
- 응답 봉투 `ApiResponse<T>`·`PageResponse<T>`·오류코드→분기(FORBIDDEN·COMPLIANCE_BLOCKED·NOT_REGISTERED_COMPANY·NOT_IMPLEMENTED) 그대로 구현.
- AI 생성 이미지는 `watermarkRequired`/`watermarkText`/`notice`를 **항상 노출**(제거 불가 `AiImage` 컴포넌트). 계약 §0-3 준수.
- 민감정보(IP·SSH·해시) 미표시. 사용자/업체는 이름·역할만.
## 남은 화면 (미착수)
- 웹: SCR-02 주최자 대시보드 · SCR-04 배치안 비교(S7) · SCR-05 참가업체 홈 · SCR-06 설계 스튜디오(4샷+Before/After) · SCR-07 배선 뷰 · SCR-08 신청 요약 · SCR-09 규정 리포트 · SCR-10 승인 큐 · SCR-11 검수 상세 · SCR-12 갤러리
- 모바일(390px): SCR-M1 현장 체크리스트 · SCR-M2 현장 검수
- 공통 후속: 재사용 컴포넌트(Before/After CompareSlider — reroomai 패턴, 배선 오버레이 렌더러, 데이터 테이블/지브라), 실 캔버스 인터랙션(부스 드래그·리사이즈·병합), WebGL 대규모 렌더 검토.

97
docs/API_GUIDE.md Normal file
View File

@ -0,0 +1,97 @@
# 킨텍스 자동전시시스템 — API 규약 가이드
> **WISE(UIWS) 참조** — 응답 봉투(`ApiResponse`/`PageResponse`)·인증(JWT+2FA)·에러 처리 규약은 `workspace/uiws/backend/README.md`를 따른다.
> **정본 계약서**: 엔드포인트 상세·요청/응답 shape·DB 매퍼 인수는 [`_workspace/01_backend_contracts.md`](../_workspace/01_backend_contracts.md)가 단일 진실원천이다. 본 문서는 **규약(convention)만 요약**하고 세부는 계약서로 링크한다(중복 서술 회피).
---
## 1. 경로·버전 규약
- 베이스: `/api`. 프론트 axios `baseURL=/api`(동일 도메인 서빙, 별도 CORS 불필요).
- 도메인 경로는 **행사 스코프** 접두: `/api/events/{eventId}/…` (예 `…/halls/{hallId}/layout`, `…/booths/{boothId}/design`).
- 시스템/관리 경로: `/api/system/**`·`/api/admin/**`(ADMIN 전용). 인증: `/api/auth/**`.
- 내부(워커) 경로: `/api/internal/**`(공유 시크릿 인증).
- **버전**: P0는 무접두(`/api/...`). 파괴적 변경 발생 시 `/api/v2/...` 도입 — 계약서 변경 이력에 기록하고 frontend·qa에 통지.
---
## 2. 응답 봉투
모든 응답은 `ApiResponse<T>`:
```json
{ "success": true, "data": { ... }, "error": null }
{ "success": false, "data": null, "error": { "code": "FORBIDDEN", "message": "이 행사/부스에 대한 권한이 없습니다." } }
```
- 목록: `PageResponse<T>` = `{ "items": [...], "page": 0, "size": 20, "total": 123 }`. (P0 갤러리/워크스페이스는 배열 직접 반환도 허용 — 계약서 §0-1.)
- `error.message`는 사람이 읽을 **요약만**. 상세·스택트레이스 미노출(서버 로그).
---
## 3. 오류 코드 → HTTP (고정)
| code | HTTP | 의미 |
|---|---|---|
| `VALIDATION` | 400 | 요청 값 오류(필드 메시지 포함) |
| `UNAUTHORIZED` | 401 | 미인증/토큰 만료 |
| `FORBIDDEN` | 403 | 행사/부스 권한 없음 |
| `NOT_REGISTERED_COMPANY` | 403 | 미등록 장치업체 초대·응찰 차단 |
| `NOT_FOUND` | 404 | 대상 없음 |
| `CONFLICT` | 409 | 상태 충돌(낙관적 잠금 등) |
| `COMPLIANCE_BLOCKED` | 422 | 규정 위반(차단) |
| `RENDER_QUOTA_EXCEEDED` | 429 | 행사 이미지 생성 쿼터 소진 |
| `NOT_IMPLEMENTED` | 501 | 매퍼/엔진 구현 대기(스켈레톤) |
| `INTERNAL` | 500 | 서버 오류(요약만) |
> 코드는 문자열 상수(`common.exception.ErrorCode`). 신규 코드 추가 시 계약서 §0-2와 본 표를 동시 갱신.
---
## 4. 인증 헤더 · RBAC
- 헤더: `Authorization: Bearer <JWT>` (HS256). 클레임: `sub`(userId)·`name`·`roles`(eventId→역할)·`hm`(홀매니저).
- 공개 경로(인증 불필요): `GET /health`, `POST /api/auth/login`, `/ws/**`, `POST /api/internal/render/callback`(워커 토큰).
- **2차 인증**: `POST /api/auth/login`(1차) → `verifyToken``POST /api/auth/verify-otp`(EMAIL 코드/OTP) → access·refresh. (WISE `auth` 이식 — [`DEVELOPMENT_GUIDE.md`](DEVELOPMENT_GUIDE.md) §4.)
- **행사 단위 RBAC**: 역할 `ORGANIZER·EXHIBITOR·CONTRACTOR·HALL_MANAGER`([`COMMON_CODES.md`](COMMON_CODES.md) `EVENT_ROLE`). 가드 — 열람=행사 멤버 or 홀매니저 / 편집·액션=엔드포인트별 역할.
---
## 5. 보안 불변 (API 계약 강제)
- 민감정보(IP·SSH·비밀번호·해시·내부 식별자·`GEMINI_API_KEY`) 응답 완전 제외. 사용자/업체 표시는 비민감 필드만.
- **AI 생성 이미지**(M5)는 응답에 `watermarkRequired:true`+`watermarkText`+`notice`(계약·심사 서류 사용 금지) **항상** 포함.
- 워커 실패 시 `errorMessage`는 요약만 통과(스택트레이스 유입 차단).
- 상세: [`DEVELOPMENT_GUIDE.md`](DEVELOPMENT_GUIDE.md) §5.
---
## 6. 비동기·실시간 (Redis + WebSocket)
- **RenderJob**: `POST …/render`(발행) → Redis 큐(`kintex:renderjob:queue`) → Python 워커 소비 → `POST /api/internal/render/callback`(콜백) → 상태 갱신.
- **WebSocket(STOMP)**: 핸드셰이크 `GET /ws`(SockJS), 브로드캐스트 prefix `/topic`, 클라→서버 `/app`. 구독 `/topic/render/{jobId}` → RenderJob 완료/실패 푸시. (승인 이벤트 토픽은 M6/C-4 확장.)
---
## 7. 엔드포인트 카탈로그 (요약 — 상세는 계약서)
> 각 항목의 요청/응답 shape·완성/스켈레톤(501) 현황은 [`_workspace/01_backend_contracts.md`](../_workspace/01_backend_contracts.md) 해당 절 참조.
| 영역 | 대표 경로 | 계약서 절 |
|------|-----------|-----------|
| 헬스 | `GET /health` | §1 |
| 인증·워크스페이스 | `/api/auth/login·workspaces·me·accept-invite` | §2 |
| M2 플로어플랜 | `/api/events/{eventId}/halls/{hallId}/layout` (`GET·PUT·validate·auto-generate`) | §3 |
| M3 부스 설계 | `/api/events/{eventId}/booths/{boothId}/design` (`GET·PUT·precheck`) | §4 |
| M4 유틸리티/배선 | `/api/events/{eventId}/booths/{boothId}/utility` (`quote·wiring·order·GET`) | §5 |
| M5 나노바나나 | `/api/events/{eventId}/booths/{boothId}/render` · `/render-jobs/{jobId}` · `/api/internal/render/callback` | §6 |
| 룰셋 | `rulesets/compliance-v1.json`·`rates-v1.json` (데이터 계약) | §7 |
| DB 매퍼 인수 | UserMapper·BoothMapper·DesignMapper·WiringMapper·RenderJobMapper (PostGIS) | §8 |
> Phase D 도메인(M10 관람객·M12 공개사이트/CMS·M15 옥션·M16 BI·M18 관리자)의 API는 각 도메인 에이전트가 계약서에 절을 추가하며 확장한다. 본 가이드의 §1~6 규약을 동일 준수.
---
## 8. 참조
- 정본 계약서: [`_workspace/01_backend_contracts.md`](../_workspace/01_backend_contracts.md)
- 나노바나나 워커 계약: [`../tools/nanobanana/_workspace/01_worker_contract.md`](../tools/nanobanana/_workspace/01_worker_contract.md)
- 개발 표준: [`DEVELOPMENT_GUIDE.md`](DEVELOPMENT_GUIDE.md) · 공통코드: [`COMMON_CODES.md`](COMMON_CODES.md)

87
docs/BUILD_DEPLOY.md Normal file
View File

@ -0,0 +1,87 @@
# 킨텍스 자동전시시스템 — 빌드/실행 개요
> **WISE(UIWS) 참조** — 빌드·배포 흐름(프론트 vite build → 백엔드 번들 / systemd / webhook 자동배포)은 `workspace/uiws/deploy/README_배포.md`·`DEV_HANDOFF.md`를 따른다.
> 본 문서는 **빌드/실행 개요**만 다룬다. 상세 CI/CD 파이프라인(Gitea webhook·deploy_server·systemd·nginx·롤백)은 **Phase E `kintex-devops-dev`** 산출물이 정본이다. 운영 서버·포트는 **G2 게이트**(GUARDiA 인프라와 별개 도메인) 확정 후.
---
## 1. 산출물 구성
킨텍스는 **3개 실행 단위**로 구성된다:
| 단위 | 빌드 | 산출물 | 실행 |
|------|------|--------|------|
| 백엔드 | `./gradlew bootJar` (JDK17) | `build/libs/kintex-*.jar` | `java -jar`(systemd 권장) |
| 프론트(웹) | `npm run build` (Node 18+, Vite) | `dist/`(역할별 번들) | nginx 정적 서빙(SPA 폴백) |
| 나노바나나 워커 | (Python, 빌드 없음) | `tools/nanobanana` 모듈 | 큐 소비 데몬(별도 서비스) |
> 프론트 dist를 백엔드 static에 번들하는 단일 jar 패키징(GUARDiA 표준)도 가능하나, 킨텍스는 **역할별 프론트 번들 분리**(PLANNING §2-1)이므로 nginx 정적 서빙 + 백엔드 API 분리를 기본으로 한다(Phase A SA가 배포 토폴로지 확정).
---
## 2. 로컬 빌드/실행
환경변수·설치는 [`ENV_SETUP.md`](ENV_SETUP.md) 참조.
```bash
# 백엔드
cd src/backend
./gradlew build # 컴파일+테스트
./gradlew bootRun # 개발 실행 (또는 java -jar build/libs/kintex-*.jar)
# 프론트
cd ../frontend
npm install
npm run dev # 개발 서버(프록시 → /api)
npm run build # dist/ 생성
# 나노바나나 워커 (G1 승인 후 실호출)
cd ../../tools/nanobanana
pip install google-genai Pillow
python -m tools.nanobanana.worker
```
---
## 3. 빌드 게이트 (push 전 — WISE 관행)
WISE `.githooks/pre-push` 패턴을 준용해 파이프라인을 보호한다:
1. 시크릿 파일 커밋 차단(`.env`·`*.key`·`*-adminsdk-*.json` — gitignore 유지)
2. Flyway 마이그레이션 번호 충돌 검사(신규 = 최대+1)
3. 변경분 백엔드 `compileJava` / 프론트·워커 `tsc`·lint — **실패 시 push 차단**
4. 자동배포 경고(파이프라인 연결 시 push=배포 트리거)
---
## 4. 배포 흐름 (개요 — 상세는 Phase E)
GUARDiA 표준 배포 파이프라인 준용:
```
workspace/kintex ── git push ──> Gitea(zio/kintex) ── webhook ──> deploy_server
└─> 빌드(gradlew bootJar · vite build) → Flyway 마이그 → jar 재기동 · dist 배포 → 헬스 게이트(GET /health)
```
- **Fail-Safe**: 백업 → 배포 → 헬스체크(200) → 실패 시 롤백(이전 jar 유지). 깨진 jar가 서버를 죽이지 않도록 clean bootJar 검증 후 교체(WISE 배포 자기방어 패턴).
- **systemd**: 백엔드 jar·워커 데몬을 유닛으로 등록(부팅 자동기동·재시작). AI env drop-in(`ANTHROPIC_API_KEY`·`ADMIN_PASSWORD_ENC`)은 표준 프레임워크 §7 방식.
- **nginx**: `<kintex-domain>` vhost → `/`=프론트 정적, `/api/`·`/ws`=백엔드 포트. TLS는 certbot. (도메인·포트 = G2 확정.)
- **운영 배포는 소유자 승인 필수.**
---
## 5. 선행 게이트
| 게이트 | 내용 | 영향 |
|--------|------|------|
| **G1** | 나노바나나(Gemini) 외부 호출 승인(PLANNING R12) | 워커 실이미지 생성·M5 배포. 미승인 시 목/degraded |
| **G2** | 배포 대상 서버·포트(별개 도메인) | Phase E 배포 착수 전 |
---
## 6. 참조
- 환경 구축: [`ENV_SETUP.md`](ENV_SETUP.md) · 개발 표준: [`DEVELOPMENT_GUIDE.md`](DEVELOPMENT_GUIDE.md)
- 구현 백로그(Phase E 배포): [`IMPLEMENTATION_BACKLOG.md`](IMPLEMENTATION_BACKLOG.md)
- WISE 배포 원본(참조): `workspace/uiws/deploy/README_배포.md`·`workspace/uiws/DEV_HANDOFF.md`
- 표준 프레임워크 배포: `workspace/_framework/GUARDIA_STANDARD_FRAMEWORK.md` §7

74
docs/COMMON_CODES.md Normal file
View File

@ -0,0 +1,74 @@
# 킨텍스 자동전시시스템 — 공통코드 정의
> **WISE(UIWS) 참조** — 공통코드 체계(그룹 `TB_CODE_GRP` / 값 `TB_CODE`, 코드값=영문 상수·코드명=한글 표기)는 `workspace/uiws/_workspace/01_analyst_codes.md`를 따른다.
> **확정 규칙**: **확정**=PLANNING/계약서에 값 명시 / **확인 필요**=Phase A(DA)·도메인 에이전트 확정 대기. 미정 코드값은 임의 확정 금지 — 확정 시 본 문서 + ERD 컬럼 주석 동시 갱신.
> 근거: [`PLANNING.md`](PLANNING.md)·[`design.md`](design.md)·[`_workspace/01_backend_contracts.md`](../_workspace/01_backend_contracts.md).
---
## 1. 공통코드 관리 원칙 (WISE 체계 이식)
- 적재: `TB_CODE_GRP`(그룹) / `TB_CODE`(값). 코드값은 **영문 상수**, 코드명은 화면 표기 **한글**.
- 시스템관리(B-2)의 공통코드 관리 화면에서 CRUD. DTO 필드 ↔ 코드그룹 매핑은 §4 표를 단일 출처로 준수.
- **코드 vs 마스터 구분**: 열거 가능한 소수 값은 공통코드, 다건·CRUD 대상(홀·요율·규정 룰셋·등록업체)은 **마스터 테이블**로 관리(공통코드 아님).
---
## 2. 킨텍스 도메인 공통코드
| 그룹코드 | 그룹명 | 코드값 목록 (코드값=코드명) | 사용처 | 확정여부 |
|---|---|---|---|---|
| `EVENT_ROLE` | 행사 역할(RBAC) | `ORGANIZER`=주최자, `EXHIBITOR`=참가업체, `CONTRACTOR`=장치·시공업체, `HALL_MANAGER`=홀매니저(킨텍스 운영) | 계약서 §0-5 RBAC, JWT `roles`, 역할별 포털 | **확정** (계약서 §0-5) |
| `PORTAL_ROLE` | 포털/채널 역할(6분리) | `ORGANIZER`, `EXHIBITOR`, `CONTRACTOR`, `OPS`=킨텍스 직원/홀매니저, `ADMIN`=시스템관리자, `VISITOR`=관람객, `PUBLIC`=일반 대중 | PLANNING §2-1 역할별 웹/모바일 포털 분리 | **확정** (PLANNING §2-1) — VISITOR/PUBLIC은 P1·공개·셀프서비스 쓰기 제한 |
| `BOOTH_TYPE` | 부스 유형 | `independent`=독립부스, `assembled`=조립부스 | M2/M3 설계(계약서 `boothType`), 부스 마스터 | **확정** (PLANNING §5 M3: 조립/독립 · 계약서 design `boothType:"independent"`) |
| `ZONE_TYPE` | 부스 구역 유형 | `demo`=시연, `consult`=상담, `storage`=창고, `reception`=접수 (확장 가능) | M3 DesignSpec `zones[].type` | 부분 확정 (계약서에 demo·consult 명시, 그 외 **확인 필요** — designer/DA) |
| `LAYOUT_STATUS` | 배치안 상태 | `draft`=작성중, `submitted`=제출, `approved`=승인, `rejected`=반려 | M2 LayoutDto `status` | 부분 확정 (계약서 `draft` 명시, 전이 상태는 M6 승인 워크플로 **확인 필요**) |
| `DESIGN_STATUS` | 설계안 상태 | `draft`=작성중, `submitted`=제출, `approved`=승인, `rejected`=반려 | M3 DesignPlanDto `status` | 부분 확정 (계약서 `draft` 명시, 나머지 **확인 필요**) |
| `COMPLIANCE_SEVERITY` | 규정 심각도 | `block`=차단, `warn`=경고, `pass`=통과 | M2/M3 ComplianceReport `violations[].severity` | **확정** (계약서 §3·§4: block/warn + passCount) |
| `COMPLIANCE_GROUP` | 규정 그룹 | `egress`=피난/비상, `structure`=구조/하중, `height`=높이, `fire`=방염, `lighting`=조명 (룰셋 기준) | 규정 룰셋(`compliance-v1.json`), ComplianceReport `group` | 부분 확정 (계약서 `egress` 명시 — 전체 그룹은 룰셋 데이터가 정본, **확인 필요**) |
| `RENDER_STATUS` | 렌더잡 상태 | `QUEUED`=대기, `RUNNING`=진행, `DONE`=완료, `FAILED`=실패 | M5 RenderJobDto `status` | **확정** (계약서 §6) |
| `SHOT_PRESET` | 표준 샷 세트 | `S1`=정면 주간, `S2`=정면 야간, `S3`=통로 뷰, `S4`=내부 뷰, `S5`=Before/After, `S6`=배선 오버레이(래스터), `S7`=홀 전경 조감 | M5 RenderJobRequest `shotPreset` | **확정** (PLANNING §6-3 · 워커 README) |
| `UTILITY_ORDER_STATUS` | 유틸리티 신청 상태 | `draft`=작성중, `submitted`=제출, `relayed`=릴레이완료 | M4 UtilityOrderDto `status` | 부분 확정 (계약서 `submitted` 명시, 나머지 **확인 필요**) |
| `AUCTION_STATUS` | 옥션 상태 | `OPEN`=응찰중, `BIDDING`=라운드진행, `AWARDED`=낙찰, `CLOSED`=마감 (예시) | M15 공사/장치 옥션(Auction) | **확인 필요** (Phase D bidding-dev/DA 확정 — 계약 미정의) |
| `QUOTATION_STATUS` | 견적서 상태 | `SUBMITTED`=제출, `REVISED`=수정, `AWARDED`=낙찰, `REJECTED`=탈락 (예시) | M15 Quotation | **확인 필요** (Phase D bidding-dev/DA 확정) |
| `USE_YN` | 사용여부 | `Y`=사용, `N`=미사용 | 전 관리화면 공통 | **확정** (시스템 공통) |
> 위 상태·옥션·구역 코드 중 **확인 필요** 항목은 예시 제안값이다. Phase A(DA)·해당 도메인 에이전트가 화면/워크플로 확정 시 값을 고정하고 본 문서·ERD를 동시 갱신한다.
---
## 3. WISE 공통 레이어 코드 (이식 대상)
공통 업무·시스템관리 레이어(B-2/B-3)를 WISE에서 이식할 때 아래 코드도 함께 이식한다(값은 WISE `01_analyst_codes.md` 정본):
| 그룹코드 | 그룹명 | 요지 |
|---|---|---|
| `USER_ROLE` | 시스템 사용자 역할 | `USER`/`MANAGER`/`ADMIN` — 데이터 가시범위·권한 단일 소스(본인/팀/전체). 킨텍스는 `EVENT_ROLE`(행사 스코프)와 병행 운용 |
| `VERIFY_METHOD` | 2차검증 방식 | `EMAIL`=이메일 인증코드, `OTP`=OTP앱(TOTP). 사용자별 선택 |
| `PRG_TYPE` | 프로그램 유형 | `FORM`/`POPUP` — 메뉴/프로그램 관리 |
| `MSG_RCV_TYPE` | 쪽지 수신구분 | `RECV`/`REF` — 공통 message 모듈 이식 시 |
| (기타) | worklog·schedule·stats 코드 | worklog·schedule·통계 모듈 이식 시 WISE 코드(WORK_STATUS·WORK_TYPE·SCHE_GUBUN·IMPORTANCE·WORK_PROGRESS 등) 동반 이식 |
> 킨텍스는 **행사 단위 역할(`EVENT_ROLE`)이 1차 권한 소스**다. WISE `USER_ROLE`(전역 가시범위)은 공통 업무 레이어(worklog 등)를 이식할 때만 병행 적용한다.
---
## 4. DTO 필드 ↔ 코드그룹 매핑 요약
| DTO 필드 | 코드그룹 | 비고 |
|---|---|---|
| `myRole` / `eventRoles` | EVENT_ROLE | 행사별 역할(계약서 login·me) |
| `boothType` | BOOTH_TYPE | M2/M3 |
| `zones[].type` | ZONE_TYPE | M3 DesignSpec |
| `status`(layout) | LAYOUT_STATUS | M2 |
| `status`(design) | DESIGN_STATUS | M3 |
| `violations[].severity` | COMPLIANCE_SEVERITY | M2/M3 규정 리포트 |
| `violations[].group` | COMPLIANCE_GROUP | 룰셋 데이터 기준 |
| `status`(render) | RENDER_STATUS | M5 |
| `shotPreset` | SHOT_PRESET | M5 |
| `status`(utility order) | UTILITY_ORDER_STATUS | M4 |
| `status`(auction) | AUCTION_STATUS | M15 (확인 필요) |
| `verifyMethod` | VERIFY_METHOD | 2차 인증 |
| `useYn` | USE_YN | 공통 |
> 비-코드(마스터 테이블): 홀(`Hall`)·요율 룰셋(`rates-v1.json`)·규정 룰셋(`compliance-v1.json`)·등록업체(`Company`)는 공통코드가 아니라 마스터/버전 파일로 관리(관리자 백오피스 M18에서 CRUD·버전).

130
docs/DEVELOPMENT_GUIDE.md Normal file
View File

@ -0,0 +1,130 @@
# 킨텍스 자동전시시스템 — 개발 표준 가이드
> **WISE(UIWS) 참조**`workspace/uiws`(GUARDiA 표준 프레임워크 정본)의 백엔드/인증/보안 컨벤션을 킨텍스 스택(MyBatis·PostGIS·Redis·나노바나나 워커)에 맞춰 정리했다.
> 정본 링크: 아키텍처 표준은 Phase A `docs/architecture/*`(kintex-aa/sa/ta), API 계약은 [`_workspace/01_backend_contracts.md`](../_workspace/01_backend_contracts.md), 프로젝트 규칙은 [`CLAUDE.md`](../CLAUDE.md).
---
## 1. 기술 스택 (확정 — 변경 금지)
[`CLAUDE.md`](../CLAUDE.md) §기술 스택이 정본. 요약:
| 레이어 | 표준 |
|--------|------|
| 프론트(웹) | React 18/19 + Vite + TypeScript (역할별 번들 분리 — PLANNING §2-1) |
| 백엔드 | Spring Boot 3.x(Java 17) + MyBatis — REST + WebSocket(STOMP) |
| DB | PostgreSQL + **PostGIS**(부스 polygon·트렌치 point·배선 LineString) |
| 비동기 | Redis 작업 큐(RenderJob·서류·알림) |
| 이미지 생성 | 나노바나나 Python 워커 사이드카(`tools/nanobanana`, google-genai) |
| AI(텍스트) | Claude 기본 + 설정형 전환(`AiTextRouter`/`AiConfig`) — 실패 시 Ollama 폴백 |
| 인증 | 행사 단위 RBAC(JWT HS256) + 2차 인증(OTP/TOTP) |
- 패키지 루트: **`com.zioinfo.kintex`** · DB: **`kintex_db`**
- 신규 코드는 이 스택만 사용. 나노바나나 호출은 `tools/nanobanana` Python 워커만 경유(백엔드는 큐 발행·상태·콜백까지만, `GEMINI_API_KEY` 미취급).
---
## 2. 패키지·레이어 구조 (백엔드)
WISE 계층(`controller·service·repository·domain·dto`)을 MyBatis로 매핑한다. `com.zioinfo.kintex` 하위:
```
com.zioinfo.kintex
├── config # SecurityConfig, JwtProperties, WebSocketConfig, RedisConfig, MyBatis @MapperScan(annotationClass=Mapper.class)
├── security # JwtTokenProvider, JwtAuthenticationFilter, KintexPrincipal, RestAuthEntryPoint
├── common # response(ApiResponse·PageResponse), exception(ApiException·ErrorCode·GlobalExceptionHandler), audit(AOP)
├── auth # controller / service(AuthService·TotpService) / mapper / dto
├── module
│ ├── m2 # 플로어플랜: controller·service·mapper(BoothMapper, PostGIS ST_*)·dto·engine(ComplianceRuleEngine)
│ ├── m3 # 부스 설계: DesignMapper·precheck
│ ├── m4 # 유틸리티/배선: WiringMapper·요율 룰
│ ├── m5 # 나노바나나 RenderJob: 큐 발행·콜백·WebSocket 푸시
│ └── … # M10·M12·M15·M16·M18 등 (Phase D)
├── system # 시스템관리(사용자·역할/권한·공통코드·메뉴·감사로그·설정) — WISE 이식(B-2)
└── work # 공통 업무기능(worklog·schedule·message·stats·notice…) — WISE 이식(B-3)
```
- **레이어 규칙**: `controller`(요청 검증·RBAC 진입) → `service`(트랜잭션·룰·엔진) → `mapper`(MyBatis XML, 공간 쿼리 `ST_*`). 컨트롤러는 도메인 로직 금지, 매퍼는 비즈니스 판단 금지.
- **매퍼**: `@Mapper` 인터페이스 + `resources/mybatis/mapper/*.xml`. PostGIS 연산(`ST_MakePolygon`·`ST_Area`·`ST_Distance`·`<->` KNN)은 XML에.
- **룰셋은 코드가 아닌 데이터**: 규정(`rulesets/compliance-v1.json`)·요율(`rulesets/rates-v1.json`)은 버전 파일. 개정 시 파일 교체, 리포트에 `rulesetVersion`·`disclaimer` 항상 기록.
### 프론트(웹) 구조
WISE 컨벤션 `pages/components/api/store/hooks/routes`. 역할별 포털(organizer·exhibitor·contractor·ops·admin·public+visitor)은 번들 분리(PLANNING §2-1)하되 공유 디자인 시스템·공통 컴포넌트·API 계약을 상속한다. axios `baseURL=/api`.
---
## 3. API·응답 규약
상세는 [`API_GUIDE.md`](API_GUIDE.md) 및 계약서 [`_workspace/01_backend_contracts.md`](../_workspace/01_backend_contracts.md). 핵심:
- 응답 봉투 `ApiResponse<T>` = `{ success, data, error }`, 목록 `PageResponse<T>` = `{ items, page, size, total }`.
- 오류 코드(문자열)→HTTP 매핑 고정(`VALIDATION`400·`FORBIDDEN`403·`COMPLIANCE_BLOCKED`422·`NOT_IMPLEMENTED`501 …).
- 모든 도메인 경로는 `{eventId}` 스코프 + 행사 단위 RBAC 가드.
---
## 4. 인증 표준 (JWT + 2FA/OTP) — WISE 이식
GUARDiA 표준 프레임워크 §2 + WISE `auth` 모듈을 이식한다(백로그 B-1).
- **1차 로그인**(ID/PW) → `verifyToken` 발급 → **2차 검증**(EMAIL 인증코드 또는 OTP/TOTP) → access·refresh 토큰.
- **OTP**: TOTP RFC6238(SHA1·30초·6자리·±1윈도). `TotpService` 이식. 최초 QR 등록, 마이페이지 재설정/해제, **관리자 OTP 초기화**(`otp_secret=NULL`). 사용자별 `VERIFY_METHOD`(EMAIL/OTP)로 분기.
- **RBAC**: JWT 클레임 `roles`(eventId→역할)·`hm`(홀매니저). `/api/system/**`·`/api/admin/**` = `hasRole(ADMIN)`. 데이터 가시범위(역할 스코프)는 WISE `DataScopeService` 패턴 참조.
- **로그인 실패 잠금** + 관리자 해제.
- **admin 비밀번호**: env `ADMIN_PASSWORD_ENC`(AES-256-GCM) + 별도 키파일 복호 → 기동 시 BCrypt 재시드. **`admin123` 하드코딩 시드 금지.**
---
## 5. 보안 불변 (계약 강제 — 위반 시 QA 반려)
| 규칙 | 내용 |
|------|------|
| 자격증명 미노출 | IP·SSH·비밀번호·해시·`GEMINI_API_KEY`·`ANTHROPIC_API_KEY`·OTP 시크릿을 응답·로그·에러메시지·커밋에 절대 노출 금지 |
| 민감 필드 제외 | 사용자/업체 응답은 이름·역할·번호 등 비민감 필드만. 내부 식별자·해시 shape 제외 |
| 스택트레이스 차단 | `error.message`는 사람이 읽을 요약만. 상세는 서버 로그. `GlobalExceptionHandler`·`DataAccessException` 핸들러로 누출 차단 |
| AI 이미지 워터마크 | 나노바나나 산출 이미지 응답은 `watermarkRequired:true`+`watermarkText`+`notice`(계약·심사 서류 사용 금지) **항상** 포함 — 제거 불가 |
| 등록업체 응찰 | 장치업체(CONTRACTOR)는 킨텍스 등록업체 검증 통과분만 초대·응찰(`NOT_REGISTERED_COMPANY` 403) |
| 외부 API 금지 | 온프레미스 우선. 예외: `api.anthropic.com`(Claude, 키 env only·실패 시 Ollama 폴백) + `Gemini`(나노바나나, G1 승인 대상·워커 전용) |
| 암호화 저장 | 비밀·자격증명 AES-256-GCM. 비밀번호는 BCrypt 해시 |
---
## 6. 코딩 규약
- **언어**: 문서·주석·커밋 본문 설명은 한국어 허용, **코드 식별자·커밋 제목·PR 제목은 영어**.
- **네이밍**: Java `camelCase`/`PascalCase`, DB 컬럼 `SNAKE_CASE`(WISE와 동일 — 예 `WRITER_ID`·`START_HOUR`), DTO 필드 `camelCase`.
- **DTO ↔ 코드그룹 매핑**은 [`COMMON_CODES.md`](COMMON_CODES.md) 표를 단일 출처로 준수(`boothType`·`myRole`·`severity` 등).
- **널/기본값**: 상태·역할 등 NOT NULL 기본값은 코드 문서 기준(예 역할 기본 `USER`/부스 상태 기본 `draft`).
- **프론트**: TypeScript strict. API 응답 타입은 계약서 shape과 1:1. 임의 `any` 지양.
- **DB 마이그레이션**: `kintex_db`**Flyway 순번 마이그레이션**(`V__` / 번호 규약, 백로그 B-0). 스키마가 단일 진실원천 — 엔티티/매퍼는 이를 따른다. 마이그 번호 충돌 금지(신규는 최대 번호+1).
---
## 7. 브랜치·커밋·PR
WISE 파이프라인 보호(`.githooks/pre-push`) 관행을 준용한다.
- **브랜치**: `main`(정본) 보호. 기능은 `feat/<module>-<요약>`, 수정은 `fix/<요약>`. 아키텍처/공통은 Phase 라벨(예 `phaseB/auth-otp`).
- **커밋 메시지**: **Conventional Commits**`feat(m2): 플로어플랜 규정검증 API`, `fix(auth): OTP 윈도우 경계 처리`, `docs(codes): 옥션 상태 코드 추가`. 타입: `feat·fix·docs·refactor·test·chore·build·ci`.
- **push 전 게이트(권장)**: 변경분 백엔드 `compileJava` / 프론트·워커 `tsc`·lint 통과 → 실패 시 push 금지. 시크릿 파일 커밋 차단(`.env`·`*.key`·`*-firebase-adminsdk-*.json` 등은 gitignore 유지).
- **PR 규칙**: 대상 Phase/모듈 명시 · 계약서(경계면) 변경 시 frontend·db·qa 영향 기재 · 보안 불변 체크(§5) · 관련 QA 통과 링크. 아키텍처 표준(Phase A) 위반은 시정 후 병합.
- **커밋/푸시 시점**: 사용자/오케스트레이터 지시가 있을 때만. 운영 배포는 소유자 승인 필수.
---
## 8. 테스트
- **백엔드**: 서비스·룰 엔진 단위 테스트(규정 평가·요율 산식·배선 최단경로). 공간 쿼리는 PostGIS 통합 테스트(testcontainers 또는 로컬 PostGIS).
- **경계면(계약) 검증**: `kintex-qa`가 API 응답 shape ↔ 프론트 훅/컴포넌트 호출을 교차 대조(계약서 단일 출처). 각 모듈 완성 직후 점진 검증.
- **보안 회귀**: 자격증명·PII·스택트레이스 미노출, AI 워터마크 강제, 등록업체 응찰 가드, admin env 시드를 QA 반려 사유로 상시 점검.
- **워커**: 나노바나나 모듈은 키/네트워크 없이도 import·구조 성립(목/degraded). 쿼터는 성공 시에만 차감.
---
## 9. 참조
- 프로젝트 규칙·에이전트 워크플로: [`CLAUDE.md`](../CLAUDE.md)
- 환경 구축: [`ENV_SETUP.md`](ENV_SETUP.md) · 빌드/배포: [`BUILD_DEPLOY.md`](BUILD_DEPLOY.md)
- API 규약: [`API_GUIDE.md`](API_GUIDE.md) · 공통코드: [`COMMON_CODES.md`](COMMON_CODES.md)
- 백엔드 계약서(정본): [`_workspace/01_backend_contracts.md`](../_workspace/01_backend_contracts.md)
- 표준 프레임워크: `workspace/_framework/GUARDIA_STANDARD_FRAMEWORK.md`

127
docs/ENV_SETUP.md Normal file
View File

@ -0,0 +1,127 @@
# 킨텍스 자동전시시스템 — 개발환경 구축 가이드
> **WISE(UIWS) 참조**`workspace/uiws``backend/README.md`·`db/README_DB연동.md`·`DEV_HANDOFF.md` 세팅 절차를 킨텍스 스택(MyBatis·PostGIS·Redis·나노바나나 Python 워커)에 맞춰 정리했다.
> ⚠️ 본 문서의 환경변수 **이름은 표준 컨벤션**(신규 코드가 준수할 규약)이며, 실제 비밀값·서버 포트는 저장소에 두지 않는다(Phase A SA/DEV 및 사내 비밀관리에서 확정).
---
## 1. 사전 요구 도구
| 도구 | 버전 | 용도 |
|------|------|------|
| **JDK 17** | 17.x (LTS) | Spring Boot 3.x 백엔드 빌드/실행(`gradlew`) |
| **Node.js** | 18+ (LTS) | Vite 빌드(Node 16은 vite build 불가 — WISE 함정) |
| **npm** | Node 동봉 | 프론트 의존성 |
| **PostgreSQL** | 15+/16 | `kintex_db` |
| **PostGIS** | 3.x | 공간 확장(부스 polygon·트렌치 point·배선 LineString) |
| **Redis** | 6+/7 | 작업 큐(RenderJob·서류·알림) |
| **Python** | 3.11+ | 나노바나나 워커 사이드카 |
| **Git** | 2.x | Gitea(zio/kintex) |
> Gradle Wrapper(`gradlew`)가 없으면 로컬 Gradle 8.x로 `gradle wrapper --gradle-version 8.7` 1회 실행해 생성(WISE 관행).
---
## 2. 초기 세팅
```bash
git clone <gitea>/zio/kintex.git
cd kintex
# 백엔드 (JDK17)
cd src/backend && ./gradlew build # Phase B-0 스캐폴드 이후
# 프론트 (Node 18+)
cd ../frontend && npm install
# 나노바나나 Python 워커
cd ../../tools/nanobanana
pip install google-genai Pillow
```
---
## 3. PostgreSQL + PostGIS (`kintex_db`)
WISE와 동일하게 **공유 PostgreSQL 인스턴스에 전용 DB + 전용 계정**을 두어 타 솔루션과 물리 분리한다.
```sql
-- 관리자(postgres/sudo) 권한으로 실행
CREATE ROLE kintex LOGIN PASSWORD '<개발용-임시-변경대상>'
NOSUPERUSER NOCREATEDB NOCREATEROLE;
CREATE DATABASE kintex_db OWNER kintex ENCODING 'UTF8';
-- kintex_db 접속 후 PostGIS 활성화
\c kintex_db
CREATE EXTENSION IF NOT EXISTS postgis;
```
- 콜레이션은 서버 인스턴스 컨벤션에 맞춤(WISE는 `en_US.UTF-8`; UTF8이라 한글 저장/조회 정상).
- 스키마/시드는 **Flyway 순번 마이그레이션**(백로그 B-0)으로 적용. `ddl` 수동 검증이 필요하면 `SET ROLE kintex;` 후 마이그 SQL 실행.
- **원격 DB 접속(개발 PC)**: 5432가 외부 차단이면 SSH 로컬 포워딩 —
```bash
ssh -L 5432:localhost:5432 <shell계정>@<db-host> -N
# 앱은 jdbc:postgresql://localhost:5432/kintex_db 로 접속
```
---
## 4. Redis
로컬 기본 포트 6379. 큐 키 예: `kintex:renderjob:queue`(백엔드 발행 → Python 워커 소비). 개발 중 워커 미가동 시 큐잉·상태는 동작(발행까지).
---
## 5. 환경변수 목록 (표준 컨벤션)
> 비밀값은 **하드코딩 금지** — 모두 환경변수/`application.yml` 프로퍼티로 주입. `.env`·`*.key`는 gitignore.
### 5-1. 백엔드(Spring Boot)
| 변수 | 필수 | 설명 |
|------|------|------|
| `KINTEX_DB_PASSWORD` | **필수** | PostgreSQL `kintex` 계정 비밀번호 |
| `KINTEX_JWT_SECRET` | 권장 | JWT HMAC(HS256) 시크릿(최소 32바이트). 미설정 시 개발용 기본값(운영 금지) |
| `SERVER_PORT` | 선택 | 백엔드 포트(운영 포트는 G2 게이트에서 확정 — GUARDiA 인프라와 별개 도메인) |
| `REDIS_HOST` / `REDIS_PORT` | 선택 | 기본 `localhost` / `6379` |
| `RENDER_WORKER_TOKEN` | 워커 연동 시 | `/api/internal/render/callback` 공유 시크릿(`X-Worker-Token`) |
| `ANTHROPIC_API_KEY` | AI(Claude) 사용 시 | Claude 텍스트 AI. 키는 env only — DB/코드/로그/커밋/응답 기록 금지, 실패 시 Ollama 폴백 |
| `ADMIN_PASSWORD_ENC` / `ADMIN_KEY_FILE` | 운영 | admin 비번 AES-256-GCM 암호문 + 별도 키파일(root 600) → 기동 시 BCrypt 재시드 |
| `SMTP_HOST`·`SMTP_PORT`·`SMTP_USERNAME`·`SMTP_PASSWORD`·`KINTEX_MAIL_FROM` | 메일 발송 시 | 2차 인증 EMAIL 코드·알림 발송. 미설정 시 로컬 로그 모드 |
### 5-2. 나노바나나 Python 워커
| 변수 | 필수 | 설명 |
|------|------|------|
| `GEMINI_API_KEY` | 실호출 시(**G1 승인 대상**) | Gemini 이미지 생성 키. **워커에서만** 로드 — 백엔드 미취급, 코드/로그/커밋 금지 |
| `NANOBANANA_MODEL` | 선택 | 기본 `gemini-3.1-flash-image-preview` 오버라이드 |
> **G1 게이트**: Gemini 외부 호출은 소유자 승인 대상. 미승인 시 워커는 목/degraded로 동작(import·구조 성립, 실이미지 미생성).
---
## 6. 실행
```bash
# 백엔드
export KINTEX_DB_PASSWORD='****'
export KINTEX_JWT_SECRET='****-32bytes이상****'
cd src/backend && ./gradlew bootRun # 또는 java -jar build/libs/kintex-*.jar
# 프론트 (개발 서버 — axios baseURL=/api, 프록시로 백엔드 연결)
cd src/frontend && npm run dev
# 나노바나나 워커 (G1 승인 후 실호출; 미승인 시 목)
export GEMINI_API_KEY='****'
python -m tools.nanobanana.worker # 큐 소비 → 콜백(RENDER_WORKER_TOKEN)
```
헬스체크: `GET /health``{ "success": true, "data": { "status": "UP", "service": "kintex-backend" } }`.
---
## 7. 참조
- 빌드/배포 개요: [`BUILD_DEPLOY.md`](BUILD_DEPLOY.md)
- 개발 표준: [`DEVELOPMENT_GUIDE.md`](DEVELOPMENT_GUIDE.md)
- 나노바나나 워커: [`../tools/nanobanana/README.md`](../tools/nanobanana/README.md)
- WISE 세팅 원본(참조): `workspace/uiws/backend/README.md`·`workspace/uiws/db/README_DB연동.md`

75
docs/README.md Normal file
View File

@ -0,0 +1,75 @@
# 킨텍스 자동전시시스템 — 문서 인덱스 (문서 지도)
> **WISE(UIWS) 참조** — 본 문서 세트는 GUARDiA 표준 프레임워크 정본 `workspace/uiws`(UIMS/WISE)의 개발 문서 구성을 레퍼런스로 킨텍스에 맞게 구성했다.
> 목적: 처음 합류하는 개발자가 "어떤 문서가 무엇을 담는지" 한눈에 파악하고, 중복 없이 정본 문서로 이동하게 한다.
---
## 1. 프로젝트 한 줄 요약
킨텍스(한국국제전시장) 전시 운영 전 과정 — **부스 배치 → 부스/장치 설계 → 유틸리티 배선 → 나노바나나(Gemini) 시공 후 사진 시각화 → 공사 옥션 → 관람객·경영분석·공개사이트** — 을 AI로 자동화하는 **자동전시시스템(Exhibition Automation Platform)**.
- 스택: React(Vite·TS) + Spring Boot 3.x(Java 17)·MyBatis + PostgreSQL(PostGIS) + Redis + 나노바나나 Python 워커
- 패키지: `com.zioinfo.kintex` · DB: `kintex_db`
- AI: Claude 기본 + 설정형 프로바이더 전환(`AiTextRouter`/`AiConfig`) — GUARDiA 표준 프레임워크 준수
- 공통 레이어(인증 2FA·시스템관리·업무기능) 레퍼런스: **WISE(UIWS)** `workspace/uiws`
---
## 2. 문서 지도
### 2-1. 기획·설계 (정본 — 해당 에이전트 경유로만 수정)
| 문서 | 담는 내용 | 수정 경로 |
|------|-----------|-----------|
| [`PLANNING.md`](PLANNING.md) | 시스템 기획서 v2.0 — 배경·역할/페르소나·모듈(M1~M18)·역할별 포털 분리·아키텍처 개요·나노바나나 파이프라인·리스크(R1~R12) | `planner` 에이전트 |
| [`design.md`](design.md) | UI 디자인 스펙 v1.1 — 화면(SCR-*) Stitch 프롬프트·화면 흐름·컴포넌트 | `designer` 에이전트 |
| [`IMPLEMENTATION_BACKLOG.md`](IMPLEMENTATION_BACKLOG.md) | 구현 백로그 v2.0 — Phase A~E, 모듈별 작업·담당 에이전트·완료 기준·선행 게이트(G1/G2) | `kintex-impl-orchestrator` |
| [`BACKLOG.md`](BACKLOG.md) | 검증(reviewer) 지적사항 티켓 목록 | `reviewer` 에이전트 |
| `architecture/` (Phase A 예정) | 애플리케이션·시스템·기술·데이터·네트워크 아키텍처 — **kintex-aa/sa/ta/da/na 산출 예정** | 아키텍트 팀 (Phase A) |
### 2-2. 개발·온보딩·표준 (본 세트 — WISE 참조로 신규 작성)
| 문서 | 담는 내용 |
|------|-----------|
| [`README.md`](README.md) | (이 문서) 문서 인덱스·문서 지도 |
| [`DEVELOPMENT_GUIDE.md`](DEVELOPMENT_GUIDE.md) | 개발 표준 — 패키지/레이어·코딩 규약·인증(JWT+2FA/OTP)·보안 불변·브랜치/커밋(conventional)·테스트·PR |
| [`ENV_SETUP.md`](ENV_SETUP.md) | 개발환경 구축 — JDK17·Node·PostgreSQL+PostGIS·Redis·Python 워커 설치·실행·환경변수 목록 |
| [`COMMON_CODES.md`](COMMON_CODES.md) | 공통코드 정의 — WISE 공통코드 체계 + 킨텍스 도메인 코드(역할·부스타입·상태·규정 심각도·옥션 상태 등) |
| [`API_GUIDE.md`](API_GUIDE.md) | API 규약 — 경로·응답 봉투·오류 코드·인증 헤더·RBAC. 상세 계약은 백엔드 계약서 링크 |
| [`BUILD_DEPLOY.md`](BUILD_DEPLOY.md) | 빌드/실행 개요 — gradlew·vite·나노바나나 워커·단일 산출물. 상세 CI/CD는 Phase E devops |
### 2-3. API·워커 계약 (정본 — 경계면 단일 진실원천)
| 문서 | 담는 내용 |
|------|-----------|
| [`../_workspace/01_backend_contracts.md`](../_workspace/01_backend_contracts.md) | P0 백엔드 API 계약 — 인증·M2~M5 엔드포인트·응답 shape·오류 코드·DB 매퍼 인수 목록 (frontend·db·qa 대조용) |
| [`../tools/nanobanana/_workspace/01_worker_contract.md`](../tools/nanobanana/_workspace/01_worker_contract.md) | 나노바나나 워커 계약 — RenderJob 큐·scene 스키마·콜백 |
### 2-4. 리서치·자산
| 위치 | 담는 내용 |
|------|-----------|
| [`analysis/kintex-website.md`](analysis/kintex-website.md) | kintex.com 전 페이지 + 주최자/참가업체 매뉴얼 분석 |
| [`analysis/reroomai-source.md`](analysis/reroomai-source.md) | ReRoomAI 소스 분석(나노바나나 image-to-image 패턴) |
| [`assets/floorplans/`](assets/floorplans/) | 홀별 평면도 JPG 15장 + CAD(트렌치 DWG, gitignore·로컬 보존) |
### 2-5. 하네스
| 위치 | 담는 내용 |
|------|-----------|
| [`../CLAUDE.md`](../CLAUDE.md) | 프로젝트 마스터 컨텍스트 — 규칙·에이전트 워크플로·하네스 이력 |
| `../.claude/agents/` | 전문 에이전트 17종(아키텍트·공통·코어·도메인·AI·QA·devops) + 범용(planner·designer·developer·visualizer·reviewer) |
| `../.claude/skills/` | 스킬(kintex-impl-orchestrator·nanobanana-visualize) |
---
## 3. 신규 개발자 시작 순서 (권장)
1. 이 문서로 문서 지도 파악 → [`../CLAUDE.md`](../CLAUDE.md) 규칙 숙지
2. [`ENV_SETUP.md`](ENV_SETUP.md) — 로컬 개발환경 구축(JDK17·Node·PostGIS·Redis·Python 워커)
3. [`PLANNING.md`](PLANNING.md) §1~2(배경·역할) + [`design.md`](design.md)(화면) 로 도메인 이해
4. [`DEVELOPMENT_GUIDE.md`](DEVELOPMENT_GUIDE.md) + [`API_GUIDE.md`](API_GUIDE.md) + [`COMMON_CODES.md`](COMMON_CODES.md) 로 개발 표준 습득
5. [`IMPLEMENTATION_BACKLOG.md`](IMPLEMENTATION_BACKLOG.md) 에서 자기 Phase/모듈 확인 후 착수 (Phase A 아키텍처 → B 공통 → C 코어 → D 도메인 → E 배포)
> **중복 회피 원칙**: 기획·화면·모듈 정의는 PLANNING/design/BACKLOG가 정본이다. 본 개발 문서 세트는 그 내용을 **재서술하지 않고 링크·요약**만 한다. 아키텍처(app/system/tech/data/network)는 Phase A 아키텍트 산출물(`architecture/`)이 정본이며 본 세트에서 생성하지 않는다.

401
docs/architecture/app.md Normal file
View File

@ -0,0 +1,401 @@
# 킨텍스 자동전시시스템 — 애플리케이션 아키텍처 표준 (app.md)
> 작성: 애플리케이션 아키텍트(AA) · 작성일: 2026-07-11 · 버전: **v1.0** · BACKLOG **A-1**
> 근거: `docs/PLANNING.md` v2.0(§2 6역할 포털·§4 모듈맵·§5 M1~M9·§5A M10~M18·§5B 공통레이어·§8 아키텍처)·`docs/IMPLEMENTATION_BACKLOG.md`(Phase A~E)·`_workspace/01_backend_contracts.md`(P0 계약)·`src/backend` 스캐폴드 실측.
> 스택(확정·불변): React 18/19(Vite·TypeScript) + Spring Boot 3.x(Java 17) + MyBatis + PostgreSQL(PostGIS) + Redis + 나노바나나 Python 워커 사이드카.
>
> **문서 소유권**: 본 문서는 AA만 수정한다. 구현 에이전트(BE/FE/DB/COM/도메인 devs)는 이 표준을 **준수**하며, 위반 발견 시 kintex-qa와 함께 시정한다. 교차 문서(system.md·tech.md·data.md·network.md)와의 정합은 링크로 참조하고 직접 수정하지 않는다.
---
## 0. 목적과 적용 범위
본 문서는 킨텍스 자동전시시스템의 **애플리케이션 구조 일관성**을 규정하는 단일 표준이다. 개별 기능 구현 방식이 아니라 **모듈 경계·레이어링·패키지·API 규격·공통 컴포넌트·의존성 규칙**을 정의한다.
- **적용 대상**: `src/backend`(Spring Boot) 전 모듈, `src/frontend`(React) 전 포털, 나노바나나 워커와의 큐 계약, kintex-common(WISE/UIWS 이식) 공통 레이어.
- **정합 기준**: PLANNING §8/§8-1 아키텍처 개요와 **정합하며 이를 구체화**한다. 상충 시 PLANNING이 상위, 본 문서가 구현 표준.
- **현행 스캐폴드 정합**: 본 표준은 이미 스캐폴드된 실제 구조(§1.2)를 성문화한 것이며, 신규 모듈은 이 패턴을 복제한다. 기존 코드 변경을 요구하지 않는다(성문화·확장).
---
## 1. 패키지 구조 표준
### 1-1. 루트 패키지
전 백엔드 코드는 `com.zioinfo.kintex` 하위에 둔다(GUARDiA 표준 프레임워크 정렬, WISE=`com.zioinfo.*` 관례). 최상위는 **횡단 관심사(cross-cutting)****도메인 모듈(module)** 로 나뉜다.
```
com.zioinfo.kintex
├── KintexApplication # 부트 진입점
├── common # 횡단: 응답봉투·페이징·에러·감사·유틸 (모듈 무의존)
│ ├── ApiResponse / PageResponse
│ ├── error/ (ErrorCode·ApiException·GlobalExceptionHandler)
│ ├── audit/ (감사 AOP·@Audited — Phase B B-2/B-4)
│ └── code/ (공통코드 조회 캐시 — Phase B)
├── config # 부트 설정: SecurityConfig·WebSocketConfig·RedisConfig·MyBatisConfig
├── auth # 인증/인가: JWT·RBAC·2FA(OTP)·principal·guard (도메인 무관 공용)
│ ├── dto/ · mapper/
├── rules # 룰 엔진: 규정(compliance)·요율(rate) 룰셋 로딩·평가 (서비스 계층)
├── health # 헬스체크
└── module # ★도메인 모듈 루트 — 모듈별 서브패키지
├── m1 … m9 # 판매·운영(배정·서류·매칭·정산·물류)
├── m2 · m3 · m4 · m5 # ★P0 부스 시공 코어
├── m10 · m11 · m12 · m13 · m14 # 관람·참가·마케팅·wayfinding·현장운영
└── m15 · m16 · m17 · m18 # 옥션·BI·CMS·관리자
```
### 1-2. 모듈 내부 구조 (표준 레이아웃 — 스캐폴드 실측)
각 도메인 모듈 `module.mN`은 아래 4계층을 **고정 서브패키지**로 둔다. M2가 정본 참조 패턴이다.
```
module.mN
├── MNController # REST 진입 — 얇게 유지(가드·바인딩·위임만)
├── MNService # 서비스 인터페이스(계약)
├── MNServiceImpl # 서비스 구현(비즈니스 로직·트랜잭션 경계)
├── dto/ # 요청/응답 DTO — record 우선(불변)
│ └── *Dto / *Request / *Response
├── mapper/ # MyBatis 매퍼 인터페이스(@Mapper)
│ └── MNMapper (XML은 resources/mybatis/mapper/)
├── MNProperties (선택) # @ConfigurationProperties 모듈 설정
└── domain/ (선택) # 순수 도메인 모델·값객체(엔티티 매핑 시)
```
> **명명 규칙**: 서비스는 인터페이스(`FloorplanService`) + 구현(`FloorplanServiceImpl`) 분리(스캐폴드 실측). 컨트롤러는 `<도메인명>Controller`. DTO는 `record` 우선(불변·직렬화 안정). 모듈 접두어 `mN`은 패키지에만 쓰고 클래스명은 도메인 어휘(Floorplan·Design·Utility·RenderJob·Auction·Visitor…)를 쓴다.
### 1-3. 리소스 레이아웃
```
src/backend/src/main/resources
├── application.yml # 시크릿·엔드포인트는 env 플레이스홀더만(하드코딩 금지)
├── mybatis/mapper/**/*.xml # 공간 SQL(ST_*) 포함 매퍼 XML — mapper-locations로 로드
└── rulesets/ # 버전 관리 룰셋 데이터(코드 아님)
├── compliance-v1.json (compliance-v1.0)
└── rates-v1.json (rates-v1.0)
```
---
## 2. 레이어링 표준 (controller / service / mapper / domain / dto)
### 2-1. 레이어 책임 경계
| 레이어 | 책임 | 금지 |
|---|---|---|
| **Controller** | HTTP 바인딩, 입력 검증(`@Valid`), RBAC 가드 호출, 서비스 위임, `ApiResponse` 래핑 | 비즈니스 로직·SQL·트랜잭션·매퍼 직접 호출 |
| **Service (interface+Impl)** | 비즈니스 규칙, 트랜잭션 경계(`@Transactional`), 룰 엔진 호출, 매퍼 오케스트레이션, 도메인 예외 발생 | HTTP 타입(HttpServletRequest 등) 참조, 매퍼 XML 로직 침범 |
| **Mapper (MyBatis)** | DB 접근, 공간 SQL(`ST_*`) 바인딩. 인터페이스+XML 쌍 | 비즈니스 분기, DTO 조립(원시 `Map`/도메인 반환까지) |
| **DTO** | 계층·경계 데이터 전달(record 불변) | 로직·영속 어노테이션 |
| **domain / 값객체(선택)** | 순수 도메인 모델·계산(엔티티 매핑 시) | 프레임워크 의존 |
### 2-2. 계층 관통 흐름 (표준)
```
Controller ──(가드: EventAccessGuard)──► Service(interface)
└► ServiceImpl ──► Mapper(@Mapper) ──► PostgreSQL/PostGIS
└──► RuleEngine(rules) (공간 SQL은 XML)
└──► RedisTemplate(비동기 큐/실시간)
결과 DTO ◄── ServiceImpl ◄── Mapper(Map/도메인)
Controller ──► ApiResponse.ok(dto) | 예외 ──► GlobalExceptionHandler ──► ApiResponse.fail
```
- 컨트롤러는 **가드 호출 → 서비스 위임 → 봉투 래핑**만 한다(FloorplanController가 정본). 로직이 컨트롤러에 새면 위반.
- 서비스는 매퍼가 반환한 원시(`Map<String,Object>`/도메인)를 **DTO로 조립**한다. 매퍼는 DTO 조립을 하지 않는다.
- 공간 연산(부스 폴리곤·트렌치 KNN·배선 LineString·면적)은 **서비스가 아니라 매퍼 XML의 PostGIS SQL**로 수행하고 서비스는 스칼라/GeoJSON 결과만 사용한다(스캐폴드 `BoothMapper`·`WiringMapper` 계약).
### 2-3. 트랜잭션·읽기 정책
- 쓰기 서비스 메서드는 `@Transactional`, 조회는 `@Transactional(readOnly=true)`.
- **낙관적 잠금**: 배치·설계 등 버전 있는 리소스는 `version` 불일치 시 `CONFLICT`(409). (LayoutSaveRequest·DesignSaveRequest에 `version` 존재.)
- **BI(M16)**: 운영 DB 직조회 금지 — KpiSnapshot/데이터마트(스타 스키마) 또는 읽기 전용 경로로 격리(PLANNING §8-1·M16-1, 상세는 data.md DA 트랙).
---
## 3. 모듈 경계와 분류
### 3-1. 모듈 3계열 + 공통 레이어
| 계열 | 모듈 | 패키지 | 우선순위 | 비고 |
|---|---|---|---|---|
| **공통 레이어(선행 기반)** | 인증·시스템관리·공통업무기능 | `auth`·`common`·`module.m18`(system)·공통 모듈 | P1(전 모듈 선행) | §5B WISE/UIWS 이식 |
| **P0 부스 시공 코어(불변·심장)** | M2 플로어플랜·M3 부스설계·M4 유틸리티·M5 나노바나나 | `module.m2~m5` | **P0** | 스캐폴드 완비 |
| 판매·운영 | M1 배정견적·M6 서류·M7 매칭·M9 정산·M8 물류 | `module.m1·m6·m7·m9·m8` | P1/P2 | |
| 발주·계약 | **M15 공사/장치 옥션** | `module.m15` | **P1(핵심 플로우)** | 폐루프 연결고리 |
| 관람·참가·마케팅 | M10 관람객·M11 매칭·M12 마케팅/공개사이트·M13 wayfinding·M14 현장운영 | `module.m10~m14` | P1/P2 | |
| 경영·콘텐츠·관리 | M16 BI·M17 CMS·M18 관리자 | `module.m16·m17·m18` | P1 | |
### 3-2. 공간 데이터 공유 원칙 (불변)
M2(부스 폴리곤)→M3(부스 내부)→M4(배선)→M5(시각화)는 **하나의 PostGIS 공간 데이터 모델을 공유**한다. M13 wayfinding·M14 부하집계·M16 ㎡당 수익은 **동일 원천(Booth 폴리곤·Wiring LineString)을 재사용**한다. → 공간 지오메트리 소유는 **M2/M4 매퍼가 권위**이며, 소비 모듈은 조회만 한다(중복 저장 금지).
### 3-3. 권위(ownership) 경계 — 중복 제거 (PLANNING §5B-2 규칙)
| 관심사 | 권위 모듈 | 소비 모듈(읽기/이벤트) |
|---|---|---|
| 경영·수익 지표 | **M16 BI** | 대시보드·포털 |
| 일상 업무보고·통계 | 공통 `report/stats` | — |
| 콘텐츠·공지 발행 | **M17 CMS** | 공개사이트·사이니지 |
| 사내 알림성 공지 | 공통 `notice` | — |
| 알림 발송 채널 | 공통 `notification`(단일화) | M10·M12·M15(이벤트 발행) |
| 사용자·역할·공통코드·감사·마스터데이터 | **M18(=system)** | 전 모듈(RBAC·룰셋 공급) |
| 규정·요율 룰셋 | `rules` + M18(버전 관리) | M1·M2·M3·M4 |
---
## 4. 의존성 규칙 (참조 방향·순환 금지)
### 4-1. 허용 참조 방향 (단방향)
```
module.mN ──► rules · auth · common (횡단 계층 참조 허용)
module.mN ──► module.mK (오직 §4-2 표에 명시된 방향만, 하위→상위 데이터 소비)
common ──► (무의존) ★common은 어떤 module·auth·rules도 참조하지 않는다
auth ──► common (에러·봉투만)
rules ──► common
config ──► auth · common (보안/웹소켓/레디스 배선)
```
**철칙**: `common`은 순수 횡단 유틸(봉투·에러·감사·페이징)로 **어떤 도메인/인증/룰도 모른다**. 도메인 모듈이 common을 참조하지, 그 역은 없다.
### 4-2. 모듈 간 참조(도메인) — 명시 방향만 허용
PLANNING §4 모듈맵의 데이터 흐름을 코드 의존으로 옮긴다. **화살표 방향으로만 참조**(소비자→생산자 조회, 순환 금지).
| 소비 모듈 | 참조(생산) 모듈 | 목적 |
|---|---|---|
| M3 → M2 | 부스 좌표·행사 역참조 |
| M4 → M2 | 트렌치·부스 지오메트리 |
| M5 → M2·M3·M4 | 씬 컴파일 입력(scene) |
| M15 → M2·M3·M4·M5·M7 | 옥션 자료 패키지·등록업체 검증 |
| M9 → M1·M4·M15 | 정산 대상(배정·유틸·낙찰) |
| M13 → M2 | wayfinding 지오메트리 |
| M14 → M4·M10 | 부하·체크인 파생 |
| M16 → 전 모듈 | 지표 소비(읽기 전용/스냅샷) |
| M12 → M10·M17 | 세그먼트·콘텐츠 |
- **순환 금지**: 위 표에 역방향이 필요하면 **직접 참조 대신 이벤트(알림 큐)·공유 식별자**로 디커플. 예: M15 낙찰→M9는 M15가 M9를 호출하는 것이 아니라 **도메인 이벤트/발주 링크**로 전달(순환 회피).
- **모듈 간 결합은 서비스 인터페이스로만**: `mK.MKService`를 주입해 쓰고, 상대 모듈의 `mapper`·`ServiceImpl`·`dto` 내부를 직접 참조하지 않는다(계약 경유).
- **공간 원천**은 M2/M4 매퍼가 권위(§3-2) — 타 모듈은 그 서비스로 조회.
- 검증: 빌드 타임 아키텍처 테스트(ArchUnit 권장, tech.md TA 트랙)로 `common→module` 역참조·모듈 순환을 CI에서 차단.
---
## 5. REST API 설계 표준
### 5-1. 경로·버전
- **베이스**: `/api`. 공개(비인증) 홍보/워커 경로는 `/api/public/**`·`/api/internal/**` 접두어로 분리.
- **행사 스코프 리소스**: `/api/events/{eventId}/…` 하위에 배치(모든 도메인 리소스는 `{eventId}` 스코프). 중첩 예:
- M2 `…/events/{eventId}/halls/{hallId}/layout`
- M3 `…/events/{eventId}/booths/{boothId}/design`
- M4 `…/events/{eventId}/booths/{boothId}/utility`
- M5 `…/events/{eventId}/booths/{boothId}/render` · `…/events/{eventId}/render-jobs/{jobId}`
- **플랫폼(비행사) 리소스**: `/api/admin/**`(M18·백오피스, `hasRole(ADMIN)` 게이트), `/api/auth/**`(인증), `/api/me/**`(개인).
- **버전 정책**: P0/P1은 무접두 `/api`(단일 버전). **파괴적 변경 시에만** `/api/v2/…` 도입. 계약 진화는 **후방호환 우선**(필드 추가는 non-breaking, 제거·의미변경만 버전 상향). 룰셋·계약 semver는 페이로드의 `rulesetVersion`으로 별도 표기(코드 API 버전과 분리).
- **동사 규약**: 자원 CRUD는 표준 HTTP 메서드. 비 CRUD 액션은 하위 동사 세그먼트(`/validate`·`/auto-generate`·`/precheck`·`/quote`·`/wiring`·`/order`·`/render`)로 표현(스캐폴드 실측 패턴). 액션은 POST.
### 5-2. 응답 봉투 (ApiResponse<T> — 스캐폴드 정본)
모든 REST 응답은 `common.ApiResponse<T>`를 사용한다(예외 없음).
```json
{ "success": true, "data": { ... }, "error": null }
{ "success": false, "data": null, "error": { "code": "FORBIDDEN", "message": "요약 메시지" } }
```
- 성공은 컨트롤러가 `ApiResponse.ok(dto)`. 실패는 **던지고**(ApiException) `GlobalExceptionHandler`가 봉투로 변환(컨트롤러에서 실패 봉투 수동 조립 금지).
- **목록**: `common.PageResponse<T>` = `{ items, page, size, total }`. (P0 갤러리/워크스페이스처럼 소량 고정 목록은 배열 직접 반환 허용 — 계약 §0-1.)
### 5-3. 오류 코드 → HTTP (ErrorCode enum — 안정 계약)
`common.error.ErrorCode`가 코드↔HTTP 단일 매핑. 신규 코드는 여기에만 추가한다.
| code | HTTP | 의미 |
|---|---|---|
| `VALIDATION` | 400 | 요청 값 오류(필드 메시지) |
| `UNAUTHORIZED` | 401 | 미인증/토큰 만료 |
| `FORBIDDEN` | 403 | 행사/부스/역할 권한 없음 |
| `NOT_FOUND` | 404 | 대상 없음 |
| `CONFLICT` | 409 | 상태/버전 충돌(낙관적 잠금) |
| `COMPLIANCE_BLOCKED` | 422 | 규정 위반(차단) |
| `RENDER_QUOTA_EXCEEDED` | 429 | 이미지 생성 쿼터 소진 |
| `NOT_REGISTERED_COMPANY` | 403 | 미등록 장치업체 차단 |
| `NOT_IMPLEMENTED` | 501 | 매퍼/엔진 구현 대기(스켈레톤) |
| `INTERNAL` | 500 | 서버 오류(요약만) |
- **미구현 지점**은 `ApiException.notImplemented(...)`(501) 표준 사용 — 계약은 확정하되 매퍼/워커 대기 구간 표시(스캐폴드 관례).
- 도메인 확장 코드(옥션 마감·배지 만료 등)는 계열 접두 없이 `ErrorCode`에 추가하고 본 표에 반영(AA 승인).
### 5-4. 페이징·정렬·필터
- 쿼리 파라미터: `page`(0-base)·`size`(기본 20, 상한 100)·`sort=field,asc|desc`. 응답은 `PageResponse<T>`.
- 필터는 명시 쿼리 파라미터(자유 텍스트 SQL 금지). 통합검색(공통 search)은 별도 검색 서비스 경유.
### 5-5. 인증 헤더·공개 경로
- `Authorization: Bearer <JWT>`(HS256). 클레임: `sub`(userId)·`name`·`roles`(eventId→역할)·`hm`(홀매니저)·(Phase B 확장) `plat`(플랫폼 역할 ADMIN 등)·`otp`(2FA 통과 플래그).
- **무상태**(SessionCreationPolicy.STATELESS). CSRF disable, CORS는 config에서 관리.
- **공개(permitAll)**: `GET /health`, `POST /api/auth/login`, `/ws/**`, `POST /api/internal/render/callback`(워커 토큰), (Phase D) `/api/public/**`(공개 홍보사이트 조회). 그 외 전부 인증.
- 내부 워커 콜백은 `X-Worker-Token`(env) 검증. 공개사이트는 읽기 전용(행사 데이터 쓰기 불가).
### 5-6. 보안 불변 (API 계약 강제 — 위반 시 QA 반려)
1. **스택트레이스·내부 세부 미노출**`error.message`는 사람이 읽을 요약만, 상세는 서버 로그. (`server.error.include-*: never` + GlobalExceptionHandler.)
2. **민감정보 응답 완전 제외** — IP·SSH·비밀번호·`os_pw_enc`·해시·내부 식별자. 사용자/업체는 이름·역할·번호 등 비민감 필드만.
3. **`GEMINI_API_KEY`는 백엔드가 다루지 않는다** — 나노바나나 Python 워커 전용. M5는 큐 발행까지만.
4. **AI 생성 이미지 응답은 항상** `watermarkRequired:true`+`watermarkText`+`notice`(계약·심사 서류 사용 금지) 포함(제거 불가, PLANNING §6-5).
5. **admin 비번**은 env `ADMIN_PASSWORD_ENC`(AES-256-GCM)+별도 키파일 주입, `admin123` 하드코딩 금지(§5B-3).
---
## 6. 인증·인가 아키텍처 (이중 RBAC)
PLANNING §2 6역할·§8-1 SSO 이중 권한을 코드 모델로 표준화한다. 인증 스택은 **WISE/UIWS 표준 이식**(JWT+2FA/OTP), 그 위에 킨텍스 행사 RBAC를 얹는다(재설계 금지).
### 6-1. 이중 권한 평가
| 계층 | 대상 | 저장/평가 | 게이트 |
|---|---|---|---|
| **플랫폼 역할(platform)** | ADMIN(백오피스), 셀프서비스(VISITOR/PUBLIC) | JWT `plat` 클레임 + Spring `hasRole` | `/api/admin/**`=`hasRole(ADMIN)`(§5B-1) |
| **행사 역할(event)** | ORGANIZER·EXHIBITOR·CONTRACTOR·HALL_MANAGER | JWT `roles`(eventId→역할)·`hm`, `KintexPrincipal.roleFor(eventId)` | `EventAccessGuard.requireRole(...)` |
- **현행 스캐폴드**(P0): `EventRole`(4역할) + `KintexPrincipal.hallManager` 플래그 + `EventAccessGuard`(require/requireEventAccess/requireRole). 이 4역할 게이트가 정본.
- **Phase B 확장**: 플랫폼 역할(ADMIN)·관람객 셀프서비스 계정·2FA(OTP)·로그인 실패 잠금을 `auth`에 추가(WISE `TotpService` 이식). `EventRole`은 유지, 플랫폼 역할은 별도 축으로 평가(직교).
### 6-2. 가드 사용 규약 (컨트롤러 표준)
```
guard.requireEventAccess(principal, eventId); // 열람: 멤버 or 홀매니저
guard.requireRole(principal, eventId, EventRole.ORGANIZER); // 편집/액션: 역할 한정
guard.requireRole(principal, eventId, EventRole.ORGANIZER, HALL_MANAGER); // 복수 허용
```
- **열람=행사 멤버 or 홀매니저 / 편집·액션=역할별**(계약 §0-5). 홀매니저는 전 행사 열람+승인(`hasAccess`가 항상 true).
- **등록업체 게이트(불변)**: CONTRACTOR 초대 수락·M15 응찰은 `companyRegistrationNo` 킨텍스 등록업체 검증 필수 → 미등록 `NOT_REGISTERED_COMPANY`(403). M7이 검증 권위.
- 가드는 **컨트롤러에서** 호출한다(서비스 진입 전). 서비스는 이미 인가된 것으로 가정하되, 크로스-모듈 호출 시 재검증이 필요하면 호출 측이 책임.
### 6-3. 개인정보·감사
- 리드캡처(M10)·관람객 데이터는 개인정보 — 동의·보존정책 필수(PLANNING R10). 접근은 소유 참가업체+주최자+홀매니저로 한정.
- **감사 대상**(§5B-1): 승인·**낙찰(M15)**·설계 변경·룰셋 개정·리드 접근을 `common.audit` AOP로 전수 기록(§7-3).
---
## 7. 공통 컴포넌트 표준 (kintex-common / WISE 정합)
공통 레이어는 `workspace/uiws`(WISE=GUARDiA 표준 프레임워크) 이식을 원칙으로 하되, 아래 컴포넌트는 **kintex 스캐폴드가 이미 정의한 계약을 정본**으로 삼는다(재설계 금지, 이식 시 정합).
### 7-1. 응답 봉투·페이징
- `common.ApiResponse<T>`(record: success·data·error{code,message})·`common.PageResponse<T>`. §5-2 정본. 모든 응답 필수.
### 7-2. 예외 체계
- `common.error.ErrorCode`(enum, HTTP 매핑) → `ApiException`(코드+요약 메시지) → `@RestControllerAdvice GlobalExceptionHandler`(봉투 변환·로그 격리). 3자 세트가 표준(§5-3). 신규 예외는 `ApiException`+`ErrorCode`만 사용(RuntimeException 남발 금지 — 최종 방어선만 `INTERNAL`).
### 7-3. 감사 AOP (Phase B B-2/B-4)
- `common.audit.@Audited` 어노테이션 + AOP 어드바이스로 상태 변경 API를 `TB_AUDIT_LOG`에 기록(액터·행사·대상·before/after 요약·룰셋 버전). **민감정보·비번·스택트레이스 미기록**(§5-6 정합). WISE `TB_AUDIT_LOG` 스키마 이식.
### 7-4. 공통코드 (Phase B)
- `common.code`가 코드 그룹/상세를 캐시 제공(홀·부스유형·공종 14분류·유틸리티 요금코드 등 도메인 코드 포함). 권위는 M18(system). 도메인 모듈은 하드코딩 대신 공통코드 조회.
### 7-5. 룰셋(버전 관리 데이터)
- `rules``rulesets/*.json`(compliance·rate)을 로드·평가. **코드가 아닌 데이터** — 개정 시 파일 교체·`rulesetVersion` 리포트 기록(감사·면책, PLANNING R2). 연산자: `lte·gte·between·isTrue·eq·lteHall·excludesAll`.
### 7-6. 알림 단일화 (§5B-2)
- 발송 채널은 공통 `notification` 단일. 도메인 모듈(M10·M12·M15)은 직접 발송하지 않고 **이벤트를 발행**한다(마감 리마인더·낙찰·승인·결제 알림). WebSocket 실시간 경로는 §8.
### 7-7. 프론트 공통(FE, Phase B B-4)
- 2FA 화면·공통코드·검색바·그리드·달력·모달·파일업로드는 **공유 컴포넌트 라이브러리**로(WISE 이식, design.md 토큰 정합). 역할별 포털이 상속(중복 구현 금지).
---
## 8. WebSocket 이벤트 규격 (STOMP)
`config.WebSocketConfig` 정본. 실시간 진행/이벤트 푸시는 STOMP over WebSocket으로만 한다(REST 폴링 지양).
- **핸드셰이크**: `GET /ws`(SockJS). 공개 경로(핸드셰이크 후 STOMP CONNECT 헤더에 JWT 전달 — 인가는 구독 시점 평가).
- **prefix**: 서버→클라 브로드캐스트 `/topic`, 클라→서버 `/app`.
- **토픽 네이밍 표준**: `/topic/<도메인>/<식별자>`.
| 토픽 | 이벤트 | 발행 시점 | 대상 |
|---|---|---|---|
| `/topic/render/{jobId}` | `RenderJobDto`(DONE/FAILED) | 워커 콜백 relay(M5) | 발행 멤버 |
| `/topic/auction/{auctionId}` | 순위/라운드 마감(M15) | 응찰·타이머 | 옥션 참여 업체 |
| `/topic/events/{eventId}/notifications` | 알림(승인·마감·결제) | 공통 notification | 행사 멤버 |
| `/topic/events/{eventId}/checkin` | 입장/혼잡(M10·M14) | 체크인 | 홀매니저/주최자 |
- **페이로드는 REST DTO 재사용**(RenderJobDto 등) — 별도 WS 전용 스키마 금지(계약 일원화).
- **인가**: 구독 대상이 행사/부스 스코프면 CONNECT 시 신원 + 구독 시 접근 검증(민감 토픽 무단 구독 차단). 브로드캐스트에도 §5-6 민감정보 제외 동일 적용.
- 나노바나나·서류·알림은 **동일 비동기 패턴**: REST가 Redis 큐 발행 → 워커/서비스 처리 → WS 완료 푸시.
---
## 9. 비동기·큐 계약 (Redis · Python 워커)
- **RenderJob 큐**: `kintex:renderjob:queue`(env `RENDER_QUEUE_KEY`). 백엔드가 scene 페이로드(§6-2 PLANNING) leftPush → Python 워커 소비. 상태 `kintex:renderjob:job:{jobId}`, 쿼터 `kintex:renderjob:quota:{eventId}`(스캐폴드 실측 키).
- **성공 시에만 쿼터 차감**(PLANNING §6-5). 실패 에러는 `safeError`로 요약만 통과(스택트레이스 유입 차단).
- **워커 결합은 얇은 큐 계약으로만** — 백엔드는 큐잉·상태·콜백·WS relay만, 나노바나나 실호출·방어 로직은 워커(§6-4 PLANNING). `GEMINI_API_KEY` 백엔드 미접촉.
- 옥션 실시간 순위·라운드 마감 타이머, 서류/알림 생성도 Redis 재사용(동일 패턴). BI 집계는 배치/스냅샷(§2-3).
- **G1 게이트**: Gemini 외부 호출 미승인 시에도 큐잉/상태는 동작(목/degraded). 실호출·배포는 소유자 승인 후.
---
## 10. 역할별 프론트/백엔드 모듈화 원칙
### 10-1. 프론트 — 역할별 번들 분리 (PLANNING §2-1·§8-1)
6개 프론트를 **역할별 번들·도메인/서브패스 분리**로 배포해 최소권한·공격면 축소. **공유 디자인 시스템·공유 컴포넌트·공유 API 계약을 상속**(중복 구현 금지).
| 프론트 | 도메인(예) | 주 사용 모듈 | 채널 |
|---|---|---|---|
| 주최자 콘솔 | `organizer.` | M1·M2·M6·M15·M16·M12 | 데스크톱 주력 |
| 참가업체 포털 | `exhibitor.` | M3·M4·M5·M10·M11·M15·M9 | 데스크톱+모바일(리드캡처) |
| 업체 포털 | `contractor.` | M3·M4·M15·M8·M7 | 데스크톱+모바일(현장) |
| 운영 대시보드 | `ops.` | M2·M6·M8·M14·M16 | 데스크톱+모바일(검수) |
| 관리자 백오피스 | `admin.` | M18 | 웹 전용 |
| 공개/관람객 | `www`·`expo.` | M12·M10·M11·M13·M17 | 공개 SEO/SSR + 관람객 모바일 |
- **공유 계층**(모노레포 워크스페이스 권장): `packages/api-client`(계약 타입·fetch 래퍼·ApiResponse 언랩), `packages/ui`(공유 컴포넌트·디자인 토큰 `tokens.css`), `packages/auth`(JWT·2FA·라우팅 가드). 각 포털 앱은 이를 의존(역참조 금지).
- **기술 표준(스캐폴드)**: React 18 + Vite + TS, `react-router-dom`·`@tanstack/react-query`(서버 상태)·`zustand`(클라 상태)·`@stomp/stompjs`+`sockjs-client`(WS). 상세 빌드·라우팅은 tech.md(TA).
- **공개 홍보사이트(M12/M17)**: SEO/SSR·다국어(한/영/중/일)·CDN — 인증 앱과 **별도 렌더 경로**(공개 성능·검색 노출). 쓰기 불가.
### 10-2. 백엔드 — 단일 공유 모놀리식(모듈러) (PLANNING §8-1)
- **공유 Spring Boot 백엔드 1개**(모든 포털이 SSO+RBAC로 접근). 역할별로 백엔드를 쪼개지 않는다 — **모듈러 모놀리스**(`module.mN` 경계 + §4 의존 규칙)로 경계를 코드 레벨에서 강제.
- API 노출은 경로 접두(`/api/events/**`·`/api/admin/**`·`/api/public/**`)와 RBAC로 역할별 표면을 나눈다(별도 서비스 아님).
- 장래 서비스 분리가 필요하면 §4 모듈 경계가 분할선(느슨한 결합·이벤트 디커플이 선행 조건).
---
## 11. 신규 모듈 추가 체크리스트 (구현 에이전트용)
새 도메인 모듈(mN) 추가 시 본 표준 준수 확인:
1. 패키지 `com.zioinfo.kintex.module.mN` + 4계층(Controller·Service/Impl·dto·mapper) 생성(§1-2).
2. 컨트롤러는 가드→위임→`ApiResponse` 래핑만(§2-2, FloorplanController 패턴 복제).
3. 경로 `/api/events/{eventId}/…`(행사 스코프) 또는 `/api/admin/**`(플랫폼)(§5-1).
4. DTO는 record, 목록은 `PageResponse`, 오류는 `ApiException`+`ErrorCode`(§5-2/5-3).
5. 공간 데이터는 M2/M4 매퍼 권위 재사용(§3-2), 신규 지오메트리만 자기 매퍼 XML(PostGIS).
6. 크로스 모듈은 상대 `Service` 인터페이스로만, §4-2 방향 준수·순환 금지(이벤트 디커플).
7. 실시간은 `/topic/<도메인>/<id>` STOMP, 비동기는 Redis 큐(§8/§9).
8. 감사 대상 액션에 `@Audited`(§7-3), 알림은 `notification` 이벤트 발행(§7-6).
9. 보안 불변 5종(§5-6) 자체 점검 → QA 반려 방지.
10. 미완 구간은 `ApiException.notImplemented(...)`(501)로 계약만 확정(스캐폴드 관례).
---
## 12. 교차 아키텍처 참조 (링크)
- 시스템·NFR·배포 토폴로지 → `docs/architecture/system.md`(SA)
- 기술 표준·빌드/관측성·AiTextRouter → `docs/architecture/tech.md`(TA)
- 전사 ERD·공간데이터·마스터·BI 데이터마트 → `docs/architecture/data.md`(DA)
- DMZ/내부망·방화벽·외부 아웃바운드(Gemini) → `docs/architecture/network.md`(NA)
- P0 백엔드 API 계약(정본 예시) → `_workspace/01_backend_contracts.md`
- 기획·모듈 정의 → `docs/PLANNING.md` v2.0 · 실행 → `docs/IMPLEMENTATION_BACKLOG.md`
---
## 13. 변경 이력
| 버전 | 일자 | 작성자 | 내용 |
|---|---|---|---|
| v1.0 | 2026-07-11 | AA | 최초 — A-1. 패키지 구조(`com.zioinfo.kintex`)·4계층 레이어링·모듈 경계(P0 코어 M2~M5·도메인 M10~M18·공통 레이어 §5B)·의존성 규칙(common 무의존·모듈 단방향·순환 금지)·REST 표준(경로/버전/봉투/에러/페이징/인증)·이중 RBAC(플랫폼+행사)·WebSocket STOMP 규격·공통 컴포넌트(WISE 정합)·Redis 큐 계약·역할별 프론트 번들 분리 + 모듈러 모놀리스 백엔드. 스캐폴드(`src/backend`) 실측 정합, PLANNING v2.0 §8 정합. |

696
docs/architecture/data.md Normal file
View File

@ -0,0 +1,696 @@
# 킨텍스 자동전시시스템 — 데이터 아키텍처 (A-4)
> 작성: kintex-data-architect(DA) · 작성일: 2026-07-11 · 버전: **v1.0**
> 근거: [`docs/PLANNING.md`](../PLANNING.md) v2.0(§5A M15/M16·§5B 공통코드·§7 ERD·§8 아키텍처) · [`_workspace/01_backend_contracts.md`](../../_workspace/01_backend_contracts.md)(P0 API 계약·§8 매퍼 인수) · [`docs/COMMON_CODES.md`](../COMMON_CODES.md)(공통코드) · [`docs/assets/floorplans/README.md`](../assets/floorplans/README.md)(홀 실측·트렌치 CAD) · 룰셋 `rulesets/compliance-v1.json`·`rates-v1.json`
> **문서 소유권(DA 트랙)**: 본 문서는 **데이터 모델·표준·거버넌스의 단일 출처**다. 물리 스키마(DDL·PostGIS·MyBatis 매퍼 XML) **구현은 kintex-db-engineer**가 담당하며, 본 문서는 그 구현 대상(target model)·표준·검수 기준을 정의한다. DA는 설계·표준·검수만 하고 `src/backend/**/db`·매퍼는 수정하지 않는다.
> 정합 대상: A-1 app.md(AA)·A-2 system.md(SA)·A-3 tech.md(TA)·A-5 network.md(NA) — 상충 발견 시 A-6(reviewer) 티켓화.
---
## 0. 범위·계층·원칙
### 0-1. 데이터 아키텍처 스코프
| 계층 | 대상 | 저장소 |
|---|---|---|
| 공통·시스템관리 (WISE/UIWS 이식) | 사용자·역할·공통코드·메뉴·감사·업무모듈 | PostgreSQL `TB_*` |
| 도메인 (킨텍스 코어 P0) | 행사·홀·부스·설계·유틸리티·렌더잡 | PostgreSQL + **PostGIS** |
| 도메인 (v2.0 확장) | 옥션·관람객/리드·CMS·마스터데이터 | PostgreSQL |
| 마스터·룰셋 (버전 관리 데이터) | 홀·요율·유틸요금·규정 룰셋·등록업체 | 파일(룰셋 JSON) + `TB_*` 마스터 |
| BI 데이터마트 (M16) | Fact/Dim 스타 스키마 + KpiSnapshot | PostgreSQL(별도 스키마 `mart`) / 읽기 전용 복제 |
| 대용량 바이너리 | 도면·생성 이미지·서식·견적 PDF | 오브젝트 스토리지(경로만 DB) |
### 0-2. 설계 원칙 (불변)
1. **단일 공간 원천**: 부스 폴리곤·트렌치 포인트·배선 LineString은 **PostGIS 단일 지오메트리 원천**. M2 검증·M4 라우팅·M5 시각화·M13 wayfinding·M14 부하집계·M16 ㎡당 수익이 **같은 지오메트리를 재사용**(PLANNING §7-3 불변, §4 설계원칙 (1)).
2. **룰셋은 데이터**: 요율·규정은 코드가 아닌 **버전 관리 파일**(`compliance-v*.json`·`rates-v*.json`). 모든 산출물에 `rulesetVersion`·`disclaimer` 각인(감사·면책). 마스터데이터 개정은 M18 백오피스에서 무중단 반영.
3. **PII 최소수집·분리·암호화**: 관람객/리드 개인정보는 §5 분류·보존·동의·암호화 정책을 강제. 민감 컬럼은 API 응답에서 완전 제외(계약 §0-3).
4. **운영/분석 분리**: 경영 지표는 운영 DB 직조회 금지 — **BI 데이터마트(스타 스키마)** 배치 적재 또는 읽기 전용 복제로 운영 부하 회피(PLANNING §8-1).
5. **이식 우선(재설계 금지)**: 공통·시스템·인증 스키마는 `workspace/uiws` `TB_*`를 이식(멱등 DDL). 킨텍스 고유는 도메인 테이블에만.
---
## 1. 전사 데이터 모델 — 개념(Conceptual)
### 1-1. 개념 ERD (도메인 영역)
```mermaid
erDiagram
EVENT ||--o{ HALL_ASSIGNMENT : "배정"
HALL ||--o{ HALL_ASSIGNMENT : "가용"
HALL_ASSIGNMENT ||--o{ LAYOUT : "배치안(버전)"
LAYOUT ||--o{ BOOTH : "부스(폴리곤)"
HALL ||--o{ TRENCH : "트렌치 그리드"
BOOTH ||--o{ DESIGN_PLAN : "설계안(버전)"
BOOTH ||--o{ UTILITY_ORDER : "유틸리티 신청"
UTILITY_ORDER ||--o{ WIRING_PATH : "배선(LineString)"
BOOTH ||--o{ RENDER_JOB : "시각화 샷"
EVENT ||--o{ EVENT_MEMBER : "참여자(RBAC)"
USER ||--o{ EVENT_MEMBER : "소속"
COMPANY ||--o{ EVENT_MEMBER : "업체계정"
EVENT ||--o{ AUCTION : "옥션(M15)"
AUCTION ||--o{ QUOTATION : "견적서=응찰"
AUCTION ||--o| AWARD : "낙찰"
COMPANY ||--o{ QUOTATION : "응찰업체(등록검증)"
BOOTH ||--o{ AUCTION : "자료첨부"
EVENT ||--o{ REGISTRATION : "관람객 등록(M10)"
VISITOR ||--o{ REGISTRATION : "관람객"
REGISTRATION ||--o{ BADGE : "배지/QR"
BADGE ||--o{ CHECK_IN : "체크인"
BOOTH ||--o{ LEAD : "리드캡처"
VISITOR ||--o{ LEAD : "스캔대상"
EVENT ||--o{ SETTLEMENT : "정산(M9)"
EVENT ||--o{ DOCUMENT : "서류/마일스톤(M6)"
EVENT ||--o{ CONTENT : "CMS(M17)"
EVENT ||--o{ FACT_MART : "BI 집계(M16)"
```
### 1-2. 주제영역(Subject Area) 맵
| 주제영역 | 핵심 엔티티 | 소유 모듈 | 특성 |
|---|---|---|---|
| **행사·조직·권한** | Event, User, Company, EventMember, Role | §5B·M18 | 마스터·RBAC 기준축 |
| **공간·시설** | Hall, HallFeature, Trench | M2·마스터 | PostGIS 지오메트리 |
| **설계·시공(P0)** | Layout, Booth, DesignPlan, UtilityOrder, WiringPath, RenderJob | M2~M5 | 버전·공간·비동기 |
| **발주·정산** | Auction, Quotation, Award, Settlement, PaymentSchedule, Document | M6·M9·M15 | 금액·계약·감사 |
| **관람·참가** | Visitor, Registration, Badge, CheckIn, Lead, Meeting | M10·M11 | **PII 집중 영역** |
| **콘텐츠·마스터** | Content, Microsite, MasterData, Ruleset | M17·M18 | 다국어·버전 |
| **분석(BI)** | FactBooking/Settlement/Utility/Auction/Visitor, Dim*, KpiSnapshot | M16 | 스타 스키마·집계 |
| **공통·감사** | AuditLog, CodeGroup, Code, Menu, Notification | §5B | 이식·전 모듈 공유 |
---
## 2. 전사 데이터 모델 — 논리·물리(Logical/Physical)
> 물리 테이블은 kintex-db-engineer가 구현. 아래는 **표준 대상 모델**(테이블·컬럼·타입·제약). 명명 규칙은 §4. `geom` 컬럼 상세는 §3.
### 2-1. 코어 P0 물리 ERD
```mermaid
erDiagram
TB_EVENT {
uuid event_id PK
varchar event_name
date start_date
date end_date
varchar status
timestamptz created_at
}
TB_HALL {
varchar hall_id PK "H1..H10, 반홀 H1A"
varchar hall_name
numeric area_m2
numeric floor_load_t_per_m2
numeric width_m
numeric depth_m
numeric ceiling_m
varchar floor_finish "concrete_polished|carpet"
int booth_capacity
geometry footprint "Polygon,0"
}
TB_HALL_ASSIGNMENT {
uuid assignment_id PK
uuid event_id FK
varchar hall_id FK
date occupy_from
date occupy_to
}
TB_BOOTH {
uuid booth_id PK
uuid layout_id FK
varchar booth_no "A-102"
varchar booth_type "independent|assembled"
numeric width_m
numeric depth_m
numeric height_m
numeric floor_load_t_per_m2
boolean premium
uuid assigned_company_id FK "nullable"
geometry geom "Polygon,0 · 홀로컬"
}
TB_LAYOUT {
uuid layout_id PK
uuid assignment_id FK
int version
varchar name
varchar status "draft|submitted|approved|rejected"
int source_option "선택/병합 출처 안번호"
jsonb merge_provenance "병합 출처 레이어"
timestamptz updated_at
}
TB_TRENCH {
uuid trench_id PK
varchar hall_id FK
varchar supply_matrix "power,water,air,gas,network 비트/배열"
boolean assumed "가정 그리드 여부(R4)"
geometry geom "Point,0 · 탭포인트"
geometry run_geom "LineString,0 · nullable"
}
TB_DESIGN_PLAN {
uuid design_id PK
uuid booth_id FK
int version
varchar status
jsonb spec "DesignSpec"
timestamptz updated_at
}
TB_UTILITY_ORDER {
uuid order_id PK
uuid booth_id FK
varchar status "draft|submitted|relayed"
jsonb quote "UtilityQuote 스냅샷"
varchar rateset_version
varchar location_diagram_url
timestamptz created_at
}
TB_WIRING_PATH {
uuid wiring_id PK
uuid order_id FK
varchar kind "power|network|plumbing|air"
numeric kw "nullable"
numeric length_m
geometry geom "LineString,0"
}
TB_RENDER_JOB {
uuid job_id PK
uuid booth_id FK
uuid event_id FK
varchar shot_preset "S1..S7"
varchar status "QUEUED|RUNNING|DONE|FAILED"
varchar image_url
varchar schema_hash "캐시키"
varchar model_version
varchar error_message "요약만"
timestamptz created_at
}
TB_COMPANY {
uuid company_id PK
varchar company_name
varchar registration_no "사업자번호(등록검증)"
varchar category_code "14분류 CONTRACTOR_CATEGORY"
varchar region
boolean kintex_registered "미등록 응찰 차단 게이트"
}
TB_EVENT_MEMBER {
uuid member_id PK
uuid event_id FK
uuid user_id FK
uuid company_id FK "nullable"
uuid booth_id FK "nullable · 참가업체 부스 스코프"
varchar event_role "ORGANIZER|EXHIBITOR|CONTRACTOR|HALL_MANAGER"
}
TB_EVENT ||--o{ TB_HALL_ASSIGNMENT : ""
TB_HALL ||--o{ TB_HALL_ASSIGNMENT : ""
TB_HALL ||--o{ TB_TRENCH : ""
TB_HALL_ASSIGNMENT ||--o{ TB_LAYOUT : ""
TB_LAYOUT ||--o{ TB_BOOTH : ""
TB_BOOTH ||--o{ TB_DESIGN_PLAN : ""
TB_BOOTH ||--o{ TB_UTILITY_ORDER : ""
TB_UTILITY_ORDER ||--o{ TB_WIRING_PATH : ""
TB_BOOTH ||--o{ TB_RENDER_JOB : ""
TB_COMPANY ||--o{ TB_BOOTH : "배정"
TB_EVENT ||--o{ TB_EVENT_MEMBER : ""
```
**계약 정합 근거**(01_backend_contracts §8 매퍼 인수 목록):
- `BoothMapper``TB_BOOTH.geom`(ST_MakePolygon/ST_AsGeoJSON), 판매면적 `ST_Area(geom)`, 통로폭 `ST_Distance/ST_Buffer`, 비상구 `ST_Intersects(TB_HALL_FEATURE)`.
- `DesignMapper``TB_DESIGN_PLAN.spec`(jsonb), `findEventIdByBooth`(RBAC 역참조 = TB_BOOTH→TB_LAYOUT→TB_HALL_ASSIGNMENT→event_id).
- `WiringMapper``TB_TRENCH` KNN(`geom <-> :point`), `TB_WIRING_PATH.geom` 최단(ST_Length), `TB_TRENCH.assumed` 플래그.
- `RenderJobMapper``TB_RENDER_JOB` 내구 이력·쿼터 정본(`countSucceededByEvent`, Redis는 큐/실시간).
- `UserMapper``TB_USER`(§2-3) 인증행(해시 응답 제외)·`TB_EVENT_MEMBER` 역할.
### 2-2. v2.0 확장 물리 ERD (옥션·관람·CMS)
```mermaid
erDiagram
TB_AUCTION {
uuid auction_id PK
uuid event_id FK
varchar auction_type "REVERSE|RFQ|FIXED"
varchar category_code "공종 14분류"
varchar status "OPEN|BIDDING|AWARDED|CLOSED"
int round_no
timestamptz deadline_at
varchar award_criteria "LOWEST|COMPOSITE"
jsonb weight "가격/평판/납기 가중치"
jsonb attached_refs "Booth/Design/Utility/Render 참조 자료"
}
TB_QUOTATION {
uuid quotation_id PK
uuid auction_id FK
uuid company_id FK "등록업체 검증"
int version
varchar status "SUBMITTED|REVISED|AWARDED|REJECTED"
jsonb line_items "공종·자재·수량·단가·금액"
numeric subtotal
numeric vat
numeric total
date valid_until
varchar lead_time
varchar pdf_url
timestamptz submitted_at
}
TB_AWARD {
uuid award_id PK
uuid auction_id FK
uuid quotation_id FK "선정 견적서"
numeric composite_score
varchar reason
varchar contract_doc_url "M6/M9 연동"
timestamptz awarded_at
}
TB_VISITOR {
uuid visitor_id PK
varchar name_enc "PII·AES-GCM"
varchar email_enc "PII·AES-GCM"
varchar phone_enc "PII·AES-GCM"
varchar org_name "준식별"
varchar job_title
jsonb interests "관심 업종"
varchar visitor_type "VISITOR|BUYER"
timestamptz created_at
}
TB_REGISTRATION {
uuid registration_id PK
uuid event_id FK
uuid visitor_id FK
varchar reg_type
boolean consent_privacy "동의(필수)"
boolean consent_marketing "동의(선택·정보통신망법)"
timestamptz consent_at
timestamptz registered_at
}
TB_BADGE {
uuid badge_id PK
uuid registration_id FK
varchar qr_token "회전 토큰·비추측"
varchar badge_template_id "M17"
}
TB_CHECK_IN {
uuid checkin_id PK
uuid badge_id FK
timestamptz checked_at
varchar gate
}
TB_LEAD {
uuid lead_id PK
uuid event_id FK
uuid booth_id FK "참가업체 스코프"
uuid visitor_id FK
int interest_score
varchar memo_enc "PII·AES-GCM"
boolean consent_share "리드 공유 동의"
timestamptz captured_at
}
TB_CONTENT {
uuid content_id PK
uuid event_id FK "nullable"
varchar content_type
varchar locale "ko|en|zh|ja"
int version
varchar status "draft|review|published"
jsonb body
}
TB_AUCTION ||--o{ TB_QUOTATION : ""
TB_AUCTION ||--o| TB_AWARD : ""
TB_QUOTATION ||--o| TB_AWARD : "선정"
TB_VISITOR ||--o{ TB_REGISTRATION : ""
TB_REGISTRATION ||--o{ TB_BADGE : ""
TB_BADGE ||--o{ TB_CHECK_IN : ""
TB_VISITOR ||--o{ TB_LEAD : ""
```
**보조 테이블**(도메인 완결): `TB_SETTLEMENT`(정산·M9)·`TB_PAYMENT_SCHEDULE`(납부 스케줄 20/30/20/30 + 예치금)·`TB_DOCUMENT`(서류·마일스톤 D-150/30/25/7·M6)·`TB_MEETING`(비즈매칭·M11)·`TB_MICROSITE`(참가업체·M17)·`TB_MASTER_DATA`(마스터 버전·M18). 상세 컬럼은 해당 도메인 에이전트 확정 시 본 문서 갱신.
### 2-3. 공통·시스템관리 물리 모델 (WISE/UIWS 이식 — 정본 참조)
> **재설계 금지**: 아래는 `workspace/uiws` `TB_*` 정본을 **그대로 이식**(멱등 DDL·`sql.init mode=always`+continue-on-error). 킨텍스는 표준 준수만 하고 컬럼을 임의 변경하지 않는다. 상세 컬럼 정의는 UIWS 레퍼런스가 정본.
| 테이블 | 역할 | 킨텍스 접합 |
|---|---|---|
| `TB_USER` | 사용자·인증(BCrypt 해시·`otp_secret` AES) | 6역할 + 등록업체 계정 + 관람객 셀프서비스 |
| `TB_CODE_GRP` / `TB_CODE` | 공통코드 그룹/값 | §4-3 도메인 코드 적재 |
| `TB_MENU` | 메뉴 트리·권한 매핑 | 역할별 포털 IA(§2-1) |
| `TB_AUDIT_LOG` | 감사 로그 | 승인·**낙찰**·설계변경·룰셋개정·**리드 접근(PII)** 전수 |
| `TB_NOTIFICATION` | 통합 알림 | D-데이 리마인더·낙찰·결제 |
| worklog/schedule/message/notice/meeting/report 등 | 공통 업무 | §5B-2 접합점만 이식 |
- **인증 표준(§5B-3)**: JWT + TOTP(RFC6238, SHA1·30s·6자리·±1) 2차 인증 + 로그인 실패 잠금. `admin` 비번은 env `ADMIN_PASSWORD_ENC`(AES-256-GCM) + 별도 키파일 복호 → 기동 시 BCrypt 재시드. **하드코딩 시드 금지**.
- **행사 RBAC 이중 평가**: 전역 `USER_ROLE`(WISE) + 행사 스코프 `EVENT_ROLE`(TB_EVENT_MEMBER) 병행. 킨텍스 1차 권한 = `EVENT_ROLE`(COMMON_CODES §3).
---
## 3. 공간 데이터 모델 표준 (PostGIS)
> M2~M5·M13·M14·M16이 공유하는 **단일 공간 원천**. 물리 구현(ST_* 매퍼 XML)은 db-engineer, 좌표계·타입·인덱스·검증 계약은 본 절이 표준.
### 3-1. 좌표계 표준 — 홀 로컬 데카르트
| 항목 | 표준 | 근거 |
|---|---|---|
| **SRID** | **`0`(로컬 데카르트, 미터)** — 지리좌표(4326) 아님 | 부스/트렌치/배선은 홀 로컬 미터 좌표(계약 `polygon`=홀 로컬 미터, `[[0,0],[6,0]...]`) |
| 타입 | **`geometry`**(geography 아님) | 평면 미터 연산: `ST_Area`=㎡ 직접, `ST_Distance`=m 직접, `ST_Length`=m 직접 |
| 원점 | 홀별 원점(도면 좌하단) 기준, hall_id로 좌표계 분리 | 홀마다 독립 로컬 원점 |
| 단위 | 미터(m). 각도는 도(°) | 계약 `sizeM`·`heightM`·`lengthM` |
| 정밀도 | 좌표 소수 3자리(mm), 면적/길이 소수 2자리 | 시공 실무 정밀도 |
> **주의(교차 좌표계 금지)**: 홀 로컬 좌표는 홀 간 직접 공간연산 불가(각 홀 원점 상이). 홀 전경(S7)·부지 컨텍스트가 필요하면 별도 `venue` 좌표계 변환 테이블로 배치(Phase 2). Phase 1은 단일 홀 기준(홀7 권장, PLANNING §9).
### 3-2. 지오메트리 컬럼 표준
| 엔티티 | 컬럼 | PostGIS 타입 | 규칙 |
|---|---|---|---|
| **부스** `TB_BOOTH` | `geom` | `geometry(Polygon, 0)` | 닫힌 링(첫=끝 좌표), 단순(ST_IsSimple)·유효(ST_IsValid), CCW 권장 |
| **홀 외곽** `TB_HALL` | `footprint` | `geometry(Polygon, 0)` | 홀 경계 |
| **홀 시설** `TB_HALL_FEATURE` | `geom` | `geometry(Geometry, 0)` | 기둥(Point Ø2.5m 버퍼)·비상구(Point/LineString)·셔터·화장실 — `feature_type` 구분 |
| **트렌치** `TB_TRENCH` | `geom` | `geometry(Point, 0)` | 탭/액세스 포인트(KNN 대상). `run_geom geometry(LineString,0)` 옵션(트렌치 런) |
| **배선** `TB_WIRING_PATH` | `geom` | `geometry(LineString, 0)` | 트렌치→단말 경로. `kind`별 1행 |
### 3-3. 공간 연산 계약 (매퍼 XML 대상 — db-engineer 인수)
| 용도 | 연산 | 규정/계약 매핑 |
|---|---|---|
| 판매면적 | `ST_Area(geom)` (m²) | LayoutSummary `salesAreaM2`, BI ㎡당 수익 |
| 통로 폭 최소 | `ST_Distance` + `ST_Buffer`(부스 간극) | 규정 `AISLE_WIDTH_MIN`(≥3m·block) |
| 비상구 차단 | `ST_Intersects(booth, exit_access_zone)` count | 규정 `EXIT_ACCESS`(=0·block) |
| 최근접 트렌치 | KNN `geom <-> :point ORDER BY … LIMIT k` | `WiringMapper.findNearestTrenches` |
| 최단 배선 | 경로 LineString `ST_Length` (통로 횡단 최소 휴리스틱) | `WiringMapper.shortestPath`, `WiringResult.lengthM` |
| 부스 겹침 | `ST_Overlaps` / `ST_Intersects` 자기조인 | 배치 무결성(솔버 후검증) |
| 홀 이탈 | `ST_Contains(hall.footprint, booth.geom)` | 부스가 홀 경계 내 |
- **가정 트렌치(R4)**: 실측 미확보 홀은 공개 규격 기반 가정 그리드 → `TB_TRENCH.assumed=true`. `WiringResult.assumedTrench=true` → 프론트 "가정 트렌치 좌표(실측 대기)" 배지(계약 §5). CAD 트렌치 실측(`평면,트렌치.dwg`, floorplans README) 확보 시 홀 단위 교체·`assumed=false`.
- **좌표 검증 게이트**: 저장 전 `ST_IsValid`·닫힌 링·홀 내포 검증 실패 시 `VALIDATION`(400). 무효 지오메트리 저장 금지.
### 3-4. 공간 인덱스·성능 표준
- 전 `geom` 컬럼 **GiST 인덱스**(`USING gist(geom)`) 필수. 트렌치 KNN·통로 버퍼·비상구 교차의 실시간 응답 근거.
- 부스 수 홀당 200~600(PLANNING). 배치 검증은 홀 단위 배치(bounding box 선필터 후 정밀 연산).
- 대량 좌표는 서버 산출값 권위 — 프론트 좌표는 참고, 규정 판정은 PostGIS 산출값과 대조(compliance-v1 `AISLE_WIDTH_MIN.note`).
---
## 4. 데이터 표준 (명명·코드·마스터)
### 4-1. 명명 규칙 (Naming Convention)
| 대상 | 규칙 | 예 |
|---|---|---|
| 테이블 | `TB_` + `UPPER_SNAKE`(단수) — WISE 표준 계승 | `TB_BOOTH`, `TB_AUCTION` |
| 컬럼(물리) | `snake_case`, PostgreSQL 무인용 소문자(대소문자 혼용·인용식별자 금지) | `booth_id`, `floor_load_t_per_m2` |
| PK | `<엔티티>_id`, **UUID**(도메인) / WISE 이식 테이블은 정본 PK 유지 | `event_id`, `job_id` |
| FK | 참조 PK명 동일 | `TB_BOOTH.layout_id` |
| 지오메트리 | `geom`(주 지오메트리) / `<용도>_geom` | `geom`, `footprint`, `run_geom` |
| 암호화 PII | `<필드>_enc` 접미 | `email_enc`, `otp_secret`(WISE) |
| 코드 컬럼 | `<의미>_code` 또는 상태 `status` | `category_code`, `status` |
| 불리언 | `is_`/동사 또는 `<x>_yn`(WISE 공통은 `USE_YN`) | `premium`, `assumed`, `use_yn` |
| 시각 | `*_at`(`timestamptz`), 날짜 `*_date`(`date`) | `created_at`, `deadline_at`, `start_date` |
| 금액 | `numeric`, 원(KRW) 정수 스케일, 통화 `currency` 명시 | `total`, `vat` |
| BI 마트 | 팩트 `FACT_*`, 차원 `DIM_*`, 스냅샷 `KPI_SNAPSHOT` | `FACT_BOOKING`, `DIM_HALL` |
- **DTO(camelCase) ↔ 컬럼(snake_case) 매핑**: MyBatis `mapUnderscoreToCamelCase=true` 또는 명시 `resultMap`. 계약 DTO(`boothNo`↔`booth_no`, `floorLoadTPerM2`↔`floor_load_t_per_m2`)는 01_backend_contracts를 정본으로 매핑.
- **응답 제외 컬럼(불변, 계약 §0-3)**: `*_enc`·비번 해시·`otp_secret`·내부 IP/SSH·내부 식별자는 API 응답 완전 제외. 표시는 이름·역할·번호 등 비민감 필드만.
### 4-2. 데이터 타입 표준
| 논리형 | 물리형(PostgreSQL) | 비고 |
|---|---|---|
| 식별자 | `uuid`(도메인) | `gen_random_uuid()` |
| 반정형 스펙 | `jsonb` | DesignSpec·quote·line_items·merge_provenance — GIN 인덱스 선택 |
| 공간 | `geometry(<type>, 0)` | §3 |
| 상태·코드 | `varchar` + 공통코드 검증(앱 레벨) | ENUM 물리타입 지양(룰셋/코드 유연성) |
| 금액 | `numeric(15,2)` | |
| 시각 | `timestamptz`(UTC 저장, Asia/Seoul 표시) | |
### 4-3. 공통코드 체계 (WISE 정합)
> 정본 = [`docs/COMMON_CODES.md`](../COMMON_CODES.md)(단일 출처). 적재 `TB_CODE_GRP`/`TB_CODE`, 코드값=영문 상수·코드명=한글. **DA 검수 관점**: 코드 vs 마스터 경계 준수, "확인 필요" 코드값 임의 확정 금지.
- **코드 vs 마스터 경계(핵심 표준)**: 열거 가능 소수값=**공통코드**(BOOTH_TYPE·RENDER_STATUS·SHOT_PRESET 등), 다건·CRUD·버전 대상=**마스터/룰셋**(홀·요율·규정·등록업체) — 공통코드에 넣지 않는다(COMMON_CODES §1·§4 말미).
- **확정 코드**(계약/PLANNING 근거): `EVENT_ROLE`·`PORTAL_ROLE`·`BOOTH_TYPE`·`COMPLIANCE_SEVERITY`·`RENDER_STATUS`·`SHOT_PRESET`·`USE_YN`.
- **DA 확정 대기(확인 필요)**: `AUCTION_STATUS`·`QUOTATION_STATUS`(M15)·`ZONE_TYPE` 확장·상태 전이(`LAYOUT_STATUS`/`DESIGN_STATUS`/`UTILITY_ORDER_STATUS`의 submitted 이후)·`COMPLIANCE_GROUP` 전체 — 도메인 에이전트 확정 시 **COMMON_CODES + 본 ERD 컬럼 주석 동시 갱신**(DA 검수 항목).
- **신규 도메인 코드 제안**(DA): `CONTRACTOR_CATEGORY`(등록업체 14분류: 전시디자인설치·리깅·전기시설·카펫/파이텍스·급배수/Air·가스설비·철거·운수통관·가구비품·경비용역·광고싸인물·지게차·방염·구조해석), `UTILITY_KIND`(power·network·plumbing·air·gas), `AUCTION_TYPE`(REVERSE·RFQ·FIXED), `CONTENT_LOCALE`(ko·en·zh·ja) — 확정 시 COMMON_CODES §2에 승격.
### 4-4. 마스터데이터 관리 정책 (M18 백오피스)
| 마스터 | 저장 형태 | 버전 관리 | 권한 | 개정 절차 |
|---|---|---|---|---|
| **홀 마스터** | `TB_HALL`(+`TB_HALL_FEATURE`·`footprint`) | 스키마 컬럼 `revision`·이력 테이블 | ADMIN | CAD 실측 확보 시 교체(floorplans README 대조), 제3전시장(2028) H11~H18 확장 구조 |
| **요율 룰셋** | `rulesets/rates-v*.json`(파일) | 파일 버전(`rates-v1.0`) + `TB_MASTER_DATA` 메타 | ADMIN | 연 단위 개정 → 새 파일 교체, 산출물에 `rulesetVersion` 각인, 기존 견적 스냅샷 불변 |
| **유틸리티 요금** | rates-v*.json `utility` 절 | 상동 | ADMIN | 인터넷 150,000 vs KT 80,000 정합 확인 후 확정(R8) |
| **규정 룰셋** | `rulesets/compliance-v*.json` | 파일 버전(`compliance-v1.0`) | ADMIN | 규정 개정 시 교체, 리포트에 `rulesetVersion`+`disclaimer` 각인(면책·감사) |
| **등록업체 DB** | `TB_COMPANY`(739개·14분류) | 주기 수집 + `kintex_registered` 검증 플래그 | ADMIN | 웹 공개 데이터 수집→자체 DB화, 추후 공식 피드. **미등록=옥션 응찰/초대 차단 게이트**(불변) |
- **룰셋 스냅샷 원칙**: 견적(`TB_UTILITY_ORDER.quote`·`rateset_version`)·규정 리포트는 산출 시점 룰셋 버전을 **스냅샷 각인**. 이후 룰셋 개정이 과거 산출물을 변경하지 않는다(감사·재현성).
- **버전 관리 = 룰 엔진 결합**: 룰셋은 코드가 아닌 데이터 → M18 개정이 무중단 반영(PLANNING §8-1). 개정은 `TB_AUDIT_LOG` 전수 기록.
---
## 5. 데이터 품질·거버넌스
### 5-1. 개인정보(PII) 분류 체계
> 집중 영역 = 관람·참가(M10/M11). **주민등록번호 등 고유식별정보는 수집하지 않는다**(수집 최소화).
| 등급 | 분류 | 대상 컬럼(예) | 처리 |
|---|---|---|---|
| **P1 식별정보** | 직접 식별 | `TB_VISITOR.name_enc/email_enc/phone_enc`, `TB_LEAD.memo_enc` | **컬럼 AES-256-GCM 암호화** 저장, 응답 제외/마스킹, 접근 감사 |
| **P2 준식별** | 결합 식별 | `org_name`·`job_title`·`interests`·`visitor_type` | 접근 통제, BI는 집계/익명화만 반입 |
| **P3 인증비밀** | 크리덴셜 | `TB_USER` 비번 해시(BCrypt)·`otp_secret`(AES) | 절대 응답 금지, 로그 금지 |
| **P4 공개/비식별** | 비민감 | 부스명·업체명(표시용)·행사·집계 | 일반 처리 |
- **BoothDto 준거**: `assignedCompanyName`는 "표시용, 내부 식별자·민감정보 미포함"(BoothDto Javadoc) — P4. 부스 설계(TB_DESIGN_PLAN.spec)는 영업비밀(R10) → 행사 격리·접근 제한.
### 5-2. 암호화 정책
| 데이터 | 방식 | 근거 |
|---|---|---|
| PII 컬럼(P1) | **AES-256-GCM** 컬럼 암호화, 키는 서버 env/별도 키파일(코드·DB·커밋·로그 금지) | GUARDiA 보안 불변 `os_pw_enc` 패턴 |
| 비밀번호 | BCrypt(단방향) | WISE 표준 |
| OTP 시크릿 | AES-256-GCM | §5B-3 TOTP |
| 전송 | TLS(포털·API·워커 콜백) | NA(network.md) 정합 |
| 오브젝트 스토리지 | 접근 제어 URL(서명·만료), 도면/설계 행사 격리 | R10 |
### 5-3. 동의(Consent)·수집 최소화
- **동의 분리**: `TB_REGISTRATION.consent_privacy`(개인정보 수집·이용, **필수**) / `consent_marketing`(EDM 발송, **선택**, 정보통신망법) / `TB_LEAD.consent_share`(참가업체 리드 공유). 각 `consent_at` 시각 기록.
- **목적 구속**: 마케팅 미동의자는 M12 EDM 세그먼트 제외(발송 파이프라인 게이트). 리드 공유 미동의는 참가업체 반출 차단.
- **최소 수집**: 관람객 폼은 목적 필요 최소 필드. 셀프서비스 계정은 행사 데이터 쓰기 권한 없음(PLANNING §2).
### 5-4. 보존·파기 정책
| 데이터 | 보존 | 파기 |
|---|---|---|
| 관람객 등록·배지·체크인(PII) | 행사 종료 후 정책 기간(기본 1년, 재참가 분석 목적 별도 동의 시 연장) | 기간 경과 자동 익명화/삭제 |
| 리드(참가업체 반출본) | 참가업체 자산 — 반출 시점 이후 참가업체 책임, 플랫폼 원본은 위 관람객 정책 준수 | 상동 |
| 도면·설계·생성 이미지 | 행사 종료 후 보존(참가업체 자산, PLANNING §8) — 별도 정의 | 참가업체 요청 시 삭제 |
| 견적서 PDF·낙찰(M15) | 계약·감사 목적 장기 보존 | 법정 보존기간 준수 |
| 감사로그 | 장기 보존(불변·append-only) | 미파기 |
| BI 마트·KpiSnapshot | 집계(비식별) — 장기 보존 | — |
### 5-5. 감사(Audit)·데이터 계보(Lineage)
- **감사 대상(전수, `TB_AUDIT_LOG`)**: 승인·**낙찰(M15 Award)**·설계 변경·**룰셋/마스터 개정**·**리드/PII 접근**·권한 변경·로그인/OTP. append-only, 행위자·시각·전후값·행사 스코프 기록(COMMON_CODES §2-1 audit 확장).
- **데이터 계보(원천→마트)**:
```
[운영 원천] [BI 데이터마트 M16]
TB_HALL_ASSIGNMENT / 행사일정 ──▶ FACT_BOOKING (가동률·RevPAD·㎡당수익)
TB_SETTLEMENT (M9) ──▶ FACT_SETTLEMENT (매출구성·P&L)
TB_UTILITY_ORDER (M4) ──▶ FACT_UTILITY (유틸 매출)
TB_AUCTION/QUOTATION/AWARD ──▶ FACT_AUCTION (옥션 수수료)
TB_REGISTRATION/CHECK_IN (M10) ──▶ FACT_VISITOR (관람·리텐션) ※PII 비반입, 집계만
TB_HALL/PostGIS ST_Area ──▶ DIM_HALL (면적 정규화)
─(배치/야간 적재)─▶ KPI_SNAPSHOT (경영진 KPI)
```
- **PII 격리(계보 규칙)**: BI 마트는 P1/P2 원본을 반입하지 않는다 — 관람객은 **집계·코호트·익명 키**만 반입(FACT_VISITOR는 방문 카운트·세그먼트 차원, 개인 식별자 없음). M16 리텐션/LTV는 참가사(Company) 단위이며 개인 관람객이 아님.
- **데이터 품질 규칙(DQ)**: 참조무결성(FK), 지오메트리 유효성(§3-3 게이트), 룰셋 버전 각인 누락 0, 금액 통화 명시, 상태 코드 공통코드 준수. 마트 적재 시 원천-집계 정합 체크(§6-4).
---
## 6. BI 데이터마트 (M16) — 스타 스키마
> 대상: **kintex-bi-dev**. PLANNING §5A M16-1 운영사(킨텍스) 관점 7지표 정합. 관점 격리 = ① 참가업체 ROI(자기 부스) / ② 운영사 수익성(전 행사) 별도 대시보드·권한(PLANNING §440). 적재 = 배치/야간 `KPI_SNAPSHOT` 또는 읽기 전용 복제(운영 부하 회피, §8-1).
### 6-1. 스타 스키마 ERD
```mermaid
erDiagram
DIM_DATE {
int date_key PK "YYYYMMDD"
date full_date
int year
int quarter
int month
boolean is_peak "성수기 3-5·9-11"
boolean is_offpeak "비수기 1·2·7·12"
}
DIM_HALL {
varchar hall_key PK "H1..H10/반홀"
varchar hall_name
numeric area_m2 "㎡ 정규화 기준"
varchar center "1전시장|2전시장"
numeric floor_load
varchar floor_finish
}
DIM_EVENT {
uuid event_key PK
varchar event_name
varchar event_type
date start_date
date end_date
int duration_days
}
DIM_EXHIBITOR {
uuid exhibitor_key PK "=company_id"
varchar company_name
varchar category
varchar region
int first_participation_year "코호트"
}
FACT_BOOKING {
uuid booking_id PK
int date_key FK
varchar hall_key FK
uuid event_key FK
numeric occupied_area_m2
numeric available_area_m2
int occupied_days
int available_days
numeric rental_revenue
numeric season_coeff "성수기/비수기/1전시장 계수"
}
FACT_SETTLEMENT {
uuid settlement_id PK
int date_key FK
uuid event_key FK
varchar revenue_segment "rental|utility|auction_fee|lobby|outdoor|parking"
numeric revenue
numeric direct_cost "운영·에너지·인력"
numeric contribution_margin
}
FACT_UTILITY {
uuid util_fact_id PK
int date_key FK
uuid event_key FK
uuid exhibitor_key FK
varchar utility_kind "power|network|plumbing|air"
numeric amount
}
FACT_AUCTION {
uuid auction_fact_id PK
int date_key FK
uuid event_key FK
varchar category_code
numeric awarded_amount
numeric platform_fee
int bid_count
}
FACT_VISITOR {
uuid visitor_fact_id PK
int date_key FK
uuid event_key FK
varchar visitor_segment "visitor|buyer (익명 세그먼트)"
int registered_count
int checkin_count
int lead_count "PII 없음·집계만"
}
KPI_SNAPSHOT {
uuid snapshot_id PK
int date_key FK
varchar scope "venue|center|hall|event"
varchar scope_key
varchar kpi_code "OCC|REVPAD|MARGIN|RETENTION|LTV|YIELD"
numeric kpi_value
numeric target_value
timestamptz built_at
}
DIM_DATE ||--o{ FACT_BOOKING : ""
DIM_HALL ||--o{ FACT_BOOKING : ""
DIM_EVENT ||--o{ FACT_BOOKING : ""
DIM_DATE ||--o{ FACT_SETTLEMENT : ""
DIM_EVENT ||--o{ FACT_SETTLEMENT : ""
DIM_DATE ||--o{ FACT_UTILITY : ""
DIM_EXHIBITOR ||--o{ FACT_UTILITY : ""
DIM_DATE ||--o{ FACT_AUCTION : ""
DIM_DATE ||--o{ FACT_VISITOR : ""
DIM_EVENT ||--o{ FACT_VISITOR : ""
DIM_DATE ||--o{ KPI_SNAPSHOT : ""
```
### 6-2. 팩트 그레인(Grain) 정의
| 팩트 | 그레인(1행 = ) | 가법성 |
|---|---|---|
| `FACT_BOOKING` | 행사×홀(반홀)×기간 배정 1건 | 면적·일수·매출 가법, 계수 비가법 |
| `FACT_SETTLEMENT` | 행사×매출세그먼트×정산일 | 매출·원가·공헌이익 가법 |
| `FACT_UTILITY` | 행사×참가사×유틸종류 신청 1건 | 금액 가법 |
| `FACT_AUCTION` | 옥션(낙찰) 1건 | 낙찰액·수수료 가법, bid_count 준가법 |
| `FACT_VISITOR` | 행사×관람일×세그먼트 집계 | 카운트 가법(**개인 식별자 없음**) |
### 6-3. M16-1 지표 → 마트 매핑
| # | 운영사 지표 | 산식 | 소스 팩트/차원 |
|---|---|---|---|
| ① | 홀·기간별 가동률 | Σ occupied_area×days / Σ available_area×days ×100 | FACT_BOOKING × DIM_HALL × DIM_DATE |
| ② | 매출 구성(mix) | revenue by segment | FACT_SETTLEMENT/FACT_UTILITY/FACT_AUCTION |
| ③ | 행사별 P&L·마진 | Σ revenue Σ direct_cost = 공헌이익, 마진율 | FACT_SETTLEMENT × DIM_EVENT |
| ④ | 전시장별 ROI·RevPAD·㎡당 수익 | revenue / area_m2 (㎡ 정규화) | FACT_BOOKING × DIM_HALL(area_m2) |
| ⑤ | 참가사 리텐션·LTV | 코호트 재참가율, LTV=Σ(임대+유틸+옥션)/재참가주기 | DIM_EXHIBITOR(first_year) × FACT_* 다년 |
| ⑥ | 수요예측·수율/가격 | 성수기 계수·홀별 수요, 요율 시뮬레이션 | FACT_BOOKING 이력 × rates 룰셋 |
| ⑦ | 경영진 KPI 대시보드 | ①~⑥ 요약 + 목표 대비(점유 60~75%) | KPI_SNAPSHOT |
### 6-4. 적재·품질 표준
- **적재 방식**: 야간 배치 ETL(운영→`mart` 스키마) 또는 읽기 전용 복제(운영 부하 회피). `KPI_SNAPSHOT`은 스냅샷 시점(`built_at`) 각인 → 시계열 추이·재현성.
- **㎡ 정규화 권위**: DIM_HALL.area_m2는 **PostGIS `ST_Area` 산출값 또는 홀 마스터 확정값** 단일 출처(대형홀 vs 소형홀 생산성 비교 정합, ④ RevPAD 근거).
- **정합 체크(DQ)**: 마트 매출 합 = 운영 정산 합(허용오차 0), 가동률 분모(가용 홀·일수) = 행사일정×홀 마스터, 관점 격리(참가사 대시보드는 exhibitor_key 필터 강제).
- **PII 비반입(불변)**: FACT_VISITOR는 카운트/세그먼트만 — TB_VISITOR P1/P2 컬럼 마트 유입 금지(§5-5 계보 규칙).
- **한계 각인**: LTV·리텐션 다년 데이터 필요(초기 단년 근사), 수율 최적가=시뮬레이션 참고치(최종 요율은 킨텍스 경영 결정, R8) — 대시보드 고지.
---
## 7. DA 검수 체크리스트 (Phase A 게이트)
> 본 문서를 기준으로 db-engineer 물리 구현·타 트랙 산출물을 검수하는 항목(A-6 reviewer 정합 입력).
| # | 검수 항목 | 기준 |
|---|---|---|
| 1 | 명명 규칙 준수 | `TB_`·snake_case·`_enc`·`geom`·`FACT_/DIM_` (§4-1) |
| 2 | 공간 좌표계 | SRID 0·geometry·GiST 인덱스·`ST_IsValid` 게이트 (§3) |
| 3 | 공통코드 경계 | 코드 vs 마스터 분리, "확인 필요" 임의확정 금지 (§4-3) |
| 4 | 룰셋 스냅샷 | 견적·리포트에 `rulesetVersion` 각인, 과거본 불변 (§4-4) |
| 5 | PII 암호화·응답제외 | P1 `_enc` AES-GCM, 민감컬럼 API 완전 제외 (§5-1/5-2, 계약 §0-3) |
| 6 | 동의 게이트 | marketing 미동의 EDM 제외, share 미동의 반출 차단 (§5-3) |
| 7 | 감사 전수 | 낙찰·룰셋개정·리드접근·권한변경 기록 (§5-5) |
| 8 | BI PII 비반입 | 마트에 개인식별자 유입 0, 관점 격리 (§6-4) |
| 9 | 계약 정합 | §8 매퍼 인수 테이블/컬럼 = 본 ERD (§2-1) |
---
## 8. 미결·후속 (확정 대기)
| 항목 | 상태 | 담당 |
|---|---|---|
| M15 옥션 상태 코드(`AUCTION_STATUS`·`QUOTATION_STATUS`) 확정 | 확인 필요 | bidding-dev + DA |
| 상태 전이(layout/design/utility submitted 이후) | 확인 필요 | M6 승인 워크플로 + DA |
| `TB_SETTLEMENT`·`TB_MEETING`·`TB_MICROSITE` 상세 컬럼 | 골격만 | 도메인 에이전트 + DA |
| CAD 트렌치 실측 → `TB_TRENCH.assumed=false` 교체 | 미확보(R4) | 킨텍스 협의 |
| 홀 간 venue 좌표계 변환(S7·부지) | Phase 2 | DA + M2 |
| 제3전시장(2028) H11~H18 홀 마스터 확장 | 구조 대비 | DA |
| 인터넷 요금 정합(150,000 vs 80,000) | 확인 필요(R8) | 킨텍스 + M18 |
---
## 9. 변경 이력
| 버전 | 일자 | 작성자 | 내용 |
|---|---|---|---|
| v1.0 | 2026-07-11 | kintex-data-architect(DA) | 최초 — 전사 데이터 모델(개념→논리→물리 ERD, PLANNING §7 확장)·공간 데이터 표준(PostGIS SRID0·부스 POLYGON·트렌치 POINT·배선 LineString)·데이터 표준(명명·타입·공통코드 WISE 정합·마스터/룰셋 관리)·품질·거버넌스(PII 분류·암호화·동의·보존·감사·계보)·**BI 데이터마트 M16 스타 스키마(FACT_BOOKING/SETTLEMENT/UTILITY/AUCTION/VISITOR + DIM_DATE/HALL/EVENT/EXHIBITOR + KPI_SNAPSHOT, M16-1 7지표 정합)** 정의. 물리 구현은 db-engineer 인수, 코드값 확인필요 항목은 §8 후속. |

View File

@ -0,0 +1,273 @@
# 킨텍스 자동전시시스템 — 네트워크 아키텍처 (A-5)
> 작성: 네트워크 아키텍트(NA) · 작성일: 2026-07-11 · 버전 v1.0
> 근거: `../PLANNING.md` v2.0 (§2-1 역할별 포털, §8 아키텍처, §8-1 v2.0 보강, §10 R12 외부 API 게이트), `../IMPLEMENTATION_BACKLOG.md` A-5
> 교차참조(Phase A 동시 산출·정합 대상): AA `app.md`(A-1) · SA `system.md`(A-2, 보안영역·배포 토폴로지) · TA `tech.md`(A-3, 관측성·AiTextRouter) · DA `data.md`(A-4)
> 범위: **IT 인프라 네트워크(계정·서비스·데이터 트래픽 경로)** 설계·정책. 구현(nginx·방화벽 룰·systemd·CI/CD)은 devops(DEV) 트랙. 본 문서는 설계·정책·리뷰이며 코드/설정을 생성하지 않는다.
---
## 0. 범위 경계 — M4 전시장 유틸리티 배선과의 구분 (필독)
본 문서가 다루는 것은 **플랫폼 IT 인프라 네트워크**(웹/모바일 클라이언트 ↔ 애플리케이션 ↔ 데이터/AI 워커 사이의 트래픽 경로, 보안영역, 방화벽, 부하분산, 외부 아웃바운드)이다.
이는 **M4 유틸리티 설계 모듈이 다루는 전시장 바닥 트렌치 배선(전기·조명·인터넷/전화·급배수·압축공기)과 완전히 별개**다.
| 구분 | M4 유틸리티 배선 (PLANNING §M4) | 본 문서 = IT 인프라 네트워크 (A-5) |
|---|---|---|
| 대상 | 전시홀 바닥 트렌치의 **물리 배선**(부스별 전기 kW·인터넷 회선·급배수 구) | 플랫폼 서버·서비스·클라이언트 간 **데이터 트래픽** |
| 데이터 성격 | 배선 경로 = PostGIS LineString(**업무 데이터**), 위치표시도, 자동 견적 | VLAN·서브넷·방화벽 존·LB·TLS·아웃바운드 |
| 소유 | M4 개발(BE/DB/FE) — 도메인 기능 | NA(본 문서) + devops 구현 |
| 관계 | M4의 "인터넷 유선 150,000원/회선" 신청은 **전시 참가업체가 전시장에서 쓸 회선** — 플랫폼 운영 네트워크와 무관 | 플랫폼 자체가 도는 인프라 |
> 요약: "전시장에 깔리는 인터넷 회선"(M4, 참가업체 상품)과 "이 플랫폼이 도는 네트워크"(A-5, 운영 인프라)는 이름만 겹칠 뿐 다른 계층이다. 혼동 시 보안영역·정산이 오염되므로 문서·코드·용어에서 항상 분리한다.
---
## 1. 설계 원칙
1. **2영역 분리(공개 vs 내부) 최소권한**: 불특정 다수가 접근하는 **공개/관람객 트래픽(DMZ)** 과 킨텍스 직원·관리자·백오피스가 쓰는 **내부 운영(내부망)** 을 물리/논리적으로 분리한다. 공개 영역이 뚫려도 내부망·데이터 계층에 직접 도달하지 못한다(계층 방어).
2. **역할별 프론트 = 공격면 분리**(PLANNING §8-1): organizer·exhibitor·contractor·ops·admin·public/visitor 6개 프론트를 별도 번들·도메인/서브패스로 배포. 네트워크 계층에서도 **공개 성격(public/visitor)****인증 필수(organizer·exhibitor·contractor·ops·admin)** 를 존으로 나눈다.
3. **기본 거부(default-deny)**: 인바운드·아웃바운드 모두 화이트리스트. 특히 **아웃바운드는 승인된 목적지(Claude·Gemini·PG·SMTP·모델서버)만** 포워드 프록시/egress 방화벽을 통해 허용, 그 외 전면 차단(폐쇄망 지향).
4. **단일 진입 + TLS 종단**: 모든 외부 인바운드는 리버스 프록시/로드밸런서(nginx) 한 곳에서 TLS 종단하고 내부는 사설망 트래픽. 백엔드 포트는 외부 미노출.
5. **GUARDiA 인프라와 도메인 분리**: kintex는 독립 저장소(`zio/kintex`)이며 GUARDiA ITSM/관제 인프라 서버(`101.79.17.164`)와 **별개 도메인·별개 배포 대상**이다(배포 서버·포트는 G2 게이트에서 확정). 본 설계는 그 별개 도메인 위에 존을 정의한다.
---
## 2. 보안영역(Security Zone) 모델
4계층 존 + 관리 존으로 나눈다. 존 간 통신은 명시된 방향·포트만 허용한다.
```mermaid
graph TB
subgraph INET[인터넷 / 불특정 다수]
U1[일반 대중·관람객]
U2[주최자·참가업체·업체<br/>인증 사용자]
U3[킨텍스 직원·관리자]
PGc[PG사 콜백]
end
subgraph EDGE[엣지 / DDoS·CDN·WAF]
CDN[CDN·정적 캐시<br/>공개사이트 에셋·이미지]
WAF[WAF + DDoS 방어<br/>레이트리밋]
end
subgraph DMZ[DMZ · 공개 영역]
LBp[리버스 프록시/LB<br/>TLS 종단 · 공개]
PUB[public www/expo<br/>SSR·SEO·다국어 렌더]
VIS[visitor 관람객 앱 게이트웨이]
APIGWp[공개 API GW<br/>등록·조회·티켓·wayfinding·PG콜백]
end
subgraph INTNET[내부망 · 인증·운영 영역]
LBi[내부 프록시/LB<br/>TLS · mTLS 옵션]
AUTH[SSO/JWT·RBAC·2FA<br/>인증 게이트]
APIGWi[내부 API GW<br/>organizer·exhibitor·contractor·ops·admin]
APP[공유 Spring Boot 백엔드<br/>룰·배치/배선·옥션·BI·CMS]
end
subgraph AITIER[AI 워커 존 · egress 제한]
REDIS[(Redis 작업 큐)]
NB[나노바나나 Python 워커]
OLL[온프레미스 모델<br/>Ollama 폴백]
end
subgraph DATA[데이터 존 · 최내곽]
PG[(PostgreSQL+PostGIS)]
OBJ[(오브젝트 스토리지)]
RREP[(BI 읽기전용 복제/스냅샷)]
end
subgraph MGMT[관리 존]
BAST[배스천/점프호스트<br/>SSH·운영접근]
OBS[관측성<br/>로그·메트릭·트레이스]
end
subgraph OUT[승인된 아웃바운드 목적지]
ANTH[api.anthropic.com<br/>Claude ✅승인]
GEM[generativelanguage.googleapis.com<br/>Gemini ⚠G1 게이트]
PGext[PG사 결제 API]
SMTP[SMTP 메일]
end
U1 --> WAF --> CDN
U1 --> WAF --> LBp
U2 --> WAF --> LBp
PGc --> WAF --> LBp
U3 -->|VPN/허용 IP| LBi
LBp --> PUB & VIS & APIGWp
APIGWp --> AUTH
LBi --> AUTH --> APIGWi --> APP
APIGWp -.제한 라우팅.-> APP
APP --> REDIS --> NB
APP --> PG
APP --> OBJ
APP --> RREP
NB --> OBJ
NB -.egress only.-> GEM
APP -.egress.-> ANTH
APP -.egress.-> PGext
APP -.egress.-> SMTP
NB & APP -.폴백.-> OLL
BAST -.운영 SSH.-> INTNET & AITIER & DATA
APP & NB & PUB -.로그/메트릭.-> OBS
```
### 2-1. 존 정의·신뢰수준
| 존 | 신뢰 | 구성 요소 | 인바운드 허용 | 아웃바운드 허용 |
|---|---|---|---|---|
| **엣지** | 무신뢰 | CDN, WAF/DDoS | 인터넷 80/443 | DMZ LB |
| **DMZ(공개)** | 낮음 | 공개 LB(TLS 종단), `public` SSR, `visitor` 게이트웨이, 공개 API GW, PG 콜백 수신 | 엣지에서만 | 내부망 API GW(제한 라우팅), 데이터 존 직결 **금지** |
| **내부망(인증·운영)** | 중 | 내부 LB, SSO/인증 게이트, 내부 API GW, 공유 Spring Boot 백엔드, 룰·배치/배선·옥션·BI·CMS 서비스 | DMZ(허용 API만)·관리 존(허용 IP/VPN) | 데이터 존, AI 워커 존, 승인 아웃바운드(프록시 경유) |
| **AI 워커 존** | 중(egress 통제) | Redis 큐, 나노바나나 Python 워커, 온프레미스 모델(Ollama) | 내부망(큐 소비만) | 데이터 존(OBJ 적재), **Gemini egress 단일 경로만** |
| **데이터 존(최내곽)** | 높음 | PostgreSQL+PostGIS, 오브젝트 스토리지, BI 읽기전용 복제 | 내부망·AI 워커(정의된 서비스 계정만) | 없음(아웃바운드 전면 차단) |
| **관리 존** | 높음 | 배스천/점프호스트, 관측성(로그·메트릭·트레이스), 백업 | 운영자 VPN/허용 IP만 | 대상 존 SSH·수집 |
**핵심 규칙**: DMZ → 데이터 존 **직접 접근 절대 금지**. 공개 트래픽이 데이터에 닿으려면 반드시 내부망 백엔드 API를 경유(인증·인가·검증). 데이터 존은 아웃바운드가 없어 유출 경로 자체를 제거한다.
---
## 3. 공개(DMZ) vs 내부망 — 포털·도메인 매핑
PLANNING §2-1 6역할 포털을 네트워크 노출 성격으로 재분류한다.
| 포털/앱 | 도메인(예시, G2에서 확정) | 노출 영역 | 인증 | 렌더 경로 |
|---|---|---|---|---|
| 공개 홍보 사이트 (M12) | `www.` / `expo.` | **DMZ(공개)** | 불필요(쓰기 없음) | SSR/정적 생성 + CDN, SEO·다국어(hreflang) |
| 관람객 앱 (M10·M13) | `visitor.` (모바일 API) | **DMZ(공개)** | 셀프서비스(경량, 쓰기 제한) | 모바일 클라이언트 ↔ 공개 API GW |
| 주최자 콘솔 | `organizer.` | **내부망(인증)** | JWT SSO + Event owner | React SPA + 내부 API GW |
| 참가업체 포털 | `exhibitor.` | **내부망(인증)** | JWT SSO + Event member | React SPA |
| 업체 포털·옥션 | `contractor.` | **내부망(인증)** | 등록업체 검증 계정 | React SPA |
| 운영 대시보드(홀매니저) | `ops.` | **내부망(운영)** — 접근 IP/VPN 제한 권장 | 킨텍스 내부 계정 | React SPA |
| 관리자 백오피스 (M18) | `admin.` | **내부망(관리)****강한 제한**(VPN/허용 IP·2FA 필수·모바일 미제공) | 플랫폼 관리자 + 2FA 강제 | React SPA(웹 전용) |
- **공개(DMZ) 3원칙**: (1) 쓰기 권한 없음(등록·조회·티켓·매칭 조회만), (2) 데이터 존 직결 불가, (3) 캐시/CDN 적극(공개 성능·검색 노출). 공개 API GW는 **읽기·등록 한정 엔드포인트만** 화이트리스트로 노출.
- **내부망 인증 앱**: SSO/JWT + 역할·행사 이중 RBAC(§8-1). `ops.`·`admin.`은 킨텍스 내부 대역/VPN·허용 IP로 접근 자체를 좁힌다(직원 대상이므로 인터넷 전면 노출 불필요).
- **admin. 백오피스**: 최고 위험. VPN 또는 킨텍스 사내망 + 허용 IP allowlist + 2FA(OTP) 강제 + 감사로그 전량. 크로스-테넌트 권한을 가지므로 공개 인터넷 경로에서 도달 불가하게 한다.
---
## 4. 방화벽 · WAF 정책
### 4-1. 방화벽(존 간 트래픽 매트릭스)
| From \ To | 엣지 | DMZ | 내부망 | AI 워커 | 데이터 | 관리 | 인터넷(egress) |
|---|---|---|---|---|---|---|---|
| **인터넷** | 443/80 | (엣지 경유) | ✗ | ✗ | ✗ | ✗ | — |
| **엣지** | — | 443 | ✗ | ✗ | ✗ | ✗ | ✗ |
| **DMZ** | — | — | 내부 API GW(허용 엔드포인트 https) | ✗ | ✗ | ✗ | ✗ |
| **내부망** | — | — | — | Redis 6379·워커 | PG 5432·OBJ https | ✗ | 프록시 경유(§5) |
| **AI 워커** | — | — | 완료 푸시(WebSocket) | — | OBJ https | ✗ | Gemini 단일(§5) |
| **데이터** | — | — | — | — | — | ✗ | **✗(전면 차단)** |
| **관리** | — | — | SSH | SSH | SSH(제한) | — | 패키지/업데이트(제한) |
- 기본 정책 = **DROP**. 위 표의 명시 경로만 ALLOW. 포트는 예시(운영값은 devops가 G2에서 확정).
- 백엔드(Spring Boot)·Redis·PostgreSQL·오브젝트 스토리지 포트는 **인터넷에 미노출** — 사설 대역에서만 청취.
- DMZ↔내부망 사이는 **애플리케이션 프로토콜(HTTPS/API)만** 통과, DB 프로토콜(5432 등) 횡단 금지.
### 4-2. WAF (공개 트래픽 전면)
DMZ 진입 전 엣지에서 WAF를 통과시킨다.
- **OWASP Top10 룰셋**(SQLi·XSS·경로조작·SSRF·파일업로드 악용). 도면/이미지 업로드(M2·M3·M5)·CMS(M17)·공개 등록 폼(M10)이 주요 표적 → 업로드 확장자·MIME·크기 검증을 WAF + 애플리케이션 이중.
- **봇/스크래핑 방어**: 공개사이트(M12)는 SEO 목적상 정상 크롤러(구글봇 등)는 허용하되, 등록·티켓·비즈매칭 엔드포인트는 봇 챌린지·리캡차 옵션.
- **PG 콜백 검증**: DMZ에서 수신하는 PG 결제 콜백은 **출처 IP allowlist + 서명 검증**을 WAF/애플리케이션에서 이중 확인(위조 콜백 차단).
- **관리자·옥션 엔드포인트**: `admin.`·M15 옥션 응찰(가격 조작 민감)은 WAF 예외 없이 최상위 룰 + 레이트리밋 강화.
---
## 5. 외부 연동 아웃바운드 정책 (승인 게이트 준수)
**폐쇄망 지향 — egress 기본 차단.** 승인된 목적지만 포워드 프록시(egress 게이트웨이)를 통해 도메인 화이트리스트로 허용하고, 그 외 전량 차단·로깅한다.
| 목적지 | 용도 | 승인 상태 | 경로 · 정책 |
|---|---|---|---|
| `api.anthropic.com` | Claude 텍스트 AI(규정검수·서류·BI 인사이트·AiTextRouter 기본) | **✅ 승인**(2026-07-03, 소유자) | 백엔드 → egress 프록시. `ANTHROPIC_API_KEY`**서버 env only** — DB·코드·커밋·로그·응답 기록 금지. 실패 시 Ollama 폴백 |
| `generativelanguage.googleapis.com` | Gemini 나노바나나 이미지 생성(M5) | **⚠️ 미승인 — G1 게이트**(PLANNING R12) | **나노바나나 Python 워커에서만** egress. `GEMINI_API_KEY`는 워커 env only(백엔드 미취급). **G1 소유자 승인 전 실호출·배포 금지** → 미승인 시 워커 목/degraded(import·구조만 성립). 승인 시에도 워커 존 단일 경로만 개방 |
| PG사 결제 API | M9 정산·결제, M10 티켓, M15 발주 | 결제 필수(도메인 확정 G2) | 백엔드 → egress. 콜백은 인바운드(§4-2). 키·상점ID env only |
| SMTP | 2차 인증 EMAIL 코드·알림 발송 | 필요 | 지정 SMTP host:port만. 미설정 시 로컬 로그 모드 |
| 온프레미스 모델(Ollama 등) | AI 폴백·임베딩 | 내부(egress 아님) | AI 워커/내부망 내부 통신 — 외부 나가지 않음 |
**아웃바운드 통제 원칙**:
1. **단일 egress 게이트웨이** 경유 — 각 서비스가 임의 외부 IP로 직접 나가지 못한다. 목적지 도메인 allowlist로만 통과.
2. **키 격리 = 네트워크 격리와 정합**: Gemini 키는 워커 존에만, Claude/PG 키는 백엔드에만. 데이터 존은 아웃바운드 자체가 없어 어떤 키도 외부로 나갈 수 없다.
3. **G1 게이트 강제**: Gemini egress 룰은 G1 승인 이전엔 **비활성(차단)** 상태로 배포. 승인 티켓 없이 열리지 않도록 devops가 방화벽/프록시 룰을 게이트에 종속.
4. **감사**: 전 아웃바운드는 목적지·시각·서비스만 로깅(요청 본문·키·응답 미기록 — 자격증명 보호 원칙).
---
## 6. 부하분산 · TLS 종단 · API 게이트웨이
### 6-1. 로드밸런싱
- **공개 LB(DMZ)**: `www/expo`·`visitor`·공개 API GW 앞단. SSR 렌더 인스턴스와 정적 에셋(CDN 오프로드)을 분리. 관람객 대량 트래픽(개막일 사전등록·체크인 피크, M10)을 수평 확장 대상으로 본다.
- **내부 LB(내부망)**: 공유 Spring Boot 백엔드 앞단. organizer·exhibitor·contractor·ops·admin 트래픽을 라우팅. 무상태 세션(JWT) 전제로 라운드로빈/최소연결 + 헬스체크.
- **WebSocket/STOMP**: 나노바나나 완료 푸시·옥션 실시간 순위(M15)는 WS 스티키/전용 경로 필요 — LB에서 WS 업그레이드·타임아웃·스티키 정책 별도(TA `tech.md`와 정합).
- **AI 워커**: HTTP 인입 대상 아님(Redis 큐 소비형) — LB 대상 아님, 워커 수평 확장은 큐 컨슈머 스케일로 처리.
### 6-2. TLS 종단
- **모든 외부 인바운드는 LB(nginx)에서 TLS 종단**. 인증서는 공개 도메인(공개사이트)과 인증 포털 도메인 각각 관리(SAN/멀티도메인 또는 개별).
- 종단 후 내부는 사설망 평문 또는 존 간 mTLS(옵션). **DMZ→내부망 호출은 mTLS 권장**(공개 영역이 내부 API를 사칭 호출하지 못하도록).
- HSTS·TLS1.2+ 강제·약한 cipher 비활성. `admin.`·옥션 등 민감 경로는 최신 TLS만.
### 6-3. API 게이트웨이 (공개/내부 분리)
- **공개 API GW(DMZ)**: 화이트리스트 엔드포인트만 — 관람객 등록·조회·티켓·wayfinding 조회·PG 콜백. **쓰기·관리·설계·옥션 엔드포인트 노출 금지**. 레이트리밋·봇 방어 1차.
- **내부 API GW(내부망)**: 인증 필수 전 기능. SSO/JWT 검증 → 역할·행사 RBAC(§8-1) → 백엔드 라우팅. 옥션(M15)·설계(M2~M5)·BI(M16)·admin(M18) 전부 여기.
- 두 GW는 **동일 공유 백엔드**를 바라보되 **노출 표면이 다르다**(공개는 극히 일부, 내부는 전체). 이 분리가 최소권한 네트워크의 핵심.
---
## 7. DDoS · 레이트리밋 (공개 트래픽)
공개 영역은 불특정 다수 노출 → 가용성 위협 상수. 계층 방어한다.
| 계층 | 대상 | 정책 |
|---|---|---|
| 엣지(WAF/CDN) | 볼류메트릭 DDoS(L3/4), 정적 에셋 | CDN 캐시로 오리진 보호, SYN/UDP 플러드 스크러빙, 지오/IP 평판 필터 |
| 공개 LB | L7 요청 폭주 | 커넥션·요청률 상한, slowloris 타임아웃, 동시연결 캡 |
| 공개 API GW | 엔드포인트별 레이트리밋 | 등록·티켓·비즈매칭은 IP/계정별 토큰버킷. **개막일 피크(M10 체크인)** 대비 버스트 허용치 별도 |
| 애플리케이션 | 남용 방지 | 로그인·2FA·PG 콜백은 강한 레이트리밋 + 실패 잠금(§8-1 로그인 실패 잠금과 정합) |
- **옥션(M15) 특수**: 마감 직전 응찰 폭주·자동응찰 스크립트 가능성 → 응찰 API는 계정별 레이트리밋 + 서버 권위 타임스탬프(클라이언트 시간 불신) + 이상 패턴(초당 다중 인하) 플래그.
- **관람객 체크인 오프라인 폴백**(PLANNING M10 한계): 현장 네트워크 불안정·DDoS 시에도 QR 체크인은 오프라인 대기열·후동기화가 가능해야 함 — 네트워크 장애가 현장 운영 정지로 직결되지 않도록 클라이언트 폴백을 전제.
---
## 8. 세그먼테이션 · 격리
1. **행사(Event) 단위 데이터 격리**(PLANNING R10): 부스 설계는 경쟁사 민감 정보. 네트워크가 아니라 **애플리케이션 RBAC + 데이터 존 접근 계정 최소화**로 격리(같은 백엔드 안에서 행사별 인가). 네트워크 세그먼트는 존 단위, 테넌트 격리는 인가 계층 — 이중.
2. **서비스 계정 분리**: 백엔드·워커·BI 복제는 **각기 다른 DB 계정/최소 권한**으로 데이터 존 접근(워커는 OBJ 쓰기만, BI는 읽기전용 복제만). 한 서비스 침해가 전 데이터로 번지지 않게 한다.
3. **BI 부하·경로 분리**(PLANNING §8-1): 운영 DB 보호를 위해 BI(M16)는 **읽기전용 복제/스냅샷(KpiSnapshot)** 을 별도로 바라본다 — 운영 트래픽과 분석 트래픽의 경로 분리.
4. **관리 평면 분리**: SSH·운영 접근은 배스천 경유만(직접 접근 금지). 관측성(로그·메트릭)은 관리 존에 수집하되 로그에 자격증명·PII·스택트레이스 미기록(에러 응답 = 요약만, GUARDiA 보안 원칙 정합).
5. **모바일 채널**: 참가업체 리드캡처·홀매니저 현장 검수·업체 반입 QR·관람객 배지(§2-1)는 각 포털의 API 표면만 사용 — 모바일이라고 별도 백도어 없음. 공개(visitor)는 DMZ, 인증(exhibitor/ops/contractor 모바일)은 내부 API GW.
---
## 9. 폐쇄망 · 온프레미스 제약 반영
- **egress 기본 차단**은 폐쇄망 운영 기관 배포를 겨냥한 기본값이다. 승인 4목적지(Claude·Gemini(G1)·PG·SMTP) 외 외부 통신이 없어야 정상.
- **완전 폐쇄망(외부 API 전면 불가) 시나리오**: Claude/Gemini 미허용 환경에서는 온프레미스 모델(Ollama)로 폴백 — 텍스트 AI는 AiTextRouter가 자동 폴백, 이미지 생성(M5)은 온프레미스 대체(SDXL 등) 어댑터 검토(PLANNING R12 완화책). 이 경우 egress 게이트웨이의 외부 룰 전량 비활성.
- **GUARDiA 인프라 비침투**: kintex는 GUARDiA ITSM 관제망·`101.79.17.164`와 트래픽·자격증명·DB를 공유하지 않는다(별개 도메인·별개 존). 상호 egress/인바운드 룰 없음.
- **내부 모델 서버**는 AI 워커 존 내부 통신(외부 egress 아님) — RAM 제약(온프레미스 소형모델) 고려는 TA/AI 트랙, 네트워크는 내부 경로만 보장.
---
## 10. 미결·후속(devops·SA 정합 필요)
| # | 항목 | 의존 |
|---|---|---|
| N-1 | 실제 도메인·서브도메인·포트·인증서 발급 | **G2 게이트**(배포 대상 서버·도메인 확정) |
| N-2 | Gemini egress 방화벽 룰 활성화 | **G1 게이트**(소유자 승인) — 승인 전 차단 상태 배포 |
| N-3 | 존 물리 구현(VLAN/보안그룹/네임스페이스) vs 논리 분리 선택 | SA `system.md`(A-2) 배포 토폴로지와 정합 |
| N-4 | CDN·WAF 벤더·DDoS 스크러빙 사업자 선정 | devops·비용 |
| N-5 | mTLS(DMZ↔내부망) 적용 범위·인증서 회전 | TA `tech.md`(A-3) |
| N-6 | 관측성 수집 경로·보존정책(PII/자격증명 미기록 검증) | TA·QA |
| N-7 | BI 읽기전용 복제 토폴로지(복제 지연·경로) | DA `data.md`(A-4) |
> 본 문서는 네트워크 **설계·정책·존 계약**을 정의한다. 실제 nginx/방화벽/보안그룹/CI·CD 설정 코드는 devops(DEV)가 Phase E에서 구현하며, 존 모델·아웃바운드 게이트·2영역 분리 원칙은 본 문서를 계약으로 준수한다.

355
docs/architecture/system.md Normal file
View File

@ -0,0 +1,355 @@
# 킨텍스 자동전시시스템 — 시스템 아키텍처 · 비기능요건(NFR)
> 산출: 시스템 아키텍트(SA) · 작성일: 2026-07-11 · 대상 백로그: `IMPLEMENTATION_BACKLOG.md` **A-2**
> 정합 근거: [`../PLANNING.md`](../PLANNING.md) v2.0 §2(6역할)·§7(데이터·연동)·§8(아키텍처)·§10(리스크) · [`../IMPLEMENTATION_BACKLOG.md`](../IMPLEMENTATION_BACKLOG.md) · [`../analysis/reroomai-source.md`](../analysis/reroomai-source.md)(나노바나나 워커)
> 확정 스택(불변): React 18/19(Vite·TS) + Spring Boot 3.x(Java 17)+MyBatis + PostgreSQL(PostGIS) + Redis + 나노바나나 Python 워커. — `../DEVELOPMENT_GUIDE.md` §1, `../PLANNING.md` §8
> **문서 소유권**: 본 문서는 SA(시스템 아키텍처·NFR·구성·배포 토폴로지)만 정본. 애플리케이션 경계/API 표준=AA([`app.md`](app.md), A-1), 기술 표준/빌드·관측성=TA([`tech.md`](tech.md), A-3), 전사 ERD/공간·마스터·BI마트=DA([`data.md`](data.md), A-4), DMZ/방화벽/부하분산=NA([`network.md`](network.md), A-5). 본 문서는 그 상위 시스템 관점만 다루며 세부는 각 문서로 위임(교차참조).
---
## 0. 선행 게이트 (SA 반영 — 착수 전 소유자 확인)
| 게이트 | 내용 | 시스템 아키텍처 영향 |
|---|---|---|
| **G1** | 나노바나나(Gemini `generativelanguage.googleapis.com`) 외부 호출 승인(PLANNING R12) | 미승인 시 나노바나나 워커는 **목(mock)/degraded 모드**로 기동(구조·큐·워터마크 계약은 유지, 실이미지 생성만 차단). 승인 시 워커 서비스에만 `GEMINI_API_KEY` env 주입(§6-4). **아웃바운드 방화벽 정책은 워커 서브넷 한정** — NA 확정 |
| **G2** | 배포 대상 서버·포트(GUARDiA 인프라와 **별개 도메인**) | 배포 토폴로지(§4)의 물리 매핑·도메인·포트·TLS는 G2 확정 후 Phase E에서 실체화. 본 문서는 **논리 토폴로지**를 확정하고 물리값은 플레이스홀더(`<kintex-domain>`) 처리 |
> 두 게이트는 **아키텍처 설계를 막지 않는다** — 논리 구성·NFR·용량 산정은 게이트와 무관하게 확정하고, 물리 실체화(실호출·실배포)만 게이트에 종속시킨다.
---
## 1. 시스템 개요
킨텍스 자동전시시스템은 전시 생애주기(판매·기획 → 설계·시각화 → 발주·계약 → 참가·관람 → 현장운영 → 사후·경영)를 하나의 데이터·계정 체계로 자동화하는 **베뉴 운영 플랫폼**이다. 시스템은 **6개 역할별 분리 프론트 + 단일 공유 Spring Boot 백엔드 + 나노바나나 Python 워커 사이드카**를 **SSO·역할 RBAC** 위에 얹고, **PostGIS 공간 데이터 · Redis 비동기 큐 · 오브젝트 스토리지**를 공유 자원으로 둔다.
**아키텍처 대원칙 (PLANNING §4·§8 정합)**
1. **단일 공간 데이터 모델** — 부스 폴리곤·트렌치 포인트·배선 LineString을 PostGIS 지오메트리로 일원화. 시각화(M5)·검증(M2)·정산(M9)·wayfinding(M13)·BI(M16)가 같은 원천을 재사용.
2. **이미지 생성 전면 비동기** — Spring이 RenderJob을 Redis 큐에 발행 → Python 워커가 소비·생성 → 오브젝트 스토리지 적재 → WebSocket 완료 푸시. 백엔드는 오케스트레이션·상태만, `GEMINI_API_KEY`는 워커 전유.
3. **역할별 프론트 분리** — 최소권한·공격면 축소. 공유 디자인 시스템·컴포넌트·API 계약을 상속(중복 구현 금지).
4. **룰셋은 코드가 아닌 버전 데이터** — 요율·규정을 버전 파일로 두어 킨텍스 연 단위 개정을 무중단 반영.
5. **공개 vs 내부 보안영역 분리** — 공개 홍보/관람객(비인증·쓰기제한)과 내부 백오피스(관리자·크로스테넌트)를 물리·논리로 격리.
---
## 2. 논리 아키텍처 (컴포넌트 구성도)
```mermaid
graph TB
subgraph EDGE["엣지 / 공개영역 (DMZ)"]
CDN[CDN · 정적 캐시<br/>공개 이미지·번들·플로어플랜]
WAF[WAF / Reverse Proxy · nginx<br/>TLS 종단·라우팅·레이트리밋]
end
subgraph FRONT["역할별 분리 프론트 (React·Vite·TS, 공유 디자인시스템)"]
FO[organizer. 주최자 콘솔]
FE[exhibitor. 참가업체 포털]
FC[contractor. 업체 포털·옥션]
FM[ops. 운영 대시보드]
FA[admin. 관리자 백오피스<br/>★내부 전용]
FP[www/expo. 공개 홍보 사이트<br/>SSR/SSG·SEO·다국어]
FV[관람객 모바일/웹<br/>배지·wayfinding·매칭]
end
subgraph GATE["SSO · 역할 RBAC · API 진입"]
SSO[JWT SSO + TOTP 2FA<br/>플랫폼·행사 이중 RBAC 평가]
end
subgraph BACKEND["공유 Spring Boot 3.x 백엔드 (Java 17·MyBatis)"]
API[REST + WebSocket/STOMP]
RULE[룰 엔진 · 요율/규정 버전셋]
LAYOUT[배치·배선 엔진 · PostGIS 공간 SQL]
AUC[옥션 엔진 M15 · 라운드·순위·낙찰스코어]
BI[BI 집계 M16 · KpiSnapshot]
CMS[CMS·다국어 M17]
PUBSVC[공개 API · 사전등록·조회 read-only]
end
subgraph ASYNC["비동기 처리"]
REDIS[(Redis<br/>작업 큐·순위·타이머·캐시·쿼터·세션)]
NBW[나노바나나 Python 워커<br/>tools/nanobanana · google-genai]
DOCW[서류·PDF·EDM 워커<br/>동일 큐 계약]
end
subgraph DATA["데이터 계층"]
PG[(PostgreSQL + PostGIS<br/>업무·공간 데이터)]
RPL[(읽기 복제본<br/>BI·공개조회 오프로드)]
OBJ[(오브젝트 스토리지<br/>도면·생성이미지·서식·콘텐츠)]
end
subgraph EXT["외부 시스템 / 게이트"]
GEM[Google Gemini<br/>나노바나나 · ★G1]
CLA[Anthropic Claude<br/>AiTextRouter · env only]
PGPAY[PG 결제 · 세금계산서]
KXWP[kxwp/kxfp 작업신고<br/>파일 릴레이]
CCPY[등록업체 DB 739]
EVT[행사일정 시스템]
end
CDN --> WAF
FP -.CDN 캐시.-> CDN
FO & FE & FC & FM & FA & FP & FV --> WAF --> SSO --> API
WAF -->|공개 read-only| PUBSVC
API --> RULE & LAYOUT & AUC & BI & CMS
API --> REDIS
REDIS --> NBW & DOCW
NBW --> OBJ
NBW -.WebSocket 완료푸시.-> API
NBW -.★G1.-> GEM
DOCW --> OBJ
API --> PG
BI --> RPL
PUBSVC --> RPL
API -.AiTextRouter.-> CLA
API --> PGPAY
API -.파일 릴레이.-> KXWP
API --- CCPY & EVT
PG -.복제.-> RPL
```
### 2-1. 컴포넌트 책임 (시스템 관점)
| 컴포넌트 | 실행 단위 | 책임 | 확장 방식 |
|---|---|---|---|
| **역할별 프론트 6종+공개** | nginx 정적 서빙(역할별 dist 번들) | organizer·exhibitor·contractor·ops·admin(내부)·public/visitor. SPA. 데스크톱=설계·에디터, 모바일=현장·조회·승인 | CDN + 정적 스케일(무상태) |
| **공개 홍보 사이트(M12·M17)** | SSR/SSG 렌더 경로 + CDN | SEO·다국어(한/영/중/일)·hreflang·사이트맵, 공개 플로어플랜(M2 부산물) | CDN 캐시·정적 재생성, 인증영역과 별도 |
| **공유 백엔드(Spring Boot jar)** | `java -jar`(systemd, N 인스턴스) | REST+WebSocket, 룰/배치/배선/옥션/BI/CMS 서비스, 큐 발행·상태·콜백 | **수평 확장(무상태)** — 세션·순위·타이머는 Redis 외부화 |
| **나노바나나 워커** | Python 데몬(systemd, M 인스턴스) | Redis 큐 소비 → Gemini image-to-image → 워터마크 후처리 → OBJ 적재 → WebSocket 콜백. 재시도·비용상한·스키마해시 캐시·쿼터(성공 시 차감) | **큐 깊이 기반 수평 확장**(GPU/비용 독립 스케일) |
| **서류·PDF·EDM 워커** | Python/Java 데몬(동일 큐 계약) | 서식(HWP/PDF)·견적서 PDF(M15)·EDM 발송 비동기 | 큐 깊이 기반 확장 |
| **Redis** | 관리형/전용 노드(HA) | 작업 큐, 옥션 실시간 순위·라운드 마감 타이머, 응답 캐시·스키마해시 캐시, RenderJob 쿼터, WebSocket 세션 상관 | Sentinel/Cluster(§3-2) |
| **PostgreSQL+PostGIS** | 프라이머리 + 읽기 복제 | 업무·공간 데이터 단일 진실원천(Flyway 스키마) | 프라이머리 수직 + 읽기 복제 수평(BI·공개조회 오프로드) |
| **오브젝트 스토리지** | S3 호환/파일 스토어 | 도면·생성이미지·서식·콘텐츠·마이크로사이트 자산 | 스토리지 독립 확장 + CDN 프론팅 |
> **AA 위임**: 모듈 경계·패키지·API 표준·응답 봉투는 [`app.md`](app.md). 본 표는 **배포 단위·확장 특성** 관점만.
---
## 3. 비기능요건(NFR)
> 정량 목표는 킨텍스 실측(홀당 200~600부스, 홀 10개, 대형 행사 수만 관람객)과 PLANNING §10 리스크(R6 이미지 비용·지연)를 근거로 산정. 값은 **초기 목표치**이며 파일럿(Phase 1) 실측으로 보정한다.
### 3-1. 확장성 (Scalability)
| 항목 | 설계 | 근거·목표 |
|---|---|---|
| **백엔드 수평 확장** | Spring 인스턴스 무상태화 — JWT(자족적)·세션 없음, 옥션 순위·타이머·쿼터·캐시는 **Redis 외부화**. WebSocket은 STOMP+Redis 브로커 릴레이(다중 인스턴스 팬아웃) | 인스턴스 N대 로드밸런싱, 무중단 스케일아웃 |
| **워커 독립 확장** | 나노바나나·서류·EDM 워커는 백엔드와 **분리 스케일** — 큐 깊이(대기 Job 수) 기반 인스턴스 증감 | 이미지 생성 피크(행사 오픈 전 대량 컨펌)를 백엔드 응답성과 무관하게 흡수 |
| **읽기 부하 오프로드** | BI 집계·공개사이트 조회·플로어플랜 열람은 **읽기 복제본** 라우팅. 운영 프라이머리는 쓰기 트랜잭션 보호 | BI(M16) 무거운 집계가 운영 DB를 압박하지 않음(PLANNING §8-1) |
| **공개 트래픽 흡수** | 공개 홍보 사이트·공개 플로어플랜·이미지는 **CDN·SSG 캐시**로 오리진 오프로드. 사전등록만 오리진 write | 관람객 트래픽 급증(행사 D-데이) 시 오리진 보호 |
| **데이터 파티셔닝 여지** | 행사(Event) 단위 격리(§5) → 대량 데이터(리드·체크인·RenderJob)는 행사·기간 파티션 가능(DA 후속) | 다년·다행사 누적 확장 |
| **멀티테넌시** | 행사 단위 워크스페이스 + 플랫폼 RBAC. 신규 행사는 데이터·권한만 추가(코드·인프라 불변) | 무제한 행사 온보딩 |
### 3-2. 가용성 · HA (Availability)
| 계층 | HA 설계 | 목표 |
|---|---|---|
| **프론트/공개** | 정적 번들 CDN 다중 엣지 + nginx 다중화 | 단일 노드 장애 무영향 |
| **백엔드** | 최소 2 인스턴스 + 헬스체크(`GET /health`) 로드밸런싱. 롤링 배포(무중단) | 무중단 배포·인스턴스 장애 격리 |
| **Redis** | Sentinel(자동 failover) 또는 Cluster. 큐 손실 방지 위해 **AOF 지속화** + 소비자 ACK/재큐 | 프라이머리 다운 시 자동 승격, 진행 중 Job 유실 0 |
| **PostgreSQL** | 프라이머리-스탠바이 스트리밍 복제 + 자동 failover(Patroni/관리형). PITR 백업 | RPO 최소·RTO 분 단위 |
| **워커** | 무상태 소비자 N대. 처리 중 죽으면 **가시성 타임아웃 후 재큐**(멱등 Job) | 워커 장애가 사용자 응답 차단 안 함(비동기) |
| **오브젝트 스토리지** | 복제·버전 관리 스토리지 | 자산 내구성 보장 |
| **Fail-Safe 배포** | 백업 → 배포 → 헬스체크(200) → 실패 시 롤백(이전 jar 유지). clean bootJar 검증 후 교체 | 깨진 배포가 서비스 중단 유발 안 함(`../BUILD_DEPLOY.md`) |
| **degraded 모드** | G1 미승인/Gemini 장애 시 워커 목 응답(구조·워터마크 유지). Claude 실패 시 Ollama 폴백(AiTextRouter). PG 복제 지연 시 프라이머리 폴백 | 외부 의존 장애 시에도 코어 기능 유지 |
> **HA 목표 SLO(초기)**: 코어 인증·설계·조회 경로 **99.5%**(행사 성수기 99.9% 지향). 나노바나나 생성은 **best-effort 비동기**(SLA 대상 아님, 큐 소진 목표만). — 구체 SLO/알림 임계는 TA 관측성([`tech.md`](tech.md))과 연계.
### 3-3. 성능 (Performance)
**(a) 대량 부스 · 공간 연산**
| 시나리오 | 부하 특성 | 설계 대응 | 목표 |
|---|---|---|---|
| 플로어플랜 3안 생성(M2) | 홀당 200~600부스 제약 솔버 | 결정적 솔버(비-LLM) + 서비스 계층 연산, LLM은 조건 해석만. 무거운 생성은 필요 시 비동기화 | 3안 생성 수 분 내(PLANNING 목표) |
| 규정 검증·최단 배선(M2/M4) | PostGIS 버퍼·거리·KNN(`<->`) 연산 | 공간 인덱스(GiST) + 매퍼 XML `ST_*` 최적화. 홀 단위 스코프 쿼리 | 배치·배선 상호작용 P95 < 2s |
| 부스 목록·조회 | 대형 행사 수천 부스 그리드 | 페이지네이션(PageResponse) + 읽기 복제 + 캐시 | 목록 P95 < 500ms |
**(b) 이미지 생성 큐 (나노바나나)**
| 항목 | 산정 | 설계 |
|---|---|---|
| 생성 지연 | 단건 평균 ~40s(design 배지), Gemini 왕복 의존 | **전면 비동기** — 사용자는 진행 배지·WebSocket 완료 푸시 수신. 동기 대기 없음 |
| 대량 동시 요청 | 홀당 200~600부스 × S1·S7 자동(PLANNING R6) | **자동 생성은 S1·S7 한정**, 나머지 온디맨드. **동일 스키마해시 캐시**로 재생성 회피. 행사별 **RenderJob 쿼터**(성공 시에만 차감) |
| 처리량 스케일 | 워커 M대 병렬, 큐 깊이 기반 증감 | 피크 시 워커 스케일아웃으로 큐 소진 시간 제어. Gemini 쿼터·비용 상한은 워커 레벨 관리 |
| 전처리 절감 | 도면/현장사진 클라이언트 Canvas **긴 쪽 1024px 다운스케일 + JPEG 0.85**(ReRoomAI 실증) | 전송량·모델 비용·지연 동시 절감 |
| S6 배선 오버레이 | 좌표 정확성 목적 | **백엔드 래스터 합성 우선**(Gemini 미경유 결정적 산출) — 생성 비용·지연에서 제외 |
**(c) 공개사이트 트래픽**
| 항목 | 설계 | 목표 |
|---|---|---|
| 공개 홍보/플로어플랜 | SSR/SSG + CDN 캐시, 오리진 캐시 미스만 read-replica | 캐시 히트 P95 < 200ms, 트래픽 스파이크 CDN 흡수 |
| 사전등록 write | 오리진 write(멱등·중복 등록 가드) + 큐잉 완충(배지 발급 비동기) | 등록 급증 시 write 완충 |
| 실시간 옥션 순위(M15) | Redis 정렬셋 순위 + WebSocket 델타 푸시, 라운드 마감 타이머 | 순위 갱신 < 1s, 다중 인스턴스 팬아웃 |
### 3-4. 용량 산정 (Capacity — 초기 근사)
> 단일 대형 행사 기준. 킨텍스 대형 홀(홀7/8 각 510부스, 홀9/10 ~600부스), 대형 행사 관람객 수만 명 가정. **Phase 1 파일럿 실측으로 교정**.
| 자원 | 산정 기준 | 초기 용량 |
|---|---|---|
| **부스 데이터** | 대형 행사 3,000~5,000부스 × (폴리곤 + DesignPlan 버전 + UtilityOrder 배선) | 행사당 수만 지오메트리 레코드 — GiST 인덱스 필수 |
| **RenderJob/이미지** | 부스 3,000 × 자동 2샷(S1·S7) + 온디맨드 α, 이미지 평균 0.5~2MB(1024px) | 행사당 ~수천~1만 이미지, 수 GB~수십 GB → OBJ + CDN. 캐시로 재생성 억제 |
| **리드·체크인(M10)** | 관람객 수만 × 체크인 이벤트 + 참가업체 리드캡처 | 행사당 수만~수십만 이벤트 로우 → 파티셔닝 후보(개인정보 §5) |
| **동시 사용자** | 설계 피크(주최자·업체) 수백 + 공개/관람객 수천~수만(D-데이) | 인증영역 수백 동시(백엔드 N대) / 공개영역 CDN 흡수 |
| **Redis** | 큐 대기 Job + 옥션 순위셋 + 캐시 + 쿼터 카운터 | 수 GB. 큐 백로그 상한·모니터링 |
| **DB 연결풀** | 공유 백엔드 다인스턴스 × Hikari 풀 | **Hikari max 제한 + PgBouncer 권고**(GUARDiA 실측 교훈: 공유 PG `max_connections` 포화 방지). 인스턴스 수 × 풀 ≤ PG 상한 |
| **오브젝트 스토리지** | 도면 + 이미지 + 서식 + 콘텐츠, 다년 누적 | 행사당 수십 GB, 보존정책(§5-3) 기반 아카이빙 |
---
## 4. 배포 토폴로지
> **논리 토폴로지 확정**. 물리 서버·도메인·포트·TLS는 **G2 확정 후 Phase E**(`E-DEP`, `kintex-devops-dev`)에서 실체화. DMZ/방화벽/부하분산 세부는 NA([`network.md`](network.md)).
### 4-1. 배포 존(Zone) 구성
```mermaid
graph TB
subgraph PUBZ["공개 존 (DMZ / 인터넷 노출)"]
LB1[Reverse Proxy / WAF · nginx · TLS]
CDNZ[CDN 엣지]
PUBFE[공개 프론트 · SSR/SSG 정적]
end
subgraph APPZ["애플리케이션 존 (내부망)"]
APPFE[인증 프론트 정적 서빙<br/>organizer·exhibitor·contractor·ops]
APPBE[공유 백엔드 jar × N · systemd]
WKZ[워커 데몬 × M · systemd<br/>나노바나나 · 서류/EDM]
end
subgraph MGMTZ["관리 존 (내부 전용 · 접근 제한)"]
ADMFE[admin. 백오피스 프론트]
end
subgraph DATAZ["데이터 존 (내부, 최심부)"]
PGZ[(PostgreSQL+PostGIS<br/>프라이머리+스탠바이)]
RPLZ[(읽기 복제본)]
REDISZ[(Redis HA)]
OBJZ[(오브젝트 스토리지)]
end
subgraph EGRESS["아웃바운드 게이트 (워커/백엔드 한정)"]
OUT[허용 아웃바운드<br/>Gemini(G1)·Claude·PG결제·kxwp]
end
CDNZ --> PUBFE
LB1 --> PUBFE & APPFE & ADMFE
APPFE & ADMFE --> APPBE
APPBE --> PGZ & REDISZ & RPLZ & OBJZ
WKZ --> REDISZ & OBJZ
WKZ --> OUT
APPBE --> OUT
PGZ -.복제.-> RPLZ
```
### 4-2. 배포 단위(산출물) — `../BUILD_DEPLOY.md` §1 정합
| 단위 | 빌드 | 산출물 | 실행 | 배포 존 |
|---|---|---|---|---|
| 인증 프론트 6종 | `vite build`(역할별 번들) | `dist/`(organizer·exhibitor·contractor·ops·admin) | nginx 정적(SPA 폴백) | APP존(admin은 MGMT존) |
| 공개 프론트 | SSR/SSG 빌드 | 정적/렌더 번들 | CDN + 엣지 렌더 | 공개존 |
| 공유 백엔드 | `./gradlew bootJar`(JDK17) | `kintex-*.jar` | `java -jar` systemd × N | APP존 |
| 나노바나나 워커 | (빌드 없음) | `tools/nanobanana` | 큐 소비 데몬 systemd × M | APP존(아웃바운드 게이트) |
| 서류/EDM 워커 | — | 워커 모듈 | 큐 소비 데몬 | APP존 |
- **배포 흐름**(GUARDiA 표준 준용): `workspace/kintex` → git push → Gitea(`zio/kintex`) → webhook → deploy_server → 빌드(`bootJar`·`vite build`) → Flyway 마이그 → jar 재기동·dist 배포 → 헬스 게이트(`GET /health` 200) → 실패 시 롤백.
- **systemd 유닛**: 백엔드 jar · 워커 데몬 각각 유닛(부팅 자동기동·재시작). AI env drop-in(`ANTHROPIC_API_KEY`·`ADMIN_PASSWORD_ENC`)은 표준 프레임워크 §7 방식. **`GEMINI_API_KEY`는 나노바나나 워커 유닛에만** 주입(백엔드·프론트 미취급).
- **nginx vhost**: `<kintex-domain>``/`=프론트 정적, `/api/`·`/ws`=백엔드 포트. 관리자 백오피스(admin.)는 **별도 vhost + 접근 제한**(§5-1). TLS certbot. — 도메인·포트=G2.
- **환경 분리**: dev / staging / prod. staging에서 헬스·마이그·경계면 검증 후 prod 승격(운영 배포는 소유자 승인 필수).
> CI/CD 파이프라인 상세(deploy_server·롤백·webhook)는 **Phase E `kintex-devops-dev`** 정본. TA([`tech.md`](tech.md))의 빌드·관측성 표준과 연계.
---
## 5. 보안영역 · 데이터 보존 · 개인정보 경계
### 5-1. 보안영역 분리 (공개 vs 내부 백오피스)
| 영역 | 대상 | 노출 | 인증 | 격리 |
|---|---|---|---|---|
| **공개 영역** | www/expo 홍보사이트, 공개 플로어플랜, 사전등록, 관람객 앱 | 인터넷(DMZ) | 비인증 또는 셀프서비스(쓰기 제한) | 별도 렌더 경로·read-only API·행사 데이터 write 불가 |
| **인증 업무 영역** | organizer·exhibitor·contractor·ops | 인증 후 접근 | JWT SSO + TOTP 2FA + 행사 RBAC | 행사 단위 데이터 격리(§5-2) |
| **관리 영역(백오피스)** | admin. — 사용자·RBAC·마스터데이터·룰셋·감사로그·크로스테넌트 | **내부 전용**(웹 전용, 모바일 미제공) | ADMIN 역할 + 2FA 필수 + **접근 제한**(내부망/허용 IP — NA 확정) | 별도 vhost·존, 공개영역과 물리·논리 분리 |
- **최소권한·공격면 축소**: 6개 프론트 번들 분리로 역할별 코드·권한 최소화. 백오피스는 공개 인터넷에 노출하지 않는다.
- **이중 RBAC 평가**: 플랫폼 레벨(관리자)과 행사 레벨(주최자/참가/업체/홀매니저)을 JWT 클레임(`roles` eventId→역할, `hm` 홀매니저)으로 이중 평가. `/api/admin/**`·`/api/system/**` = `hasRole(ADMIN)`(PLANNING §5B-1, `../DEVELOPMENT_GUIDE.md` §4).
- **인증 스택(UIWS 표준 이식)**: JWT(HS256) + TOTP(RFC6238 SHA1·30s·6자리·±1) 2차 인증 + 로그인 실패 잠금 + admin 비번 env(`ADMIN_PASSWORD_ENC` AES-256-GCM + 별도 키파일 재시드, `admin123` 하드코딩 금지). 대상: 관리자·홀매니저·주최자·업체(내부/발주 권한) 2FA 필수, 관람객 셀프서비스 선택.
### 5-2. 데이터 격리 (행사 단위 · 영업비밀)
- **행사(Event) 단위 워크스페이스**: 부스 설계는 경쟁사에 민감(PLANNING R10). 부스 데이터 접근은 **소유 참가업체 + 주최자 + 홀매니저**로 한정. 모든 도메인 경로는 `{eventId}` 스코프 + RBAC 가드.
- **옥션(M15) 자료 격리**: AI 설계자료(M2~M5)는 옥션 초대된 **킨텍스 등록업체(M7 검증 통과)**만 열람. 미등록 업체 응찰 원천 차단(`NOT_REGISTERED_COMPANY` 403).
- **BI 관점 격리**: 참가업체 관점 ROI(자기 부스)와 운영사 관점 수익성(전 행사)은 **별도 대시보드·권한**으로 격리(PLANNING M16-1).
### 5-3. 개인정보 경계 · 데이터 보존
| 데이터 | 개인정보 등급 | 처리 원칙 | 보존 |
|---|---|---|---|
| 관람객 등록·배지·체크인·리드(M10) | **개인정보(PLANNING R10)** | 수집 시 **동의** 필수. 리드 접근은 감사로그(`TB_AUDIT_LOG`) 전수 기록. 참가업체는 자기 리드만 | **보존정책 명시**(행사 후 N개월, 동의 철회·삭제권 지원 — DA 확정) |
| 부스 설계·도면·생성이미지 | 참가업체 자산·영업비밀 | 행사 단위 격리, 응답에서 내부 식별자·해시 shape 제외 | **행사 종료 후 보존 정책 별도**(참가업체 자산, PLANNING §8) |
| 결제·정산(M9) | 금융·과세 정보 | PG 위임(카드정보 비보관), 세금계산서 연동 | 법정 보존 기간 준수 |
| 자격증명·비밀·API키 | 최고 민감 | **응답·로그·에러·커밋에 절대 노출 금지**. `GEMINI_API_KEY`·`ANTHROPIC_API_KEY`·OTP 시크릿·비번 해시 미노출. AES-256-GCM 저장, 비번 BCrypt | env only, DB·코드 미기록 |
| AI 생성 이미지 | 오인 위험(R1) | **워터마크 강제**("AI 생성 예상 이미지…") + 메타데이터. 계약·심사 서류 자동 배제. 제거 불가 | 캐시·스키마해시 관리 |
- **보안 불변(위반=QA 반려)**: 스택트레이스 차단(`GlobalExceptionHandler`·`DataAccessException` 핸들러), 민감필드 응답 제외, AI 워터마크 항상 포함, 등록업체 응찰 가드, admin env 시드. — `../DEVELOPMENT_GUIDE.md` §5 계약.
- **감사 추적**: 승인·낙찰(M15)·설계 변경·룰셋 개정·리드 접근(개인정보) 전수 `TB_AUDIT_LOG` 기록(PLANNING §5B-1).
---
## 6. 연동 아키텍처
### 6-1. SSO · 역할 RBAC (6역할)
- **단일 JWT SSO** 위에 플랫폼 레벨 + 행사 레벨 권한 이중 평가. 6역할: 주최자·참가업체·장치/공사업체·킨텍스 직원(홀매니저·운영)·관리자·관람객/일반대중.
- 역할별 프론트는 동일 SSO·API 계약을 상속하되 **번들·도메인 분리**. 백오피스(admin.)는 내부 전용·2FA 필수(§5-1).
- 등록업체 계정은 **M7 등록업체 DB 검증** 통과분만 초대·옥션 응찰. 관람객·일반대중은 셀프서비스(쓰기 제한).
- 인증 상세 계약은 [`app.md`](app.md)(AA)·`../DEVELOPMENT_GUIDE.md` §4.
### 6-2. 외부 게이트 (아웃바운드 통제)
| 연동 | 경로 | 통제 | 게이트 |
|---|---|---|---|
| **나노바나나(Gemini)** | 나노바나나 **워커 전용** 아웃바운드 → `generativelanguage.googleapis.com` | `GEMINI_API_KEY` 워커 env only. 워커 서브넷만 아웃바운드 허용(NA). 미승인 시 목/degraded | **G1** |
| **Claude(텍스트 AI)** | 백엔드 `AiTextRouter``api.anthropic.com` | `ANTHROPIC_API_KEY` env only(코드·DB·로그·응답 미기록). **실패 시 Ollama 폴백** | 승인됨(예외) |
| **PG 결제(M9)** | 백엔드 → 국내 PG(카드·계좌·세금계산서) | 카드정보 비보관(PG 위임), 결제 콜백 검증 | — |
| **kxwp/kxfp 작업신고(M6)** | **파일 릴레이**(제출용 파일 생성 + 업로드 안내) | 폐쇄형·API 미공개(R3). 초기 수동 릴레이, 정식 API는 킨텍스 협의(Phase 3) | — |
| **등록업체 DB(739)** | 주기 수집 → 자체 DB화 | 공개 데이터 수집, 추후 공식 피드 | — |
| **행사일정 시스템** | 공개 캘린더 수집 → 가용성 역산 | 정확 가용성은 킨텍스 내부 데이터 협의 | — |
| **CDN** | 공개 자산·이미지 프론팅 | 공개 read-only 자산만 | — |
- **아웃바운드 원칙**: 인터넷 아웃바운드는 **워커·백엔드 특정 경로만 허용**(화이트리스트). 그 외 외부 API 금지(GUARDiA 보안 제약, `../DEVELOPMENT_GUIDE.md` §5). 세부 방화벽 규칙은 NA([`network.md`](network.md)).
### 6-3. 비동기 큐 계약 (Redis)
- Spring 백엔드는 RenderJob·서류·EDM·알림을 **Redis 큐에 발행**하고 상태만 관리. 워커가 소비·처리·완료 콜백(WebSocket). **Java 재구현 대신 얇은 큐 계약으로 결합**(PLANNING §8, google-genai는 Python SDK → 워커 유지).
- 큐 신뢰성: AOF 지속화 + 소비자 ACK + 가시성 타임아웃 재큐(멱등 Job). 쿼터·순위·타이머·캐시도 Redis(§2-1).
---
## 7. 아키텍처 결정 요약 (ADR-lite)
| # | 결정 | 이유 | 대안·트레이드오프 |
|---|---|---|---|
| 1 | 나노바나나를 **Python 워커 사이드카**로 분리 | google-genai=Python SDK, ReRoomAI 검증 방어로직 이식(Java 재구현 회피). 이미지 생성 독립 스케일 | Java 통합(재검증 비용·강결합) 배제 |
| 2 | **역할별 프론트 번들 분리** | 최소권한·공격면 축소, 공개/백오피스 격리 | 단일 SPA(권한 혼재·공격면 확대) 배제. 공유 디자인/API로 중복 완화 |
| 3 | 백엔드 **무상태 + Redis 외부화** | 수평 확장·무중단 배포·다중 인스턴스 WebSocket 팬아웃 | 인메모리 상태(ReRoomAI Map 방식) — 재시작·다중인스턴스 취약(§reroomai (F)) 배제 |
| 4 | **읽기 복제 오프로드**(BI·공개조회) | 운영 프라이머리 보호(PLANNING §8-1) | 단일 DB 직조회(BI 부하 전파) 배제 |
| 5 | **CDN + SSG**로 공개 트래픽 흡수 | 관람객 스파이크·SEO·다국어 | 오리진 직서빙(스파이크 취약) 배제 |
| 6 | 룰셋 **버전 데이터** 분리 | 연 단위 요율·규정 개정 무중단 반영 | 코드 하드코딩(배포 필요) 배제 |
| 7 | S6 배선 오버레이 **백엔드 래스터 우선** | 좌표 정확성(생성 왜곡 배제)·비용/지연 제외 | Gemini 생성(재질·색 왜곡) 배제 |
---
## 8. 교차참조
- 기획 정본: [`../PLANNING.md`](../PLANNING.md) §2·§7·§8·§10
- 구현 백로그: [`../IMPLEMENTATION_BACKLOG.md`](../IMPLEMENTATION_BACKLOG.md) Phase A~E
- 나노바나나 파이프라인 근거: [`../analysis/reroomai-source.md`](../analysis/reroomai-source.md)
- 애플리케이션 아키텍처(AA·A-1): [`app.md`](app.md) — 모듈 경계·API 표준·패키지
- 기술 표준(TA·A-3): [`tech.md`](tech.md) — 빌드·배포·관측성·AiTextRouter
- 데이터 아키텍처(DA·A-4): [`data.md`](data.md) — 전사 ERD·공간·마스터·BI 마트
- 네트워크 아키텍처(NA·A-5): [`network.md`](network.md) — DMZ/방화벽/부하분산/아웃바운드
- 개발 표준: [`../DEVELOPMENT_GUIDE.md`](../DEVELOPMENT_GUIDE.md) · 빌드/배포: [`../BUILD_DEPLOY.md`](../BUILD_DEPLOY.md)
---
## 9. 변경 이력
| 버전 | 일자 | 작성자 | 내용 |
|---|---|---|---|
| v1.0 | 2026-07-11 | SA | 최초 작성(A-2) — 시스템 구성도(역할별 프론트·공유 백엔드·백오피스·공개사이트·나노바나나 워커·Redis·PostGIS), 논리/배포 토폴로지, NFR(확장성·HA·성능(대량부스·이미지큐·공개트래픽)·용량 산정), 연동 아키텍처(SSO/RBAC 6역할·PG결제·kxwp 릴레이·외부 게이트 Claude/Gemini), 보안영역(공개 vs 백오피스)·데이터 보존·개인정보 경계, G1/G2 게이트, ADR-lite. PLANNING §8 정합·확정 스택 준수. AA/TA/DA/NA 교차참조 |

324
docs/architecture/tech.md Normal file
View File

@ -0,0 +1,324 @@
# 킨텍스 자동전시시스템 — 기술 표준 (Technical Architecture)
> 작성: 기술 아키텍트(TA) · 작성일: 2026-07-11 · 버전: v1.0
> 근거: `docs/PLANNING.md` v2.0(§8 확정 스택·§8-1 아키텍처 보강·§10 리스크)·`docs/IMPLEMENTATION_BACKLOG.md`(A-3)·실측 스캐폴드(`src/backend/build.gradle`·`src/backend/src/main/resources/application.yml`·`src/frontend/package.json`·`tools/nanobanana/`)·`.claude/agents/kintex-ai-dev.md`
> 교차참조: 앱 아키텍처 `docs/architecture/app.md`(A-1) · 시스템/NFR `docs/architecture/system.md`(A-2) · 데이터 `docs/architecture/data.md`(A-4) · 네트워크 `docs/architecture/network.md`(A-5)
> **문서 소유권**: 본 tech.md는 TA만 수정한다. 확정 스택·버전·빌드/배포·개발표준·관측성·AI 프로바이더 표준의 단일 출처(SSOT)다. 스택 변경은 본 문서 개정을 선행한다.
---
## 0. 이 문서의 위치
Phase A(아키텍처·거버넌스) 4개 표준 문서 중 **기술 표준(A-3)** 이다. 애플리케이션 경계·레이어(app.md), NFR·토폴로지(system.md), 데이터 모델(data.md), 네트워크(network.md)와 정합한다. 본 문서는 "무엇을 어떤 버전으로, 어떻게 빌드·배포·개발·관측하는가"의 기술 규범을 확정한다. 기능 범위·모듈 우선순위는 PLANNING이 권위이며 본 문서는 그 위 기술 계층만 다룬다.
핵심 원칙 4가지:
1. **실측 스캐폴드 정합** — 이미 스캐폴드된 실제 스택(Spring Boot 3.2.5·React 18.3.1·google-genai 워커)에 표기를 맞춘다. "3.x" 같은 느슨한 표기 대신 핀 버전을 SSOT로 둔다.
2. **GUARDiA/UIWS 표준 정렬** — kintex는 `zio/kintex` 독립 저장소이나 GUARDiA 표준 프레임워크(UIWS)와 스택·인증·AI 프로바이더 패턴을 공유한다.
3. **보안 불변 우선** — 시크릿 env-only, 스택트레이스·자격증명·PII 미노출, AES-256-GCM은 코드보다 상위 제약(§10, PLANNING §10·보안 불변).
4. **결정론과 폐쇄망 우선** — 규정/요율은 버전 관리 데이터, AI는 온프레미스 폴백 필수, 외부 아웃바운드는 승인된 도메인만.
---
## 1. 확정 기술 스택 (버전 표준·SSOT)
> 아래 버전은 **실측 스캐폴드에서 채택된 값**이다. 임의 상향/하향 금지 — 변경은 TA 승인 + 본 표 개정 후.
### 1-1. 백엔드 (Spring Boot · Java 17 · MyBatis)
`src/backend/build.gradle` 기준.
| 항목 | 표준 값 | 근거/비고 |
|---|---|---|
| 언어/런타임 | **Java 17** (`sourceCompatibility`/`targetCompatibility` = 17) | GUARDiA 표준(전 솔루션 Java 17 정렬). Java 21 금지 |
| 프레임워크 | **Spring Boot 3.2.5** | 핀 버전. `org.springframework.boot` 플러그인 |
| 의존성 관리 | `io.spring.dependency-management` **1.1.4** | Spring Boot BOM 정렬 |
| 빌드 도구 | **Gradle** (wrapper 동봉 `gradlew`/`gradlew.bat`) | 시스템 Gradle 미의존, wrapper 고정 |
| 그룹/패키지 | `com.zioinfo.kintex` / rootProject `kintex-backend` | GUARDiA 네이밍 규약 |
| 버전 | `0.1.0-SNAPSHOT` | SemVer, 릴리스 시 `-SNAPSHOT` 제거 |
| ORM | **MyBatis** `mybatis-spring-boot-starter` **3.0.3** | Spring Boot 3.2.x 호환 핀. JPA 금지(공간 SQL은 매퍼 XML) |
| DB 드라이버 | `org.postgresql:postgresql` (runtimeOnly, BOM 관리) | PostGIS 함수는 `ST_*` 매퍼 XML |
| 캐시/큐 | `spring-boot-starter-data-redis` | RenderJob·서류·알림 큐 + 옥션 실시간 순위 |
| 실시간 | `spring-boot-starter-websocket` (STOMP) | RenderJob 완료·옥션 순위 푸시 |
| 인증 | `spring-boot-starter-security` + **jjwt 0.12.5** (api/impl/jackson) | JWT HS256 + RBAC + TOTP 2FA |
| 검증 | `spring-boot-starter-validation` | DTO Bean Validation |
| 보일러플레이트 | Lombok (compileOnly + annotationProcessor) | |
| 테스트 | `spring-boot-starter-test` + `spring-security-test`, JUnit Platform | |
| 인코딩 | **UTF-8 강제** (`JavaCompile.options.encoding = 'UTF-8'`) | Windows javac CP949 한글 리터럴 손상 방지 — **불변, 전 모듈 유지** |
**MyBatis 규약**(application.yml 기준):
- `mapper-locations: classpath*:mybatis/mapper/**/*.xml`
- `map-underscore-to-camel-case: true`, `jdbc-type-for-null: NULL`
- `@MapperScan`은 GUARDiA 표준(`annotationClass = Mapper.class`) — 빈 누락 크래시 방지(다른 솔루션 회귀 이력). db-engineer가 공간 SQL 매퍼 XML 소유.
**Hikari 풀 표준**: `maximum-pool-size = ${DB_POOL_MAX:3}`. 공유 PostgreSQL 보호(GUARDiA 표준). kintex 전용 DB `kintex_db`라도 서버 공용 PG면 캡 유지. 상향 필요 시 SA(system.md)·DA와 합의.
### 1-2. 프론트엔드 (React · Vite · TypeScript)
`src/frontend/package.json`·`vite.config.ts`·`tsconfig.json` 기준.
| 항목 | 표준 값 | 비고 |
|---|---|---|
| UI 라이브러리 | **React 18.3.1** (`react`/`react-dom`) | PLANNING "18/19" 중 **18.3.1 확정 채택** |
| 빌드/번들러 | **Vite 5.4.8** + `@vitejs/plugin-react` 4.3.2 | dev 서버 :5173, `/api`·`/ws` 프록시 |
| 언어 | **TypeScript 5.6.2** (`strict: true`) | `noUnusedLocals`/`noUnusedParameters`/`noFallthroughCasesInSwitch` 켬 |
| 라우팅 | `react-router-dom` **6.26.2** | 역할별 포털 라우팅 |
| 서버 상태 | `@tanstack/react-query` **5.59.0** | API 캐싱·재검증 표준. 수동 fetch 지양 |
| 클라이언트 상태 | `zustand` **4.5.5** | 전역 상태(경량). Redux 금지 |
| 실시간 | `@stomp/stompjs` **7.0.0** + `sockjs-client` **1.6.1** | 백엔드 STOMP 정합 |
| 경로 별칭 | `@/*``src/*` (vite alias + tsconfig paths) | 상대경로 지옥 회피 |
| 모듈 타입 | `"type": "module"` (ESM), `target ES2020` | |
**빌드 스크립트**(package.json): `build = "tsc -b && vite build"`, `typecheck/lint = "tsc --noEmit"`. **타입 에러는 빌드 실패** — CI 게이트.
**역할별 프론트 분리**(PLANNING §2-1·§8-1): organizer·exhibitor·contractor·ops·admin(인증) + public/visitor(공개). **번들 분리** 방식은 designer/FE 트랙 결정(모노레포 다중 진입점 vs 서브패스). 공유 디자인 시스템(design.md)·공유 컴포넌트·공유 API 계약은 **상속**(중복 구현 금지). 현재 스캐폴드는 단일 Vite 앱(`src/frontend`) — 분리 실행 시 본 표준의 라이브러리 버전을 전 번들이 공유한다.
### 1-3. 나노바나나 Python 워커 (사이드카)
`tools/nanobanana/` 기준. PLANNING §8 "Python 워커 유지 근거"(google-genai는 Python SDK, ReRoomAI 검증 client.py 재사용 — Java 재구현 회피).
| 항목 | 표준 값 | 비고 |
|---|---|---|
| 런타임 | **Python 3.11+** (개발 실측 3.14 `__pycache__`) | 배포는 3.11/3.12 LTS 권장(3.14는 개발 로컬) |
| 이미지 SDK | **google-genai** (`pip install google-genai`) | Gemini image-to-image. 지연 임포트(무네트워크 import 성립) |
| 모델 | `gemini-3.1-flash-image-preview` (나노바나나 2, env `NANOBANANA_MODEL`) | 하드코딩 아님, env 오버라이드 |
| 이미지 처리 | **Pillow(PIL)** | S6 배선 오버레이 결정적 래스터 합성·목 플레이스홀더 |
| 큐/이벤트 | **redis** (`redis.from_url`, BLPOP 소비 + pub/sub 발행) | 지연 연결 |
| 실행 | `python -m tools.nanobanana.worker` (루프) / `--smoke` (무네트워크) | |
**워커 불변식**(worker.py 헤더): ①G1 게이트 — 실 Gemini 호출은 `NANOBANANA_LIVE=1` + `GEMINI_API_KEY` 동시 충족 시만, 기본 목/degraded. ②지연 연결 — Redis 미기동이어도 `process_job()` 직접 호출 성립. ③S6은 생성형 아님(항상 로컬 PIL). ④비밀 미노출(키/IP/스택트레이스 미기록).
**의존성 관리 표준**: 현재 워커에 `requirements.txt` **부재** — 배포 전 `tools/nanobanana/requirements.txt`(google-genai·Pillow·redis 핀 버전) 추가 필요(§9 백로그). devops-dev(DEV) 담당.
### 1-4. 데이터·인프라
| 항목 | 표준 값 | 비고 |
|---|---|---|
| DB | **PostgreSQL + PostGIS** (`kintex_db`) | 공간 데이터 일원화(부스 폴리곤·트렌치 포인트·배선 LineString). 상세 DA/data.md |
| 캐시/큐/실시간 | **Redis** | 작업 큐 + 옥션 라운드 타이머 + 순위 |
| 오브젝트 스토리지 | 로컬 FS(기본 degraded 어댑터) → S3/GCS(운영) | `ObjectStore.save_image()` 반환 계약 유지하며 어댑터 교체 |
| 마이그레이션 | **미확정** — B-0 백로그는 Flyway 명시(현 build.gradle 미포함) | §3-1 참조. TA 결정: Flyway 채택 권고 |
---
## 2. 통합 계약 (백엔드 ↔ 워커 ↔ 프론트)
### 2-1. RenderJob 큐 계약 (Spring → Redis → Python 워커)
Spring 백엔드가 RenderJob을 Redis 리스트에 push → 워커가 BLPOP 소비 → 오브젝트 스토리지 적재 → pub/sub 이벤트 발행 → 백엔드 구독 → WebSocket(STOMP) 프론트 푸시. 계약 단일 출처: `tools/nanobanana/_workspace/01_worker_contract.md`.
> **★ 실측 불일치(리스크 R-T1, §10)**: 큐/채널 키 기본값이 백엔드와 워커에서 다르다.
> - `application.yml`: `kintex.render.queue-key = kintex:renderjob:queue`
> - `worker.py`: `NANOBANANA_QUEUE 기본 = kintex:renderjobs`, `EVENT_CHANNEL 기본 = kintex:renderjob:events`
>
> 양측 모두 env 오버라이드 가능하나 **기본값 불일치는 배포 시 조용한 무처리(silent no-op)** 위험. **표준 확정**: 큐 키 `kintex:renderjob:queue`, 이벤트 채널 `kintex:renderjob:events`로 통일하고 배포 env(`RENDER_QUEUE_KEY`/`NANOBANANA_QUEUE`/`NANOBANANA_EVENT_CHANNEL`)를 동일 값으로 명시 주입. BE·VIZ·DEV가 `01_worker_contract.md`에 최종 키를 고정한다.
### 2-2. WebSocket(STOMP) 계약
- 백엔드 `WebSocketConfig`(STOMP) — 프론트 `@stomp/stompjs` + `sockjs-client`. dev는 Vite 프록시 `/ws`(ws:true).
- 이벤트: `renderjob.completed`/`renderjob.failed`(워커→백엔드→구독 클라), 옥션 순위 푸시(M15). 페이로드에 `image_ref`·`meta`(live/degraded 플래그) 포함, 비밀·스택트레이스 미포함.
### 2-3. API 응답 봉투
실측: `common/ApiResponse.java`·`common/PageResponse.java`·`common/error/GlobalExceptionHandler.java` 존재. 표준 응답 봉투 + 페이지 봉투 + 전역 예외 핸들러로 **에러 응답 표준화**(스택트레이스 미노출, `ErrorCode` 코드+요약 메시지만). 상세 계약은 app.md(A-1) 소유 — 본 문서는 정합만 명시.
---
## 3. 빌드·배포 표준
### 3-1. 백엔드 빌드 (Gradle · 단일 jar)
- 빌드: `./gradlew clean bootJar` → 단일 실행 jar(`build/libs/kintex-backend-<ver>.jar`). GUARDiA 단일 jar 표준.
- **프론트→백엔드 static 번들**(GUARDiA 표준 옵션): 운영 배포는 역할별 프론트 번들을 백엔드 static 리소스 또는 nginx 정적 서빙 중 택1. 역할별 프론트 분리(§1-2)이므로 **백오피스/포털별 별도 정적 서빙 + 공유 백엔드 jar** 토폴로지가 기본(system.md 확정). 공개사이트(M12/M17)는 SEO·다국어로 별도 렌더 경로(SSR/정적 생성).
- 테스트: `./gradlew test`(JUnit Platform). compileJava·test 통과가 배포 게이트.
- **마이그레이션(TA 결정)**: B-0가 Flyway를 명시하나 현 build.gradle 미포함. **Flyway 채택 권고**`org.flywaydb:flyway-core` + `flyway-database-postgresql`(PostGIS 정합) 추가, `db/migration/V__*.sql`(PostGIS 확장·공간 인덱스 포함). 시드/후행 테이블은 GUARDiA 교훈(멱등화 + 누출 차단) 준수 — sql.init `mode=never` 후행 추가 테이블 미적용 회귀(schema-integrity 하네스 교훈) 방지. DB 스키마 상세는 DA/data.md.
### 3-2. 프론트 빌드 (Vite)
- `npm ci && npm run build`(= `tsc -b && vite build`) → `dist/`. 타입 에러 시 실패.
- 역할별 번들 분리 시 각 진입점 빌드 산출물을 도메인/서브패스별 배포.
- **★로컬 rollup win32 크래시 함정(리스크 R-T2, §10)**: GUARDiA 전 프로젝트에서 로컬 Windows rollup 네이티브 렌더 크래시가 반복 관측됨(homepage-renewal·CMS 등). **표준 대응**: (1) CI/서버 빌드(Linux) 신뢰 — 서버 `npm run build`가 권위. (2) 로컬 검증은 `tsc --noEmit`(typecheck)로 대체하거나 esbuild 경로. (3) `package-lock.json` 커밋으로 `npm ci` 재현성 확보. (4) 로컬 크래시가 서버 빌드 성공을 막지 않음 — 서버 번들 검증(최신 청크 diff)로 마무리.
### 3-3. 워커 배포 (systemd 서비스)
- 별도 프로세스(사이드카). systemd 유닛으로 상주(`ExecStart=python -m tools.nanobanana.worker`), `Restart=on-failure`.
- env(`EnvironmentFile` 또는 drop-in): `REDIS_URL`·`NANOBANANA_QUEUE`·`NANOBANANA_EVENT_CHANNEL`·`NANOBANANA_OUTPUT_DIR`·(G1 승인 후)`NANOBANANA_LIVE=1`·`GEMINI_API_KEY`. **GEMINI_API_KEY는 워커 env에만**(백엔드 미보유 — application.yml 주석 명시). GUARDiA 서버는 명령줄 인자 기동 서비스가 많아 **systemd drop-in EnvironmentFile 방식** 채택(기존 ExecStart 불변, guardia-claude-ai 트랙 패턴).
- 미승인(G2/G1 전) 기본 목/degraded 모드로 상주 가능(무네트워크).
### 3-4. CI/CD 파이프라인
GUARDiA 표준 흐름 정렬(솔루션 푸시 구조 메모리):
```
workspace/kintex (개발·SSOT)
→ repos/kintex (fresh git init — 모노레포 히스토리 상속 금지, bundle 비대화 방지)
→ Gitea zio/kintex (push)
→ webhook :9999 (deploy_server.py)
→ 서버 빌드(gradlew bootJar + npm build + 워커 배포) → systemd 재시작 → health 게이트
```
- **선행 게이트 G2**(BACKLOG): 배포 대상 서버·포트(GUARDiA 인프라와 별개 도메인) 확정 전 Phase E 착수 금지.
- **함정(GUARDiA 교훈, 배포블록 반영 필수)**: ①`repos/kintex`는 반드시 fresh `git init`(모노레포 `.git` 상속 시 bundle 1.5GB 회귀). ②`deploy_server.py`에 kintex 블록 추가 시 **서버 `/opt/zioinfo/deploy_server.py` 사본 반영 + `zioinfo-deploy` 재시작 필수**(로컬만 고치면 웹훅 1ms no-op). ③백엔드 jar만이 아니라 **워커 서비스도 배포 대상**(별도 systemd). ④health 200 확인이 완료 게이트.
### 3-5. 환경변수 표준 (시크릿 env-only)
application.yml은 **모든 시크릿을 플레이스홀더로만** 주입(하드코딩 금지, 주석 명시).
| env | 용도 | 소비자 |
|---|---|---|
| `DB_URL`/`DB_USER`/`DB_PASSWORD` | PostgreSQL(PostGIS) | 백엔드 |
| `DB_POOL_MAX` (기본 3) | Hikari 캡 | 백엔드 |
| `REDIS_HOST`/`REDIS_PORT`/`REDIS_PASSWORD` | Redis | 백엔드 |
| `REDIS_URL` | Redis(워커) | 워커 |
| `JWT_SECRET`(≥32B)/`JWT_ACCESS_TTL` | JWT HS256 | 백엔드 |
| `RENDER_QUEUE_KEY`/`NANOBANANA_QUEUE` | 큐 키(통일) | 백엔드/워커 |
| `NANOBANANA_EVENT_CHANNEL` | 이벤트 채널 | 워커/백엔드 |
| `RENDER_EVENT_QUOTA`(기본 500) | 행사별 생성 쿼터 | 백엔드 |
| `NANOBANANA_LIVE`/`GEMINI_API_KEY` | G1 승인 후 실 Gemini | **워커 전용** |
| `ANTHROPIC_API_KEY` | Claude AI(§6) | 백엔드 |
| `ADMIN_PASSWORD_ENC` + 키파일 | admin 비번(AES-256-GCM) | 백엔드 |
| `VITE_BACKEND_ORIGIN`/`VITE_API_BASE` | 프론트 오리진/프록시 | 프론트 |
**admin 비번**(B-1·GUARDiA 표준): `admin123` 등 평문 시드 **금지**. `ADMIN_PASSWORD_ENC`(AES-256-GCM) + 별도 키파일 주입, 최초 기동 재시드(guardia-claude-ai `guardia_master.key` 패턴).
---
## 4. 개발 표준
### 4-1. 코드 스타일
- **Java**: Google Java Style 기준(4-space, 100~120 col). Lombok 활용(`@Getter`/`@RequiredArgsConstructor`/`@Slf4j`), 필드 주입 금지(생성자 주입). 패키지 = 모듈별(`module.m2`·`module.m5`·`auth`·`common`·`config`) — app.md 경계 준수. **UTF-8 소스 필수**(build.gradle 강제).
- **TypeScript**: `strict` 전면. `any` 지양(불가피 시 주석). 컴포넌트 함수형, hooks 규약. 서버 상태는 react-query, 전역은 zustand. `@/*` 별칭 사용.
- **Python(워커)**: PEP 8 + type hints(`from __future__ import annotations`). 방어적 임포트(SDK/redis 지연). 비밀 미노출 규약(에러 메시지 300자 절단·키 미기록) 유지.
### 4-2. 테스트 표준
- **백엔드**: JUnit 5 + spring-security-test. 단위(서비스·룰엔진·요율 계산) + 슬라이스(`@WebMvcTest`/`@MyBatisTest`) + 통합(핵심 왕복). **필수 테스트**(GUARDiA feedback_test_required): 임포트/컴파일 검증 + 라우트 확인 + curl 응답. 룰엔진(규정·요율)·PostGIS 공간 SQL은 결정적 테스트 필수.
- **프론트**: `tsc --noEmit` 게이트(현 최소선). 확장 시 Vitest + Testing Library 권고(현 미도입).
- **워커**: `python -m tools.nanobanana.worker --smoke`(무네트워크 스모크) — 목 잡 S2 + S6 래스터 처리·사이드카 확인. CI 필수 게이트.
- **AI/외부 호출**: 목/degraded 경로가 무네트워크로 통과해야 함(폐쇄망·미승인 대비).
### 4-3. 브랜치·커밋 규약
- **브랜치**: `main`(보호) + 작업 브랜치(`feat/`·`fix/`·`chore/`). main 직접 커밋 금지(작업 브랜치 → PR). 기본 브랜치 push는 `origin HEAD:main`(BI repo master 함정 교훈 — repo별 기본 브랜치 확인).
- **커밋(Conventional Commits)**: `type(scope): summary`. type = `feat`/`fix`/`docs`/`refactor`/`test`/`chore`/`build`/`perf`. scope = 모듈(`m2`·`m5`·`auth`·`worker`·`bidding`). **커밋 메시지 영어**(kintex-ai-dev·visualizer 산출 규약). 예: `feat(m5): add renderjob quota guard`.
- **커밋 금지 대상**: 시크릿·`.env`·CAD zip(gitignore)·build 산출물. `.gitignore` 준수(`.gradle/`·`build/`·`*.log`·`.env`).
- **커밋/푸시 타이밍**: 사용자·오케스트레이터 명시 요청 시에만.
### 4-4. 저장소·문서 규약
- kintex는 **독립 저장소**(`zio/kintex`) — GUARDiA ITSM(관공서 관제)과 별개 도메인. R12 게이트상 Gemini 외부호출은 kintex 독립성과 무관하게 소유자 승인 선행.
- 아키텍처 문서는 `docs/architecture/`. PLANNING(planner)·design(designer) 소유권 존중 — 본 문서는 직접 수정하지 않고 교차참조.
---
## 5. 성능·관측성 표준
### 5-1. 로깅
- 백엔드: SLF4J/Logback(Spring Boot 기본). `logging.level.root: INFO`, `com.zioinfo.kintex: DEBUG`(개발). **운영은 INFO**로 하향(env `LOGGING_LEVEL_*` 오버라이드). 구조화(JSON) 로깅은 관측성 승격 시 권고.
- **로그 보안 불변**: 자격증명·IP·SSH·PII·스택트레이스 로그 금지. 워커는 예외 요약만(`type(e).__name__`), 키 미기록. `include-stacktrace: never` 유지.
- 상관관계: 요청별 traceId(MDC) 표준화 권고 — 옥션·RenderJob 비동기 흐름 추적.
### 5-2. 메트릭·트레이싱 (Observability)
- **표준(Micrometer + Actuator 권고)**: `spring-boot-starter-actuator` 추가(현 미포함) → `/actuator/health`(배포 게이트), `/actuator/metrics`, Prometheus `/actuator/prometheus`(GUARDiA guardia-rag `/metrics` 패턴 정렬).
- **핵심 지표**: RenderJob 처리량·지연·실패율(목/live 구분), 큐 적체(Redis 리스트 길이), 옥션 순위 계산 지연, PostGIS 공간 쿼리 지연, Hikari 풀 사용률, AI 프로바이더 폴백 발생률(Claude→Ollama).
- **트레이싱**: OpenTelemetry(OTel)는 관측성 트랙 승격 시(GreenOps/observability-platform 패턴). 초기는 로그 상관관계 + Actuator 메트릭.
- health 계약: 배포 후 `/actuator/health` 200이 완료 게이트(§3-4). 워커는 하트비트/최근 처리 시각을 이벤트/로그로 관측(전용 health 엔드포인트 부재 — 큐 소비 로그로 감시).
### 5-3. 성능 표준·부하 목표
PLANNING §10 리스크(R6 이미지 비용·지연) 정렬:
- **이미지 생성**(R6): 홀당 200~600부스 동시 생성 시 비용/지연 급증. **표준**: 자동 생성은 S1·S7 한정 + **온디맨드 + 스키마 해시 캐시**(client.py 캐시) + **행사별 쿼터**(`RENDER_EVENT_QUOTA` 기본 500). 워커는 성공 시에만 쿼터 차감(worker 방어 로직).
- **PostGIS 대량 배치**: 부스 폴리곤·배선 LineString 대량 연산은 공간 인덱스(GiST) 전제. 배치 배치도 생성·정산 집계는 트랜잭션 분할.
- **BI 집계**(M16, PLANNING §8-1): 운영 DB 부하 회피 — 배치/스냅샷(KpiSnapshot) 또는 읽기 전용 복제. 실시간 대시보드 직접 집계 지양.
- **비동기 우선**: 이미지·서류·알림·PDF(옥션 견적서)는 전면 Redis 큐 경유(동기 블로킹 금지).
---
## 6. AI 프로바이더 기술 표준 (Claude 기본 + 설정형 전환)
> 근거: `.claude/agents/kintex-ai-dev.md`·GUARDiA guardia-claude-ai 트랙·UIWS 패턴. 나노바나나(Gemini 이미지)는 **별개**(visualizer·§1-3·G1 게이트) — 본 절은 **텍스트/지능 AI**(부스배치 조건해석·규정검증 보조·예측·매칭·서류검수·챗봇).
### 6-1. 프로바이더 라우팅 아키텍처
UIWS 표준 3-컴포넌트(현 스캐폴드 **미구현** — AI 모듈 착수 시 신설):
- **`ClaudeTextClient`**: Anthropic Claude API(`api.anthropic.com` — 소유자 승인 예외 2026-07-03) 호출. 키는 env `ANTHROPIC_API_KEY`에서만 로드(코드·DB·로그·커밋·응답 기록 금지).
- **`AiTextRouter`**: 프로바이더 선택·폴백 오케스트레이션. **기본 Claude → 실패 시 Ollama 자동 폴백**(온프레미스 소형: `qwen3:1.7b`·`llama3.2:1b` 등). 하드코딩 금지.
- **`AiConfig`/`AiConfigService` + 설정 화면**: 런타임 프로바이더/모델 전환(화이트리스트 `claude-*` 기본 + 승인된 Ollama). generation_model·temperature·top_k·enabled 설정.
### 6-2. 폴백·폐쇄망·결정론
- **Ollama 폴백 필수**(폐쇄망·Claude 장애 대비). RAM 제약 준수 — 서버 가용 ~2GB, 대형 모델 금지(소형만, [[project_ollama_ram_constraint]]).
- **결정론 기능**(분류·추출·서류검수): 구조화 출력(`format:json`). 환각 방지 — 근거 없는 답변 보류·인용(guardia-ai-trust 정렬).
- **부스 배치(M2)**: 생성형 LLM이 배치를 만드는 것이 아니라 **제약 솔버/휴리스틱**이 3안 생성, LLM은 조건 해석·설명에만(kintex-ai-dev 규약).
### 6-3. 외부 아웃바운드 게이트 (불변)
| 도메인 | 상태 | 조건 |
|---|---|---|
| `api.anthropic.com` | **승인**(2026-07-03 소유자 예외) | Claude 텍스트 AI. 키 env-only, 실패 시 Ollama 폴백 |
| `generativelanguage.googleapis.com` | **미승인 게이트 G1**(PLANNING R12) | 나노바나나. M5 실호출 착수 전 소유자 승인 선행. 미승인 시 목/degraded |
| 그 외 외부 API | **금지** | GUARDiA 보안 불변 |
네트워크 아웃바운드 화이트리스트·프록시는 network.md(A-5) 소유. 본 문서는 AI 게이트만 확정.
---
## 7. 보안 기술 표준 (불변 요약)
PLANNING §10·GUARDiA 보안 불변 정렬(상세는 app.md/network.md):
- **시크릿 env-only** — 코드·DB·커밋·로그·응답 기록 금지. application.yml 플레이스홀더만.
- **자격증명·PII·스택트레이스 미노출** — API 응답/에러/로그/이벤트. `include-stacktrace: never`, 워커 에러 요약만.
- **AES-256-GCM** — admin 비번(`ADMIN_PASSWORD_ENC`)·민감 자격증명. 별도 키파일.
- **인증**(B-1): JWT(HS256, jjwt 0.12.5) + RBAC(6역할·행사 단위) + **TOTP 2FA(RFC6238)** + 로그인 실패 잠금. UIWS 이식.
- **AI 워터마크**(R1): 나노바나나 전 이미지 "AI 생성 예상 — 실제 시공과 다를 수 있음" 고지 강제(worker 목/live 공통). 계약·심사 서류 자동 배제.
- **등록업체 게이트**(M15): 미등록 업체 옥션 응찰 원천 차단(M7 검증).
---
## 8. 기술 리스크 · PoC
PLANNING §10(R1~R12)의 **기술 실행 리스크**를 TA 관점으로 구체화. 도메인/법적 리스크(R1·R2·R8·R9·R10)는 PLANNING 소유.
### 8-1. TA 신규/구체화 리스크
| # | 리스크 | 영향 | 완화·PoC |
|---|---|---|---|
| **R-T1** | **큐/이벤트 키 기본값 불일치**(§2-1) — application.yml `kintex:renderjob:queue` vs worker.py `kintex:renderjobs` | 높음(배포 시 조용한 무처리) | 키 통일(`kintex:renderjob:queue`/`kintex:renderjob:events`) + `01_worker_contract.md` 고정 + 배포 env 명시. **PoC: 백엔드 push → 워커 소비 → WebSocket 완료 왕복 스모크** |
| **R-T2** | **로컬 rollup win32 크래시**(§3-2) — Windows Vite 빌드 네이티브 렌더 크래시(GUARDiA 반복 관측) | 중간(로컬 개발 저해) | 서버 빌드 신뢰 + `tsc --noEmit` 로컬 게이트 + `package-lock.json` 커밋(`npm ci` 재현) |
| **R-T3** | **PostGIS 대량 배치 성능** — 홀당 200~600부스 폴리곤·배선 최단경로·통로버퍼 검증 대량 연산 | 중간 | GiST 공간 인덱스 + 매퍼 XML `ST_*` 튜닝 + 배치 분할. **PoC: 600부스 배치도 생성·규정검증 SQL 부하 측정**(DA 협업) |
| **R-T4** | **이미지 큐 부하**(PLANNING R6) — 대량 동시 RenderJob 비용·지연 | 중간 | S1/S7 한정 자동생성 + 스키마 해시 캐시 + 행사 쿼터(500) + 워커 동시성 제한. **PoC: 목 모드 N=500 잡 큐 처리량·적체 측정**(무비용) |
| **R-T5** | **Flyway 부재**(§3-1·B-0 명시) — 마이그레이션 도구 미결정, 후행 테이블 미적용 회귀(GUARDiA schema-integrity 교훈) | 중간 | Flyway 채택 + 멱등 스키마 + 누출 차단. DA와 확정 |
| **R-T6** | **워커 requirements.txt 부재**(§1-3) — 의존성 핀 미고정 | 낮음 | `tools/nanobanana/requirements.txt` 추가(google-genai·Pillow·redis 핀). DEV 담당 |
| **R-T7** | **AiTextRouter/AiConfig 미구현**(§6-1) — AI 프로바이더 표준 코드 부재 | 낮음(설계 확정, 착수 대기) | AI 모듈 착수 시 UIWS 패턴 이식. Ollama 폴백 무네트워크 검증 |
### 8-2. 권장 PoC 순서 (TA 실행 가능·Bash)
1. **워커 스모크**(무네트워크·무비용) — `python -m tools.nanobanana.worker --smoke`. S2 목 + S6 래스터 사이드카 확인. **즉시 실행 가능**.
2. **큐 왕복 PoC**(R-T1) — 로컬 Redis + 백엔드 push + 워커 소비 스모크(키 통일 검증).
3. **PostGIS 배치 PoC**(R-T3) — 600부스 합성 데이터로 배치·검증 공간 SQL EXPLAIN ANALYZE.
4. **이미지 큐 부하 PoC**(R-T4) — 목 모드 500잡 처리량·적체.
> 본 구현은 구현 에이전트(BE·VIZ·DB·AI)가 표준대로 수행. TA는 PoC 스크립트 실행·표준 개선만.
---
## 9. 미결 사항 (구현 착수 전 확정 필요)
| # | 항목 | 담당 | Phase |
|---|---|---|---|
| 1 | 큐/이벤트 키 통일(R-T1) → `01_worker_contract.md` 고정 | BE·VIZ·DEV | B/C |
| 2 | Flyway 채택·마이그레이션 구조(R-T5) | DA·DB·TA | B-0 |
| 3 | Actuator/Micrometer 관측성 의존성 추가(§5-2) | DEV·TA | B |
| 4 | 워커 `requirements.txt` 핀(R-T6) | DEV | C-M5 |
| 5 | 역할별 프론트 번들 분리 방식(§1-2) 확정 | DES·FE | C/D |
| 6 | `AiTextRouter`/`AiConfig` 이식(§6-1) | AI·BE | D |
| 7 | 배포 서버·포트·도메인(G2) | DEV·SA | E |
| 8 | Gemini 외부호출 승인(G1) | 소유자 | C-M5 |
---
## 10. 변경 이력
| 버전 | 일자 | 작성자 | 내용 |
|---|---|---|---|
| v1.0 | 2026-07-11 | TA | 최초 작성(A-3). 실측 스캐폴드 정합 — 확정 스택 핀 버전(Spring Boot 3.2.5·MyBatis 3.0.3·jjwt 0.12.5·React 18.3.1·Vite 5.4.8·TS 5.6.2·google-genai 워커) SSOT화, 빌드·배포(Gradle 단일 jar·Vite·워커 systemd·CI/CD)·개발표준(코드스타일·테스트·Conventional Commits·브랜치)·관측성(로깅·Actuator/Micrometer·성능목표)·AI 프로바이더(Claude 기본+AiTextRouter/AiConfig+Ollama 폴백·외부 아웃바운드 게이트)·기술 리스크7종(R-T1~R-T7)+PoC 확정. ★실측 불일치 발견: 큐 키 기본값 백엔드/워커 상이(R-T1) — 통일 표준 제시. app.md/system.md/data.md/network.md 교차참조 |

View File

@ -37,6 +37,9 @@ dependencies {
implementation "org.mybatis.spring.boot:mybatis-spring-boot-starter:${mybatisStarterVersion}"
runtimeOnly 'org.postgresql:postgresql'
// --- Flyway ( DDL· ) Spring Boot 3.2.x (Flyway 9.x, PG ) ---
implementation 'org.flywaydb:flyway-core'
// --- Redis (RenderJob· · ) ---
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

View File

@ -27,6 +27,11 @@ spring:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
# Flyway — 순번 DDL/시드가 스키마의 권위(sql.init 미사용, 후행 테이블 미적용 함정 회피).
flyway:
enabled: ${FLYWAY_ENABLED:true}
locations: classpath:db/migration
baseline-on-migrate: true # 기존 DB에도 안전 적용(파괴적 변경 금지)
# MyBatis — 공간 SQL 매퍼 XML은 kintex-db-engineer가 채운다(D-2/M2-1/M4-1).
mybatis:

View File

@ -0,0 +1,5 @@
-- 킨텍스 자동전시시스템 — 공간 확장 활성화 (PLANNING §8 공간 데이터 일원화)
-- PostGIS: 부스 POLYGON·트렌치 POINT·배선 LINESTRING 지오메트리.
-- pgcrypto: gen_random_uuid() (PG13+ 코어이나 하위 호환 위해 명시).
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgcrypto;

View File

@ -0,0 +1,100 @@
-- 킨텍스 — 신원·마스터 스키마 (PLANNING §7-3 ERD / §5B UIWS 인증 이식)
-- 보안 불변(계약 §0-3): password_hash·otp_secret 컬럼은 API 응답에서 완전 제외한다.
-- 사용자 (UIWS TB_USER 이식 대응) --------------------------------------------
CREATE TABLE IF NOT EXISTS app_user (
id varchar(40) PRIMARY KEY,
email varchar(200) NOT NULL UNIQUE,
display_name varchar(120) NOT NULL,
password_hash varchar(120) NOT NULL, -- BCrypt. 응답 제외(민감정보).
hall_manager boolean NOT NULL DEFAULT false,
otp_secret varchar(96), -- TOTP(RFC6238) 시크릿, 암호화 저장. 응답 제외.
failed_login_count integer NOT NULL DEFAULT 0, -- 로그인 실패 잠금(§5B-3)
locked_until timestamptz,
status varchar(20) NOT NULL DEFAULT 'ACTIVE',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON COLUMN app_user.password_hash IS 'BCrypt 해시 — API 응답/로그/메신저 노출 금지(계약 §0-3)';
COMMENT ON COLUMN app_user.otp_secret IS 'TOTP 시크릿(암호화) — API 응답 노출 금지';
-- 등록업체 (킨텍스 등록업체 DB 739 / M7·M15 응찰 게이트) ----------------------
CREATE TABLE IF NOT EXISTS company (
id varchar(40) PRIMARY KEY,
registration_no varchar(20) NOT NULL UNIQUE, -- 사업자등록번호(초대 수락 검증 키)
name varchar(200) NOT NULL,
category varchar(40), -- 14개 분류(전시디자인설치·리깅·전기시설 …)
region varchar(40),
registered boolean NOT NULL DEFAULT true, -- 킨텍스 등록 여부(미등록 응찰 차단)
phone varchar(40),
created_at timestamptz NOT NULL DEFAULT now()
);
-- 행사 -----------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS event (
id varchar(40) PRIMARY KEY,
name varchar(200) NOT NULL,
start_date date,
end_date date,
status varchar(20) NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now()
);
-- 홀 마스터 (PLANNING §7-1 / floorplans README 실측) --------------------------
CREATE TABLE IF NOT EXISTS hall (
id varchar(20) PRIMARY KEY, -- 'H1'..'H10','OUTDOOR'
exhibition_center integer NOT NULL, -- 1=제1전시장, 2=제2전시장
label varchar(60) NOT NULL, -- '제1전시장 홀1' 등(워크스페이스 hallLabel)
width_m numeric(8,2),
depth_m numeric(8,2),
height_m numeric(6,2),
area_m2 numeric(10,2),
floor_load_t_per_m2 numeric(5,2), -- 홀6 2t/㎡, 그 외 5t/㎡ (규정 FLOOR_LOAD)
floor_type varchar(30), -- concrete_polished | carpet | outdoor
booth_capacity integer,
has_gas boolean NOT NULL DEFAULT false, -- 홀1·7 가스 공급
is_assumed_trench boolean NOT NULL DEFAULT true, -- 트렌치 좌표 실측 여부(PLANNING R4)
created_at timestamptz NOT NULL DEFAULT now()
);
-- 홀 배정 (Event 1─N HallAssignment) -----------------------------------------
CREATE TABLE IF NOT EXISTS hall_assignment (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
hall_id varchar(20) NOT NULL REFERENCES hall(id),
is_primary boolean NOT NULL DEFAULT false,
UNIQUE (event_id, hall_id)
);
-- 행사 단위 RBAC 멤버십 (계약 §0-5: ORGANIZER·EXHIBITOR·CONTRACTOR·HALL_MANAGER)
CREATE TABLE IF NOT EXISTS event_member (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
user_id varchar(40) NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
role_code varchar(20) NOT NULL, -- ORGANIZER|EXHIBITOR|CONTRACTOR|HALL_MANAGER
booth_id varchar(40), -- EXHIBITOR 부스 스코프(선택)
company_id varchar(40) REFERENCES company(id), -- CONTRACTOR 소속 등록업체(선택)
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (event_id, user_id, role_code)
);
CREATE INDEX IF NOT EXISTS idx_event_member_user ON event_member(user_id);
-- 부스 표준 사양 (PLANNING §7-1 / rates-v1.json boothStandard) -----------------
CREATE TABLE IF NOT EXISTS booth_standard (
code varchar(40) PRIMARY KEY, -- assembled | premium
name varchar(100),
spec jsonb,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- 마스터데이터 참조(M18: 요율·유틸 요금·규정 룰셋 버전) -----------------------
-- 권위 룰셋은 classpath rulesets/*.json(룰 엔진 로드). 본 테이블은 M18 백오피스 조회·버전 추적용 사본.
CREATE TABLE IF NOT EXISTS master_data (
category varchar(40) NOT NULL, -- RATE | UTILITY_FEE | COMPLIANCE
code varchar(80) NOT NULL,
name varchar(200),
value_json jsonb,
ruleset_version varchar(40),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (category, code)
);

View File

@ -0,0 +1,105 @@
-- 킨텍스 — 공간 코어 스키마 (PLANNING §8: 부스 POLYGON·트렌치 POINT·배선 LINESTRING)
-- SRID 0 = 홀 로컬 평면 좌표계(미터). ST_Area/ST_Distance/ST_Length 결과 단위 = m/㎡.
-- 공간 인덱스는 전부 GiST.
-- 트렌치 (POINT) — 유틸리티 공급 매트릭스(홀1·7 가스) ------------------------
CREATE TABLE IF NOT EXISTS trench (
id varchar(60) PRIMARY KEY,
hall_id varchar(20) NOT NULL REFERENCES hall(id) ON DELETE CASCADE,
geom geometry(Point,0) NOT NULL,
supply_power boolean NOT NULL DEFAULT true,
supply_water boolean NOT NULL DEFAULT true,
supply_air boolean NOT NULL DEFAULT true,
supply_network boolean NOT NULL DEFAULT true,
supply_gas boolean NOT NULL DEFAULT false,
is_assumed boolean NOT NULL DEFAULT true -- 실측 아닌 가정 그리드(PLANNING R4)
);
CREATE INDEX IF NOT EXISTS idx_trench_geom ON trench USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_trench_hall ON trench (hall_id);
-- 비상구 (POINT + 이격 버퍼) — 비상구 차단 검증(ST_Intersects) ---------------
CREATE TABLE IF NOT EXISTS hall_exit (
id varchar(60) PRIMARY KEY,
hall_id varchar(20) NOT NULL REFERENCES hall(id) ON DELETE CASCADE,
geom geometry(Point,0) NOT NULL,
clearance_m numeric(5,2) NOT NULL DEFAULT 3.0, -- 비상구 접근 확보 버퍼(m)
is_assumed boolean NOT NULL DEFAULT true
);
CREATE INDEX IF NOT EXISTS idx_hall_exit_geom ON hall_exit USING GIST (geom);
-- 배치안 (Layout, 버전) ------------------------------------------------------
CREATE TABLE IF NOT EXISTS layout (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
hall_id varchar(20) NOT NULL REFERENCES hall(id),
version integer NOT NULL DEFAULT 1,
name varchar(120),
status varchar(20) NOT NULL DEFAULT 'draft',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (event_id, hall_id, version)
);
-- 부스 (POLYGON) -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS booth (
id varchar(40) PRIMARY KEY,
layout_id varchar(40) NOT NULL REFERENCES layout(id) ON DELETE CASCADE,
booth_no varchar(40),
booth_type varchar(20), -- assembled|independent|corner|island
geom geometry(Polygon,0) NOT NULL,
size_w_m numeric(8,2),
size_d_m numeric(8,2),
height_m numeric(6,2), -- 규정 HEIGHT_MAX 대상
floor_load_t_per_m2 numeric(5,2), -- 규정 FLOOR_LOAD 대상
assigned_company_name varchar(200), -- 표시용(민감정보 아님)
premium boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_booth_geom ON booth USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_booth_layout ON booth (layout_id);
-- 부스 설계안 (DesignPlan 버전, spec jsonb) ----------------------------------
CREATE TABLE IF NOT EXISTS design_plan (
id varchar(40) PRIMARY KEY,
booth_id varchar(40) NOT NULL REFERENCES booth(id) ON DELETE CASCADE,
version integer NOT NULL DEFAULT 1,
status varchar(20) NOT NULL DEFAULT 'draft',
spec jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (booth_id, version)
);
-- 유틸리티 신청 (UtilityOrder, 배선 LINESTRING) ------------------------------
-- booth_id는 배치 재저장(replaceBooths) 시 부스 교체 가능성 때문에 소프트 참조(FK 미설정).
CREATE TABLE IF NOT EXISTS utility_order (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
booth_id varchar(40) NOT NULL,
status varchar(20) NOT NULL DEFAULT 'submitted',
quote jsonb,
wiring geometry(MultiLineString,0), -- 전기/네트워크/급배수 배선 경로
location_diagram_url varchar(300), -- 자동 생성 위치표시도(M4-3)
supply_timing varchar(120),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_utility_order_wiring ON utility_order USING GIST (wiring);
CREATE INDEX IF NOT EXISTS idx_utility_order_booth ON utility_order (booth_id);
-- 렌더잡 (RenderJob, 이력·쿼터 정본) -----------------------------------------
-- event_id/booth_id 소프트 참조(감사·쿼터 이력은 부스 교체와 무관하게 보존).
CREATE TABLE IF NOT EXISTS render_job (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL,
booth_id varchar(40) NOT NULL,
shot_preset varchar(10) NOT NULL, -- S1..S7
status varchar(12) NOT NULL DEFAULT 'QUEUED', -- QUEUED|RUNNING|DONE|FAILED
image_url varchar(300),
schema_hash varchar(80),
model_version varchar(60),
error_message varchar(300), -- 사용자 친화 요약만(스택트레이스 금지)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_render_job_booth ON render_job (booth_id, shot_preset);
CREATE INDEX IF NOT EXISTS idx_render_job_event_status ON render_job (event_id, status);

View File

@ -0,0 +1,54 @@
-- 킨텍스 — 홀 마스터 시드 (멱등, PLANNING §7-1 / floorplans README 실측치)
-- 재실행 안전: ON CONFLICT DO UPDATE 로 최신 규격 갱신.
INSERT INTO hall (id, exhibition_center, label, width_m, depth_m, height_m, area_m2,
floor_load_t_per_m2, floor_type, booth_capacity, has_gas, is_assumed_trench)
VALUES
-- 제1전시장 홀1~5: 63×171×15m, 5t/㎡, 콘크리트 폴리싱, 약 600부스
('H1', 1, '제1전시장 홀1', 63, 171, 15, 10611, 5.0, 'concrete_polished', 600, true, true),
('H2', 1, '제1전시장 홀2', 63, 171, 15, 10773, 5.0, 'concrete_polished', 600, false, true),
('H3', 1, '제1전시장 홀3', 63, 171, 15, 10773, 5.0, 'concrete_polished', 600, false, true),
('H4', 1, '제1전시장 홀4', 63, 171, 15, 10773, 5.0, 'concrete_polished', 600, false, true),
('H5', 1, '제1전시장 홀5', 63, 171, 15, 10611, 5.0, 'concrete_polished', 600, false, true),
-- 옥외전시장 2,849㎡ (56×52)
('OUTDOOR', 1, '옥외전시장', 56, 52, NULL, 2849, 5.0, 'outdoor', NULL, false, true),
-- 제2전시장 홀6(이벤트홀): 93×60×10m, 2t/㎡, 카펫, 200부스
('H6', 2, '제2전시장 홀6', 93, 60, 10, 5580, 2.0, 'carpet', 200, false, true),
-- 홀7/8: 126×90×12m, 5t/㎡, 510부스 (홀7 가스)
('H7', 2, '제2전시장 홀7', 126, 90, 12, 11290, 5.0, 'concrete_polished', 510, true, true),
('H8', 2, '제2전시장 홀8', 126, 90, 12, 11290, 5.0, 'concrete_polished', 510, false, true),
-- 홀9/10: 132×99×15m, 5t/㎡, 550부스
('H9', 2, '제2전시장 홀9', 132, 99, 15, 13238, 5.0, 'concrete_polished', 550, false, true),
('H10', 2, '제2전시장 홀10', 132, 99, 15, 13072, 5.0, 'concrete_polished', 550, false, true)
ON CONFLICT (id) DO UPDATE SET
exhibition_center = EXCLUDED.exhibition_center,
label = EXCLUDED.label,
width_m = EXCLUDED.width_m,
depth_m = EXCLUDED.depth_m,
height_m = EXCLUDED.height_m,
area_m2 = EXCLUDED.area_m2,
floor_load_t_per_m2 = EXCLUDED.floor_load_t_per_m2,
floor_type = EXCLUDED.floor_type,
booth_capacity = EXCLUDED.booth_capacity,
has_gas = EXCLUDED.has_gas,
is_assumed_trench = EXCLUDED.is_assumed_trench;
-- 비상구(가정): 사각형 홀 각 변 중앙 4개소, 이격 3m. CAD 실측 추출 시 교체(TODO).
INSERT INTO hall_exit (id, hall_id, geom, clearance_m, is_assumed)
SELECT h.id || '-EXIT-' || g.tag,
h.id,
ST_SetSRID(ST_MakePoint(g.x, g.y), 0),
3.0,
true
FROM hall h
CROSS JOIN LATERAL (VALUES
('S', h.width_m / 2.0, 0.0),
('N', h.width_m / 2.0, h.depth_m),
('W', 0.0, h.depth_m / 2.0),
('E', h.width_m, h.depth_m / 2.0)
) AS g(tag, x, y)
WHERE h.id <> 'OUTDOOR' AND h.width_m IS NOT NULL AND h.depth_m IS NOT NULL
ON CONFLICT (id) DO UPDATE SET
geom = EXCLUDED.geom,
clearance_m = EXCLUDED.clearance_m,
is_assumed = EXCLUDED.is_assumed;

View File

@ -0,0 +1,16 @@
-- 킨텍스 — 트렌치 가정 그리드 시드 (멱등, PLANNING R4 / backend isAssumedTrench 계약 정합)
-- ★가정 그리드: 실측 아님(is_assumed=true). CAD "평면,트렌치.dwg" 좌표 추출 시 교체(후속 TODO).
-- 그리드: 각 변에서 3m 안쪽부터 6m 간격 격자 포인트. 각 트렌치는 전기·급배수·압축공기·네트워크 번들,
-- 가스는 has_gas 홀(홀1·7)만 공급.
INSERT INTO trench (id, hall_id, geom,
supply_power, supply_water, supply_air, supply_network, supply_gas, is_assumed)
SELECT h.id || '-T-' || gx || '-' || gy,
h.id,
ST_SetSRID(ST_MakePoint(gx, gy), 0),
true, true, true, true, h.has_gas, true
FROM hall h
CROSS JOIN LATERAL generate_series(3, floor(h.width_m)::int - 3, 6) AS gx
CROSS JOIN LATERAL generate_series(3, floor(h.depth_m)::int - 3, 6) AS gy
WHERE h.id <> 'OUTDOOR' AND h.width_m IS NOT NULL AND h.depth_m IS NOT NULL
ON CONFLICT (id) DO NOTHING;

View File

@ -0,0 +1,50 @@
-- 킨텍스 — 부스 표준·요율/요금 참조·등록업체·데모 행사 시드 (멱등)
-- 권위 룰셋(요율·규정)은 classpath rulesets/*.json(룰 엔진 로드). 아래 master_data 는 M18 조회용 사본.
-- 부스 표준 사양 (rates-v1.json boothStandard / PLANNING §7-1) ----------------
INSERT INTO booth_standard (code, name, spec) VALUES
('assembled', '조립(기본)부스',
'{"includedPowerKw":1.0,"spotlights":5,"outlets220v":1,"floorFireRetardant":"A"}'::jsonb),
('premium', '프리미엄부스',
'{"sizeM":[6,3,4],"includedPowerKw":2.0}'::jsonb)
ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, spec = EXCLUDED.spec, updated_at = now();
-- 임대 요율 참조 (rates-v1.0) -------------------------------------------------
INSERT INTO master_data (category, code, name, value_json, ruleset_version) VALUES
('RATE', 'exhibitionHallPerM2', '전시홀 임대 요율(원/㎡·12h)', '2250'::jsonb, 'rates-v1.0'),
('RATE', 'lobbyPerM2', '로비 요율(원/㎡)', '10000'::jsonb, 'rates-v1.0'),
('RATE', 'outdoorPerM2', '옥외 요율(원/㎡)', '2000'::jsonb, 'rates-v1.0'),
('RATE', 'eventHallPerM2', '이벤트홀 요율(원/㎡)', '2420'::jsonb, 'rates-v1.0'),
('RATE', 'seasonFactors', '성수기/비수기/1전시장 계수',
'{"peak":0.10,"offPeak":-0.10,"hall1":0.10}'::jsonb, 'rates-v1.0'),
-- 유틸리티 요금 참조
('UTILITY_FEE', 'power.perKw', '전기 220V 단상 1kW(원)', '55000'::jsonb, 'rates-v1.0'),
('UTILITY_FEE', 'power.distributionBox50A', '분전반 50A 추가(원)', '100000'::jsonb, 'rates-v1.0'),
('UTILITY_FEE', 'network.internetWiredPerLine', '인터넷 유선 회선(원)', '150000'::jsonb, 'rates-v1.0'),
('UTILITY_FEE', 'plumbing.perOutlet', '급배수 1구(원)', '150000'::jsonb, 'rates-v1.0'),
('UTILITY_FEE', 'compressedAir.perOutlet', '압축공기 1구(원)', '150000'::jsonb, 'rates-v1.0'),
-- 규정 룰셋 참조 (compliance-v1.0)
('COMPLIANCE', 'HEIGHT_MAX', '장치물 최고 높이(m)', '5'::jsonb, 'compliance-v1.0'),
('COMPLIANCE', 'RIGGING_RANGE', '리깅 허용 높이(m)', '[6.5,8.5]'::jsonb, 'compliance-v1.0'),
('COMPLIANCE', 'MEZZANINE_RATIO','복층 면적 비율 상한', '0.5'::jsonb, 'compliance-v1.0'),
('COMPLIANCE', 'AISLE_WIDTH_MIN','피난 통로 폭 최소(m)', '3'::jsonb, 'compliance-v1.0')
ON CONFLICT (category, code) DO UPDATE SET
name = EXCLUDED.name, value_json = EXCLUDED.value_json,
ruleset_version = EXCLUDED.ruleset_version, updated_at = now();
-- 등록업체 샘플 (M7/M15 응찰 게이트 검증용 — 사업자번호는 가상 예시) ----------
INSERT INTO company (id, registration_no, name, category, region, registered, phone) VALUES
('co-0001', '111-11-11111', '(주)한빛부스디자인', '전시디자인설치', '경기', true, NULL),
('co-0002', '222-22-22222', '대한리깅', '리깅', '서울', true, NULL),
('co-0003', '333-33-33333', '세계전기시설', '전기시설', '경기', true, NULL),
('co-0004', '444-44-44444', '고양카펫', '카펫/파이텍스', '경기', true, NULL)
ON CONFLICT (registration_no) DO NOTHING;
-- 데모 행사 + 홀 배정 (구조 검증용. 사용자·멤버십은 B-1 인증 트랙에서 프로비저닝) --
INSERT INTO event (id, name, start_date, end_date, status) VALUES
('e-2026-smf', '2026 스마트팩토리 코리아', DATE '2026-08-11', DATE '2026-08-14', 'active')
ON CONFLICT (id) DO NOTHING;
INSERT INTO hall_assignment (id, event_id, hall_id, is_primary) VALUES
('ha-2026-smf-h7', 'e-2026-smf', 'H7', true)
ON CONFLICT (event_id, hall_id) DO NOTHING;

View File

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
BoothMapper — M2 플로어플랜 공간 SQL (PostGIS). SRID 0 = 홀 로컬 평면(미터).
판매면적 ST_Area · 통로폭 ST_Distance/ST_DWithin · 비상구 차단 ST_Intersects/ST_Buffer.
파라미터 계약(백엔드 조립):
- upsertLayout params: {layoutId?, eventId, hallId, version, name, status}
- replaceBooths booths[i]: {boothId?, boothNo, type, wkt(POLYGON WKT), sizeW, sizeD,
heightM, floorLoad, assignedCompanyName, premium}
· wkt 예: 'POLYGON((0 0,6 0,6 3,0 3,0 0))'
- findBooths 반환: polygon 은 GeoJSON 문자열(polygonJson) — 백엔드가 좌표배열로 파싱.
-->
<mapper namespace="com.zioinfo.kintex.module.m2.mapper.BoothMapper">
<!-- 배치안 헤더(version=null → 최신). -->
<select id="findLayout" resultType="map">
SELECT id AS "layoutId",
version,
name,
status,
to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
FROM layout
WHERE event_id = #{eventId}
AND hall_id = #{hallId}
<if test="version != null">
AND version = #{version}
</if>
ORDER BY version DESC
LIMIT 1
</select>
<!-- 배치안 부스 목록(폴리곤은 GeoJSON). -->
<select id="findBooths" resultType="map">
SELECT id AS "boothId",
booth_no AS "boothNo",
booth_type AS "type",
ST_AsGeoJSON(geom) AS "polygonJson",
size_w_m AS "sizeW",
size_d_m AS "sizeD",
height_m AS "heightM",
floor_load_t_per_m2 AS "floorLoadTPerM2",
assigned_company_name AS "assignedCompanyName",
premium
FROM booth
WHERE layout_id = #{layoutId}
ORDER BY booth_no
</select>
<!-- 배치안 헤더 upsert → layoutId 반환. INSERT ... RETURNING(PG). -->
<select id="upsertLayout" resultType="string">
INSERT INTO layout (id, event_id, hall_id, version, name, status, updated_at)
VALUES (COALESCE(#{params.layoutId}, gen_random_uuid()::text),
#{params.eventId}, #{params.hallId},
COALESCE(#{params.version}, 1),
#{params.name},
COALESCE(#{params.status}, 'draft'),
now())
ON CONFLICT (event_id, hall_id, version) DO UPDATE
SET name = EXCLUDED.name,
status = EXCLUDED.status,
updated_at = now()
RETURNING id
</select>
<!-- 배치안 단위 부스 교체 저장: 데이터 수정 CTE 로 삭제 후 일괄 INSERT(단일 statement). -->
<insert id="replaceBooths">
WITH del AS (DELETE FROM booth WHERE layout_id = #{layoutId})
INSERT INTO booth (id, layout_id, booth_no, booth_type, geom,
size_w_m, size_d_m, height_m, floor_load_t_per_m2,
assigned_company_name, premium)
VALUES
<foreach collection="booths" item="b" separator=",">
(COALESCE(#{b.boothId}, gen_random_uuid()::text),
#{layoutId}, #{b.boothNo}, #{b.type},
ST_GeomFromText(#{b.wkt}, 0),
#{b.sizeW}, #{b.sizeD}, #{b.heightM}, #{b.floorLoad},
#{b.assignedCompanyName}, #{b.premium})
</foreach>
</insert>
<!-- 판매 면적 합계(㎡). -->
<select id="sumSalesArea" resultType="double">
SELECT COALESCE(SUM(ST_Area(geom)), 0)
FROM booth
WHERE layout_id = #{layoutId}
</select>
<!-- 최소 통로 폭(m): 10m 이내 인접 부스 폴리곤 쌍의 최소 이격거리. -->
<select id="minAisleWidth" resultType="double">
SELECT MIN(ST_Distance(a.geom, b.geom))
FROM booth a
JOIN booth b
ON a.layout_id = b.layout_id
AND a.id <![CDATA[<]]> b.id
AND ST_DWithin(a.geom, b.geom, 10)
WHERE a.layout_id = #{layoutId}
</select>
<!-- 비상구를 차단하는 부스 수: 비상구 이격 버퍼와 교차하는 부스. -->
<select id="countExitsBlocked" resultType="int">
SELECT COUNT(DISTINCT bo.id)
FROM booth bo
JOIN hall_exit ex ON ex.hall_id = #{hallId}
WHERE bo.layout_id = #{layoutId}
AND ST_Intersects(bo.geom, ST_Buffer(ex.geom, ex.clearance_m))
</select>
</mapper>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
DesignMapper — M3 부스 설계안(버전). spec 은 jsonb.
파라미터 계약: upsertDesign params {designId?, boothId, version, status, specJson(JSON 문자열)}.
findDesign 반환: spec 은 specJson(문자열) — 백엔드가 DesignSpec 으로 역직렬화.
-->
<mapper namespace="com.zioinfo.kintex.module.m3.mapper.DesignMapper">
<!-- 설계안 조회(version=null → 최신). -->
<select id="findDesign" resultType="map">
SELECT id AS "designId",
booth_id AS "boothId",
version,
status,
spec::text AS "specJson",
to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
FROM design_plan
WHERE booth_id = #{boothId}
<if test="version != null">
AND version = #{version}
</if>
ORDER BY version DESC
LIMIT 1
</select>
<!-- 설계안 upsert → designId 반환. -->
<select id="upsertDesign" resultType="string">
INSERT INTO design_plan (id, booth_id, version, status, spec, updated_at)
VALUES (COALESCE(#{params.designId}, gen_random_uuid()::text),
#{params.boothId},
COALESCE(#{params.version}, 1),
COALESCE(#{params.status}, 'draft'),
CAST(#{params.specJson} AS jsonb),
now())
ON CONFLICT (booth_id, version) DO UPDATE
SET status = EXCLUDED.status,
spec = EXCLUDED.spec,
updated_at = now()
RETURNING id
</select>
<!-- 부스 → 행사 역참조(RBAC 확인용). -->
<select id="findEventIdByBooth" resultType="string">
SELECT l.event_id
FROM booth b
JOIN layout l ON l.id = b.layout_id
WHERE b.id = #{boothId}
</select>
</mapper>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
RenderJobMapper — M5 렌더잡 내구 이력·쿼터 정본(Redis 는 큐/실시간). 성공(DONE) 시에만 쿼터 차감(§6-5).
파라미터 계약: insertJob params {jobId, eventId, boothId, shotPreset, status,
imageUrl?, schemaHash?, modelVersion?, errorMessage?}.
-->
<mapper namespace="com.zioinfo.kintex.module.m5.mapper.RenderJobMapper">
<!-- 잡 생성 기록(발행 시). 재발행 안전 위해 ON CONFLICT DO NOTHING. -->
<insert id="insertJob">
INSERT INTO render_job (id, event_id, booth_id, shot_preset, status,
image_url, schema_hash, model_version, error_message, created_at, updated_at)
VALUES (#{params.jobId}, #{params.eventId}, #{params.boothId}, #{params.shotPreset},
COALESCE(#{params.status}, 'QUEUED'),
#{params.imageUrl}, #{params.schemaHash}, #{params.modelVersion},
#{params.errorMessage}, now(), now())
ON CONFLICT (id) DO NOTHING
</insert>
<!-- 상태·이미지·오류 갱신(워커 콜백). -->
<update id="updateStatus">
UPDATE render_job
SET status = #{status},
image_url = #{imageUrl},
error_message = #{errorMessage},
updated_at = now()
WHERE id = #{jobId}
</update>
<!-- 부스 단위 이력 목록(SCR-12 갤러리). shotPreset=null → 전체. -->
<select id="findByBooth" resultType="map">
SELECT id AS "jobId",
booth_id AS "boothId",
shot_preset AS "shotPreset",
status,
image_url AS "imageUrl",
schema_hash AS "schemaHash",
model_version AS "modelVersion",
error_message AS "errorMessage",
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt"
FROM render_job
WHERE booth_id = #{boothId}
<if test="shotPreset != null">
AND shot_preset = #{shotPreset}
</if>
ORDER BY created_at DESC
</select>
<!-- 행사별 성공(DONE) 건수(쿼터 정본 산정). -->
<select id="countSucceededByEvent" resultType="int">
SELECT COUNT(*)
FROM render_job
WHERE event_id = #{eventId}
AND status = 'DONE'
</select>
</mapper>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
UserMapper — 인증·행사 멤버십 조회.
보안(계약 §0-3): password_hash 는 findAuthByEmail 인증 검증 용도로만 반환하며 응답 DTO로 노출 금지.
다른 조회에는 절대 select 하지 않는다.
-->
<mapper namespace="com.zioinfo.kintex.auth.mapper.UserMapper">
<!-- 이메일 → 인증행(userId, displayName, passwordHash, hallManager). 없으면 null. -->
<select id="findAuthByEmail" resultType="map">
SELECT id AS "userId",
display_name AS "displayName",
password_hash AS "passwordHash",
hall_manager AS "hallManager"
FROM app_user
WHERE email = #{email}
AND status = 'ACTIVE'
</select>
<!-- 사용자의 행사별 역할 + 워크스페이스 카드 메타(행사명·기간·홀라벨·D-데이). -->
<select id="findEventRoles" resultType="map">
SELECT em.event_id AS "eventId",
em.role_code AS "roleCode",
e.name AS "eventName",
e.start_date AS "startDate",
e.end_date AS "endDate",
h.label AS "hallLabel",
(e.start_date - CURRENT_DATE) AS "dday"
FROM event_member em
JOIN event e ON e.id = em.event_id
LEFT JOIN hall_assignment ha ON ha.event_id = e.id AND ha.is_primary = true
LEFT JOIN hall h ON h.id = ha.hall_id
WHERE em.user_id = #{userId}
ORDER BY e.start_date
</select>
</mapper>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
WiringMapper — M4 유틸리티 배선(PostGIS). 트렌치 POINT KNN(<->), 최단 배선 ST_MakeLine/ST_Distance.
kind: power|network|plumbing|water|air|gas → 트렌치 공급 컬럼 매핑.
shortestPath 반환: lineJson(GeoJSON LineString) + lengthM + assumed — 백엔드가 coords 파싱.
-->
<mapper namespace="com.zioinfo.kintex.module.m4.mapper.WiringMapper">
<!-- kind → 공급 조건 SQL 조각. -->
<sql id="supplyCondition">
<choose>
<when test="kind == 'power'">t.supply_power = true</when>
<when test="kind == 'network'">t.supply_network = true</when>
<when test="kind == 'plumbing' or kind == 'water'">t.supply_water = true</when>
<when test="kind == 'air'">t.supply_air = true</when>
<when test="kind == 'gas'">t.supply_gas = true</when>
<otherwise>true</otherwise>
</choose>
</sql>
<!-- 부스 중심 기준 최근접 트렌치(kind 공급 가능) — KNN 정렬 상위 5개. -->
<select id="findNearestTrenches" resultType="map">
SELECT t.id AS "trenchId",
ST_X(t.geom) AS "x",
ST_Y(t.geom) AS "y",
ST_Distance(t.geom, c.centroid) AS "distanceM",
t.is_assumed AS "assumed"
FROM trench t
CROSS JOIN (SELECT ST_Centroid(geom) AS centroid FROM booth WHERE id = #{boothId}) c
WHERE t.hall_id = #{hallId}
AND <include refid="supplyCondition"/>
ORDER BY t.geom <![CDATA[<->]]> c.centroid
LIMIT 5
</select>
<!-- 단말(fromX,fromY) → 최근접 공급 트렌치 최단 직선 경로 + 길이. -->
<select id="shortestPath" resultType="map">
SELECT ST_AsGeoJSON(
ST_MakeLine(t.geom, ST_SetSRID(ST_MakePoint(#{fromX}, #{fromY}), 0))
) AS "lineJson",
ST_Distance(t.geom, ST_SetSRID(ST_MakePoint(#{fromX}, #{fromY}), 0)) AS "lengthM",
t.is_assumed AS "assumed"
FROM trench t
WHERE t.hall_id = #{hallId}
AND <include refid="supplyCondition"/>
ORDER BY t.geom <![CDATA[<->]]> ST_SetSRID(ST_MakePoint(#{fromX}, #{fromY}), 0)
LIMIT 1
</select>
<!-- 홀 트렌치가 가정 그리드인지(실측 미확보) 여부. -->
<select id="isAssumedTrench" resultType="boolean">
SELECT is_assumed_trench
FROM hall
WHERE id = #{hallId}
</select>
</mapper>

View File

@ -0,0 +1,4 @@
# 백엔드 오리진 (Spring Boot). 개발 시 Vite 프록시 대상.
VITE_BACKEND_ORIGIN=http://localhost:8080
# 프로덕션에서 API를 절대 경로로 호출할 때 사용(비우면 same-origin + 프록시).
VITE_API_BASE=

9
src/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules
dist
dist-ssr
*.local
.env
.env.*
!.env.example
.vite
*.tsbuildinfo

27
src/frontend/README.md Normal file
View File

@ -0,0 +1,27 @@
# KINTEX AI 전시관리 — Frontend
React 18 + Vite 5 + TypeScript. 백엔드(Spring Boot :8080) 계약(`_workspace/01_backend_contracts.md`) 기반.
## 개발
```bash
npm install
npm run dev # Vite 개발 서버 :5173 (/api·/ws → :8080 프록시)
npm run typecheck # tsc -b (타입 체크)
npm run build # tsc -b && vite build
```
`.env.example``.env` 복사. `VITE_BACKEND_ORIGIN`으로 백엔드 오리진 지정(기본 `http://localhost:8080`).
## 구조
- `src/styles/tokens.css` — 디자인 토큰 **단일 출처**(design.md §1). 하드코딩 금지, 이 변수만 참조.
- `src/api/``client`(JWT·봉투·오류코드), `endpoints`(계약 래퍼), `types`(응답 shape), `websocket`(STOMP RenderJob).
- `src/store/authStore.ts` — 인증/워크스페이스 상태(zustand).
- `src/components/ui/` — StatusBadge·DdayChip·AiLabel·Button·AiImage(워터마크 상시)·States.
- `src/components/layout/AppShell.tsx` — 인증 셸(사이드바+상단바).
- `src/screens/login/` — SCR-01 로그인·워크스페이스·초대(등록업체 차단).
- `src/screens/floorplan/` — SCR-03 배치 에디터(SVG 캔버스·AI 자동배치·검증).
## 원칙
- 백엔드 응답 shape과 정확히 일치. 불일치는 `_workspace/02_frontend_progress.md`에 기록.
- AI 생성 이미지 워터마크·고지문 제거 불가. 접근성 WCAG AA.
- 캔버스 편집은 데스크톱 전용(모바일은 뷰어+승인).

17
src/frontend/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KINTEX AI 전시관리</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2005
src/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
src/frontend/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "kintex-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "킨텍스 AI 전시관리 시스템 — 프론트엔드 (React + Vite + TypeScript)",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b",
"preview": "vite preview",
"lint": "tsc --noEmit"
},
"dependencies": {
"@stomp/stompjs": "^7.0.0",
"@tanstack/react-query": "^5.59.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"sockjs-client": "^1.6.1",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/node": "^20.19.43",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/sockjs-client": "^1.5.4",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}

38
src/frontend/src/App.tsx Normal file
View File

@ -0,0 +1,38 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { useAuthStore } from './store/authStore';
import { LoginPage } from './screens/login/LoginPage';
import { BoothLayoutEditorPage } from './screens/floorplan/BoothLayoutEditorPage';
import { AppShell } from './components/layout/AppShell';
/** 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) */
function RequireAuth({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* 인증 필요 — 사이드바 셸 안 */}
<Route
element={
<RequireAuth>
<AppShell />
</RequireAuth>
}
>
{/* SCR-03 부스 배치 에디터 (M2) */}
<Route
path="/events/:eventId/halls/:hallId/layout"
element={<BoothLayoutEditorPage />}
/>
{/* 향후: SCR-02 대시보드, SCR-05 부스홈, SCR-10 승인큐 등 */}
</Route>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
}

View File

@ -0,0 +1,98 @@
/*
* API base URL·JWT · .
* 근거: _workspace/01_backend_contracts.md §0 (ApiResponse , HTTP, ).
*/
import type { ApiError, ApiErrorCode, ApiResponse } from './types';
// 프로덕션에서 절대 base가 필요하면 VITE_API_BASE 사용, 아니면 same-origin(개발은 Vite 프록시).
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '';
/** 봉투 error를 그대로 던지는 오류 타입 — UI는 code로 분기(FORBIDDEN·COMPLIANCE_BLOCKED 등). */
export class ApiRequestError extends Error {
readonly code: ApiErrorCode | 'NETWORK' | 'UNKNOWN';
readonly httpStatus: number;
constructor(code: ApiRequestError['code'], message: string, httpStatus: number) {
super(message);
this.name = 'ApiRequestError';
this.code = code;
this.httpStatus = httpStatus;
}
}
// ── JWT 토큰 저장소 (메모리 + sessionStorage 백업) ──
const TOKEN_KEY = 'kintex.accessToken';
let accessToken: string | null = sessionStorage.getItem(TOKEN_KEY);
export function setAccessToken(token: string | null): void {
accessToken = token;
if (token) sessionStorage.setItem(TOKEN_KEY, token);
else sessionStorage.removeItem(TOKEN_KEY);
}
export function getAccessToken(): string | null {
return accessToken;
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: unknown;
signal?: AbortSignal;
/** 인증 헤더 생략(공개 경로). */
anonymous?: boolean;
}
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (!opts.anonymous && accessToken) headers.Authorization = `Bearer ${accessToken}`;
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, {
method: opts.method ?? 'GET',
headers,
body: opts.body != null ? JSON.stringify(opts.body) : undefined,
signal: opts.signal,
});
} catch (e) {
if ((e as Error).name === 'AbortError') throw e;
throw new ApiRequestError('NETWORK', '네트워크 연결을 확인해 주세요.', 0);
}
// 401 → 토큰 만료: 세션 정리 (라우터가 로그인으로 유도)
if (res.status === 401) {
setAccessToken(null);
}
let payload: ApiResponse<T> | null = null;
const text = await res.text();
if (text) {
try {
payload = JSON.parse(text) as ApiResponse<T>;
} catch {
payload = null;
}
}
if (!res.ok || (payload && payload.success === false)) {
const err: ApiError | null = payload?.error ?? null;
throw new ApiRequestError(
(err?.code as ApiErrorCode) ?? 'UNKNOWN',
err?.message ?? `요청을 처리하지 못했습니다. (${res.status})`,
res.status,
);
}
if (!payload) {
// 봉투 없이 204 등 — 빈 값 허용
return undefined as unknown as T;
}
return payload.data as T;
}
export const api = {
get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(path, { ...opts, method: 'GET' }),
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(path, { ...opts, method: 'POST', body }),
put: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(path, { ...opts, method: 'PUT', body }),
};

View File

@ -0,0 +1,55 @@
/*
* (§2~§6) .
* 501(NOT_IMPLEMENTED) ( code로 degraded ).
*/
import { api } from './client';
import type {
AcceptInviteRequest,
AutoLayoutOption,
AutoLayoutRequest,
ComplianceReport,
KintexPrincipal,
LayoutDto,
LayoutSaveRequest,
LoginRequest,
LoginResponse,
RenderJobDto,
WorkspaceDto,
} from './types';
// ── 인증·워크스페이스 (SCR-01) ──
export const authApi = {
login: (body: LoginRequest) =>
api.post<LoginResponse>('/api/auth/login', body, { anonymous: true }),
workspaces: () => api.get<WorkspaceDto[]>('/api/auth/workspaces'),
me: () => api.get<KintexPrincipal>('/api/auth/me'),
acceptInvite: (body: AcceptInviteRequest) =>
api.post<LoginResponse>('/api/auth/accept-invite', body),
};
// ── M2 플로어플랜 (SCR-03/04) ──
const layoutBase = (eventId: string, hallId: string) =>
`/api/events/${encodeURIComponent(eventId)}/halls/${encodeURIComponent(hallId)}/layout`;
export const layoutApi = {
get: (eventId: string, hallId: string, version?: number) =>
api.get<LayoutDto>(
`${layoutBase(eventId, hallId)}${version != null ? `?version=${version}` : ''}`,
),
save: (eventId: string, hallId: string, body: LayoutSaveRequest) =>
api.put<LayoutDto>(layoutBase(eventId, hallId), body),
validate: (eventId: string, hallId: string, version?: number) =>
api.post<ComplianceReport>(
`${layoutBase(eventId, hallId)}/validate${version != null ? `?version=${version}` : ''}`,
),
autoGenerate: (eventId: string, hallId: string, body: AutoLayoutRequest) =>
api.post<AutoLayoutOption[]>(`${layoutBase(eventId, hallId)}/auto-generate`, body),
};
// ── M5 RenderJob (SCR-12) ──
export const renderApi = {
status: (eventId: string, jobId: string) =>
api.get<RenderJobDto>(
`/api/events/${encodeURIComponent(eventId)}/render-jobs/${encodeURIComponent(jobId)}`,
),
};

View File

@ -0,0 +1,165 @@
/*
* API ( 출처: _workspace/01_backend_contracts.md)
* shape과 . _workspace/ ·.
*/
// ── 0-1 응답 봉투 ──
export interface ApiError {
code: ApiErrorCode;
message: string;
}
export interface ApiResponse<T> {
success: boolean;
data: T | null;
error: ApiError | null;
}
export interface PageResponse<T> {
items: T[];
page: number;
size: number;
total: number;
}
// ── 0-2 오류 코드 ──
export type ApiErrorCode =
| 'VALIDATION'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'COMPLIANCE_BLOCKED'
| 'RENDER_QUOTA_EXCEEDED'
| 'NOT_REGISTERED_COMPANY'
| 'NOT_IMPLEMENTED'
| 'INTERNAL';
// ── 0-5 역할 ──
export type EventRole = 'ORGANIZER' | 'EXHIBITOR' | 'CONTRACTOR' | 'HALL_MANAGER';
// ── 2. 인증·워크스페이스 (SCR-01) ──
export interface WorkspaceDto {
eventId: string;
eventName: string;
startDate: string;
endDate: string;
hallLabel: string;
myRole: EventRole;
dday: number;
}
export interface AuthUser {
userId: string;
displayName: string;
hallManager: boolean;
}
export interface LoginResponse {
accessToken: string;
expiresInSeconds: number;
user: AuthUser;
workspaces: WorkspaceDto[];
}
export interface LoginRequest {
email: string;
password: string;
}
export interface AcceptInviteRequest {
inviteCode: string;
companyRegistrationNo: string;
}
export interface KintexPrincipal {
userId: string;
displayName: string;
eventRoles: Record<string, EventRole>;
hallManager: boolean;
}
// ── 3. M2 플로어플랜 (SCR-03/04) ──
export type BoothType = 'basic' | 'premium' | 'independent' | 'island';
export interface BoothDto {
boothId: string;
boothNo: string;
type: BoothType | string;
polygon: number[][]; // 홀 로컬 좌표계(미터), 닫힌 링
sizeM: [number, number];
heightM: number;
floorLoadTPerM2: number;
assignedCompanyName: string | null;
premium: boolean;
}
export interface LayoutSummary {
boothCount: number;
targetBoothCount: number;
salesAreaM2: number;
minAisleWidthM: number;
violationBlock: number;
violationWarn: number;
}
export interface LayoutDto {
layoutId: string;
eventId: string;
hallId: string;
version: number;
name: string;
status: string;
booths: BoothDto[];
summary: LayoutSummary;
updatedAt: string;
}
export interface LayoutSaveRequest {
name: string;
version: number;
booths: BoothDto[];
}
// 규정검증 리포트 (§3-validate / §4 precheck 공통 shape)
export type ViolationSeverity = 'block' | 'warn';
export interface Violation {
pin: number;
code: string;
group: string;
label: string;
severity: ViolationSeverity;
measured: string;
requiresDocument?: string;
}
export interface ComplianceReport {
rulesetVersion: string;
disclaimer: string;
blockCount: number;
warnCount: number;
passCount: number;
submittable: boolean;
violations: Violation[];
}
// AI 자동배치
export interface AutoLayoutRequest {
targetBoothCount: number;
premiumRatio: number;
stageCount: number;
loungeCount: number;
mainEntranceCount: number;
optionCount: number;
}
export interface AutoLayoutOption {
optionId: string;
label: string;
summary: LayoutSummary;
s7RenderJobId: string;
}
// ── 6. M5 RenderJob (SCR-06/12) ──
export type RenderStatus = 'QUEUED' | 'RUNNING' | 'DONE' | 'FAILED';
export interface RenderJobDto {
jobId: string;
boothId: string;
shotPreset: string;
status: RenderStatus;
imageUrl: string | null;
schemaHash: string | null;
modelVersion: string | null;
watermarkRequired: boolean;
watermarkText: string;
notice: string;
errorMessage: string | null;
createdAt: string;
}

View File

@ -0,0 +1,74 @@
/*
* WebSocket(STOMP over SockJS) RenderJob .
* 근거: 계약 §6 GET /ws(SockJS), prefix /topic,
* /topic/render/{jobId} RenderJobDto (design.md §1-4).
*/
import { Client, type IMessage, type StompSubscription } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
import type { RenderJobDto } from './types';
let client: Client | null = null;
let refCount = 0;
const WS_URL = `${(import.meta.env.VITE_API_BASE as string | undefined) ?? ''}/ws`;
function ensureClient(): Client {
if (client) return client;
client = new Client({
// SockJS 팩토리 (계약이 SockJS 핸드셰이크 명시).
webSocketFactory: () => new SockJS(WS_URL) as unknown as WebSocket,
reconnectDelay: 4000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
// 조용한 프로덕션 로깅
debug: () => {},
});
client.activate();
return client;
}
/**
* RenderJob / .
* @returns .
*/
export function subscribeRenderJob(
jobId: string,
onUpdate: (job: RenderJobDto) => void,
): () => void {
const c = ensureClient();
refCount += 1;
let sub: StompSubscription | null = null;
const topic = `/topic/render/${jobId}`;
const doSubscribe = () => {
sub = c.subscribe(topic, (msg: IMessage) => {
try {
onUpdate(JSON.parse(msg.body) as RenderJobDto);
} catch {
/* 잘못된 페이로드 무시 */
}
});
};
if (c.connected) {
doSubscribe();
} else {
// 연결 후 구독
const prev = c.onConnect;
c.onConnect = (frame) => {
prev?.(frame);
doSubscribe();
};
}
return () => {
sub?.unsubscribe();
refCount -= 1;
if (refCount <= 0 && client) {
void client.deactivate();
client = null;
refCount = 0;
}
};
}

View File

@ -0,0 +1,84 @@
import { Outlet } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
import { DdayChip } from '../ui/Badge';
import './app-shell.css';
const NAV_ITEMS = [
{ key: 'dashboard', label: '대시보드', icon: '▦' },
{ key: 'floorplan', label: '플로어플랜 스튜디오', icon: '◱' },
{ key: 'exhibitors', label: '참가업체 관리', icon: '☷' },
{ key: 'documents', label: '서류·마일스톤', icon: '❒' },
{ key: 'settlement', label: '정산', icon: '₩' },
];
/**
* (design.md §2-2) 240px + (·D-·).
* .
*/
export function AppShell() {
const user = useAuthStore((s) => s.user);
const workspace = useAuthStore((s) => s.currentWorkspace());
const logout = useAuthStore((s) => s.logout);
return (
<div className="kx-shell">
<nav className="kx-shell__sidebar" aria-label="주 메뉴">
<div className="kx-shell__brand">
<span className="kx-shell__brand-mark" aria-hidden="true">K</span>
<span className="kx-shell__brand-text">
KINTEX AI
<small>Exhibition Manager</small>
</span>
</div>
{workspace && (
<button className="kx-shell__event-switch" type="button">
<span className="kx-shell__event-name">{workspace.eventName}</span>
<span className="kx-shell__event-meta">{workspace.hallLabel}</span>
</button>
)}
<ul className="kx-shell__nav">
{NAV_ITEMS.map((item, i) => (
<li key={item.key}>
<a className={`kx-shell__nav-link ${i === 1 ? 'is-active' : ''}`} href="#">
<span className="kx-shell__nav-icon" aria-hidden="true">{item.icon}</span>
{item.label}
</a>
</li>
))}
</ul>
<div className="kx-shell__user">
<span className="kx-shell__avatar" aria-hidden="true">
{user?.displayName?.[0] ?? '·'}
</span>
<span className="kx-shell__user-name">{user?.displayName ?? '사용자'}</span>
<button className="kx-shell__logout" type="button" onClick={logout}>
</button>
</div>
</nav>
<div className="kx-shell__main">
<header className="kx-shell__topbar">
<div className="kx-shell__crumb">
{workspace?.eventName ?? '워크스페이스'}
<span className="kx-shell__crumb-sep">/</span>
</div>
<div className="kx-shell__topbar-right">
{workspace && <DdayChip dday={workspace.dday} />}
<button className="kx-shell__icon-btn" type="button" aria-label="알림">
</button>
</div>
</header>
<main className="kx-shell__content">
<Outlet />
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,187 @@
.kx-shell {
display: flex;
height: 100vh;
overflow: hidden;
}
.kx-shell__sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
background: var(--color-white);
border-right: var(--border-card);
display: flex;
flex-direction: column;
padding: var(--space-5) 0;
}
.kx-shell__brand {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0 var(--space-5);
margin-bottom: var(--space-5);
}
.kx-shell__brand-mark {
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background: var(--color-primary-600);
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.kx-shell__brand-text {
display: flex;
flex-direction: column;
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-primary-700);
line-height: 1.1;
}
.kx-shell__brand-text small {
font-size: 11px;
font-weight: 500;
color: var(--color-neutral-500);
}
.kx-shell__event-switch {
margin: 0 var(--space-4) var(--space-5);
padding: var(--space-3);
background: var(--color-neutral-050);
border: var(--border-card);
border-radius: var(--radius-lg);
text-align: left;
display: flex;
flex-direction: column;
gap: 2px;
}
.kx-shell__event-name {
font-size: var(--fs-body);
font-weight: 600;
color: var(--color-neutral-900);
}
.kx-shell__event-meta {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-shell__nav {
list-style: none;
margin: 0;
padding: 0 var(--space-3);
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.kx-shell__nav-link {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 10px var(--space-4);
border-radius: var(--radius-sm);
color: var(--color-neutral-700);
font-size: var(--fs-body);
font-weight: 500;
}
.kx-shell__nav-link:hover {
background: var(--color-neutral-050);
color: var(--color-neutral-900);
}
.kx-shell__nav-link.is-active {
background: var(--color-primary-100);
color: var(--color-primary-700);
font-weight: 600;
}
.kx-shell__nav-icon {
width: 20px;
text-align: center;
}
.kx-shell__user {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-5) 0;
border-top: var(--border-card);
margin-top: var(--space-3);
}
.kx-shell__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-primary-100);
color: var(--color-primary-700);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 13px;
}
.kx-shell__user-name {
flex: 1;
font-size: var(--fs-caption);
font-weight: 500;
color: var(--color-neutral-700);
}
.kx-shell__logout {
background: none;
border: none;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-shell__logout:hover {
color: var(--color-primary-600);
}
.kx-shell__main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.kx-shell__topbar {
height: 56px;
flex-shrink: 0;
background: var(--color-white);
border-bottom: var(--border-card);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--gutter);
}
.kx-shell__crumb {
font-size: var(--fs-body);
color: var(--color-neutral-500);
display: flex;
align-items: center;
gap: var(--space-2);
}
.kx-shell__crumb-sep {
color: var(--color-neutral-200);
}
.kx-shell__topbar-right {
display: flex;
align-items: center;
gap: var(--space-3);
}
.kx-shell__icon-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--color-neutral-500);
}
.kx-shell__icon-btn:hover {
background: var(--color-neutral-100);
}
.kx-shell__content {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
}

View File

@ -0,0 +1,66 @@
import type { RenderJobDto } from '../../api/types';
import { AiLabel } from './Badge';
import './ai-image.css';
/*
* AI · ( ).
* 근거: design.md §1-4 + §0-3 (watermarkRequired/watermarkText/notice ).
*/
interface AiImageProps {
imageUrl: string | null;
watermarkText: string;
notice: string;
shotLabel?: string;
versionTag?: string;
status?: RenderJobDto['status'];
onRetry?: () => void;
alt?: string;
}
export function AiImage({
imageUrl,
watermarkText,
notice,
shotLabel,
versionTag,
status = 'DONE',
onRetry,
alt = 'AI 생성 예상 이미지',
}: AiImageProps) {
return (
<figure className="kx-aiimg">
<div className="kx-aiimg__frame">
{status === 'DONE' && imageUrl ? (
<img className="kx-aiimg__img" src={imageUrl} alt={alt} loading="lazy" />
) : status === 'FAILED' ? (
<div className="kx-aiimg__state kx-aiimg__state--failed">
<span> </span>
{onRetry && (
<button type="button" className="kx-aiimg__retry" onClick={onRetry}>
</button>
)}
</div>
) : (
<div className="kx-aiimg__state kx-aiimg__skeleton" aria-live="polite">
<span className="kx-aiimg__spinner" aria-hidden="true" />
40
</div>
)}
{/* 상시 워터마크 (제거 불가) */}
<div className="kx-aiimg__watermark" aria-hidden="true">
{watermarkText}
</div>
<div className="kx-aiimg__topbar">
<AiLabel>AI </AiLabel>
{shotLabel && <span className="kx-aiimg__shot">{shotLabel}</span>}
{versionTag && <span className="kx-aiimg__ver">{versionTag}</span>}
</div>
</div>
{/* 고지문 — 항상 노출 */}
<figcaption className="kx-aiimg__notice">{notice}</figcaption>
</figure>
);
}

View File

@ -0,0 +1,61 @@
import type { ReactNode } from 'react';
import './badge.css';
// ── StatusBadge (design.md §1-4) ──
export type FlowStatus =
| 'draft'
| 'submitted'
| 'ai-review'
| 'approved'
| 'rejected'
| 'building'
| 'inspected';
const STATUS_LABEL: Record<FlowStatus, string> = {
draft: '작성중',
submitted: '제출됨',
'ai-review': 'AI검토',
approved: '승인',
rejected: '반려',
building: '시공중',
inspected: '검수완료',
};
/** 신청 흐름 상태 배지 — 모든 목록·카드 공통. */
export function StatusBadge({ status }: { status: FlowStatus }) {
return (
<span className={`kx-badge kx-badge--status-${status}`}>{STATUS_LABEL[status]}</span>
);
}
/** 역할 배지 (SCR-01 워크스페이스 카드). */
export function RoleBadge({ role }: { role: string }) {
const label =
role === 'ORGANIZER'
? '주최자'
: role === 'EXHIBITOR'
? '참가업체'
: role === 'CONTRACTOR'
? '장치업체'
: role === 'HALL_MANAGER'
? '홀매니저'
: role;
return <span className="kx-badge kx-badge--role">{label}</span>;
}
/** D-데이 칩 — D-3 이내 warning, 초과 error. */
export function DdayChip({ dday }: { dday: number }) {
const tone = dday < 0 ? 'over' : dday <= 3 ? 'warn' : 'normal';
const text = dday < 0 ? `D+${Math.abs(dday)}` : `D-${dday}`;
return <span className={`kx-badge kx-badge--dday-${tone} tnum`}>{text}</span>;
}
/** AI 라벨 — 보라 외곽선 칩. */
export function AiLabel({ children = 'AI 초안' }: { children?: ReactNode }) {
return (
<span className="kx-badge kx-badge--ai">
<span className="kx-badge__spark" aria-hidden="true"></span>
{children}
</span>
);
}

View File

@ -0,0 +1,30 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import './button.css';
type Variant = 'primary' | 'secondary' | 'ai' | 'ghost' | 'danger';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
leadingIcon?: ReactNode;
block?: boolean;
}
/** design.md §1-4 — 주요 버튼(primary blue), AI 액션(ai-accent 보라). radius 4px. */
export function Button({
variant = 'primary',
leadingIcon,
block,
className = '',
children,
...rest
}: ButtonProps) {
return (
<button
className={`kx-btn kx-btn--${variant} ${block ? 'kx-btn--block' : ''} ${className}`}
{...rest}
>
{leadingIcon && <span className="kx-btn__icon" aria-hidden="true">{leadingIcon}</span>}
{children}
</button>
);
}

View File

@ -0,0 +1,62 @@
import type { ReactNode } from 'react';
import { Button } from './Button';
import './states.css';
/** 로딩 스켈레톤 블록. */
export function Skeleton({ height = 16, width = '100%', radius = 4 }: {
height?: number | string;
width?: number | string;
radius?: number;
}) {
return (
<span
className="kx-skeleton"
style={{ height, width, borderRadius: radius }}
aria-hidden="true"
/>
);
}
/** 빈 상태. */
export function EmptyState({
title,
description,
action,
icon,
}: {
title: string;
description?: string;
action?: ReactNode;
icon?: ReactNode;
}) {
return (
<div className="kx-state" role="status">
{icon && <div className="kx-state__icon" aria-hidden="true">{icon}</div>}
<p className="kx-state__title">{title}</p>
{description && <p className="kx-state__desc">{description}</p>}
{action && <div className="kx-state__action">{action}</div>}
</div>
);
}
/** 에러 상태 + 재시도. */
export function ErrorState({
message = '데이터를 불러오지 못했습니다.',
onRetry,
}: {
message?: string;
onRetry?: () => void;
}) {
return (
<div className="kx-state kx-state--error" role="alert">
<p className="kx-state__title">{message}</p>
{onRetry && (
<div className="kx-state__action">
<Button variant="secondary" onClick={onRetry}>
</Button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,102 @@
.kx-aiimg {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-aiimg__frame {
position: relative;
border: var(--border-card);
border-left: var(--accent-ai);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-ai-surface);
aspect-ratio: 4 / 3;
}
.kx-aiimg__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.kx-aiimg__state {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
color: var(--color-neutral-500);
font-size: var(--fs-caption);
}
.kx-aiimg__skeleton {
background: linear-gradient(100deg, #ece9fb 30%, #f5f3ff 50%, #ece9fb 70%);
background-size: 200% 100%;
animation: kx-shimmer 1.4s infinite;
}
@keyframes kx-shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
.kx-aiimg__spinner {
width: 24px;
height: 24px;
border: 3px solid var(--color-primary-100);
border-top-color: var(--color-ai-accent);
border-radius: 50%;
animation: kx-spin 0.8s linear infinite;
}
@keyframes kx-spin {
to { transform: rotate(360deg); }
}
.kx-aiimg__state--failed {
color: var(--color-error);
background: #fef3f2;
}
.kx-aiimg__retry {
border: 1px solid var(--color-error);
color: var(--color-error);
background: var(--color-white);
border-radius: var(--radius-sm);
padding: 4px 12px;
font-size: var(--fs-caption);
}
.kx-aiimg__watermark {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transform: rotate(-24deg);
color: rgba(255, 255, 255, 0.55);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.kx-aiimg__topbar {
position: absolute;
top: var(--space-2);
left: var(--space-2);
right: var(--space-2);
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.kx-aiimg__shot,
.kx-aiimg__ver {
background: rgba(16, 24, 40, 0.72);
color: var(--color-white);
border-radius: var(--radius-pill);
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
}
.kx-aiimg__notice {
font-size: 11px;
line-height: 15px;
color: var(--color-neutral-500);
}

View File

@ -0,0 +1,70 @@
.kx-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
font-weight: 600;
white-space: nowrap;
}
/* 상태 배지 — 옅은 배경 + 진한 텍스트 (AA 대비) */
.kx-badge--status-draft {
background: var(--color-neutral-100);
color: var(--color-neutral-700);
}
.kx-badge--status-submitted {
background: var(--color-primary-050);
color: var(--color-primary-700);
}
.kx-badge--status-ai-review {
background: var(--color-ai-surface);
color: var(--color-ai-accent);
}
.kx-badge--status-approved {
background: #e6f4ee;
color: var(--color-success);
}
.kx-badge--status-rejected {
background: #fef3f2;
color: var(--color-error);
}
.kx-badge--status-building {
background: #ede9fe;
color: var(--status-building);
}
.kx-badge--status-inspected {
background: var(--color-primary-100);
color: var(--color-primary-700);
}
.kx-badge--role {
background: var(--color-primary-100);
color: var(--color-primary-700);
}
/* D-데이 */
.kx-badge--dday-normal {
background: var(--color-neutral-100);
color: var(--color-neutral-700);
}
.kx-badge--dday-warn {
background: #fff4e5;
color: var(--color-warning);
}
.kx-badge--dday-over {
background: #fef3f2;
color: var(--color-error);
}
/* AI 라벨 — 보라 외곽선 */
.kx-badge--ai {
background: var(--color-ai-surface);
color: var(--color-ai-accent);
border: 1px solid var(--color-ai-accent);
}
.kx-badge__spark {
font-size: 11px;
}

View File

@ -0,0 +1,69 @@
.kx-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
height: 40px;
padding: 0 var(--space-4);
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: var(--fs-body);
font-weight: 600;
line-height: 1;
transition: background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
white-space: nowrap;
}
.kx-btn--block {
width: 100%;
}
.kx-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.kx-btn__icon {
display: inline-flex;
font-size: 18px;
}
.kx-btn--primary {
background: var(--color-primary-600);
color: var(--color-white);
}
.kx-btn--primary:not(:disabled):hover {
background: var(--color-primary-700);
}
.kx-btn--secondary {
background: var(--color-white);
color: var(--color-neutral-700);
border-color: var(--color-neutral-200);
}
.kx-btn--secondary:not(:disabled):hover {
background: var(--color-neutral-050);
}
.kx-btn--ai {
background: var(--color-ai-accent);
color: var(--color-white);
box-shadow: 0 0 12px rgba(109, 74, 255, 0.3);
}
.kx-btn--ai:not(:disabled):hover {
opacity: 0.92;
}
.kx-btn--ghost {
background: transparent;
color: var(--color-neutral-700);
}
.kx-btn--ghost:not(:disabled):hover {
background: var(--color-neutral-100);
}
.kx-btn--danger {
background: var(--color-white);
color: var(--color-error);
border-color: var(--color-error);
}
.kx-btn--danger:not(:disabled):hover {
background: #fef3f2;
}

View File

@ -0,0 +1,38 @@
.kx-skeleton {
display: block;
background: linear-gradient(100deg, #eceff3 30%, #f6f8fa 50%, #eceff3 70%);
background-size: 200% 100%;
animation: kx-skel 1.4s infinite;
}
@keyframes kx-skel {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
.kx-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-8) var(--space-5);
text-align: center;
color: var(--color-neutral-500);
}
.kx-state__icon {
font-size: 32px;
opacity: 0.6;
}
.kx-state__title {
font-size: var(--fs-h3);
font-weight: 600;
color: var(--color-neutral-900);
}
.kx-state__desc {
font-size: var(--fs-body);
color: var(--color-neutral-500);
max-width: 360px;
}
.kx-state--error .kx-state__title {
color: var(--color-error);
}

26
src/frontend/src/main.tsx Normal file
View File

@ -0,0 +1,26 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './styles/global.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

View File

@ -0,0 +1,242 @@
import { useEffect, useState } from 'react';
import { layoutApi, renderApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client';
import { subscribeRenderJob } from '../../api/websocket';
import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge';
import { AiImage } from '../../components/ui/AiImage';
import { ErrorState } from '../../components/ui/States';
import type { AutoLayoutOption, RenderJobDto } from '../../api/types';
import './auto-layout.css';
/*
* AI (SCR-03 SCR-04 릿).
* auto-generate로 1·2·3 S7 (RenderJob) WebSocket
* "이 안으로 편집 시작"(/). (onApply).
*/
interface AutoLayoutDialogProps {
eventId: string;
hallId: string;
onClose: () => void;
onApply: (option: AutoLayoutOption) => void;
}
const DEFAULT_REQ = {
targetBoothCount: 510,
premiumRatio: 0.15,
stageCount: 1,
loungeCount: 1,
mainEntranceCount: 2,
optionCount: 3,
};
export function AutoLayoutDialog({ eventId, hallId, onClose, onApply }: AutoLayoutDialogProps) {
const [req, setReq] = useState(DEFAULT_REQ);
const [phase, setPhase] = useState<'form' | 'generating' | 'result'>('form');
const [options, setOptions] = useState<AutoLayoutOption[]>([]);
const [renderJobs, setRenderJobs] = useState<Record<string, RenderJobDto>>({});
const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// S7 조감 RenderJob 구독 (옵션별 s7RenderJobId).
useEffect(() => {
if (phase !== 'result') return;
const unsubs = options
.filter((o) => o.s7RenderJobId)
.map((o) =>
subscribeRenderJob(o.s7RenderJobId, (job) =>
setRenderJobs((prev) => ({ ...prev, [o.optionId]: job })),
),
);
// 폴백: 구독 즉시 현재 상태 1회 조회
options.forEach(async (o) => {
if (!o.s7RenderJobId) return;
try {
const job = await renderApi.status(eventId, o.s7RenderJobId);
setRenderJobs((prev) => ({ ...prev, [o.optionId]: job }));
} catch {
/* 미구현/미완 무시 */
}
});
return () => unsubs.forEach((u) => u());
}, [phase, options, eventId]);
async function generate() {
setError(null);
setPhase('generating');
try {
const res = await layoutApi.autoGenerate(eventId, hallId, req);
setOptions(res);
setSelected(res[0]?.optionId ?? null);
setPhase('result');
} catch (err) {
if (err instanceof ApiRequestError) {
setError(
err.code === 'NOT_IMPLEMENTED'
? 'AI 자동배치 엔진이 준비 중입니다(백엔드 매퍼 대기).'
: err.message,
);
} else {
setError('자동배치 생성 중 오류가 발생했습니다.');
}
setPhase('form');
}
}
const selectedOption = options.find((o) => o.optionId === selected) ?? null;
return (
<div className="kx-modal" role="dialog" aria-modal="true" aria-labelledby="auto-title">
<div className="kx-modal__backdrop" onClick={onClose} />
<div className="kx-auto">
<header className="kx-auto__header">
<div>
<h3 id="auto-title" className="kx-auto__title">
<AiLabel>AI </AiLabel>
</h3>
<p className="kx-auto__sub">
.
.
</p>
</div>
<button className="kx-auto__close" onClick={onClose} aria-label="닫기">
</button>
</header>
{phase === 'form' && (
<div className="kx-auto__body">
<div className="kx-auto__form">
<NumField
label="목표 부스 수"
value={req.targetBoothCount}
onChange={(v) => setReq({ ...req, targetBoothCount: v })}
/>
<NumField
label="프리미엄 비율 (%)"
value={Math.round(req.premiumRatio * 100)}
onChange={(v) => setReq({ ...req, premiumRatio: v / 100 })}
/>
<NumField
label="무대 수"
value={req.stageCount}
onChange={(v) => setReq({ ...req, stageCount: v })}
/>
<NumField
label="주출입구 수"
value={req.mainEntranceCount}
onChange={(v) => setReq({ ...req, mainEntranceCount: v })}
/>
<NumField
label="생성 안 수"
value={req.optionCount}
onChange={(v) => setReq({ ...req, optionCount: Math.min(3, Math.max(1, v)) })}
/>
</div>
{error && <ErrorState message={error} onRetry={generate} />}
<div className="kx-modal__actions">
<Button variant="ghost" onClick={onClose}>
</Button>
<Button variant="ai" leadingIcon="✦" onClick={generate}>
</Button>
</div>
</div>
)}
{phase === 'generating' && (
<div className="kx-auto__generating" aria-live="polite">
<span className="kx-aiimg__spinner" aria-hidden="true" />
<p> </p>
</div>
)}
{phase === 'result' && (
<div className="kx-auto__body">
<div className="kx-auto__grid">
{options.map((o) => {
const job = renderJobs[o.optionId];
const isSel = o.optionId === selected;
return (
<label
key={o.optionId}
className={`kx-auto__card ${isSel ? 'is-selected' : ''}`}
>
<input
type="radio"
name="auto-option"
checked={isSel}
onChange={() => setSelected(o.optionId)}
/>
<AiImage
imageUrl={job?.imageUrl ?? null}
status={job?.status ?? 'QUEUED'}
watermarkText={
job?.watermarkText ?? 'AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다'
}
notice={
job?.notice ??
'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다'
}
shotLabel="S7 홀 전경"
alt={`${o.label} 조감`}
/>
<div className="kx-auto__card-body">
<span className="kx-auto__card-title">{o.label}</span>
<ul className="kx-auto__metrics tnum">
<li> {o.summary.boothCount} / {o.summary.targetBoothCount}</li>
<li> {o.summary.salesAreaM2.toLocaleString()}</li>
<li> {o.summary.minAisleWidthM}m</li>
<li
className={
o.summary.violationBlock > 0 ? 'kx-auto__viol-bad' : 'kx-auto__viol-ok'
}
>
{o.summary.violationBlock + o.summary.violationWarn}
</li>
</ul>
</div>
</label>
);
})}
</div>
<div className="kx-modal__actions">
<Button variant="ghost" onClick={() => setPhase('form')}>
</Button>
<Button
disabled={!selectedOption}
onClick={() => selectedOption && onApply(selectedOption)}
>
</Button>
</div>
</div>
)}
</div>
</div>
);
}
function NumField({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (v: number) => void;
}) {
return (
<div className="kx-field">
<label>{label}</label>
<input
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="tnum"
/>
</div>
);
}

View File

@ -0,0 +1,297 @@
import { useCallback, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { layoutApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client';
import { Button } from '../../components/ui/Button';
import { Skeleton } from '../../components/ui/States';
import { FloorplanCanvas, type CanvasLayer } from './FloorplanCanvas';
import { AutoLayoutDialog } from './AutoLayoutDialog';
import { ValidationPanel } from './ValidationPanel';
import { sampleLayout } from './sampleLayout';
import type { AutoLayoutOption, ComplianceReport, LayoutDto } from '../../api/types';
import './editor.css';
type RightTab = 'props' | 'validation';
export function BoothLayoutEditorPage() {
const { eventId = '', hallId = '' } = useParams();
const [layers, setLayers] = useState<CanvasLayer>({
trench: true,
structure: true,
violations: true,
});
const [selectedBoothId, setSelectedBoothId] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [rightTab, setRightTab] = useState<RightTab>('validation');
const [autoOpen, setAutoOpen] = useState(false);
const [report, setReport] = useState<ComplianceReport | null>(null);
const [validating, setValidating] = useState(false);
// 배치 조회 (M2 GET layout) — 501이면 degraded 샘플로 캔버스 시연.
const layoutQuery = useQuery<{ layout: LayoutDto; degraded: boolean }>({
queryKey: ['layout', eventId, hallId],
queryFn: async () => {
try {
const layout = await layoutApi.get(eventId, hallId);
return { layout, degraded: false };
} catch (err) {
if (err instanceof ApiRequestError && err.code === 'NOT_IMPLEMENTED') {
return { layout: sampleLayout(eventId, hallId), degraded: true };
}
throw err;
}
},
});
const layout = layoutQuery.data?.layout;
const degraded = layoutQuery.data?.degraded ?? false;
const selectedBooth = useMemo(
() => layout?.booths.find((b) => b.boothId === selectedBoothId) ?? null,
[layout, selectedBoothId],
);
const runValidate = useCallback(async () => {
setValidating(true);
try {
const r = await layoutApi.validate(eventId, hallId, layout?.version);
setReport(r);
setRightTab('validation');
} catch (err) {
if (err instanceof ApiRequestError && err.code === 'NOT_IMPLEMENTED') {
// degraded: 요약 카운트만이라도 노출
setReport(null);
}
} finally {
setValidating(false);
}
}, [eventId, hallId, layout?.version]);
function applyAutoOption(_option: AutoLayoutOption) {
// 병합: 실제 부스 좌표는 백엔드 저장/재조회로 반영. 지금은 재조회 트리거.
setAutoOpen(false);
void layoutQuery.refetch();
}
const summary = layout?.summary;
return (
<div className="kx-editor">
{/* 상단 툴바 */}
<header className="kx-editor__toolbar">
<div className="kx-editor__toolbar-left">
<h2 className="kx-editor__hall">{layout?.name ?? '배치안'}</h2>
<span className="kx-editor__version">
v{layout?.version ?? '—'}
<span className="kx-editor__saved"></span>
</span>
{degraded && (
<span className="kx-editor__degraded" title="백엔드 매퍼 준비 중 — 샘플 데이터 표시">
()
</span>
)}
</div>
<div className="kx-editor__toolbar-right">
<Button variant="secondary" leadingIcon="🖼" onClick={() => setAutoOpen(true)}>
</Button>
<Button variant="secondary" leadingIcon="✓" onClick={runValidate} disabled={validating}>
{validating ? '검증 중…' : '규정 검증 실행'}
</Button>
<Button variant="ai" leadingIcon="✦" onClick={() => setAutoOpen(true)}>
AI
</Button>
</div>
</header>
<div className="kx-editor__workspace">
{/* 좌측 도구 패널 */}
<aside className="kx-editor__left" aria-label="도구">
<section className="kx-tool-group">
<h3> </h3>
<div className="kx-palette">
<PaletteItem label="기본 3×3m" w={24} h={24} />
<PaletteItem label="프리미엄 6×3m" w={40} h={24} />
<PaletteItem label="아일랜드 (자유형)" full />
</div>
</section>
<section className="kx-tool-group">
<h3> </h3>
<div className="kx-facility">
<button></button>
<button></button>
<button></button>
</div>
</section>
<section className="kx-tool-group">
<h3></h3>
<LayerToggle
label="트렌치 그리드"
on={layers.trench}
onToggle={() => setLayers((l) => ({ ...l, trench: !l.trench }))}
/>
<LayerToggle
label="기둥·비상구"
on={layers.structure}
onToggle={() => setLayers((l) => ({ ...l, structure: !l.structure }))}
/>
<LayerToggle
label="위반 오버레이"
on={layers.violations}
onToggle={() => setLayers((l) => ({ ...l, violations: !l.violations }))}
/>
</section>
</aside>
{/* 중앙 캔버스 */}
<section className="kx-editor__canvas-wrap">
<div className="kx-editor__canvas-stage">
{layoutQuery.isLoading ? (
<div className="kx-editor__canvas-loading">
<Skeleton height={480} radius={8} />
</div>
) : layoutQuery.isError ? (
<div className="kx-editor__canvas-error" role="alert">
<p> .</p>
<Button variant="secondary" onClick={() => layoutQuery.refetch()}>
</Button>
</div>
) : layout && layout.booths.length === 0 ? (
<div className="kx-editor__canvas-empty">
<p> AI .</p>
<Button variant="ai" leadingIcon="✦" onClick={() => setAutoOpen(true)}>
AI
</Button>
</div>
) : (
layout && (
<FloorplanCanvas
hallDims={[126, 90]}
booths={layout.booths}
layers={layers}
selectedBoothId={selectedBoothId}
onSelectBooth={setSelectedBoothId}
zoom={zoom}
/>
)
)}
</div>
{/* 하단 상태 바 */}
<div className="kx-editor__statusbar">
<div className="kx-editor__stats tnum">
{summary && (
<>
<span> {summary.boothCount} / {summary.targetBoothCount}</span>
<span className="kx-dot" />
<span> {summary.salesAreaM2.toLocaleString()}</span>
</>
)}
</div>
<div className="kx-editor__zoom">
<button aria-label="축소" onClick={() => setZoom((z) => Math.max(0.5, z - 0.1))}>
</button>
<span className="tnum">{Math.round(zoom * 100)}%</span>
<button aria-label="확대" onClick={() => setZoom((z) => Math.min(2, z + 0.1))}>
+
</button>
</div>
</div>
</section>
{/* 우측 속성/검증 패널 */}
<aside className="kx-editor__right" aria-label="속성 및 검증">
<div className="kx-editor__tabs" role="tablist">
<button
role="tab"
aria-selected={rightTab === 'props'}
className={rightTab === 'props' ? 'is-active' : ''}
onClick={() => setRightTab('props')}
>
</button>
<button
role="tab"
aria-selected={rightTab === 'validation'}
className={rightTab === 'validation' ? 'is-active' : ''}
onClick={() => setRightTab('validation')}
>
{summary ? ` (${summary.violationBlock + summary.violationWarn})` : ''}
</button>
</div>
{rightTab === 'props' ? (
<div className="kx-props">
{selectedBooth ? (
<dl className="kx-props__list">
<PropRow k="부스번호" v={selectedBooth.boothNo} />
<PropRow k="타입" v={selectedBooth.type} />
<PropRow k="크기" v={`${selectedBooth.sizeM[0]}×${selectedBooth.sizeM[1]}m`} />
<PropRow k="높이" v={`${selectedBooth.heightM}m`} />
<PropRow k="바닥하중" v={`${selectedBooth.floorLoadTPerM2} t/㎡`} />
<PropRow k="배정 업체" v={selectedBooth.assignedCompanyName ?? '미배정'} />
</dl>
) : (
<p className="kx-props__empty"> .</p>
)}
</div>
) : (
<ValidationPanel
report={report}
summary={summary}
degraded={degraded}
/>
)}
</aside>
</div>
{autoOpen && (
<AutoLayoutDialog
eventId={eventId}
hallId={hallId}
onClose={() => setAutoOpen(false)}
onApply={applyAutoOption}
/>
)}
</div>
);
}
function PaletteItem({ label, w, h, full }: { label: string; w?: number; h?: number; full?: boolean }) {
return (
<div className={`kx-palette__item ${full ? 'is-full' : ''}`}>
<span className="kx-palette__swatch" style={full ? undefined : { width: w, height: h }} />
<span className="kx-palette__label">{label}</span>
</div>
);
}
function LayerToggle({ label, on, onToggle }: { label: string; on: boolean; onToggle: () => void }) {
return (
<label className="kx-layer">
<span>{label}</span>
<button
type="button"
role="switch"
aria-checked={on}
aria-label={label}
className={`kx-switch ${on ? 'is-on' : ''}`}
onClick={onToggle}
>
<span className="kx-switch__knob" />
</button>
</label>
);
}
function PropRow({ k, v }: { k: string; v: string }) {
return (
<div className="kx-props__row">
<dt>{k}</dt>
<dd className="tnum">{v}</dd>
</div>
);
}

View File

@ -0,0 +1,135 @@
import { useMemo } from 'react';
import type { BoothDto } from '../../api/types';
import './canvas.css';
/*
* SVG (SCR-03 ).
* - (#1C2536) + + .
* - 좌표계: SVG (1m = 1unit), viewBox로 .
* - ( ) WebGL/Canvas . SVG .
*/
export interface CanvasLayer {
trench: boolean;
structure: boolean;
violations: boolean;
}
interface FloorplanCanvasProps {
hallDims: [number, number]; // [width_m, height_m]
booths: BoothDto[];
layers: CanvasLayer;
selectedBoothId: string | null;
onSelectBooth: (boothId: string | null) => void;
zoom: number;
}
const TRENCH_SPACING_M = 9; // 가정 트렌치 그리드 간격(실측 대체 — R4)
const PAD = 6; // viewBox 여백(m)
function centroid(poly: number[][]): [number, number] {
const n = poly.length;
let x = 0;
let y = 0;
for (const [px, py] of poly) {
x += px;
y += py;
}
return [x / n, y / n];
}
export function FloorplanCanvas({
hallDims,
booths,
layers,
selectedBoothId,
onSelectBooth,
zoom,
}: FloorplanCanvasProps) {
const [hw, hh] = hallDims;
const trenchLines = useMemo(() => {
const lines: { x1: number; y1: number; x2: number; y2: number }[] = [];
for (let x = TRENCH_SPACING_M; x < hw; x += TRENCH_SPACING_M) {
lines.push({ x1: x, y1: 0, x2: x, y2: hh });
}
for (let y = TRENCH_SPACING_M; y < hh; y += TRENCH_SPACING_M) {
lines.push({ x1: 0, y1: y, x2: hw, y2: y });
}
return lines;
}, [hw, hh]);
return (
<div className="kx-canvas" style={{ transform: `scale(${zoom})` }}>
<svg
className="kx-canvas__svg"
viewBox={`${-PAD} ${-PAD} ${hw + PAD * 2} ${hh + PAD * 2}`}
role="img"
aria-label={`홀 도면 ${hw}×${hh}m, 부스 ${booths.length}`}
onClick={() => onSelectBooth(null)}
>
{/* 홀 외곽 */}
<rect
x={0}
y={0}
width={hw}
height={hh}
className="kx-canvas__hall"
rx={0.5}
/>
{/* 트렌치 그리드 */}
{layers.trench && (
<g className="kx-canvas__trench">
{trenchLines.map((l, i) => (
<line key={i} x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2} />
))}
</g>
)}
{/* 부스 폴리곤 */}
<g className="kx-canvas__booths">
{booths.map((b) => {
const pts = b.polygon.map(([x, y]) => `${x},${y}`).join(' ');
const [cx, cy] = centroid(b.polygon);
const selected = b.boothId === selectedBoothId;
return (
<g
key={b.boothId}
className={`kx-canvas__booth ${b.premium ? 'is-premium' : ''} ${
selected ? 'is-selected' : ''
}`}
onClick={(e) => {
e.stopPropagation();
onSelectBooth(b.boothId);
}}
tabIndex={0}
role="button"
aria-label={`부스 ${b.boothNo}${b.assignedCompanyName ? `, ${b.assignedCompanyName}` : ''}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectBooth(b.boothId);
}
}}
>
<polygon points={pts} />
<text x={cx} y={cy} className="kx-canvas__booth-label">
{b.boothNo}
</text>
</g>
);
})}
</g>
{/* 축척 눈금 (상단) */}
<g className="kx-canvas__ruler" aria-hidden="true">
<line x1={0} y1={-2} x2={hw} y2={-2} />
<text x={0} y={-3}>0m</text>
<text x={hw / 2} y={-3} textAnchor="middle">{Math.round(hw / 2)}m</text>
<text x={hw} y={-3} textAnchor="end">{hw}m</text>
</g>
</svg>
</div>
);
}

View File

@ -0,0 +1,78 @@
import type { ComplianceReport, LayoutSummary } from '../../api/types';
import { EmptyState } from '../../components/ui/States';
/*
* (SCR-03 [] ).
* report(POST validate ) , layout.summary .
*/
export function ValidationPanel({
report,
summary,
degraded,
}: {
report: ComplianceReport | null;
summary: LayoutSummary | undefined;
degraded: boolean;
}) {
const blockCount = report?.blockCount ?? summary?.violationBlock ?? 0;
const warnCount = report?.warnCount ?? summary?.violationWarn ?? 0;
const submittable = report?.submittable ?? blockCount === 0;
return (
<div className="kx-valid">
<div className="kx-valid__summary">
<span className="kx-valid__chip kx-valid__chip--block"> {blockCount}</span>
<span className="kx-valid__chip kx-valid__chip--warn"> {warnCount}</span>
</div>
{submittable && blockCount === 0 && (report || summary) && (
<div className="kx-valid__ok" role="status">
0
</div>
)}
{report ? (
report.violations.length === 0 ? (
<EmptyState title="위반 항목이 없습니다" />
) : (
<>
<ul className="kx-valid__list">
{report.violations.map((v) => (
<li
key={v.pin}
className={`kx-valid__card kx-valid__card--${v.severity}`}
>
<span className={`kx-valid__pin kx-valid__pin--${v.severity}`}>{v.pin}</span>
<div className="kx-valid__card-body">
<div className="kx-valid__card-head">
<span className="kx-valid__label">{v.label}</span>
<button className="kx-valid__locate" type="button">
</button>
</div>
<p className="kx-valid__measured">{v.measured}</p>
{v.requiresDocument && (
<span className="kx-valid__doc"> D-7 </span>
)}
</div>
</li>
))}
</ul>
<p className="kx-valid__disclaimer">
{report.disclaimer} · {report.rulesetVersion}
</p>
</>
)
) : (
<EmptyState
title={degraded ? '규정 검증 준비 중' : '규정 검증을 실행하세요'}
description={
degraded
? '검증 엔진 연결 후 위반 상세가 표시됩니다.'
: '"규정 검증 실행"을 눌러 위반 항목을 확인하세요.'
}
/>
)}
</div>
);
}

View File

@ -0,0 +1,119 @@
.kx-auto {
position: relative;
width: 100%;
max-width: 920px;
max-height: 90vh;
overflow: auto;
background: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-level2);
}
.kx-auto__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
border-bottom: var(--border-card);
position: sticky;
top: 0;
background: var(--color-white);
z-index: 1;
}
.kx-auto__title {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-h2);
font-weight: 600;
color: var(--color-neutral-900);
}
.kx-auto__sub {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
margin-top: var(--space-2);
}
.kx-auto__close {
background: none;
border: none;
font-size: 16px;
color: var(--color-neutral-500);
padding: 4px;
}
.kx-auto__body {
padding: var(--space-6);
}
.kx-auto__form {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.kx-auto__generating {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
padding: var(--space-8);
color: var(--color-neutral-500);
}
.kx-auto__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
@media (max-width: 800px) {
.kx-auto__grid {
grid-template-columns: 1fr;
}
}
.kx-auto__card {
border: var(--border-card);
border-radius: var(--radius-lg);
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-3);
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
position: relative;
}
.kx-auto__card.is-selected {
border-color: var(--color-primary-600);
box-shadow: 0 0 0 2px rgba(0, 102, 179, 0.2);
}
.kx-auto__card input[type='radio'] {
position: absolute;
top: var(--space-3);
right: var(--space-3);
z-index: 2;
accent-color: var(--color-primary-600);
}
.kx-auto__card-body {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-auto__card-title {
font-size: var(--fs-h3);
font-weight: 600;
color: var(--color-neutral-900);
}
.kx-auto__metrics {
list-style: none;
margin: 0;
padding: 0;
font-size: var(--fs-caption);
color: var(--color-neutral-700);
display: flex;
flex-direction: column;
gap: 2px;
}
.kx-auto__viol-ok {
color: var(--color-success);
font-weight: 600;
}
.kx-auto__viol-bad {
color: var(--color-error);
font-weight: 600;
}

View File

@ -0,0 +1,68 @@
.kx-canvas {
transform-origin: center center;
transition: transform 0.12s ease;
max-width: 100%;
max-height: 100%;
}
.kx-canvas__svg {
width: min(900px, 100%);
height: auto;
max-height: 100%;
display: block;
}
.kx-canvas__hall {
fill: var(--color-canvas-bg);
stroke: #4b5b78;
stroke-width: 0.6;
}
.kx-canvas__trench line {
stroke: var(--color-canvas-line);
stroke-width: 0.15;
stroke-dasharray: 1 1;
}
.kx-canvas__booth polygon {
fill: rgba(0, 102, 179, 0.72);
stroke: #7cb2e0;
stroke-width: 0.15;
transition: fill 0.12s ease;
cursor: pointer;
}
.kx-canvas__booth.is-premium polygon {
fill: rgba(0, 102, 179, 0.5);
stroke-width: 0.3;
}
.kx-canvas__booth:hover polygon {
fill: rgba(0, 102, 179, 0.9);
}
.kx-canvas__booth.is-selected polygon {
fill: rgba(0, 102, 179, 0.95);
stroke: var(--color-white);
stroke-width: 0.4;
}
.kx-canvas__booth:focus {
outline: none;
}
.kx-canvas__booth:focus polygon {
stroke: var(--color-ai-accent);
stroke-width: 0.4;
}
.kx-canvas__booth-label {
fill: rgba(255, 255, 255, 0.92);
font-size: 1px;
font-family: var(--font-mono);
text-anchor: middle;
dominant-baseline: middle;
pointer-events: none;
}
.kx-canvas__ruler line {
stroke: #5b6b88;
stroke-width: 0.1;
}
.kx-canvas__ruler text {
fill: #8a9bb8;
font-size: 1.4px;
}

View File

@ -0,0 +1,423 @@
.kx-editor {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
}
/* 툴바 */
.kx-editor__toolbar {
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--gutter);
background: var(--color-white);
border-bottom: var(--border-card);
}
.kx-editor__toolbar-left {
display: flex;
align-items: center;
gap: var(--space-3);
}
.kx-editor__hall {
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-editor__version {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-editor__saved::before {
content: '● ';
color: var(--color-success);
font-size: 8px;
}
.kx-editor__degraded {
font-size: 11px;
font-weight: 600;
color: var(--color-warning);
background: #fff4e5;
padding: 2px 8px;
border-radius: var(--radius-pill);
}
.kx-editor__toolbar-right {
display: flex;
gap: var(--space-2);
}
/* 3열 워크스페이스 */
.kx-editor__workspace {
flex: 1;
min-height: 0;
display: flex;
}
.kx-editor__left {
width: var(--panel-left-w);
flex-shrink: 0;
background: var(--color-white);
border-right: var(--border-card);
overflow-y: auto;
}
.kx-editor__right {
width: var(--panel-right-w);
flex-shrink: 0;
background: var(--color-white);
border-left: var(--border-card);
display: flex;
flex-direction: column;
min-height: 0;
}
/* 좌측 도구 */
.kx-tool-group {
padding: var(--space-4);
border-bottom: var(--border-card);
}
.kx-tool-group h3 {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-neutral-500);
margin-bottom: var(--space-3);
}
.kx-palette {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2);
}
.kx-palette__item {
border: var(--border-card);
border-radius: var(--radius-sm);
padding: var(--space-3);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
cursor: grab;
transition: border-color 0.12s ease, background 0.12s ease;
}
.kx-palette__item:hover {
border-color: var(--color-primary-600);
background: var(--color-primary-050);
}
.kx-palette__item.is-full {
grid-column: span 2;
}
.kx-palette__swatch {
display: block;
min-width: 20px;
min-height: 20px;
border: 1px solid var(--color-primary-600);
background: rgba(0, 102, 179, 0.15);
}
.kx-palette__item.is-full .kx-palette__swatch {
width: 100%;
height: 28px;
}
.kx-palette__label {
font-size: 11px;
text-align: center;
color: var(--color-neutral-700);
}
.kx-facility {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-facility button {
text-align: left;
padding: var(--space-2) var(--space-3);
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
.kx-facility button:hover {
background: var(--color-neutral-050);
}
/* 레이어 토글 */
.kx-layer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) 0;
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
.kx-switch {
width: 34px;
height: 18px;
border-radius: var(--radius-pill);
border: none;
background: var(--color-neutral-200);
position: relative;
transition: background 0.15s ease;
}
.kx-switch.is-on {
background: var(--color-primary-600);
}
.kx-switch__knob {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-white);
transition: left 0.15s ease;
}
.kx-switch.is-on .kx-switch__knob {
left: 18px;
}
/* 중앙 캔버스 */
.kx-editor__canvas-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--color-canvas-bg);
}
.kx-editor__canvas-stage {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
overflow: hidden;
}
.kx-editor__canvas-loading {
width: min(900px, 90%);
}
.kx-editor__canvas-error,
.kx-editor__canvas-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
color: rgba(255, 255, 255, 0.85);
}
.kx-editor__statusbar {
height: 40px;
flex-shrink: 0;
background: var(--color-white);
border-top: var(--border-card);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--gutter);
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-editor__stats {
display: flex;
align-items: center;
gap: var(--space-3);
}
.kx-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-neutral-200);
}
.kx-editor__zoom {
display: flex;
align-items: center;
gap: var(--space-2);
}
.kx-editor__zoom button {
width: 24px;
height: 24px;
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
color: var(--color-neutral-700);
}
.kx-editor__zoom span {
width: 44px;
text-align: center;
font-weight: 600;
color: var(--color-neutral-700);
}
/* 우측 탭 */
.kx-editor__tabs {
display: flex;
border-bottom: var(--border-card);
flex-shrink: 0;
}
.kx-editor__tabs button {
flex: 1;
padding: var(--space-3);
background: none;
border: none;
border-bottom: 2px solid transparent;
font-size: var(--fs-body);
font-weight: 600;
color: var(--color-neutral-500);
}
.kx-editor__tabs button.is-active {
color: var(--color-primary-600);
border-bottom-color: var(--color-primary-600);
}
/* 속성 */
.kx-props {
padding: var(--space-4);
overflow-y: auto;
}
.kx-props__empty {
color: var(--color-neutral-500);
font-size: var(--fs-body);
}
.kx-props__list {
margin: 0;
}
.kx-props__row {
display: flex;
justify-content: space-between;
padding: var(--space-3) 0;
border-bottom: var(--border-card);
}
.kx-props__row dt {
color: var(--color-neutral-500);
font-size: var(--fs-caption);
}
.kx-props__row dd {
margin: 0;
color: var(--color-neutral-900);
font-size: var(--fs-body);
font-weight: 500;
}
/* 검증 */
.kx-valid {
padding: var(--space-4);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-4);
background: var(--color-neutral-050);
flex: 1;
}
.kx-valid__summary {
display: flex;
gap: var(--space-2);
}
.kx-valid__chip {
padding: 4px 12px;
border-radius: var(--radius-pill);
font-size: var(--fs-caption);
font-weight: 700;
}
.kx-valid__chip--block {
background: #fef3f2;
color: var(--color-error);
}
.kx-valid__chip--warn {
background: var(--color-violation-warn-bg);
color: var(--color-violation-warn-text);
}
.kx-valid__ok {
background: #e6f4ee;
color: var(--color-success);
font-weight: 600;
padding: var(--space-3);
border-radius: var(--radius-sm);
font-size: var(--fs-body);
}
.kx-valid__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-valid__card {
display: flex;
gap: var(--space-3);
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-sm);
padding: var(--space-3);
}
.kx-valid__card--block {
border-left: 3px solid var(--color-error);
}
.kx-valid__card--warn {
border-left: 3px solid var(--color-violation-warn);
}
.kx-valid__pin {
width: 18px;
height: 18px;
border-radius: 50%;
color: var(--color-white);
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.kx-valid__pin--block {
background: var(--color-error);
}
.kx-valid__pin--warn {
background: var(--color-violation-warn);
}
.kx-valid__card-body {
flex: 1;
min-width: 0;
}
.kx-valid__card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-2);
}
.kx-valid__label {
font-size: var(--fs-body);
font-weight: 600;
color: var(--color-neutral-900);
}
.kx-valid__locate {
background: none;
border: none;
color: var(--color-primary-600);
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.kx-valid__measured {
font-size: var(--fs-caption);
color: var(--color-neutral-700);
margin-top: 2px;
}
.kx-valid__doc {
display: inline-block;
margin-top: var(--space-2);
font-size: 11px;
font-weight: 600;
color: var(--color-ai-accent);
background: var(--color-ai-surface);
border: 1px solid var(--color-ai-accent);
border-radius: var(--radius-pill);
padding: 2px 8px;
}
.kx-valid__disclaimer {
font-size: 11px;
line-height: 15px;
color: var(--color-neutral-500);
}

View File

@ -0,0 +1,66 @@
import type { BoothDto, LayoutDto } from '../../api/types';
/*
* Degraded 501(NOT_IMPLEMENTED) .
* . shape과 API .
* 126×90m . 3×3m/6×3m .
*/
export function sampleLayout(eventId: string, hallId: string): LayoutDto {
const booths: BoothDto[] = [];
const cols = 12;
const rows = 6;
const bw = 6;
const bh = 3;
const gapX = 4;
const gapY = 6;
const originX = 10;
const originY = 12;
let idx = 0;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const x = originX + c * (bw + gapX);
const y = originY + r * (bh + gapY);
const rowLetter = String.fromCharCode(65 + r); // A, B, C…
const no = `${rowLetter}-${101 + c}`;
const premium = r === 2 && c >= 4 && c <= 6;
booths.push({
boothId: `sample-${idx}`,
boothNo: no,
type: premium ? 'premium' : 'basic',
polygon: [
[x, y],
[x + bw, y],
[x + bw, y + bh],
[x, y + bh],
[x, y],
],
sizeM: [bw, bh],
heightM: 4.2,
floorLoadTPerM2: 3.0,
assignedCompanyName: idx % 3 === 0 ? '(주)한빛로보틱스' : null,
premium,
});
idx += 1;
}
}
return {
layoutId: 'sample-layout',
eventId,
hallId,
version: 3,
name: '배치안 B (샘플)',
status: 'draft',
booths,
summary: {
boothCount: booths.length,
targetBoothCount: 510,
salesAreaM2: booths.length * bw * bh,
minAisleWidthM: 3.2,
violationBlock: 2,
violationWarn: 3,
},
updatedAt: new Date().toISOString(),
};
}

View File

@ -0,0 +1,125 @@
import { useState } from 'react';
import { authApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client';
import { useAuthStore } from '../../store/authStore';
import { Button } from '../../components/ui/Button';
import './login.css';
/**
* (SCR-01) (CONTRACTOR) .
* NOT_REGISTERED_COMPANY(403) .
*/
export function InviteDialog({
onClose,
onSuccess,
}: {
onClose: () => void;
onSuccess?: () => void;
}) {
const applyLogin = useAuthStore((s) => s.applyLogin);
const [inviteCode, setInviteCode] = useState('');
const [regNo, setRegNo] = useState('');
const [error, setError] = useState<string | null>(null);
const [blocked, setBlocked] = useState(false);
const [submitting, setSubmitting] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBlocked(false);
setSubmitting(true);
try {
const res = await authApi.acceptInvite({
inviteCode: inviteCode.trim(),
companyRegistrationNo: regNo.trim(),
});
applyLogin(res);
onSuccess?.();
onClose();
} catch (err) {
if (err instanceof ApiRequestError) {
if (err.code === 'NOT_REGISTERED_COMPANY') {
setBlocked(true);
} else if (err.code === 'NOT_IMPLEMENTED') {
setError('초대 수락 서비스가 준비 중입니다.');
} else {
setError(err.message);
}
} else {
setError('초대 수락 중 오류가 발생했습니다.');
}
} finally {
setSubmitting(false);
}
}
return (
<div className="kx-modal" role="dialog" aria-modal="true" aria-labelledby="invite-title">
<div className="kx-modal__backdrop" onClick={onClose} />
<div className="kx-modal__panel">
{blocked ? (
<div className="kx-modal__blocked" role="alert">
<span className="kx-modal__blocked-icon" aria-hidden="true"></span>
<h3 id="invite-title"> </h3>
<p>
.
.
</p>
<div className="kx-modal__actions">
<Button variant="secondary" onClick={() => setBlocked(false)}>
</Button>
<Button onClick={onClose}></Button>
</div>
</div>
) : (
<form onSubmit={submit}>
<h3 id="invite-title" className="kx-modal__title">
</h3>
<p className="kx-modal__desc">
. ·
.
</p>
<div className="kx-field">
<label htmlFor="invite-code"> </label>
<input
id="invite-code"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="INV-XXXX"
required
/>
</div>
<div className="kx-field">
<label htmlFor="reg-no"> (·)</label>
<input
id="reg-no"
value={regNo}
onChange={(e) => setRegNo(e.target.value)}
placeholder="123-45-67890"
inputMode="numeric"
/>
</div>
{error && (
<p className="kx-login__error" role="alert">
{error}
</p>
)}
<div className="kx-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}>
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '확인 중…' : '참여하기'}
</Button>
</div>
</form>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,196 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { authApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client';
import { useAuthStore } from '../../store/authStore';
import { Button } from '../../components/ui/Button';
import { DdayChip, RoleBadge } from '../../components/ui/Badge';
import { EmptyState } from '../../components/ui/States';
import { InviteDialog } from './InviteDialog';
import type { WorkspaceDto } from '../../api/types';
import './login.css';
type Phase = 'login' | 'workspace';
export function LoginPage() {
const navigate = useNavigate();
const applyLogin = useAuthStore((s) => s.applyLogin);
const selectEvent = useAuthStore((s) => s.selectEvent);
const workspaces = useAuthStore((s) => s.workspaces);
const [phase, setPhase] = useState<Phase>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const res = await authApi.login({ email, password });
applyLogin(res);
setPhase('workspace');
} catch (err) {
if (err instanceof ApiRequestError) {
// 계약: login은 매퍼 대기 시 501(NOT_IMPLEMENTED) — degraded 안내
setError(
err.code === 'NOT_IMPLEMENTED'
? '로그인 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.'
: err.message,
);
} else {
setError('로그인 중 오류가 발생했습니다.');
}
} finally {
setSubmitting(false);
}
}
function enterWorkspace(ws: WorkspaceDto) {
selectEvent(ws.eventId);
// 주최자는 배치 에디터 진입점 예시(SCR-03). 홀 id는 워크스페이스에 홀 단일 가정 — 실제 홀 선택은 대시보드에서.
navigate(`/events/${ws.eventId}/halls/H7/layout`);
}
if (phase === 'workspace') {
return (
<div className="kx-ws">
<div className="kx-ws__inner">
<header className="kx-ws__header">
<h1> </h1>
<p> .</p>
</header>
{workspaces.length === 0 ? (
<EmptyState
title="참여 중인 행사가 없습니다"
description="초대 링크를 확인하거나 초대 코드로 참여하세요."
action={<Button onClick={() => setInviteOpen(true)}> </Button>}
/>
) : (
<>
<div className="kx-ws__grid">
{workspaces.map((ws) => (
<button
key={ws.eventId}
type="button"
className="kx-ws__card"
onClick={() => enterWorkspace(ws)}
>
<div className="kx-ws__card-top">
<RoleBadge role={ws.myRole} />
<DdayChip dday={ws.dday} />
</div>
<h2 className="kx-ws__card-title">{ws.eventName}</h2>
<p className="kx-ws__card-meta tnum">
{ws.startDate} {ws.endDate}
</p>
<p className="kx-ws__card-hall">{ws.hallLabel}</p>
</button>
))}
</div>
<div className="kx-ws__invite-row">
<Button variant="secondary" onClick={() => setInviteOpen(true)}>
</Button>
</div>
</>
)}
</div>
{inviteOpen && <InviteDialog onClose={() => setInviteOpen(false)} />}
</div>
);
}
return (
<div className="kx-login">
{/* 좌측 55% 브랜드 패널 */}
<section className="kx-login__brand" aria-hidden="true">
<div className="kx-login__brand-overlay" />
<div className="kx-login__brand-content">
<span className="kx-login__ai-cap"> AI </span>
<h1 className="kx-login__headline">
,
<br />
.
</h1>
<p className="kx-login__sub">
KINTEX AI .
</p>
</div>
</section>
{/* 우측 45% 로그인 카드 */}
<section className="kx-login__panel">
<div className="kx-login__card">
<div className="kx-login__logo">
<span className="kx-login__logo-mark">K</span>
<span className="kx-login__logo-text">KINTEX AI</span>
</div>
<h2 className="kx-login__title"></h2>
<p className="kx-login__title-sub">KINTEX .</p>
<form className="kx-login__form" onSubmit={handleLogin}>
<div className="kx-field">
<label htmlFor="email"></label>
<input
id="email"
type="email"
autoComplete="username"
placeholder="name@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="kx-field">
<label htmlFor="password"></label>
<input
id="password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && (
<p className="kx-login__error" role="alert">
{error}
</p>
)}
<Button type="submit" block disabled={submitting}>
{submitting ? '로그인 중…' : '로그인'}
</Button>
</form>
<div className="kx-login__divider">
<span></span>
</div>
<Button variant="secondary" block leadingIcon="🪪" type="button">
</Button>
<button
className="kx-login__invite-link"
type="button"
onClick={() => setInviteOpen(true)}
>
</button>
</div>
<p className="kx-login__copy">© 2026 KINTEX. All rights reserved.</p>
</section>
{inviteOpen && <InviteDialog onClose={() => setInviteOpen(false)} />}
</div>
);
}

View File

@ -0,0 +1,328 @@
/* ── SCR-01 로그인 ── */
.kx-login {
display: flex;
min-height: 100vh;
}
.kx-login__brand {
position: relative;
width: 55%;
display: none;
background: linear-gradient(135deg, #0a2a47, #004c86);
overflow: hidden;
}
@media (min-width: 1024px) {
.kx-login__brand {
display: block;
}
}
.kx-login__brand-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to right,
rgba(0, 76, 134, 0.92) 0%,
rgba(0, 76, 134, 0.7) 100%
);
}
.kx-login__brand-content {
position: absolute;
bottom: var(--space-8);
left: var(--space-8);
right: var(--space-8);
max-width: 640px;
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.kx-login__ai-cap {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: var(--radius-pill);
border: 1px solid rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.1);
color: #d3c7ff;
font-size: var(--fs-caption);
font-weight: 600;
backdrop-filter: blur(4px);
}
.kx-login__headline {
color: var(--color-white);
font-size: var(--fs-display);
line-height: var(--lh-display);
font-weight: 700;
letter-spacing: -0.01em;
}
@media (min-width: 1280px) {
.kx-login__headline {
font-size: 36px;
line-height: 44px;
}
}
.kx-login__sub {
color: rgba(255, 255, 255, 0.82);
font-size: var(--fs-h3);
line-height: var(--lh-h3);
}
.kx-login__panel {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8) var(--space-5);
background: var(--color-white);
position: relative;
}
.kx-login__card {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.kx-login__logo {
display: flex;
align-items: center;
gap: var(--space-2);
justify-content: center;
}
.kx-login__logo-mark {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
background: var(--color-primary-600);
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
}
.kx-login__logo-text {
font-size: var(--fs-h2);
font-weight: 700;
color: var(--color-primary-700);
}
.kx-login__title {
font-size: var(--fs-h1);
line-height: var(--lh-h1);
font-weight: 700;
color: var(--color-neutral-900);
text-align: center;
}
.kx-login__title-sub {
font-size: var(--fs-body);
color: var(--color-neutral-500);
text-align: center;
margin-top: -12px;
}
.kx-login__form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.kx-login__error {
color: var(--color-error);
font-size: var(--fs-caption);
margin: -4px 0 0;
}
.kx-login__divider {
display: flex;
align-items: center;
gap: var(--space-4);
color: var(--color-neutral-500);
font-size: var(--fs-caption);
}
.kx-login__divider::before,
.kx-login__divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-neutral-200);
}
.kx-login__invite-link {
background: none;
border: none;
color: var(--color-primary-600);
font-size: var(--fs-body);
font-weight: 500;
text-align: center;
}
.kx-login__invite-link:hover {
color: var(--color-primary-700);
}
.kx-login__copy {
position: absolute;
bottom: var(--space-5);
color: var(--color-neutral-500);
font-size: 11px;
}
/* ── 공통 필드 ── */
.kx-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-field label {
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-700);
}
.kx-field input {
height: 44px;
padding: 0 var(--space-4);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
font-size: var(--fs-body);
color: var(--color-neutral-900);
background: var(--color-white);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.kx-field input:focus {
outline: none;
border-color: var(--color-primary-600);
box-shadow: 0 0 0 3px rgba(0, 102, 179, 0.15);
}
/* ── 워크스페이스 선택 ── */
.kx-ws {
min-height: 100vh;
background: var(--color-neutral-050);
display: flex;
justify-content: center;
padding: var(--space-8) var(--space-5);
}
.kx-ws__inner {
width: 100%;
max-width: 960px;
}
.kx-ws__header {
margin-bottom: var(--space-6);
}
.kx-ws__header h1 {
font-size: var(--fs-h1);
line-height: var(--lh-h1);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-ws__header p {
color: var(--color-neutral-500);
margin-top: var(--space-2);
}
.kx-ws__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-5);
}
.kx-ws__card {
text-align: left;
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-2);
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.kx-ws__card:hover {
border-color: var(--color-primary-600);
box-shadow: var(--shadow-level2);
}
.kx-ws__card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
}
.kx-ws__card-title {
font-size: var(--fs-h3);
line-height: var(--lh-h3);
font-weight: 600;
color: var(--color-neutral-900);
}
.kx-ws__card-meta {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-ws__card-hall {
font-size: var(--fs-caption);
color: var(--color-neutral-700);
}
.kx-ws__invite-row {
margin-top: var(--space-6);
display: flex;
justify-content: center;
}
/* ── 모달 ── */
.kx-modal {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
}
.kx-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(16, 24, 40, 0.45);
backdrop-filter: blur(2px);
}
.kx-modal__panel {
position: relative;
width: 100%;
max-width: 440px;
background: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-level2);
padding: var(--space-6);
}
.kx-modal__title {
font-size: var(--fs-h2);
line-height: var(--lh-h2);
font-weight: 600;
color: var(--color-neutral-900);
margin-bottom: var(--space-2);
}
.kx-modal__desc {
font-size: var(--fs-body);
color: var(--color-neutral-500);
margin-bottom: var(--space-5);
}
.kx-modal__panel .kx-field {
margin-bottom: var(--space-4);
}
.kx-modal__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
margin-top: var(--space-5);
}
.kx-modal__blocked {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
}
.kx-modal__blocked-icon {
font-size: 40px;
}
.kx-modal__blocked h3 {
font-size: var(--fs-h2);
font-weight: 700;
color: var(--color-error);
}
.kx-modal__blocked p {
font-size: var(--fs-body);
color: var(--color-neutral-700);
}

View File

@ -0,0 +1,49 @@
/*
* (zustand) ··· .
* client.ts의 setAccessToken과 ( client, ).
*/
import { create } from 'zustand';
import { getAccessToken, setAccessToken } from '../api/client';
import type { AuthUser, EventRole, LoginResponse, WorkspaceDto } from '../api/types';
interface AuthState {
user: AuthUser | null;
workspaces: WorkspaceDto[];
currentEventId: string | null;
isAuthenticated: boolean;
applyLogin: (res: LoginResponse) => void;
selectEvent: (eventId: string) => void;
logout: () => void;
currentWorkspace: () => WorkspaceDto | null;
roleForCurrent: () => EventRole | null;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
workspaces: [],
currentEventId: null,
isAuthenticated: Boolean(getAccessToken()),
applyLogin: (res) => {
setAccessToken(res.accessToken);
set({
user: res.user,
workspaces: res.workspaces,
isAuthenticated: true,
});
},
selectEvent: (eventId) => set({ currentEventId: eventId }),
logout: () => {
setAccessToken(null);
set({ user: null, workspaces: [], currentEventId: null, isAuthenticated: false });
},
currentWorkspace: () => {
const { workspaces, currentEventId } = get();
return workspaces.find((w) => w.eventId === currentEventId) ?? null;
},
roleForCurrent: () => get().currentWorkspace()?.myRole ?? null,
}));

View File

@ -0,0 +1,90 @@
@import './tokens.css';
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
font-size: var(--fs-body);
line-height: var(--lh-body);
color: var(--color-neutral-700);
background: var(--color-neutral-050);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4,
p {
margin: 0;
}
a {
color: var(--color-primary-600);
text-decoration: none;
}
a:hover {
color: var(--color-primary-700);
}
button {
font-family: inherit;
cursor: pointer;
}
input,
select,
textarea {
font-family: inherit;
}
/* 숫자·좌표·금액 tabular numerals */
.tnum {
font-variant-numeric: tabular-nums;
}
.mono {
font-family: var(--font-mono);
font-size: var(--fs-mono);
}
/* 접근성: 키보드 포커스 링 (WCAG AA) */
:focus-visible {
outline: 2px solid var(--color-primary-600);
outline-offset: 2px;
}
/* 스크린리더 전용 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* prefers-reduced-motion 존중 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

View File

@ -0,0 +1,99 @@
/*
* 킨텍스 AI 전시관리 디자인 토큰 (단일 출처)
* 근거: docs/design.md v1.1 §1 (컬러 팔레트·타이포·라운드·엘리베이션·그리드)
* 하드코딩 금지 컴포넌트는 변수만 참조한다.
*/
:root {
/* ── 컬러 팔레트 (§1-2) ── */
--color-primary-700: #004c86; /* 헤더, 강조 텍스트, hover */
--color-primary-600: #0066b3; /* 주 브랜드 블루(킨텍스 CI) */
--color-primary-100: #d9eaf7; /* 선택 배경, 활성 사이드바 */
--color-primary-050: #eff6fc; /* 페이지 서브 배경, 테이블 호버 */
--color-primary-900: #00427d; /* 짙은 대체 톤(hover 한정) */
--color-ai-accent: #6d4aff; /* AI 액션 전용 */
--color-ai-surface: #f5f3ff; /* AI 카드 배경 (Intelligence 변형 채택) */
--color-success: #0e8a5f;
--color-warning: #b45309;
--color-error: #d92d20;
--color-neutral-900: #101828; /* 본문 제목 */
--color-neutral-700: #344054; /* 본문 텍스트 */
--color-neutral-500: #667085; /* 보조 텍스트, 플레이스홀더 */
--color-neutral-200: #e4e7ec; /* 구분선, 카드 테두리 */
--color-neutral-100: #f2f4f7;
--color-neutral-050: #f9fafb; /* 앱 배경 */
--color-white: #ffffff;
--color-canvas-bg: #1c2536; /* 에디터 캔버스 다크 서피스 */
--color-canvas-line: #2f3b52; /* 캔버스 위 보조 라인 */
/* 배선 오버레이 (S6) */
--color-wire-power: #ef4444; /* 전기(적) */
--color-wire-network: #3b82f6; /* 네트워크(청) */
--color-wire-water: #22c55e; /* 급배수(녹) */
/* 위반 심각도 */
--color-violation-block: var(--color-error);
--color-violation-warn: #f79009; /* 도면 오버레이용 주황 (텍스트는 warning 진값) */
--color-violation-warn-bg: #fef0c7;
--color-violation-warn-text: #b54708;
/* 상태 배지 진행 색 (§1-4) */
--status-draft: var(--color-neutral-500); /* 작성중(회색) */
--status-submitted: var(--color-primary-600); /* 제출됨(파랑) */
--status-ai-review: var(--color-ai-accent); /* AI검토(보라) */
--status-approved: var(--color-success); /* 승인(녹색) */
--status-rejected: var(--color-error); /* 반려(빨강) */
--status-building: #7a5af8; /* 시공중 */
--status-inspected: var(--color-primary-700); /* 검수완료 */
/* ── 타이포 (§1-3) — Pretendard 권위, Inter 라틴 보조 ── */
--font-sans: 'Pretendard', 'Inter', 'Noto Sans KR', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace;
--fs-display: 28px;
--lh-display: 36px;
--fs-h1: 24px;
--lh-h1: 32px;
--fs-h2: 20px;
--lh-h2: 28px;
--fs-h3: 16px;
--lh-h3: 24px;
--fs-body: 14px;
--lh-body: 22px;
--fs-caption: 12px;
--lh-caption: 18px;
--fs-mono: 13px;
/* ── 라운드 (§1-4) — 최대 8px, 칩은 pill ── */
--radius-sm: 4px; /* 버튼·인풋·작은 요소 */
--radius-lg: 8px; /* 카드·모달·섹션 */
--radius-pill: 9999px;
/* ── 엘리베이션 — 톤 레이어 + 1px 테두리 ── */
--border-card: 1px solid var(--color-neutral-200);
--shadow-level2: 0 4px 12px rgba(16, 24, 40, 0.1);
--accent-ai: 4px solid var(--color-ai-accent); /* AI 카드 좌측 액센트 */
/* ── 그리드·간격 (§1-4) — 4px 베이스 ── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-8: 48px;
--container-max: 1440px;
--gutter: 24px;
--sidebar-w: 240px;
--panel-left-w: 240px;
--panel-right-w: 360px;
/* 테이블 */
--row-h: 44px;
--row-h-mobile: 48px;
--zebra: var(--color-neutral-050);
--row-hover: #f1f5f9;
}

9
src/frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
readonly VITE_BACKEND_ORIGIN?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"],
"strict": true
},
"include": ["vite.config.ts"]
}

2
src/frontend/vite.config.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';
// 백엔드(Spring Boot)는 기본 :8080. 개발 중 /api·/ws 프록시.
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_BACKEND_ORIGIN || 'http://localhost:8080',
changeOrigin: true,
},
'/ws': {
target: process.env.VITE_BACKEND_ORIGIN || 'http://localhost:8080',
changeOrigin: true,
ws: true,
},
},
},
});

View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';
// 백엔드(Spring Boot)는 기본 :8080. 개발 중 /api·/ws 프록시.
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_BACKEND_ORIGIN || 'http://localhost:8080',
changeOrigin: true,
},
'/ws': {
target: process.env.VITE_BACKEND_ORIGIN || 'http://localhost:8080',
changeOrigin: true,
ws: true,
},
},
},
});

View File

@ -72,7 +72,7 @@ except ImportError: # 스크립트 컨텍스트 (python worker.py) — 동일
# 환경 설정 (계약 §7)
# ----------------------------------------------------------------------------
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
QUEUE_KEY = os.environ.get("NANOBANANA_QUEUE", "kintex:renderjobs")
QUEUE_KEY = os.environ.get("NANOBANANA_QUEUE", "kintex:renderjob:queue")
EVENT_CHANNEL = os.environ.get("NANOBANANA_EVENT_CHANNEL", "kintex:renderjob:events")
OUTPUT_DIR = os.environ.get("NANOBANANA_OUTPUT_DIR", "output/visualizations")