feat(public,security,data): visitor public site + AI guide, security hardening, V42-V45 migrations

- frontend: visitor public home (AiAssistant, AiPlanningBriefing, VisitorTrackPage),
  3-track public routing (PublicShell/App), i18n ko/en/zh/ja, favicon
- backend: public AI visitor-assistant + event calendar API (publicsite/*),
  profile avatar API (auth/profile/*), SecurityConfig CORS whitelist,
  SecretStartupValidator (B12 fail-fast, prod only), application-prod.yml,
  AppIntegrity, /api/auth/me expansion (MeResponse)
- db: V42 bulk demo seed, V43 visitor_guide/transport + event calendar view,
  V44 performance indexes, V45 app_user profile photo columns (all idempotent)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-12 19:05:00 +09:00
parent 433eb83a16
commit 214b6df1ef
77 changed files with 10782 additions and 96 deletions

View File

@ -85,6 +85,46 @@ GUARDiA 표준 프레임워크(Spring Boot 3.5 + React 19 + MyBatis + PostgreSQL
---
## 하네스: 벤치마킹·크롤링 (kintex-benchmark-orchestrator)
**목표:** 레퍼런스 사이트(COEX 등) 크롤·분석·벤치마크 백로그 산출(kintex-benchmark-analyst) + 외부 행사 데이터 크롤 적재(kintex-crawler-dev). 적용은 planner/renewal/wise-ui 하네스 연계.
**트리거:** 벤치마킹, 사이트 분석해서 재구성, 경쟁사 비교, 크롤링(행사정보 수집·갱신), 데이터 수집, 다시 실행, 특정 사이트만 요청 시 `kintex-benchmark-orchestrator` 스킬을 사용하라.
**변경 이력:**
| 날짜 | 변경 내용 | 대상 | 사유 |
|------|----------|------|------|
| 2026-07-12 | 초기 구성(analyst·crawler 에이전트 + 오케스트레이터) | 전체 | "크롤링/벤치마킹 하네스 생성" 요청 — COEX 3-트랙 재구성·행사 크롤 정례화 |
---
## 하네스: 전면 리뉴얼 (kintex-renewal-orchestrator)
**목표:** 내비게이션(브레드크럼·상세→메인 복귀·404)·전 화면 WISE 정렬·마스터/테스트 데이터 전면 생성(공통코드·프로그램·부서·데모 폐루프)을 총괄 리뉴얼. UI 정렬은 wise-ui 하네스 에이전트 재사용 + kintex-testdata-dev 신규.
**트리거:** 리뉴얼, 전면 개편, 상세에서 메인 못 감/내비 문제, 테스트 데이터 생성, 데이터 채워/화면 비어 있음, 공통코드·프로그램 채움, 다시 실행, 특정 영역만 요청 시 `kintex-renewal-orchestrator` 스킬을 사용하라.
**변경 이력:**
| 날짜 | 변경 내용 | 대상 | 사유 |
|------|----------|------|------|
| 2026-07-12 | 초기 구성(kintex-testdata-dev 신규 + wise-ui/menu-ia/qa 재사용 + 오케스트레이터) | 전체 | 소유자 지시 — 상세→메인 복귀 부재·전면 리뉴얼·테스트 데이터/공통코드/프로그램 채움 |
---
## 하네스: WISE UI 정렬 (kintex-wise-ui-orchestrator)
**목표:** 킨텍스 UI(셸·아코디언 메뉴 IA·캘린더·차트·전 화면)를 WISE(UIWS `C:\GUARDiA\workspace\uiws\frontend`) 컨벤션에 **문자 그대로** 정렬(자체 재해석 금지 — 소유자 원칙 2026-07-12). 감사(kintex-wise-ui-auditor)→정렬 이식(kintex-wise-ui-dev)→QA(kintex-qa 재사용).
**트리거:** WISE처럼/WISE대로, UI 정렬, 메뉴 재구성, 아코디언/햄버거 메뉴, 셸·사이드바·푸터 수정, 캘린더 WISE, 대시보드 구성, 차트, 로고 교체, 반응형 깨짐, 다시 실행, 특정 화면만 요청 시 `kintex-wise-ui-orchestrator` 스킬을 사용하라.
**변경 이력:**
| 날짜 | 변경 내용 | 대상 | 사유 |
|------|----------|------|------|
| 2026-07-12 | 초기 구성(에이전트 2 신규 + kintex-qa 재사용 + 오케스트레이터) | 전체 | 소유자 강한 피드백("전부 WISE대로 안 되어 있다" — 시스템관리 메뉴 실종·햄버거/아코디언 부재·캘린더·대시보드·로고) |
| 2026-07-12 | 메뉴 IA 전담 추가 — kintex-menu-ia-dev + kintex-menu-recompose 스킬(표준 카테고리 v1·라우트↔메뉴 정합 절차), 오케스트레이터 Phase 2.5 편입 | agents·skills | "카테고리별 메뉴 재구성 하네스" 요청 — 별도 오케스트레이터 대신 중복 회피 확장 |
---
## 하네스: 모바일 앱 (kintex-mobile-orchestrator)
**목표:** `mobile/`(Expo SDK 51 + expo-router + React Native 0.74 + TS) 모바일 앱 트랙 — 화면(SCR-M*)·실데이터 API 배선·오프라인/푸시/i18n·에셋·EAS 빌드·APK·QR 배포. **레퍼런스 = WISE 모바일**(`workspace/guardia-messenger/app/uiws/` — uiwsApi 봉투 언랩·2FA 로그인·화면 컨벤션), **디자인 = Stitch 경유**(designer → design.md SCR-M* → Stitch 생성 → RN 이식).

View File

@ -1,6 +1,6 @@
# 킨텍스 AI 전시·행사시스템 (KINTEX AI Exhibition & Event System) 기획서
> 작성: 기획 에이전트(planner) · 작성일: 2026-07-11 · 최종 갱신: 2026-07-12 · 버전: **v3.3**
> 작성: 기획 에이전트(planner) · 작성일: 2026-07-11 · 최종 갱신: 2026-07-12 · 버전: **v3.4**
> **제품 공식 명칭(소유자 확정 2026-07-12): "KINTEX AI 전시·행사시스템"** — 기존 "자동전시시스템(Exhibition Automation Platform)"을 대체한다. 전시(exhibition)뿐 아니라 행사·이벤트(event) 전반을 포괄한다는 의미를 명칭에 반영. 본 명칭이 정본이며, 이하 본문·3-트랙 기획·타 문서 후속 반영 시 이 명칭을 사용한다(기존 "자동전시시스템" 표기는 문맥 보존을 위해 삭제하지 않고 잔존하나, 신규 표기는 정본 명칭을 따른다).
> 근거 문서: `docs/analysis/kintex-website.md` (킨텍스 웹사이트 25개 페이지 + 전시주최자매뉴얼 PDF + 참가업체 매뉴얼 PDF 전수 분석, 2026-07-11)
> 근거 문서: `docs/analysis/coex-website.md` (코엑스 VISITOR/BUSINESS/CYBER 3-사이트 IA 분석 + 킨텍스 대비표, 2026-07-12) — §2-2 대외 접점 3-트랙 재구성 확정 근거
@ -10,6 +10,7 @@
> **v2.0 정체성 확장**: 본 시스템은 "부스 시공 도구"를 넘어 **킨텍스 자동전시시스템(Exhibition Automation Platform)** 이다 — 전시 기획·판매·시공·운영·관람객·사후분석 전 주기를 아우른다. 기존 M2~M5 부스 시공 코어(P0)·나노바나나 파이프라인(§6)·확정 스택(§8)은 **보존**하고, 그 위에 관람객/비즈매칭/마케팅·공개사이트/wayfinding/현장운영/공사 옥션/BI/CMS/관리자 모듈(M10~M18)을 확장한다.
> **v3.0 정체성 확장 — 다중 전시관 SaaS**: 본 시스템은 **킨텍스 전용에서 다중 전시관(Venue) 멀티테넌트 SaaS**로 확장한다. 킨텍스(KINTEX)는 **기준 테넌트(테넌트 #1)** 로 유지되고, 코엑스(COEX) 등 여타 전시관을 테넌트로 온보딩한다. 기능(M1~M18·§5B·§6 나노바나나·§8 확정 스택)은 **전부 보존**하고, 그 위에 **테넌트 격리 레이어(tenant_id 전파·컨텍스트 해소·역할 계층)** 만 얹는다 — 기능은 그대로, 데이터·권한·컨텍스트가 전시관 단위로 분리된다. 상세는 **§1A**, 격리 전략은 **§8-2**.
> **v3.2 대외 접점 3-트랙 재구성**: 코엑스(COEX) IA 분석(3-사이트 분리: VISITOR/BUSINESS/CYBER)을 근거로, 대외 접점을 **visitor(관람객) / business(비즈니스=주최자·참가업체) / agency(에이전시=공사·장치·협력사)** 3개 트랙으로 분리·재편한다. 기존 6역할·7페르소나(§2)·포털 매트릭스(§2-1)·모듈(M1~M18)·화면(SCR-*)은 **전부 보존**하고, 그 위에 **트랙 셸(track shell) — 랜딩 3-분기 + 트랙별 서브홈 + 트랙-스코프 로그인 후 홈** IA 레이어만 얹는다. 상세는 **§2-2**(내부 역할 ops·admin은 대외 트랙 외부에 그대로 유지).
> **v3.4 AI 사용성 & 토큰 최소화**: 소유자 지시(2026-07-12) — "AI 기능을 쉽게 사용하도록 기획 + 토큰을 최소한으로 사용하는 방식 채용". 산재해 있던 AI 규약을 **§8A**로 명문화한다 — ① AI 사용성 표준(전 화면 인라인 진입점·예시 질문 칩·원탭 액션·다음 명령 제시·구조화 카드+근거 인용·대화 히스토리·다국어/모바일/음성) ② 토큰 최소화 6원칙(결정론 우선 라우팅으로 LLM 우회·소형모델 우선 티어링·RAG 발췌로 프롬프트 축소·캐싱·출력 상한/구조화·집계는 SQL) ③ 측정지표·목표 ④ design/개발 계약 포인트. 기존 스코프(§5B 공통 AI·§6 나노바나나·§8 스택·§8-2 격리)·화면·라우트 보존, 원칙·계약·지표만 순증. M10 관람객 AI 도우미·M16 BI·§5B 공통 AI 보조에 §8A 링크. 이미 진행 중(visitor-assistant DB 근거 응답·AiTextRouter Claude→Ollama 폴백)과 정합(재발명 아님·표준화). src·design.md 미수정(designer/backend 후속).
> **v3.3 로그인 사용자 구분 → 메인페이지·메뉴 그룹 결정 규칙**: 소유자 확정(2026-07-12) — **"로그인한 사용자의 사용자 구분으로 메인페이지가 결정되고, 좌측 메뉴 그룹도 결정된다."** §2-2의 3-트랙(visitor/business/agency) + 내부 역할(ops/admin) 위에, **미인증 기본 진입 = visitor 공개 관람 랜딩**, **인증 후 역할별 전용 메인**(business 메인·agency 메인·visitor 메인은 신규 화면 — designer Stitch 의뢰, admin/내부 = 관리 메인 또는 비즈니스 메인+시스템관리)과 **트랙별 좌측 메뉴 그룹 노출/숨김**을 결정하는 규칙을 **§2-3**에 확정한다. 기존 §2·§2-2·화면(SCR-*)·라우트는 보존(안 B 정합), 결정 규칙·트랙 메인 화면 목록만 순증. src·design.md 미수정(frontend·designer 후속).
> **문서 소유권**: 본 PLANNING.md는 planner만 수정한다. design.md·타 문서는 이 문서 변경 시 "designer/planner 후속 반영 필요"로만 표기하고 직접 수정하지 않는다.
@ -654,6 +655,7 @@ sequenceDiagram
- **현재**: 킨텍스 공식은 행사일정 검색 수준. 관람객 등록·배지·리드캡처는 행사별 주최자 사무국에 파편화(공통 플랫폼 부재).
- **AI 자동화 후**: **간편가입(이메일 또는 소셜 로그인) 또는 게스트 예매(가입 없이 티켓 구매, SCR-P7 기반영)** → 온라인 사전등록(관람객/바이어 유형별 폼) → **모바일 배지/QR 발급** → 현장 QR 체크인(즉석 배지 인쇄·오프라인 대비) → 실시간 입장 집계. 참가업체 **리드캡처**: 운영 앱(B2B)으로 관람객 배지 QR 스캔 → 연락처·관심도 평점·메모 저장 → 팔로업 EDM(M12) 연계. 사전등록·체크인 데이터는 M16 BI로 흐른다.
- **계정 트랙·승격 연속성(소유자 확정 2026-07-11, §2-1-1)**: 관람객은 **간편가입 트랙(2FA 미강제)** 이며 게스트 예매를 허용한다. 관람객 → **바이어/참가업체 담당자 승격** 시 **단일 계정 위 등급 상향**으로 처리해 **리드·비즈매칭·재방문 이력의 데이터 연속성**을 유지한다(계정 재생성 없음, 승격 시 업무 트랙 요건 2FA 추가). 게스트 예매 후 간편가입 시 예매 이력을 티켓 지갑에 병합.
- **관람객 AI 도우미(§8A 정합)**: visitor 메인의 AI 도우미는 **§8A-1 사용성 표준**(인라인 진입점·예시 질문 칩·구조화 카드+근거 인용)과 **§8A-2 토큰 최소화**(일정·교통·주차·부스 위치 등 사실조회는 **DB 직접 응답=LLM 미호출 P1**, 종합 필요 시에만 소형모델 우선 P2·RAG 발췌 P3)를 따른다. 이미 DB 근거로 답하는 구조를 §8A 계약(`/ai/ask`)으로 표준화.
- **채널**: 관람객 1차 접점은 공개 웹(SCR-P7/P8), 리텐션은 **관람객 앱(B2C, 스토어 공개)** — 티켓 지갑·배지/QR·wayfinding·비즈매칭(§2-1-1 B).
- **기대 효과**: 현장 등록 대기 제거, 게스트 예매로 가입 마찰 최소화, 참가업체 ROI 측정(리드 수·품질), 관람객 흐름 데이터 확보.
- **한계**: 배지 QR·리드캡처는 개인정보(§10 R10) — 동의·보존정책 필수. 오프라인 체크인 폴백 필요(현장 네트워크 불안정). 게스트 예매는 최소 개인정보 수집·사후 계정 병합 정책 명시 필요.
@ -724,6 +726,7 @@ sequenceDiagram
| ⑥ | **수요예측·수율/가격(yield/pricing) 최적화** | 성수기 계수·홀별 수요 예측, 요율·성수기(+10%)/비수기(-10%)·1전시장(+10%) 계수 시뮬레이션으로 수율 최적가 탐색 | M1 요율·과거 배정·M18 룰셋 |
| ⑦ | **경영진 KPI 대시보드** | 위 ①~⑥ 요약 + 목표 대비 실적(점유율 목표 60~75%·매출·마진·리텐션), 전시장·분기 드릴다운 | KpiSnapshot 집계 |
- **BI AI(§8A 정합·토큰 최소화 핵심)**: 모든 **집계·랭킹·추이 수치는 데이터마트 SQL이 산출(§8A-2 P6)**, LLM은 그 수치에 대한 **자연어 설명·해석만** 담당(수치 계산에 LLM 금지). 자연어 질의(예 "지난 분기 홀7 가동률은?")도 **SQL 집계 → 카드 렌더**를 우선하고, LLM은 설명·후속 제안에만 사용. 동일 기간/스코프 요약은 캐시 재사용(§8A-2 P4).
- **데이터마트 설계(DA 트랙)**: 위 지표는 운영 DB 직조회가 아니라 **BI 데이터마트(스타 스키마: FactBooking·FactSettlement·FactUtility·FactAuction·FactVisitor + DimHall·DimEvent·DimDate·DimExhibitor)** 로 집계한다. `KpiSnapshot`(배치/야간 적재) 또는 읽기 전용 복제로 운영 부하 회피(§8-1). **데이터마트 상세 모델링은 DA(데이터 분석가) 후속 트랙**이며, planner는 지표·데이터원·관점 분리만 확정.
- **한계**: LTV·리텐션은 다년 축적 데이터 필요(초기엔 단년 근사). 수율 최적가는 시뮬레이션 참고치이며 최종 요율 확정은 킨텍스 경영 의사결정(§10 R8).
- **근거**: JoinWays·FinancialModelsLab·TicketFairy(RevPAD·㎡당 수익·점유율 60~75% 목표·이벤트별 P&L·수율 관리), ExhibitForce(이벤트 ROI·수익성 대시보드).
@ -787,6 +790,8 @@ UIWS 표준 공통 모듈을 이식해 전 포털이 공유. 킨텍스 도메인
> **경계(중복 제거)**: 경영·수익 지표는 **M16 BI**가 권위, 일상 업무보고·통계는 공통 `report/stats`가 담당. 콘텐츠/공지 발행은 **M17 CMS**가 권위, 사내 알림성 공지는 공통 `notice`. 알림 발송 채널은 공통 `notification` 단일화(M10·M12·M15가 이벤트 발행).
> **공통 AI 보조(§8A 정합)**: 전 업무 화면의 AI 보조(search·meeting 액션아이템·report 요약·초안 생성 등)는 **§8A 단일 AI 계약(`/ai/ask`)·사용성 표준·토큰 최소화 원칙**을 공유한다 — 사실조회는 결정론/DB 직답(LLM 미호출), 종합은 소형모델 우선·Claude 승급(`AiTextRouter`)·RAG 발췌·캐시·구조화 출력. 신규 LLM 직접 호출 경로 신설 금지(중앙 `/ai/ask` 경유).
### 5B-3. 인증 (UIWS 표준: JWT + 2FA/OTP)
kintex 인증은 §2 행사 단위 RBAC를 **UIWS 표준 인증 위에** 구현한다(재설계 금지).
@ -1138,6 +1143,71 @@ graph TB
---
## 8A. AI 사용성 & 토큰 최소화 아키텍처 (v3.4, 소유자 지시 2026-07-12)
> **소유자 지시**: "기획자는 AI 기능을 **쉽게 사용**할 수 있도록 기획하고, **토큰을 최소한으로 사용**하는 방식을 채용하라." 본 절은 전 모듈에 걸친 **AI 사용성 표준(§8A-1)****토큰 최소화 설계 원칙(§8A-2)** 을 명문화한다. **재발명이 아니라 이미 진행 중인 패턴의 표준화**다 — visitor 관람객 AI 도우미가 이미 **DB 근거로 답하고**, 공통 AI 계층이 **`AiTextRouter`로 Claude→Ollama(소형) 폴백**하며, 크롤 적재 DB(행사 데이터 등)를 근거로 삼는 구조가 존재한다. 본 절은 이를 **원칙·계약·측정지표로 고정**해 전 모듈(M10 관람객 AI 도우미·M16 BI·§5B 공통 AI 보조)이 동일 규약을 따르게 한다.
> **보존·경계**: §5B 공통 레이어·§8 확정 스택·§8-2 테넌트 격리·§6 나노바나나(이미지 생성은 별개 파이프라인·본 절의 "토큰"은 **텍스트 LLM** 대상)를 삭제·재작성하지 않는다. AI 게이트(외부 API 금지, `api.anthropic.com` 예외 — §10)·온프레미스 소형모델 RAM 제약([[project_ollama_ram_constraint]])과 정합한다.
### 8A-1. AI 사용성 표준 — "어디서나 한 번에 물어본다"
전 화면에 **동일한 AI 진입 규약**을 심어, 사용자가 메뉴를 뒤지지 않고 자연어로 즉시 도움을 받게 한다. 관람객(visitor 메인 AI 도우미)과 업무 사용자(업무 화면 AI 보조)가 **같은 컴포넌트·같은 로직**을 공유한다.
| # | 사용성 요소 | 내용 | 적용 범위 |
|---|---|---|---|
| U1 | **인라인 AI 진입점** | 전 화면 공통 위치(헤더/우하단)에 "AI에게 물어보기" 버튼 + 자연어 질문창(공통 컴포넌트 `AiAssistant`). 화면 컨텍스트(현재 행사·부스·화면 ID)를 자동 첨부 | 웹·모바일 전 화면 |
| U2 | **예시 질문 칩(prompt chips)** | 자주 묻는 질문을 원클릭 칩으로 노출(화면·역할별 큐레이션). 예: 관람객 "오늘 열리는 전시는?"·"주차 어디?"·"부스 어떻게 찾아?" / 참가업체 "내 유틸리티 신청 마감일?"·"이 도면 규정 위반 있나?" | 화면·역할별 |
| U3 | **원탭 AI 액션** | 목록/상세에서 버튼 한 번으로 요약·추천·초안 생성. 예: 서류 요약, 옥션 응찰 비교 요약, EDM 카피 초안(M12), 회의록 액션아이템 추출(§5B meeting) | 목록·상세 화면 |
| U4 | **다음 명령 제시(proactive)** | 결과 하단에 후속 액션·후속 질문 칩 제시("이 배치안으로 시각화 생성?"·"이 업체에 옥션 초대?"). 프로액티브 제안은 **규칙 기반 우선**(LLM 미사용, §8A-2 P1) | 결과 카드 |
| U5 | **구조화 카드 + 근거 인용** | 답변은 자유 서술 대신 **구조화 카드**(핵심값·표·링크) + **근거 인용**(출처 레코드/문서 링크·"근거: 행사마스터 #123"). 근거 없으면 **"모름" 폴백**(환각 차단, GUARDiA AI-trust 정합) | 전 답변 |
| U6 | **대화 히스토리·후속 질문** | 세션 내 멀티턴 히스토리 + 후속 질문 칩. 히스토리는 **요약 압축**해 컨텍스트 재주입 최소화(§8A-2 P4) | 도우미 패널 |
| U7 | **접근성·다국어·모바일·음성** | 동일 로직을 웹·모바일 공유(§2-3 채널 규칙 정합), 다국어(한/영/중/일) UI, 스크린리더 대응, **음성 입력 옵션**(온디바이스 STT 고려·서버 부하·토큰 무관) | 전 채널 |
- **관람객 vs 업무 사용자 공통화**: 두 페르소나가 **단일 `AiAssistant` 컴포넌트 + 단일 `/ai/ask` 계약**을 공유하고, 노출 위치·예시 칩·허용 액션만 역할/트랙(§2-3)으로 스코프한다 → 구현 중복 회피·UX 일관성.
- **신뢰성 우선**: 사용성(쉽게)은 신뢰성(근거·모름 폴백)과 함께 간다 — "빠르고 그럴듯한 환각"보다 "근거 있는 절제된 답"을 표준으로 한다(U5).
### 8A-2. 토큰 최소화 설계 원칙 (핵심 설계 원칙 — 비용 효율)
> **대원칙: "LLM은 최후에 최소로."** 답할 수 있는 것은 DB/규칙/집계로 먼저 답하고, 자연어 종합이 실제 필요한 부분에만, 가장 작은 모델로, 가장 짧은 컨텍스트로 호출한다. 아래 6원칙은 **`/ai/ask` 오케스트레이션의 결정 순서**이기도 하다.
| 원칙 | 내용 | 계약/구현 지점 |
|---|---|---|
| **P1. 결정론 우선 라우팅** | 의도 분류(키워드·규칙·화면 컨텍스트)로 **LLM 없이 답할 수 있으면 LLM을 호출하지 않는다.** 연/월 일정·교통·주차·마감일·요금·부스 위치·통계 수치 등 **사실 조회형은 DB/뷰가 직접 응답**(카드 렌더). LLM은 **요약·추천·자연어 종합·다중 근거 통합**이 실제 필요할 때만 | `IntentRouter`(규칙 테이블) → `DirectAnswer`(DB) vs `LlmAnswer` 분기 |
| **P2. 소형모델 우선 티어링** | 기본 온프레미스 **소형(Ollama `qwen3:1.7b`/`llama3.2:1b`)** 로 처리 → 난도·실패 시에만 **Claude로 에스컬레이션**(`AiTextRouter` 폴백 체인 역방향 활용: 저비용 우선 → 고비용 승급). 난도 판정 = 컨텍스트 길이·요구 추론 깊이·소형모델 신뢰도 | `AiTextRouter`(기존)·`AiConfig` 티어 정책. RAM 제약([[project_ollama_ram_constraint]]) 정합 |
| **P3. RAG 그라운딩으로 프롬프트 축소** | 크롤 적재/도메인 DB에서 **필요 레코드만 발췌**(top_k 제한·컬럼 프로젝션)해 **짧은 컨텍스트**로 전달. **전체 문서·전체 테이블 주입 금지.** 근거 스니펫만 넣어 프롬프트 길이·환각 동시 억제 | RAG 검색 `top_k`(기본 3~5)·발췌 요약·근거 스니펫 |
| **P4. 캐싱** | ① **응답 캐시**: 동일 의도+동일 파라미터 질의 결과 캐시(TTL, 예 일정·요금 24h) ② **시스템 프롬프트 캐시**(프롬프트 프리픽스 재사용) ③ **동일 기간/스코프 요약 재사용**(M16 BI·기간 요약). 히스토리는 요약본만 재주입 | Redis 응답 캐시(키=의도+파라미터+tenant+locale), 프롬프트캐시 |
| **P5. 출력 상한·구조화** | `max_tokens` 상한 강제, **구조화(JSON/카드) 출력**으로 장문 방지. **스트리밍은 UX 체감용일 뿐 토큰량은 불변**임을 명시(비용 절감 아님) | `max_tokens`·`response_format`(structured), 스트리밍=표시 옵션 |
| **P6. 집계는 SQL로** | 통계·집계·랭킹·추이는 **LLM이 아니라 DB 집계(뷰/데이터마트)** 로 산출. LLM은 그 수치에 대한 **자연어 설명·해석만** 담당(수치 계산에 LLM 금지 — 환각·비용 이중 방지) | M16 BI 데이터마트(§M16-1)·SQL 집계 → LLM은 설명만 |
**오케스트레이션 흐름(요약)**: `사용자 질문 → IntentRouter(P1) → [사실조회면] DB/뷰 카드 응답(LLM 0토큰) / [종합필요면] RAG 발췌(P3) → 캐시 조회(P4) → 소형모델(P2) → (난도↑) Claude 승급 → max_tokens·구조화 출력(P5) → 근거 인용 카드(U5)`. 통계는 항상 SQL 선처리(P6).
### 8A-3. 측정지표 및 목표 (비용 가시성)
> 토큰 최소화는 **측정되어야 관리된다.** 아래 지표를 `ai_usage_log`(질의별 토큰·모델·경로·캐시히트·지연)로 적재하고 M16 BI/관리자 대시보드에 노출한다. 목표치는 초기 가설이며 파일럿 후 보정(근거 없는 확정 금지).
| 지표 | 정의 | 목표(초기 가설) |
|---|---|---|
| **LLM 우회율(deflection)** | 전 질의 중 P1 결정론/DB로 **LLM 없이** 처리된 비율 | **≥ 40%** (일정·요금·위치·통계형 다수) |
| **소형모델 처리율** | LLM 호출 중 소형(Ollama)로 완결(Claude 미승급) 비율 | **≥ 70%** |
| **캐시 적중률** | 캐시(P4)로 응답한 비율 | **≥ 30%**(반복 질의 화면) |
| **호출당 평균 입력 토큰** | LLM 호출 1건당 평균 프롬프트 토큰(RAG 발췌·히스토리 요약 후) | **≤ 1,500** |
| **호출당 평균 출력 토큰** | `max_tokens` 상한 하 평균 출력 | **≤ 400** |
| **근거 인용률** | LLM 답변 중 근거 링크·"모름" 폴백을 포함한 비율(U5) | **≥ 95%** |
- **비용 귀속**: `ai_usage_log``tenant_id`(§8-2)·모듈·역할을 태깅해 **테넌트/모듈별 비용 배분**을 가능케 한다(플랫폼 슈퍼관리자 크로스-테넌트 집계).
### 8A-4. design/개발 에이전트 계약 포인트
> planner는 원칙·계약을 확정한다. **화면·컴포넌트 상세는 designer(Stitch 경유), 라우팅·캐시·모델 티어 구현은 backend/ai-dev 후속.** design.md·src 미수정.
1. **단일 AI 계약** `POST /ai/ask` — 입력: `{ question, contextRef(화면·행사·부스), locale, history(요약) }`, 출력: `{ answerCard(구조화), citations[], followups[], route(direct|small|escalated), usage(tokens·cached) }`. 전 화면·전 채널(웹·모바일)이 이 단일 계약을 사용(U1·U7).
2. **라우팅 계약**: `IntentRouter`**규칙 테이블(공통코드)** 로 관리(코드 배포 없이 의도·직답 매핑 추가). 사실조회 의도는 **DB 리졸버 매핑**(일정·요금·위치·통계). 미매핑만 LLM 경로.
3. **캐시 계약**: 캐시 키 = `hash(intent + params + tenant_id + locale)`, TTL은 의도별 정책(공통코드). 무효화 = 원천 데이터 변경 이벤트(§5B notification 버스 재사용).
4. **모델 티어 계약**: `AiConfig`(§5B, 기존 AiTextRouter/AiConfig 패턴)에 **티어 정책**(기본 소형·승급 임계·모델명) 설정형 노출 — 하드코딩 금지, 관리자 조정 가능. 실패 시 폴백(소형↔Claude 상호).
5. **UI 컴포넌트 계약**: 공통 `AiAssistant`(진입점·질문창·칩·카드·히스토리) + `AiActionButton`(원탭 액션). 예시 칩·허용 액션은 **화면/역할 메타데이터**로 주입(designer가 화면별 큐레이션, Stitch 프롬프트에 반영).
6. **모듈 링크**: M10(§5A 관람객 AI 도우미)·M16(§M16-1 BI 자연어 설명)·§5B 공통 AI 보조가 본 절 원칙을 **동일 계약으로** 준수. 나노바나나(§6) 이미지 생성은 본 절 토큰 원칙 대상 아님(별도 RenderJob 쿼터·캐시).
---
## 9. 로드맵
### Phase 1 — 설계·시각화 코어 (MVP, ~4개월)
@ -1191,6 +1261,7 @@ graph TB
| 버전 | 일자 | 작성자 | 내용 |
|---|---|---|---|
| **v3.4** | 2026-07-12 | planner | **AI 사용성 & 토큰 최소화 아키텍처 신설(소유자 지시 2026-07-12).** 기존 스코프·화면·라우트 전부 보존, AI 원칙·계약·지표만 순증. ①헤더 v3.4 라인 + 버전 상향 ②**§8A 신설**(§8-2 뒤·§9 앞) — (8A-1) AI 사용성 표준 U1~U7(인라인 진입점 `AiAssistant`·예시 질문 칩·원탭 AI 액션·다음 명령 제시(규칙 우선)·구조화 카드+근거 인용·"모름" 폴백·대화 히스토리 요약·접근성/다국어/모바일/음성, 관람객·업무 사용자 단일 컴포넌트 공유) (8A-2) 토큰 최소화 6원칙 P1~P6(결정론 우선 라우팅으로 사실조회 LLM 미호출·소형모델 우선 티어링 AiTextRouter 저비용→승급·RAG 발췌 top_k 제한·캐싱 응답/프롬프트/기간요약·max_tokens 상한+구조화 출력(스트리밍=UX용 토큰 불변)·집계는 SQL LLM은 설명만) + 오케스트레이션 흐름 (8A-3) 측정지표·목표(LLM 우회율≥40%·소형모델 처리율≥70%·캐시 적중률≥30%·평균 입력≤1500/출력≤400·근거 인용률≥95%, `ai_usage_log` 적재·tenant/모듈 비용 귀속) (8A-4) design/개발 계약 6종(단일 `/ai/ask`·규칙테이블 IntentRouter·캐시 키·AiConfig 티어·공통 `AiAssistant`/`AiActionButton`·모듈 링크) ③**M10·M16-1·§5B-2에 §8A 정합 링크** 순증(관람객 AI 도우미 DB 직답·BI 집계 SQL/LLM 설명만·공통 AI 보조 중앙 `/ai/ask` 경유). 이미 진행 중(visitor-assistant DB 근거·AiTextRouter 폴백)과 정합·표준화(재발명 아님). 외부 API 게이트·RAM 제약 정합. **src·design.md 미수정** — designer(AiAssistant/칩 Stitch)·backend/ai-dev(라우팅·캐시·티어) 후속 |
| v1.0 | 2026-07-11 | planner | 최초 작성 — kintex-website.md 분석 기반 전체 기획. ReRoomAI 소스 분석(reroomai-source.md)은 추가 시 6장 갱신 예정 |
| v1.1 | 2026-07-11 | planner | ReRoomAI 소스 분석 반영. ①§6 나노바나나 파이프라인 제어 파라미터 확정 — 모델 `gemini-3.1-flash-image-preview`(나노바나나 2)·`@google/genai` SDK·image-to-image(inlineData+text parts) 호출 스택, 구조화 사전(BOOTH_TYPES·BOOTH_STYLES·FIXTURE_LAYERS) + "보존/교체 명시 분리" 부스 프롬프트 템플릿(§6-4), Canvas 1024px 전처리·RenderJob 방어 로직(크기 가드·SAFETY·에러 분기·성공 시에만 쿼터 차감)·단계별 로딩 UX 신설(§6-5). ②S6 배선 오버레이 백엔드 래스터 합성 우선 명확화(§6-3). ③§7 마스터 데이터 순증 반영(기본부스 표준 품목·프리미엄 6×3×4m 2kW·조명 반입 금지·이격 30/60cm·옥외 2,849㎡·홀6 93×60×10m·제3전시장 홀11~18). ④§10 R11 해소, R12(Gemini 외부 API 미승인·소유자 승인 게이트) 추가. BACKLOG B-11 done, B-09 정리 |
| v1.2 | 2026-07-11 | planner | **기술 스택 확정 — React + Spring Boot 3.x(Java 17) + MyBatis + PostgreSQL(PostGIS), 나노바나나 Python 워커 사이드카. GUARDiA 표준 프레임워크 정렬(사용자 지정).** §8 아키텍처 전면 정합화: 백엔드 FastAPI→Spring Boot 3.x + MyBatis(REST + WebSocket/STOMP, 룰·배치/배선 엔진=서비스 계층 + PostGIS 공간 SQL), 프론트 Next.js→React 18/19(Vite·TS, SVG/WebGL 캔버스), 비동기 큐 Redis 유지 + 이미지 생성은 별도 **Python 워커 사이드카(`tools/nanobanana`, google-genai Python SDK)** 로 분리(Spring이 RenderJob 큐잉→Python 워커 소비·생성→오브젝트 스토리지 적재→WebSocket 완료 푸시, 서류·알림도 동일 큐). Python 워커 유지 근거 명시(ReRoomAI 검증 client.py·Python SDK — Java 재구현 회피). 인증(행사 단위 RBAC+JWT)·오브젝트 스토리지 유지. §8 mermaid 갱신. **기능 범위(M1~M9)·우선순위·나노바나나 파이프라인 로직 불변** — 스택 표기 정합화만 수행 |

View File

@ -0,0 +1,131 @@
# 전시·공연 모바일 앱 벤치마킹 (인기순) — 킨텍스 관람객(B2C) 앱
> 작성일: 2026-07-12 · 대상 트랙: KINTEX AI 전시·행사시스템 관람객(M10)·모바일 앱(`mobile/`, Expo)
> 소유자 지시: "모바일은 전시·공연 앱 벤치마킹 => 다운로드·좋아요가 가장 많은 순으로"
> 원칙: 스토어에서 확인된 수치만 사실로 기재하고 미확인·추정은 명시. 본 문서는 **분석·권고**만 기록하며 PLANNING.md·design.md를 직접 수정하지 않는다(적용은 kintex-mobile-orchestrator/designer 경유).
> 보완 관계: 기존 `docs/analysis/ticketing-app-benchmark.md`(예매·티켓 기능 대조)의 **상위 문서** — 여기서는 앱 인기순 랭킹·IA/UX·패턴 카탈로그를 다루고 기능 세부 대조는 티켓 문서를 참조한다.
---
## 0. 방법론 · 데이터 신뢰도 주의
- 수집: Google Play / App Store(KR) 공개 페이지 + 공개 보도. 크롤은 WebFetch/WebSearch만 사용.
- **Google Play 설치수**는 봇 차단으로 자동 수집 시 헤더만 노출되어 다수 **추정(범위)**로 표기했다. App Store는 평점·평가수가 안정적으로 노출되어 실값을 기재했다.
- ★**중대 해석 주의**: 한국 티켓팅 앱의 App Store 평점은 **1.3~1.8**로 매우 낮다. 이는 품질이 아니라 **티켓팅 실패·대기열 좌절에 따른 별점 폭탄(review bomb) 아티팩트**다 — 설치수·실사용은 오히려 최상위다. 따라서 "좋아요(평점) 순"과 "다운로드 순"은 **상반**하며, 아래 §1에 **두 개의 랭킹**을 분리 제시한다.
---
## 1. 인기순 랭킹표 (두 축)
### 1-A. 다운로드(설치) 기준 — "가장 많이 쓰는"
| 순위 | 앱 | 분류 | 플랫폼 | 설치수(Android) | 평점 | 평가/리뷰수 | 출처·비고 |
|---|---|---|---|---|---|---|---|
| 1 | **NOL(야놀자)** | 여가·공연·전시 슈퍼앱 | GP·AS | **1,000만+** (GP 단독) | — | — | outstanding.kr 보도(GP 누적 1천만). 공연·전시 예매 포함 슈퍼앱 |
| 2 | **NOL 티켓(구 인터파크티켓)** | 공연·전시·티켓 빅3 | GP·AS | 100만~500만+ *(추정)* | iOS 1.8 | iOS 2,100 | AS id440487844. 국내 공연·전시 예매 1위권. 평점=별점폭탄 |
| 3 | **Fever** (글로벌) | 이벤트·전시 발견·티켓 | GP·AS | **14,000,000** | GP 4.70 | 140,000 | appbrain/GP. 몰입형 전시(반고흐 등) 강세 |
| 4 | **Eventbrite** (글로벌) | 이벤트 발견·티켓 | GP·AS | 1,000만+ | GP 4.9 | 대량 | GP/SimilarWeb |
| 5 | **DICE** (글로벌) | 공연·라이브 티켓 | GP·AS | 월 1,000만+ 사용 | iOS 4.8 | iOS 97,000 | GP fm.dice. 양도(waitlist)·반환매 강점 |
| 6 | **티켓링크** | 공연·스포츠 티켓 빅3 | GP·AS | 50만~100만+ *(추정)* | — | — | GP kr.co.ticketlink.cne. 스마트티켓·스마트스탬프 |
| 7 | **멜론티켓** | 공연 티켓 빅3 | GP·AS | 50만~100만+ *(추정)* | iOS 1.8 | iOS 426 | AS id1103635576. 좌석 5분 선점 |
| 8 | **예스24 티켓** | 공연 티켓 | GP·AS | 10만~50만+ *(추정)* | iOS 1.3 | iOS 773 | AS id937042887 |
| 9 | **아트맵 ARTMAP** | 전시 큐레이션·발견 | GP·AS | 회원 14만+ (앱+웹+SNS) | 정성 긍정 | — | art-map.co.kr. 취향 기반 전시 추천·좋아요/다녀옴 |
| 10 | **국립현대미술관 전시안내(MMCA)** | 미술관 관람 | GP·AS | 5만~10만+ *(추정)* | iOS 4.2 | iOS 20 | AS id1350753069. 전시예약·QR관람권·오디오가이드·실내길찾기 |
| — | 서울시립미술관 도슨팅 / 국립중앙박물관 가이드 | 미술관/박물관 | GP·AS | 소~중규모 *(추정)* | — | — | 도슨트·오디오가이드 중심 |
| — | KOPIS(공연예술통합전산망) | 공연 통계·정보 공공 | 모바일웹 중심 | 전용앱 미약 | — | — | kopis.or.kr/mob. 예매 아닌 정보·통계 허브 |
| — | Whova (글로벌 컨퍼런스) | B2B 이벤트 앱 | GP·AS | 100만+ | 높음 | — | 기존 티켓 벤치마크 §1 참조(개인 아젠다·QR 명함·셀프 체크인) |
### 1-B. 좋아요(평점·만족도) 기준 — "가장 사랑받는"
| 순위 | 앱 | 평점 | 근거 | 킨텍스 시사 |
|---|---|---|---|---|
| 1 | **Eventbrite** | GP 4.9 | 발견성·간편 등록·게스트리스트 체크인 | 무료·정원제 전시 등록 UX가 콘서트 예매보다 관람객 앱에 적합 |
| 2 | **DICE** | iOS 4.8 (97K) | 무발권 QR·정품 양도(waitlist)·반환매 | 티켓 신뢰(재판매·양도) UX 우수 |
| 3 | **Fever** | GP 4.70 (140K) | 몰입형 전시 발견·큐레이션·추천 | 전시 "발견" 홈피드·추천이 만족도 핵심 |
| 4 | **국립현대미술관(MMCA)** | iOS 4.2 | 전시예약+QR관람권+오디오가이드+실내길찾기 **한 앱** 통합 | 킨텍스 관람객 앱과 가장 근접한 국내 롤모델 |
| 5 | **아트맵 ARTMAP** | 정성 긍정 | 취향 추천·좋아요/다녀옴 컬렉션 | 개인화 발견·"찜/방문" 데이터 루프 |
| 하위 | 국내 티켓팅 빅3(NOL 티켓·멜론·예스24) | 1.3~1.8 | **별점 폭탄 아티팩트**(티켓팅 좌절) | 설치는 최상위이나 만족도 낮음 → 킨텍스는 대기열 스트레스 낮은 무료 전시라 **만족도 우위 확보 가능** |
**요지**: 킨텍스 관람객 앱의 벤치마크 최적 조합 = **MMCA(국내 전시 통합 UX) + Eventbrite/DICE(신뢰·발견·양도) + Fever/아트맵(개인화 발견) + Whova(현장 인게이지먼트·비즈매칭)**. 국내 티켓팅 빅3는 예매 퍼널·부정입장 방지 기법만 차용(만족도 반면교사).
---
## 2. 상위 앱별 IA · 기능 · UX 분석
### 2-1. 국립현대미술관 전시안내 (MMCA) — 국내 전시 앱 롤모델 (iOS 4.2)
- **홈 IA**: 전시추천 → 전시예약 → QR관람권 → 오디오가이드(작품감상) → 실내 길찾기. **관람 여정 전체를 한 앱**에 담은 국내 유일급.
- **차용가치 최상**: (a) **전시 사전예약→QR 관람권 발권→현장 QR 입장** 폐루프, (b) **작품 단위 오디오가이드**(전시장 비콘/번호 입력), (c) **관내 실내 길찾기**. 킨텍스 SCR-M5/M6/M14/M15와 1:1 대응.
- **약점**: 4관 한정·평가 표본 적음(20). 추천·소셜·다국어 얕음.
### 2-2. Fever (글로벌, 14M·4.70) — 전시 "발견" 엔진
- **홈 IA**: 위치기반 **발견 피드**(오늘/이번주/인기/카테고리 타일) 중심. 리스트가 아니라 **큐레이션 카드**.
- **UX 강점**: 몰입형 전시(디지털 아트) 강세, **관심·위치 기반 추천**, 원탭 예약, 캘린더 리마인더.
- **차용**: 관람객 홈을 "행사 리스트"가 아닌 **개인화 발견 피드**(추천 존·근처·마감임박·AI추천 칩)로.
### 2-3. DICE (글로벌, iOS 4.8·97K) — 티켓 신뢰 UX
- **강점**: **무발권 QR**(입장 직전 활성화), **정품 재판매/양도 waitlist**(암표 차단), 리마인더, 라인업 상세.
- **차용**: 티켓 **양도·환불 대기열**(정원 소진 세션/티켓의 대기·취소표 알림), 입장 직전 QR 활성화(캡처 무효).
### 2-4. Eventbrite (글로벌, GP 4.9) — 무료·게스트 등록 표준
- **강점**: 마켓플레이스 **발견성**, 무료/유료 티켓 유형 선택→간편 등록→QR, 오거나이저 앱 게스트리스트 체크인.
- **차용**: 전시(무료·정원제)에 맞는 **짧은 등록 퍼널 + 게스트 등록 + 셀프 체크인**.
### 2-5. 아트맵 ARTMAP (전시 큐레이션, 회원 14만+)
- **강점**: **취향 기반 전시 추천**, **좋아요·다녀옴** 버튼으로 개인 컬렉션·취향 분석, 근처 갤러리, 일 400+ 전시 정보.
- **차용**: **찜(좋아요)·다녀옴 데이터 루프 → 개인화 추천** (킨텍스 즐겨찾기를 취향학습으로 승격).
### 2-6. 국내 티켓팅 빅3 (NOL 티켓·멜론·티켓링크·예스24) — 예매·입장 기법 (반면교사)
- **예매 퍼널**: 회차/좌석→권종→예매자→결제→완료. 좌석 선점 타이머(멜론 5분)·대기열·매크로 방지(CAPTCHA).
- **입장/부정방지**: 티켓링크 **스마트티켓**(무발권 QR/바코드)+**스마트스탬프**(단말 고유값 인식 → 캡처본 무효). QR 인식 위해 밝기 상향.
- **개인화**: 관심인물/공연/장르, **티켓오픈 알림**, 예매대기·취소표 우선권.
- **차용**: 부정입장 방지(디바이스 바인딩·회전 QR)·오픈 알림·다구간 환불 규정 — 상세는 `ticketing-app-benchmark.md` §2·§3.
- **주의**: 낮은 평점=티켓팅 스트레스. 킨텍스 무료 전시는 이 스트레스가 없어 **만족도 우위**가 기본값.
### 2-7. Whova (글로벌 B2B 컨퍼런스) — 현장 인게이지먼트
- **강점**: 참가자 고유 QR(배지+앱), **개인 아젠다(세션 정원)**, **QR 명함교환·매치메이킹·밋업**, 셀프 체크인 키오스크, 라이브 공지/폴.
- **차용**: 비즈매칭(SCR-M8)·세션 아젠다 정원(SCR-M9)·QR 명함교환·라이브 공지 피드.
---
## 3. 패턴 카탈로그 (전시·공연 앱 공통 베스트 프랙티스)
| 패턴 | 정의 | 대표 앱 | 킨텍스 대응 |
|---|---|---|---|
| **P-A 발견 피드 홈** | 리스트가 아닌 위치·취향 기반 큐레이션 카드 홈 | Fever·아트맵·NOL | SCR-M5 홈을 발견 피드로 |
| **P-B 관람 폐루프** | 예약→QR 관람권→현장 QR 입장→배지 승격 | MMCA·Whova | SCR-M14→M15→체크인→M5 (이미 정의) |
| **P-C 무발권/회전 QR** | 입장 직전 활성화·디바이스 바인딩으로 캡처 무효 | DICE·티켓링크 | SCR-M15 강화(현재 정적 QR) |
| **P-D 오픈/관심 알림 구독** | 오픈 예정·관심 행사 구독→푸시/EDM | NOL·COEX | 신규(공개사이트+앱) |
| **P-E 취향 학습(좋아요/다녀옴)** | 찜·방문 데이터→개인화 추천 | 아트맵·Fever | SCR-M5·M7 즐겨찾기→추천 승격 |
| **P-F 개인 아젠다·정원 RSVP** | 세션 즐겨찾기 + 정원·마감·대기 | Whova | SCR-M9 정원 상태 추가 |
| **P-G QR 명함교환·컨택트 지갑** | 관람객↔관람객/참가업체 상호 QR | Whova | SCR-M8 확장 |
| **P-H 오디오가이드/도슨트** | 작품·부스 단위 음성 해설 | MMCA·서울시립·박물관 | 신규(킨텍스 부스/전시 해설) |
| **P-I 실내 wayfinding** | 블루닷·경로·존레벨 폴백 | MMCA·COEX | SCR-M6(이미 정의) |
| **P-J 현장 편의 통합** | 주차 실시간·결제·혼잡/대기 한 동선 | 킨텍스 공식앱·COEX | 신규(주차·혼잡 위젯) |
| **P-K 셀프 체크인/즉석 배지** | 키오스크 셀프 등록·현장 배지 인쇄 | Whova·Eventbrite | 신규 화면 |
| **P-L 다국어·접근성** | 한/영/중/일 + WCAG AA + QR 대체 | Whova·COEX | 전 화면(강화) |
| **P-M 티켓 양도·취소표 대기** | 정품 재판매·대기열 알림(암표 차단) | DICE·NOL | 신규(정원 세션·티켓) |
---
## 4. 출처 URL
- NOL(야놀자) 1천만 다운로드: https://outstanding.kr/ (보도), GP `com.cultsotry.yanolja.nativeapp`
- NOL 티켓(인터파크): AS https://apps.apple.com/kr/app/id440487844 · GP `com.interpark.app.ticket`
- 멜론티켓: AS https://apps.apple.com/kr/app/id1103635576 · GP `com.iloen.melonticket`
- 예스24 티켓: AS https://apps.apple.com/kr/app/id937042887
- 티켓링크: GP `kr.co.ticketlink.cne` · 스마트티켓 https://www.ticketlink.co.kr/help/guide/popup/smart-ticket
- 아트맵 ARTMAP: AS id1446240686 · GP `com.artmap.app` · https://art-map.co.kr/
- 국립현대미술관 전시안내: AS https://apps.apple.com/kr/app/id1350753069 · GP `kr.go.mmca.hdexhibit`
- 서울시립미술관 도슨팅: GP `seoul.museum.art.act` · 국립중앙박물관: GP `or.kr.nationalmuseum`
- KOPIS: https://www.kopis.or.kr/mob/main/main.do
- Fever: GP `com.feverup.fever` · appbrain fever-events-tickets
- DICE: GP `fm.dice` · https://dice.fm/
- Eventbrite: GP `com.eventbrite.attendee` · SimilarWeb 통계
- Whova: https://whova.com/whova-event-app/
- (기능 세부 대조) `docs/analysis/ticketing-app-benchmark.md`
---
## 변경 이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-12 | 최초 작성 — 전시·공연·티켓·미술관 앱 인기순 랭킹(다운로드/평점 2축), MMCA·Fever·DICE·Eventbrite·아트맵·티켓팅빅3·Whova IA/UX 분석, 패턴 카탈로그 13종(P-A~P-M). 별점폭탄 아티팩트 해석 주의 명기 |

View File

@ -689,8 +689,193 @@ erDiagram
---
## 10. FK 최소화·공통코드 관리 표준 (소유자 지시 2026-07-12)
> 소유자 원칙: **"FK는 최소화, 공통코드로 관리"**. 본 절은 §4·§5-5(DQ) 위에 **참조무결성·범주값 관리 방식의 단일 정책**을 확정한다. 이미 적용된 마이그레이션(V1~V43)은 **불변** — 본 절은 표준·감사·백로그이며 파괴적 재작성을 지시하지 않는다. 구현(신규 멱등 마이그레이션)은 kintex-db-engineer.
### 10-1. 물리 FK 제약 최소화 정책
- **기본값 = 물리 FK 미설정**: 신규 테넌트/도메인 테이블은 DB `FOREIGN KEY`/`REFERENCES`를 **두지 않는다**. 참조무결성은 **애플리케이션 레이어(서비스·매퍼 검증) + 명명 규약(`*_id` 소프트 참조)**로 보장(§4-1 FK 명명 유지, 물리 제약만 생략).
- **근거(4)**: ① **멀티테넌트 복합키 마찰** — 테넌트 루트(`event`·`hall`·`app_user`)는 `PRIMARY KEY (tenant_id, id)`로 전환됨(V31). 단일 `id` 참조 FK는 `UNIQUE(id)` 보조제약을 강제하고, V31이 실제로 **전 자식 FK를 드롭→복합PK 전환→UNIQUE(id)로 재생성**하는 동적 스윕을 수행해야 했다(마찰 실증). ② **MyBatis** — 조인·삭제 순서를 앱이 제어. ③ **마이그레이션·시드 순서 자유** — 멱등 `ON CONFLICT` 시드가 부모 선삽입에 묶이지 않음. ④ **성능·재배치** — 부스 replaceBooths·부스 교체 시 자식 재지정이 잦음(V3 utility_order·render_job는 이미 소프트 참조 채택 — 정본 사례).
- **예외 화이트리스트(FK 유지 허용)**: 아래 **강한 무결성이 필수이고 테넌트 복합키가 아닌 전역 시스템/RBAC 구성**만 물리 FK를 허용한다. 그 외 신규 FK 신설 **금지**.
| # | 자식 → 부모 | 마이그레이션 | 유지 사유 |
|---|---|---|---|
| W1 | `common_code.grp_code``common_code_group` | V7 | 코드값 고아 방지(공통코드 정합의 근간)·전역·정적 |
| W2 | `sys_menu.parent_id``sys_menu`(self) | V7 | 메뉴 트리 순환/고아 방지·전역 |
| W3 | `sys_role_permission`(role_code→`sys_role`, perm_code→`sys_permission`) | V7 | RBAC 권한 매핑 무결성(보안 임계)·전역 |
| W4 | `sys_role_menu`(role_code→`sys_role`, menu_id→`sys_menu`) | V37 | RBAC 메뉴 매핑 무결성·전역 |
### 10-2. 공통코드로 범주값 관리 정책
- **범주형 컬럼 = 공통코드**: 상태·유형·카테고리·모드·구분·심각도 등 **열거 가능 소수값**은 자유문자열/DB enum/전용 참조테이블이 아닌 **공통코드(`common_code_group`/`common_code`)로 관리**. 컬럼엔 코드값(영문 상수)만 저장, 표시명은 조인/캐시(§4-3, COMMON_CODES 정본). DB `ENUM` 물리타입은 지양(§4-2 — 룰셋/코드 유연성).
- **정본 화면 = W12 공통코드 관리**(CommonCodeAdminPage): 그룹/상세 CRUD의 단일 관리 지점. 신규 범주 컬럼 도입 시 **먼저 공통코드 그룹을 정의**하고 컬럼 주석에 `-- 공통코드(GRP)` 표기(V37 `partner_type` 사례).
- **코드 vs 마스터 경계(불변, §4-3)**: 다건·CRUD·버전 대상(홀·요율·규정 룰셋·등록업체)은 공통코드가 아니라 마스터/룰셋. 범주 컬럼만 공통코드로.
### 10-3. 테넌트 표준 정합
- 공통코드도 **테넌트 스코프 원칙 준수**([[tenant-id-pk-standard]]). 현행 `common_code_group`/`common_code`는 전역(테넌트 미부여) 이식본 — 킨텍스 단일 테넌트 운영 중엔 전역 공유가 유효하나, 멀티테넌트 확장 시 **테넌트별 코드 오버라이드**가 필요하면 `(tenant_id, grp_code, code)` 확장을 백로그로 둔다(§10-7 B4, 지금은 순증 시드만).
- 소프트 참조 인덱스는 `(tenant_id, *_id)` 복합 선두로 생성(§10-4).
### 10-4. 소프트 참조 무결성 보완책 (명문화)
물리 FK를 생략하는 대신 아래를 강제한다:
1. **앱 레이어 검증**: 부모 존재 확인은 서비스/매퍼에서 수행(삽입 전 조회 또는 조인 검증). 낙찰·정산 등 임계 트랜잭션은 명시적 존재검증 필수.
2. **삭제 시 고아 방지 규약**: 부모 삭제는 서비스가 자식 선삭제/무효화(soft-delete `use_yn='N'` 우선). 물리 CASCADE에 의존하지 않는다.
3. **논리참조 인덱스**: 조회·조인·고아 스캔 가속을 위해 소프트 FK 컬럼에 `(tenant_id, <ref>_id)` 인덱스(§10-7 B2).
4. **고아 검증 쿼리(DQ)**: 야간/배포 후 `LEFT JOIN ... WHERE parent.id IS NULL` 고아 스캔을 운영 점검 쿼리로 상비(마트 적재 전 DQ 게이트, §6-4). §5-5 DQ의 "참조무결성(FK)"은 **소프트 참조 무결성(앱+검증쿼리)**으로 해석 갱신.
---
## 11. 뷰·구체화뷰·함수 사용 지침 (소유자 지시 2026-07-12)
> 파생·집계·재사용 계산은 **결정론적 SQL 객체**로 캡슐화해 상태 백필·중복 로직·토큰 낭비를 줄인다. **실사용 근거 없는 선제 생성 금지**(남용 방지).
### 11-1. VIEW (`v_*`) — 실시간 파생·경량 조인·상태 산출
- 용도: 저장 없이 **결정론적 파생**(생명주기 상태·경량 조인·표시용 코드명 조인). 상태/구분 컬럼 백필 대신 **뷰 산출 권장**.
- 정본 사례(V43): `v_event_calendar`(날짜로 `lifecycle` UPCOMING/ONGOING/ENDED 산출 — 별도 상태 컬럼 불필요), `v_event_monthly_summary`(월별 건수 집계). 이 패턴을 신규 파생에 재사용.
- 규약: `CREATE OR REPLACE VIEW`, `tenant_id` 컬럼 노출·필터 유지, 하부 테이블 인덱스에 의존(뷰 자체 인덱스 불가), 민감 컬럼(§5-1 P1/P3) 미노출·마스킹만.
### 11-2. MATERIALIZED VIEW (`mv_*`) — 무겁고 자주 조회·실시간성 낮은 집계
- 용도: **BI 대시보드 KPI·홀 가동률·리드/ROI·월/연 통계** 등 비용 큰 집계로 실시간성이 덜 중요한 것(§6 마트 KPI_SNAPSHOT과 정합 — mview는 경량 대체/보조).
- **새로고침 전략 명시 필수**: 주기(야간 배치)·`REFRESH MATERIALIZED VIEW CONCURRENTLY`(무중단·유니크 인덱스 전제)·mview 자체 인덱스(`tenant_id` + 조회 키) 생성.
- 남용 판단 기준:
| 상황 | 선택 |
|---|---|
| 실시간성 필수·경량 | VIEW |
| 실시간성 낮음·집계 무거움·반복 조회 | MATERIALIZED VIEW |
| 경영 KPI 시계열·스냅샷 재현성 | KPI_SNAPSHOT 테이블(§6) |
| 1회성·희소 조회 | 뷰/mview 생성 안 함(온디맨드 쿼리) |
### 11-3. FUNCTION (`fn_*`) — 재사용 계산 로직 캡슐화
- 용도: 여러 쿼리·화면 공유 계산(기간→분기 산출, 요율 계산 보조, 거리/트래블타임 보조). **부수효과 없는 순수/`IMMUTABLE`·`STABLE` 우선**, PL/pgSQL은 꼭 필요할 때만(SQL 함수 우선).
- 규약: `CREATE OR REPLACE FUNCTION fn_*`, `tenant_id` 파라미터화, 룰셋 의존 계산(요율)은 스냅샷 버전 인자(§4-4)로 재현성 보장.
### 11-4. STORED PROCEDURE (`sp_*`) — 집합연산·다단계 트랜잭션
- 용도: **대량 집합 처리·다단계 트랜잭션·정기 롤업**은 앱 루프(행 단위 왕복) 대신 **DB 프로시저(PL/pgSQL) 세트기반 처리**. 예: 월마감 집계, `REFRESH MATERIALIZED VIEW`, 대량 상태전이, 정산 롤업.
- **경계(남용 금지)**: **비즈니스 로직 대부분은 앱(Service) 유지**. DB 프로시저는 **성능이 결정적일 때만**(대량·세트기반이 앱 루프 대비 확연히 유리). 검증·권한·감사·룰셋 판정 등 도메인 규칙을 프로시저로 이관하지 않는다.
- 순수/부수효과 구분: 반환값만 있는 재사용 계산 = `fn_*`(`IMMUTABLE`/`STABLE`), **부수효과(쓰기·다단계 커밋) 있는 것만 `PROCEDURE sp_*`**(`CALL`). 명명 `fn_*`/`sp_*`, 멱등 `CREATE OR REPLACE`, `tenant_id` 파라미터·스코프 준수.
### 11-5. 공통 규약
- 명명 `v_*`·`mv_*`·`fn_*`·`sp_*`. 마이그레이션 멱등(`CREATE OR REPLACE` / mview는 `IF NOT EXISTS` 가드). 테넌트 스코프 유지. 실사용 근거(화면/API/AI 답변) 있는 것만 생성.
---
## 13. 자동화 배치 카탈로그 (대상·주기·멱등 — 데이터 표준 관점)
> **무엇을 배치로 돌릴지**(대상·주기·멱등·잠금)만 데이터 표준에서 정의한다. **실행 프레임워크**(Spring `@Scheduled`/Quartz/cron·분산락)는 아키텍처(A-1 app.md/A-3 tech.md, kintex-sa/ta) 표준 영역 — 본 카탈로그를 그쪽으로 링크. 구현 인계: **스케줄러=backend-dev, 프로시저/mview=db-engineer**.
| # | 배치 작업 | 데이터 대상 | 주기(권고) | 멱등성 | 재시도·잠금 요건 |
|---|---|---|---|---|---|
| J1 | **mview 새로고침** | `mv_hall_occupancy`·`mv_lead_roi_summary`·KPI 집계(§6) | 야간 1회(+수요 트리거) | `REFRESH ... CONCURRENTLY` 자연 멱등 | 단일 실행락(중복 REFRESH 방지)·실패 시 다음 주기 |
| J2 | **KPI 스냅샷 적재** | `KPI_SNAPSHOT`(§6-4, `built_at` 각인) | 야간 배치 | 스냅샷 키(date_key,scope) UPSERT | 원천-집계 정합 DQ 통과 후 커밋 |
| J3 | **마감임박 알림·해야할일 자동생성** | D-데이(문서/마일스톤 D-150/30/25/7)·옥션 deadline·납부 스케줄 | 일 1회(+정시) | 발송/생성 대상 dedup 키(대상+일자) | 이미 발송분 스킵(중복 통지 금지)·실패 재큐 |
| J4 | **EDM/옥션 통지 발송** | 캠페인·옥션 통지(SMTP, [[smtp-email-approval]]) | 예약시각·이벤트 트리거 | mail_log 상태(sent/skipped)로 재발송 차단 | 마케팅 미동의 세그먼트 제외 게이트(§5-3)·발송락 |
| J5 | **세션/토큰·만료 데이터 정리** | 만료 비번재설정 토큰·잠금 해제·로그인이력 보존기간 | 시간별/일별 | 조건부 삭제(자연 멱등) | — |
| J6 | **소프트 참조 고아 검출(DQ)** | §10-4 논리참조 무결성(event/hall/company 참조 고아) | 야간(마트 적재 전) | 읽기 전용 스캔 | 고아 발견 시 리포트·차단(마트 적재 게이트) |
| J7 | **PII 보존·파기** | 관람객 등록·배지·체크인(행사종료+1년, §5-4) | 일 1회 | 기간 경과분 익명화/삭제(재실행 안전) | 동의 연장분 제외·감사로그 기록 |
| J8 | **행사 crawl 갱신** | 외부 행사정보(kintex-crawler 트랙) | 정기(일/주) | 소스키 UPSERT | 소스 실패 격리·부분성공 허용 |
| J9 | **정산 롤업** | `sp_settlement_rollup`(정산→FACT_SETTLEMENT) | 마감 주기 | 재실행 시 기간 재계산(UPSERT) | 정산 확정 상태만 대상·실행락 |
- **공통 요건**: 모든 배치는 ① **멱등**(재실행 안전 — UPSERT/조건부/dedup), ② **중복실행 방지 잠금**(분산락 — 실행수단은 아키텍처), ③ **실패 격리·재시도**(부분성공 허용·다음 주기 복구), ④ **감사**(J4·J7 등 통지·파기는 `audit_log` 기록), ⑤ **테넌트 스코프**(배치도 tenant 루프/필터).
- 세트기반 롤업(J2·J9)·대량 상태전이는 §11-4 `sp_*` 프로시저 후보. 알림/발송(J3·J4)의 **판정·세그먼트 규칙은 앱(Service)** — 프로시저는 대량 적재만.
---
## 12. 감사표 + kintex-db-engineer 인계 백로그
> 대조 대상: `src/backend/src/main/resources/db/migration/` V1~V43 전수. **파괴적 재작성 금지** — FK 드롭은 권고(별도 승인 후), 즉시 실행 백로그 = 공통코드 시드·논리참조 인덱스·뷰(순증 멱등). 신규 마이그레이션 번호는 **V43 이후 여유 번호(V44~) 권고**(진행 중 V43·신규분과 충돌 회피 — 실제 번호는 db-engineer가 병합 시점 최댓값+1로 확정).
### 12-1. 감사표 A — 현행 물리 FK 목록 (유지/제거 권고)
현행 물리 FK 약 40건. **유지=화이트리스트 4그룹(§10-1)**, 그 외는 소프트 참조 **제거 권고**(우선순위 P1: 테넌트 루트 참조 / P3: 아그리게잇 내부 — 무해·비복제).
| 분류 | 자식 → 부모(FK) | 마이그레이션 | 판정 |
|---|---|---|---|
| **유지(W1~W4)** | common_code.grp_code→common_code_group; sys_menu.parent_id(self); sys_role_permission(role/perm); sys_role_menu(role/menu) | V7·V37 | **유지** |
| **P1 제거권고** | hall_assignment.event_id→event, hall_id→hall; event_member.event_id→event, user_id→app_user, company_id→company | V2 | 소프트 참조(테넌트 루트·V31 마찰) |
| **P1 제거권고** | trench.hall_id→hall; hall_exit.hall_id→hall; layout.event_id→event, hall_id→hall; utility_order.event_id→event | V3 | 소프트 참조(hall/event 루트) |
| **P1 제거권고** | doc/milestone 3× event_id→event | V14 | 소프트 참조 |
| **P1 제거권고** | dock_reservation.event_id→event, hall_id→hall, dock_id→dock; booth_sale.event_id→event, hall_id→hall | V15·V27 | 소프트 참조 |
| **P1 제거권고** | auction.event_id→event; auction_invite.company_id→company; bid.company_id→company; company_reputation.company_id→company | V16 | 소프트 참조(company/event 루트) |
| **P1 제거권고** | visitor_registration.event_id→event; lead.event_id→event; campaign 3× event_id→event; sponsorship.package_id; settlement.event_id→event; logistics 3× event_id→event | V17·V18·V24·V33 | 소프트 참조 |
| **P3 잔류허용(무해·비복제)** | booth.layout_id→layout; design_plan.booth_id→booth; auction_invite/bid/award.auction_id→auction, award.bid_id→bid; content_version.content_id→cms_content; payment_schedule/refund/tax.invoice_id→invoice; approval_line/history.approval_id→approval; message_recipient/opinion_reply/meeting_attendee | V3·V8·V16·V19·V24·V29·V34 | 아그리게잇 내부 CASCADE — 유지해도 무해, **신규 테이블엔 미복제**(소프트 우선) |
> **주의**: P1 제거는 **자동 실행 대상 아님** — 기존 FK 드롭은 테넌트 복합키 정합·데이터 검증 후 **소유자 승인 별도 DROP 마이그레이션**으로만. 표준의 실질 효력은 **신규 테이블에 FK 미신설 + P1 패턴 미복제**에 있다.
### 12-2. 감사표 B — 범주형 컬럼 → 공통코드 그룹 매핑 (전환 후보)
자유문자열/주석 열거 범주 컬럼 ≈ **34개**. 기존 시드 그룹(V7·V37)과 정합, 신규 그룹(★)은 V44 순증 시드 대상. 코드값=현행 저장값 유지(스키마 불변).
| 그룹코드 | 컬럼(테이블.컬럼) | 마이그 | 코드값(현행) | 상태 |
|---|---|---|---|---|
| `USER_STATUS`★ | app_user.status | V2 | ACTIVE·(LOCKED·INACTIVE) | 신규 |
| `CONTRACTOR_CATEGORY`★ | company.category, auction.category | V2·V16 | 14분류(전시디자인설치·리깅·전기시설·카펫/파이텍스·급배수Air·가스·철거·운수통관·가구비품·경비·광고싸인·지게차·방염·구조해석) | 신규(COMMON_CODES §2 승격) |
| `HALL_FLOOR_TYPE`★ | hall.floor_type | V2 | concrete_polished·carpet·outdoor | 신규 |
| `MASTER_DATA_CATEGORY`★ | master_data.category | V2 | RATE·UTILITY_FEE·COMPLIANCE | 신규 |
| `EVENT_STATUS`★ | event.status | V2 | active·cancelled·(closed) | 신규 |
| `EVENT_CATEGORY`★ | event.category | V23 | (행사 분류 — 값 확인 필요) | 신규 |
| `LAYOUT_STATUS` | layout.status | V3 | draft·submitted·approved·rejected | COMMON_CODES 등재(시드화 필요) |
| `BOOTH_TYPE` | booth.booth_type | V3 | assembled·independent·corner·island | 등재(corner·island 추가) |
| `DESIGN_STATUS` | design_plan.status | V3 | draft·submitted·approved·rejected | 등재(시드화) |
| `UTILITY_ORDER_STATUS` | utility_order.status | V3 | draft·submitted·relayed | 등재(시드화) |
| `RENDER_STATUS` | render_job.status | V3 | QUEUED·RUNNING·DONE·FAILED | 등재(시드화) |
| `SHOT_PRESET` | render_job.shot_preset | V3 | S1~S7 | 등재(시드화) |
| `MILESTONE_TYPE`★ | milestone.milestone_type | V14 | assignment·pre_review·utility·documents·opening | 신규 |
| `DOC_TYPE`★ | document.doc_type | V14 | operation_plan·booth_layout·disaster_plan… | 신규 |
| `APPROVAL_STATUS`★ | document.status, approval.status | V14·V29 | pending·draft·submitted·approved·rejected / DRAFT… | 신규 |
| `DOCK_RESV_STATUS`★ | dock_reservation.status | V15 | (예약 상태) | 신규 |
| `AUCTION_TYPE` | auction.auction_type | V16 | reverse·rfq | 등재(COMMON_CODES §2, 소문자 정합) |
| `AWARD_CRITERIA`★ | auction.award_criteria | V16 | lowest·comprehensive | 신규 |
| `QUOTATION_STATUS` | bid.status | V16 | submitted·revised·awarded·rejected | 등재(확인 필요 → 확정) |
| `VISITOR_TYPE` | visitor_registration.visitor_type | V17 | visitor·buyer·vip | 등재(vip 추가) |
| `CHECKIN_STATE`★ | visitor_registration.checkin_state | V17 | done·waiting·cancelled | 신규 |
| `CAMPAIGN_STATUS`★ | campaign.status | V18 | draft·scheduled·sending·done | 신규 |
| `SPONSOR_CONTRACT_STATUS`★ | sponsorship.contract_status | V18 | signed·pending | 신규 |
| `CONTENT_TYPE`★ | cms_content.content_type | V19 | PAGE·POST·NOTICE·BLOCK | 신규 |
| `CONTENT_STATUS`★ | cms_content.status | V19 | draft·review·approved·published | 신규 |
| `TRANS_STATUS`★ | content_i18n.trans_status | V19 | none·ai·reviewed | 신규 |
| `INQUIRY_TYPE`★ | public inquiry.inquiry_type | V20 | shell·raw·premium·general | 신규 |
| `INQUIRY_STATUS`★ | public inquiry.status | V20 | received·in_review·closed | 신규 |
| `HALL_ASSIGN_STATUS`★ | hall_assignment.status | V25 | assigned·… | 신규 |
| `TENANT_STATUS`★ | tenant.status | V26 | active·onboarding·suspended | 신규 |
| `BOOTH_SALE_STATUS`★ | booth_sale.status | V27 | available·held·sold·blocked | 신규 |
| `INVOICE_CATEGORY`★ | invoice.category | V28 | rental·utility·auction_fee… | 신규 |
| `SETTLEMENT_STATUS`★ | settlement.status | V24 | pending·invoiced·paid·overdue | 신규 |
| `PAYMENT_METHOD`★ | payment_schedule.method | V24 | manual·transfer·card | 신규 |
| `APPROVAL_LINE_STATUS`★ | approval_line.line_status | V29 | WAIT·approved·rejected | 신규 |
| `EQUIP_CATEGORY`★·`RENTAL_STATUS`★·`RENTAL_ITEM_CATEGORY`★·`ORDER_STATUS`★·`FREIGHT_STATUS`★ | V33 logistics 5종 | V33 | forklift…/requested…/furniture…/ordered…/registered… | 신규 |
| `REFUND_STATUS`★·`TAX_INVOICE_STATUS`★ | refund.status·tax_invoice.status | V34 | recorded·settled / issued·void | 신규 |
| `WEBHOOK_EVENT_TYPE`★·`WEBHOOK_STATUS`★ | webhook 등 | V35 | lead.hot…/disabled·queued·sent·failed | 신규 |
| `MAIL_CATEGORY`★·`MAIL_STATUS`★ | mail_log.category·status | V36 | AUCTION_OPEN·EDM·PASSWORD_RESET / sent·failed·skipped | 신규 |
| `VISITOR_GUIDE_CAT`★ | visitor_guide.category | V43 | TRANSPORT·PARKING·ADMISSION·FACILITY·ACCESS·OVERVIEW | 신규 |
| `TRANSPORT_MODE`★ | (관람객 교통 안내 세부) | V43 | (지하철·버스·자가용·KTX 등 — 확인 필요) | 신규 |
> 이미 시드된 그룹(V7·V37): USE_YN·USER_ROLE·VERIFY_METHOD·PRG_TYPE·MSG_RCV_TYPE·WORK_*·SCHE_GUBUN·IMPORTANCE·NOTICE_TYPE·OPINION_STATUS·NOTI_TYPE·REPORT_TYPE·PARTNER_TYPE — 재시드 불필요. **"확인 필요" 코드값(EVENT_CATEGORY·QUOTATION_STATUS·TRANSPORT_MODE 등)은 도메인 에이전트 확정 후 값 고정**(COMMON_CODES §2 규칙).
### 12-3. db-engineer 인계 백로그 (신규 멱등 마이그레이션 — 번호 V44~ 권고)
| ID | 백로그 | 형태 | 우선순위 |
|---|---|---|---|
| **B1** | 감사표 B ★ 신규 그룹 + 상세 공통코드 **순증 시드**(`common_code_group`/`common_code`, `ON CONFLICT DO UPDATE`) + 기존 등재 그룹(LAYOUT_STATUS·DESIGN_STATUS·RENDER_STATUS·SHOT_PRESET 등) 시드화. 컬럼 스키마 불변(코드값=현행 저장값). "확인 필요" 값은 확정분만. | V44(멱등 시드) | **P1** |
| **B2** | 소프트 참조 **논리참조 인덱스**: P1 소프트 참조 컬럼에 `(tenant_id, <ref>_id)` 인덱스(`CREATE INDEX IF NOT EXISTS`). 예: event 참조 자식들의 `(tenant_id, event_id)`, hall 참조의 `(tenant_id, hall_id)`. 이미 있는 idx(idx_lead_event 등)는 스킵. | V45(멱등 인덱스) | **P2** |
| **B3** | 뷰/mview/함수 **순증 후보**(§11): ⓐ `v_booth_display`(부스+코드명 조인·표시용), ⓑ `mv_hall_occupancy`(홀·기간 가동률 — REFRESH CONCURRENTLY·`(tenant_id,hall_key)` 유니크 인덱스), ⓒ `mv_lead_roi_summary`(리드/ROI 월별), ⓓ `fn_period_to_quarter(date)`·`fn_utility_fee(...,ruleset_version)`(재사용 계산). **실사용 화면/AI 근거 확인 후** 생성. | V46(뷰/함수, CREATE OR REPLACE) | P3 |
| **B4** | (멀티테넌트 확장 시) 공통코드 테넌트 오버라이드 `(tenant_id, grp_code, code)` 확장 — 현재 단일 테넌트라 **보류**(구조 대비만). | 후속 | P4 |
| **B5** | (소유자 승인 후) P1 FK **DROP 마이그레이션** — 테넌트 복합키 정합·고아 검증 통과 후에만. 자동 실행 금지. | 승인 대기 | P4 |
| **B6** | **프로시저 후보**(§11-4·§13): `sp_settlement_rollup`(J9)·`sp_kpi_snapshot_build`(J2)·`sp_refresh_marts`(J1 mview 일괄 REFRESH). 성능 결정적일 때만·CREATE OR REPLACE. | V46(뷰와 동반) | P3 |
| **B7** | **자동화 배치**(§13 J1~J9) — 대상·주기·멱등은 본 표 확정. 실행 프레임워크(스케줄러·분산락)는 **아키텍처(kintex-sa/ta) 인계**, 스케줄러 배선 backend-dev. | 인계(아키텍처+backend) | P2 |
---
## 9. 변경 이력
| 버전 | 일자 | 작성자 | 내용 |
|---|---|---|---|
| v1.1 | 2026-07-12 | kintex-data-architect(DA) | **§10 FK 최소화·공통코드 관리 표준**(소유자 지시) — 물리 FK 기본 미설정+화이트리스트 4그룹(W1~W4 RBAC/공통코드 구성), 범주값 공통코드화 정책, 테넌트 스코프 정합, 소프트 참조 무결성 보완책 / **§11 뷰·구체화뷰·함수·프로시저 지침**(`v_*` 실시간 파생·`mv_*` 무거운 집계+REFRESH 전략·`fn_*` 재사용 계산·`sp_*` 집합연산/다단계 트랜잭션·남용 판단 기준) / **§13 자동화 배치 카탈로그**(J1~J9 대상·주기·멱등·잠금 — 실행 프레임워크는 아키텍처 인계) / **§12 감사표 A(현행 FK 약 40건 유지4/제거권고 P1·잔류P3)+B(범주 컬럼 34개→공통코드 그룹 매핑)+db-engineer 백로그 B1~B7(V44 시드·V45 인덱스·V46 뷰·프로시저·배치 인계, 번호 V44~ 권고)**. 기존 마이그레이션 불변·비파괴. |
| 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,373 @@
# 킨텍스 AI 전시·행사시스템 — Open SSO · 인사(조직) API 연동 아키텍처
> 산출: 시스템 아키텍트(SA) · 작성일: 2026-07-12 · 대상: 소유자 지시("직원 인사(조직)정보는 API 방식, Open SSO 도입")
> 정합 근거: [`system.md`](system.md)(SA §5·§6 인증/연동), [`app.md`](app.md)(AA 인증 계약), [`network.md`](network.md)(NA 존/아웃바운드), [`data.md`](data.md)(DA 마스터/소프트참조), [`../PLANNING.md`](../PLANNING.md) §2(6역할)·§5B(공통·시스템관리)·§8, `../DEVELOPMENT_GUIDE.md` §4·§5
> **본 문서는 설계·계약·이행계획만 정본. src·기존 인증코드 미수정(파괴적 구현 금지).** 실제 구현은 인계 백로그(§9)로 각 dev 트랙에 위임.
> 확정 스택(불변): React 18/19(Vite·TS) + Spring Boot 3.x(Java 17)+MyBatis + PostgreSQL(PostGIS) + Redis. 신규 IdP(Keycloak)는 **온프레미스 인프라 구성요소**로 추가(스택 위반 아님).
---
## 0. 요지 (Executive Summary)
| 항목 | 결정 | 근거 |
|---|---|---|
| **인증 프로토콜** | **OIDC(우선) + SAML 2.0(옵션)** — Authorization Code + PKCE | 표준·웹/모바일 공통·JWKS 검증. SAML은 고객사 레거시 IdP 대비 폴백 |
| **IdP** | **Keycloak(자체 호스팅, 온프레미스)** | 오픈소스·OIDC/SAML/그룹·클레임 매핑·2FA·back-channel 로그아웃 내장. 외부 인터넷 API 아님(내부망 IdP) |
| **기존 JWT+2FA 관계** | **삭제·교체 금지. 공존(coexist)** — SSO를 1차 인증, 앱 JWT를 세션토큰으로 **브로커 발급**. 로컬 로그인은 폴백으로 유지 | W12 자산(app_user·event_member·TotpService) 보존, 무중단 이행 |
| **2FA** | IdP가 OTP 제공 시 **위임**(앱 OTP 강제 해제), 미제공 시 앱 TOTP 유지 | 중복 2FA 방지·기존 `TwoFactorService` 재사용 |
| **인사·조직** | 로컬 마스터 유지가 아니라 **`HrDirectoryClient` 어댑터로 외부 HR API 조달** + 로컬 **읽기 캐시 스냅샷**(HR 미가용 폴백) | HR 정본화, dept/sys_user는 캐시로 전환(멱등 upsert 배치) |
| **3자 매핑** | **SSO subject ↔ HR 사번(empNo) ↔ 로컬 user(app_user.id)** 매핑 테이블 신설 | 계정·조직·권한 정합 단일 키 체계 |
| **이행** | **coexist → pilot(파일럿 부서) → cutover(SSO 우선) → 로컬로그인 축소** 4단계 + 롤백 게이트 | 파괴 없는 점진 전환 |
**보안 정합(불변):** Open SSO(자체 IdP)·HR API(고객 내부망 인사시스템)는 **온프레미스/내부 통합 → 외부 인터넷 API 금지 규칙에 해당하지 않음**(Claude/Gemini 예외와 별개 범주). 단, 토큰·클라이언트 시크릿·HR 자격증명은 **env/시크릿 스토어 only**, 응답·로그·커밋 미노출, 마스킹 유지.
---
## 1. 현행(As-Is) 인증·조직 기준선
설계는 기존 코드를 **읽어 확인한 실제 계약** 위에 얹는다(교체 없음).
| 자산 | 실체 | 본 설계에서의 처리 |
|---|---|---|
| `app_user`(V2) | id·email·display_name·password_hash(BCrypt)·hall_manager·otp_secret·failed_login_count·locked_until·status | **보존.** 로컬 계정 정본 → 이행 후 "SSO 매핑 대상 + 로컬 폴백" |
| `event_member`(V2) | 행사 RBAC(ORGANIZER·EXHIBITOR·CONTRACTOR·HALL_MANAGER, booth/company 스코프) | **보존.** IdP는 행사 역할을 모름 → 행사 역할은 계속 로컬 권위 |
| `app_user.role_code`(V31) | 전역 역할(rc 클레임) | IdP 그룹/클레임 → 전역 역할 매핑 소스로 확장 |
| `dept`(V37) | 부서 트리(tenant_id·id·parent_id·sort_order·use_yn) | **HR 정본 시 읽기 캐시로 전환**(스냅샷 upsert) |
| `company`(V2/V37) | 등록업체(M7 응찰 게이트) | 불변(HR 무관 — 외부 파트너) |
| `JwtService` | HS256, 클레임 sub·name·roles(eventId→role)·hm·tid·rc | **불변.** SSO 성공 후 이 서비스로 **동일 형태 앱 JWT를 브로커 발급**(다운스트림 RBAC 무변경) |
| `TwoFactorService`/`TotpService`/`LoginAttemptService` | 로컬 2FA·잠금·챌린지 | IdP 2FA 위임 시 우회, 로컬 폴백 시 유지 |
| `JwtAuthenticationFilter` | Bearer 앱JWT → SecurityContext | **불변.** SSO 도입해도 백엔드 보호경로는 계속 앱 JWT 검증(핵심 무중단 포인트) |
> **설계 핵심 원칙:** SSO는 **로그인 게이트웨이만 교체**한다. 로그인 성공의 산출물은 여전히 "기존 형태의 앱 JWT"이므로, 84개 화면·전 RBAC 가드·행사 스코프 로직은 **한 줄도 바뀌지 않는다**. HR API는 **조직 마스터의 데이터 출처만 교체**한다(dept 트리 소비자 무변경).
---
## 2. 설계 A — Open SSO 도입 (OIDC / Keycloak)
### 2-1. 채택 결정 (ADR)
| # | 결정 | 이유 | 대안·트레이드오프 |
|---|---|---|---|
| A1 | **OIDC 우선**(SAML 옵션) | 웹+모바일(expo-auth-session) 동일 표준, JSON/JWKS, PKCE로 공개 클라이언트 안전 | SAML-only(모바일·SPA 부적합) 배제. 고객 레거시가 SAML뿐이면 Keycloak가 SAML IdP↔OIDC 브로커로 흡수 |
| A2 | **Keycloak 자체 호스팅** | 오픈소스·온프레미스·그룹/클레임 매핑·OTP·Identity Brokering(외부 AD/LDAP/SAML 연합)·back-channel 로그아웃 | 상용 IdP(비용·외부 SaaS=보안 위반) 배제. 경량 대안(자체 OIDC 구현)은 표준 준수·유지비 열위 |
| A3 | **브로커드 세션토큰**(IdP 토큰 → 앱 JWT 교환) | 다운스트림 RBAC·행사 스코프·tid/rc 클레임 무변경, 무상태 유지 | IdP 액세스토큰 직접 신뢰(리소스서버 모드)는 **행사 RBAC 클레임 부재**로 전 가드 재작성 필요 → 이행기엔 배제(§2-6 장기 옵션으로만) |
| A4 | **로컬 로그인 폴백 유지** | IdP 장애 시 관리자·핵심 운영 지속(가용성 NFR) | IdP 단일 의존(장애 시 전면 로그인 불가) 배제 |
**IdP 후보 근거:** Keycloak(1순위, 위 A2) / 대안 Authentik·ZITADEL(경량·최신 UX이나 조직 내 운영 실적·AD 연합 성숙도에서 Keycloak 우위) / Gluu·Ory Hydra(각각 무거움·인증 파트 분리 필요). **킨텍스 온프레미스·AD 연합·SAML 흡수** 요건에서 Keycloak 채택.
### 2-2. 토폴로지 (존 배치)
Keycloak는 **내부 애플리케이션 존**에 배치하고, 브라우저 리다이렉트를 위해 **리버스 프록시(nginx)의 별도 vhost**(`auth.<kintex-domain>`)로만 외부 노출한다. HR API·AD 연합은 IdP↔내부망 구간.
```mermaid
graph TB
subgraph EDGE["엣지 / DMZ (nginx TLS 종단)"]
RP[리버스 프록시<br/>app.· auth.· api. vhost]
end
subgraph APPZ["애플리케이션 존 (내부망)"]
FE[역할별 프론트 SPA + 공개/관람객]
BE[공유 백엔드 Spring Boot × N<br/>OIDC RP + 앱JWT 브로커]
KC[Keycloak IdP<br/>realm=KINTEX · OIDC/SAML · OTP]
end
subgraph DATAZ["데이터 존 (최심부)"]
PG[(PostgreSQL+PostGIS<br/>app_user·매핑·조직 캐시)]
KCDB[(Keycloak DB<br/>별도 스키마/DB)]
RED[(Redis<br/>state·nonce·세션상관)]
end
subgraph HRZONE["고객 내부망 (연동 대상)"]
AD[AD / LDAP<br/>직원 계정]
HRAPI[HR 인사시스템 API<br/>조직·직원 마스터]
end
FE -->|1 로그인 리다이렉트| RP --> KC
KC -.연합(선택).-> AD
FE -->|2 code+PKCE 콜백| RP --> BE
BE -->|3 code→token 교환·JWKS 검증| KC
BE -->|4 앱 JWT 발급| FE
BE --> PG
BE -.HrDirectoryClient(배치·조회).-> HRAPI
KC --> KCDB
BE --> RED
```
**배치 규칙**
- Keycloak DB는 앱 PG와 **별도 데이터베이스/스키마**(계정 데이터 격리·백업 주기 분리).
- `auth.<kintex-domain>` vhost는 IdP UI/토큰 엔드포인트만 프록시. 백오피스(admin.)와 동일하게 **접근 제한 정책**은 NA 확정.
- 아웃바운드: Keycloak→AD/LDAP, 백엔드→HR API는 **내부망 구간**(인터넷 egress 아님). NA egress 화이트리스트에 인터넷 목적지 추가 없음.
### 2-3. 로그인 시퀀스 (Authorization Code + PKCE → 앱 JWT 브로커)
```mermaid
sequenceDiagram
participant U as 사용자(브라우저/앱)
participant FE as 프론트(SPA/Expo)
participant BE as 백엔드(OIDC RP)
participant KC as Keycloak(IdP)
U->>FE: 로그인 클릭
FE->>FE: PKCE code_verifier/challenge, state, nonce 생성
FE->>KC: /authorize (code, PKCE challenge, redirect_uri)
KC->>U: 로그인 폼(+IdP OTP if 위임)
U->>KC: 자격증명(+OTP)
KC->>FE: redirect_uri?code=...&state=...
FE->>BE: POST /api/auth/oidc/callback (code, code_verifier, state)
BE->>KC: token 교환(code + code_verifier + client_secret)
KC->>BE: id_token + access_token (JWT)
BE->>KC: JWKS 공개키(캐시) — id_token 서명·iss·aud·nonce·exp 검증
BE->>BE: subject(sub)로 매핑 조회 → 로컬 user 해석/JIT 프로비저닝(§2-5)
BE->>BE: event_member 행사역할·hall_manager·tid·rc 조립
BE->>FE: 앱 JWT(JwtService.issue) + 워크스페이스(기존 LoginResponse 형태)
FE->>FE: 기존 저장키에 앱 JWT 저장 — 이후 전 API는 기존 그대로
```
**핵심:** 5~7단계 산출물 = **현행 `LoginResponse`와 100% 동일**. `/api/auth/oidc/callback`만 신설, 나머지 전 경로 불변.
### 2-4. 토큰 갱신·로그아웃 시퀀스
| 흐름 | 설계 |
|---|---|
| **앱 JWT 갱신** | 현행 TTL 정책 유지. 만료 시 프론트가 **silent OIDC 재인증**(prompt=none, IdP 세션 유효 시 무마찰) → 백엔드가 앱 JWT 재브로커. 리프레시 토큰은 백엔드가 보관(HttpOnly·서버측), 프론트 미노출 |
| **IdP 세션 만료** | silent 재인증 실패 → 로그인 화면. 앱 JWT 짧게, IdP 세션이 마스터 수명 |
| **로그아웃** | 프론트 로컬 토큰 파기 + `/api/auth/oidc/logout` → Keycloak end-session(`id_token_hint`). **back-channel logout**: Keycloak → 백엔드 `/api/auth/oidc/backchannel-logout`(로그아웃 토큰 검증) → Redis 세션상관 무효화(다중 인스턴스 팬아웃) |
| **단일 로그아웃(SLO)** | Keycloak realm SSO 로그아웃으로 연동 클라이언트 전파(웹·모바일·타 GUARDiA 연계 시) |
### 2-5. 계정 매핑 · JIT 프로비저닝 (SSO subject ↔ 로컬 user)
```mermaid
graph LR
SUB[OIDC sub<br/>=IdP subject] --> MAP[user_identity 매핑]
EMP[HR empNo<br/>=사번] --> MAP
LU[app_user.id<br/>=로컬 user] --> MAP
MAP --> RESOLVE{로컬 user 존재?}
RESOLVE -->|Yes| LOGIN[역할 조립 → 앱 JWT]
RESOLVE -->|No| JIT[JIT 프로비저닝<br/>app_user upsert + 매핑 생성]
JIT --> LOGIN
```
- **매핑 키:** IdP `sub`(불변 식별자) 우선. 보조 매칭키 = 이메일 + HR 사번(empNo, IdP 커스텀 클레임). **이메일 단독 매칭 금지**(재사용·변경 위험) — sub↔empNo 확정 후 email은 표시용.
- **JIT 프로비저닝:** 첫 SSO 로그인 시 매핑 없으면 `app_user`**password_hash 없이(SSO 전용 플래그)** 생성 + `user_identity` 매핑 생성. 로컬 폴백 비번은 미설정(SSO 전용 계정).
- **충돌 규칙:** 동일 이메일에 기존 로컬 계정 존재 → **자동 병합 금지, 관리자 수동 링크**(계정 탈취 방지). `link_status`(UNLINKED/LINKED/CONFLICT).
- **역할 소스:** 전역 역할(rc)·hall_manager = IdP 그룹/클레임 매핑(§2-7). **행사 역할(event_member)은 로컬 권위 유지**(IdP는 행사를 모름).
### 2-6. 기존 JWT+2FA 공존안 (핵심)
| 관심사 | 공존 규칙 |
|---|---|
| **1차 인증** | Keycloak(OIDC). 성공 후 백엔드가 **기존 `JwtService.issue()`로 앱 JWT 브로커** — 다운스트림 무변경 |
| **로컬 로그인** | `/api/auth/login`·`/login/secure` **유지**(폴백). 관리자·핵심 운영은 IdP 장애 시 로컬 폴백 허용. 일반 사용자는 이행 단계별로 SSO 우선/로컬 차단(§8) |
| **2FA** | IdP realm이 OTP 요구 시 → 앱 `requiresOtp` 위임(앱 OTP 강제 해제, 이중 2FA 방지). IdP OTP 미구성 계정·로컬 폴백 로그인 → **기존 `TwoFactorService` 그대로** |
| **admin 비번** | `ADMIN_PASSWORD_ENC` env 시드 정책 **유지**(IdP 장애 시 최후 관리자 접근) |
| **토큰 신뢰 모드** | 이행기 = **브로커 모드**(앱 JWT). 장기 옵션 = 리소스서버 모드(IdP 토큰 직접, 커스텀 클레임 매퍼로 행사역할 주입) — **행사 RBAC 재검증 부담으로 이행 완료 후 별도 과제**(A3) |
**공존 상태 판정 로직(개념):**
```
로그인 요청
├─ SSO 경로(/oidc/callback): IdP 검증 성공 → 매핑 해석 → 앱 JWT (2FA는 IdP 위임)
└─ 로컬 경로(/login, /login/secure): 기존 TwoFactorService (단계별 허용범위 §8)
다운스트림(전 API): 기존 JwtAuthenticationFilter가 앱 JWT만 검증 — 경로 무관 동일
```
### 2-7. 멀티테넌트·역할 매핑
- **tenant:** realm=단일(`KINTEX`) 또는 realm 그룹 클레임 `tenant`. 앱 `tid` 클레임 = IdP `tenant` 클레임(부재 시 기준 테넌트 `KINTEX` 폴백, 현행 `normalizeTenant` 규칙 준수).
- **역할 매핑표(IdP 그룹/클레임 → 앱 역할):**
| IdP 그룹/클레임 | 앱 전역 역할(rc) | hall_manager | 트랙 |
|---|---|---|---|
| `/kintex/admin` | ADMIN | - | 내부 백오피스 |
| `/kintex/manager` | MANAGER | - | 내부 운영 |
| `/kintex/hall-manager` | (rc null) | true | 내부 운영(홀) |
| `/kintex/staff` | STAFF | - | 내부 직원 |
| (매핑 없음·외부) | null | false | 행사역할은 event_member로 |
- **6역할 정합:** 주최자·참가업체·장치/공사업체는 대개 **외부**(HR 대상 아님) → SSO 계정이라도 행사 역할은 `event_member` 초대로만 부여. 관람객/일반대중은 셀프서비스(SSO 대상 아님·별도 트랙 §8 note).
### 2-8. 웹 + 모바일 플로우
| 클라이언트 | 라이브러리 | 리다이렉트 URI | 특이사항 |
|---|---|---|---|
| **웹 SPA(역할별 6종)** | `oidc-client-ts` 또는 백엔드 BFF 콜백 | `https://<role>.<kintex-domain>/auth/callback` | PKCE. 역할별 번들마다 클라이언트 등록(또는 와일드카드 redirect + 역할 라우팅) |
| **모바일(Expo)** | `expo-auth-session`(PKCE 기본) | `kintexai://auth/callback` (앱스킴) + `https://<domain>/auth/native-callback`(유니버설 링크 폴백) | 공개 클라이언트(시크릿 없음)=PKCE 필수. B2B 앱=SSO+IdP OTP, B2C 관람객 앱=SSO 대상 아님(셀프서비스 유지) |
- **클라이언트 등록(Keycloak):** `kintex-web`(confidential, BFF 콜백) / `kintex-mobile`(public, PKCE). redirect_uri·web_origins(CORS)·post_logout_redirect 화이트리스트 등록.
- **모바일 레퍼런스:** WISE 모바일(`workspace/guardia-messenger/app/uiws`) 2FA 로그인 화면 컨벤션 위에 expo-auth-session 브라우저 플로우 삽입(디자인은 Stitch 경유 — MEMORY 준수).
---
## 3. 설계 B — 인사(조직) API 연동 (HrDirectoryClient)
### 3-1. 채택 결정 (ADR)
| # | 결정 | 이유 |
|---|---|---|
| B1 | **어댑터 패턴 `HrDirectoryClient` 인터페이스 + 고객 HR 구현** | 고객사별 HR API 상이 → 인터페이스로 격리, 구현만 교체. Mock 구현으로 개발·폴백 |
| B2 | **로컬 읽기 캐시 스냅샷 유지**(HR 미가용 폴백) | HR 장애·야간 배치 실패에도 조직도·직원조회 지속(가용성). HR=정본, 로컬 dept/직원=파생 캐시 |
| B3 | **증분 동기화(배치) + 실시간 조회 혼합** | 조직 트리·직원 마스터는 배치 스냅샷(멱등 upsert), 로그인 순간 신원 확인은 실시간 조회(선택) |
| B4 | **PII 최소 수집·마스킹** | 직원 개인정보 경계(SA §5-3). 필요 필드만 수집, 연락처·주민식별 미수집/마스킹 |
### 3-2. 어댑터 계약 (인터페이스 · DTO)
**인터페이스(개념 시그니처 — backend-dev 구현):**
| 메서드 | 목적 | 동기성 |
|---|---|---|
| `List<HrDept> fetchOrgTree(tenantId, sinceRev?)` | 조직(부서) 트리 — 전체 또는 변경분 | 배치 |
| `Page<HrEmployee> fetchEmployees(tenantId, sinceRev?, page)` | 직원 마스터 — 전체/증분 페이지 | 배치 |
| `Optional<HrEmployee> findByEmpNo(tenantId, empNo)` | 로그인·매핑 시 단건 신원 확인 | 실시간(선택) |
| `SyncCursor currentRevision(tenantId)` | 증분 동기화 커서(변경 기준점) | 배치 |
**표준 DTO(계약·PII 최소):**
```
HrDept { empDeptCode, deptName, parentDeptCode, sortOrder, useYn, revision }
HrEmployee {
empNo, // 사번 = 매핑 정본 키
name, // 표시명
email, // SSO sub 보조 매칭·표시
deptCode, // 소속 부서(HrDept.empDeptCode 참조)
positionCode, // 직급(공통코드 POSITION 매핑)
employmentStatus, // 재직상태 ACTIVE|LEAVE|RETIRED
revision // 증분 동기화 기준
// 미수집: 주민번호·개인 연락처·급여 등 (PII 최소)
}
```
- **원격/캐시 이원화:** 서비스 계층은 항상 `HrDirectoryClient`를 부르되, HR 실패 시 `HrSnapshotRepository`(로컬 캐시)로 폴백(`degraded=true` 표기). 소비자(조직도·직원선택 컴포넌트)는 출처를 모름.
### 3-3. 동기화 전략
```mermaid
sequenceDiagram
participant SCH as 스케줄러(배치)
participant HC as HrDirectoryClient
participant HR as 고객 HR API
participant SNAP as 로컬 스냅샷(hr_dept_snapshot·hr_emp_snapshot)
participant DEPT as dept(파생 캐시)
SCH->>HC: fetchOrgTree(sinceRev) / fetchEmployees(sinceRev)
HC->>HR: GET 조직·직원 변경분
alt HR 정상
HR->>HC: 변경 레코드
HC->>SNAP: 멱등 upsert(revision 기준)
SNAP->>DEPT: dept 트리 파생 반영(use_yn·parent 매핑)
else HR 미가용
HC->>SNAP: 기존 스냅샷 유지(degraded)
Note over DEPT: 조직도·직원조회는 마지막 스냅샷으로 계속 서비스
end
```
- **주기:** 조직 트리·직원 = 야간 전체 리컨실 + 주간(수시) 증분. 로그인 시점 신원은 `findByEmpNo` 실시간(선택, 실패 시 스냅샷).
- **멱등:** `revision`/`empNo`/`empDeptCode` 기준 upsert. 삭제는 **소프트**(useYn='N', employmentStatus=RETIRED) — 물리삭제 금지(감사·과거 참조 보존).
- **정본 전환:** HR 정본화 후 `dept`·직원정보는 **읽기 전용 캐시**. 백오피스 조직 편집 UI는 "HR 마스터 편집 안내"로 전환(직접 수정 차단), 단 로컬 전용 부서(외부 파트너 그룹핑 등)는 `source='LOCAL'` 플래그로 공존 허용.
### 3-4. 기존 dept/sys_user 매핑
| 현행 | HR 연동 후 |
|---|---|
| `dept`(로컬 편집) | `source` 컬럼 추가 → `HR`(캐시, 읽기전용) / `LOCAL`(로컬 전용, 편집가능) 공존. HR 파생행은 배치만 갱신 |
| `sys_user`(=app_user 조회) | 직원 신원(name·dept·position)은 **HR 스냅샷 조인**으로 표시, `app_user`는 로그인/권한 최소필드만 |
| 조직도 화면(WISE 트리) | 데이터 출처만 HR 스냅샷 → 컴포넌트 무변경 |
---
## 4. 3자 매핑 통합 규칙 (SSO subject ↔ HR 사번 ↔ 로컬 user)
| 키 | 소유 | 역할 |
|---|---|---|
| `sub`(OIDC subject) | Keycloak | 로그인 신원 불변 식별자 |
| `empNo`(사번) | HR API | 직원·조직 정본 키(직급·부서·재직상태) |
| `app_user.id` | 로컬 | 앱 계정·권한(event_member·hall_manager·tid) 앵커 |
- **연결 테이블 `user_identity`**(§5): (tenant_id, app_user_id) ↔ (idp_sub, emp_no) + link_status.
- **로그인 해석 순서:** `sub` → user_identity 조회 → app_user 해석 → (선택) empNo로 HR 스냅샷 조인(부서·직급 최신화) → 역할 조립 → 앱 JWT.
- **불일치 처리:** sub 있으나 empNo 없음(외부 SSO 사용자) 허용 / empNo 있으나 sub 없음(미로그인 직원) 허용(사전 프로비저닝) / 이메일 충돌 = CONFLICT(관리자 수동 링크).
---
## 5. 데이터 모델 (신설 — DA 정합, 소프트참조 표준)
> 신규 테이블만. **기존 테이블 변경 최소**(dept에 `source` 컬럼 순증만). 테넌트 표준(V31: tenant_id DEFAULT 'KINTEX' 복합 PK 선두)·소프트참조(FK 남발 금지, 애플리케이션 조인) 준수. 실제 DDL은 db-engineer 인계(§9).
| 테이블 | 목적 | 핵심 컬럼(개념) |
|---|---|---|
| `user_identity` | 3자 매핑 | (tenant_id, app_user_id) PK, idp_sub UNIQUE, emp_no, link_status, provider, linked_at |
| `hr_dept_snapshot` | HR 조직 캐시 | (tenant_id, emp_dept_code) PK, dept_name, parent_dept_code, sort_order, use_yn, revision, synced_at |
| `hr_emp_snapshot` | HR 직원 캐시 | (tenant_id, emp_no) PK, name, email, dept_code, position_code, employment_status, revision, synced_at |
| `hr_sync_log` | 배치 감사 | (tenant_id, id) PK, kind(DEPT/EMP), rows_upserted, result, degraded, started_at, finished_at |
| `dept`(순증) | 출처 구분 | `source varchar(10) DEFAULT 'LOCAL'`('HR'|'LOCAL') 컬럼 추가(멱등 ALTER) |
- **PII 경계:** hr_emp_snapshot는 name·email·dept·position·status만. 연락처·식별번호 미보관. email은 표시·매칭용(마스킹은 로그·감사 계층).
- **소프트참조:** emp_no·emp_dept_code·idp_sub는 **소프트 참조**(FK 미설정) — HR/IdP 외부 키를 물리 FK로 묶지 않음(배치 순서·정합 유연성). 공통코드(POSITION·EMPLOYMENT_STATUS)는 common_code 순증.
---
## 6. NFR 정합 (SA system.md §3)
| NFR | SSO/HR 반영 |
|---|---|
| **확장성** | 백엔드 무상태 유지(OIDC state/nonce는 Redis 외부화). Keycloak는 클러스터 가능. HR 배치는 백엔드와 분리 스케줄(응답성 무영향) |
| **가용성/HA** | **로컬 로그인 폴백**(IdP 장애)·**HR 스냅샷 폴백**(HR 장애) 이중 degraded 모드로 코어 기능 유지. Keycloak 최소 2노드 + DB HA |
| **성능** | JWKS 공개키 캐시(매 요청 IdP 미호출). 로그인만 IdP 왕복, 이후 앱 JWT 로컬 검증(현행 성능 무변경). HR은 배치 오프라인 |
| **보안영역** | Keycloak=내부 애플리케이션 존, `auth.` vhost만 노출. HR/AD=내부망 구간(인터넷 egress 무증가). 토큰·시크릿 env only |
| **용량** | user_identity·hr_*_snapshot는 직원 규모(수백~수천) 소량. hr_sync_log 보존정책(N개월) |
---
## 7. 보안 · 개인정보 (불변 준수)
- **온프레미스 판정:** Keycloak(자체 IdP)·HR API(고객 내부 인사시스템)는 내부망 통합 → **외부 인터넷 API 금지 규칙 비해당**. NA egress 화이트리스트에 인터넷 목적지 신규 추가 없음.
- **시크릿:** OIDC client_secret·HR API 자격증명·리프레시 토큰 = **env/시크릿 스토어 only**(systemd EnvironmentFile drop-in, `ADMIN_PASSWORD_ENC` 방식 준용). DB·코드·로그·커밋·API응답 미기록.
- **토큰 노출 금지:** id_token/access_token/refresh_token은 응답 본문·로그 미노출. 프론트에는 앱 JWT만(리프레시는 서버 보관).
- **감사:** 계정 링크/언링크·JIT 프로비저닝·HR 배치·권한 매핑 변경 = `TB_AUDIT_LOG` 전수. 로그인 성공/실패는 기존 `login_history`(이메일/IP 마스킹) 재사용.
- **PII 최소화:** HR 수집 필드 최소, 삭제권·재직상태 반영(RETIRED 소프트), 보존정책 DA 확정.
- **검증:** id_token 서명(JWKS)·iss·aud·exp·nonce, code 교환 시 code_verifier(PKCE), state(CSRF), back-channel logout token 검증 필수.
---
## 8. 이행 계획 (coexist → pilot → cutover → 롤백)
| 단계 | 범위 | 로컬 로그인 | 완료 기준(게이트) | 롤백 |
|---|---|---|---|---|
| **0. 준비** | Keycloak 배치(realm/클라이언트)·매핑 테이블·HrDirectoryClient(Mock)·백엔드 `/oidc/*` 엔드포인트(피처플래그 `sso.enabled=false`) | 전면 허용(현행) | staging OIDC 왕복·앱 JWT 브로커·회귀 0 | 플래그 off = 완전 현행 |
| **1. coexist** | SSO 로그인 **옵션** 노출(로그인 화면 "SSO로 로그인" 병행). JIT 프로비저닝·수동 링크 UI | 전면 허용 | 파일럿 계정 SSO 로그인·역할 정합·2FA 위임 검증 | SSO 버튼 숨김 |
| **2. pilot** | **1개 부서(내부 직원)** SSO 우선 + HR 배치 실연동(스냅샷→dept). 로컬은 폴백만 | 파일럿 외 허용, 파일럿은 폴백 | HR 스냅샷 정합·조직도 HR 출처·degraded 폴백 동작 | 파일럿 부서 로컬 복귀 |
| **3. cutover** | 전 내부 직원 SSO 필수(로컬은 관리자·비상만). HR 정본화(dept 읽기전용) | 관리자·비상만 | 전 직원 SSO·HR 정본·이중 2FA 없음·감사 완비 | IdP 장애 시 로컬 폴백 자동 |
| **4. 정착** | 로컬 비번 로그인 축소(SSO 전용 계정 비번 미발급). 리소스서버 모드 전환은 별도 과제(A3) | 최소(비상 관리자) | 운영 안정·SLO 충족 | — |
- **무중단 원칙:** 각 단계는 **피처플래그**로 제어, 언제든 이전 단계 복귀. 다운스트림(앱 JWT 소비)은 전 단계 불변 → 화면·API 회귀 0이 롤백 안전판.
- **관람객/외부(B2C):** SSO 이행 대상 아님 — 셀프서비스(간편가입·게스트) 트랙 유지. 본 이행은 **내부 직원·조직** 한정.
---
## 9. 인계 백로그 (트랙별)
> 본 문서는 설계 정본. 아래는 각 dev 트랙 착수 항목. **모두 피처플래그 뒤에서 순증 구현**(기존 인증코드 미파괴).
| 트랙 | 항목 | 산출 |
|---|---|---|
| **backend-dev** | ① OIDC RP: `/api/auth/oidc/authorize-info·callback·logout·backchannel-logout`, JWKS 캐시·id_token 검증 ② 콜백 성공 후 **기존 `JwtService.issue()` 재사용**한 앱 JWT 브로커 ③ `HrDirectoryClient` 인터페이스 + Mock 구현 + 고객 어댑터 스텁 ④ 매핑 해석·JIT 프로비저닝·수동 링크 서비스 | 신규 패키지 `auth/oidc`, `hr/` (기존 `auth`·`security` 불변) |
| **common-dev(인증 레이어)** | ① 로그인 화면 SSO 버튼(피처플래그)·expo-auth-session(모바일) ② `TwoFactorService.requiresOtp` IdP 위임 분기(설정형) ③ 계정 링크/언링크 마이페이지·관리자 링크 UI | 인증 공통 레이어 |
| **db-engineer** | ① `user_identity`·`hr_dept_snapshot`·`hr_emp_snapshot`·`hr_sync_log` DDL(V38+, 멱등·테넌트 표준·소프트참조) ② `dept.source` 순증 ALTER ③ common_code POSITION·EMPLOYMENT_STATUS 순증 | Flyway 마이그레이션 |
| **mobile** | expo-auth-session PKCE 플로우 + B2B 앱 SSO/IdP OTP, WISE 모바일 로그인 컨벤션 위 삽입, 디자인 Stitch 경유 | `mobile/` 로그인 |
| **devops** | ① Keycloak 배치(systemd/컨테이너·별도 DB·realm import) ② `auth.<domain>` nginx vhost·TLS·접근제한 ③ client_secret·HR 자격증명 env drop-in(시크릿 스토어) ④ HR 배치 스케줄 | 인프라(G2 연계) |
| **planner(인계)** | PLANNING §5B/§8에 **Open SSO·HR API 연동** 반영(6역할·인증 스택·조직 마스터 출처 갱신). 관람객 SSO 제외 명시 | PLANNING 개정 |
| **NA/DA(정합)** | NA: `auth.` vhost·IdP↔AD/HR 내부망 구간 존 반영 / DA: hr_*_snapshot·user_identity ERD·PII 보존정책 편입 | network.md·data.md 갱신 |
---
## 10. 리스크
| # | 리스크 | 완화 |
|---|---|---|
| R1 | IdP 단일 장애 → 전면 로그인 불가 | 로컬 로그인 폴백 유지(§2-6)·Keycloak HA·앱 JWT 수명으로 순간 장애 흡수 |
| R2 | 이메일 재사용·계정 병합 오류 → 권한 탈취 | sub↔empNo 확정 매핑, 이메일 단독 병합 금지, CONFLICT 수동 링크·감사 |
| R3 | HR API 스펙 상이·미제공 | `HrDirectoryClient` 어댑터로 격리, Mock·스냅샷 폴백으로 개발·운영 지속 |
| R4 | 이중 2FA(IdP+앱) 마찰 | IdP OTP 위임 시 앱 OTP 강제 해제(설정형 분기) |
| R5 | 행사 RBAC 클레임 누락(리소스서버 직접 신뢰 시) | 이행기 브로커 모드 고정(앱 JWT에 event_member 조립). 직접 신뢰는 정착 후 별도 과제 |
| R6 | HR 정본 전환 후 로컬 조직 편집 상실 | `dept.source=LOCAL` 공존 허용(외부 파트너 그룹핑 등 로컬 전용 유지) |
| R7 | 토큰/시크릿 노출 | env only·서버측 리프레시 보관·응답/로그 미노출·JWKS 검증 |
---
## 11. 변경 이력
| 버전 | 일자 | 작성자 | 내용 |
|---|---|---|---|
| v1.0 | 2026-07-12 | SA | 최초 작성 — Open SSO(OIDC/Keycloak, PKCE, 브로커드 앱JWT 공존, 로컬 폴백, 2FA 위임, 6역할/멀티테넌트 매핑, 웹+모바일) + 인사·조직 API 연동(HrDirectoryClient 어댑터·스냅샷 폴백·증분 동기화·PII 최소) + 3자 매핑(sub↔empNo↔app_user) + 데이터 모델·NFR·보안·이행(coexist→pilot→cutover→롤백)·인계 백로그·리스크. 기존 W12 인증(app_user·event_member·TotpService·JwtService) 미수정 공존 설계. system.md/app.md/network.md/data.md 교차참조 |

View File

@ -299,6 +299,7 @@ graph TB
- 역할별 프론트는 동일 SSO·API 계약을 상속하되 **번들·도메인 분리**. 백오피스(admin.)는 내부 전용·2FA 필수(§5-1).
- 등록업체 계정은 **M7 등록업체 DB 검증** 통과분만 초대·옥션 응찰. 관람객·일반대중은 셀프서비스(쓰기 제한).
- 인증 상세 계약은 [`app.md`](app.md)(AA)·`../DEVELOPMENT_GUIDE.md` §4.
- **Open SSO(OIDC/Keycloak) 도입 + 인사·조직 API 연동**은 별도 정본 [`sso-hr-integration.md`](sso-hr-integration.md)(SA) — 기존 JWT+2FA를 삭제 없이 **공존(SSO 1차 인증→앱 JWT 브로커, 로컬 폴백)**, 직원·조직은 `HrDirectoryClient` 어댑터로 조달(로컬 스냅샷 폴백). 관람객/외부는 SSO 제외(셀프서비스 유지).
### 6-2. 외부 게이트 (아웃바운드 통제)
@ -339,6 +340,7 @@ graph TB
- 기획 정본: [`../PLANNING.md`](../PLANNING.md) §2·§7·§8·§10
- 구현 백로그: [`../IMPLEMENTATION_BACKLOG.md`](../IMPLEMENTATION_BACKLOG.md) Phase A~E
- **Open SSO · 인사(조직) API 연동(SA)**: [`sso-hr-integration.md`](sso-hr-integration.md) — OIDC/Keycloak·HrDirectoryClient·3자 매핑·이행(coexist→cutover)
- 나노바나나 파이프라인 근거: [`../analysis/reroomai-source.md`](../analysis/reroomai-source.md)
- 애플리케이션 아키텍처(AA·A-1): [`app.md`](app.md) — 모듈 경계·API 표준·패키지
- 기술 표준(TA·A-3): [`tech.md`](tech.md) — 빌드·배포·관측성·AiTextRouter

View File

@ -1,6 +1,6 @@
# 킨텍스 AI 전시관리 시스템 — UI 디자인 스펙 (design.md)
> 작성: 디자인 에이전트(designer) · 작성일: 2026-07-12 · 버전: **v2.2**
> 작성: 디자인 에이전트(designer) · 작성일: 2026-07-12 · 버전: **v2.3.2**
> 근거 문서: `docs/PLANNING.md` **v3.0**(모듈 맵 M1~M18 + §5B 공통/시스템관리 레이어 + §1A 멀티테넌시, 6+2 역할, 나노바나나 표준 샷 세트 S1~S7) · `docs/FEATURE_BACKLOG_100.md`(100대 기능 F001~F100·NFR 4축) · Stitch 산출물 `stitch_kintex_ai_system_architect/`(18화면 + DESIGN.md 3변형) 학습·정합
> **단일 출처(Single Source of Truth)**: 프론트 구현 토큰·컴포넌트 계약은 본 문서가 권위다. Stitch 화면(code.html/screen.png)은 시각 참조로만 사용하며, 상충 시 본 문서를 따른다.
> 용도: **각 화면의 "Stitch 프롬프트" 코드블록을 Google Stitch(stitch.withgoogle.com)에 그대로 붙여넣어 화면을 생성**한다. 문서는 한국어, Stitch 프롬프트만 영어.
@ -418,7 +418,7 @@ An enterprise MDI (multiple document interface) application shell for "KINTEX AI
| SCR | 화면명 | 모듈 | 역할 | 라우트 | 상태 |
|---|---|---|---|---|---|
| SCR-T0 | 트랙 관문 랜딩(3-트랙 분기) | 공통·M12 | 대 | `/` | 🔲미생성 |
| SCR-T1 | visitor 서브홈(가이드 허브) | M10·M13·M12 | 대 | `/visitor` | 🔲미생성 |
| SCR-T1 | visitor 메인(공개 홈·AI 관람 도우미) | M10·M11·M13·M12·AI | 대 | `/visitor` | 🔲미생성 |
| SCR-T2 | business 서브홈(비즈니스 진입) | M1·M3·M4·M6 | 대 | `/business` | 🔲미생성 |
| SCR-T3 | agency 서브홈(등록업체·옥션) | M15·안전경영 | 협·대 | `/agency` | 🔲미생성 |
@ -1494,23 +1494,56 @@ A public "check & cancel my ticket booking" page for an exhibition, Korean marke
A public landing "track gateway" home page for the product "KINTEX AI 전시·행사시스템" (an AI exhibition & event operations platform), Korean, desktop 1440px, light theme, primary blue #0066B3, purple #6D4AFF for AI accents, clean modern marketing design (not an MDI app shell). All UI text in Korean; use line (stroke) SVG icons only, NO emoji. Top: a public header with a "KINTEX AI 전시·행사시스템" wordmark on the left, a 3-tab track switcher "관람 · 비즈니스 · 협력사" (none active on this gateway), and right utilities "언어(한/영/중/일) · 로그인". Full-bleed hero with a photorealistic exhibition-hall render as background (with a faint watermark "AI 생성 예상 이미지" and a dim overlay for text contrast), a large headline "KINTEX AI 전시·행사시스템", a subheading "전시 기획부터 시공·관람까지 전 과정을 AI로 자동화", and two CTAs "관람 사전등록" and "참가 문의". Directly below the hero: three large branching track cards in one row — card 1 "관람객" (slightly larger, primary) with a stroke icon, text "행사 일정 · 사전등록 · 오시는 길" and a button "관람 트랙 →"; card 2 "주최 · 참가 (비즈니스)" with text "대관 · 자동견적 · 부스 신청 · 시공 시안" and a button "비즈니스 트랙 →"; card 3 "공사 · 협력사 (에이전시)" with text "등록업체 · 공사 옥션 · 작업신고" and a button "협력사 트랙 →". Below: a horizontal scrolling rail of event poster cards (poster, title, dates, hall, a "D-25" day chip, "사전등록" button) titled "진행 중 · 예정 전시". Footer with company info and "오시는 길 · GTX-A 킨텍스역". Spacious, multilingual, conversion-oriented gateway aesthetic; rounded 8px cards, WCAG AA contrast.
```
### SCR-T1 visitor 서브홈 (관람객 공개 홈 · 가이드 허브) [visitor · 공개 · `/visitor`]
### SCR-T1 visitor 메인 (관람객 공개 홈 · AI 관람 도우미 중심 · 세련된 마케팅 홈페이지) [visitor · 공개 · `/visitor`]
- **대상**: 일반 대중·관람객(게스트+회원) · **모듈/P**: M10(사전등록·티켓)·M13(실내 길찾기)·M12(공지) · P1 · **진입 경로**: 관문 SCR-T0 "관람 트랙" · TrackSwitcher "관람" · 직접 `/visitor`
- **레이아웃**(위→아래, PublicShell·SCR-P1 확장):
1. **PublicShell 헤더 + TrackSwitcher**("관람" 활성)
2. **관람 히어로 + 주 CTA**: 관람객 톤 히어로 + "사전등록"·"입장권 예매"(→ SCR-P4·P7) 이중 CTA + 맞춤 알림 유도("관심 분야 알림 받기")
3. **행사 일정 그리드**(SCR-P1 행사 카드 재사용): 진행 중·예정 행사 카드 그리드(포스터·행사명·기간·홀·상태 배지·"사전등록")+전시장 세그먼트 필터(전체/홀, 동적 옵션)
4. **방문 가이드 허브**(코엑스 가이드 차용 — 2×3 카드): ① 교통(GTX-A 킨텍스역 도보 3분·광역버스) ② 주차(iparking 실시간·요금·게이트) ③ **실내 길찾기**(M13 wayfinding 딥링크) ④ 편의시설(식음·수유실·물품보관·ATM) ⑤ 접근성·편의(휠체어·수어통역 고지) ⑥ 고객문의(FAQ·연락처). 각 카드 선 SVG 아이콘+1줄 설명+링크
5. **공지·알림마당**: 최근 공지 5건 리스트(§5B notice 재사용)+더보기
6. **PublicShell 푸터**
- **구성요소**: HeroBanner·EventGrid(P1 카드)·GuideHubCard×6·NoticeList·LanguageSelector·CTA(사전등록·티켓·알림)
- **데이터 원천**: 행사 그리드 = `/api/public/events`(SCR-P1 자산 재사용) · 사전등록/티켓 CTA = SCR-P4/P7 딥링크 · 실내 길찾기 = M13 wayfinding(SCR-M9 계열) 딥링크 · 공지 = §5B notice `/work/notice` 공개 서브셋 → 실 API 재사용, 가이드 정적 콘텐츠(교통·주차·편의 안내)는 CMS/정적. **SEO·다국어 적용, MDI 비적용**
- **상태**: 로딩(행사·공지 스켈레톤) · 빈(행사 없음 "예정된 전시가 없습니다"·공지 없음) · 에러(영역별 재시도, 가이드 허브는 정적이라 상시 노출)
- **접근성**: 가이드 카드 링크 텍스트 명시, 접근성 안내 카드 자체가 WCAG AA·수어/휠체어 정보 제공, 색 외 라벨 병기. **i18n 네임스페이스 `track.visitor.*`**(+ 공유 `track.shell.*`)
> **v2.3 격상.** 기존 v2.2 "visitor 서브홈(가이드 허브)"을 **일반 대중 대상 세련된 공개 마케팅 홈페이지**로 격상하고, **화면 중심 요소를 대화형 "AI 관람 도우미"로 재구성**한다(소유자 지시 2026-07-12: "비지터가 가장 알고 싶어하는 정보를 AI로 쉽게"). business(SCR-T2)·agency(SCR-T3) 메인은 여전히 **B2B 업무 진입 톤**이며, 본 화면만 코엑스/글로벌 전시장 홈페이지 수준의 마케팅 톤(풀블리드 히어로·타이포 위계·공백 없는 밀도·전환 지향)으로 차별화한다. 기존 §3C 공개 셸(PublicShell)·TrackSwitcher·라우트(`/visitor`)·데이터 재사용 원칙·신규 토큰 0은 유지한다.
- **대상**: 일반 대중·관람객(게스트+회원, 미인증 기본 랜딩) · **모듈/P**: M10(사전등록·티켓)·M11(비즈매칭·세션)·M13(실내 길찾기)·M12(공지·홍보)·**AI 라우터(Claude→Ollama 폴백, 공개 행사 데이터)** · P1 · **진입 경로**: 미인증 기본(§2-3 순서 5 `resolveLandingTrack→visitor`)·관문 SCR-T0 "관람 트랙"·TrackSwitcher "관람"·직접 `/visitor`·검색/광고/QR
- **화면 목표(관람객 관심 우선순위)**: ① 지금/곧 어떤 전시 + 관심사 맞춤 추천 ② 가는 길·주차·소요시간 ③ 입장 방법(사전등록·입장권·무료/유료) ④ 부스 위치·볼거리·이벤트(wayfinding) ⑤ 편의시설·접근성. 이 5가지를 **AI 관람 도우미 하나의 자연어 입력으로 관통**시키고, 스크롤 하위 섹션은 동일 정보를 시각적으로 재확인시킨다.
- **레이아웃**(위→아래, PublicShell·풀블리드 마케팅 밀도·반응형 풀스크린):
1. **PublicShell 헤더 + TrackSwitcher**("관람" 활성·`aria-current`) + 우측 유틸(언어 한/영/중/일·**라이트/다크 테마 토글**·`로그인`·인증 시 `내 홈`)
2. **풀블리드 히어로 + AI 관람 도우미(중심 요소)**:
- 배경 = 대형 전시장 전경 비주얼(나노바나나 AI 생성, 워터마크 "AI 생성 예상 이미지" 상시·텍스트 대비용 딤 오버레이). 헤드라인 "**무엇이든 물어보세요 — AI가 관람을 안내합니다**"(H1/Display) + 서브카피 "전시 일정·오시는 길·입장권·부스 위치를 대화로 한 번에".
- **AI 관람 도우미 대화 입력창(히어로 정중앙·대형)**: 폭 넓은 자연어 질문 인풋(placeholder 로테이션 "이번 주말 로봇 전시 있어?" → "킨텍스 주차 어디에 해요?" → "가족과 볼만한 전시 추천") + 우측 전송 버튼(선 SVG send 아이콘) + 좌측 AiLabel(보라 `ai-accent` "AI 도우미"). 인풋 하단 **예시 질문칩(자주 묻는 것)** 5개: `이번 주말 로봇 전시 있어?` · `킨텍스 주차 어디에 해요?` · `가족과 볼만한 전시 추천` · `사전등록 어떻게 해요?` · `GTX-A로 얼마나 걸려요?`(칩 클릭=즉시 질의).
- **AI 답변 패널(질의 시 인풋 아래 확장·대화형)**: 답변 텍스트 + **구조화 결과 카드**(매칭 전시=포스터·행사명·기간·홀·D-day·"사전등록"/교통=경로·소요시간·주차 카드/입장=사전등록·입장권 CTA/부스·wayfinding 딥링크) + **후속 질문칩**("무료인가요?"·"길찾기 열기"·"티켓 예매") + 하단 고지문 "AI 생성 답변 — 정확한 정보는 공식 안내를 확인하세요"(제거 불가) + 근거 출처 링크. AI 산출물은 §1-4 AiLabel(보라)로 구분.
- 보조 CTA(인풋 하단·항상 노출): "사전등록"·"입장권 예매"(→ SCR-P4·P7).
3. **AI 기능 쇼케이스 타일(히어로 직하·AI 전면 노출 — 소유자 지시 2026-07-12)**: "이 시스템의 AI가 해주는 것"을 관람객이 한눈에 알도록 **AI 능력 카드 그리드**(4~6타일, 각 카드 좌상단 AiLabel 보라·선 SVG 아이콘·제목·1줄 설명·바로가기). 대표 타일: ① **AI 관람 도우미**(자연어 질문·위 히어로 입력창으로 스크롤/포커스) ② **AI 전시 추천**(관심사 맞춤 큐레이션→§4 추천 레일) ③ **AI 길찾기**(실내 wayfinding·최적 동선, M13 딥링크) ④ **AI 일정 도우미**(관심 세션·동선 기반 관람 코스 자동 구성, M11) ⑤ **AI 혼잡·대기 안내**(시간대별 관람 추천, degraded 시 정적 안내) ⑥ **AI 시공 예상 이미지**(나노바나나 — 참가·주최 대상 능력 홍보, "비즈니스 트랙에서" 안내). AI가 시스템 전면에 있음을 시각적으로 각인시키는 것이 목적(밀도 유지·공백 금지).
4. **AI 전시·행사 기획 브리핑(연간·월간 선제 제시 — 소유자 지시 2026-07-12)**: 단발 질의응답을 넘어 **AI가 개최 예정 전시·행사를 먼저 큐레이션**하는 섹션. 상단에 **연/월 관점 토글** — ① `연간(올해)` = 12개월 **연간 타임라인**(월별 레인 위 전시·행사 바/도트, 카테고리 색·규모 표시, 성수기 하이라이트) + 좌상단 **AiLabel 보라 "AI 요약"** 헤드라인(예: "2026년은 상반기 스마트팩토리·로봇, 하반기 헬스케어·친환경 전시가 축을 이룹니다") + 분기별 핵심 3~5건 카드 ② `월간(월 선택)` = 선택 월 캘린더/리스트(해당 월 전시·행사 카드·개막일·홀·D-day) + **AI 월간 요약**(예: "이번 달은 로봇·헬스케어 전시가 집중됩니다 · 주말 방문 추천"). 연/월 전환·이전/다음 이동 시 AI가 해당 기간 데이터를 근거로 요약·추천 갱신. **중복 회피**: 하단 "진행 중/이번 달/다음 달" 쇼케이스(레이아웃 5)와 달리 본 섹션은 **연간 기획 관점(연 타임라인 + 기간 요약)**이 차별점이며, 월간 뷰는 쇼케이스 카드 데이터와 정합(동일 event 소스, 표현만 캘린더/요약).
5. **진행 중·예정 전시 쇼케이스**(SCR-HOME 쇼케이스 레일 패턴 재사용·시각적으로 풍부): 세로 2:3 포스터 카드 그리드/캐러셀 8~12장(포스터·행사명·기간·`hall_label`·상태 배지·D-day·"사전등록") + 탭 `진행 중 | 이번 달 | 다음 달` + 전시장 세그먼트 필터(전체/제1·제2전시장, 동적 옵션). `poster_url` 부재 시 그라디언트+행사명 폴백.
6. **관심사 맞춤 추천 레일**(M11·AI): "당신의 관심사에 맞는 전시"(관심 분야 칩 선택→AI 추천, AiLabel) — 미선택 시 인기·추천 큐레이션. 관심 분야 알림 유도("관심 분야 알림 받기").
7. **방문 가이드 허브**(코엑스 가이드 차용 — 2×3 아이콘 카드, 선 SVG): ① 교통(GTX-A 킨텍스역 도보 3분·광역버스·소요시간) ② 주차(iparking 실시간·요금·게이트) ③ **실내 길찾기**(M13 wayfinding 딥링크) ④ 편의시설(식음·수유실·물품보관·ATM) ⑤ 접근성·편의(휠체어·수어통역 고지) ⑥ 고객문의(FAQ·연락처).
8. **관람객 여정 안내(3-스텝)**: `사전등록 → 배지/QR 발급 → 현장 체크인` 가로 스텝(각 1줄 설명·해당 딥링크 SCR-P4·M5·SCR-31), 티켓→체크인→배지 흐름 시각화.
9. **공지·뉴스**: 최근 공지/보도 5건 리스트(§5B notice 공개 서브셋 재사용)+더보기.
10. **전시장 소개**: 킨텍스 규모·홀 규격·부대시설 요약 밴드(+공개 홍보 SCR-P2 링크).
11. **PublicShell 푸터**: 회사정보·오시는 길(GTX-A 킨텍스역)·SNS·고객문의·정책·언어.
- **구성요소**: AiVisitorAssistant(대화 입력·예시칩·답변 패널·AiLabel)·AiCapabilityTiles(AI 기능 쇼케이스 4~6타일)·**AiPlanningBriefing(연/월 토글·연간 타임라인·월간 캘린더/리스트·AI 요약 태그)**·HeroBanner(AI 워터마크)·EventShowcaseRail(포스터 카드)·RecommendRail(관심사·AI)·GuideHubCard×6·JourneyStepper(3-스텝)·NoticeList·VenueIntroBand·TrackSwitcher·LanguageSelector·ThemeToggle·CTA(사전등록·티켓·알림)
- **데이터 원천 (★근거 = 사전 크롤 적재 온프레미스 DB — 실시간 외부 크롤 아님, 소유자 지시 2026-07-12)**:
- **AI 관람 도우미** = **신규 공개 엔드포인트 제안 `POST /api/public/ai/visitor-assistant`**(질의 body·비인증·CORS·레이트리밋·PII 미수집; 응답 = 답변 텍스트 + 구조화 결과 참조(eventId·가이드 키·CTA 딥링크) + **근거 출처(DB 레코드 id·출처 라벨)**). 백엔드는 **기존 AiTextRouter(Claude→Ollama 폴백)****미리 크롤해 DB에 적재한 공개 데이터만을 컨텍스트/근거로 RAG 답변**한다(실시간 외부 호출 없음·온프레미스·근거 인용·환각 방지 §1-4). 답변 근거 DB 테이블(예시 계약): 전시=`exhibition`/`event`(크롤 34건+, `poster_url`·기간·홀·카테고리), 교통·오시는 길=`transport_info`(경로·수단·소요시간), 주차=`parking_info`(위치·요금·게이트·실시간 여부 플래그), 입장·티켓=`ticket_policy`/M10, 편의시설=`amenity_info`, 방문 가이드/FAQ=`visitor_guide`, 부스·wayfinding=`booth`/`floorplan`. **AI는 이 테이블 밖 지식으로 답하지 않으며(범위 밖=degraded 안내), 각 답변에 참조 DB 레코드를 인용**. **계약은 제안 — 백엔드 후속(공개 조회 API가 크롤 적재 DB에서 서빙).**
- **AI 전시·행사 기획 브리핑(연간·월간)** = **신규 공개 엔드포인트 제안 `GET /api/public/events/planning?view=year|month&year=YYYY&month=MM`****크롤 적재 `exhibition`/`event` 테이블을 개최연·월 기준 그룹 조회**(기간·`poster_url`·카테고리·홀). **AI 기간 요약**(연간/월간 헤드라인)은 **AiTextRouter(Claude→Ollama 폴백)****해당 기간 DB 데이터만을 근거로 요약·추천**(외부 실시간 호출 없음·근거 인용·§1-4 AiLabel). 요약은 캐시(기간별)로 서빙. **계약은 제안 — 백엔드 후속(DB 서빙).** 월간 뷰는 쇼케이스(레이아웃 5)와 동일 event 소스로 정합.
- 전시 쇼케이스·추천 = `/api/public/events`**크롤 적재 `exhibition`/`event` 테이블**(`poster_url`, SCR-P1/HOME 자산 재사용) · AI 기능 타일 = 정적 구성(AI 능력 메타)+각 기능 딥링크 · 사전등록/티켓 = SCR-P4/P7 딥링크(M10 `ticket_policy`) · 실내 길찾기 = M13 wayfinding(SCR-M6/M9 계열, `floorplan`) 딥링크 · 방문 가이드 = **크롤 적재 `visitor_guide`/`transport_info`/`parking_info`/`amenity_info`**(정적 폴백) · 공지·뉴스 = §5B notice `/work/notice` 공개 서브셋(+크롤 보도자료) · 전시장 소개 = CMS/정적(M17). **모든 공개 조회는 DB 서빙(외부 실시간 크롤 없음)·SEO·다국어 적용, MDI 비적용**
- **상태**:
- **AI 도우미** — 대기(placeholder 로테이션·예시칩) · 생각 중(타이핑 인디케이터·"답변 생성 중…") · 성공(답변+결과 카드+**근거 DB 출처 인용**) · **degraded(AI 라우터 미가용 → "AI 도우미 점검 중 — 크롤 적재 데이터 검색으로 안내" 폴백: 질의어로 DB(`exhibition`/`visitor_guide` 등) 직접 검색+가이드 링크 노출)** · 에러(재시도) · 빈 결과("해당 정보를 찾지 못했어요 — 전체 일정/가이드 보기", DB 근거 범위 밖 질의는 "공식 안내에서 확인" 유도)
- **AI 기능 타일** — 정적 상시 노출(로딩 없음) · 각 타일 대상 기능 미가용 시 해당 타일 "준비 중" 배지(예: 나노바나나 시공 이미지=비즈니스 트랙 안내)
- **AI 기획 브리핑** — 로딩(타임라인/캘린더 스켈레톤·"AI 요약 생성 중…") · 성공(기간 데이터+AI 요약 태그) · **degraded(AI 요약 미가용 → 요약 태그 숨김, 기간 전시·행사 데이터는 DB로 정상 노출)** · 빈(해당 연/월 개최 없음 "이 기간 예정된 전시가 없습니다") · 에러(재시도) · 연/월 전환 시 부분 로딩
- **쇼케이스/추천** — 로딩 스켈레톤 · 빈("예정된 전시가 없습니다") · 에러(영역별 재시도) · 관심사 미선택(인기 큐레이션)
- **공지/가이드** — 로딩 스켈레톤 · 빈 · 가이드 허브·전시장 소개는 크롤 적재 DB(`visitor_guide` 등)/정적이라 상시 노출
- **접근성**: AI 입력창 `role="search"`+명시 라벨·예시칩 버튼 명확 텍스트, 답변 패널 `aria-live="polite"`(스트리밍 답변 스크린리더 고지), 답변 내 결과 카드 링크 텍스트 명시, AI 라벨 색 외 텍스트 병기, 히어로 배경 위 헤드라인 대비 확보(딤 오버레이), 가이드·접근성 카드 자체가 WCAG AA·수어/휠체어 정보 제공, 테마 토글 키보드 조작·포커스 링 `primary-600` 2px. 색만으로 상태 전달 금지. **라이트/다크 양 테마 모두 WCAG AA**. **i18n 네임스페이스 `track.visitor.*`**(+ 공유 `track.shell.*`)
- **테마 주의**: 본 공개 마케팅 홈페이지는 소유자 지시로 **라이트/다크 테마 토글을 제공**한다(§1-5의 업무 셸 라이트 단일 원칙에 대한 **공개 트랙 마케팅 예외**). 색은 §1 토큰을 CSS 변수로 주입(하드코딩 hex 금지)해 양 테마 대응, 라이트가 기본.
- **Stitch 프롬프트**:
```
A public visitor sub-home / visit-guide hub page for the product "KINTEX AI 전시·행사시스템", visitor track, Korean, desktop 1440px, light theme, primary blue #0066B3, clean marketing design (not MDI). All UI text in Korean; line (stroke) SVG icons only, NO emoji. Top: public header with "KINTEX AI 전시·행사시스템" wordmark, a 3-tab track switcher "관람 · 비즈니스 · 협력사" with "관람" active (underlined, aria-current), and right utilities "언어 · 로그인". A visitor-toned hero with headline "전시 관람, 여기서 시작하세요", dual CTAs "사전등록" and "입장권 예매", and a subtle "관심 분야 알림 받기" prompt. Below: an event schedule grid of cards (poster, title, dates "2026.09.0509.08", hall, a status badge, "사전등록" button) with a venue segment filter "전체 · 제1전시장 · 제2전시장". Then a "방문 가이드" hub section as a 2×3 grid of cards, each with a stroke icon, title and one-line text and a link: "교통 · GTX-A 킨텍스역 도보 3분", "주차 · 실시간 주차 안내", "실내 길찾기", "편의시설 · 식음·수유실·물품보관", "접근성 · 휠체어·수어통역 안내", "고객문의 · FAQ·연락처". Then a "공지사항" list of 5 recent notices with a "더보기" link. Footer. Helpful, welcoming, accessible public visitor-guide aesthetic; rounded 8px cards, WCAG AA contrast.
A premium exhibition-center marketing homepage for the product "KINTEX AI 전시·행사시스템" (an AI exhibition & event operations platform), visitor track, for the general public. Korean, desktop 1440px, full-bleed responsive layout, modern conversion-oriented marketing aesthetic (NOT an MDI business app shell). Primary blue #0066B3, purple #6D4AFF for AI accents; support BOTH light and dark theme (show LIGHT as default). All UI text in Korean exactly as given; use line (stroke) SVG icons only, NO emoji. Product name must read exactly "KINTEX AI 전시·행사시스템".
Top: a clean public header with the "KINTEX AI 전시·행사시스템" wordmark on the left, a 3-tab track switcher "관람 · 비즈니스 · 협력사" with "관람" active (underlined, aria-current), and right utilities "언어(한/영/중/일) · 라이트/다크 토글 · 로그인".
HERO (full-bleed, the centerpiece): a large photorealistic exhibition-hall panorama as background (with a faint watermark "AI 생성 예상 이미지" and a dim overlay for text contrast). Centered over it: a big headline "무엇이든 물어보세요 — AI가 관람을 안내합니다" and a subheading "전시 일정 · 오시는 길 · 입장권 · 부스 위치를 대화로 한 번에". The visual focus is a large conversational AI ASSISTANT search bar in the center: a wide natural-language question input with a rotating placeholder "이번 주말 로봇 전시 있어?", a small purple "AI 도우미" label on the left and a send (paper-plane) icon button on the right. Directly below the input, a row of example question chips: "이번 주말 로봇 전시 있어?", "킨텍스 주차 어디에 해요?", "가족과 볼만한 전시 추천", "사전등록 어떻게 해요?", "GTX-A로 얼마나 걸려요?". Show one AI answer panel expanded below the input in a chat style: an answer paragraph with a purple "AI" tag, structured result cards (a matched exhibition poster card with title/dates/hall/"사전등록", a transport card "GTX-A 킨텍스역 도보 3분 · 소요 약 40분", an entry card "무료 · 사전등록 권장"), follow-up chips "무료인가요? · 길찾기 열기 · 티켓 예매", and a small disclaimer "AI 생성 답변 — 정확한 정보는 공식 안내를 확인하세요". Under the hero, two secondary CTAs "사전등록" and "입장권 예매".
Immediately below the hero, prominently, an "AI가 도와드립니다" capability showcase — a row/grid of 4 to 6 cards that advertise what THIS system's AI does, each with a small purple "AI" tag, a stroke SVG icon, a title and one line: "AI 관람 도우미 · 무엇이든 물어보세요", "AI 전시 추천 · 관심사 맞춤 큐레이션", "AI 길찾기 · 실내 최적 동선", "AI 일정 도우미 · 나만의 관람 코스", "AI 혼잡·대기 안내 · 여유로운 시간 추천", "AI 시공 예상 이미지 · 부스 미리보기". This section must make it obvious at a glance that the platform is AI-powered.
Next, an "AI 전시·행사 기획 브리핑" section where the AI proactively curates upcoming exhibitions by period. It has a view toggle at top: "연간 (올해)" and "월간" (with a month picker "2026년 3월 ◂ ▸"). In the "연간" view, show a 12-month annual timeline (a horizontal row of month lanes JanDec with colored event bars/dots grouped by category and highlighted peak seasons), with a prominent purple "AI 요약" headline card reading "2026년은 상반기 스마트팩토리·로봇, 하반기 헬스케어·친환경 전시가 축을 이룹니다" and 35 quarterly highlight cards. Below that, a compact "월간" summary example with a purple "AI 요약" tag "이번 달은 로봇·헬스케어 전시가 집중됩니다 · 주말 방문 추천" and a small calendar/list of that month's exhibitions (title, opening date, hall, D-day). Make it clearly AI-curated and forward-looking, distinct from the "진행 중/이번 달/다음 달" showcase below.
Scroll sections below: (1) "진행 중 · 예정 전시" showcase — a rich grid/carousel of 2:3 poster cards (poster image, title, dates "2026.09.0509.08", hall label, a status badge, a "D-25" day chip, "사전등록"), with tabs "진행 중 | 이번 달 | 다음 달" and a venue filter "전체 · 제1전시장 · 제2전시장". (2) "당신의 관심사에 맞는 전시" recommendation rail with interest chips and a small purple "AI 추천" tag. (3) "방문 가이드" hub as a 2×3 grid of stroke-icon cards: "교통 · GTX-A 킨텍스역 도보 3분", "주차 · 실시간 주차 안내", "실내 길찾기", "편의시설 · 식음·수유실·물품보관", "접근성 · 휠체어·수어통역 안내", "고객문의 · FAQ·연락처". (4) a 3-step visitor journey "사전등록 → 배지/QR 발급 → 현장 체크인". (5) a "공지 · 뉴스" list of 5 recent items with "더보기". (6) a "전시장 소개" band with KINTEX scale/hall figures. Footer with company info, "오시는 길 · GTX-A 킨텍스역", SNS links, 고객문의, language.
All exhibition, transport, parking, entry and amenity data shown here is served from a pre-crawled on-premise database (not live external calls); the AI assistant answers ONLY from this stored data and cites its source. Spacious yet dense (no large empty gaps), sophisticated global exhibition-center homepage feel, rounded 8px cards, tabular numerals for dates, WCAG AA contrast in both themes.
```
### SCR-T2 business 서브홈 (비즈니스 안내·진입 홈) [business · 공개 · `/business`]
@ -1772,6 +1805,9 @@ A mobile "my ticket wallet" screen for an exhibition visitor, 390px width, light
| 버전 | 일자 | 작성자 | 내용 |
|---|---|---|---|
| **v2.3.2** | 2026-07-12 | designer | **SCR-T1 — AI 전시·행사 기획 브리핑(연간·월간 선제 제시) 추가(소유자 추가 지시 2026-07-12).** 단발 질의응답을 넘어 **AI가 개최 예정 전시·행사를 먼저 큐레이션**하는 섹션을 히어로 AI 능력 타일 직하(레이아웃 4번)에 신설 — **연/월 관점 토글**(① 연간=12개월 타임라인+분기 하이라이트+AiLabel "AI 요약" 헤드라인, ② 월간=선택 월 캘린더/리스트+AI 월간 요약), 연/월 전환 시 AI가 해당 기간 데이터 근거로 요약·추천 갱신. **중복 회피**: 하단 "진행 중/이번 달/다음 달" 쇼케이스(레이아웃 5)와 달리 본 섹션은 **연간 기획 관점**이 차별점, 월간은 동일 event 소스로 정합. 구성요소 `AiPlanningBriefing` 추가, 이하 레이아웃 5~11 재번호. **데이터 원천** — 신규 공개 엔드포인트 제안 `GET /api/public/events/planning?view=year|month&year&month`(크롤 적재 `exhibition`/`event`를 개최연·월 그룹 조회, AI 기간 요약은 AiTextRouter Claude→Ollama 폴백이 해당 기간 DB만 근거·근거 인용·캐시, DB 서빙·외부 실시간 호출 없음, 계약 제안). 상태(로딩·성공·**degraded(요약 미가용→데이터만 노출)**·빈·에러·기간 전환 부분 로딩) 정의. **Stitch 프롬프트 갱신** — 연간 타임라인+"AI 요약" 헤드라인("2026년은 상반기 스마트팩토리·로봇, 하반기 헬스케어·친환경…")+월간 요약("이번 달은 로봇·헬스케어 전시가 집중됩니다") 문단 추가, 쇼케이스와 구별 명시. 세련된 홈페이지 톤·풀블리드·반응형·라이트/다크·크롤 적재 DB 근거 원칙은 유지. 신규 토큰 0·PLANNING.md·src 무수정 |
| **v2.3.1** | 2026-07-12 | designer | **SCR-T1 마무리 — AI 전면 노출 + AI 답변 근거=크롤 적재 DB 명시(소유자 추가 지시 2건 2026-07-12).** ①**AI 기능 쇼케이스 타일 신설**(히어로 직하 레이아웃 3번): "AI 시스템인데 AI가 어디 있는지 모르겠다" 지적 반영 — AI 관람 도우미·AI 전시 추천·AI 길찾기·AI 일정 도우미·AI 혼잡/대기 안내·AI 시공 예상 이미지 6능력을 AiLabel 카드로 전면 홍보(구성요소 AiCapabilityTiles·상태(정적 상시·미가용 "준비 중")·이하 레이아웃 4~10 재번호). ②**데이터 원천 = 사전 크롤 적재 온프레미스 DB 명시**: AI 관람 도우미(`POST /api/public/ai/visitor-assistant`)가 **실시간 외부 크롤이 아니라 미리 크롤해 DB에 적재한 공개 데이터만을 RAG 근거로 답하고 참조 레코드를 인용**(범위 밖=degraded)함을 명문화 — 근거 테이블 계약 예시(`exhibition`/`event`·`transport_info`·`parking_info`·`ticket_policy`·`amenity_info`·`visitor_guide`·`floorplan`), 각 섹션 출처를 크롤 적재 DB로 매핑, degraded 폴백을 "크롤 적재 데이터 검색"으로 갱신, 백엔드 계약을 "공개 조회 API가 크롤 적재 DB에서 서빙(외부 실시간 호출 없음·온프레미스·근거 인용)" 전제로 기술. ③**Stitch 프롬프트 갱신** — AI 능력 쇼케이스 카드 6종 문구 + "served from a pre-crawled on-premise database, AI answers ONLY from stored data and cites its source" 추가. 세련된 홈페이지 톤·풀블리드·반응형·라이트/다크는 v2.3 그대로 유지. 신규 토큰 0·PLANNING.md·src 무수정 |
| **v2.3** | 2026-07-12 | designer | **SCR-T1 visitor 메인을 "세련된 공개 마케팅 홈페이지"로 격상 + 중심 요소를 대화형 AI 관람 도우미로 재구성(소유자 지시 2026-07-12).** ①**§3C SCR-T1 전면 재작성** — 기존 "visitor 서브홈(가이드 허브)"을 일반 대중 대상 마케팅 홈페이지(코엑스/글로벌 전시장 홈 수준·풀블리드·타이포 위계·공백 없는 밀도·전환 지향)로 격상. **화면 중심 = AI 관람 도우미**(히어로 정중앙 대형 자연어 질문 입력창 + 예시 질문칩 5종 + 대화형 답변 패널: 답변 텍스트·구조화 결과 카드(전시/교통/입장/wayfinding)·후속 질문칩·AI 고지문·근거 출처, AiLabel 보라 구분). 관람객 관심 우선순위 5(전시 추천·교통·입장·부스위치·편의)를 자연어 하나로 관통. 하위 섹션 순증: 전시 쇼케이스(2:3 포스터 그리드/캐러셀·탭·전시장 필터·`poster_url`)·관심사 맞춤 추천 레일(M11 AI)·방문가이드 허브(2×3, GTX-A·주차·M13 길찾기·편의·접근성·문의)·관람객 여정 3-스텝(사전등록→배지/QR→체크인)·공지·뉴스·전시장 소개·푸터(회사·SNS·문의). ②**데이터 원천** — **신규 공개 엔드포인트 제안 `POST /api/public/ai/visitor-assistant`**(비인증·CORS·레이트리밋·PII 미수집, 기존 AiTextRouter Claude→Ollama 폴백 + 공개 행사 데이터 근거·환각방지, **계약 제안·백엔드 후속**), 나머지 `/api/public/events`·notice·CMS 재사용. ③**상태** — AI 도우미 대기/생각중/성공/**degraded(AI 미가용→키워드 검색 폴백)**/에러/빈결과 정의. ④**테마** — 공개 마케팅 홈페이지에 한해 **라이트/다크 테마 토글 제공(§1-5 업무 셸 라이트 단일 원칙의 공개 트랙 예외)**, 색은 §1 토큰 CSS 변수 주입·라이트 기본. ⑤**접근성** — AI 입력 `role="search"`·답변 `aria-live="polite"`·색 외 라벨·양 테마 WCAG AA, i18n `track.visitor.*`. ⑥영어 **Stitch 프롬프트 전면 갱신**("premium exhibition-center marketing homepage"·1440 full-bleed·AI 관람 도우미 centerpiece·제품명 "KINTEX AI 전시·행사시스템" 정확 표기·NO emoji/line SVG·라이트·다크). ⑦**§2.8-2 인벤토리** SCR-T1 행 명칭/모듈 갱신. **경계 준수**: business(SCR-T2)·agency(SCR-T3) 메인은 B2B 업무 톤 유지(격상 대상 아님) 명시, 라우트 `/visitor`·PublicShell·TrackSwitcher·세션키·기존 SCR 불변, **신규 토큰 0**, PLANNING.md·src 무수정 |
| **v2.2** | 2026-07-12 | designer | **3-트랙(visitor/business/agency) 대외 접점 재편 — 신규 4화면 SCR-T0~T3 순증(planner 지시서 `_workspace/plan_3track.md`·PLANNING v3.2 §2-2·`docs/analysis/coex-website.md` 근거).** 코엑스 3-사이트 IA(VISITOR·BUSINESS·CYBER)를 킨텍스 단일 도메인 위 3-트랙 얕은 진입 레이어로 정형화. ①**§3C 신설** — 3C-0 공개 셸(PublicShell) 재사용+상단 3-트랙 스위처(TrackSwitcher, `role=tablist`·`관람/비즈니스/협력사`·워드마크 `/` 복귀·언어·로그인·`내 홈`) 사양, SCR-T0 관문 랜딩(루트 `/`·제품 히어로 "KINTEX AI 전시·행사시스템"+3-트랙 대형 분기 카드+행사 하이라이트 레일, 관람 1순위), SCR-T1 visitor 서브홈(`/visitor`·행사 그리드+사전등록/티켓 CTA+2×3 방문 가이드 허브(교통 GTX-A·주차·M13 실내 길찾기·편의·접근성·문의)+공지), SCR-T2 business 서브홈(`/business`·주최자/참가 2-분기 가치제안+임대 절차 스텝+요금·서류+로그인/문의 CTA→SCR-01→SCR-02/05, 온라인 신청 일원화 강조), SCR-T3 agency 서브홈(`/agency`·등록업체 안내 739·14분류·미등록 시공 엄금 고지+진행 중 M15 공사 옥션 역경매 리스트+작업신고·규정+등록업체 로그인→SCR-27/28, 옥션 차별점 강조) — 각 화면 레이아웃·구성요소·데이터 원천(기존 API 재사용 매핑: login-slides·`/api/public/events`·notice·SCR-P6·M15 공개 서브셋)·상태(로딩/빈/에러/degraded)·접근성·i18n 네임스페이스(`track.gateway/visitor/business/agency/shell.*`)+영어 Stitch 프롬프트(Precision Enterprise AI·1440·NO emoji·선 SVG·WCAG AA). ②**SCR-HOME 관계 명시**: 관문 SCR-T0(`/`)=미인증 전용 공개 랜딩, 인증 세션 시 `/home`(SCR-HOME) 우선 리다이렉트(딥링크·SEO·게스트 티켓 조회 예외). ③**§2.8-2 인벤토리**에 SCR-T* 표 추가(집계 85→**89화면**, 미생성 2→6: SCR-HOME·M3+T0~T3). **경계 준수**: 기존 SCR·라우트·MDI 셸(§2.7)·티켓 공개 라우트(SCR-P7/P8)·세션키 불변, 신규 라우트는 얕은 트랙 진입 4개만, ops/admin 트랙 외부 유지, §1A 멀티테넌시 정합. §1 토큰 권위·**신규 토큰 0**, 기존 화면 스펙·PLANNING.md·src 무수정 |
| **v2.1.2** | 2026-07-12 | designer | **로그인 후 메인 홈(SCR-HOME) 신설 + 로그인 랜딩 흐름 변경 + 셸 하단 상태바(ShellFooter) 신설(소유자 지시 2026-07-12).** ①**§3 SCR-HOME 신설**(라우트 `/home`, AppShell 셸 내부 핀 홈 탭) — 인사·요약 KPI 스트립 4타일(진행 전시·오늘 체크인·D-임박·미결 결재, 기존 실 API 재사용), 행사소개 배너 캐러셀(**SCR-01 login-slides 자산/API 재사용**·자동슬라이드·좌우 내비), 현재·예정 전시 일정 캘린더(**WISE `CalendarView` 패턴 참조** — 월 그리드 6주×7열·요일 헤더·오늘 강조·단일일 색점칩/기간 spanning bar·"+N" 팝업 + **전시장 세그먼트 필터(동적 옵션·하드코딩 금지 — `전체`+홀 마스터 센터 구분 distinct, 3관·4관 확장 대비)** 기본값 전체, `/api/events/calendar`+`/api/public/events`+홀 마스터 옵션 API 재사용), **"진행 중·다가오는 전시" 쇼케이스(확정판 — 배너 아래 가로 스크롤 포스터 카드 레일 8~12장·세로 2:3 포스터+행사명/기간/hall_label/상태배지/D-day·좌우 화살표·탭 `진행 중|이번 달|다음 달`·poster_url 부재 시 그라디언트+행사명 폴백, 데이터=event 테이블 poster_url V32 크롤 34건)**, 우측 컬럼 4위젯 스택(**내 할 일(역할 기반, 최상단) — 주최자=미결 결재/서류 마감 D-n/홀 배정 대기/옥션 낙찰 대기·참가업체=신고서류/설계 확정/견적·청구·공사업체=응찰 마감/시공 일정·관리자=승인 대기/점검, 행=상태점+작업명+대상+D-day 클릭 이동·건수 배지·0건 "모두 완료"+CTA, 데이터=§5B approval·M6·M15·M9 집계 조합 — 신규 통합 API `GET /api/home/todos` 계약 제안 명시**·공지 최근 5건+더보기(§5B notice `/work/notice`)·**빠른 작업 2×3 바로가기 그리드(역할별)**·**최근 활동 타임라인 5건(알림 피드 재사용)**), 내 행사 워크스페이스 카드 그리드(참여 행사·카드 클릭→역할별 대시보드·빈 상태 초대코드 안내) + **KPI 4타일 역할별 구성 명시(주최자/참가업체/공사업체/관리자 예시 세트)** + **밀도 원칙 4항(소유자 추가 지시)**: 대형 공백 금지·12컬럼 컴팩트(섹션 간 24px 표준/32px 상한)·배너 높이 220~260px 제한·좌우 컬럼 세로 균형·전 빈 상태 안내+행동 버튼(CTA) 조합. **본 판이 SCR-HOME 스펙 확정판** — 레이아웃·상태(영역별 로딩/빈/에러)·데이터 원천 매핑·접근성(WCAG AA·i18n `home.`·이모지 금지 선SVG·`prefers-reduced-motion`)+영어 Stitch 프롬프트(Precision Enterprise AI·1440·footer 포함). ②**SCR-01 흐름 수정**: 로그인 성공→`/home` 랜딩, "행사 워크스페이스 선택" phase 제거(SCR-HOME 섹션으로 흡수), SCR-01은 순수 로그인 화면으로 축소. ③**§2-2 ShellFooter 신설**(약 36px, 전 셸 화면 공통): 좌측 저작권 "© 2026 KINTEX. All rights reserved.", 우측 시스템 상태 점(정상/점검·색외 텍스트 병기)·앱 버전·고객지원 링크, `role="contentinfo"`·라이트/다크 토큰·WCAG AA·i18n `shell.footer.*`, 모바일 미적용(더보기 이관). §2.7-1 셸 레이아웃 다이어그램에 하단 36px 행 추가. ④**§2-1 사이트맵·§2.8-2 인벤토리**에 SCR-HOME 반영(집계 84→**85화면**, 미생성 0→2: SCR-HOME+SCR-M3). §1 토큰 권위 유지·**신규 토큰 0**, 기존 화면 스펙·PLANNING.md·src 무수정 |
| **v2.1.1** | 2026-07-12 | designer | **Stitch 잔여 39화면 전량 확보(64/64) 상태 반영 + 이전 확보 25화면 상태 정합 + 집계 갱신.** 2026-07-12 Stitch 수확 재실행으로 미확보 39화면이 전량 확보됨 — §2.8-2 인덱스 상태 컬럼만 `🔲미생성`→`✅Stitch` 교체(웹코어 SCR-22·23·25·26·27·29·30·32~51, 관리자 SCR-A5·A6·A8·A9, 공개 SCR-P1~P7, 모바일 SCR-M15). 추가로 이전 세션 확보분 25화면(디스크 code.html 존재 검증: 웹코어 SCR-18·19·20·21·24·28·31, 관리자 SCR-A1~A4·A7·A10, 공개 SCR-P8, 모바일 SCR-M4~M14)도 동일 규칙으로 정합화 — 총 64화면 상태 교체. §2.8-2 집계 문장 갱신: ✅Stitch 18→**83** · 🔲미생성 64→**0**(Stitch 미확보는 SCR-M3 1건뿐). 화면 스펙·프롬프트·구조 무수정. 수확 상세 `_workspace/stitch_gen/harvest_report.md` |

View File

@ -0,0 +1,138 @@
# KINTEX AI 모바일 앱 + 백엔드 보안 감사 결과
> 감사일: 2026-07-12 · 감사자: 통합 QA(reviewer) · 방식: 정적 소스 감사(코드 미수정, 읽기전용)
> 체크리스트: `mobile-security-checklist.md` · 개선 백로그: 본 문서 §하단
> 증거는 `파일:라인` 표기. `mobile/`는 다른 에이전트 편집 중이라 스냅샷 기준.
## 종합 판정
| 영역 | PASS | FAIL | N-A | 총평 |
|------|:----:|:----:|:---:|------|
| 1. 위변조 방지 | 1 | 5 | 0 | **최대 약점** — 루트/디버거/무결성 탐지·난독화 선언 부재 |
| 2. 데이터 보호 | 5 | 2 | 0 | SecureStore·로그 청결 우수 / FLAG_SECURE·백그라운드 마스킹 부재 |
| 3. 통신 보안 | 2 | 2 | 0 | HTTPS 강제 / cleartext 차단 선언·피닝 부재 |
| 4. 인증·세션 | 4 | 1 | 0 | 2FA·잠금·RBAC 견고 / 생체 로컬잠금 부재 |
| 5. 시큐어 코딩 | 7 | 0 | 0 | **모범** — SQLi 가드·CSPRNG·스택트레이스 차단 완비 |
| 6. 빌드·배포 | 4 | 2 | 0 | env 시크릿·오류응답 청결 / 권한 최소화·CORS 명시 보강 |
전반: **백엔드·시큐어코딩은 공공 기준 대비 견고**. **모바일 클라이언트 측 위변조 방지(anti-tampering)와 화면보호가 최대 갭** — 공공 대민 앱 기준(KISA 위변조 항목군)에서 필수 보강 대상.
---
## 1. 앱 위변조 방지
| ID | 판정 | 위험도 | 증거 / 근거 |
|----|------|--------|-------------|
| AT-1 루트/탈옥 탐지 | **FAIL** | High | package.json 의존성에 `jail-monkey`·`freerasp`·root 탐지 라이브러리 없음(mobile/package.json 13~33). 코드 내 탐지 로직 0건. |
| AT-2 서명/무결성 | **FAIL** | High | Play Integrity/App Attest·서명 지문 검증 코드 없음. 리패키징 탐지 부재. |
| AT-3 코드 난독화 | **FAIL** | Med | app.json에 `jsEngine: "hermes"` 미선언(mobile/app.json 2~53) — RN 0.74 기본 Hermes지만 명시·R8 설정 없음. `expo-build-properties`(R8/ProGuard) 미설치. |
| AT-4 디버거/에뮬레이터 | **FAIL** | Med | Frida/디버거/에뮬레이터 탐지 없음. |
| AT-5 디버그 비활성 | **부분 PASS** | Low | eas.json production=`app-bundle`(비디버그 기본, eas.json 18~22). 단 `android:debuggable=false` 명시·`__DEV__` 스트립 확인 필요. |
| AT-6 개발자모드/USB | **FAIL** | Med | 개발자옵션·USB 디버깅 감지 없음. |
## 2. 데이터 보호
| ID | 판정 | 위험도 | 증거 / 근거 |
|----|------|--------|-------------|
| DP-1 토큰 안전저장 | **PASS** | — | JWT를 expo-secure-store(Keychain/Keystore)에 저장(mobile/lib/secureStore.ts 10~25, mobile/context/AuthContext.tsx 62~66). web만 AsyncStorage 폴백(비네이티브). |
| DP-2 하드코딩 비밀 0 | **PASS** | — | 시크릿/키/토큰 grep 0건(mobile 전체). config.ts는 base URL만(공개, mobile/lib/config.ts 10~11). |
| DP-3 비밀번호 미저장 | **PASS** | — | 비밀번호는 요청 body로만 전송, 저장 로직 없음(mobile/lib/auth.ts 26~33, login.tsx 주석 4). |
| DP-4 화면캡처/오버레이 | **FAIL** | Med | 2FA/티켓QR 등 민감화면에 FLAG_SECURE 미적용(grep 0건). `expo-screen-capture` 미설치. |
| DP-5 백그라운드 스냅샷 | **FAIL** | Med | 앱 전환 시 민감화면 마스킹 로직 없음. |
| DP-6 클립보드 보호 | **PASS(경미)** | Low | 클립보드 강제복사 코드 없음. 티켓/개인정보 필드 클립보드 제어는 별도 검토 권장. |
| DP-7 로그 민감정보 0 | **PASS** | — | `console.log/warn/error` grep 0건(mobile 전체). 토큰·비밀번호 로깅 없음. |
## 3. 통신 보안
| ID | 판정 | 위험도 | 증거 / 근거 |
|----|------|--------|-------------|
| NW-1 TLS 강제 | **부분 PASS** | Med | base URL `https://kintex.zioinfo.co.kr`(mobile/app.json 45, config.ts 11). cleartext `http://` grep 0건. 단 Android `usesCleartextTraffic=false`·networkSecurityConfig 미선언(기본 SDK34 false지만 명시 권장). |
| NW-2 인증서 검증 | **PASS** | — | 표준 fetch(트러스트스토어 검증), 검증 우회 코드 없음(mobile/lib/api.ts 56~61). |
| NW-3 인증서 피닝 | **FAIL** | Med | 인증서/공개키 피닝 미적용. 공공 대민 앱 권고사항. |
| NW-4 API 키 미노출 | **PASS** | — | 통신에 클라이언트 API 키 없음. Authorization은 런타임 발급 JWT만(mobile/lib/api.ts 52). |
## 4. 인증·세션
| ID | 판정 | 위험도 | 증거 / 근거 |
|----|------|--------|-------------|
| AU-1 2FA(TOTP) | **PASS** | — | OTP_REQUIRED→재제출 흐름(mobile/lib/auth.ts 26~38, login.tsx 62~84). 서버 TotpService(HS 라이브러리 codeVerifier, backend security/TotpService.java 46~57). |
| AU-2 생체·로컬잠금 | **FAIL** | Med | `expo-local-authentication` 미설치, 로컬 재잠금 없음(진행 중 내정보 트랙과 연계). |
| AU-3 세션 만료·재발급 | **PASS** | — | 401 시 메모리 토큰 정리·재로그인 유도(mobile/lib/api.ts 67~70). 서버 TTL env(backend application.yml 67, JwtService 만료 66). |
| AU-4 로그인 실패 잠금 | **PASS** | — | 임계 초과 시 locked_until 설정, 계정열거 방지 일반화 메시지(backend security/LoginAttemptService.java 34~57). 클라 ACCOUNT_LOCKED 처리(login.tsx 74~75). |
| AU-5 서버측 RBAC | **PASS** | — | 행사 단위 RBAC를 JWT roles 클레임+컨트롤러 가드로 서버 강제(backend JwtService.java 47~69, SecurityConfig.java 48). |
## 5. 시큐어 코딩
| ID | 판정 | 위험도 | 증거 / 근거 |
|----|------|--------|-------------|
| SC-1 입력검증 | **PASS** | — | 클라 검증(login.tsx 53~60, register.tsx 27~30) + 서버 @Valid(GlobalExceptionHandler MethodArgumentNotValid 39~47). |
| SC-2 SQLi 방지 | **PASS** | — | MyBatis 동적 `${}`는 NlQueryReadMapper.xml `${sql}` 단 1곳, 결정론적 화이트리스트 가드 통과분만 실행(backend NlQuerySqlGuard.java: 단일SELECT·테이블 화이트리스트·주석/다중문/PII/LIMIT 검증 56~130). 그 외 전부 `#{}` 바인딩. |
| SC-3 안전한 난수 | **PASS** | — | OTP IV SecureRandom(backend SecretCipher.java 31,44), OTP secret은 totp SecretGenerator(CSPRNG), 웹훅 HmacSHA256. |
| SC-4 웹뷰 안전설정 | **N-A→PASS** | — | WebView 미사용(mobile grep 0건). 공격면 없음. |
| SC-5 딥링크 검증 | **부분 PASS** | Low | scheme `kintex`(app.json 6). expo-router 라우팅. 딥링크 파라미터 명시 검증 로직 추가 권장(현재 인증가드로 보호). |
| SC-6 스택트레이스 미노출 | **PASS** | — | 서버 include-stacktrace/message: never(application.yml 13~14), GlobalExceptionHandler 요약만 반환·스택 미저장(51~62), 401 봉투(SecurityConfig 49~57). 클라 요약 메시지만 노출(api.ts 82~89). |
| SC-7 암호화 저장 | **PASS** | — | OTP secret AES-256-GCM 봉인(backend SecretCipher.java seal/open 39~77, `enc:v1:` 포맷). |
## 6. 빌드·배포
| ID | 판정 | 위험도 | 증거 / 근거 |
|----|------|--------|-------------|
| BD-1 릴리스 로그/소스맵 | **PASS(경미)** | Low | console 로그 0건. 소스맵 배포 제외·`__DEV__` 스트립은 빌드시 확인 권장. |
| BD-2 최소 권한 | **부분 PASS** | Med | app.json에 명시 권한 선언 없음(기본 Expo 권한만). expo-image-picker 사용(package.json 19) → 카메라/사진 권한 발생. `blockedPermissions`로 불필요 권한 제거 권장. |
| BD-3 서드파티 취약점 | **미검증** | Med | `npm audit` 미실행(감사 범위상 코드 실행 배제). 의존성 버전은 SDK51 정렬 최신(package.json). CI에 audit 게이트 권장. |
| BD-4 서버 오류응답 | **PASS** | — | IP/SSH/비밀번호/스택트레이스 응답 미노출(GlobalExceptionHandler, ServerOut 스키마 규칙 준수). error_log 요약 1줄만(51~59). |
| BD-5 CORS/헤더 | **부분 PASS** | Med | `.cors(cors -> {})`에 CorsConfigurationSource 빈 미정의(backend SecurityConfig.java 35) → 명시 허용출처 없음(와일드카드 `*`도 없음). 운영 도메인 명시 권장. |
| BD-6 시크릿 주입 | **PASS(주의)** | Med | JWT_SECRET·KINTEX_OTP_ENC_KEY·DB/SMTP/ADMIN 비번 전부 env 주입(application.yml 27,43,66,76,83). **주의**: JWT_SECRET 개발 폴백 `CHANGE_ME_DEV_ONLY...`(66), OTP 키 미주입 시 개발 파생키 경고(SecretCipher 88) → **운영 env 주입 필수**. GEMINI_API_KEY는 워커 전용, 백엔드 미사용(9). |
---
## 추가 관찰(비차단, 운영 하드닝)
- **로그 레벨**: `logging.level.com.zioinfo.kintex: DEBUG`(application.yml 106) — 운영 프로파일에서 INFO로 하향 권장(민감 파라미터 verbose 노출 위험).
- **JWT 저장 위치**: 웹 타깃만 AsyncStorage 폴백(secureStore.ts 10) — 관람객 B2C 웹뷰/PWA 배포 시 XSS 토큰 탈취면 존재. 네이티브 앱은 무관.
- **웹훅 인증**: `/api/webhooks/in/**` permitAll이나 토큰+HMAC-SHA256 이중검증·상수시간 비교(WebhookSignature.java `MessageDigest.isEqual` 58) — 양호.
---
## 개선 백로그 (FAIL/취약 → 구현 작업 전환)
> 담당: 위변조·SecureStore·화면보호·network config = **kintex-mobile-dev** / 백엔드 = **kintex-backend-dev** / EAS·빌드설정 = **kintex-devops-dev**
> 코드 편집은 각 담당 에이전트가 수행(본 QA는 문서·감사만). SDK51 호환 주의 병기.
### P0 (High · 공공 대민 필수)
| # | 항목 | 담당 | 권장 구현 | SDK51 주의 |
|---|------|------|-----------|-----------|
| B1 | AT-1 루트/탈옥 탐지 | mobile-dev | `jail-monkey`(isJailBroken/canMockLocation) 또는 `freerasp-react-native` 도입 → 루팅 단말 경고·민감기능 제한. config plugin으로 prebuild 반영 | jail-monkey는 config plugin 필요(bare/prebuild). Expo 관리형은 `expo prebuild` 또는 dev-client 필요 — devops와 협의 |
| B2 | AT-2 앱 무결성 검증 | mobile-dev + backend-dev | Android **Play Integrity API**·iOS **App Attest** 토큰을 로그인/민감 API에 첨부 → backend에서 검증. 서명 지문 확인 | Expo 관리형은 네이티브 모듈 → dev-client/prebuild. 서버 검증 엔드포인트 신설 |
| B3 | AT-3 난독화 선언 | devops-dev | app.json `android.jsEngine:"hermes"`(또는 최상위) 명시 + `expo-build-properties``android.enableProguardInReleaseBuilds:true`·`enableShrinkResources:true` | expo-build-properties 플러그인 추가·prebuild 필요. Hermes는 RN0.74 기본이나 명시로 회귀 방지 |
| B4 | DP-4 화면캡처 방지 | mobile-dev | `expo-screen-capture` `preventScreenCaptureAsync()`를 2FA·티켓QR·개인정보 화면 focus 시 적용 | expo-screen-capture SDK51 호환. iOS는 캡처 감지만·Android는 FLAG_SECURE 차단 |
### P1 (Med)
| # | 항목 | 담당 | 권장 구현 | SDK51 주의 |
|---|------|------|-----------|-----------|
| B5 | NW-1 cleartext 차단 명시 | devops-dev | `expo-build-properties``android.usesCleartextTraffic:false` 명시 + iOS ATS 유지 확인 | prebuild 반영 |
| B6 | NW-3 인증서 피닝 | mobile-dev + devops-dev | `react-native-ssl-pinning` 또는 build-properties networkSecurityConfig `pin-set`(SPKI 해시). 백엔드 인증서 갱신 로테이션 절차 병행 | 피닝 만료/로테이션 운영 리스크 — 백업 핀 2개 이상 |
| B7 | DP-5 백그라운드 마스킹 | mobile-dev | AppState 'inactive/background' 시 오버레이 블러/스플래시 표시(민감 화면) | RN AppState + 조건부 렌더 |
| B8 | AU-2 생체 로컬잠금 | mobile-dev | `expo-local-authentication`으로 앱 재진입·민감작업 시 생체 재인증(내정보 트랙과 통합) | SDK51 호환. 폴백(PIN) 설계 |
| B9 | AT-4/AT-6 디버거·개발자모드 탐지 | mobile-dev | freerasp의 debugger/emulator/hooking 탐지 콜백 활용(B1과 통합) | B1과 동일 도입 경로 |
| B10 | BD-2 권한 최소화 | devops-dev | app.json `android.permissions` 명시 + `blockedPermissions`로 불필요 권한 제거. image-picker 권한 문자열(카메라/사진) 최소화 | image-picker는 권한 필요 — 사용처만 요청 |
| B11 | BD-5 CORS 명시 | backend-dev | `CorsConfigurationSource` 빈으로 운영 도메인(`kintex.zioinfo.co.kr`·`kintex.wise.ai.kr`)만 allowedOrigins 명시, 와일드카드 금지 | Spring Security 6 lambda DSL |
| B12 | BD-6 운영 시크릿 강제 | devops-dev + backend-dev | 운영 배포 시 JWT_SECRET·KINTEX_OTP_ENC_KEY env 주입 검증(부재 시 기동 실패 fail-fast 검토), DEBUG 로그 INFO 하향 | 개발 폴백 값 운영 유입 차단 |
| B13 | BD-3 의존성 CVE | devops-dev | CI에 `npm audit --audit-level=high`(mobile) + gradle 의존성 스캔 게이트 추가 | 정기 스캔 |
### P2 (Low · 하드닝)
| # | 항목 | 담당 | 권장 구현 |
|---|------|------|-----------|
| B14 | AT-5 debuggable 검증 | devops-dev | 릴리스 빌드 `android:debuggable=false`·소스맵 미배포·`__DEV__` 스트립 CI 검증 |
| B15 | SC-5 딥링크 검증 | mobile-dev | `kintex://` 딥링크 진입 파라미터 스키마 검증·화이트리스트 라우팅 |
| B16 | DP-6 클립보드 정책 | mobile-dev | 티켓QR·개인정보 필드 `contextMenuHidden`·민감 복사 자동클리어 |
---
## 재검증 게이트(구현 후)
1. `mobile/`: jail-monkey/freerasp 도입·screen-capture 적용·build-properties(hermes/R8/cleartext) 반영 → 본 문서 AT-*/DP-4/NW-1 재판정.
2. `backend`: CorsConfigurationSource 빈·운영 로그레벨·시크릿 fail-fast → BD-5/6 재판정.
3. 빌드 회귀: `mobile tsc --noEmit` EXIT 0, `./gradlew build` 통과 확인.

View File

@ -0,0 +1,85 @@
# KINTEX AI 모바일 앱 + 백엔드 보안 체크리스트
> 대상: `mobile/`(Expo SDK 51 · expo-router · RN 0.74) + `src/backend`(Spring Boot 3.x · MyBatis · PostgreSQL)
> 기준 표준: **OWASP MASVS v2 / MSTG**, **행안부·KISA "모바일 대민 서비스 앱 소스코드 검증 가이드"**, **전자정부 모바일 표준보안 요구사항**
> 킨텍스는 공공 전시장(한국국제전시장) 대상 서비스 — 공공기관 대민 앱 기준을 함께 적용한다.
> 판정 결과는 `mobile-security-audit.md`, 개선 작업은 동 문서 하단 백로그 참조.
## 표준 매핑 표기
- MASVS-STORAGE / -CRYPTO / -AUTH / -NETWORK / -PLATFORM / -CODE / -RESILIENCE
- KISA: 대민 앱 검증 가이드 항목군(위변조·데이터보호·통신·인증·시큐어코딩·서버)
---
## 1. 앱 위변조 방지 (Anti-Tampering) — MASVS-RESILIENCE / KISA 위변조
| ID | 항목 | 기준 | 근거 표준 |
|----|------|------|-----------|
| AT-1 | 루트/탈옥 탐지 | 루팅(Android)·탈옥(iOS) 단말에서 실행 시 경고·기능 제한·차단 | MASVS-RESILIENCE-1, KISA 위변조-1 |
| AT-2 | 앱 서명/무결성 검증 | 서명 인증서 지문 확인, 리패키징(재서명) 탐지, Play Integrity/App Attest 검토 | MASVS-RESILIENCE-2, KISA 위변조-2 |
| AT-3 | 코드 난독화 | JS 번들 Hermes 바이트코드 컴파일, 네이티브 R8/ProGuard 난독화·최적화 활성 | MASVS-RESILIENCE-3 |
| AT-4 | 디버거/후킹 탐지 | 디버거 attach·Frida/Xposed 후킹·에뮬레이터 실행 탐지 | MASVS-RESILIENCE-4 |
| AT-5 | 디버그 비활성 | 릴리스 빌드 `android:debuggable=false`, JS `__DEV__` 코드 제거 | MASVS-RESILIENCE, KISA 위변조-3 |
| AT-6 | 개발자모드/USB 디버깅 대응 | 민감 작업 시 개발자옵션·USB 디버깅 활성 감지·경고 | KISA 위변조-4 |
## 2. 데이터 보호 (Data at Rest) — MASVS-STORAGE / KISA 데이터보호
| ID | 항목 | 기준 | 근거 표준 |
|----|------|------|-----------|
| DP-1 | 토큰 안전저장 | JWT·세션 토큰은 Keychain(iOS)/Keystore(Android) = expo-secure-store, 평문 AsyncStorage 금지 | MASVS-STORAGE-1, KISA 데이터-1 |
| DP-2 | 하드코딩 비밀 0 | 소스·설정에 API 키·비밀번호·토큰 하드코딩 없음(grep 0건) | MASVS-STORAGE-2, KISA 데이터-2 |
| DP-3 | 비밀번호 미저장 | 로그인 비밀번호를 단말에 어떤 형태로도 저장하지 않음 | MASVS-STORAGE-1 |
| DP-4 | 화면캡처/오버레이 방지 | 민감 화면(2FA·티켓QR·개인정보) FLAG_SECURE로 캡처·녹화·오버레이 차단 | MASVS-PLATFORM-3, KISA 데이터-4 |
| DP-5 | 백그라운드 스냅샷 | 앱 전환 시 민감 화면 마스킹(태스크 스위처 스냅샷 노출 방지) | MASVS-STORAGE-7 |
| DP-6 | 클립보드 보호 | 비밀번호·토큰·개인정보 필드 클립보드 복사 최소화·자동삭제 | MASVS-STORAGE-8 |
| DP-7 | 로그 내 민감정보 0 | 토큰·비밀번호·개인정보를 로그(console/logcat)에 남기지 않음 | MASVS-STORAGE-3, KISA 데이터-3 |
## 3. 통신 보안 (Data in Transit) — MASVS-NETWORK / KISA 통신
| ID | 항목 | 기준 | 근거 표준 |
|----|------|------|-----------|
| NW-1 | TLS 강제 | 모든 API가 HTTPS. 평문 HTTP(cleartext) 차단 — Android networkSecurityConfig(`cleartextTrafficPermitted=false`), iOS ATS 유지 | MASVS-NETWORK-1, KISA 통신-1 |
| NW-2 | 인증서 검증 | 서버 인증서·호스트명 검증(기본 트러스트스토어). 검증 우회 코드 없음 | MASVS-NETWORK-2 |
| NW-3 | 인증서 피닝 | 공공/민감 앱 권고 — 서버 인증서·공개키 피닝 검토·적용 | MASVS-NETWORK-4, KISA 통신-2 |
| NW-4 | API 키 미노출 | 통신에 클라이언트 하드코딩 API 키 사용 안 함(base URL만 공개) | MASVS-STORAGE-2 |
## 4. 인증·세션 — MASVS-AUTH / KISA 인증
| ID | 항목 | 기준 | 근거 표준 |
|----|------|------|-----------|
| AU-1 | 2차 인증(2FA) | TOTP 기반 OTP 로그인 흐름(OTP_REQUIRED→재제출) 지원 | MASVS-AUTH-1, KISA 인증-1 |
| AU-2 | 생체·로컬 잠금 | 앱 로컬 생체(Face/Touch/지문) 재잠금 옵션(민감 진입) | MASVS-AUTH-2 |
| AU-3 | 세션 만료·재발급 | 액세스 토큰 TTL, 만료 시 401 처리·재로그인 유도 | MASVS-AUTH-3 |
| AU-4 | 로그인 실패 잠금 | 연속 실패 임계 초과 시 계정 잠금(서버), 계정 열거 방지 일반화 메시지 | MASVS-AUTH-6, KISA 인증-2 |
| AU-5 | 서버측 권한검증 | 행사 단위 RBAC를 서버에서 강제(클라이언트 신뢰 금지) | MASVS-AUTH-4 |
## 5. 시큐어 코딩 — MASVS-CODE / KISA 시큐어코딩
| ID | 항목 | 기준 | 근거 표준 |
|----|------|------|-----------|
| SC-1 | 입력검증 | 클라이언트·서버 이중 입력검증(이메일·비밀번호 길이·형식) | MASVS-CODE-4, KISA SC-1 |
| SC-2 | SQL 인젝션 방지 | MyBatis 파라미터 바인딩(`#{}`), 동적 `${}` 미사용 또는 화이트리스트 가드 | KISA SC-2 |
| SC-3 | 안전한 난수 | 토큰·OTP·IV 생성에 CSPRNG(SecureRandom) 사용 | MASVS-CRYPTO-3 |
| SC-4 | 웹뷰 안전설정 | (있으면) JS브리지 최소화·파일접근 차단·신뢰 URL만 로드 | MASVS-PLATFORM-2 |
| SC-5 | 딥링크 검증 | 커스텀 스킴(`kintex://`) 파라미터 검증, 미검증 라우팅 금지 | MASVS-PLATFORM-1 |
| SC-6 | 예외/스택트레이스 미노출 | 서버·클라이언트 모두 스택트레이스·내부 세부 미노출, 요약 메시지만 | MASVS-CODE-5, KISA 서버-4 |
| SC-7 | 암호화 저장 | 민감 시크릿(OTP secret 등) AES-256-GCM 저장 | MASVS-CRYPTO-1 |
## 6. 빌드·배포 — MASVS-RESILIENCE / KISA 서버·배포
| ID | 항목 | 기준 | 근거 표준 |
|----|------|------|-----------|
| BD-1 | 릴리스 로그/소스맵 | 릴리스 빌드에 디버그 로그·소스맵 미포함 | MASVS-RESILIENCE-3 |
| BD-2 | 최소 권한 | app.json 권한 최소화, 불필요 권한 제거(blockedPermissions) | MASVS-PLATFORM-1, KISA 서버-1 |
| BD-3 | 서드파티 취약점 | package.json 의존성 CVE 스캔(npm audit), 최신 유지 | MASVS-CODE-3 |
| BD-4 | 서버 오류응답 | 백엔드 오류 응답에 IP·SSH·비밀번호·스택트레이스 미노출 | KISA 서버-3·4 |
| BD-5 | CORS/헤더 | CORS 허용출처 명시(와일드카드 금지), 보안 헤더 | KISA 서버-2 |
| BD-6 | 시크릿 주입 | JWT_SECRET·암호화 키·DB/SMTP 비번은 env 주입, 코드/커밋 미포함 | MASVS-STORAGE-2 |
---
## 판정 기호
- **PASS** — 기준 충족(증거 있음)
- **FAIL** — 기준 미충족(구현 필요)
- **N-A** — 현재 미적용 대상(해당 기능 부재)
- 위험도: **High**(공공/민감 필수) · **Med** · **Low**

View File

@ -28,7 +28,8 @@ API 베이스는 `app.json > extra.apiBase` 또는 `EXPO_PUBLIC_API_BASE` 환경
| `app/(tabs)/index.tsx` | 홈/대시보드(역할별 진입) | design §2-2 |
| `app/(tabs)/field.tsx` | 현장 허브(역할 분기) | — |
| `app/(tabs)/gallery.tsx` | 시각화 갤러리(워터마크 상시) | SCR-12 |
| `app/(tabs)/more.tsx` | 계정·서버 상태·로그아웃 | — |
| `app/(tabs)/more.tsx` | 계정(내정보 진입)·서버 상태·로그아웃 | — |
| `app/profile.tsx` | 내정보(WISE mypage 이식·생체인식 잠금·프로필 사진 등록) | WISE `uiws/mypage.tsx` |
| `app/checklist.tsx` | 현장 체크리스트(48px 터치·오프라인·사진) | SCR-M1 |
| `app/inspection.tsx` | 현장 검수(승인/반려·AI 플래그) | SCR-M2 |
@ -37,6 +38,14 @@ API 베이스는 `app.json > extra.apiBase` 또는 `EXPO_PUBLIC_API_BASE` 환경
- AI 생성 이미지는 `components/AiImage.tsx`가 워터마크 + "AI 생성 예상 이미지" 고지를 **항상** 표시(제거 불가).
- 비밀번호는 저장하지 않음. "아이디 기억"은 이메일만 AsyncStorage 보관.
## 보안 하드닝(위변조 방지 · 화면보호)
근거: `docs/security/mobile-security-audit.md`(B1~B13) · 상세: `_workspace/08_mobile_security_hardening.md`. 모두 방어적(네이티브 미지원 기기 graceful).
- **위변조 탐지(B1/B9)**: `lib/integrity.ts`(jail-monkey) — 루팅·후킹·디버거·개발자모드·USB 디버깅 탐지. 로그인 시 비차단 경고(공공 대민 정책).
- **화면캡처 방지(B4)+백그라운드 마스킹(B7)**: `context/SecureScreenContext.tsx``useSecureScreen(key)` — 2FA 로그인·티켓 QR·내정보에서 캡처 차단(FLAG_SECURE)+태스크 스위처 마스킹.
- **난독화(B3)·cleartext 차단(B5)·권한 최소화(B10)**: `app.json`(Hermes·expo-build-properties R8/ProGuard·usesCleartextTraffic:false·iOS ATS·android.permissions/blockedPermissions).
- **앱 무결성 토큰(B2 전송부)**: `lib/api.ts``X-Integrity-Token` 조건부 첨부(발급기 등록 시). 서버 검증은 backend-dev 인계.
- **한계**: 인증서 피닝(B6)은 회귀 위험으로 미도입 — 커스텀 config plugin 경로 문서화(`_workspace/08`). EAS 실빌드는 devops·G3 게이트.
## 남은 작업
- 실 화면 데이터 API 배선 확대(부스 목록·검수 요청 제출 — 백엔드 M6/C-4 확장 대기).
- 앱 아이콘/스플래시 에셋(`assets/`), i18n, 푸시 알림.

View File

@ -7,6 +7,7 @@
"version": "0.1.0",
"orientation": "portrait",
"userInterfaceStyle": "light",
"jsEngine": "hermes",
"icon": "./assets/icon.png",
"splash": {
"image": "./assets/splash.png",
@ -18,7 +19,12 @@
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "kr.co.zioinfo.kintex"
"bundleIdentifier": "kr.co.zioinfo.kintex",
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": false
}
}
},
"android": {
"package": "kr.co.zioinfo.kintex",
@ -26,7 +32,22 @@
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0066B3"
}
},
"permissions": [
"android.permission.CAMERA",
"android.permission.USE_BIOMETRIC",
"android.permission.USE_FINGERPRINT",
"android.permission.READ_MEDIA_IMAGES"
],
"blockedPermissions": [
"android.permission.RECORD_AUDIO",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.ACCESS_MEDIA_LOCATION",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION"
]
},
"web": {
"bundler": "metro",
@ -36,7 +57,32 @@
"plugins": [
"expo-router",
"expo-secure-store",
"expo-font"
"expo-font",
"expo-screen-capture",
[
"expo-image-picker",
{
"photosPermission": "프로필 사진 등록을 위해 사진 보관함 접근이 필요합니다.",
"cameraPermission": "프로필 사진 촬영을 위해 카메라 접근이 필요합니다."
}
],
[
"expo-local-authentication",
{
"faceIDPermission": "생체인식 잠금 해제를 위해 Face ID 사용이 필요합니다."
}
],
[
"expo-build-properties",
{
"android": {
"enableProguardInReleaseBuilds": true,
"enableShrinkResources": true,
"usesCleartextTraffic": false
},
"ios": {}
}
]
],
"experiments": {
"typedRoutes": false

View File

@ -5,6 +5,7 @@ import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Avatar } from '../../components/Avatar';
import { Card } from '../../components/Card';
import { useAuth } from '../../context/AuthContext';
import { api, isDegraded } from '../../lib/api';
@ -36,12 +37,25 @@ export default function MoreScreen() {
return (
<ScrollView contentContainerStyle={styles.scroll}>
<Card>
<Text style={styles.name}>{user?.displayName ?? '사용자'}</Text>
<Text style={styles.meta}>
{user?.hallManager ? '킨텍스 내부 계정 (홀매니저)' : '일반 계정'}
</Text>
</Card>
{/* 내 정보 진입 — 프로필/생체인식 잠금/사진 등록 (SCR-M 내정보) */}
<Pressable
onPress={() => router.push('/profile')}
accessibilityRole="button"
accessibilityLabel="내 정보 열기"
>
<Card>
<View style={styles.meRow}>
<Avatar name={user?.displayName} size={48} />
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={styles.name}>{user?.displayName ?? '사용자'}</Text>
<Text style={styles.meta}>
{user?.hallManager ? '킨텍스 내부 계정 (홀매니저)' : '일반 계정'}
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.neutral500} />
</View>
</Card>
</Pressable>
<Card>
<Row label="API 서버" value={API_BASE} />
@ -75,6 +89,7 @@ function Row({ label, value }: { label: string; value: string }) {
const styles = StyleSheet.create({
scroll: { padding: spacing.md, gap: spacing.md },
meRow: { flexDirection: 'row', alignItems: 'center' },
name: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
meta: { marginTop: 4, color: colors.neutral500, fontSize: type.caption.fontSize },
row: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, paddingVertical: 4 },

View File

@ -8,6 +8,7 @@ import React from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AuthProvider } from '../context/AuthContext';
import { SecureScreenProvider } from '../context/SecureScreenContext';
import { colors } from '../theme';
export default function RootLayout() {
@ -15,6 +16,7 @@ export default function RootLayout() {
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<AuthProvider>
<SecureScreenProvider>
<StatusBar style="dark" />
<Stack
screenOptions={{
@ -29,11 +31,13 @@ export default function RootLayout() {
<Stack.Screen name="register" options={{ title: '회원가입' }} />
<Stack.Screen name="forgot-password" options={{ title: '비밀번호 찾기' }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="profile" options={{ title: '내 정보' }} />
<Stack.Screen name="checklist" options={{ title: '현장 체크리스트' }} />
<Stack.Screen name="inspection" options={{ title: '현장 검수' }} />
<Stack.Screen name="tickets/index" options={{ title: '내 티켓' }} />
<Stack.Screen name="tickets/select" options={{ title: '티켓 예매' }} />
</Stack>
</SecureScreenProvider>
</AuthProvider>
</SafeAreaProvider>
</GestureHandlerRootView>

View File

@ -19,8 +19,10 @@ import { Banner } from '../components/Banner';
import { Button } from '../components/Button';
import { Field } from '../components/Field';
import { useAuth } from '../context/AuthContext';
import { useSecureScreen } from '../context/SecureScreenContext';
import { ApiRequestError } from '../lib/api';
import { isOtpRequired, login } from '../lib/auth';
import { useDeviceIntegrity } from '../lib/integrity';
import { prefDelete, prefGet, prefSet } from '../lib/secureStore';
import { colors, radius, spacing, touch, type } from '../theme';
@ -30,6 +32,11 @@ export default function LoginScreen() {
const insets = useSafeAreaInsets();
const { signIn } = useAuth();
// 2FA/자격증명 입력 화면 — 캡처 차단 + 백그라운드 마스킹(B4/B7).
useSecureScreen('login');
// 위변조(루팅·후킹·디버거) 단말 경고(B1/B9) — 비차단 고지.
const integrity = useDeviceIntegrity();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [otp, setOtp] = useState('');
@ -99,6 +106,14 @@ export default function LoginScreen() {
<Text style={styles.brandCaption}>AI · </Text>
</View>
{/* 위변조 단말 경고(비차단) — 공공 대민 정책: 경고 후 이용 제한 안내 */}
{integrity.compromised ? (
<Banner tone="warning">
(··). ·
.
</Banner>
) : null}
{/* 로그인 카드 */}
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>

688
mobile/app/profile.tsx Normal file
View File

@ -0,0 +1,688 @@
/*
* SCR-M(/) WISE mypage (·· ).
* 레퍼런스: guardia-messenger/app/uiws/mypage.tsx.
* · (+ )
* · (InfoRow) · /(2FA ) · · ·
* : ( ) (/).
* 보안: 릿·· console로 . OS에만.
*/
import { Ionicons } from '@expo/vector-icons';
import { router, useFocusEffect } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Image,
Modal,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from 'react-native';
import { Avatar } from '../components/Avatar';
import { Banner } from '../components/Banner';
import { Card } from '../components/Card';
import { useAuth } from '../context/AuthContext';
import { useSecureScreen } from '../context/SecureScreenContext';
import { ApiRequestError, isDegraded } from '../lib/api';
import {
authenticateBiometric,
isBiometricEnabled,
isBiometricSupported,
isSessionUnlocked,
markSessionUnlocked,
setBiometricEnabled,
} from '../lib/biometric';
import {
isPickerAvailable,
pickFromCamera,
pickFromLibrary,
type PickedImage,
} from '../lib/imagePick';
import { avatarUrl, getMe, getOtpStatus, uploadAvatar } from '../lib/profile';
import { prefGet, prefSet } from '../lib/secureStore';
import type { EventRole, MePrincipal, OtpStatusDto } from '../lib/types';
import { colors, radius, spacing, touch, type } from '../theme';
const roleLabel: Record<EventRole, string> = {
ORGANIZER: '주최자',
EXHIBITOR: '참가업체',
CONTRACTOR: '장치·시공업체',
HALL_MANAGER: '홀매니저',
};
// 환경설정(이 기기 로컬 저장 — 서버 동기화 계약 부재, web MyPage와 동일 정책).
const PREFS_KEY = 'kintex.prefs';
interface Prefs {
notifyDeadline: boolean;
notifyApproval: boolean;
notifyPayment: boolean;
}
const DEFAULT_PREFS: Prefs = { notifyDeadline: true, notifyApproval: true, notifyPayment: true };
export default function ProfileScreen() {
const { user, signOut } = useAuth();
// 개인정보 화면 — 캡처 차단 + 백그라운드 마스킹(B4/B7).
useSecureScreen('profile');
const [me, setMe] = useState<MePrincipal | null>(null);
const [loading, setLoading] = useState(false);
const [loadErr, setLoadErr] = useState('');
const [otp, setOtp] = useState<OtpStatusDto | null>(null);
// 프로필 사진 — 선택 → 미리보기(적용/취소) → 적용 시에만 업로드(WISE 불변식).
const [photoUrl, setPhotoUrl] = useState<string | null>(null);
const [photoBusy, setPhotoBusy] = useState(false);
const [previewImg, setPreviewImg] = useState<PickedImage | null>(null);
const [previewErr, setPreviewErr] = useState('');
const [photoPending, setPhotoPending] = useState(false); // 서버 미반영(degrade) 로컬 프리뷰 상태
// 생체인식
const [bioSupported, setBioSupported] = useState(false);
const [bioEnabled, setBioEnabled] = useState(false);
const [locked, setLocked] = useState(false); // 잠금 게이트 통과 전 콘텐츠 차단
// 환경설정
const [prefs, setPrefs] = useState<Prefs>(DEFAULT_PREFS);
const displayName = me?.displayName ?? user?.displayName ?? '사용자';
const loadProfile = useCallback(async () => {
setLoading(true);
setLoadErr('');
try {
const p = await getMe();
setMe(p);
setPhotoUrl(avatarUrl(p.userId));
setPhotoPending(false);
} catch (e) {
// /me 실패 시 최소한 로그인 세션 정보로 화면 유지(전면 오류 회피).
setLoadErr(e instanceof ApiRequestError ? e.message : '내정보를 불러오지 못했습니다.');
} finally {
setLoading(false);
}
try {
setOtp(await getOtpStatus());
} catch {
setOtp(null); // 상태 조회 실패는 무시(섹션은 안내로 degrade)
}
}, []);
// 환경설정 로드
useEffect(() => {
prefGet(PREFS_KEY)
.then((raw) => {
if (raw) setPrefs({ ...DEFAULT_PREFS, ...(JSON.parse(raw) as Partial<Prefs>) });
})
.catch(() => setPrefs(DEFAULT_PREFS));
}, []);
// 생체 지원/토글 상태
useEffect(() => {
isBiometricSupported().then(setBioSupported).catch(() => setBioSupported(false));
isBiometricEnabled().then(setBioEnabled).catch(() => setBioEnabled(false));
}, []);
// 진입 게이트 — 생체 잠금 활성 + 세션 미해제 시 인증 요구.
const runLockGate = useCallback(async () => {
const enabled = await isBiometricEnabled();
if (!enabled || isSessionUnlocked()) {
setLocked(false);
return;
}
setLocked(true);
const ok = await authenticateBiometric();
if (ok) {
markSessionUnlocked();
setLocked(false);
}
}, []);
useFocusEffect(
useCallback(() => {
runLockGate();
loadProfile();
}, [runLockGate, loadProfile]),
);
const savePrefs = async (next: Prefs) => {
setPrefs(next);
try {
await prefSet(PREFS_KEY, JSON.stringify(next));
} catch {
/* graceful */
}
};
const toggleBiometric = async (next: boolean) => {
if (next) {
// 활성화 전 1회 본인 확인.
const ok = await authenticateBiometric();
if (!ok) {
Alert.alert('생체인식', '생체 인증에 실패했습니다. 다시 시도해 주세요.');
return;
}
markSessionUnlocked(); // 방금 인증했으므로 이 세션은 통과 처리(즉시 재프롬프트 방지).
}
await setBiometricEnabled(next);
setBioEnabled(next);
};
// ── 사진 선택(미리보기용) — 업로드 호출 없음 ──
const pickForPreview = async (from: 'library' | 'camera') => {
if (photoBusy) return;
if (!isPickerAvailable()) {
Alert.alert('프로필 사진', '이미지 선택 기능을 사용할 수 없는 기기입니다.');
return;
}
const img = from === 'library' ? await pickFromLibrary() : await pickFromCamera();
if (!img) {
// 권한 거부/취소/미설치: 모달이 열려있으면 유지, 아니면 무동작 + 권한 안내.
return;
}
setPreviewImg(img);
setPreviewErr('');
};
// ── 적용 — 이 시점에만 1회 업로드. 미구현/네트워크면 로컬 프리뷰로 degrade ──
const applyPhoto = async () => {
if (!previewImg || photoBusy) return;
setPhotoBusy(true);
setPreviewErr('');
try {
const res = await uploadAvatar(previewImg);
const base = res.photoUrl || (me ? avatarUrl(me.userId) : null);
setPhotoUrl(base ? `${base}${base.includes('?') ? '&' : '?'}_t=${Date.now()}` : previewImg.uri);
setPhotoPending(false);
setPreviewImg(null);
} catch (e) {
if (isDegraded(e)) {
// 백엔드 아바타 업로드 미구현 — 로컬 프리뷰 유지 + 대기 안내(파괴적 실패 아님).
setPhotoUrl(previewImg.uri);
setPhotoPending(true);
setPreviewImg(null);
} else {
setPreviewErr(e instanceof ApiRequestError ? e.message : '사진 업로드에 실패했습니다.');
}
} finally {
setPhotoBusy(false);
}
};
const cancelPreview = () => {
if (photoBusy) return;
setPreviewImg(null);
setPreviewErr('');
};
const onChangePhoto = () => {
if (photoBusy) return;
Alert.alert('프로필 사진', '사진을 선택하세요', [
{ text: '갤러리', onPress: () => pickForPreview('library') },
{ text: '카메라', onPress: () => pickForPreview('camera') },
{ text: '취소', style: 'cancel' },
]);
};
const onSignOut = () => {
Alert.alert('로그아웃', '로그아웃하시겠습니까?', [
{ text: '취소', style: 'cancel' },
{
text: '로그아웃',
style: 'destructive',
onPress: async () => {
await signOut();
router.replace('/login');
},
},
]);
};
// 생체 잠금 게이트 — 미통과 시 콘텐츠 차단 화면.
if (locked) {
return (
<View style={styles.lockWrap}>
<Ionicons name="finger-print" size={56} color={colors.primary600} />
<Text style={styles.lockTitle}> </Text>
<Text style={styles.lockHint}> Face로 .</Text>
<Pressable
style={styles.lockBtn}
onPress={runLockGate}
accessibilityRole="button"
accessibilityLabel="생체 인증 다시 시도"
>
<Text style={styles.lockBtnText}> </Text>
</Pressable>
</View>
);
}
const roles = me?.eventRoles ? Object.values(me.eventRoles) : [];
return (
<ScrollView style={styles.wrap} contentContainerStyle={styles.scroll}>
{/* ── 프로필 ── */}
<Text style={styles.section}> </Text>
{loading ? (
<ActivityIndicator color={colors.primary600} style={{ marginVertical: spacing.md }} />
) : (
<Card>
<View style={styles.profileHead}>
<Pressable
testID="profile-photo"
onPress={onChangePhoto}
disabled={photoBusy}
accessibilityLabel="프로필 사진 변경"
accessibilityRole="button"
>
<Avatar photoUrl={photoUrl} name={displayName} size={56} testID="profile-avatar" />
<View style={styles.photoEdit}>
{photoBusy ? (
<ActivityIndicator color={colors.white} size="small" />
) : (
<Ionicons name="camera" size={13} color={colors.white} />
)}
</View>
</Pressable>
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={styles.profileName}>{displayName}</Text>
<Text style={styles.profileId}>
{me?.userId ?? user?.userId ?? '-'}
{me?.roleCode ? ` · ${me.roleCode}` : ''}
</Text>
</View>
</View>
{photoPending ? (
<Text style={styles.pendingNote}>
.
</Text>
) : null}
{loadErr ? <Banner tone="warning">{loadErr}</Banner> : null}
<InfoRow label="이름" value={displayName} />
<InfoRow label="사용자 ID" value={me?.userId ?? user?.userId ?? null} testID="profile-userid" />
<InfoRow label="전역 역할" value={me?.roleCode ?? null} />
<InfoRow label="전시관(테넌트)" value={me?.tenantId ?? null} />
<InfoRow
label="구분"
value={me?.hallManager ?? user?.hallManager ? '킨텍스 내부(홀매니저)' : '일반 계정'}
/>
</Card>
)}
{/* ── 참여 행사 역할 ── */}
{roles.length > 0 ? (
<>
<Text style={styles.section}> </Text>
<Card>
{Object.entries(me!.eventRoles).map(([eventId, r]) => (
<View key={eventId} style={styles.infoRow}>
<Text style={styles.infoLabel} numberOfLines={1}>
{eventId}
</Text>
<View style={styles.rolePill}>
<Text style={styles.rolePillText}>{roleLabel[r] ?? r}</Text>
</View>
</View>
))}
</Card>
</>
) : null}
{/* ── 계정·보안(2차 인증) ── */}
<Text style={styles.section}> · </Text>
<Card>
<View style={styles.bioRow}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={styles.rowTitle}>2 (OTP)</Text>
<Text style={styles.hint}>
{otp?.otpEnabled
? `활성화됨 · ${otp.verifyMethod === 'OTP' ? 'Authenticator(OTP)' : otp.verifyMethod || 'OTP'}`
: '미설정 — 로그인 시 인증 앱(TOTP) 2단계 인증을 사용합니다.'}
</Text>
</View>
<View style={otp?.otpEnabled ? styles.badgeOn : styles.badgeOff}>
<Text style={otp?.otpEnabled ? styles.badgeOnText : styles.badgeOffText}>
{otp?.otpEnabled ? '켜짐' : '꺼짐'}
</Text>
</View>
</View>
</Card>
{/* ── 생체인식 잠금 ── */}
<Text style={styles.section}> </Text>
<Card>
{bioSupported ? (
<View style={styles.bioRow}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={styles.rowTitle}> · Face </Text>
<Text style={styles.hint}>
Face로 . 2 .
</Text>
</View>
<Switch
testID="profile-biometric-toggle"
value={bioEnabled}
onValueChange={toggleBiometric}
trackColor={{ false: colors.neutral200, true: colors.primary600 }}
thumbColor={colors.white}
/>
</View>
) : (
<Text style={styles.hint}>
/Face가 .
.
</Text>
)}
</Card>
{/* ── 환경설정(알림) ── */}
<Text style={styles.section}> </Text>
<Card>
{(
[
['notifyDeadline', '마감 D-데이 알림'],
['notifyApproval', '승인·검수 알림'],
['notifyPayment', '결제·정산 알림'],
] as const
).map(([key, label]) => (
<View key={key} style={styles.bioRow}>
<Text style={[styles.rowTitle, { flex: 1 }]}>{label}</Text>
<Switch
testID={`profile-pref-${key}`}
value={prefs[key]}
onValueChange={(v) => savePrefs({ ...prefs, [key]: v })}
trackColor={{ false: colors.neutral200, true: colors.primary600 }}
thumbColor={colors.white}
/>
</View>
))}
<Text style={styles.hint}> . .</Text>
</Card>
{/* ── 로그아웃 ── */}
<Pressable
style={styles.logoutBtn}
onPress={onSignOut}
accessibilityRole="button"
accessibilityLabel="로그아웃"
>
<Ionicons name="log-out-outline" size={18} color={colors.error} />
<Text style={styles.logoutText}></Text>
</Pressable>
<Text style={styles.version}>KINTEX AI · </Text>
{/* ── 프로필 사진 미리보기 모달(선택→미리보기→적용 확정) ── */}
<Modal visible={!!previewImg} transparent animationType="fade" onRequestClose={cancelPreview}>
<Pressable style={styles.modalBackdrop} onPress={cancelPreview}>
<Pressable style={styles.modalSheet} onPress={() => {}}>
<View style={styles.modalHeaderRow}>
<Text style={styles.modalTitle}> </Text>
<Pressable
testID="photo-cancel-x"
onPress={cancelPreview}
disabled={photoBusy}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
accessibilityLabel="닫기"
>
<Ionicons name="close" size={22} color={colors.neutral500} />
</Pressable>
</View>
<View style={styles.previewWrap}>
{previewImg ? (
<Image
testID="photo-preview-image"
source={{ uri: previewImg.uri }}
style={styles.previewImg}
resizeMode="cover"
/>
) : null}
{photoBusy ? (
<View style={styles.previewOverlay}>
<ActivityIndicator color={colors.white} size="small" />
</View>
) : null}
</View>
<Text style={styles.previewCaption}> .</Text>
<View style={styles.modalPickRow}>
<Pressable
testID="photo-pick-library"
style={[styles.pickBtn, photoBusy && styles.disabled]}
onPress={() => pickForPreview('library')}
disabled={photoBusy}
>
<Ionicons name="image-outline" size={16} color={colors.neutral900} />
<Text style={styles.pickText}></Text>
</Pressable>
<Pressable
testID="photo-pick-camera"
style={[styles.pickBtn, photoBusy && styles.disabled]}
onPress={() => pickForPreview('camera')}
disabled={photoBusy}
>
<Ionicons name="camera-outline" size={16} color={colors.neutral900} />
<Text style={styles.pickText}></Text>
</Pressable>
</View>
{previewErr ? <Text style={styles.err}>{previewErr}</Text> : null}
<Pressable
testID="photo-apply"
style={[styles.applyBtn, photoBusy && styles.disabled]}
onPress={applyPhoto}
disabled={photoBusy}
>
{photoBusy ? (
<View style={styles.applyBusyRow}>
<ActivityIndicator color={colors.white} size="small" />
<Text style={styles.applyText}> </Text>
</View>
) : (
<Text style={styles.applyText}></Text>
)}
</Pressable>
<Pressable
testID="photo-cancel"
style={[styles.cancelBtn, photoBusy && styles.disabled]}
onPress={cancelPreview}
disabled={photoBusy}
>
<Text style={styles.cancelText}></Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
</ScrollView>
);
}
function InfoRow({ label, value, testID }: { label: string; value: string | null; testID?: string }) {
return (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue} testID={testID} numberOfLines={1}>
{value && value.length ? value : '-'}
</Text>
</View>
);
}
const styles = StyleSheet.create({
wrap: { flex: 1, backgroundColor: colors.neutral050 },
scroll: { padding: spacing.md, paddingBottom: spacing.xl },
section: {
color: colors.neutral900,
fontSize: type.h3.fontSize,
fontWeight: '800',
marginTop: spacing.md,
marginBottom: spacing.sm,
},
profileHead: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm },
photoEdit: {
position: 'absolute',
right: -2,
bottom: -2,
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.primary600,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.white,
},
profileName: { color: colors.neutral900, fontSize: type.h2.fontSize, fontWeight: '800' },
profileId: { color: colors.neutral500, fontSize: type.caption.fontSize, marginTop: 3 },
pendingNote: { color: colors.warning, fontSize: type.caption.fontSize, marginBottom: spacing.sm },
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 9,
borderTopWidth: 1,
borderTopColor: colors.neutral200,
},
infoLabel: { color: colors.neutral500, fontSize: type.caption.fontSize, fontWeight: '700' },
infoValue: {
color: colors.neutral900,
fontSize: type.body.fontSize,
fontWeight: '600',
flexShrink: 1,
textAlign: 'right',
marginLeft: 12,
},
rolePill: {
backgroundColor: colors.primary100,
borderRadius: radius.pill,
paddingHorizontal: 10,
paddingVertical: 4,
},
rolePillText: { color: colors.primary700, fontSize: type.caption.fontSize, fontWeight: '700' },
bioRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4 },
rowTitle: { color: colors.neutral900, fontSize: type.body.fontSize, fontWeight: '700', marginBottom: 4 },
hint: { color: colors.neutral500, fontSize: type.caption.fontSize, lineHeight: 18, marginTop: 4 },
badgeOn: { backgroundColor: '#E7F5EF', borderRadius: radius.pill, paddingHorizontal: 10, paddingVertical: 4 },
badgeOnText: { color: colors.success, fontSize: type.caption.fontSize, fontWeight: '800' },
badgeOff: { backgroundColor: colors.neutral200, borderRadius: radius.pill, paddingHorizontal: 10, paddingVertical: 4 },
badgeOffText: { color: colors.neutral700, fontSize: type.caption.fontSize, fontWeight: '800' },
logoutBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
marginTop: spacing.lg,
minHeight: touch.min,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.error,
backgroundColor: colors.white,
},
logoutText: { color: colors.error, fontSize: type.h3.fontSize, fontWeight: '700' },
version: { textAlign: 'center', color: colors.neutral500, fontSize: type.caption.fontSize, marginTop: spacing.md },
// 생체 잠금 게이트
lockWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
padding: spacing.lg,
backgroundColor: colors.neutral050,
},
lockTitle: { color: colors.neutral900, fontSize: type.h2.fontSize, fontWeight: '800', marginTop: spacing.sm },
lockHint: { color: colors.neutral500, fontSize: type.body.fontSize, textAlign: 'center' },
lockBtn: {
marginTop: spacing.md,
minHeight: touch.min,
paddingHorizontal: spacing.lg,
borderRadius: radius.sm,
backgroundColor: colors.primary600,
alignItems: 'center',
justifyContent: 'center',
},
lockBtnText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' },
// 사진 미리보기 모달
modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)', justifyContent: 'flex-end' },
modalSheet: {
backgroundColor: colors.white,
borderTopLeftRadius: radius.md,
borderTopRightRadius: radius.md,
borderWidth: 1,
borderColor: colors.neutral200,
padding: spacing.lg,
paddingBottom: spacing.xl,
},
modalHeaderRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
modalTitle: { color: colors.neutral900, fontSize: type.h3.fontSize, fontWeight: '800' },
previewWrap: {
width: 120,
height: 120,
borderRadius: 60,
alignSelf: 'center',
marginVertical: spacing.md,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.neutral200,
backgroundColor: colors.primary050,
},
previewImg: { width: 120, height: 120, borderRadius: 60 },
previewOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.35)',
},
previewCaption: { color: colors.neutral500, fontSize: type.caption.fontSize, textAlign: 'center', marginTop: -4 },
modalPickRow: { flexDirection: 'row', gap: 10, marginTop: spacing.md },
pickBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
minHeight: touch.min,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.neutral200,
backgroundColor: colors.white,
},
pickText: { color: colors.neutral900, fontSize: type.body.fontSize, fontWeight: '700' },
applyBtn: {
marginTop: spacing.md,
minHeight: touch.min,
borderRadius: radius.sm,
backgroundColor: colors.primary600,
alignItems: 'center',
justifyContent: 'center',
},
applyBusyRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
applyText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '800' },
cancelBtn: {
marginTop: spacing.sm,
minHeight: touch.min,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.neutral200,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.white,
},
cancelText: { color: colors.neutral700, fontSize: type.h3.fontSize, fontWeight: '700' },
err: { color: colors.error, fontSize: type.caption.fontSize, marginTop: spacing.sm },
disabled: { opacity: 0.6 },
});

View File

@ -9,6 +9,7 @@ import { router, Stack } from 'expo-router';
import React, { useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Banner } from '../../components/Banner';
import { useSecureScreen } from '../../context/SecureScreenContext';
import { QrViewerModal } from '../../components/tickets/QrViewerModal';
import { TicketCard } from '../../components/tickets/TicketCard';
import {
@ -20,6 +21,9 @@ import {
import { colors, radius, spacing, type } from '../../theme';
export default function TicketWalletScreen() {
// 티켓 QR 화면 — 캡처 차단(QR 재사용 방지) + 백그라운드 마스킹(B4/B7).
useSecureScreen('tickets');
const [filter, setFilter] = useState<TicketFilter>('active');
const [qrOpen, setQrOpen] = useState(false);
const [qrIndex, setQrIndex] = useState(0);

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
mobile/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
mobile/assets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1,72 @@
/*
* Avatar (photoUrl) , / . WISE Avatar .
* 레퍼런스: guardia-messenger/app/uiws/_lib/components/Avatar.tsx.
* <Image> api Bearer .
* theme ( ).
*/
import React, { useEffect, useState } from 'react';
import { Image, StyleSheet, Text, View } from 'react-native';
import { getAccessToken } from '../lib/api';
import { colors } from '../theme';
interface AvatarProps {
photoUrl?: string | null;
name?: string | null;
size?: number;
testID?: string;
}
function initials(name?: string | null): string {
if (!name) return '?';
const trimmed = name.trim();
if (!trimmed) return '?';
const isAscii = /^[\x00-\x7F]+$/.test(trimmed);
if (isAscii) {
const parts = trimmed.split(/\s+/);
return (parts.length >= 2 ? parts[0][0] + parts[1][0] : trimmed.slice(0, 2)).toUpperCase();
}
return trimmed.slice(0, 1);
}
export function Avatar({ photoUrl, name, size = 48, testID }: AvatarProps) {
const [failed, setFailed] = useState(false);
const token = getAccessToken();
// photoUrl/token 변화 시 실패 상태 초기화 후 재시도(업로드·캐시버스터 반영).
useEffect(() => {
setFailed(false);
}, [photoUrl, token]);
const dim = { width: size, height: size, borderRadius: size / 2 };
const showImage = !!photoUrl && !!token && !failed;
if (showImage) {
return (
<Image
testID={testID}
source={{
uri: photoUrl as string,
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
}}
style={[dim, { borderWidth: 1, borderColor: colors.neutral200, backgroundColor: colors.primary050 }]}
onError={() => setFailed(true)}
accessibilityIgnoresInvertColors
/>
);
}
return (
<View
testID={testID}
style={[dim, styles.fallback, { backgroundColor: colors.primary100, borderColor: colors.neutral200 }]}
accessibilityLabel={name ? `${name} 프로필` : '프로필'}
>
<Text style={[styles.initials, { color: colors.primary700, fontSize: size * 0.4 }]}>
{initials(name)}
</Text>
</View>
);
}
const styles = StyleSheet.create({
fallback: { alignItems: 'center', justifyContent: 'center', borderWidth: 1 },
initials: { fontWeight: '800' },
});

View File

@ -0,0 +1,101 @@
/*
* B4( ) + B7( ) .
* 근거: docs/security/mobile-security-audit.md B4·B7, checklist DP-4·DP-5.
*
* :
* · focus되면 useSecureScreen (activeCount++) + preventCapture.
* · background/inactive( · ) activeCount>0
* .
* · / .
* 방어적: 캡처 (B7) RN(AppState) .
*/
import { useFocusEffect } from 'expo-router';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { AppState, type AppStateStatus, StyleSheet, Text, View } from 'react-native';
import { allowCapture, preventCapture } from '../lib/screenCapture';
import { colors, spacing, type } from '../theme';
interface SecureScreenContextValue {
register: () => void;
unregister: () => void;
}
const SecureScreenContext = createContext<SecureScreenContextValue | undefined>(undefined);
export function SecureScreenProvider({ children }: { children: React.ReactNode }) {
const [activeCount, setActiveCount] = useState(0);
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState);
const register = useCallback(() => setActiveCount((n) => n + 1), []);
const unregister = useCallback(() => setActiveCount((n) => Math.max(0, n - 1)), []);
useEffect(() => {
const sub = AppState.addEventListener('change', (next) => setAppState(next));
return () => sub.remove();
}, []);
const value = useMemo<SecureScreenContextValue>(() => ({ register, unregister }), [register, unregister]);
// 민감화면 활성 + 비활성 상태(태스크 스위처/백그라운드) → 마스킹.
const masked = activeCount > 0 && appState !== 'active';
return (
<SecureScreenContext.Provider value={value}>
{children}
{masked ? (
<View style={styles.mask} pointerEvents="none" accessibilityElementsHidden>
<Text style={styles.markTop}>KINTEX</Text>
<Text style={styles.markSub}>AI ·</Text>
</View>
) : null}
</SecureScreenContext.Provider>
);
}
/**
* 1 .
* @param key ( ).
* focus + , blur .
*/
export function useSecureScreen(key: string): void {
const ctx = useContext(SecureScreenContext);
const registered = useRef(false);
useFocusEffect(
useCallback(() => {
preventCapture(key);
if (ctx && !registered.current) {
ctx.register();
registered.current = true;
}
return () => {
allowCapture(key);
if (ctx && registered.current) {
ctx.unregister();
registered.current = false;
}
};
// ctx는 프로바이더 수명 내 안정적 — key 변경 시에만 재바인딩
}, [ctx, key]),
);
}
const styles = StyleSheet.create({
mask: {
...StyleSheet.absoluteFillObject,
backgroundColor: colors.primary700,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
},
markTop: { color: colors.white, fontSize: 30, fontWeight: '800', letterSpacing: 2 },
markSub: { color: 'rgba(255,255,255,0.8)', fontSize: type.body.fontSize, fontWeight: '600' },
});

View File

@ -4,6 +4,7 @@
* (src/frontend/src/api/client.ts) . 릿 .
*/
import { API_BASE } from './config';
import { getAttestationToken } from './integrity';
import type { ApiError, ApiErrorCode, ApiResponse } from './types';
/** 봉투 error를 그대로 던지는 오류 타입 — UI는 code로 분기(OTP_REQUIRED·COMPLIANCE_BLOCKED 등). */
@ -51,6 +52,13 @@ 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}`;
// B2 앱 무결성 토큰(Play Integrity/App Attest) — 발급기 등록 시에만 첨부(미등록 시 null → 무해).
// 서버 검증 엔드포인트는 backend-dev 인계(_workspace/08_mobile_security_hardening.md).
if (!opts.anonymous) {
const attestation = await getAttestationToken();
if (attestation) headers['X-Integrity-Token'] = attestation;
}
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, {

92
mobile/lib/biometric.ts Normal file
View File

@ -0,0 +1,92 @@
/*
* (·Face) WISE .
* 레퍼런스: guardia-messenger/app/uiws/_lib/constants/biometric.ts ( ).
*
* 성격: 2FA "로컬 재인증/잠금" .
* require: expo-local-authentication graceful( ).
* OS만 , (boolean) ( ).
* pref kintex (WISE `wise_*` CLAUDE.md ).
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let LocalAuth: any = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
LocalAuth = require('expo-local-authentication');
} catch {
LocalAuth = null;
}
const PREF_KEY = 'kintex.biometric'; // '1' | '0'
/** 기기가 생체인증 하드웨어 + OS 등록(지문/Face)을 모두 지원하는지. */
export async function isBiometricSupported(): Promise<boolean> {
if (!LocalAuth) return false;
try {
const hasHw = await LocalAuth.hasHardwareAsync();
const enrolled = await LocalAuth.isEnrolledAsync();
return !!(hasHw && enrolled);
} catch {
return false;
}
}
/** 하드웨어만 보유(OS 미등록 포함) — "생체 등록 유도" 안내 노출 판정용. */
export async function isBiometricHardwareAvailable(): Promise<boolean> {
if (!LocalAuth) return false;
try {
return !!(await LocalAuth.hasHardwareAsync());
} catch {
return false;
}
}
/*
* ( ) .
* (JS ) 1.
* , .
*/
let sessionUnlocked = false;
export function isSessionUnlocked(): boolean {
return sessionUnlocked;
}
export function markSessionUnlocked(): void {
sessionUnlocked = true;
}
export function resetSessionUnlocked(): void {
sessionUnlocked = false;
}
/** 사용자 토글(pref) 조회 — 기본 비활성('0'/미설정). */
export async function isBiometricEnabled(): Promise<boolean> {
try {
return (await AsyncStorage.getItem(PREF_KEY)) === '1';
} catch {
return false;
}
}
/** 사용자 토글(pref) 설정. */
export async function setBiometricEnabled(on: boolean): Promise<void> {
try {
await AsyncStorage.setItem(PREF_KEY, on ? '1' : '0');
} catch {
/* graceful */
}
}
/** 생체 인증 프롬프트. 성공 시 true. 미지원/취소/실패 시 false(생체 원본 미노출). */
export async function authenticateBiometric(): Promise<boolean> {
if (!LocalAuth) return false;
try {
const res = await LocalAuth.authenticateAsync({
promptMessage: 'KINTEX 생체인식 잠금 해제',
cancelLabel: '취소',
disableDeviceFallback: false,
});
return !!res?.success;
} catch {
return false;
}
}

99
mobile/lib/imagePick.ts Normal file
View File

@ -0,0 +1,99 @@
/*
* expo-image-picker (/, 1:1 ). WISE .
* 레퍼런스: guardia-messenger/app/uiws/_lib/constants/imagePick.ts.
* require: graceful(null ). · .
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ImagePicker: any = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
ImagePicker = require('expo-image-picker');
} catch {
ImagePicker = null;
}
export interface PickedImage {
uri: string;
fileName: string;
mimeType: string;
/** base64 원본(백엔드 멀티파트 미지원 시 대체 계약용). quality 절충. */
base64?: string | null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function toPicked(result: any): PickedImage | null {
if (!result || result.canceled) return null;
const asset = result.assets?.[0];
if (!asset?.uri) return null;
const uri: string = asset.uri;
const ext = (uri.split('.').pop() || 'jpg').split('?')[0].toLowerCase();
const mime = asset.mimeType || (ext === 'png' ? 'image/png' : 'image/jpeg');
return {
uri,
fileName: asset.fileName || `profile.${ext}`,
mimeType: mime,
base64: asset.base64 ?? null,
};
}
export function isPickerAvailable(): boolean {
return !!ImagePicker;
}
/** 갤러리에서 1:1 크롭 선택. 권한 거부/취소/미설치 시 null. */
export async function pickFromLibrary(): Promise<PickedImage | null> {
if (!ImagePicker) return null;
try {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm?.granted) return null;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions?.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 0.7,
base64: true,
});
return toPicked(result);
} catch {
return null;
}
}
/** 카메라 촬영(1:1 크롭). 권한 거부/취소/미설치 시 null. */
export async function pickFromCamera(): Promise<PickedImage | null> {
if (!ImagePicker) return null;
try {
const perm = await ImagePicker.requestCameraPermissionsAsync();
if (!perm?.granted) return null;
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
aspect: [1, 1],
quality: 0.7,
base64: true,
});
return toPicked(result);
} catch {
return null;
}
}
/** 권한 상태 조회 — 거부 시 안내 문구 분기용(요청 없이 현 상태만). */
export async function permissionState(): Promise<{ media: boolean; camera: boolean }> {
if (!ImagePicker) return { media: false, camera: false };
try {
const m = await ImagePicker.getMediaLibraryPermissionsAsync();
const c = await ImagePicker.getCameraPermissionsAsync();
return { media: !!m?.granted, camera: !!c?.granted };
} catch {
return { media: false, camera: false };
}
}
/** PickedImage → multipart FormData(field "file"). RN 멀티파트: { uri, name, type }. */
export function toPhotoFormData(img: PickedImage): FormData {
const fd = new FormData();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fd.append('file', { uri: img.uri, name: img.fileName, type: img.mimeType } as any);
return fd;
}

143
mobile/lib/integrity.ts Normal file
View File

@ -0,0 +1,143 @@
/*
* / (B1 · / B9 ··USB / B2 ).
* 근거: docs/security/mobile-security-audit.md B1·B2·B9, checklist AT-1/AT-4/AT-6/AT-2.
*
* ( ): "경고 후 제한" .
* ( · ) (graceful).
* 경로: jail-monkey는 Expo Go// require로
* "탐지 불가(안전 가정)" . dev-client/prebuild(EAS) . (biometric.ts )
* 릿 미보관: 앱은 / · .
*/
import { useEffect, useState } from 'react';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let JailMonkey: any = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
JailMonkey = require('jail-monkey');
// 일부 번들러는 default 래핑 → 안전 정규화
if (JailMonkey && JailMonkey.default) JailMonkey = JailMonkey.default;
} catch {
JailMonkey = null;
}
export interface IntegrityResult {
/** 네이티브 탐지 모듈 사용 가능 여부(불가 시 아래 값은 모두 안전 기본값 false). */
available: boolean;
/** 루팅(Android)·탈옥(iOS) 단말. */
jailBroken: boolean;
/** 디버거 attach(Android). */
debugger: boolean;
/** 후킹(Frida/Xposed 등, Android). */
hooking: boolean;
/** USB 디버깅(ADB) 활성(Android). */
adbEnabled: boolean;
/** 개발자 옵션 활성(Android). */
devMode: boolean;
/** 위치 모의(mock) 허용. */
mockLocation: boolean;
/** 종합 위변조 신호(위 항목 중 하나라도 참). */
compromised: boolean;
}
const SAFE: IntegrityResult = {
available: false,
jailBroken: false,
debugger: false,
hooking: false,
adbEnabled: false,
devMode: false,
mockLocation: false,
compromised: false,
};
/** 동기 불린 호출을 방어적으로 실행(예외/미구현 시 false). */
function safeBool(fn: (() => boolean) | undefined): boolean {
if (typeof fn !== 'function') return false;
try {
return !!fn();
} catch {
return false;
}
}
/** Promise 반환 API를 방어적으로 실행(예외/미구현 시 false). */
async function safeBoolAsync(fn: (() => Promise<boolean>) | undefined): Promise<boolean> {
if (typeof fn !== 'function') return false;
try {
return !!(await fn());
} catch {
return false;
}
}
/**
* . SAFE( false) graceful .
* UI에서 "경고 배너" ( ).
*/
export async function checkDeviceIntegrity(): Promise<IntegrityResult> {
if (!JailMonkey) return { ...SAFE };
try {
const jailBroken = safeBool(JailMonkey.isJailBroken);
const hooking = safeBool(JailMonkey.hookDetected);
const adbEnabled = safeBool(JailMonkey.AdbEnabled);
const mockLocation = safeBool(JailMonkey.canMockLocation);
const debugger_ = await safeBoolAsync(JailMonkey.isDebuggedMode);
const devMode = await safeBoolAsync(JailMonkey.isDevelopmentSettingsMode);
const compromised = jailBroken || hooking || debugger_;
return {
available: true,
jailBroken,
debugger: debugger_,
hooking,
adbEnabled,
devMode,
mockLocation,
compromised,
};
} catch {
return { ...SAFE };
}
}
/**
* React 1 (). .
*/
export function useDeviceIntegrity(): IntegrityResult {
const [result, setResult] = useState<IntegrityResult>(SAFE);
useEffect(() => {
let cancelled = false;
checkDeviceIntegrity().then((r) => {
if (!cancelled) setResult(r);
});
return () => {
cancelled = true;
};
}, []);
return result;
}
// ── B2 앱 무결성 토큰(전송부만) ───────────────────────────────────────────────
/*
* Play Integrity(Android)·App Attest(iOS) "발급".
* (provider) "전송" (api.ts가 ).
* backend-dev (_workspace/08_mobile_security_hardening.md).
* provider getAttestationToken() null ().
*/
type AttestationProvider = () => Promise<string | null>;
let attestationProvider: AttestationProvider | null = null;
/** 네이티브 무결성 토큰 발급기 등록(추후 Play Integrity/App Attest 모듈 연동 시). */
export function setAttestationProvider(provider: AttestationProvider | null): void {
attestationProvider = provider;
}
/** 등록된 발급기로 무결성 토큰 조회. 미등록/실패 시 null(graceful). */
export async function getAttestationToken(): Promise<string | null> {
if (!attestationProvider) return null;
try {
return await attestationProvider();
} catch {
return null;
}
}

81
mobile/lib/profile.ts Normal file
View File

@ -0,0 +1,81 @@
/*
* () API ·OTP · .
* : /api/auth/me(KintexPrincipal)·/api/auth/otp/status(OtpStatusResponse).
* / (404/501/)
* degrade( ). 노트: _workspace/mobile_myinfo_avatar_contract.md.
*/
import { API_BASE } from './config';
import { ApiRequestError, api, getAccessToken } from './api';
import type {
ApiResponse,
AvatarUploadResponse,
MePrincipal,
OtpStatusDto,
} from './types';
import type { PickedImage } from './imagePick';
import { toPhotoFormData } from './imagePick';
/** 내 신원·행사별 역할(GET /api/auth/me). */
export async function getMe(): Promise<MePrincipal> {
return api.get<MePrincipal>('/api/auth/me');
}
/** 본인 OTP(2차 인증) 상태(GET /api/auth/otp/status). */
export async function getOtpStatus(): Promise<OtpStatusDto> {
return api.get<OtpStatusDto>('/api/auth/otp/status');
}
/**
* URL( 계약: GET /api/auth/users/{userId}/photo).
* <Image> axios/fetch Avatar Bearer .
*/
export function avatarUrl(userId: string): string {
return `${API_BASE}/api/auth/users/${encodeURIComponent(userId)}/photo`;
}
/**
* ( 계약: POST /api/auth/profile/photo, multipart field "file").
* photoUrl. / ApiRequestError로 degrade .
* JSON api fetch (Content-Type boundary).
*/
export async function uploadAvatar(img: PickedImage): Promise<AvatarUploadResponse> {
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
// Content-Type은 지정하지 않는다 — RN이 multipart boundary를 자동 설정한다.
let res: Response;
try {
res = await fetch(`${API_BASE}/api/auth/profile/photo`, {
method: 'POST',
headers,
body: toPhotoFormData(img),
});
} catch {
throw new ApiRequestError('NETWORK', '네트워크 연결을 확인해 주세요.', 0);
}
let payload: ApiResponse<AvatarUploadResponse> | null = null;
const text = await res.text();
if (text) {
try {
payload = JSON.parse(text) as ApiResponse<AvatarUploadResponse>;
} catch {
payload = null;
}
}
if (!res.ok || (payload && payload.success === false)) {
const err = payload?.error ?? null;
throw new ApiRequestError(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(err?.code as any) ?? (res.status === 404 ? 'NOT_IMPLEMENTED' : 'UNKNOWN'),
err?.message ?? `프로필 사진 업로드를 처리하지 못했습니다. (${res.status})`,
res.status,
);
}
if (payload?.data?.photoUrl) return payload.data;
// 봉투는 성공이나 data 부재 — 표준 서빙 URL로 폴백.
return { photoUrl: '' };
}

View File

@ -0,0 +1,44 @@
/*
* / (B4 · checklist DP-4 / MASVS-PLATFORM-3).
* 근거: docs/security/mobile-security-audit.md B4.
*
* expo-screen-capture:
* · Android: FLAG_SECURE ··
* · iOS: 캡처 (OS ) "감지" (), Android FLAG_SECURE
* require: /Expo Go/ graceful no-op( ).
* key / .
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ScreenCapture: any = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
ScreenCapture = require('expo-screen-capture');
} catch {
ScreenCapture = null;
}
/** 이 기기/빌드에서 캡처 차단 기능을 쓸 수 있는지. */
export function isScreenCaptureGuardAvailable(): boolean {
return typeof ScreenCapture?.preventScreenCaptureAsync === 'function';
}
/** 캡처/녹화 차단 활성(진입 시). key로 화면 스택 구분. */
export async function preventCapture(key: string): Promise<void> {
if (!isScreenCaptureGuardAvailable()) return;
try {
await ScreenCapture.preventScreenCaptureAsync(key);
} catch {
/* graceful */
}
}
/** 캡처/녹화 차단 해제(이탈 시). 진입 시와 동일 key 필요. */
export async function allowCapture(key: string): Promise<void> {
if (typeof ScreenCapture?.allowScreenCaptureAsync !== 'function') return;
try {
await ScreenCapture.allowScreenCaptureAsync(key);
} catch {
/* graceful */
}
}

View File

@ -103,3 +103,25 @@ export interface HealthDto {
service: string;
time: string;
}
// ── 내정보(SCR-M 프로필) ──
// GET /api/auth/me → KintexPrincipal(백엔드 record). 이메일·부서·전화는 현 계약 미포함(백엔드 인계 대상).
export interface MePrincipal {
userId: string;
displayName: string;
eventRoles: Record<string, EventRole>;
hallManager: boolean;
roleCode: string | null;
tenantId: string | null;
}
// GET /api/auth/otp/status → { otpEnabled, verifyMethod }.
export interface OtpStatusDto {
otpEnabled: boolean;
verifyMethod: string;
}
/** 아바타 업로드 응답(제안 계약 — 백엔드 미구현 시 degrade). */
export interface AvatarUploadResponse {
photoUrl: string;
}

View File

@ -11,14 +11,18 @@
"@expo/vector-icons": "^14.0.3",
"@react-native-async-storage/async-storage": "1.23.1",
"expo": "^51.0.39",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
"expo-font": "~12.0.10",
"expo-image-picker": "~15.1.0",
"expo-linking": "~6.3.1",
"expo-local-authentication": "~14.0.1",
"expo-router": "~3.5.23",
"expo-screen-capture": "~6.0.1",
"expo-secure-store": "~13.0.2",
"expo-splash-screen": "~0.27.7",
"expo-status-bar": "~1.12.1",
"jail-monkey": "^2.8.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.74.5",
@ -7624,6 +7628,31 @@
"expo": "*"
}
},
"node_modules/expo-build-properties": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-0.12.5.tgz",
"integrity": "sha512-donC1le0PYfLKCPKRMGQoixuWuwDWCngzXSoQXUPsgHTDHQUKr8aw+lcWkTwZcItgNovcnk784I0dyfYDcxybA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.11.0",
"semver": "^7.6.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-build-properties/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/expo-constants": {
"version": "16.0.2",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-16.0.2.tgz",
@ -7701,6 +7730,18 @@
"invariant": "^2.2.4"
}
},
"node_modules/expo-local-authentication": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-14.0.1.tgz",
"integrity": "sha512-kAwUD1wEqj1fhwQgIHlP4H/JV9AcX+NO3BJwhPM2HuCFS0kgx2wvcHisnKBSTRyl8u5Jt4odzMyQkDJystwUTg==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-1.11.3.tgz",
@ -7802,6 +7843,15 @@
}
}
},
"node_modules/expo-screen-capture": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/expo-screen-capture/-/expo-screen-capture-6.0.1.tgz",
"integrity": "sha512-rJnwcKxZ6anfEx2xFeRo9MAVp2JgI4S+TuhUurHRf9rildPVorS2JvLey3i5xZPs7CxEe2xcijTmr5WT5Lw4dA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-secure-store": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-13.0.2.tgz",
@ -9438,6 +9488,12 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jail-monkey": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/jail-monkey/-/jail-monkey-2.8.5.tgz",
"integrity": "sha512-c91Bsfl18VyCRaoJAxB87ZC27lX23BL0EbHajFQu+h5Sa8OU/yeo33Lu8BplHPejdqxBwL31R//N/zYAb1XOSA==",
"license": "MIT"
},
"node_modules/jest-environment-node": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz",

View File

@ -14,13 +14,18 @@
"@expo/vector-icons": "^14.0.3",
"@react-native-async-storage/async-storage": "1.23.1",
"expo": "^51.0.39",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
"expo-image-picker": "~15.0.7",
"expo-font": "~12.0.10",
"expo-image-picker": "~15.1.0",
"expo-linking": "~6.3.1",
"expo-local-authentication": "~14.0.1",
"expo-router": "~3.5.23",
"expo-screen-capture": "~6.0.1",
"expo-secure-store": "~13.0.2",
"expo-splash-screen": "~0.27.7",
"expo-status-bar": "~1.12.1",
"jail-monkey": "^2.8.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.74.5",

View File

@ -3,6 +3,7 @@ package com.zioinfo.kintex.auth;
import com.zioinfo.kintex.auth.dto.AcceptInviteRequest;
import com.zioinfo.kintex.auth.dto.LoginRequest;
import com.zioinfo.kintex.auth.dto.LoginResponse;
import com.zioinfo.kintex.auth.dto.MeResponse;
import com.zioinfo.kintex.auth.dto.WorkspaceDto;
import com.zioinfo.kintex.common.ApiResponse;
import jakarta.validation.Valid;
@ -45,10 +46,13 @@ public class AuthController {
return ApiResponse.ok(authService.acceptInvite(principal, request));
}
/** GET /api/auth/me — 현재 토큰의 신원·행사별 역할 요약(디버그·프론트 부트스트랩). */
/**
* GET /api/auth/me 현재 토큰의 신원·행사별 역할 + 프로필(email·phone·deptId·photoUrl) 순증.
* 기존 principal 필드의 상위집합이라 부트스트랩 회귀 0(계약: {@link MeResponse}).
*/
@GetMapping("/me")
public ApiResponse<KintexPrincipal> me(@AuthenticationPrincipal KintexPrincipal principal) {
public ApiResponse<MeResponse> me(@AuthenticationPrincipal KintexPrincipal principal) {
guard.require(principal);
return ApiResponse.ok(principal);
return ApiResponse.ok(authService.me(principal));
}
}

View File

@ -3,6 +3,7 @@ package com.zioinfo.kintex.auth;
import com.zioinfo.kintex.auth.dto.AcceptInviteRequest;
import com.zioinfo.kintex.auth.dto.LoginRequest;
import com.zioinfo.kintex.auth.dto.LoginResponse;
import com.zioinfo.kintex.auth.dto.MeResponse;
import com.zioinfo.kintex.auth.dto.WorkspaceDto;
import java.util.List;
@ -13,6 +14,9 @@ public interface AuthService {
/** 이메일/비밀번호 검증 → JWT 발급 + 참여 행사 목록. */
LoginResponse login(LoginRequest request);
/** 현재 사용자 신원 + 프로필(email·phone·deptId·photoUrl) 순증. 웹/모바일 부트스트랩. */
MeResponse me(KintexPrincipal principal);
/** 현재 사용자의 참여 행사 워크스페이스 목록(SCR-01 카드 그리드). */
List<WorkspaceDto> listWorkspaces(KintexPrincipal principal);

View File

@ -3,8 +3,10 @@ package com.zioinfo.kintex.auth;
import com.zioinfo.kintex.auth.dto.AcceptInviteRequest;
import com.zioinfo.kintex.auth.dto.LoginRequest;
import com.zioinfo.kintex.auth.dto.LoginResponse;
import com.zioinfo.kintex.auth.dto.MeResponse;
import com.zioinfo.kintex.auth.dto.WorkspaceDto;
import com.zioinfo.kintex.auth.mapper.UserMapper;
import com.zioinfo.kintex.auth.profile.ProfilePhotoService;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.security.TwoFactorService;
@ -48,6 +50,20 @@ public class AuthServiceImpl implements AuthService {
return secured.login();
}
@Override
public MeResponse me(KintexPrincipal principal) {
Map<String, Object> p = userMapper.findProfile(principal.userId());
String email = p == null ? null : str(p.get("email"));
String phone = p == null ? null : str(p.get("phone"));
String deptId = p == null ? null : str(p.get("deptId"));
boolean hasPhoto = p != null && Boolean.TRUE.equals(bool(p.get("hasPhoto")));
String photoUrl = hasPhoto ? ProfilePhotoService.serveUrl(principal.userId()) : null;
return new MeResponse(
principal.userId(), principal.displayName(), principal.eventRoles(),
principal.hallManager(), principal.roleCode(), principal.tenantId(),
email, phone, deptId, photoUrl);
}
@Override
public List<WorkspaceDto> listWorkspaces(KintexPrincipal principal) {
return loadWorkspaces(principal.userId());

View File

@ -0,0 +1,27 @@
package com.zioinfo.kintex.auth.dto;
import com.zioinfo.kintex.auth.EventRole;
import java.util.Map;
/**
* {@code GET /api/auth/me} 응답 기존 {@code KintexPrincipal} 필드의 <b>상위집합</b>(순증).
* 웹은 기존 필드(userId·displayName·eventRoles·hallManager·roleCode·tenantId) 그대로 읽고(회귀 0),
* 모바일 {@code MePrincipal} email·phone·deptId·photoUrl 순증 필드를 표시에 사용한다.
* 민감 컬럼(password_hash·otp_secret) 절대 포함하지 않는다(계약 §0-3).
*
* @param photoUrl 프로필 사진이 있으면 서빙 URL, 없으면 null(모바일 이니셜 폴백).
*/
public record MeResponse(
String userId,
String displayName,
Map<String, EventRole> eventRoles,
boolean hallManager,
String roleCode,
String tenantId,
String email,
String phone,
String deptId,
String photoUrl
) {
}

View File

@ -0,0 +1,38 @@
package com.zioinfo.kintex.auth.integrity;
import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.auth.integrity.dto.AppIntegrityRequest;
import com.zioinfo.kintex.auth.integrity.dto.AppIntegrityResponse;
import com.zioinfo.kintex.common.ApiResponse;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* B2 무결성 검증 엔드포인트(골격). 인증 필요(SecurityConfig anyRequest().authenticated()).
* {@code POST /api/auth/app-integrity/verify} 모바일이 Play Integrity/App Attest 토큰을 첨부.
* 온프레미스 외부 API 금지로 실검증은 스텁(mode=STUB) {@link AppIntegrityService} 참조.
*/
@RestController
@RequestMapping("/api/auth/app-integrity")
public class AppIntegrityController {
private final AppIntegrityService service;
private final EventAccessGuard guard;
public AppIntegrityController(AppIntegrityService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@PostMapping("/verify")
public ApiResponse<AppIntegrityResponse> verify(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody AppIntegrityRequest req) {
guard.require(principal);
return ApiResponse.ok(service.verify(principal.userId(), req));
}
}

View File

@ -0,0 +1,46 @@
package com.zioinfo.kintex.auth.integrity;
import com.zioinfo.kintex.auth.integrity.dto.AppIntegrityRequest;
import com.zioinfo.kintex.auth.integrity.dto.AppIntegrityResponse;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.Locale;
import java.util.Set;
/**
* B2 무결성(위변조 방지) 서버 검증 <b>골격</b>.
*
* <p>실제 Play Integrity(Google)/App Attest(Apple) 토큰 검증은 플랫폼 서버로의 <b>외부 호출</b>
* 필요하다. 온프레미스 외부 API 금지 제약(보안 불변) 하에서는 여기서 실검증을 수행하지 않는다
* 요청 형식만 검증하고 {@code mode=STUB, attested=false} 안전하게 반환한다.
* 모바일은 결과로 로그인/기능을 <b>차단하지 않는다</b>(감사 문서 B2: 인계 골격).
*
* <p>운영에서 검증기를 붙일 진입점(TODO): (1) Android Google Play Integrity API로 토큰 복호·검증
* ( 패키지/서명 지문/nonce 대조), (2) iOS App Attest 공개키·nonce·receipt 검증. 검증 결과만
* boolean 으로 반환하고 토큰 원문은 저장/로그 금지(계약 §0-3).
*/
@Service
public class AppIntegrityService {
private static final Logger log = LoggerFactory.getLogger(AppIntegrityService.class);
private static final Set<String> PLATFORMS = Set.of("ANDROID", "IOS");
private static final int MAX_TOKEN_LEN = 8192; // 무결성 토큰 길이 상한(방어적)
public AppIntegrityResponse verify(String userId, AppIntegrityRequest req) {
String platform = req.platform() == null ? "" : req.platform().trim().toUpperCase(Locale.ROOT);
if (!PLATFORMS.contains(platform)) {
throw new ApiException(ErrorCode.VALIDATION, "platform 은 ANDROID 또는 IOS 여야 합니다.");
}
if (req.token() == null || req.token().isBlank() || req.token().length() > MAX_TOKEN_LEN) {
throw new ApiException(ErrorCode.VALIDATION, "무결성 토큰이 올바르지 않습니다.");
}
// 토큰 원문은 로깅하지 않는다 수신 사실만 기록(사용자 식별자도 마스킹된 감사에 위임).
log.info("[integrity] attestation received platform={} (stub verifier, no external call)", platform);
return new AppIntegrityResponse(false, "STUB",
"무결성 검증기 미구성(온프레미스) — 서버 검증 골격만 동작합니다. 접근은 차단하지 않습니다.");
}
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.kintex.auth.integrity.dto;
import jakarta.validation.constraints.NotBlank;
/**
* 무결성(위변조 방지) 검증 요청 모바일이 Play Integrity(Android)/App Attest(iOS) 토큰을 첨부.
*
* @param platform ANDROID | IOS
* @param token 플랫폼 발급 무결성 토큰(불투명). 서버 로그에 원문 저장/노출 금지.
* @param nonce (선택) 재생공격 방지용 서버발급 nonce 에코.
*/
public record AppIntegrityRequest(
@NotBlank String platform,
@NotBlank String token,
String nonce
) {
}

View File

@ -0,0 +1,11 @@
package com.zioinfo.kintex.auth.integrity.dto;
/**
* 무결성 검증 결과.
*
* @param attested 무결성 검증 통과 여부. {@code mode=STUB} 에서는 항상 false(실검증 미구성).
* @param mode STUB(서버 검증기 미구성·온프레미스 폴백) | LIVE(플랫폼 검증 구성 완료).
* @param reason 판정 사유(사용자/클라 안내용 요약, 민감정보 없음).
*/
public record AppIntegrityResponse(boolean attested, String mode, String reason) {
}

View File

@ -24,4 +24,15 @@ public interface UserMapper {
* 장치업체(CONTRACTOR) 초대 수락 게이트(계약: NOT_REGISTERED_COMPANY). 기존 company 테이블 read-only.
*/
Boolean findCompanyRegistered(@Param("registrationNo") String registrationNo);
/** 내정보 표시용 프로필(email·phone·deptId + 사진 유무). password_hash·otp_secret 절대 select 금지. */
Map<String, Object> findProfile(@Param("userId") String userId);
/** 프로필 사진 메타(photoPath·photoContentType) 조회 — 서빙 엔드포인트용. 없으면 null. */
Map<String, Object> findPhotoMeta(@Param("userId") String userId);
/** 프로필 사진 메타 갱신(본인만 호출됨 — 서비스에서 principal.userId 강제). 영향 행수 반환. */
int updatePhoto(@Param("userId") String userId,
@Param("photoPath") String photoPath,
@Param("photoContentType") String photoContentType);
}

View File

@ -0,0 +1,70 @@
package com.zioinfo.kintex.auth.profile;
import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.auth.profile.dto.PhotoBase64Request;
import com.zioinfo.kintex.auth.profile.dto.PhotoUploadResponse;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.audit.Audited;
import jakarta.validation.Valid;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.time.Duration;
/**
* 프로필 사진 API 모바일 내정보(SCR-M) 아바타 계약(단일 창구).
* <ul>
* <li>업로드: {@code POST /api/auth/profile/photo} (본인 · multipart {@code file} 또는 base64 JSON) {@code { photoUrl }}</li>
* <li>서빙: {@code GET /api/auth/users/{userId}/photo} (인증 · 없으면 404) 바이트 스트림 + 캐시 헤더</li>
* </ul>
* 인증 경로는 SecurityConfig {@code anyRequest().authenticated()} 보호된다(별도 permitAll 없음).
*/
@RestController
@RequestMapping("/api/auth")
public class ProfilePhotoController {
private final ProfilePhotoService service;
private final EventAccessGuard guard;
public ProfilePhotoController(ProfilePhotoService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** 업로드(멀티파트) — 본인 사진만. userId 는 항상 principal 에서 온다(경로/폼 파라미터 미수용). */
@Audited(action = "PROFILE_PHOTO_UPLOAD", targetType = "app_user")
@PostMapping(value = "/profile/photo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<PhotoUploadResponse> upload(@AuthenticationPrincipal KintexPrincipal principal,
@RequestPart("file") MultipartFile file) {
guard.require(principal);
String url = service.upload(principal.userId(), file);
return ApiResponse.ok(new PhotoUploadResponse(url));
}
/** 업로드(base64 JSON 대체안) — 멀티파트 곤란 단말용. */
@Audited(action = "PROFILE_PHOTO_UPLOAD", targetType = "app_user")
@PostMapping(value = "/profile/photo", consumes = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<PhotoUploadResponse> uploadBase64(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody PhotoBase64Request req) {
guard.require(principal);
String url = service.uploadBase64(principal.userId(), req.base64(), req.mimeType());
return ApiResponse.ok(new PhotoUploadResponse(url));
}
/** 서빙 — 인증 사용자면 조회 가능(멤버 목록·아바타 표시). 없으면 404(이니셜 폴백). */
@GetMapping("/users/{userId}/photo")
public ResponseEntity<byte[]> photo(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String userId) {
guard.require(principal);
ProfilePhotoService.StoredPhoto photo = service.load(userId);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(photo.contentType()))
.cacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePrivate())
.body(photo.bytes());
}
}

View File

@ -0,0 +1,221 @@
package com.zioinfo.kintex.auth.profile;
import com.zioinfo.kintex.auth.mapper.UserMapper;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
/**
* 프로필 사진 업로드/서빙 서비스 모바일 내정보 아바타 계약.
*
* <p>저장 컨벤션은 로그인 슬라이드와 동일(파일시스템 {@code {kintex.upload.dir}/avatars/} + 서버 UUID 파일명).
* , 서빙은 정적 /uploads 아니라 인증 엔드포인트({@code GET /api/auth/users/{id}/photo}) 바이트 스트림한다
* (RN {@code <Image>} Authorization 헤더를 직접 부여하는 계약). photo_path 서버 UUID 저장
* 원본 파일명·클라이언트 경로는 신뢰하지 않으므로 경로 순회는 구조적으로 불가능하다.
*
* <p>검증(다층): 콘텐츠타입 화이트리스트 크기 상한 매직바이트(실제 파일 시그니처) 확장자/헤더 위조로
* SVG·실행형(스크립트) 업로드를 차단한다.
*/
@Service
public class ProfilePhotoService {
private static final Logger log = LoggerFactory.getLogger(ProfilePhotoService.class);
/** 아바타 크기 상한(5MB) — 모바일은 1:1 크롭·quality 0.7로 축소 전송. */
private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024;
/** 허용 MIME → 확장자. SVG·gif 등은 화이트리스트 밖(실행형/애니 차단). */
private static final Map<String, String> ALLOWED_TYPES = Map.of(
"image/png", "png",
"image/jpeg", "jpg",
"image/webp", "webp");
private final UserMapper userMapper;
private final Path avatarDir;
public ProfilePhotoService(UserMapper userMapper,
@Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
this.userMapper = userMapper;
this.avatarDir = Paths.get(uploadDir).toAbsolutePath().normalize().resolve("avatars");
}
/** 서빙 URL(캐시버스터는 모바일이 부착). {@code GET /api/auth/users/{userId}/photo} 와 동일 경로. */
public static String serveUrl(String userId) {
return "/api/auth/users/" + userId + "/photo";
}
// 업로드(멀티파트)
/** 본인 프로필 사진 업로드 → 서빙 URL 반환. userId 는 principal 에서만 온다(본인 강제). */
public String upload(String userId, MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ApiException(ErrorCode.VALIDATION, "이미지 파일을 첨부해 주세요.");
}
byte[] bytes;
try {
bytes = file.getBytes();
} catch (IOException e) {
log.warn("avatar read failed: {}", e.getMessage());
throw new ApiException(ErrorCode.VALIDATION, "이미지를 읽을 수 없습니다.");
}
return storeBytes(userId, bytes, file.getContentType());
}
// 업로드(base64 JSON 대체안)
/** base64 페이로드 업로드(멀티파트 곤란 단말 대체). data-URL prefix 는 서비스에서 제거한다. */
public String uploadBase64(String userId, String base64, String mimeType) {
if (base64 == null || base64.isBlank()) {
throw new ApiException(ErrorCode.VALIDATION, "이미지 데이터를 전달해 주세요.");
}
String payload = base64;
int comma = payload.indexOf(','); // "data:image/png;base64,...." 형태 허용
if (payload.startsWith("data:") && comma > 0) {
payload = payload.substring(comma + 1);
}
byte[] bytes;
try {
bytes = java.util.Base64.getDecoder().decode(payload.trim());
} catch (IllegalArgumentException e) {
throw new ApiException(ErrorCode.VALIDATION, "이미지 데이터 형식이 올바르지 않습니다.");
}
return storeBytes(userId, bytes, mimeType);
}
private String storeBytes(String userId, byte[] bytes, String declaredType) {
if (bytes.length == 0) {
throw new ApiException(ErrorCode.VALIDATION, "빈 이미지입니다.");
}
if (bytes.length > MAX_IMAGE_BYTES) {
throw new ApiException(ErrorCode.VALIDATION, "이미지는 5MB 이하만 업로드할 수 있습니다.");
}
// 매직바이트로 실제 유형 확정(선언 타입은 참고만) SVG/스크립트/위조 헤더 차단.
String detected = detectImageType(bytes);
if (detected == null) {
throw new ApiException(ErrorCode.VALIDATION, "PNG/JPG/WebP 이미지만 업로드할 수 있습니다.");
}
// 선언 타입이 있으면 화이트리스트 여부만 확인(불일치는 매직바이트를 신뢰).
String normalizedDeclared = declaredType == null ? null : declaredType.toLowerCase(Locale.ROOT).trim();
if (normalizedDeclared != null && !normalizedDeclared.isBlank()
&& !ALLOWED_TYPES.containsKey(normalizedDeclared)) {
throw new ApiException(ErrorCode.VALIDATION, "PNG/JPG/WebP 이미지만 업로드할 수 있습니다.");
}
String contentType = mimeFor(detected);
String fileName = UUID.randomUUID() + "." + detected;
try {
Files.createDirectories(avatarDir);
Path target = avatarDir.resolve(fileName).normalize();
if (!target.startsWith(avatarDir)) { // 파일명이 UUID라 사실상 불변식이나 방어적 확인
throw new ApiException(ErrorCode.VALIDATION, "잘못된 파일 경로입니다.");
}
Files.write(target, bytes);
// 이전 사진 정리(교체) 실패는 무해(로그만).
Map<String, Object> prev = userMapper.findPhotoMeta(userId);
userMapper.updatePhoto(userId, fileName, contentType);
if (prev != null) {
removeQuietly(str(prev.get("photoPath")));
}
} catch (IOException e) {
log.error("avatar store failed for user (masked): {}", e.getMessage());
throw new ApiException(ErrorCode.INTERNAL, "이미지 저장에 실패했습니다.");
}
return serveUrl(userId);
}
// 서빙
/** 사진 바이트 + Content-Type. 없으면 404(모바일 이니셜 폴백). */
public StoredPhoto load(String userId) {
Map<String, Object> meta = userMapper.findPhotoMeta(userId);
if (meta == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "등록된 프로필 사진이 없습니다.");
}
String fileName = str(meta.get("photoPath"));
if (fileName == null || fileName.isBlank() || !isSafeName(fileName)) {
throw new ApiException(ErrorCode.NOT_FOUND, "등록된 프로필 사진이 없습니다.");
}
Path target = avatarDir.resolve(fileName).normalize();
if (!target.startsWith(avatarDir) || !Files.isRegularFile(target)) {
throw new ApiException(ErrorCode.NOT_FOUND, "등록된 프로필 사진이 없습니다.");
}
try {
byte[] data = Files.readAllBytes(target);
String ct = str(meta.get("photoContentType"));
return new StoredPhoto(data, ct == null || ct.isBlank() ? "application/octet-stream" : ct);
} catch (IOException e) {
log.warn("avatar load failed: {}", e.getMessage());
throw new ApiException(ErrorCode.NOT_FOUND, "등록된 프로필 사진이 없습니다.");
}
}
// 검증 헬퍼
/** 매직바이트로 png/jpg/webp 판별. 아니면 null(SVG·스크립트·위조 헤더 차단). 순수 정적 헬퍼. */
public static String detectImageType(byte[] b) {
if (b.length >= 8 && (b[0] & 0xFF) == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47
&& b[4] == 0x0D && b[5] == 0x0A && b[6] == 0x1A && b[7] == 0x0A) {
return "png";
}
if (b.length >= 3 && (b[0] & 0xFF) == 0xFF && (b[1] & 0xFF) == 0xD8 && (b[2] & 0xFF) == 0xFF) {
return "jpg";
}
// WEBP: "RIFF"...."WEBP"
if (b.length >= 12 && b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F'
&& b[8] == 'W' && b[9] == 'E' && b[10] == 'B' && b[11] == 'P') {
return "webp";
}
return null;
}
private static String mimeFor(String ext) {
return switch (ext) {
case "png" -> "image/png";
case "webp" -> "image/webp";
default -> "image/jpeg";
};
}
private static boolean isSafeName(String name) {
return !name.contains("/") && !name.contains("\\") && !name.contains("..");
}
private void removeQuietly(String fileName) {
if (fileName == null || fileName.isBlank() || !isSafeName(fileName)) {
return;
}
try {
Files.deleteIfExists(avatarDir.resolve(fileName));
} catch (IOException e) {
log.warn("avatar cleanup failed: {}", e.getMessage());
}
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
/** 서빙 바이트 컨테이너. */
public record StoredPhoto(byte[] bytes, String contentType) {
public StoredPhoto {
bytes = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length);
}
public byte[] bytes() {
return Arrays.copyOf(bytes, bytes.length);
}
}
}

View File

@ -0,0 +1,10 @@
package com.zioinfo.kintex.auth.profile.dto;
import jakarta.validation.constraints.NotBlank;
/** 아바타 업로드(base64 JSON 대체안) — 멀티파트 곤란 단말용. data-URL prefix 허용. */
public record PhotoBase64Request(
@NotBlank String base64,
String mimeType
) {
}

View File

@ -0,0 +1,5 @@
package com.zioinfo.kintex.auth.profile.dto;
/** 아바타 업로드 응답 — 서빙 URL. 모바일은 캐시버스터(?_t=...)를 부착해 사용한다. */
public record PhotoUploadResponse(String photoUrl) {
}

View File

@ -0,0 +1,82 @@
package com.zioinfo.kintex.config;
import com.zioinfo.kintex.auth.AuthProperties;
import com.zioinfo.kintex.security.SecurityPolicyProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import java.util.Arrays;
/**
* B12 운영 시크릿 fail-fast. 운영 프로파일에서 JWT 서명 /OTP 암호화 키가 <b>개발 폴백</b>이거나
* 미주입이면 컨텍스트 초기화 단계에서 기동을 중단한다(개발 폴백 값의 운영 유입 차단).
* 개발 프로파일에서는 경고만 남긴다(로컬 편의 유지).
*
* <p>보안 불변(계약 §0-3): 시크릿 <b></b> 로그·예외 메시지에 절대 담지 않는다 항목명과 조치만 노출한다.
* 운영 판정: 활성 프로파일에 {@code prod} 포함 또는 {@code kintex.security.enforce-secrets=true}.
*/
@Component
public class SecretStartupValidator implements InitializingBean {
private static final Logger log = LoggerFactory.getLogger(SecretStartupValidator.class);
/** application.yml 개발 폴백 접두어(env 미주입 시 주입되는 값). */
private static final String DEV_JWT_PREFIX = "CHANGE_ME_DEV_ONLY";
private final Environment env;
private final AuthProperties authProperties;
private final SecurityPolicyProperties securityProperties;
public SecretStartupValidator(Environment env, AuthProperties authProperties,
SecurityPolicyProperties securityProperties) {
this.env = env;
this.authProperties = authProperties;
this.securityProperties = securityProperties;
}
@Override
public void afterPropertiesSet() {
boolean production = isProduction();
boolean jwtWeak = isBlank(authProperties.getJwtSecret())
|| authProperties.getJwtSecret().startsWith(DEV_JWT_PREFIX);
boolean otpKeyMissing = isBlank(securityProperties.getOtp().getEncKey());
if (production) {
if (jwtWeak) {
throw new IllegalStateException(
"운영 기동 중단(fail-fast): JWT_SECRET 이 미주입 또는 개발 폴백값입니다. "
+ "최소 32바이트 시크릿을 환경변수 JWT_SECRET 로 주입하세요.");
}
if (otpKeyMissing) {
throw new IllegalStateException(
"운영 기동 중단(fail-fast): KINTEX_OTP_ENC_KEY(OTP AES-256-GCM 키)가 미주입입니다. "
+ "base64 32바이트 키를 환경변수 KINTEX_OTP_ENC_KEY 로 주입하세요.");
}
log.info("[security] 운영 시크릿 검증 통과 — JWT_SECRET·KINTEX_OTP_ENC_KEY 주입 확인.");
} else {
if (jwtWeak) {
log.warn("[security] JWT_SECRET 이 개발 폴백/미주입 상태입니다 — 운영 배포 전 env 주입 필수(운영 프로파일은 fail-fast).");
}
if (otpKeyMissing) {
log.warn("[security] KINTEX_OTP_ENC_KEY 미주입 — 개발 파생키 사용 중(운영 프로파일은 fail-fast).");
}
}
}
/** 활성 프로파일에 prod 포함 또는 명시 강제 플래그. */
private boolean isProduction() {
if (Boolean.parseBoolean(env.getProperty("kintex.security.enforce-secrets", "false"))) {
return true;
}
return Arrays.stream(env.getActiveProfiles())
.anyMatch(p -> p != null && p.toLowerCase().contains("prod"));
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
}

View File

@ -8,6 +8,7 @@ import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.ApiResponse.ApiError;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
@ -18,6 +19,12 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.ArrayList;
import java.util.List;
/**
* 보안 설정 무상태 JWT. 공개 경로: /health, 로그인, WebSocket 핸드셰이크.
@ -26,13 +33,21 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@Configuration
public class SecurityConfig {
/**
* CORS 허용 오리진 화이트리스트(env {@code CORS_ALLOWED_ORIGINS}, 콤마구분). 와일드카드('*') 금지
* 값에 '*' 있으면 무시하고 로컬 개발 오리진만 허용한다(운영 오리진은 반드시 명시 주입).
*/
@Value("${kintex.cors.allowed-origins:http://localhost:5173,http://localhost:3000}")
private List<String> allowedOrigins;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService,
TenantResolver tenantResolver,
ObjectMapper objectMapper) throws Exception {
ObjectMapper objectMapper,
CorsConfigurationSource corsConfigurationSource) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> {})
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/health", "/api/auth/login", "/ws/**",
@ -63,6 +78,39 @@ public class SecurityConfig {
return http.build();
}
/**
* B11 CORS 명시(화이트리스트). 와일드카드('*') 오리진은 제거하고 명시 오리진만 허용한다.
* 인증은 Bearer JWT(쿠키 미사용)이므로 {@code allowCredentials=false} 자격증명 쿠키 반사 위험을 원천 차단한다.
* 허용 헤더는 Authorization·Content-Type·테넌트 헤더로 최소화한다.
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
List<String> origins = new ArrayList<>();
if (allowedOrigins != null) {
for (String o : allowedOrigins) {
if (o == null) {
continue;
}
String v = o.trim();
// 와일드카드는 금지(운영 오리진 명시 강제) '*' 단독/포함 값은 무시한다.
if (!v.isEmpty() && !v.contains("*")) {
origins.add(v);
}
}
}
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(origins);
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Tenant-Id", "X-Requested-With"));
cfg.setExposedHeaders(List.of("Content-Disposition"));
cfg.setAllowCredentials(false);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();

View File

@ -0,0 +1,61 @@
package com.zioinfo.kintex.publicsite;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.MonthlySummaryDto;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.PublicCalendarEventDto;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.TransportInfoDto;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.VisitorGuideDto;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 공개 관람객 안내 API (V43 근거 비인증 공개, {@code /api/public/**} permitAll).
* <p>/ 전시 캘린더 + 방문가이드 + 교통 외부 실시간 호출 없이 적재 DB 서빙.
* <p>기존 {@code PublicSiteController} {@code /api/public/events}(카탈로그)와는 별도 리터럴 경로로 충돌 없음:
* 컨트롤러는 캘린더 (lifecycle·/) 기반의 {@code /events/calendar}·{@code /events/monthly-summary} 신설한다.
*/
@RestController
@RequestMapping("/api/public")
public class PublicGuideController {
private final PublicGuideService service;
public PublicGuideController(PublicGuideService service) {
this.service = service;
}
/**
* GET /api/public/events/calendar?year=&month=&lifecycle= //상태 캘린더(v_event_calendar).
* <p>필터 없으면 전체(시작일 오름차순, 상한 {@code limit}). lifecycle UPCOMING|ONGOING|ENDED.
*/
@GetMapping("/events/calendar")
public ApiResponse<List<PublicCalendarEventDto>> calendar(
@RequestParam(required = false) Integer year,
@RequestParam(required = false) Integer month,
@RequestParam(required = false) String lifecycle,
@RequestParam(required = false, defaultValue = "300") int limit) {
return ApiResponse.ok(service.calendar(year, month, lifecycle, limit));
}
/** GET /api/public/events/monthly-summary?year= — 월별 전시 건수 집계(v_event_monthly_summary). */
@GetMapping("/events/monthly-summary")
public ApiResponse<List<MonthlySummaryDto>> monthlySummary(@RequestParam(required = false) Integer year) {
return ApiResponse.ok(service.monthlySummary(year));
}
/** GET /api/public/visitor-guide?category= — 방문가이드(교통/주차/입장/편의/접근성/개요). */
@GetMapping("/visitor-guide")
public ApiResponse<List<VisitorGuideDto>> visitorGuide(@RequestParam(required = false) String category) {
return ApiResponse.ok(service.guides(category));
}
/** GET /api/public/transport?mode= — 교통수단 구조화(SUBWAY/GTX/BUS/CAR/AIRPORT/KTX). */
@GetMapping("/transport")
public ApiResponse<List<TransportInfoDto>> transport(@RequestParam(required = false) String mode) {
return ApiResponse.ok(service.transport(mode));
}
}

View File

@ -0,0 +1,145 @@
package com.zioinfo.kintex.publicsite;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
/**
* 공개 관람객 안내 매퍼 (V43 근거) 조회 read-only, tenant_id='KINTEX' 고정.
* <p>근거: v_event_calendar / v_event_monthly_summary / visitor_guide / transport_info.
* <p> Map 반환 @Select camelCase 별칭에 반드시 쌍따옴표({@code AS "x"}) 사용한다(PG lower-fold 방지).
* <p> 외부 실시간 호출 없이 DB 서빙 AI 관람 도우미의 유일한 근거 소스.
*/
@Mapper
public interface PublicGuideMapper {
// / 캘린더 (v_event_calendar)
/**
* //생명주기 필터 캘린더 조회 시작일 오름차순(타임라인). 필터는 모두 선택.
* lifecycle UPCOMING|ONGOING|ENDED (null 이면 전체).
*/
@Select("""
<script>
SELECT id,
name,
category,
hall_label AS "hallLabel",
poster_url AS "posterUrl",
to_char(start_date, 'YYYY-MM-DD') AS "startDate",
to_char(end_date, 'YYYY-MM-DD') AS "endDate",
event_year AS "eventYear",
event_month AS "eventMonth",
lifecycle
FROM v_event_calendar
<where>
tenant_id = 'KINTEX'
<if test="year != null"> AND event_year = #{year} </if>
<if test="month != null"> AND event_month = #{month} </if>
<if test="lifecycle != null and lifecycle != ''"> AND lifecycle = #{lifecycle} </if>
</where>
ORDER BY start_date ASC NULLS LAST, id
LIMIT #{limit}
</script>
""")
List<Map<String, Object>> findCalendar(@Param("year") Integer year,
@Param("month") Integer month,
@Param("lifecycle") String lifecycle,
@Param("limit") int limit);
/**
* 행사명 부분검색 AI 도우미 이름 매칭용. 미래 행사 우선(가까운 ) 과거 최신순.
* tokens 이름 ILIKE OR 결합( 리스트면 호출자가 스킵).
*/
@Select("""
<script>
SELECT id,
name,
category,
hall_label AS "hallLabel",
poster_url AS "posterUrl",
to_char(start_date, 'YYYY-MM-DD') AS "startDate",
to_char(end_date, 'YYYY-MM-DD') AS "endDate",
event_year AS "eventYear",
event_month AS "eventMonth",
lifecycle
FROM v_event_calendar
WHERE tenant_id = 'KINTEX'
AND (
<foreach collection="tokens" item="t" separator=" OR ">
name ILIKE ('%' || #{t} || '%')
</foreach>
)
ORDER BY (start_date >= CURRENT_DATE) DESC,
CASE WHEN start_date >= CURRENT_DATE THEN start_date END ASC NULLS LAST,
start_date DESC NULLS LAST,
id
LIMIT #{limit}
</script>
""")
List<Map<String, Object>> searchEventsByName(@Param("tokens") List<String> tokens,
@Param("limit") int limit);
// 월별 집계 (v_event_monthly_summary)
/** 연도(선택) 월별 전시 건수 집계 — 연/월 오름차순. */
@Select("""
<script>
SELECT event_year AS "eventYear",
event_month AS "eventMonth",
event_count AS "eventCount"
FROM v_event_monthly_summary
<where>
tenant_id = 'KINTEX'
<if test="year != null"> AND event_year = #{year} </if>
</where>
ORDER BY event_year, event_month
</script>
""")
List<Map<String, Object>> findMonthlySummary(@Param("year") Integer year);
// 방문가이드 (visitor_guide)
/** 카테고리(선택) 방문가이드 — use_yn='Y' 만. 카테고리·표시순 정렬. */
@Select("""
<script>
SELECT id,
category,
title,
body,
summary,
display_order AS "displayOrder",
source_url AS "sourceUrl"
FROM visitor_guide
<where>
tenant_id = 'KINTEX' AND use_yn = 'Y'
<if test="category != null and category != ''"> AND category = #{category} </if>
</where>
ORDER BY category, display_order, id
</script>
""")
List<Map<String, Object>> findGuides(@Param("category") String category);
// 교통수단 (transport_info)
/** 교통수단(선택 mode) 구조화 목록 — 표시순 정렬. */
@Select("""
<script>
SELECT id,
mode,
line_name AS "lineName",
route,
from_point AS "fromPoint",
travel_time AS "travelTime",
note,
display_order AS "displayOrder",
source_url AS "sourceUrl"
FROM transport_info
<where>
tenant_id = 'KINTEX'
<if test="mode != null and mode != ''"> AND mode = #{mode} </if>
</where>
ORDER BY display_order, mode, id
</script>
""")
List<Map<String, Object>> findTransport(@Param("mode") String mode);
}

View File

@ -0,0 +1,134 @@
package com.zioinfo.kintex.publicsite;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.MonthlySummaryDto;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.PublicCalendarEventDto;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.TransportInfoDto;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.VisitorGuideDto;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 공개 관람객 안내 서비스 / 전시 캘린더 + 방문가이드 + 교통(V43 근거, DB 서빙).
* <p> 경로 비인증 공개. 외부 실시간 호출 없이 적재 DB 조회한다.
* <p>AI 관람 도우미({@code VisitorAssistantService}) 근거 수집에 서비스를 재사용한다.
*/
@Service
public class PublicGuideService {
/** 캘린더 조회 상한(대량 데모 데이터 보호). */
static final int CALENDAR_MAX = 500;
private static final int CALENDAR_DEFAULT = 300;
private static final Set<String> LIFECYCLES = Set.of("UPCOMING", "ONGOING", "ENDED");
private final PublicGuideMapper mapper;
public PublicGuideService(PublicGuideMapper mapper) {
this.mapper = mapper;
}
// / 캘린더
@Transactional(readOnly = true)
public List<PublicCalendarEventDto> calendar(Integer year, Integer month, String lifecycle) {
return calendar(year, month, lifecycle, CALENDAR_DEFAULT);
}
@Transactional(readOnly = true)
public List<PublicCalendarEventDto> calendar(Integer year, Integer month, String lifecycle, int limit) {
String lc = normalizeLifecycle(lifecycle);
int cap = Math.min(Math.max(limit, 1), CALENDAR_MAX);
return mapper.findCalendar(validYear(year), validMonth(month), lc, cap)
.stream().map(PublicGuideService::toCalendarEvent).toList();
}
/** 행사명 토큰 부분검색(AI 도우미 근거 수집용). tokens 비면 빈 목록. */
@Transactional(readOnly = true)
public List<PublicCalendarEventDto> searchEventsByName(List<String> tokens, int limit) {
if (tokens == null || tokens.isEmpty()) {
return List.of();
}
int cap = Math.min(Math.max(limit, 1), CALENDAR_MAX);
return mapper.searchEventsByName(tokens, cap)
.stream().map(PublicGuideService::toCalendarEvent).toList();
}
// 월별 집계
@Transactional(readOnly = true)
public List<MonthlySummaryDto> monthlySummary(Integer year) {
return mapper.findMonthlySummary(validYear(year)).stream()
.map(m -> new MonthlySummaryDto(toInt(m.get("eventYear")), toInt(m.get("eventMonth")),
toInt(m.get("eventCount"))))
.toList();
}
// 방문가이드
@Transactional(readOnly = true)
public List<VisitorGuideDto> guides(String category) {
return mapper.findGuides(nullIfBlank(category)).stream()
.map(PublicGuideService::toGuide).toList();
}
// 교통
@Transactional(readOnly = true)
public List<TransportInfoDto> transport(String mode) {
return mapper.findTransport(nullIfBlank(mode)).stream()
.map(PublicGuideService::toTransport).toList();
}
// row DTO
static PublicCalendarEventDto toCalendarEvent(Map<String, Object> m) {
return new PublicCalendarEventDto(
str(m.get("id")), str(m.get("name")), str(m.get("category")),
str(m.get("hallLabel")), str(m.get("posterUrl")),
str(m.get("startDate")), str(m.get("endDate")),
toInt(m.get("eventYear")), toInt(m.get("eventMonth")), str(m.get("lifecycle")));
}
static VisitorGuideDto toGuide(Map<String, Object> m) {
return new VisitorGuideDto(
str(m.get("id")), str(m.get("category")), str(m.get("title")),
str(m.get("body")), str(m.get("summary")),
toInt(m.get("displayOrder")), str(m.get("sourceUrl")));
}
static TransportInfoDto toTransport(Map<String, Object> m) {
return new TransportInfoDto(
str(m.get("id")), str(m.get("mode")), str(m.get("lineName")), str(m.get("route")),
str(m.get("fromPoint")), str(m.get("travelTime")), str(m.get("note")),
toInt(m.get("displayOrder")), str(m.get("sourceUrl")));
}
// helpers
private static String normalizeLifecycle(String lifecycle) {
String v = nullIfBlank(lifecycle);
if (v == null) {
return null;
}
String up = v.toUpperCase(java.util.Locale.ROOT);
return LIFECYCLES.contains(up) ? up : null; // 없는 값은 무시(전체 조회)
}
private static Integer validYear(Integer year) {
return (year != null && year >= 2000 && year <= 2100) ? year : null;
}
private static Integer validMonth(Integer month) {
return (month != null && month >= 1 && month <= 12) ? month : null;
}
private static String nullIfBlank(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
private static String str(Object o) {
return o == null ? null : o.toString();
}
private static Integer toInt(Object o) {
return (o instanceof Number n) ? n.intValue() : null;
}
}

View File

@ -0,0 +1,32 @@
package com.zioinfo.kintex.publicsite.assistant;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.publicsite.assistant.dto.VisitorAssistantDtos.AssistantRequest;
import com.zioinfo.kintex.publicsite.assistant.dto.VisitorAssistantDtos.AssistantResult;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 공개 AI 관람 도우미 API (비인증, {@code /api/public/**} permitAll).
*
* <p>POST /api/public/ai/visitor-assistant 자연어 질문을 적재 DB(V43: 전시 캘린더·방문가이드·교통) 근거로
* 답한다. 응답은 답변 + 근거 카드 + 인용 출처 + degraded 플래그를 정직하게 담는다(환각 방지).
* AI(ClaudeOllama) 미가용 검색 요약으로 자동 폴백한다. 외부 실시간 크롤 호출은 하지 않는다.
*/
@RestController
@RequestMapping("/api/public/ai")
public class VisitorAssistantController {
private final VisitorAssistantService service;
public VisitorAssistantController(VisitorAssistantService service) {
this.service = service;
}
@PostMapping("/visitor-assistant")
public ApiResponse<AssistantResult> visitorAssistant(@RequestBody(required = false) AssistantRequest req) {
return ApiResponse.ok(service.ask(req == null ? null : req.question()));
}
}

View File

@ -0,0 +1,377 @@
package com.zioinfo.kintex.publicsite.assistant;
import com.zioinfo.kintex.ai.AiTextRouter;
import com.zioinfo.kintex.ai.AiTextRouter.AiResult;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.publicsite.PublicGuideService;
import com.zioinfo.kintex.publicsite.assistant.dto.VisitorAssistantDtos.AssistantResult;
import com.zioinfo.kintex.publicsite.assistant.dto.VisitorAssistantDtos.Citation;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.PublicCalendarEventDto;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.TransportInfoDto;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.VisitorGuideDto;
import org.springframework.stereotype.Service;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 공개 AI 관람 도우미 자연어 질문을 <b>적재 DB(V43) 근거만</b>으로 답한다(RAG-style grounding · 비인증).
*
* <p><b>파이프라인</b>: 질문 의도 감지(교통/주차/입장/편의/접근성/개요 + 전시 일정)
* {@link PublicGuideService} 관련 레코드 조회(방문가이드·교통·/ 캘린더)
* 키워드 스코어링으로 상위 근거 선별 근거 있으면 {@link AiTextRouter}(ClaudeOllama)
* <b>선별된 레코드만</b> 근거로 한국어 답변 생성 응답에 근거 카드 + 인용 출처 동봉.
*
* <p><b>환각 방지(핵심)</b>:
* <ul>
* <li>근거 0건 AI 호출 없이 "찾지 못했습니다" 정직 응답(grounded=false, provider=none).</li>
* <li>근거는 있으나 AI 미가용 지어내지 않고 <b>찾은 레코드 요약</b> 반환(degraded=true, provider="search").</li>
* <li>프롬프트에 "제공된 DB 근거만 사용, 없으면 모른다" grounding 지시. 외부 실시간 크롤 호출 금지.</li>
* </ul>
*/
@Service
public class VisitorAssistantService {
private static final int MAX_QUESTION = 500;
private static final int TOP_GUIDE = 4;
private static final int TOP_TRANSPORT = 4;
private static final int MAX_EVENTS = 12;
private static final int MAX_TOKENS = 700;
private static final Pattern YEAR = Pattern.compile("(20\\d{2})\\s*년?");
private static final Pattern MONTH = Pattern.compile("(1[0-2]|0?[1-9])\\s*월");
/** 전시/행사 일정 의도 신호. */
private static final String[] EVENT_INTENT = {
"전시", "행사", "일정", "언제", "열리", "열려", "열림", "개최", "스케줄", "볼거리", "관람",
"이번달", "이번 달", "다음달", "다음 달", "올해", "내년", "연간", "몇월", "몇 월",
"event", "exhibition", "show", "expo", "fair", "calendar"
};
/** 행사명 검색에서 제외할 일반/질문/날짜 어휘(모든 행사에 매칭되는 잡음 차단). */
private static final Set<String> NAME_STOPWORDS = Set.of(
"전시", "행사", "일정", "스케줄", "관람", "볼거리", "언제", "얼마", "어디", "어떻게", "무엇", "뭐가",
"있나요", "있어", "알려줘", "알려주세요", "해주세요", "하나요", "인가요", "개최", "열리", "열려",
"이번", "다음", "올해", "내년", "연간", "이번달", "다음달", "지금", "오늘", "내일", "요즘", "최근",
"event", "exhibition", "show", "expo", "fair", "calendar", "kintex", "킨텍스", "전시장", "전시회");
private final PublicGuideService guideService;
private final AiTextRouter router;
public VisitorAssistantService(PublicGuideService guideService, AiTextRouter router) {
this.guideService = guideService;
this.router = router;
}
/** 스코어링용 근거 항목. */
private record Scored<T>(T item, int score) {
}
public AssistantResult ask(String questionRaw) {
if (questionRaw == null || questionRaw.isBlank()) {
throw new ApiException(ErrorCode.VALIDATION, "질문을 입력해 주세요.");
}
String question = questionRaw.strip();
if (question.length() > MAX_QUESTION) {
question = question.substring(0, MAX_QUESTION);
}
List<String> tokens = tokenize(question);
// 근거 수집(DB 서빙만)
List<VisitorGuideDto> guides = scoreGuides(guideService.guides(null), tokens, TOP_GUIDE);
List<TransportInfoDto> transports = scoreTransport(guideService.transport(null), tokens, TOP_TRANSPORT);
List<PublicCalendarEventDto> events = collectEvents(question, tokens);
boolean grounded = !guides.isEmpty() || !transports.isEmpty() || !events.isEmpty();
List<Citation> citations = buildCitations(events, transports, guides);
// 근거 없음 AI 호출 없이 정직 응답(환각 차단).
if (!grounded) {
return new AssistantResult(question,
"제공된 안내 정보에서 관련 내용을 찾지 못했습니다. "
+ "교통·주차·입장·편의시설·접근성 또는 전시 일정(연도/월/행사명) 관련으로 다시 질문해 주세요.",
List.of(), List.of(), List.of(), List.of(), false, "none", true);
}
AiResult ai = router.generate(buildPrompt(question, events, transports, guides), MAX_TOKENS);
if (ai.usable()) {
return new AssistantResult(question, ai.text().trim(), events, transports, guides,
citations, true, ai.provider(), false);
}
// AI 미가용 지어내지 말고 찾은 레코드를 요약(근거 기반·정직).
return new AssistantResult(question, summarize(events, transports, guides), events, transports, guides,
citations, true, "search", true);
}
// 이벤트 근거 수집
private List<PublicCalendarEventDto> collectEvents(String question, List<String> tokens) {
if (!hasEventIntent(question)) {
return List.of();
}
String q = question.toLowerCase(Locale.ROOT).replace(" ", "");
Integer year = firstInt(YEAR, question);
Integer month = firstInt(MONTH, question);
String lifecycle = parseLifecycle(question);
// 상대 표현(이번달/다음달/올해/내년) / 산출(명시 값이 우선).
YearMonth now = YearMonth.now();
if (month == null && q.contains("다음달")) {
YearMonth next = now.plusMonths(1);
year = (year != null) ? year : next.getYear();
month = next.getMonthValue();
} else if (month == null && q.contains("이번달")) {
year = (year != null) ? year : now.getYear();
month = now.getMonthValue();
} else if (year == null && q.contains("내년")) {
year = now.getYear() + 1;
} else if (year == null && q.contains("올해")) {
year = now.getYear();
}
LinkedHashSet<PublicCalendarEventDto> merged = new LinkedHashSet<>();
if (year != null || month != null || lifecycle != null) {
merged.addAll(guideService.calendar(year, month, lifecycle, MAX_EVENTS));
} else {
// 시점 미지정 일반 일정 질문 예정(다가오는) 행사 우선.
merged.addAll(guideService.calendar(null, null, "UPCOMING", MAX_EVENTS));
}
// 행사명 토큰 검색(특정 전시 지목 대응).
List<String> nameTokens = nameSearchTokens(tokens);
if (!nameTokens.isEmpty()) {
merged.addAll(guideService.searchEventsByName(nameTokens, MAX_EVENTS));
}
return merged.stream().limit(MAX_EVENTS).toList();
}
private boolean hasEventIntent(String question) {
String q = question.toLowerCase(Locale.ROOT);
for (String kw : EVENT_INTENT) {
if (q.contains(kw)) {
return true;
}
}
return YEAR.matcher(question).find() || MONTH.matcher(question).find();
}
private List<String> nameSearchTokens(List<String> tokens) {
List<String> out = new ArrayList<>();
for (String t : tokens) {
if (t.length() >= 2 && !NAME_STOPWORDS.contains(t) && !t.matches(".*\\d.*") && out.size() < 5) {
out.add(t);
}
}
return out;
}
private String parseLifecycle(String question) {
if (question.contains("예정") || question.contains("다가오")) {
return "UPCOMING";
}
if (question.contains("진행") || question.contains("진행중") || question.contains("지금")) {
return "ONGOING";
}
if (question.contains("종료") || question.contains("끝난") || question.contains("지난")) {
return "ENDED";
}
return null;
}
// 키워드 스코어링
private List<VisitorGuideDto> scoreGuides(List<VisitorGuideDto> corpus, List<String> tokens, int topN) {
List<Scored<VisitorGuideDto>> scored = new ArrayList<>();
for (VisitorGuideDto g : corpus) {
String hay = norm(String.join(" ",
safe(g.category()), safe(g.title()), safe(g.summary()), safe(g.body())));
int s = score(hay, tokens);
if (s > 0) {
scored.add(new Scored<>(g, s));
}
}
return topItems(scored, topN);
}
private List<TransportInfoDto> scoreTransport(List<TransportInfoDto> corpus, List<String> tokens, int topN) {
List<Scored<TransportInfoDto>> scored = new ArrayList<>();
for (TransportInfoDto t : corpus) {
String hay = norm(String.join(" ",
safe(t.mode()), safe(t.lineName()), safe(t.route()), safe(t.fromPoint()),
safe(t.travelTime()), safe(t.note())));
int s = score(hay, tokens);
if (s > 0) {
scored.add(new Scored<>(t, s));
}
}
return topItems(scored, topN);
}
private static <T> List<T> topItems(List<Scored<T>> scored, int topN) {
scored.sort((a, b) -> Integer.compare(b.score(), a.score()));
List<T> out = new ArrayList<>();
for (int i = 0; i < Math.min(topN, scored.size()); i++) {
out.add(scored.get(i).item());
}
return out;
}
private static int score(String haystack, List<String> tokens) {
int s = 0;
for (String tok : tokens) {
if (tok.length() >= 2 && haystack.contains(tok)) {
s++;
}
}
return s;
}
// 프롬프트 · 요약
private String buildPrompt(String question, List<PublicCalendarEventDto> events,
List<TransportInfoDto> transports, List<VisitorGuideDto> guides) {
StringBuilder ev = new StringBuilder();
for (PublicCalendarEventDto e : events) {
ev.append("- [EVENT ").append(safe(e.id())).append("] ").append(safe(e.name()));
if (e.startDate() != null) {
ev.append(" | 기간=").append(safe(e.startDate()));
if (e.endDate() != null) {
ev.append("~").append(safe(e.endDate()));
}
}
if (e.hallLabel() != null) {
ev.append(" | 장소=").append(safe(e.hallLabel()));
}
if (e.lifecycle() != null) {
ev.append(" | 상태=").append(lifecycleKo(e.lifecycle()));
}
ev.append('\n');
}
StringBuilder tr = new StringBuilder();
for (TransportInfoDto t : transports) {
tr.append("- [TRANSPORT ").append(safe(t.id())).append("] ").append(safe(t.lineName()));
if (t.route() != null) {
tr.append(" | 경로=").append(safe(t.route()));
}
if (t.travelTime() != null) {
tr.append(" | 시간=").append(safe(t.travelTime()));
}
tr.append('\n');
}
StringBuilder gd = new StringBuilder();
for (VisitorGuideDto g : guides) {
gd.append("- [GUIDE ").append(safe(g.id())).append("] ").append(safe(g.title()))
.append(" | ").append(safe(g.summary() != null ? g.summary() : g.body()))
.append('\n');
}
StringBuilder p = new StringBuilder();
p.append("당신은 킨텍스(KINTEX) 전시장 공개 관람 안내 도우미다. ")
.append("아래 'DB 근거'만 사용해 관람객 질문에 한국어로 친절하고 간결하게 답하라.\n\n");
if (ev.length() > 0) {
p.append("[전시·행사 일정]\n").append(ev).append('\n');
}
if (tr.length() > 0) {
p.append("[교통]\n").append(tr).append('\n');
}
if (gd.length() > 0) {
p.append("[방문가이드]\n").append(gd).append('\n');
}
p.append("규칙(엄수):\n")
.append("1) 위 'DB 근거'에 있는 내용만 사용하라. 근거 밖의 날짜·요금·노선·시설을 지어내지 마라.\n")
.append("2) 근거로 답할 수 없으면 '제공된 안내 정보에서 확인되지 않습니다'라고 정직하게 답하라.\n")
.append("3) 요금·시간·주소 등 수치는 근거에 적힌 그대로만 인용하라.\n")
.append("4) 정확한 입장 조건·주차 운영은 행사별 공지 확인이 필요함을 필요 시 덧붙여라.\n")
.append("5) 마크다운 없이 자연스러운 문장으로 답하라.\n\n")
.append("질문: ").append(question);
return p.toString();
}
/** AI 미가용 시 근거 레코드 요약(지어내기 없음). */
private String summarize(List<PublicCalendarEventDto> events,
List<TransportInfoDto> transports, List<VisitorGuideDto> guides) {
StringBuilder sb = new StringBuilder("관련 안내 정보입니다(AI 요약 미가용 — 원문 기준):\n");
for (PublicCalendarEventDto e : events) {
sb.append("· ").append(safe(e.name()));
if (e.startDate() != null) {
sb.append(" (").append(safe(e.startDate()));
if (e.endDate() != null) {
sb.append("~").append(safe(e.endDate()));
}
sb.append(", ").append(lifecycleKo(e.lifecycle())).append(")");
}
sb.append('\n');
}
for (TransportInfoDto t : transports) {
sb.append("· ").append(safe(t.lineName()));
if (t.route() != null) {
sb.append("").append(safe(t.route()));
}
sb.append('\n');
}
for (VisitorGuideDto g : guides) {
sb.append("· ").append(safe(g.title()));
String detail = g.summary() != null ? g.summary() : g.body();
if (detail != null && !detail.isBlank()) {
sb.append("").append(detail);
}
sb.append('\n');
}
return sb.toString().trim();
}
private List<Citation> buildCitations(List<PublicCalendarEventDto> events,
List<TransportInfoDto> transports, List<VisitorGuideDto> guides) {
List<Citation> cites = new ArrayList<>();
for (PublicCalendarEventDto e : events) {
cites.add(new Citation("EVENT", e.id(), e.name(), null));
}
for (TransportInfoDto t : transports) {
cites.add(new Citation("TRANSPORT", t.id(), t.lineName(), t.sourceUrl()));
}
for (VisitorGuideDto g : guides) {
cites.add(new Citation("GUIDE", g.id(), g.title(), g.sourceUrl()));
}
return cites;
}
private static String lifecycleKo(String lifecycle) {
if (lifecycle == null) {
return "";
}
return switch (lifecycle) {
case "UPCOMING" -> "예정";
case "ONGOING" -> "진행중";
case "ENDED" -> "종료";
default -> lifecycle;
};
}
// 토큰화 helpers
private static List<String> tokenize(String question) {
String[] raw = norm(question).split("[\\s,·.!?()\\[\\]/]+");
List<String> out = new ArrayList<>();
for (String r : raw) {
if (r.length() >= 2) {
out.add(r);
}
}
return out;
}
private static Integer firstInt(Pattern p, String s) {
Matcher m = p.matcher(s);
return m.find() ? Integer.valueOf(m.group(1)) : null;
}
private static String norm(String s) {
return s == null ? "" : s.toLowerCase(Locale.ROOT);
}
private static String safe(String s) {
return s == null ? "" : s;
}
}

View File

@ -0,0 +1,57 @@
package com.zioinfo.kintex.publicsite.assistant.dto;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.PublicCalendarEventDto;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.TransportInfoDto;
import com.zioinfo.kintex.publicsite.dto.PublicGuideDtos.VisitorGuideDto;
import java.util.List;
/**
* 공개 AI 관람 도우미 요청·응답 DTO (비인증). 응답은 답변 + 근거 카드 + 인용 출처 + degraded 플래그를
* 정직하게 담는다(환각 방지·투명성). 답변은 <b>조회된 DB 레코드만</b> 근거로 생성한다.
*/
public final class VisitorAssistantDtos {
private VisitorAssistantDtos() {
}
/** 관람 도우미 질문 요청. */
public record AssistantRequest(String question) {
}
/**
* 인용 출처(근거 레코드) 프론트가 "출처 보기" 링크로 노출.
*
* @param type 근거 종류 EVENT | TRANSPORT | GUIDE
* @param id 참조 레코드 id
* @param title 표시명(행사명 / 교통수단 / 가이드 제목)
* @param sourceUrl 공개 출처 URL(있을 때만)
*/
public record Citation(String type, String id, String title, String sourceUrl) {
}
/**
* 관람 도우미 응답.
*
* @param question 질문 에코
* @param answer 한국어 답변(근거 기반) grounded=false "찾지 못했습니다" 정직 응답
* @param events 근거 행사 카드(//상태)
* @param transport 근거 교통 카드
* @param guide 근거 방문가이드 카드
* @param citations 인용 출처(레코드 id·source_url)
* @param grounded DB 근거를 실제로 찾았는지(false 답변 불가·환각 차단)
* @param provider 응답 출처 claude | ollama | search(AI 미가용 검색요약) | none(근거 없음)
* @param degraded AI 미가용으로 검색 요약/폴백으로 강등되었는지
*/
public record AssistantResult(
String question,
String answer,
List<PublicCalendarEventDto> events,
List<TransportInfoDto> transport,
List<VisitorGuideDto> guide,
List<Citation> citations,
boolean grounded,
String provider,
boolean degraded) {
}
}

View File

@ -0,0 +1,60 @@
package com.zioinfo.kintex.publicsite.dto;
/**
* 공개 관람객 안내 DTO 모음 (V43 근거 visitor_guide · transport_info · v_event_calendar · v_event_monthly_summary).
* <p> 항목 비인증 공개 조회용. 내부 식별자·PII·원가는 담지 않는다. 근거 출처(source_url) 투명성 위해 노출한다.
*/
public final class PublicGuideDtos {
private PublicGuideDtos() {
}
/**
* / 캘린더 행사 카드 (v_event_calendar 근거).
*
* @param lifecycle 생명주기 UPCOMING(예정) | ONGOING(진행) | ENDED(종료) | UNKNOWN. 날짜로 뷰가 산출.
*/
public record PublicCalendarEventDto(
String id,
String name,
String category,
String hallLabel,
String posterUrl,
String startDate,
String endDate,
Integer eventYear,
Integer eventMonth,
String lifecycle) {
}
/** 월별 전시 건수 요약 (v_event_monthly_summary 근거) — 연간 기획 요약. */
public record MonthlySummaryDto(
Integer eventYear,
Integer eventMonth,
Integer eventCount) {
}
/** 관람객 방문가이드 항목 (visitor_guide 근거) — 카테고리형 안내. */
public record VisitorGuideDto(
String id,
String category,
String title,
String body,
String summary,
Integer displayOrder,
String sourceUrl) {
}
/** 교통수단 구조화 항목 (transport_info 근거). */
public record TransportInfoDto(
String id,
String mode,
String lineName,
String route,
String fromPoint,
String travelTime,
String note,
Integer displayOrder,
String sourceUrl) {
}
}

View File

@ -0,0 +1,15 @@
# 킨텍스 백엔드 — 운영(prod) 프로파일 오버레이.
# 활성화: SPRING_PROFILES_ACTIVE=prod. 기본(dev) application.yml 위에 얹힌다.
#
# 이 프로파일에서는 SecretStartupValidator 가 JWT_SECRET·KINTEX_OTP_ENC_KEY 미주입/개발폴백을
# 기동 실패(fail-fast)로 처리한다(B12). 시크릿 값은 env 주입만 — 파일/로그/코드에 기록 금지.
kintex:
security:
# SecretStartupValidator 운영 강제(활성 프로파일에 prod 없이도 명시 강제 가능).
enforce-secrets: true
logging:
level:
root: INFO
# 운영은 DEBUG → INFO 하향(민감 파라미터 verbose 노출 위험 완화, 추가 관찰 항목).
com.zioinfo.kintex: ${LOG_LEVEL_KINTEX:INFO}

View File

@ -92,8 +92,12 @@ kintex:
# 버전 관리되는 룰셋 데이터(규정·요율) — 코드가 아닌 데이터로 유지(킨텍스 규정 개정 대응)
compliance-ruleset: classpath:rulesets/compliance-v1.json
rate-ruleset: classpath:rulesets/rates-v1.json
# B11 CORS — 허용 오리진 화이트리스트(콤마구분, env 주입). 와일드카드('*') 금지(코드에서 필터링).
# 운영은 CORS_ALLOWED_ORIGINS 로 운영 도메인만 명시 주입한다. 기본값은 로컬 개발 오리진.
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000}
upload:
# 관리자 업로드 파일 저장 루트(로그인 슬라이드 이미지 등) — /uploads/** 로 정적 서빙
# 관리자 업로드 파일 저장 루트(로그인 슬라이드 이미지·프로필 아바타 등) — 아바타는 인증 서빙(정적 아님)
dir: ${UPLOAD_DIR:./data/uploads}
render:
# RenderJob Redis 큐 이름(나노바나나 워커가 소비) — visualizer와 합의한 계약

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,185 @@
-- V43 관람객 방문가이드 + 연간/월간 전시 캘린더 (소유자 지시 2026-07-12)
-- 목적: AI 관람 도우미가 실시간 외부호출 없이 DB 근거로 답하도록 관람객 관심정보를 멱등 적재.
-- (1) visitor_guide — 교통·주차·입장·편의시설·접근성 안내(카테고리형, 공개 조회용)
-- (2) transport_info — 교통수단 구조화(지하철·GTX·버스·자가용·공항·KTX)
-- (3) 연/월 그룹 조회 지원 — event 파생컬럼(event_year·event_month) + 인덱스 + 캘린더 뷰
-- 원천: kintex.com 공개 페이지 크롤 (_workspace/crawl_visitor_guide_raw.md). 자격증명/PII 없음.
-- 불변: V1~V42 파일 불변. 순증(additive)·멱등만 수행(단일 트랜잭션 · Flyway 기본).
-- 테넌트 표준(V31): 신규 테넌트 소유 테이블은 tenant_id DEFAULT 'KINTEX' + PRIMARY KEY (tenant_id, id) 선두.
-- ============================================================================
-- 1) 관람객 방문가이드 — 카테고리형 안내 (공개 조회 · 민감정보 없음)
-- category: TRANSPORT | PARKING | ADMISSION | FACILITY | ACCESS | OVERVIEW
-- ============================================================================
CREATE TABLE IF NOT EXISTS visitor_guide (
tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
id varchar(60) NOT NULL,
category varchar(20) NOT NULL, -- TRANSPORT|PARKING|ADMISSION|FACILITY|ACCESS|OVERVIEW
title varchar(200) NOT NULL,
body text NOT NULL, -- 안내 본문(마크다운/평문)
summary varchar(400), -- AI 요약 답변용 한 줄 요약
display_order integer NOT NULL DEFAULT 0,
source_url varchar(300), -- 출처(공개 페이지)
use_yn char(1) NOT NULL DEFAULT 'Y',
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id)
);
CREATE INDEX IF NOT EXISTS idx_visitor_guide_cat
ON visitor_guide (tenant_id, category, display_order);
COMMENT ON TABLE visitor_guide IS
'관람객 방문가이드(교통/주차/입장/편의/접근성) — AI 관람도우미 근거 데이터. 공개 조회용(PII 없음).';
-- ── 시드(멱등: ON CONFLICT DO UPDATE 로 최신 원천 리프레시) ──────────────────
INSERT INTO visitor_guide (id, category, title, body, summary, display_order, source_url) VALUES
-- 교통 -----------------------------------------------------------------------
('vg-transport-subway', 'TRANSPORT', '지하철 · GTX',
'수도권 전철 3호선 대화역에서 도보/셔틀 연계로 접근합니다. GTX-A 킨텍스역(2024 개통)이 전시장과 직접 연결되어 서울역·수서 방면에서 빠르게 도착할 수 있습니다.',
'3호선 대화역 또는 GTX-A 킨텍스역 이용', 10,
'https://www.kintex.com/web/ko/html/service/parking_transportation.do'),
('vg-transport-bus', 'TRANSPORT', '버스',
'시내버스 33-1, 55, 83, 89, 97, 98, 062(마을), N007(마을). 직행·광역 1100, 6701, M7646, M7731. 공항버스 5600(김포공항~인천공항 T2 연계). 예) 83번 평일 배차 9~10분.',
'시내·광역·공항버스 다수 정차(83·1100·M7646·5600 등)', 20,
'https://www.kintex.com/web/ko/html/service/parking_transportation.do'),
('vg-transport-car', 'TRANSPORT', '자가용 · 주소',
'경기도 고양시 일산서구 킨텍스로 217-60 (우편번호 10390). 자유로·수도권제1순환고속도로 이용. 내비게이션 "킨텍스" 검색. 대표전화 031-995-8114.',
'주소 고양시 일산서구 킨텍스로 217-60, 자유로 이용', 30,
'https://www.kintex.com/web/ko/html/service/parking_transportation.do'),
('vg-transport-ktx-air', 'TRANSPORT', 'KTX · 공항',
'KTX는 서울역·행신역(경부/전라/중앙선) 이용 후 지하철/버스 환승. 항공은 김포공항에서 5600 공항버스, 인천공항에서 리무진/철도 연계.',
'KTX 서울·행신역, 김포/인천공항 버스 연계', 40,
'https://www.kintex.com/web/ko/html/service/parking_transportation.do'),
-- 주차 -----------------------------------------------------------------------
('vg-parking-fee', 'PARKING', '주차 요금',
'전용주차(2.5t 미만 승용/소형): 기본 20분 1,240원, 추가 10분당 620원, 일 최대 19,000원. 대형(2.5t 이상)/버스: 기본 20분 2,480원, 추가 10분당 1,240원, 일 최대 38,000원. 임시주차장: 10분당 620원(최대 6,500원).',
'승용 기본 20분 1,240원·일 최대 19,000원', 10,
'https://www.kintex.com/web/ko/service/parking_user.do'),
('vg-parking-lot', 'PARKING', '주차장 위치 · 운영',
'행사 시 임시주차장 T2(대화동 2600-7)·T3(2707-1)·T4(2720) 운영(08:00~18:30). 운영 규모는 행사 규모에 따라 변동됩니다. 대규모 행사 시 대중교통 이용을 권장합니다.',
'임시주차장 T2/T3/T4 08:00~18:30(행사별 변동)', 20,
'https://www.kintex.com/web/ko/service/parking_user.do'),
('vg-parking-discount', 'PARKING', '주차 할인',
'국가유공자·장애인·친환경(전기/수소)차량 50% 감면, 다일권 할인 적용. 감면은 현장 증빙 확인 후 적용됩니다.',
'유공/장애인/친환경차 50% 감면', 30,
'https://www.kintex.com/web/ko/service/parking_user.do'),
-- 입장 안내 -------------------------------------------------------------------
('vg-admission-prereg', 'ADMISSION', '사전등록 · 입장권',
'대부분의 전시는 온라인 무료 사전등록 후 QR/배지를 발급받아 현장에서 빠르게 입장합니다. 일부 전문 전시는 유료 또는 바이어 인증이 필요합니다. 정확한 입장 조건은 각 행사 공지를 확인하세요.',
'온라인 무료 사전등록→QR/배지 발급→현장 입장', 10,
'https://www.kintex.com'),
('vg-admission-checkin', 'ADMISSION', '현장 체크인 · 운영시간',
'현장 안내데스크 또는 무인 키오스크에서 사전등록 QR을 스캔해 배지를 수령합니다. 전시장 일반 운영시간은 09:00~18:00이며 관람 시간은 통상 10:00~17:00(행사별 공지)입니다.',
'현장 QR 스캔 체크인·관람 10:00~17:00(행사별)', 20,
'https://www.kintex.com'),
-- 편의시설 -------------------------------------------------------------------
('vg-facility-food', 'FACILITY', '식음시설 · 편의점',
'전문식당: (1전시장) 소담·본도시락·본우리반상·교동DF푸드코트·일산열무 / (2전시장) 명동칼국수·가쯔레쯔·예당·나주곰탕·IL MAZZIO·파나시아 푸드몰. 카페: 공차·뚜레쥬르·디저트39·망고식스 등. 편의점: 세븐일레븐(24시간), CU 1~5호점(08:00~18:00).',
'전시장별 전문식당·카페·편의점(세븐일레븐 24시간)', 10,
'https://www.kintex.com/web/ko/service/facilities.do?gubun=A05001'),
('vg-facility-support', 'FACILITY', '지원시설',
'ATM(현금인출기), 정보데스크(각 홀 부근), 의무실, 동전 보관함, 치안센터를 운영합니다. 분실물·응급 상황은 인근 정보데스크 또는 치안센터를 이용하세요.',
'ATM·정보데스크·의무실·보관함·치안센터', 20,
'https://www.kintex.com/web/ko/service/facilities.do?gubun=A05001'),
-- 접근성 ---------------------------------------------------------------------
('vg-access-facility', 'ACCESS', '접근성 · 유아 · 장애인',
'다층 이동 엘리베이터, 휠체어 접근 동선(식음시설·복도), 장애인 전용 주차구역, 수유실(2전시장 2층)을 제공합니다. 휠체어 대여 및 상세 접근 안내는 정보데스크에 문의하세요.',
'엘리베이터·휠체어 동선·장애인 주차·수유실(2전시장 2층)', 10,
'https://www.kintex.com/web/ko/service/facilities.do?gubun=A05001'),
-- 전시장 개요 -----------------------------------------------------------------
('vg-overview-venue', 'OVERVIEW', '전시장 개요',
'제1전시장 5개 홀(1~5, 실내 53,541㎡+옥외), 제2전시장 5개 홀(6~10, 54,470㎡). 총 약 108,011㎡로 국내 최대 규모입니다. 6홀은 이벤트홀, 그 외 홀은 이동식 칸막이로 분할/통합됩니다.',
'국내 최대 약 108,011㎡, 제1(5홀)·제2(5홀)전시장', 10,
'https://www.kintex.com/web/ko/html/facility/exhibition_overview.do')
ON CONFLICT (tenant_id, id) DO UPDATE
SET category = EXCLUDED.category, title = EXCLUDED.title, body = EXCLUDED.body,
summary = EXCLUDED.summary, display_order = EXCLUDED.display_order,
source_url = EXCLUDED.source_url, updated_at = now();
-- ============================================================================
-- 2) 교통수단 구조화 — 지도/경로 UI 및 AI 상세 답변용
-- mode: SUBWAY | GTX | BUS | CAR | AIRPORT | KTX
-- ============================================================================
CREATE TABLE IF NOT EXISTS transport_info (
tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
id varchar(60) NOT NULL,
mode varchar(20) NOT NULL, -- SUBWAY|GTX|BUS|CAR|AIRPORT|KTX
line_name varchar(120), -- 3호선 대화역 / GTX-A / 버스 83 등
route varchar(400), -- 경로/정차/노선 설명
from_point varchar(120), -- 출발 기준점(서울역, 김포공항 등)
travel_time varchar(60), -- 소요/배차 시간
note varchar(300),
display_order integer NOT NULL DEFAULT 0,
source_url varchar(300),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id)
);
CREATE INDEX IF NOT EXISTS idx_transport_info_mode
ON transport_info (tenant_id, mode, display_order);
INSERT INTO transport_info (id, mode, line_name, route, from_point, travel_time, note, display_order, source_url) VALUES
('ti-subway-3', 'SUBWAY', '수도권 전철 3호선 대화역', '대화역 하차 후 도보/셔틀 연계', '서울(고속터미널/종로3가 환승)', '역~전시장 셔틀/도보', '3호선 서쪽 종착부', 10,
'https://www.kintex.com/web/ko/html/service/parking_transportation.do'),
('ti-gtx-a', 'GTX', 'GTX-A 킨텍스역', '킨텍스역 하차, 전시장 직접 연결', '서울역·수서', '수서~킨텍스 약 20분대', '2024 개통, 최속 접근', 20,
'https://www.kintex.com/web/ko/html/service/parking_transportation.do'),
('ti-bus-city', 'BUS', '시내버스', '33-1, 55, 83, 89, 97, 98, 062마을, N007마을', '고양/일산 일대', '83번 평일 배차 9~10분', '킨텍스 정류장 정차', 30,
'https://www.kintex.com/web/ko/html/service/parking_transportation.do'),
('ti-bus-express', 'BUS', '직행·광역버스', '1100(직행), 6701(직행), M7646(광역), M7731(광역)', '서울 도심/강남권', '노선별 상이', '광역 급행 연계', 40,
'https://www.kintex.com/web/ko/html/service/parking_transportation.do'),
('ti-airport', 'AIRPORT', '공항버스 5600', '김포공항~인천공항 T2 연계', '김포/인천공항', '노선 시간표 참조', '공항 이용객 연계', 50,
'https://www.kintex.com/web/ko/html/service/parking_transportation.do'),
('ti-ktx', 'KTX', 'KTX 서울역·행신역', '서울역·행신역 하차 후 지하철/버스 환승', '전국(경부/전라/중앙선)', '환승 필요', '고속철 연계', 60,
'https://www.kintex.com/web/ko/html/service/parking_transportation.do'),
('ti-car', 'CAR', '자가용', '자유로·수도권제1순환고속도로 이용, 내비 "킨텍스" 검색', '수도권', '-', '킨텍스로 217-60(우 10390)', 70,
'https://www.kintex.com/web/ko/html/service/parking_transportation.do')
ON CONFLICT (tenant_id, id) DO UPDATE
SET mode = EXCLUDED.mode, line_name = EXCLUDED.line_name, route = EXCLUDED.route,
from_point = EXCLUDED.from_point, travel_time = EXCLUDED.travel_time,
note = EXCLUDED.note, display_order = EXCLUDED.display_order,
source_url = EXCLUDED.source_url, updated_at = now();
-- ============================================================================
-- 3) 연간/월간 전시 캘린더 지원 — event 파생컬럼 + 인덱스 + 뷰
-- 이벤트 데이터는 V10(10년 시드)·V32(2026 하반기 크롤)·V42(대량 데모)로 이미 적재됨.
-- 본 절은 "올해 주요 전시", "이번 달/다음 달 일정"을 연/월로 그룹 조회하기 위한 파생/인덱스만 순증.
-- ============================================================================
-- 3-1) 파생 컬럼(STORED 생성열) — start_date 로부터 자동 산출·동기 유지(향후 시드 자동 반영).
ALTER TABLE event ADD COLUMN IF NOT EXISTS event_year integer
GENERATED ALWAYS AS (EXTRACT(YEAR FROM start_date)::integer) STORED;
ALTER TABLE event ADD COLUMN IF NOT EXISTS event_month integer
GENERATED ALWAYS AS (EXTRACT(MONTH FROM start_date)::integer) STORED;
COMMENT ON COLUMN event.event_year IS '개최연(start_date 파생·STORED) — 연간 그룹 조회 키.';
COMMENT ON COLUMN event.event_month IS '개최월(start_date 파생·STORED) — 월간 그룹 조회 키.';
-- 3-2) 연/월 그룹 조회 가속 인덱스(tenant_id 선두 복합).
CREATE INDEX IF NOT EXISTS idx_event_calendar
ON event (tenant_id, event_year, event_month, start_date);
-- 3-3) 캘린더 뷰 — 생명주기 상태(예정/진행/종료)를 날짜로 산출(별도 컬럼 백필 불필요).
CREATE OR REPLACE VIEW v_event_calendar AS
SELECT e.tenant_id,
e.id,
e.name,
e.category,
e.hall_label,
e.poster_url,
e.start_date,
e.end_date,
e.event_year,
e.event_month,
CASE
WHEN e.start_date IS NULL THEN 'UNKNOWN'
WHEN CURRENT_DATE < e.start_date THEN 'UPCOMING' -- 예정
WHEN CURRENT_DATE > COALESCE(e.end_date, e.start_date) THEN 'ENDED' -- 종료
ELSE 'ONGOING' -- 진행중
END AS lifecycle
FROM event e
WHERE e.status <> 'cancelled';
COMMENT ON VIEW v_event_calendar IS
'연/월 캘린더 조회 뷰 — lifecycle(UPCOMING/ONGOING/ENDED)을 날짜로 산출. AI 연간/월간 일정 답변 근거.';
-- 3-4) 월별 집계 뷰 — "그 달에 몇 개 전시" 요약.
CREATE OR REPLACE VIEW v_event_monthly_summary AS
SELECT tenant_id, event_year, event_month, count(*) AS event_count
FROM event
WHERE start_date IS NOT NULL AND status <> 'cancelled'
GROUP BY tenant_id, event_year, event_month;
COMMENT ON VIEW v_event_monthly_summary IS '월별 전시 건수 집계 — 연간 기획 요약(월별 개최 수) 근거.';

View File

@ -0,0 +1,76 @@
-- V44: 소프트 참조·핫 조회 경로 성능 인덱스 (멱등 · 순수 인덱스 · 스키마 불변)
-- 배경: 데이터 표준이 물리 FK 최소화 → PostgreSQL 은 FK/소프트참조 컬럼에 인덱스를 자동 생성하지 않는다.
-- 조인/필터/정렬에 실제로 쓰이는 소프트 참조(*_id)·상태(공통코드값)·기간 컬럼 중, 기존 인덱스·
-- PK((tenant_id,id))·UNIQUE 제약 자동 인덱스로 커버되지 않는 누락분만 실사용 쿼리 근거로 추가한다.
-- 원칙: 전부 CREATE INDEX IF NOT EXISTS(멱등) · 단일 트랜잭션 · 순증 · 중복/PK커버 조합 제외.
-- 소유: 성능 인덱스 마이그레이션의 단일 소유 파일(DA 감사 트랙은 별도 인덱스 파일을 만들지 않음).
-- 신규 테이블/컬럼 없음 — V43 다음 순번.
-- ─────────────────────────────────────────────────────────────────────────────
-- 1) hall_assignment(event_id, is_primary) — 최고 빈도 핫패스
-- 근거: 이벤트 목록의 "대표 홀" LATERAL 서브쿼리 —
-- DashboardMapper.listEvents / EventCatalogMapper.list / HomeMapper /
-- MarketingMapper / OpsMapper.myEvents 의
-- "SELECT hall_id FROM hall_assignment ha WHERE ha.event_id = e.id ORDER BY is_primary DESC LIMIT 1",
-- PublicSiteMapper "LEFT JOIN hall_assignment ha ON ha.event_id = e.id AND ha.is_primary = true",
-- HallAssignMapper "WHERE ha.event_id = #{eventId} ORDER BY ha.is_primary DESC".
-- 기존 idx_hall_assignment_hall_period 는 (hall_id, occupy_start, occupy_end) 로 event_id 미커버.
CREATE INDEX IF NOT EXISTS idx_hall_assignment_event
ON hall_assignment (event_id, is_primary DESC);
-- ─────────────────────────────────────────────────────────────────────────────
-- 2) event_member(company_id, event_id) — 업체 스코프 조인
-- 근거: AuctionMapper 초대 입찰자 조회
-- "JOIN event_member em ON em.company_id = ai.company_id AND em.event_id = (SELECT event_id FROM auction ...)",
-- OpsMapper.summary awardedBooths "EXISTS(SELECT 1 FROM event_member m WHERE m.company_id = b.company_id ...)".
-- 기존 idx_event_member_user(user_id) · UNIQUE(event_id,user_id,role_code) 는 company_id 선두 미커버.
CREATE INDEX IF NOT EXISTS idx_event_member_company
ON event_member (company_id, event_id);
-- ─────────────────────────────────────────────────────────────────────────────
-- 3) bid(company_id) — 계약사(입찰업체) 내 입찰 조회
-- 근거: AuctionMapper.contractorMyBids "FROM bid b JOIN auction a ON a.id = b.auction_id WHERE b.company_id = #{companyId} ORDER BY a.deadline".
-- 기존 idx_bid_auction(auction_id) · UNIQUE(auction_id,company_id) 는 company_id 선두 미커버.
CREATE INDEX IF NOT EXISTS idx_bid_company
ON bid (company_id);
-- ─────────────────────────────────────────────────────────────────────────────
-- 4) tax_invoice(invoice_id) — 정산 세금계산서 역참조
-- 근거: SettlementMapper.getTaxInvoiceByInvoice "FROM tax_invoice WHERE invoice_id = #{invoiceId}".
-- 기존 인덱스는 (event_id) 만 존재 → invoice_id(소프트 FK) 미커버.
CREATE INDEX IF NOT EXISTS idx_tax_invoice_invoice
ON tax_invoice (invoice_id);
-- ─────────────────────────────────────────────────────────────────────────────
-- 5) message(sender_id, created_at) — 쪽지 보낸함
-- 근거: MessageMapper.listSent "FROM message m WHERE m.sender_id = #{userId} ORDER BY m.created_at DESC LIMIT/OFFSET".
-- message 테이블엔 인덱스 없음(수신함은 message_recipient(recipient_id,read_at) 로 커버).
CREATE INDEX IF NOT EXISTS idx_message_sender
ON message (sender_id, created_at DESC);
-- ─────────────────────────────────────────────────────────────────────────────
-- 6) cms_content 공개 게시 목록 — 상태(공통코드값) 필터 + 타입 + 게시일 정렬
-- 근거: CmsMapper.listPublished(공개 홍보사이트)
-- "WHERE status = 'published' AND content_type = #{type} ORDER BY published_at DESC NULLS LAST".
-- 기존 idx_cms_content_status(status) 는 content_type/published_at 정렬 미커버. 게시분만 부분 인덱스로 경량화.
CREATE INDEX IF NOT EXISTS idx_cms_content_published
ON cms_content (content_type, published_at DESC)
WHERE status = 'published';
-- ─────────────────────────────────────────────────────────────────────────────
-- 7) visitor_registration(event_id, checkin_state) — 체크인 상태(공통코드값) 필터
-- 근거: VisitorMapper.checkinHourly
-- "WHERE event_id = #{eventId} AND checkin_state = 'done' AND checkin_at IS NOT NULL ORDER BY to_char(checkin_at,'HH24')".
-- 대용량(관람객 등록) 테이블에서 (event_id) 만으로는 상태 서브셋 필터 비용 큼. 기존 (event_id,registered_at) 와 별개.
CREATE INDEX IF NOT EXISTS idx_visitor_reg_checkin_state
ON visitor_registration (event_id, checkin_state);
-- ─────────────────────────────────────────────────────────────────────────────
-- 8) lead 팔로업 스윕 — hot 리드(score>=80) 전역 조회(이벤트 무관 배치)
-- 근거: FollowupMapper.findUnprocessedHotLeads
-- "FROM lead l LEFT JOIN edm_followup_log f ... WHERE l.score >= 80 AND f.id IS NULL ORDER BY l.score DESC, l.id".
-- 기존 idx_lead_score 는 (event_id, score DESC) — event_id 선두라 전역 score 범위스캔에 부적합 → 부분 인덱스.
-- (조인측 edm_followup_log 는 UNIQUE(event_id,lead_id) 자동 인덱스로 이미 커버.)
CREATE INDEX IF NOT EXISTS idx_lead_hot_followup
ON lead (score DESC)
WHERE score >= 80;

View File

@ -0,0 +1,12 @@
-- 킨텍스 — 사용자 프로필 사진 + 연락처 컬럼(순증, 멱등). 모바일 내정보 아바타 계약 충족.
-- V1~V44 불변. 본 마이그레이션은 additive만 수행한다(파괴적 변경 없음).
-- 보안 불변(계약 §0-3): photo_path 는 서버 생성 UUID 파일명만 저장(원본 파일명 미신뢰).
-- password_hash·otp_secret 등 민감 컬럼과 무관 — 프로필 사진 메타데이터만 추가한다.
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS photo_path varchar(200); -- {uploadDir}/avatars/ 하위 UUID 파일명(경로 아님)
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS photo_content_type varchar(40); -- image/png|jpeg|webp (서빙 Content-Type)
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS photo_updated_at timestamptz; -- 마지막 업로드 시각(캐시 무효화 참조)
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS phone varchar(40); -- 내정보 표시용 연락처(선택, /me 순증)
COMMENT ON COLUMN app_user.photo_path IS '프로필 사진 저장 파일명(서버 UUID) — 원본 파일명 미저장, 경로 순회 불가';
COMMENT ON COLUMN app_user.photo_content_type IS '프로필 사진 MIME(화이트리스트: image/png|image/jpeg|image/webp)';

View File

@ -43,4 +43,35 @@
WHERE registration_no = #{registrationNo}
</select>
<!-- 내정보 표시용 프로필(민감 컬럼 제외). photo_path 존재 여부만 boolean 으로 노출. -->
<select id="findProfile" resultType="map">
SELECT id AS "userId",
email AS "email",
display_name AS "displayName",
phone AS "phone",
dept_id AS "deptId",
(photo_path IS NOT NULL) AS "hasPhoto"
FROM app_user
WHERE id = #{userId}
</select>
<!-- 프로필 사진 메타(파일명·MIME) — 서빙 시 파일 로드용. 파일명은 서버 UUID(경로 순회 불가). -->
<select id="findPhotoMeta" resultType="map">
SELECT photo_path AS "photoPath",
photo_content_type AS "photoContentType"
FROM app_user
WHERE id = #{userId}
AND photo_path IS NOT NULL
</select>
<!-- 프로필 사진 메타 갱신(본인만) — updated_at·photo_updated_at 동시 갱신. -->
<update id="updatePhoto">
UPDATE app_user
SET photo_path = #{photoPath},
photo_content_type = #{photoContentType},
photo_updated_at = now(),
updated_at = now()
WHERE id = #{userId}
</update>
</mapper>

View File

@ -0,0 +1,93 @@
package com.zioinfo.kintex.auth;
import com.zioinfo.kintex.auth.mapper.UserMapper;
import com.zioinfo.kintex.auth.profile.ProfilePhotoService;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 프로필 사진 서비스 매직바이트 판별 + base64 저장/검증 단위테스트.
* DB(UserMapper) 목으로 대체하고 파일 저장은 임시 디렉터리로 격리한다.
*/
class ProfilePhotoServiceTest {
// 매직바이트 판별(SVG/위조 헤더 차단)
@Test
void detectsPng() {
byte[] png = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0};
assertEquals("png", ProfilePhotoService.detectImageType(png));
}
@Test
void detectsJpeg() {
byte[] jpg = {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0, 0, 0};
assertEquals("jpg", ProfilePhotoService.detectImageType(jpg));
}
@Test
void detectsWebp() {
byte[] webp = {'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'};
assertEquals("webp", ProfilePhotoService.detectImageType(webp));
}
@Test
void rejectsSvgAndText() {
byte[] svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>".getBytes();
assertNull(ProfilePhotoService.detectImageType(svg), "SVG(실행형)는 이미지로 판별되면 안 됨");
byte[] script = "<script>alert(1)</script>".getBytes();
assertNull(ProfilePhotoService.detectImageType(script));
}
// base64 업로드 저장
@Test
void base64PngRoundTrip(@TempDir Path tmp) {
UserMapper mapper = mock(UserMapper.class);
when(mapper.findPhotoMeta("u1")).thenReturn(null);
ProfilePhotoService svc = new ProfilePhotoService(mapper, tmp.toString());
byte[] png = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3};
String b64 = java.util.Base64.getEncoder().encodeToString(png);
String url = svc.uploadBase64("u1", b64, "image/png");
assertEquals("/api/auth/users/u1/photo", url);
}
@Test
void base64RejectsNonImage(@TempDir Path tmp) {
UserMapper mapper = mock(UserMapper.class);
ProfilePhotoService svc = new ProfilePhotoService(mapper, tmp.toString());
String b64 = java.util.Base64.getEncoder().encodeToString("not-an-image".getBytes());
ApiException ex = assertThrows(ApiException.class, () -> svc.uploadBase64("u1", b64, "image/png"));
assertEquals(ErrorCode.VALIDATION, ex.getCode());
}
@Test
void loadReturns404WhenNoPhoto(@TempDir Path tmp) {
UserMapper mapper = mock(UserMapper.class);
when(mapper.findPhotoMeta("u1")).thenReturn(null);
ProfilePhotoService svc = new ProfilePhotoService(mapper, tmp.toString());
ApiException ex = assertThrows(ApiException.class, () -> svc.load("u1"));
assertEquals(ErrorCode.NOT_FOUND, ex.getCode());
}
@Test
void serveUrlBuildsExpectedPath() {
assertEquals("/api/auth/users/abc/photo", ProfilePhotoService.serveUrl("abc"));
assertNotNull(ProfilePhotoService.serveUrl("x"));
assertTrue(ProfilePhotoService.serveUrl("x").startsWith("/api/auth/"));
}
}

View File

@ -0,0 +1,64 @@
package com.zioinfo.kintex.config;
import com.zioinfo.kintex.auth.AuthProperties;
import com.zioinfo.kintex.security.SecurityPolicyProperties;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* B12 운영 시크릿 fail-fast 검증기 단위테스트.
* 시크릿 자체는 예외 메시지에 담기지 않음(항목명·조치만) 함께 확인한다.
*/
class SecretStartupValidatorTest {
private AuthProperties auth(String secret) {
AuthProperties p = new AuthProperties();
p.setJwtSecret(secret);
return p;
}
private SecurityPolicyProperties security(String otpKey) {
SecurityPolicyProperties p = new SecurityPolicyProperties();
p.getOtp().setEncKey(otpKey);
return p;
}
@Test
void prodFailsFastOnDevJwtFallback() {
MockEnvironment env = new MockEnvironment().withProperty("kintex.security.enforce-secrets", "true");
SecretStartupValidator v = new SecretStartupValidator(
env, auth("CHANGE_ME_DEV_ONLY_kintex_local_secret_key_min_32_bytes"),
security("base64key32byteslongxxxxxxxxxxxxx"));
IllegalStateException ex = assertThrows(IllegalStateException.class, v::afterPropertiesSet);
// 시크릿 값이 메시지에 노출되지 않아야 (항목명만).
org.junit.jupiter.api.Assertions.assertFalse(ex.getMessage().contains("CHANGE_ME_DEV_ONLY_kintex"));
}
@Test
void prodFailsFastOnMissingOtpKey() {
MockEnvironment env = new MockEnvironment().withProperty("kintex.security.enforce-secrets", "true");
SecretStartupValidator v = new SecretStartupValidator(
env, auth("a-strong-production-secret-of-at-least-32-bytes!!"), security(""));
assertThrows(IllegalStateException.class, v::afterPropertiesSet);
}
@Test
void prodPassesWithStrongSecrets() {
MockEnvironment env = new MockEnvironment().withProperty("kintex.security.enforce-secrets", "true");
SecretStartupValidator v = new SecretStartupValidator(
env, auth("a-strong-production-secret-of-at-least-32-bytes!!"),
security("base64key32byteslongxxxxxxxxxxxxx"));
assertDoesNotThrow(v::afterPropertiesSet);
}
@Test
void devOnlyWarnsNeverThrows() {
MockEnvironment env = new MockEnvironment(); // no prod profile, no enforce
SecretStartupValidator v = new SecretStartupValidator(
env, auth("CHANGE_ME_DEV_ONLY_kintex_local_secret_key_min_32_bytes"), security(""));
assertDoesNotThrow(v::afterPropertiesSet);
}
}

View File

@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KINTEX AI 전시·행사시스템</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/favicon.png" />
<meta name="theme-color" content="#0066b3" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="stylesheet"

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -71,6 +71,10 @@ import { RoleAdminPage } from './screens/admin/RoleAdminPage';
import { CommonCodeAdminPage } from './screens/admin/CommonCodeAdminPage';
import { MenuAdminPage } from './screens/admin/MenuAdminPage';
import {
PublicGatewayPage,
VisitorTrackPage,
BusinessTrackPage,
AgencyTrackPage,
PublicHomePage,
PublicEventDetailPage,
PublicFloorplanPage,
@ -94,10 +98,14 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
/** 루트/미매칭 분기 — 인증=홈, 미인증=공개 소개 랜딩(제품 아이덴티티, 2026-07-12 소유자 지적). */
/**
* / = , =SCR-T0 3-
* (design.md §3C-0: 미인증 `/` . 2026-07-12 ).
*/
function RootGate() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return <Navigate to={isAuthenticated ? '/home' : '/public'} replace />;
if (isAuthenticated) return <Navigate to="/home" replace />;
return <PublicGatewayPage />;
}
export function App() {
@ -246,6 +254,11 @@ export function App() {
<Route path="/admin/holidays" element={<AdminGuard><HolidayAdminPage /></AdminGuard>} />
</Route>
{/* SCR-T1~T3 트랙 서브홈 — 비로그인 공개, PublicShell+TrackSwitcher */}
<Route path="/visitor" element={<VisitorTrackPage />} />
<Route path="/business" element={<BusinessTrackPage />} />
<Route path="/agency" element={<AgencyTrackPage />} />
{/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */}
<Route path="/public" element={<PublicHomePage />} />
<Route path="/public/events/:eventId" element={<PublicEventDetailPage />} />

View File

@ -0,0 +1,61 @@
/*
* M15 / API .
* api/endpoints.ts .
* client.ts api(get/post) JWT· .
* 순위: 현재 WebSocket (refetchInterval).
* TODO(M5 WS ): STOMP /topic/auction/{id} .
*/
import { api } from './client';
import type {
AuctionDetail,
AuctionSummary,
AwardRequest,
AwardResult,
AwardView,
BidRequest,
BidResult,
CreateAuctionRequest,
} from './auctionTypes';
/** 실시간 순위 폴링 주기(ms). WebSocket 도입 전 계약. */
export const AUCTION_POLL_MS = 15_000;
export const auctionApi = {
/** GET /api/auctions — 옥션 목록(카드). */
list: (params?: { eventId?: string; page?: number; size?: number }) => {
const q = new URLSearchParams();
if (params?.eventId) q.set('eventId', params.eventId);
if (params?.page != null) q.set('page', String(params.page));
if (params?.size != null) q.set('size', String(params.size));
const qs = q.toString();
return api.get<AuctionSummary[]>(`/api/auctions${qs ? `?${qs}` : ''}`);
},
/** POST /api/auctions — 옥션 개설(발주자). */
create: (body: CreateAuctionRequest) => api.post<AuctionSummary>('/api/auctions', body),
/** GET /api/auctions/{id} — 상세·봉인 순위. */
detail: (id: string) => api.get<AuctionDetail>(`/api/auctions/${encodeURIComponent(id)}`),
/** POST /api/auctions/{id}/bids — 응찰(견적서 제출). 서버가 소계/부가세/총액 재계산. */
bid: (id: string, body: BidRequest) =>
api.post<BidResult>(`/api/auctions/${encodeURIComponent(id)}/bids`, body),
/** POST /api/auctions/{id}/close — 라운드 마감 처리(발주자). */
close: (id: string) => api.post<AuctionSummary>(`/api/auctions/${encodeURIComponent(id)}/close`),
/** GET /api/auctions/{id}/award-view — 마감 후 발주자 전용 견적 비교. */
awardView: (id: string) => api.get<AwardView>(`/api/auctions/${encodeURIComponent(id)}/award-view`),
/** POST /api/auctions/{id}/award — 낙찰(발주자·사유 필수). */
award: (id: string, body: AwardRequest) =>
api.post<AwardResult>(`/api/auctions/${encodeURIComponent(id)}/award`, body),
};
/** react-query 키 팩토리 — 옥션 도메인. */
export const auctionKeys = {
all: ['auction'] as const,
list: (eventId?: string) => ['auction', 'list', eventId ?? 'all'] as const,
detail: (id: string) => ['auction', 'detail', id] as const,
awardView: (id: string) => ['auction', 'awardView', id] as const,
};

View File

@ -0,0 +1,161 @@
/*
* M15 / () API com.zioinfo.kintex.auction.dto.AuctionDtos .
* 모듈: 공유 api/types.ts .
* 입찰: 응찰자 (RankRow) price=null·priceMasked=true .
*/
export type AuctionTypeLabel = '역경매' | 'RFQ';
export type AuctionStatusLabel = '진행중' | '마감' | '정산중';
export type AwardCriteria = 'lowest' | 'comprehensive';
export type MaterialKind = 'layout' | 'design' | 'boq' | 'aiimage';
export interface Weights {
price: number;
reputation: number;
delivery: number;
}
export interface AuctionSummary {
id: string;
eventId: string;
title: string;
category: string;
type: AuctionTypeLabel;
status: AuctionStatusLabel;
round: number;
dday: number;
bidderCount: number;
lowestPrice: number | null;
awardedCompany: string | null;
finalPrice: number | null;
materials: string[];
accent: string;
}
export interface MaterialPackage {
layoutUrl: string | null;
designUrl: string | null;
boqUrl: string | null;
aiImageUrl: string | null;
}
export interface RankRow {
rank: number;
alias: string;
isMe: boolean;
isLowest: boolean;
priceMasked: boolean;
price: number | null;
}
export interface AuctionDetail {
id: string;
eventId: string;
title: string;
category: string;
type: AuctionTypeLabel;
status: AuctionStatusLabel;
round: number;
dday: number;
deadline: string;
bidderCount: number;
lowestPrice: number | null;
awardCriteria: AwardCriteria;
weights: Weights;
materials: string[];
materialPackage: MaterialPackage;
sealed: boolean;
myRank: number | null;
myPrice: number | null;
ranking: RankRow[];
canBid: boolean;
canViewAward: boolean;
awardedCompany: string | null;
finalPrice: number | null;
}
/** 견적서 라인아이템 — 금액=수량×단가는 서버가 재계산(클라 값 불신). */
export interface QuoteLine {
trade: string;
material: string;
qty: number;
unitPrice: number;
}
export interface BidRequest {
total?: number;
leadDays?: number;
validUntil?: string;
terms?: string;
lines?: QuoteLine[];
}
export interface BidResult {
id: string;
round: number;
subtotal: number;
vat: number;
total: number;
grandTotal: number;
version: number;
myRank: number | null;
lowestPrice: number | null;
}
export interface CreateAuctionRequest {
eventId: string;
title: string;
category?: string;
type?: AuctionTypeLabel | 'reverse' | 'rfq';
awardCriteria?: AwardCriteria;
weights?: Weights;
round?: number;
deadline: string;
materialPackageId?: string;
materials?: string[];
invitedCompanyIds?: string[];
}
export interface Quote {
bidId: string;
companyId: string;
alias: string;
subtitle: string;
recommended: boolean;
totalPrice: number;
priceDeltaPct: number;
isLowestPrice: boolean;
leadDays: number;
isShortestLead: boolean;
reputation: number;
score: number;
}
export interface RiskBar {
label: string;
pct: number;
tone: string;
}
export interface AwardResult {
id: string;
bidId: string;
companyName: string;
awardedAt: string;
}
export interface AwardView {
auctionId: string;
auctionTitle: string;
bidderCount: number;
awardCriteria: AwardCriteria;
weights: Weights;
quotes: Quote[];
riskBars: RiskBar[];
awarded: AwardResult | null;
}
export interface AwardRequest {
bidId: string;
reason: string;
}

View File

@ -1135,6 +1135,33 @@
"citations": "Cited regulations",
"notFound": "Not found in regulations",
"degraded": "Regulation summary (AI unavailable)"
},
"assistant": {
"label": "AI Guide",
"inputAria": "Ask the visit assistant",
"sendAria": "Send question",
"chipsLabel": "Frequently asked",
"chip1": "Any robotics expo this weekend?",
"chip2": "Where do I park at KINTEX?",
"chip3": "Recommend a family-friendly exhibition",
"chip4": "How do I pre-register?",
"chip5": "How long via GTX-A?",
"thinking": "Generating answer…",
"error": "Could not get an answer. Please try again.",
"retry": "Try again",
"degraded": "AI guide in maintenance — showing stored-data search results",
"providerAi": "AI answer",
"notFound": "Couldn't find that — please check the full schedule and guide.",
"cardEventsTitle": "Related exhibitions",
"cardTransportTitle": "Getting here",
"cardGuideTitle": "Visit info",
"followupLabel": "Follow-up questions",
"followup1": "Is it free?",
"followup2": "Where to park?",
"followup3": "How to pre-register",
"citations": "Sources",
"register": "Pre-register",
"disclaimer": "AI-generated answer — verify with the official notice."
}
},
"analytics": {
@ -2140,5 +2167,364 @@
"cancel": "Cancel",
"saving": "Saving…",
"save": "Save"
},
"track": {
"shell": {
"wordmark": "KINTEX AI Exhibition & Event System",
"tablistAria": "Select track",
"tab": {
"visitor": "Visitor",
"business": "Business",
"agency": "Agency"
},
"login": "Log in",
"myHome": "My Home"
},
"gateway": {
"heroAria": "Product hero",
"aiWatermark": "AI-generated preview image",
"eyebrow": "AI Exhibition & Event Platform",
"title": "KINTEX AI Exhibition & Event System",
"subtitle": "Automating the entire journey from planning to construction and visiting with AI.",
"ctaPreReg": "Visitor Pre-registration",
"ctaInquiry": "Exhibitor Inquiry",
"tracksAria": "Track selection",
"tracksTitle": "What brings you here?",
"tracksSub": "Choose the track that fits your purpose.",
"card": {
"visitor": {
"label": "Visitor",
"title": "Visit an Event",
"desc": "Schedules, pre-registration, and directions",
"cta": "Visitor Track"
},
"business": {
"label": "Organizer / Exhibitor",
"title": "Host or Exhibit",
"desc": "Rental, auto-quotes, booth applications, construction previews",
"cta": "Business Track"
},
"agency": {
"label": "Construction Partner",
"title": "Join Construction",
"desc": "Registered vendors, construction auctions, work reports",
"cta": "Agency Track"
}
},
"eventsAria": "Current and upcoming exhibitions",
"eventsTitle": "Current & Upcoming Exhibitions",
"eventsSub": "Explore ongoing and upcoming exhibitions.",
"eventsMore": "View all schedules"
},
"visitor": {
"heroAria": "Visitor hero",
"eyebrow": "Visitor Guide",
"title": "Start Your Visit Here",
"subtitle": "Pre-register for faster entry and get alerts for topics you care about.",
"ctaPreReg": "Pre-register",
"ctaTicket": "Buy Tickets",
"eventsAria": "Event schedule",
"eventsTitle": "Event Schedule",
"eventsSub": "Check current and upcoming exhibitions and pre-register.",
"guideAria": "Visit guide",
"guideTitle": "Visit Guide",
"guideSub": "Transport, parking, amenities, and accessibility in one place.",
"guide": {
"transport": {
"title": "Transport",
"desc": "3 min walk from GTX-A KINTEX Stn · intercity buses",
"link": "Directions"
},
"parking": {
"title": "Parking",
"desc": "Real-time availability, rates, and gates",
"link": "Parking info"
},
"wayfinding": {
"title": "Indoor Wayfinding",
"desc": "Indoor routes to halls and booths",
"link": "Open wayfinding"
},
"amenities": {
"title": "Amenities",
"desc": "Dining, nursing room, storage, ATM",
"link": "View amenities"
},
"accessibility": {
"title": "Accessibility",
"desc": "Wheelchair access, sign-language guidance",
"link": "Accessibility info"
},
"inquiry": {
"title": "Support",
"desc": "FAQ and contact info",
"link": "Contact us"
}
},
"noticeAria": "Notices",
"noticeTitle": "Notices",
"noticeSub": "Check the latest notices and alerts.",
"noticeEmpty": "No notices yet.",
"aiWatermark": "AI-generated preview image",
"hero": {
"title": "Ask anything — AI guides your visit",
"subtitle": "Schedules, directions, tickets and booth locations — all in one chat"
},
"aiTiles": {
"title": "How our AI helps",
"sub": "AI supports every moment of your visit.",
"soon": "Coming soon",
"assistant": {
"title": "AI Visit Assistant",
"desc": "Ask anything"
},
"recommend": {
"title": "AI Recommendations",
"desc": "Curated to your interests"
},
"wayfinding": {
"title": "AI Wayfinding",
"desc": "Best indoor route"
},
"schedule": {
"title": "AI Itinerary",
"desc": "Your own visit course"
},
"crowd": {
"title": "AI Crowd & Wait",
"desc": "Best low-crowd times"
},
"render": {
"title": "AI Booth Preview",
"desc": "Booth preview (Business track)"
}
},
"showcaseTitle": "Ongoing & upcoming",
"showcaseSub": "See what's on now and coming up.",
"recommendTitle": "Exhibitions for your interests",
"recommendSub": "Pick your interests and AI curates for you.",
"recommendTag": "AI picks",
"interests": {
"robot": "Robotics",
"smartfactory": "Smart factory",
"healthcare": "Healthcare",
"eco": "Eco & energy",
"mobility": "Mobility",
"content": "Content & culture"
},
"guideMore": "Learn more",
"journeyTitle": "How your visit works",
"journeySub": "From pre-registration to check-in in three simple steps.",
"journey": {
"register": {
"title": "Pre-register",
"desc": "Register your visit online.",
"link": "Browse exhibitions"
},
"badge": {
"title": "Badge & QR",
"desc": "Get your mobile badge and entry QR.",
"link": "Find my ticket"
},
"checkin": {
"title": "On-site check-in",
"desc": "Scan your QR at the gate and walk in.",
"link": "Sign in"
}
},
"venueTitle": "About KINTEX",
"venueSub": "Korea's largest exhibition & convention center.",
"venue": {
"halls": {
"label": "Exhibition halls",
"value": "10"
},
"area": {
"label": "Exhibition area",
"value": "108,556㎡"
},
"parking": {
"label": "Parking",
"value": "7,500"
},
"access": {
"label": "Access",
"value": "GTX-A 3-min walk"
}
},
"briefing": {
"title": "AI Exhibition Briefing",
"sub": "AI curates this year's and this month's upcoming exhibitions for you.",
"viewYear": "Yearly ({{year}})",
"viewMonth": "Monthly",
"aiSummary": "AI summary",
"loading": "Generating AI summary…",
"empty": "No exhibitions scheduled for this period.",
"yearSummary": "{{count}} exhibitions & events are planned in {{year}}, concentrated around {{peak}}.",
"monthSummary": "{{count}} exhibitions this month · weekend visits recommended.",
"monthLabel": "month {{month}}",
"eventsCount": "{{count}} events",
"prevMonth": "Previous month",
"nextMonth": "Next month"
}
},
"business": {
"heroAria": "Business hero",
"eyebrow": "For Organizers & Exhibitors",
"title": "Host or Exhibit — All Applications Online",
"subtitle": "Handle everything online — from rental to booth application and construction.",
"ctaApply": "Apply / Inquire",
"ctaLogin": "Log in",
"ctaInquiry": "Inquire",
"valueAria": "Value propositions",
"valueTitle": "Choose Your Role",
"valueSub": "Features for organizers and exhibitors.",
"value": {
"organizer": {
"title": "Organizer · Hall Rental",
"desc": "From rental to hall assignment and floor plans in one place.",
"item": {
"rental": "Rental process",
"quote": "Auto quotation",
"floorplan": "Hall assignment & floor plan",
"rate": "Rates & facility specs"
},
"cta": "Rental inquiry · Log in"
},
"exhibitor": {
"title": "Exhibitor · Booth Application",
"desc": "From booth and utility applications to AI construction previews.",
"item": {
"apply": "Booth application",
"utility": "Utility application",
"compliance": "Compliance pre-check",
"render": "AI construction preview"
},
"cta": "Apply · Log in"
}
},
"processAria": "Rental process",
"processTitle": "Rental Process",
"processSub": "From inquiry to construction and inspection at a glance.",
"process": {
"inquiry": {
"title": "Inquiry",
"desc": "Rental consultation"
},
"quote": {
"title": "Estimate",
"desc": "Automated estimate"
},
"contract": {
"title": "Contract",
"desc": "Sign the contract"
},
"assign": {
"title": "Hall Assignment",
"desc": "Hall and zone assignment"
},
"booth": {
"title": "Booth Application",
"desc": "Exhibitor booths and utilities"
},
"inspect": {
"title": "Construction & Inspection",
"desc": "On-site inspection after construction"
}
},
"rateAria": "Rates and documents",
"rateTitle": "Rates & Documents",
"rateSub": "Rates by booth type and key documents.",
"rate": {
"shell": {
"label": "Shell Scheme",
"price": "Standard",
"desc": "Basic booth with basic power included"
},
"space": {
"label": "Space Only",
"price": "Space",
"desc": "Custom build, structural calc required"
},
"premium": {
"label": "Premium",
"price": "Premium",
"desc": "Premium location with extra utilities"
}
},
"doc": {
"assign": {
"title": "Assignment Application",
"desc": "Hall assignment application form"
},
"flame": {
"title": "Flame-retardant Certificate",
"desc": "Flame-retardant treatment certificate"
},
"structure": {
"title": "Structural Calculation",
"desc": "Structural review for space-only booths and rigging"
}
},
"ctaBandAria": "Log in or inquire",
"ctaBandText": "Already have an account?"
},
"agency": {
"heroAria": "Agency hero",
"eyebrow": "For Construction & Booth Partners",
"title": "Join Exhibition Construction as a KINTEX Registered Vendor",
"subtitle": "Win projects transparently through construction auctions (reverse auction) — a KINTEX-exclusive approach.",
"ctaLogin": "Vendor Log in",
"ctaRegister": "Apply to Register",
"ctaAuctionLogin": "Log in to bid on auctions",
"ctaWorkReport": "Submit Work Report",
"registryAria": "Registered vendor info",
"registryTitle": "Registered Vendors",
"registrySub": "Operated with 739 registered vendors across 14 categories.",
"gateTitle": "Unregistered vendors cannot participate in construction (self-construction prohibited)",
"gateBody": "Exhibition construction must go through KINTEX-registered vendors. Review the registration process and apply.",
"reg": {
"apply": {
"title": "Apply",
"desc": "Submit registration form"
},
"review": {
"title": "Review",
"desc": "Qualification and track-record review"
},
"register": {
"title": "Register",
"desc": "Listed as an official vendor"
},
"eligible": {
"title": "Eligibility",
"desc": "Bid on auctions and win projects"
}
},
"auctionAria": "Construction auctions",
"auctionTitle": "Ongoing Construction Auctions (Reverse Auction)",
"auctionSub": "Compete transparently within the budget range.",
"auctionEmptyTitle": "Auction listings are available after logging in",
"auctionEmptyBody": "After logging in as a registered vendor, view ongoing auction listings, budgets, deadlines, and bid status.",
"safetyAria": "Work reports and safety rules",
"safetyTitle": "Work Reports & Safety Rules",
"safetySub": "Review required procedures and rules before on-site work.",
"safety": {
"report": {
"title": "Work Report Procedure",
"desc": "Online work report before on-site work"
},
"prohibited": {
"title": "Prohibited Works & Noise",
"desc": "Prohibited works, noise limits, rigging structural calc"
},
"docs": {
"title": "Required Documents",
"desc": "Flame-retardant certificate, insurance"
}
},
"ctaBandAria": "Vendor log in",
"ctaBandText": "Already a registered vendor?"
}
}
}
}

View File

@ -1135,6 +1135,33 @@
"citations": "根拠規定",
"notFound": "規定で見つかりませんでした",
"degraded": "規定原文の要約AI利用不可"
},
"assistant": {
"label": "AI ガイド",
"inputAria": "来場アシスタントに質問",
"sendAria": "質問を送信",
"chipsLabel": "よくある質問",
"chip1": "今週末にロボット展はある?",
"chip2": "KINTEXの駐車場はどこ",
"chip3": "家族向けの展示を教えて",
"chip4": "事前登録の方法は?",
"chip5": "GTX-Aでどのくらい",
"thinking": "回答を生成中…",
"error": "回答を取得できませんでした。再度お試しください。",
"retry": "再試行",
"degraded": "AIガイド点検中 — 蓄積データ検索でご案内します",
"providerAi": "AI 回答",
"notFound": "該当情報が見つかりませんでした — 全日程・ガイドをご確認ください。",
"cardEventsTitle": "関連展示",
"cardTransportTitle": "アクセス",
"cardGuideTitle": "来場案内",
"followupLabel": "続けて質問",
"followup1": "無料ですか?",
"followup2": "駐車場はどこ?",
"followup3": "事前登録の方法",
"citations": "出典",
"register": "事前登録",
"disclaimer": "AI生成の回答 — 正確な情報は公式案内をご確認ください。"
}
},
"analytics": {
@ -2140,5 +2167,364 @@
"cancel": "キャンセル",
"saving": "保存中…",
"save": "保存"
},
"track": {
"shell": {
"wordmark": "KINTEX AI 展示·イベントシステム",
"tablistAria": "トラック選択",
"tab": {
"visitor": "観覧",
"business": "ビジネス",
"agency": "協力会社"
},
"login": "ログイン",
"myHome": "マイホーム"
},
"gateway": {
"heroAria": "製品ヒーロー",
"aiWatermark": "AI生成の予想画像",
"eyebrow": "AI展示·イベント運営プラットフォーム",
"title": "KINTEX AI 展示·イベントシステム",
"subtitle": "展示企画から施工·観覧まで、全工程をAIで自動化します。",
"ctaPreReg": "観覧事前登録",
"ctaInquiry": "出展のお問い合わせ",
"tracksAria": "トラック分岐",
"tracksTitle": "どのような目的でお越しですか?",
"tracksSub": "目的に合ったトラックをお選びください。",
"card": {
"visitor": {
"label": "観覧者",
"title": "イベント観覧",
"desc": "イベント日程·事前登録·アクセス",
"cta": "観覧トラック"
},
"business": {
"label": "主催·出展",
"title": "開催·出展",
"desc": "貸館·自動見積·ブース申請·施工案",
"cta": "ビジネストラック"
},
"agency": {
"label": "工事·協力会社",
"title": "展示施工に参加",
"desc": "登録業者·工事オークション·作業申告",
"cta": "協力会社トラック"
}
},
"eventsAria": "開催中·予定の展示",
"eventsTitle": "開催中 · 予定の展示",
"eventsSub": "開催中および今後の展示をご確認ください。",
"eventsMore": "全日程を見る"
},
"visitor": {
"heroAria": "観覧案内ヒーロー",
"eyebrow": "観覧者ご案内",
"title": "展示観覧はここから",
"subtitle": "事前登録で入場がスムーズに。関心分野の通知も受け取れます。",
"ctaPreReg": "事前登録",
"ctaTicket": "チケット予約",
"eventsAria": "イベント日程",
"eventsTitle": "イベント日程",
"eventsSub": "開催中·予定の展示を確認して事前登録。",
"guideAria": "訪問ガイド",
"guideTitle": "訪問ガイド",
"guideSub": "交通·駐車·施設·アクセシビリティを一か所で。",
"guide": {
"transport": {
"title": "交通",
"desc": "GTX-A キンテックス駅 徒歩3分·広域バス",
"link": "アクセス"
},
"parking": {
"title": "駐車",
"desc": "リアルタイム空き·料金·ゲート",
"link": "駐車案内"
},
"wayfinding": {
"title": "屋内ナビ",
"desc": "ホール·ブースまでの屋内経路",
"link": "ナビを開く"
},
"amenities": {
"title": "施設",
"desc": "飲食·授乳室·手荷物·ATM",
"link": "施設を見る"
},
"accessibility": {
"title": "アクセシビリティ",
"desc": "車いす·手話通訳案内",
"link": "アクセシビリティ案内"
},
"inquiry": {
"title": "お問い合わせ",
"desc": "FAQ·連絡先",
"link": "問い合わせ"
}
},
"noticeAria": "お知らせ",
"noticeTitle": "お知らせ",
"noticeSub": "最近のお知らせをご確認ください。",
"noticeEmpty": "お知らせはありません。",
"aiWatermark": "AI生成の予想画像",
"hero": {
"title": "何でも聞いてください — AIが観覧をご案内",
"subtitle": "展示日程・アクセス・入場券・ブース位置を対話でまとめて"
},
"aiTiles": {
"title": "AIがお手伝いします",
"sub": "観覧のあらゆる瞬間をAIがサポートします。",
"soon": "準備中",
"assistant": {
"title": "AI 観覧アシスタント",
"desc": "何でも聞いてください"
},
"recommend": {
"title": "AI 展示レコメンド",
"desc": "興味に合わせたキュレーション"
},
"wayfinding": {
"title": "AI 道案内",
"desc": "屋内の最適ルート"
},
"schedule": {
"title": "AI 日程アシスタント",
"desc": "自分だけの観覧コース"
},
"crowd": {
"title": "AI 混雑・待ち時間案内",
"desc": "空いている時間帯を提案"
},
"render": {
"title": "AI 施工予想画像",
"desc": "ブースプレビュー(ビジネス)"
}
},
"showcaseTitle": "開催中・開催予定",
"showcaseSub": "開催中と開催予定の展示を一目で。",
"recommendTitle": "あなたの興味に合う展示",
"recommendSub": "関心分野を選ぶとAIがおすすめします。",
"recommendTag": "AI おすすめ",
"interests": {
"robot": "ロボット・自動化",
"smartfactory": "スマートファクトリー",
"healthcare": "ヘルスケア",
"eco": "環境・エネルギー",
"mobility": "モビリティ",
"content": "コンテンツ・文化"
},
"guideMore": "詳しく見る",
"journeyTitle": "観覧の流れ",
"journeySub": "事前登録から入場まで、かんたん3ステップ。",
"journey": {
"register": {
"title": "事前登録",
"desc": "オンラインで来場情報を登録。",
"link": "展示を見る"
},
"badge": {
"title": "バッジ・QR発行",
"desc": "モバイルバッジと入場QRを受け取り。",
"link": "入場券照会"
},
"checkin": {
"title": "現地チェックイン",
"desc": "入口でQRをスキャンして入場。",
"link": "ログイン"
}
},
"venueTitle": "KINTEX会場のご案内",
"venueSub": "国内最大級の展示・コンベンションセンター。",
"venue": {
"halls": {
"label": "展示ホール",
"value": "10"
},
"area": {
"label": "展示面積",
"value": "108,556㎡"
},
"parking": {
"label": "駐車",
"value": "7,500台"
},
"access": {
"label": "アクセス",
"value": "GTX-A 徒歩3分"
}
},
"briefing": {
"title": "AI 展示・イベント企画ブリーフィング",
"sub": "今年と今月の開催予定展示をAIが先にまとめます。",
"viewYear": "年間({{year}}",
"viewMonth": "月間",
"aiSummary": "AI 要約",
"loading": "AI要約を生成中…",
"empty": "この期間に予定された展示はありません。",
"yearSummary": "{{year}}年は計{{count}}件の展示・イベントが予定され、{{peak}}に集中します。",
"monthSummary": "今月は{{count}}件の展示が開催されます · 週末の来場がおすすめ。",
"monthLabel": "{{month}}月",
"eventsCount": "{{count}}件",
"prevMonth": "前の月",
"nextMonth": "次の月"
}
},
"business": {
"heroAria": "ビジネス案内ヒーロー",
"eyebrow": "主催者·出展者ご案内",
"title": "開催·出展、オンライン申請を一元化",
"subtitle": "貸館からブース申請·施工まで、すべてオンラインで。",
"ctaApply": "申請·問い合わせ",
"ctaLogin": "ログイン",
"ctaInquiry": "問い合わせ",
"valueAria": "提供価値",
"valueTitle": "役割に合った入口",
"valueSub": "主催者·出展者向けの機能をご案内します。",
"value": {
"organizer": {
"title": "主催者 · ホール貸館",
"desc": "貸館手続きからホール割当·フロアプランまで一括。",
"item": {
"rental": "貸館手続き",
"quote": "自動見積",
"floorplan": "ホール割当·フロアプラン",
"rate": "料金·施設規格"
},
"cta": "貸館問い合わせ·ログイン"
},
"exhibitor": {
"title": "出展者 · ブース申請",
"desc": "ブース·ユーティリティ申請からAI施工案まで。",
"item": {
"apply": "ブース申請",
"utility": "ユーティリティ申請",
"compliance": "規定事前検証",
"render": "AI施工案"
},
"cta": "出展申請·ログイン"
}
},
"processAria": "貸館プロセス",
"processTitle": "貸館プロセス",
"processSub": "問い合わせから施工·検収まで一目で。",
"process": {
"inquiry": {
"title": "問い合わせ",
"desc": "貸館相談受付"
},
"quote": {
"title": "仮見積",
"desc": "自動見積算出"
},
"contract": {
"title": "契約",
"desc": "契約締結"
},
"assign": {
"title": "ホール割当",
"desc": "ホール·区画割当"
},
"booth": {
"title": "ブース申請",
"desc": "出展ブース·ユーティリティ"
},
"inspect": {
"title": "施工·検収",
"desc": "施工後の現場検収"
}
},
"rateAria": "料金·書類",
"rateTitle": "料金 · 書類のご案内",
"rateSub": "ブースタイプ別の料率と主な書類。",
"rate": {
"shell": {
"label": "組立ブース",
"price": "基本型",
"desc": "基本ブース·基本電気込み"
},
"space": {
"label": "独立ブース",
"price": "スペース型",
"desc": "自由施工·構造計算書必要"
},
"premium": {
"label": "プレミアム",
"price": "特化型",
"desc": "プレミアム位置·追加ユーティリティ"
}
},
"doc": {
"assign": {
"title": "割当申請書",
"desc": "ホール割当申請書式"
},
"flame": {
"title": "防炎確認書",
"desc": "防炎処理確認書類"
},
"structure": {
"title": "構造計算書",
"desc": "独立ブース·リギング構造検討"
}
},
"ctaBandAria": "ログイン·問い合わせ",
"ctaBandText": "すでにアカウントをお持ちですか?"
},
"agency": {
"heroAria": "協力会社案内ヒーロー",
"eyebrow": "工事·装置·協力会社ご案内",
"title": "KINTEX公式登録業者として展示施工に参加",
"subtitle": "工事オークション逆オークションで透明に受注。KINTEXならではの方式です。",
"ctaLogin": "登録業者ログイン",
"ctaRegister": "登録申請",
"ctaAuctionLogin": "ログインして応札",
"ctaWorkReport": "作業申告する",
"registryAria": "登録業者案内",
"registryTitle": "登録業者のご案内",
"registrySub": "登録業者739社·14分類で運営されます。",
"gateTitle": "未登録業者は展示施工に参加できません(自主施工不可)",
"gateBody": "展示施工は必ずKINTEX公式登録業者を通す必要があります。登録手続きをご確認のうえ申請してください。",
"reg": {
"apply": {
"title": "申請",
"desc": "登録申請書提出"
},
"review": {
"title": "資格審査",
"desc": "資格·実績審査"
},
"register": {
"title": "登録",
"desc": "公式登録業者に登載"
},
"eligible": {
"title": "受注資格",
"desc": "オークション応札·受注可能"
}
},
"auctionAria": "工事オークション",
"auctionTitle": "進行中の工事オークション(逆オークション)",
"auctionSub": "予算範囲内で透明に競争応札。",
"auctionEmptyTitle": "オークション公告はログイン後にご確認いただけます",
"auctionEmptyBody": "登録業者としてログインすると、進行中の工事オークション公告·予算·締切·応札状況を確認し応札できます。",
"safetyAria": "作業申告·安全規定",
"safetyTitle": "作業申告 · 安全規定",
"safetySub": "現場作業前に必須の手続きと規定をご確認ください。",
"safety": {
"report": {
"title": "作業申告手続き",
"desc": "現場作業前のオンライン作業申告"
},
"prohibited": {
"title": "禁止作業·騒音",
"desc": "禁止作業·騒音限度·リギング構造計算書"
},
"docs": {
"title": "必須書類",
"desc": "防炎確認書·保険証書"
}
},
"ctaBandAria": "登録業者ログイン",
"ctaBandText": "すでに登録業者ですか?"
}
}
}
}

View File

@ -1135,6 +1135,33 @@
"citations": "근거 규정",
"notFound": "규정에서 찾지 못함",
"degraded": "규정 원문 요약(AI 미가용)"
},
"assistant": {
"label": "AI 도우미",
"inputAria": "관람 도우미에게 질문하기",
"sendAria": "질문 보내기",
"chipsLabel": "자주 묻는 질문",
"chip1": "이번 주말 로봇 전시 있어?",
"chip2": "킨텍스 주차 어디에 해요?",
"chip3": "가족과 볼만한 전시 추천",
"chip4": "사전등록 어떻게 해요?",
"chip5": "GTX-A로 얼마나 걸려요?",
"thinking": "답변 생성 중…",
"error": "답변을 가져오지 못했습니다. 다시 시도해 주세요.",
"retry": "다시 시도",
"degraded": "AI 도우미 점검 중 — 적재 데이터 검색으로 안내합니다",
"providerAi": "AI 답변",
"notFound": "해당 정보를 찾지 못했어요 — 전체 일정·가이드를 확인해 보세요.",
"cardEventsTitle": "관련 전시",
"cardTransportTitle": "오시는 길",
"cardGuideTitle": "방문 안내",
"followupLabel": "이어서 물어보기",
"followup1": "무료인가요?",
"followup2": "주차 어디에 해요?",
"followup3": "사전등록 방법",
"citations": "근거 출처",
"register": "사전등록",
"disclaimer": "AI 생성 답변 — 정확한 정보는 공식 안내를 확인하세요."
}
},
"analytics": {
@ -2140,5 +2167,364 @@
"cancel": "취소",
"saving": "저장 중…",
"save": "저장"
},
"track": {
"shell": {
"wordmark": "KINTEX AI 전시·행사시스템",
"tablistAria": "트랙 선택",
"tab": {
"visitor": "관람",
"business": "비즈니스",
"agency": "협력사"
},
"login": "로그인",
"myHome": "내 홈"
},
"gateway": {
"heroAria": "제품 소개 히어로",
"aiWatermark": "AI 생성 예상 이미지",
"eyebrow": "AI 전시·행사 운영 플랫폼",
"title": "KINTEX AI 전시·행사시스템",
"subtitle": "전시 기획부터 시공·관람까지 전 과정을 AI로 자동화합니다.",
"ctaPreReg": "관람 사전등록",
"ctaInquiry": "참가 문의",
"tracksAria": "트랙 분기",
"tracksTitle": "어떤 목적으로 방문하셨나요?",
"tracksSub": "목적에 맞는 트랙을 선택하세요.",
"card": {
"visitor": {
"label": "관람객",
"title": "행사 관람",
"desc": "행사 일정·사전등록·오시는 길",
"cta": "관람 트랙"
},
"business": {
"label": "주최·참가",
"title": "전시 개최·참가",
"desc": "대관·자동견적·부스 신청·시공 시안",
"cta": "비즈니스 트랙"
},
"agency": {
"label": "공사·협력사",
"title": "전시 시공 참여",
"desc": "등록업체·공사 옥션·작업신고",
"cta": "협력사 트랙"
}
},
"eventsAria": "진행 중·예정 전시",
"eventsTitle": "진행 중 · 예정 전시",
"eventsSub": "지금 열리는 전시와 곧 열릴 전시를 확인하세요.",
"eventsMore": "전체 일정 보기"
},
"visitor": {
"heroAria": "관람 안내 히어로",
"eyebrow": "관람객 안내",
"title": "전시 관람, 여기서 시작하세요",
"subtitle": "사전등록하고 더 빠르게 입장하세요. 관심 분야 알림도 받아보세요.",
"ctaPreReg": "사전등록",
"ctaTicket": "입장권 예매",
"eventsAria": "행사 일정",
"eventsTitle": "행사 일정",
"eventsSub": "진행 중·예정 전시를 확인하고 사전등록하세요.",
"guideAria": "방문 가이드",
"guideTitle": "방문 가이드",
"guideSub": "교통·주차·편의시설·접근성 정보를 한 곳에서.",
"guide": {
"transport": {
"title": "교통",
"desc": "GTX-A 킨텍스역 도보 3분·광역버스",
"link": "오시는 길"
},
"parking": {
"title": "주차",
"desc": "실시간 주차 안내·요금·게이트",
"link": "주차 안내"
},
"wayfinding": {
"title": "실내 길찾기",
"desc": "홀·부스까지 실내 경로 안내",
"link": "길찾기 열기"
},
"amenities": {
"title": "편의시설",
"desc": "식음·수유실·물품보관·ATM",
"link": "편의시설 보기"
},
"accessibility": {
"title": "접근성·편의",
"desc": "휠체어·수어통역 안내",
"link": "접근성 안내"
},
"inquiry": {
"title": "고객문의",
"desc": "FAQ·연락처 안내",
"link": "문의하기"
}
},
"noticeAria": "공지사항",
"noticeTitle": "공지사항",
"noticeSub": "최근 공지·알림을 확인하세요.",
"noticeEmpty": "등록된 공지가 없습니다.",
"aiWatermark": "AI 생성 예상 이미지",
"hero": {
"title": "무엇이든 물어보세요 — AI가 관람을 안내합니다",
"subtitle": "전시 일정·오시는 길·입장권·부스 위치를 대화로 한 번에"
},
"aiTiles": {
"title": "AI가 도와드립니다",
"sub": "관람의 모든 순간을 AI가 함께합니다.",
"soon": "준비 중",
"assistant": {
"title": "AI 관람 도우미",
"desc": "무엇이든 물어보세요"
},
"recommend": {
"title": "AI 전시 추천",
"desc": "관심사 맞춤 큐레이션"
},
"wayfinding": {
"title": "AI 길찾기",
"desc": "실내 최적 동선"
},
"schedule": {
"title": "AI 일정 도우미",
"desc": "나만의 관람 코스"
},
"crowd": {
"title": "AI 혼잡·대기 안내",
"desc": "여유로운 시간 추천"
},
"render": {
"title": "AI 시공 예상 이미지",
"desc": "부스 미리보기 (비즈니스 트랙)"
}
},
"showcaseTitle": "진행 중 · 예정 전시",
"showcaseSub": "지금 열리는 전시와 곧 열릴 전시를 한눈에.",
"recommendTitle": "당신의 관심사에 맞는 전시",
"recommendSub": "관심 분야를 선택하면 AI가 맞춤 전시를 추천합니다.",
"recommendTag": "AI 추천",
"interests": {
"robot": "로봇·자동화",
"smartfactory": "스마트팩토리",
"healthcare": "헬스케어",
"eco": "친환경·에너지",
"mobility": "모빌리티",
"content": "콘텐츠·문화"
},
"guideMore": "자세히 보기",
"journeyTitle": "관람은 이렇게 진행됩니다",
"journeySub": "사전등록부터 현장 입장까지 3단계로 간편하게.",
"journey": {
"register": {
"title": "사전등록",
"desc": "온라인으로 관람 정보를 등록하세요.",
"link": "전시 보기"
},
"badge": {
"title": "배지·QR 발급",
"desc": "모바일 배지와 입장 QR을 받으세요.",
"link": "입장권 조회"
},
"checkin": {
"title": "현장 체크인",
"desc": "입구에서 QR을 스캔하고 바로 입장하세요.",
"link": "로그인"
}
},
"venueTitle": "킨텍스 전시장 소개",
"venueSub": "국내 최대 규모의 전시·컨벤션 센터.",
"venue": {
"halls": {
"label": "전시홀",
"value": "10개"
},
"area": {
"label": "전시면적",
"value": "108,556㎡"
},
"parking": {
"label": "주차",
"value": "7,500대"
},
"access": {
"label": "교통",
"value": "GTX-A 도보 3분"
}
},
"briefing": {
"title": "AI 전시·행사 기획 브리핑",
"sub": "올 한 해와 이번 달, AI가 개최 예정 전시를 먼저 정리해 드립니다.",
"viewYear": "연간 ({{year}})",
"viewMonth": "월간",
"aiSummary": "AI 요약",
"loading": "AI 요약 생성 중…",
"empty": "이 기간에 예정된 전시가 없습니다.",
"yearSummary": "{{year}}년에는 총 {{count}}건의 전시·행사가 예정되어 있으며, {{peak}}에 집중됩니다.",
"monthSummary": "이번 달은 {{count}}건의 전시가 열립니다 · 주말 방문을 추천합니다.",
"monthLabel": "{{month}}월",
"eventsCount": "{{count}}건",
"prevMonth": "이전 달",
"nextMonth": "다음 달"
}
},
"business": {
"heroAria": "비즈니스 안내 히어로",
"eyebrow": "주최자·참가업체 안내",
"title": "전시 개최·참가, 온라인 신청 일원화",
"subtitle": "대관부터 부스 신청·시공까지 모든 절차를 온라인에서 처리하세요.",
"ctaApply": "신청·문의",
"ctaLogin": "로그인",
"ctaInquiry": "문의하기",
"valueAria": "가치 제안",
"valueTitle": "역할에 맞는 진입",
"valueSub": "주최자와 참가업체를 위한 기능을 안내합니다.",
"value": {
"organizer": {
"title": "주최자 · 홀 임대",
"desc": "대관 절차부터 홀 배정·플로어플랜까지 한 번에.",
"item": {
"rental": "대관 절차 안내",
"quote": "자동견적",
"floorplan": "홀 배정·플로어플랜",
"rate": "요금·시설 규격"
},
"cta": "대관 문의·로그인"
},
"exhibitor": {
"title": "참가업체 · 부스 신청",
"desc": "부스 신청·유틸리티부터 AI 시공 시안까지.",
"item": {
"apply": "부스 신청",
"utility": "유틸리티 신청",
"compliance": "규정 사전검증",
"render": "AI 시공 시안"
},
"cta": "참가 신청·로그인"
}
},
"processAria": "임대 절차",
"processTitle": "임대 절차",
"processSub": "문의부터 시공·검수까지 한눈에.",
"process": {
"inquiry": {
"title": "문의",
"desc": "대관 상담 접수"
},
"quote": {
"title": "가견적",
"desc": "자동견적 산출"
},
"contract": {
"title": "계약",
"desc": "계약 체결"
},
"assign": {
"title": "홀 배정",
"desc": "홀·구획 배정"
},
"booth": {
"title": "부스 신청",
"desc": "참가업체 부스·유틸리티"
},
"inspect": {
"title": "시공·검수",
"desc": "시공 후 현장 검수"
}
},
"rateAria": "요금·서류",
"rateTitle": "요금 · 서류 안내",
"rateSub": "부스 유형별 요율과 주요 서류를 안내합니다.",
"rate": {
"shell": {
"label": "조립부스",
"price": "기본형",
"desc": "기본 부스·기본 전기 포함"
},
"space": {
"label": "독립부스",
"price": "공간형",
"desc": "자유 시공·구조계산서 필요"
},
"premium": {
"label": "프리미엄",
"price": "특화형",
"desc": "프리미엄 위치·추가 유틸리티"
}
},
"doc": {
"assign": {
"title": "배정신청서",
"desc": "홀 배정 신청 서식"
},
"flame": {
"title": "방염 확인서",
"desc": "방염 처리 확인 서류"
},
"structure": {
"title": "구조계산서",
"desc": "독립부스·리깅 구조 검토"
}
},
"ctaBandAria": "로그인·문의",
"ctaBandText": "이미 계정이 있으신가요?"
},
"agency": {
"heroAria": "협력사 안내 히어로",
"eyebrow": "공사·장치·협력업체 안내",
"title": "킨텍스 공식 등록업체로 전시 시공에 참여하세요",
"subtitle": "공사 옥션(역경매)으로 투명하게 수주하세요. 킨텍스만의 차별화된 방식입니다.",
"ctaLogin": "등록업체 로그인",
"ctaRegister": "등록 신청",
"ctaAuctionLogin": "로그인하고 옥션 응찰",
"ctaWorkReport": "작업신고 하기",
"registryAria": "등록업체 안내",
"registryTitle": "등록업체 안내",
"registrySub": "등록업체 739개사 · 14개 분류로 운영됩니다.",
"gateTitle": "미등록 업체는 전시 시공에 참여할 수 없습니다 (자체시공 불가)",
"gateBody": "전시 시공은 반드시 킨텍스 공식 등록업체를 통해야 합니다. 등록 절차를 확인하고 신청하세요.",
"reg": {
"apply": {
"title": "신청",
"desc": "등록 신청서 제출"
},
"review": {
"title": "자격심사",
"desc": "자격·실적 심사"
},
"register": {
"title": "등록",
"desc": "공식 등록업체 등재"
},
"eligible": {
"title": "수주 자격",
"desc": "옥션 응찰·수주 가능"
}
},
"auctionAria": "공사 옥션",
"auctionTitle": "진행 중 공사 옥션 (역경매)",
"auctionSub": "예산 범위 안에서 투명하게 경쟁 응찰하세요.",
"auctionEmptyTitle": "공개 옥션 공고는 로그인 후 확인하실 수 있습니다",
"auctionEmptyBody": "등록업체 로그인 시 진행 중인 공사 옥션 공고·예산·마감·응찰 현황을 확인하고 응찰할 수 있습니다.",
"safetyAria": "작업신고·안전 규정",
"safetyTitle": "작업신고 · 안전 규정",
"safetySub": "현장 작업 전 필수 절차와 규정을 확인하세요.",
"safety": {
"report": {
"title": "작업신고 절차",
"desc": "현장 작업 전 온라인 작업신고"
},
"prohibited": {
"title": "금지작업·소음",
"desc": "금지작업·소음 한도·리깅 구조계산서"
},
"docs": {
"title": "필수 서류",
"desc": "방염 확인서·보험 증서"
}
},
"ctaBandAria": "등록업체 로그인",
"ctaBandText": "이미 등록된 업체이신가요?"
}
}
}
}

View File

@ -1135,6 +1135,33 @@
"citations": "依据规定",
"notFound": "在规定中未找到",
"degraded": "规定原文摘要AI 不可用)"
},
"assistant": {
"label": "AI 助手",
"inputAria": "向参观助手提问",
"sendAria": "发送问题",
"chipsLabel": "常见问题",
"chip1": "这周末有机器人展吗?",
"chip2": "KINTEX 停车在哪里?",
"chip3": "推荐适合家庭参观的展览",
"chip4": "如何预先登记?",
"chip5": "乘 GTX-A 需要多久?",
"thinking": "正在生成回答…",
"error": "无法获取回答,请重试。",
"retry": "重试",
"degraded": "AI 助手维护中 — 以已存数据检索结果作答",
"providerAi": "AI 回答",
"notFound": "未找到相关信息 — 请查看完整日程与指南。",
"cardEventsTitle": "相关展览",
"cardTransportTitle": "交通路线",
"cardGuideTitle": "参观须知",
"followupLabel": "继续提问",
"followup1": "是免费的吗?",
"followup2": "在哪里停车?",
"followup3": "如何预先登记",
"citations": "来源",
"register": "预先登记",
"disclaimer": "AI 生成的回答 — 准确信息请以官方通知为准。"
}
},
"analytics": {
@ -2140,5 +2167,364 @@
"cancel": "取消",
"saving": "保存中…",
"save": "保存"
},
"track": {
"shell": {
"wordmark": "KINTEX AI 展览·活动系统",
"tablistAria": "选择通道",
"tab": {
"visitor": "参观",
"business": "商务",
"agency": "合作商"
},
"login": "登录",
"myHome": "我的主页"
},
"gateway": {
"heroAria": "产品介绍",
"aiWatermark": "AI 生成预览图",
"eyebrow": "AI 展览·活动运营平台",
"title": "KINTEX AI 展览·活动系统",
"subtitle": "从展览策划到施工·参观,全流程 AI 自动化。",
"ctaPreReg": "参观预登记",
"ctaInquiry": "参展咨询",
"tracksAria": "通道分流",
"tracksTitle": "您此次访问的目的是?",
"tracksSub": "请选择符合您目的的通道。",
"card": {
"visitor": {
"label": "参观者",
"title": "参观活动",
"desc": "活动日程·预登记·交通指引",
"cta": "参观通道"
},
"business": {
"label": "主办·参展",
"title": "举办·参展",
"desc": "租馆·自动报价·展位申请·施工方案",
"cta": "商务通道"
},
"agency": {
"label": "施工·合作商",
"title": "参与展览施工",
"desc": "注册企业·施工竞标·作业申报",
"cta": "合作商通道"
}
},
"eventsAria": "进行中·预定展览",
"eventsTitle": "进行中 · 预定展览",
"eventsSub": "查看正在举办及即将举办的展览。",
"eventsMore": "查看全部日程"
},
"visitor": {
"heroAria": "参观介绍",
"eyebrow": "参观者指南",
"title": "参观从这里开始",
"subtitle": "预登记可更快入场,还可接收关注领域的通知。",
"ctaPreReg": "预登记",
"ctaTicket": "购票",
"eventsAria": "活动日程",
"eventsTitle": "活动日程",
"eventsSub": "查看进行中·预定展览并预登记。",
"guideAria": "参观指南",
"guideTitle": "参观指南",
"guideSub": "交通·停车·便利设施·无障碍信息一站式了解。",
"guide": {
"transport": {
"title": "交通",
"desc": "GTX-A KINTEX站步行3分钟·城际巴士",
"link": "交通指引"
},
"parking": {
"title": "停车",
"desc": "实时车位·费用·出入口",
"link": "停车指南"
},
"wayfinding": {
"title": "室内导航",
"desc": "至展厅·展位的室内路线",
"link": "打开导航"
},
"amenities": {
"title": "便利设施",
"desc": "餐饮·母婴室·寄存·ATM",
"link": "查看设施"
},
"accessibility": {
"title": "无障碍",
"desc": "轮椅·手语翻译指引",
"link": "无障碍指南"
},
"inquiry": {
"title": "客户咨询",
"desc": "FAQ·联系方式",
"link": "联系我们"
}
},
"noticeAria": "公告",
"noticeTitle": "公告",
"noticeSub": "查看最新公告·通知。",
"noticeEmpty": "暂无公告。",
"aiWatermark": "AI 生成的预览图",
"hero": {
"title": "尽管提问 — AI 为您导览",
"subtitle": "展会日程、交通、门票与展位,一次对话全搞定"
},
"aiTiles": {
"title": "AI 为您服务",
"sub": "AI 陪伴您参观的每一刻。",
"soon": "敬请期待",
"assistant": {
"title": "AI 参观助手",
"desc": "尽管提问"
},
"recommend": {
"title": "AI 展会推荐",
"desc": "按兴趣精选"
},
"wayfinding": {
"title": "AI 室内导航",
"desc": "室内最优路线"
},
"schedule": {
"title": "AI 行程助手",
"desc": "专属参观路线"
},
"crowd": {
"title": "AI 拥挤·等候提示",
"desc": "推荐空闲时段"
},
"render": {
"title": "AI 施工预览图",
"desc": "展位预览(商务通道)"
}
},
"showcaseTitle": "进行中·即将举办",
"showcaseSub": "一览当前与即将举办的展会。",
"recommendTitle": "为您兴趣定制的展会",
"recommendSub": "选择兴趣领域AI 为您推荐。",
"recommendTag": "AI 推荐",
"interests": {
"robot": "机器人·自动化",
"smartfactory": "智能工厂",
"healthcare": "医疗健康",
"eco": "环保·能源",
"mobility": "出行",
"content": "内容·文化"
},
"guideMore": "查看详情",
"journeyTitle": "参观流程",
"journeySub": "从预先登记到现场入场,三步搞定。",
"journey": {
"register": {
"title": "预先登记",
"desc": "在线登记参观信息。",
"link": "浏览展会"
},
"badge": {
"title": "领取胸卡·二维码",
"desc": "获取手机胸卡与入场二维码。",
"link": "查询门票"
},
"checkin": {
"title": "现场签到",
"desc": "在入口扫码即可入场。",
"link": "登录"
}
},
"venueTitle": "KINTEX 展馆介绍",
"venueSub": "韩国最大的展览会议中心。",
"venue": {
"halls": {
"label": "展厅",
"value": "10 个"
},
"area": {
"label": "展览面积",
"value": "108,556㎡"
},
"parking": {
"label": "停车",
"value": "7,500 辆"
},
"access": {
"label": "交通",
"value": "GTX-A 步行3分"
}
},
"briefing": {
"title": "AI 展会企划简报",
"sub": "AI 提前为您梳理全年与本月的展会。",
"viewYear": "全年({{year}}",
"viewMonth": "月度",
"aiSummary": "AI 摘要",
"loading": "正在生成 AI 摘要…",
"empty": "该期间暂无展会安排。",
"yearSummary": "{{year}}年共安排 {{count}} 场展会,集中在{{peak}}。",
"monthSummary": "本月共 {{count}} 场展会 · 建议周末参观。",
"monthLabel": "{{month}}月",
"eventsCount": "{{count}} 场",
"prevMonth": "上个月",
"nextMonth": "下个月"
}
},
"business": {
"heroAria": "商务介绍",
"eyebrow": "主办方·参展商指南",
"title": "举办·参展,在线申请一站式",
"subtitle": "从租馆到展位申请·施工,全流程在线办理。",
"ctaApply": "申请·咨询",
"ctaLogin": "登录",
"ctaInquiry": "咨询",
"valueAria": "价值主张",
"valueTitle": "按角色进入",
"valueSub": "为主办方与参展商提供的功能。",
"value": {
"organizer": {
"title": "主办方 · 租馆",
"desc": "从租馆流程到展厅分配·平面图,一站搞定。",
"item": {
"rental": "租馆流程",
"quote": "自动报价",
"floorplan": "展厅分配·平面图",
"rate": "费用·设施规格"
},
"cta": "租馆咨询·登录"
},
"exhibitor": {
"title": "参展商 · 展位申请",
"desc": "从展位·水电申请到 AI 施工方案。",
"item": {
"apply": "展位申请",
"utility": "水电等申请",
"compliance": "规定预检",
"render": "AI 施工方案"
},
"cta": "参展申请·登录"
}
},
"processAria": "租赁流程",
"processTitle": "租赁流程",
"processSub": "从咨询到施工·验收一目了然。",
"process": {
"inquiry": {
"title": "咨询",
"desc": "受理租馆咨询"
},
"quote": {
"title": "初步报价",
"desc": "自动报价"
},
"contract": {
"title": "签约",
"desc": "签订合同"
},
"assign": {
"title": "展厅分配",
"desc": "展厅·分区分配"
},
"booth": {
"title": "展位申请",
"desc": "参展商展位·水电"
},
"inspect": {
"title": "施工·验收",
"desc": "施工后现场验收"
}
},
"rateAria": "费用·文件",
"rateTitle": "费用 · 文件指南",
"rateSub": "各展位类型的费率及主要文件。",
"rate": {
"shell": {
"label": "标准展位",
"price": "基本型",
"desc": "含基本展位·基础用电"
},
"space": {
"label": "光地展位",
"price": "空间型",
"desc": "自由施工·需结构计算书"
},
"premium": {
"label": "高级",
"price": "特化型",
"desc": "优质位置·附加水电"
}
},
"doc": {
"assign": {
"title": "分配申请书",
"desc": "展厅分配申请表"
},
"flame": {
"title": "阻燃确认书",
"desc": "阻燃处理确认文件"
},
"structure": {
"title": "结构计算书",
"desc": "光地展位·吊挂结构审查"
}
},
"ctaBandAria": "登录·咨询",
"ctaBandText": "已经拥有账号?"
},
"agency": {
"heroAria": "合作商介绍",
"eyebrow": "施工·装置·合作商指南",
"title": "成为 KINTEX 官方注册企业,参与展览施工",
"subtitle": "通过施工竞标(反向竞价)透明中标 — KINTEX 独有方式。",
"ctaLogin": "注册企业登录",
"ctaRegister": "注册申请",
"ctaAuctionLogin": "登录参与竞标",
"ctaWorkReport": "提交作业申报",
"registryAria": "注册企业指南",
"registryTitle": "注册企业指南",
"registrySub": "由 739 家注册企业·14 个分类运营。",
"gateTitle": "未注册企业不得参与展览施工(不可自行施工)",
"gateBody": "展览施工必须通过 KINTEX 官方注册企业。请查看注册流程并申请。",
"reg": {
"apply": {
"title": "申请",
"desc": "提交注册申请"
},
"review": {
"title": "资格审查",
"desc": "资格·业绩审查"
},
"register": {
"title": "注册",
"desc": "登记为官方注册企业"
},
"eligible": {
"title": "中标资格",
"desc": "可参与竞标·中标"
}
},
"auctionAria": "施工竞标",
"auctionTitle": "进行中的施工竞标(反向竞价)",
"auctionSub": "在预算范围内透明竞标。",
"auctionEmptyTitle": "竞标公告需登录后查看",
"auctionEmptyBody": "以注册企业登录后,可查看进行中的施工竞标公告·预算·截止·应标情况并参与竞标。",
"safetyAria": "作业申报·安全规定",
"safetyTitle": "作业申报 · 安全规定",
"safetySub": "现场作业前请确认必要流程与规定。",
"safety": {
"report": {
"title": "作业申报流程",
"desc": "现场作业前在线申报"
},
"prohibited": {
"title": "禁止作业·噪音",
"desc": "禁止作业·噪音限值·吊挂结构计算书"
},
"docs": {
"title": "必需文件",
"desc": "阻燃确认书·保险单"
}
},
"ctaBandAria": "注册企业登录",
"ctaBandText": "已是注册企业?"
}
}
}
}

View File

@ -0,0 +1,303 @@
/*
* AI (SCR-T1 ) .
* PLANNING §8A 정합: 자연어 (role=search) + +
* (· (//)· ··AI ·degraded ).
* 데이터: publicApi.askVisitorAssistant POST /api/public/ai/visitor-assistant( DB ).
* 상태: 대기(placeholder )···degraded(provider=search|none)··(grounded=false).
* · SVG·kx ·AiLabel() ·aria-live=polite.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AiLabel } from '../../components/ui/Badge';
import { publicApi, type AssistantResult } from './publicApi';
import { formatRange, dday, errorMessage } from './publicFormat';
import { ddayClass } from './trackShared';
import {
IconSend,
IconCalendar,
IconPin,
IconTrain,
IconInfo,
IconLink,
IconArrowRight,
} from './publicIcons';
/** 예시/후속 질문칩 i18n 키. */
const EXAMPLE_KEYS = ['chip1', 'chip2', 'chip3', 'chip4', 'chip5'] as const;
const FOLLOWUP_KEYS = ['followup1', 'followup2', 'followup3'] as const;
type Phase = 'idle' | 'thinking' | 'done' | 'error';
export function AiAssistant() {
const { t } = useTranslation();
const [question, setQuestion] = useState('');
const [phase, setPhase] = useState<Phase>('idle');
const [result, setResult] = useState<AssistantResult | null>(null);
const [error, setError] = useState('');
const [phIdx, setPhIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const reqSeq = useRef(0);
// 대기 중 placeholder 로테이션(입력 비어 있을 때만).
useEffect(() => {
if (phase !== 'idle' || question) return;
const id = window.setInterval(() => setPhIdx((i) => (i + 1) % EXAMPLE_KEYS.length), 3800);
return () => window.clearInterval(id);
}, [phase, question]);
const ask = useCallback(
(q: string) => {
const query = q.trim();
if (!query) return;
const seq = ++reqSeq.current;
setQuestion(query);
setPhase('thinking');
setError('');
publicApi
.askVisitorAssistant(query)
.then((res) => {
if (seq !== reqSeq.current) return;
setResult(res);
setPhase('done');
})
.catch((e) => {
if (seq !== reqSeq.current) return;
setError(errorMessage(e));
setPhase('error');
});
},
[],
);
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
ask(question);
};
const placeholder = t(`ai.assistant.${EXAMPLE_KEYS[phIdx]}`);
return (
<div className="kxp-ai">
<form className="kxp-ai__bar" role="search" onSubmit={onSubmit}>
<span className="kxp-ai__badge" aria-hidden>
<AiLabel>{t('ai.assistant.label')}</AiLabel>
</span>
<input
ref={inputRef}
className="kxp-ai__input"
type="search"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder={placeholder}
aria-label={t('ai.assistant.inputAria')}
enterKeyHint="search"
/>
<button
type="submit"
className="kxp-ai__send"
aria-label={t('ai.assistant.sendAria')}
disabled={phase === 'thinking' || !question.trim()}
>
<IconSend width={20} height={20} />
</button>
</form>
{/* 예시 질문칩 */}
<div className="kxp-ai__chips" aria-label={t('ai.assistant.chipsLabel')}>
{EXAMPLE_KEYS.map((k) => (
<button
key={k}
type="button"
className="kxp-ai__chip"
onClick={() => ask(t(`ai.assistant.${k}`))}
>
{t(`ai.assistant.${k}`)}
</button>
))}
</div>
{/* 대화형 답변 패널 */}
<div className="kxp-ai__panel" aria-live="polite" aria-atomic="false">
{phase === 'thinking' && (
<div className="kxp-ai__thinking" role="status">
<span className="kxp-ai__dots" aria-hidden>
<i />
<i />
<i />
</span>
{t('ai.assistant.thinking')}
</div>
)}
{phase === 'error' && (
<div className="kxp-ai__answer kxp-ai__answer--error" role="alert">
<p className="kxp-ai__text">{error || t('ai.assistant.error')}</p>
<button type="button" className="kxp-btn kxp-btn--outline" onClick={() => ask(question)}>
{t('ai.assistant.retry')}
</button>
</div>
)}
{phase === 'done' && result && <AnswerCard result={result} onFollowup={ask} />}
</div>
</div>
);
}
function AnswerCard({
result,
onFollowup,
}: {
result: AssistantResult;
onFollowup: (q: string) => void;
}) {
const { t } = useTranslation();
const { answer, events, transport, guide, citations, grounded, degraded } = result;
const hasCards = events.length > 0 || transport.length > 0 || guide.length > 0;
return (
<div className="kxp-ai__answer">
<div className="kxp-ai__answerhead">
<AiLabel>{t('ai.assistant.providerAi')}</AiLabel>
{degraded && <span className="kxp-ai__degraded">{t('ai.assistant.degraded')}</span>}
</div>
{answer ? (
<p className="kxp-ai__text">{answer}</p>
) : (
<p className="kxp-ai__text">{t('ai.assistant.notFound')}</p>
)}
{!grounded && !answer && <p className="kxp-ai__text kxp-ai__muted">{t('ai.assistant.notFound')}</p>}
{hasCards && (
<div className="kxp-ai__cards">
{events.length > 0 && (
<section className="kxp-ai__group" aria-label={t('ai.assistant.cardEventsTitle')}>
<h4 className="kxp-ai__grouptitle">{t('ai.assistant.cardEventsTitle')}</h4>
<div className="kxp-ai__grouprows">
{events.slice(0, 4).map((ev) => {
const d = dday(ev.startDate);
const ended = ev.lifecycle === 'ENDED';
return (
<article key={ev.id} className="kxp-ai__evcard">
<div className="kxp-ai__evposter" aria-hidden>
{ev.posterUrl ? (
<img src={ev.posterUrl} alt="" loading="lazy" />
) : (
<span className="kxp-ai__evfallback">{ev.name.slice(0, 2)}</span>
)}
</div>
<div className="kxp-ai__evbody">
<div className="kxp-ai__evname">{ev.name}</div>
<div className="kxp-ai__evmeta">
<span>
<IconCalendar width={15} height={15} /> {formatRange(ev.startDate, ev.endDate)}
</span>
{ev.hallLabel && (
<span>
<IconPin width={15} height={15} /> {ev.hallLabel}
</span>
)}
</div>
</div>
{!ended && d >= 0 && (
<span className={`kxp-dday ${ddayClass(d)}`}>
{d === 0 ? t('home.ddayZero') : t('home.dday', { n: d })}
</span>
)}
</article>
);
})}
</div>
</section>
)}
{transport.length > 0 && (
<section className="kxp-ai__group" aria-label={t('ai.assistant.cardTransportTitle')}>
<h4 className="kxp-ai__grouptitle">{t('ai.assistant.cardTransportTitle')}</h4>
<div className="kxp-ai__grouprows">
{transport.slice(0, 3).map((tr) => (
<div key={tr.id} className="kxp-ai__inforow">
<span className="kxp-ai__ic" aria-hidden>
<IconTrain width={18} height={18} />
</span>
<div>
<div className="kxp-ai__infotitle">{tr.lineName || tr.mode}</div>
<div className="kxp-ai__infodesc">
{[tr.route, tr.travelTime].filter(Boolean).join(' · ')}
</div>
</div>
</div>
))}
</div>
</section>
)}
{guide.length > 0 && (
<section className="kxp-ai__group" aria-label={t('ai.assistant.cardGuideTitle')}>
<h4 className="kxp-ai__grouptitle">{t('ai.assistant.cardGuideTitle')}</h4>
<div className="kxp-ai__grouprows">
{guide.slice(0, 3).map((g) => (
<div key={g.id} className="kxp-ai__inforow">
<span className="kxp-ai__ic" aria-hidden>
<IconInfo width={18} height={18} />
</span>
<div>
<div className="kxp-ai__infotitle">{g.title}</div>
{g.summary && <div className="kxp-ai__infodesc">{g.summary}</div>}
</div>
</div>
))}
</div>
</section>
)}
</div>
)}
{/* 후속 질문칩 */}
<div className="kxp-ai__followups" aria-label={t('ai.assistant.followupLabel')}>
{FOLLOWUP_KEYS.map((k) => (
<button
key={k}
type="button"
className="kxp-ai__chip kxp-ai__chip--sm"
onClick={() => onFollowup(t(`ai.assistant.${k}`))}
>
{t(`ai.assistant.${k}`)}
</button>
))}
<a className="kxp-ai__chip kxp-ai__chip--sm" href="#showcase">
{t('ai.assistant.register')} <IconArrowRight width={14} height={14} />
</a>
</div>
{/* 근거 출처 인용 */}
{citations.length > 0 && (
<div className="kxp-ai__cites">
<span className="kxp-ai__citelabel">{t('ai.assistant.citations')}</span>
{citations.slice(0, 6).map((c, i) =>
c.sourceUrl ? (
<a
key={`${c.id}-${i}`}
className="kxp-ai__cite"
href={c.sourceUrl}
target="_blank"
rel="noreferrer noopener"
>
<IconLink width={13} height={13} /> {c.title}
</a>
) : (
<span key={`${c.id}-${i}`} className="kxp-ai__cite">
<IconLink width={13} height={13} /> {c.title}
</span>
),
)}
</div>
)}
{/* AI 고지문 — 제거 불가·상시 노출 */}
<p className="kxp-ai__disclaimer">{t('ai.assistant.disclaimer')}</p>
</div>
);
}

View File

@ -0,0 +1,253 @@
/*
* AI · (SCR-T1 4) / .
* = 12 ( ) + "AI 요약" , = + AI .
* 데이터: publicApi.listMonthlySummary( ) + listCalendar( ) DB .
* AI assistant (degraded). · SVG·kx .
*/
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AiLabel } from '../../components/ui/Badge';
import { publicApi, type MonthlySummary, type PublicCalendarEvent } from './publicApi';
import { formatRange, dday, errorMessage } from './publicFormat';
import { ddayClass } from './trackShared';
import { IconChevronRight, IconCalendar, IconPin } from './publicIcons';
type View = 'year' | 'month';
const NOW = new Date();
export function AiPlanningBriefing() {
const { t } = useTranslation();
const [view, setView] = useState<View>('year');
const [year] = useState(NOW.getFullYear());
const [month, setMonth] = useState(NOW.getMonth() + 1);
const [summary, setSummary] = useState<MonthlySummary[]>([]);
const [sumLoading, setSumLoading] = useState(true);
const [sumErr, setSumErr] = useState('');
const [monthEvents, setMonthEvents] = useState<PublicCalendarEvent[]>([]);
const [mLoading, setMLoading] = useState(false);
const [mErr, setMErr] = useState('');
// 연간 월별 요약 로드(1회).
useEffect(() => {
let alive = true;
setSumLoading(true);
publicApi
.listMonthlySummary(year)
.then((list) => alive && (setSummary(list), setSumErr('')))
.catch((e) => alive && setSumErr(errorMessage(e)))
.finally(() => alive && setSumLoading(false));
return () => {
alive = false;
};
}, [year]);
// 월간 전시 로드(월 변경 시).
useEffect(() => {
if (view !== 'month') return;
let alive = true;
setMLoading(true);
publicApi
.listCalendar({ year, month, limit: 40 })
.then((list) => alive && (setMonthEvents(list), setMErr('')))
.catch((e) => alive && setMErr(errorMessage(e)))
.finally(() => alive && setMLoading(false));
return () => {
alive = false;
};
}, [view, year, month]);
const byMonth = useMemo(() => {
const m = new Map<number, number>();
summary.forEach((s) => {
if (s.eventMonth != null) m.set(s.eventMonth, (m.get(s.eventMonth) ?? 0) + (s.eventCount ?? 0));
});
return m;
}, [summary]);
const maxCount = useMemo(() => Math.max(1, ...Array.from(byMonth.values())), [byMonth]);
const totalCount = useMemo(
() => Array.from(byMonth.values()).reduce((a, b) => a + b, 0),
[byMonth],
);
const peakMonth = useMemo(() => {
let best = 0;
let bestC = -1;
byMonth.forEach((c, mo) => {
if (c > bestC) {
bestC = c;
best = mo;
}
});
return best;
}, [byMonth]);
const monthCount = byMonth.get(month) ?? monthEvents.length;
return (
<section className="kxp-section kxp-section--alt" id="briefing" aria-label={t('track.visitor.briefing.title')}>
<div className="kxp-wrap">
<div className="kxp-section__head kxp-briefing__head">
<div>
<h2 className="kxp-section__title">{t('track.visitor.briefing.title')}</h2>
<p className="kxp-section__sub">{t('track.visitor.briefing.sub')}</p>
</div>
<div className="kxp-briefing__toggle" role="tablist" aria-label={t('track.visitor.briefing.title')}>
<button
type="button"
role="tab"
aria-selected={view === 'year'}
className={`kxp-seg${view === 'year' ? ' is-active' : ''}`}
onClick={() => setView('year')}
>
{t('track.visitor.briefing.viewYear', { year })}
</button>
<button
type="button"
role="tab"
aria-selected={view === 'month'}
className={`kxp-seg${view === 'month' ? ' is-active' : ''}`}
onClick={() => setView('month')}
>
{t('track.visitor.briefing.viewMonth')}
</button>
</div>
</div>
{view === 'year' ? (
<div className="kxp-briefing__year">
{/* AI 요약 헤드라인 */}
<div className="kxp-briefing__ai">
<AiLabel>{t('track.visitor.briefing.aiSummary')}</AiLabel>
<p className="kxp-briefing__aitext">
{sumLoading
? t('track.visitor.briefing.loading')
: sumErr || totalCount === 0
? t('track.visitor.briefing.empty')
: t('track.visitor.briefing.yearSummary', {
year,
count: totalCount,
peak: t('track.visitor.briefing.monthLabel', { month: peakMonth || month }),
})}
</p>
</div>
{/* 12개월 타임라인 */}
{sumLoading ? (
<div className="kxp-briefing__timeline kxp-briefing__timeline--skel" aria-hidden>
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="kxp-briefing__lane">
<span className="kxp-briefing__bar" style={{ height: '30%' }} />
<span className="kxp-briefing__mo">{i + 1}</span>
</div>
))}
</div>
) : (
<div className="kxp-briefing__timeline" role="img" aria-label={t('track.visitor.briefing.title')}>
{Array.from({ length: 12 }).map((_, i) => {
const mo = i + 1;
const c = byMonth.get(mo) ?? 0;
const h = Math.max(6, Math.round((c / maxCount) * 100));
const isPeak = c > 0 && c === maxCount;
return (
<button
key={mo}
type="button"
className={`kxp-briefing__lane${isPeak ? ' is-peak' : ''}`}
onClick={() => {
setMonth(mo);
setView('month');
}}
title={t('track.visitor.briefing.eventsCount', { count: c })}
>
<span className="kxp-briefing__count">{c}</span>
<span className="kxp-briefing__bar" style={{ height: `${h}%` }} />
<span className="kxp-briefing__mo">{mo}</span>
</button>
);
})}
</div>
)}
</div>
) : (
<div className="kxp-briefing__month">
<div className="kxp-briefing__nav">
<button
type="button"
className="kxp-briefing__navbtn"
aria-label={t('track.visitor.briefing.prevMonth')}
onClick={() => setMonth((m) => (m <= 1 ? 12 : m - 1))}
>
<IconChevronRight width={18} height={18} style={{ transform: 'rotate(180deg)' }} />
</button>
<span className="kxp-briefing__navlabel">
{year}. {String(month).padStart(2, '0')}
</span>
<button
type="button"
className="kxp-briefing__navbtn"
aria-label={t('track.visitor.briefing.nextMonth')}
onClick={() => setMonth((m) => (m >= 12 ? 1 : m + 1))}
>
<IconChevronRight width={18} height={18} />
</button>
</div>
<div className="kxp-briefing__ai">
<AiLabel>{t('track.visitor.briefing.aiSummary')}</AiLabel>
<p className="kxp-briefing__aitext">
{mLoading
? t('track.visitor.briefing.loading')
: monthCount === 0
? t('track.visitor.briefing.empty')
: t('track.visitor.briefing.monthSummary', { count: monthCount })}
</p>
</div>
{mLoading ? (
<p className="kxp-empty" role="status">
{t('home.loading')}
</p>
) : mErr ? (
<p className="kxp-empty" role="alert" style={{ color: 'var(--color-error)' }}>
{mErr}
</p>
) : monthEvents.length === 0 ? (
<p className="kxp-empty">{t('track.visitor.briefing.empty')}</p>
) : (
<div className="kxp-briefing__list">
{monthEvents.map((ev) => {
const d = dday(ev.startDate);
const ended = ev.lifecycle === 'ENDED';
return (
<article key={ev.id} className="kxp-briefing__row">
<div className="kxp-briefing__rowmain">
<div className="kxp-briefing__rowname">{ev.name}</div>
<div className="kxp-briefing__rowmeta">
<span>
<IconCalendar width={15} height={15} /> {formatRange(ev.startDate, ev.endDate)}
</span>
{ev.hallLabel && (
<span>
<IconPin width={15} height={15} /> {ev.hallLabel}
</span>
)}
{ev.category && <span className="kxp-tag">{ev.category}</span>}
</div>
</div>
{!ended && d >= 0 && (
<span className={`kxp-dday ${ddayClass(d)}`}>
{d === 0 ? t('home.ddayZero') : t('home.dday', { n: d })}
</span>
)}
</article>
);
})}
</div>
)}
</div>
)}
</div>
</section>
);
}

View File

@ -1,15 +1,23 @@
/*
* SCR-T1 visitor (§3C) `/visitor`, · track="visitor".
* + + (2×3) + ·.
* : /api/public/events() · /api/public/cms/NOTICE() · =.
* SCR-T1 visitor (§3C v2.3.2) `/visitor`, · track="visitor".
* "세련된 AI 관람 도우미 홈페이지" 격상: 히어로 AI () AI
* AI / 3
* (PublicShell).
* (): AI (POST /ai/visitor-assistant)·(calendar+monthly-summary)·
* /(visitor-guide+transport)·(/events)·(/cms/NOTICE).
* Stitch 2 design.md + ( 0).
* Stitch // = "STITCH-REPLACE".
*/
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { PublicShell } from './PublicShell';
import { usePublicEvents, EventGrid } from './trackShared';
import { publicApi, type PublicNotice } from './publicApi';
import { AiAssistant } from './AiAssistant';
import { AiPlanningBriefing } from './AiPlanningBriefing';
import { publicApi, type PublicNotice, type VisitorGuide, type TransportInfo } from './publicApi';
import { errorMessage } from './publicFormat';
import { AiLabel } from '../../components/ui/Badge';
import {
IconTrain,
IconParking,
@ -19,13 +27,31 @@ import {
IconChat,
IconChevronRight,
IconArrowRight,
IconBell,
IconSparkles,
IconTarget,
IconCalendar,
IconRobot,
IconCloud,
IconQr,
IconCheckCircle,
IconTicket,
IconVenue,
} from './publicIcons';
import type { ComponentType, SVGProps } from 'react';
type Ico = ComponentType<SVGProps<SVGSVGElement>>;
/** 방문 가이드 허브 카드(2×3). link 가 내부 경로면 Link, 아니면 앵커. */
/** AI 기능 쇼케이스 타일(4~6) — 정적 능력 메타. `soon`=준비 중 배지. */
const AI_TILES: { key: string; icon: Ico; to: string; soon?: boolean }[] = [
{ key: 'assistant', icon: IconSparkles, to: '#hero' },
{ key: 'recommend', icon: IconTarget, to: '#recommend' },
{ key: 'wayfinding', icon: IconMap, to: '/login' },
{ key: 'schedule', icon: IconCalendar, to: '#briefing' },
{ key: 'crowd', icon: IconCloud, to: '#briefing', soon: true },
{ key: 'render', icon: IconRobot, to: '/business', soon: true },
];
/** 방문 가이드 허브(2×3) 정적 폴백 — 라이브 visitor-guide 부재 시. */
const GUIDE_CARDS: { key: string; icon: Ico; to: string }[] = [
{ key: 'transport', icon: IconTrain, to: '#transport' },
{ key: 'parking', icon: IconParking, to: '#transport' },
@ -35,28 +61,55 @@ const GUIDE_CARDS: { key: string; icon: Ico; to: string }[] = [
{ key: 'inquiry', icon: IconChat, to: '/public/exhibit-inquiry' },
];
function GuideLink({ to, children }: { to: string; children: React.ReactNode }) {
/** 관심사 추천 칩(정적 카테고리 라벨). */
const INTEREST_KEYS = ['robot', 'smartfactory', 'healthcare', 'eco', 'mobility', 'content'] as const;
/** 관람객 여정 3-스텝. */
const JOURNEY: { key: string; icon: Ico; to: string }[] = [
{ key: 'register', icon: IconTicket, to: '#showcase' },
{ key: 'badge', icon: IconQr, to: '/tickets/lookup' },
{ key: 'checkin', icon: IconCheckCircle, to: '/login' },
];
function LinkOrAnchor({ to, className, children }: { to: string; className: string; children: React.ReactNode }) {
if (to.startsWith('#')) {
return (
<a className="kxp-guide__link" href={to}>
<a className={className} href={to}>
{children}
</a>
);
}
return (
<Link className="kxp-guide__link" to={to}>
<Link className={className} to={to}>
{children}
</Link>
);
}
/** 방문가이드 카테고리 → 아이콘 휴리스틱(라이브 카테고리 텍스트 매칭). */
function guideIcon(cat: string | null, title: string | null): Ico {
const s = `${cat ?? ''} ${title ?? ''}`.toLowerCase();
if (/교통|transport|gtx|지하철|버스|subway|bus/.test(s)) return IconTrain;
if (/주차|park/.test(s)) return IconParking;
if (/길찾기|wayfind|지도|map|동선/.test(s)) return IconMap;
if (/접근|access|휠체어|수어/.test(s)) return IconShield;
if (/문의|faq|inquir|연락/.test(s)) return IconChat;
return IconInfo;
}
export function VisitorTrackPage() {
const { t } = useTranslation();
const events = usePublicEvents(8);
const events = usePublicEvents(12);
const [notices, setNotices] = useState<PublicNotice[]>([]);
const [noticeErr, setNoticeErr] = useState('');
const [noticeLoading, setNoticeLoading] = useState(true);
const [guides, setGuides] = useState<VisitorGuide[]>([]);
const [transport, setTransport] = useState<TransportInfo[]>([]);
const [interest, setInterest] = useState<string>('');
useEffect(() => {
let alive = true;
setNoticeLoading(true);
@ -65,55 +118,129 @@ export function VisitorTrackPage() {
.then((list) => alive && (setNotices(list), setNoticeErr('')))
.catch((e) => alive && setNoticeErr(errorMessage(e)))
.finally(() => alive && setNoticeLoading(false));
// 방문가이드·교통(라이브) — 실패해도 정적 폴백으로 화면 유지(빈 화면 금지).
publicApi
.listVisitorGuide()
.then((list) => alive && setGuides(list))
.catch(() => alive && setGuides([]));
publicApi
.listTransport()
.then((list) => alive && setTransport(list))
.catch(() => alive && setTransport([]));
return () => {
alive = false;
};
}, []);
const useLiveGuides = guides.length > 0;
return (
<PublicShell active="visit" track="visitor" cta={t('track.visitor.ctaPreReg')}>
{/* 관람 히어로 + 이중 CTA */}
<section className="kxp-hero" aria-label={t('track.visitor.heroAria')}>
<div
className="kxp-hero__bg"
style={{ background: 'linear-gradient(135deg, #0b2a4a 0%, #0066b3 70%, #2f8fd6 130%)' }}
aria-hidden
/>
<div className="kxp-hero__scrim" aria-hidden />
<div className="kxp-hero__inner">
<div className="kxp-hero__content">
<span className="kxp-hero__eyebrow">
<IconBell width={16} height={16} /> {t('track.visitor.eyebrow')}
</span>
<h1 className="kxp-hero__title">{t('track.visitor.title')}</h1>
<p className="kxp-hero__lead">{t('track.visitor.subtitle')}</p>
<div className="kxp-hero__actions">
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#events">
{t('track.visitor.ctaPreReg')}
</a>
<Link className="kxp-btn kxp-btn--ghost kxp-btn--lg" to="/tickets/lookup">
{t('track.visitor.ctaTicket')}
</Link>
</div>
{/* ── 1. 풀블리드 히어로 + AI 관람 도우미(중심) ── STITCH-REPLACE:hero ── */}
<section className="kxp-vhero" id="hero" aria-label={t('track.visitor.heroAria')}>
<div className="kxp-vhero__bg" aria-hidden />
<div className="kxp-vhero__scrim" aria-hidden />
<span className="kxp-vhero__wm" aria-hidden>
{t('track.visitor.aiWatermark')}
</span>
<div className="kxp-vhero__inner">
<h1 className="kxp-vhero__title">{t('track.visitor.hero.title')}</h1>
<p className="kxp-vhero__lead">{t('track.visitor.hero.subtitle')}</p>
<AiAssistant />
<div className="kxp-vhero__cta">
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#showcase">
{t('track.visitor.ctaPreReg')}
</a>
<Link className="kxp-btn kxp-btn--ghost kxp-btn--lg" to="/tickets/lookup">
{t('track.visitor.ctaTicket')}
</Link>
</div>
</div>
</section>
{/* 행사 일정 그리드 */}
<section className="kxp-section" id="events" aria-label={t('track.visitor.eventsAria')}>
{/* ── 2. AI 기능 쇼케이스 타일 ── STITCH-REPLACE:tiles ── */}
<section className="kxp-section" id="ai-tiles" aria-label={t('track.visitor.aiTiles.title')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.visitor.eventsTitle')}</h2>
<p className="kxp-section__sub">{t('track.visitor.eventsSub')}</p>
<h2 className="kxp-section__title">{t('track.visitor.aiTiles.title')}</h2>
<p className="kxp-section__sub">{t('track.visitor.aiTiles.sub')}</p>
</div>
</div>
<div className="kxp-aitiles">
{AI_TILES.map(({ key, icon: Icon, to, soon }) => (
<LinkOrAnchor key={key} to={to} className="kxp-aitile">
<span className="kxp-aitile__top">
<AiLabel>{t('ai.assistant.label')}</AiLabel>
{soon && <span className="kxp-aitile__soon">{t('track.visitor.aiTiles.soon')}</span>}
</span>
<span className="kxp-aitile__ic" aria-hidden>
<Icon width={24} height={24} />
</span>
<span className="kxp-aitile__title">{t(`track.visitor.aiTiles.${key}.title`)}</span>
<span className="kxp-aitile__desc">{t(`track.visitor.aiTiles.${key}.desc`)}</span>
<span className="kxp-aitile__go" aria-hidden>
<IconArrowRight width={16} height={16} />
</span>
</LinkOrAnchor>
))}
</div>
</div>
</section>
{/* ── 3. AI 전시·행사 기획 브리핑(연/월) ── */}
<AiPlanningBriefing />
{/* ── 4. 진행 중·예정 전시 쇼케이스 ── */}
<section className="kxp-section" id="showcase" aria-label={t('track.visitor.eventsAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.visitor.showcaseTitle')}</h2>
<p className="kxp-section__sub">{t('track.visitor.showcaseSub')}</p>
</div>
</div>
<EventGrid state={events} />
</div>
</section>
{/* 방문 가이드 허브 2×3 */}
<section className="kxp-section kxp-section--alt" id="guide" aria-label={t('track.visitor.guideAria')}>
{/* ── 5. 관심사 맞춤 추천 레일(AI) ── */}
<section className="kxp-section kxp-section--alt" id="recommend" aria-label={t('track.visitor.recommendTitle')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">
{t('track.visitor.recommendTitle')}{' '}
<span className="kxp-inlineai">
<AiLabel>{t('track.visitor.recommendTag')}</AiLabel>
</span>
</h2>
<p className="kxp-section__sub">{t('track.visitor.recommendSub')}</p>
</div>
</div>
<div className="kxp-vinterests" role="group" aria-label={t('track.visitor.recommendTitle')}>
{INTEREST_KEYS.map((k) => (
<button
key={k}
type="button"
className={`kxp-ai__chip${interest === k ? ' is-active' : ''}`}
aria-pressed={interest === k}
onClick={() => setInterest((cur) => (cur === k ? '' : k))}
>
{t(`track.visitor.interests.${k}`)}
</button>
))}
</div>
<EventGrid state={events} />
</div>
</section>
{/* ── 6. 방문 가이드 허브(2×3, 라이브 우선·정적 폴백) ── */}
<section className="kxp-section" id="guide" aria-label={t('track.visitor.guideAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
@ -121,26 +248,102 @@ export function VisitorTrackPage() {
<p className="kxp-section__sub">{t('track.visitor.guideSub')}</p>
</div>
</div>
<div className="kxp-guidegrid" id="transport">
{GUIDE_CARDS.map(({ key, icon: Icon, to }) => (
<article key={key} className="kxp-guide">
<span className="kxp-guide__ic" aria-hidden>
<Icon width={22} height={22} />
</span>
<div>
<div className="kxp-guide__title">{t(`track.visitor.guide.${key}.title`)}</div>
<p className="kxp-guide__desc">{t(`track.visitor.guide.${key}.desc`)}</p>
<GuideLink to={to}>
{t(`track.visitor.guide.${key}.link`)} <IconChevronRight width={15} height={15} />
</GuideLink>
{/* 교통 요약 스트립(라이브 transport) — 없으면 자동 숨김 */}
{transport.length > 0 && (
<div className="kxp-transtrip" id="transport" aria-label={t('track.visitor.guide.transport.title')}>
{transport.slice(0, 5).map((tr) => (
<div key={tr.id} className="kxp-transtrip__item">
<span className="kxp-transtrip__ic" aria-hidden>
<IconTrain width={18} height={18} />
</span>
<div>
<div className="kxp-transtrip__name">{tr.lineName || tr.mode}</div>
<div className="kxp-transtrip__desc">
{[tr.route, tr.travelTime].filter(Boolean).join(' · ')}
</div>
</div>
</div>
</article>
))}
))}
</div>
)}
<div className="kxp-guidegrid">
{useLiveGuides
? guides.slice(0, 6).map((g) => {
const Icon = guideIcon(g.category, g.title);
return (
<article key={g.id} className="kxp-guide">
<span className="kxp-guide__ic" aria-hidden>
<Icon width={22} height={22} />
</span>
<div>
<div className="kxp-guide__title">{g.title}</div>
<p className="kxp-guide__desc">{g.summary || g.body || ''}</p>
{g.sourceUrl && (
<a className="kxp-guide__link" href={g.sourceUrl} target="_blank" rel="noreferrer noopener">
{t('track.visitor.guideMore')} <IconChevronRight width={15} height={15} />
</a>
)}
</div>
</article>
);
})
: GUIDE_CARDS.map(({ key, icon: Icon, to }) => (
<article key={key} className="kxp-guide">
<span className="kxp-guide__ic" aria-hidden>
<Icon width={22} height={22} />
</span>
<div>
<div className="kxp-guide__title">{t(`track.visitor.guide.${key}.title`)}</div>
<p className="kxp-guide__desc">{t(`track.visitor.guide.${key}.desc`)}</p>
<LinkOrAnchor to={to} className="kxp-guide__link">
{t(`track.visitor.guide.${key}.link`)} <IconChevronRight width={15} height={15} />
</LinkOrAnchor>
</div>
</article>
))}
</div>
</div>
</section>
{/* 공지·알림마당 */}
{/* ── 7. 관람객 여정 3-스텝 ── */}
<section className="kxp-section kxp-section--alt" id="journey" aria-label={t('track.visitor.journeyTitle')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.visitor.journeyTitle')}</h2>
<p className="kxp-section__sub">{t('track.visitor.journeySub')}</p>
</div>
</div>
<ol className="kxp-journey">
{JOURNEY.map(({ key, icon: Icon, to }, i) => (
<li key={key} className="kxp-journey__step">
<span className="kxp-journey__num" aria-hidden>
{i + 1}
</span>
<span className="kxp-journey__ic" aria-hidden>
<Icon width={22} height={22} />
</span>
<div className="kxp-journey__body">
<div className="kxp-journey__title">{t(`track.visitor.journey.${key}.title`)}</div>
<p className="kxp-journey__desc">{t(`track.visitor.journey.${key}.desc`)}</p>
<LinkOrAnchor to={to} className="kxp-guide__link">
{t(`track.visitor.journey.${key}.link`)} <IconChevronRight width={15} height={15} />
</LinkOrAnchor>
</div>
{i < JOURNEY.length - 1 && (
<span className="kxp-journey__arrow" aria-hidden>
<IconArrowRight width={18} height={18} />
</span>
)}
</li>
))}
</ol>
</div>
</section>
{/* ── 8. 공지·뉴스 ── */}
<section className="kxp-section" id="notices" aria-label={t('track.visitor.noticeAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
@ -175,6 +378,31 @@ export function VisitorTrackPage() {
)}
</div>
</section>
{/* ── 9. 전시장 소개 밴드 ── */}
<section className="kxp-section kxp-section--alt" id="venue" aria-label={t('track.visitor.venueTitle')}>
<div className="kxp-wrap">
<div className="kxp-venue">
<div className="kxp-venue__intro">
<span className="kxp-venue__ic" aria-hidden>
<IconVenue width={28} height={28} />
</span>
<div>
<h2 className="kxp-section__title">{t('track.visitor.venueTitle')}</h2>
<p className="kxp-section__sub">{t('track.visitor.venueSub')}</p>
</div>
</div>
<dl className="kxp-venue__stats">
{(['halls', 'area', 'parking', 'access'] as const).map((k) => (
<div key={k} className="kxp-venue__stat">
<dt>{t(`track.visitor.venue.${k}.label`)}</dt>
<dd className="kxp-tabnum">{t(`track.visitor.venue.${k}.value`)}</dd>
</div>
))}
</dl>
</div>
</div>
</section>
</PublicShell>
);
}

View File

@ -2792,10 +2792,10 @@
/* ===================================================================
* §3C 3-트랙 관문·서브홈 (SCR-T0~T3) 상단 스위처 + 트랙 페이지 헬퍼
* 신규 토큰 0 (기존 --color-*/--radius-*/--kxp-maxw 재사용). WCAG AA.
* 신규 토큰 0 (기존 color·radius·kxp-maxw 변수 재사용). WCAG AA.
* =================================================================== */
/* -- 상단 3-트랙 스위처 바 (전 SCR-T*/SCR-P* 공통 크롬) -- */
/* -- 상단 3-트랙 스위처 바 (전 SCR-T·SCR-P 공통 크롬) -- */
.kxp-topbar {
position: sticky;
top: 0;
@ -3210,3 +3210,794 @@
font-weight: 800;
color: var(--color-primary-800, #003a66);
}
/*
SCR-T1 visitor 메인 v2.3.2 AI 관람 도우미 홈페이지 격상 (신규 토큰 0)
*/
/* ── 1. 풀블리드 히어로 + AI 도우미 ── */
.kxp-vhero {
position: relative;
isolation: isolate;
padding: 56px 20px 64px;
overflow: hidden;
}
.kxp-vhero__bg {
position: absolute;
inset: 0;
z-index: -2;
background:
radial-gradient(1200px 600px at 80% -10%, rgba(109, 74, 255, 0.35) 0%, transparent 60%),
linear-gradient(135deg, #0b2a4a 0%, #0066b3 60%, #2f8fd6 120%);
}
.kxp-vhero__scrim {
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(180deg, rgba(11, 20, 32, 0.35) 0%, rgba(11, 20, 32, 0.55) 100%);
}
.kxp-vhero__wm {
position: absolute;
right: 16px;
bottom: 12px;
font-size: 11px;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.55);
z-index: 0;
pointer-events: none;
}
.kxp-vhero__inner {
max-width: 860px;
margin: 0 auto;
text-align: center;
color: var(--color-white);
}
.kxp-vhero__title {
font-size: clamp(28px, 5.2vw, 46px);
font-weight: 800;
line-height: 1.18;
letter-spacing: -0.025em;
margin: 8px 0 12px;
color: var(--color-white);
}
.kxp-vhero__lead {
font-size: clamp(15px, 2.2vw, 19px);
color: rgba(255, 255, 255, 0.9);
margin: 0 0 28px;
}
.kxp-vhero__cta {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-top: 24px;
}
@media (min-width: 768px) {
.kxp-vhero {
padding: 88px 24px 96px;
}
}
/* ── AI 관람 도우미(공용) ── */
.kxp-ai {
max-width: 760px;
margin: 0 auto;
text-align: left;
}
.kxp-ai__bar {
display: flex;
align-items: center;
gap: 8px;
background: var(--color-white);
border: 1px solid var(--color-neutral-200);
border-radius: 999px;
padding: 8px 8px 8px 14px;
box-shadow: var(--shadow-level2);
}
.kxp-ai__badge {
flex: none;
display: inline-flex;
}
.kxp-ai__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font-size: 16px;
color: var(--color-neutral-900);
padding: 8px 4px;
}
.kxp-ai__input::placeholder {
color: var(--color-neutral-500);
}
.kxp-ai__send {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 0;
border-radius: 999px;
background: var(--color-ai-accent);
color: var(--color-white);
cursor: pointer;
transition: background 0.15s ease;
}
.kxp-ai__send:hover:not(:disabled) {
background: var(--color-ai-accent-strong);
}
.kxp-ai__send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.kxp-ai__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
justify-content: center;
}
.kxp-ai__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 7px 13px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.14);
color: var(--color-white);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.kxp-ai__chip:hover {
background: rgba(255, 255, 255, 0.26);
}
.kxp-ai__chip--sm {
padding: 5px 11px;
font-size: 12px;
border-color: var(--color-neutral-200);
background: var(--color-neutral-050);
color: var(--color-primary-700);
}
.kxp-ai__chip--sm:hover {
background: var(--color-primary-050);
}
/* 답변 패널 — 라이트 카드(대비 확보) */
.kxp-ai__panel {
margin-top: 16px;
}
.kxp-ai__thinking {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: var(--radius-lg);
background: var(--color-white);
color: var(--color-neutral-700);
font-size: 14px;
font-weight: 600;
box-shadow: var(--shadow-level2);
}
.kxp-ai__dots {
display: inline-flex;
gap: 4px;
}
.kxp-ai__dots i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-ai-accent);
animation: kxpDot 1s infinite ease-in-out;
}
.kxp-ai__dots i:nth-child(2) {
animation-delay: 0.15s;
}
.kxp-ai__dots i:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes kxpDot {
0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-3px); }
}
.kxp-ai__answer {
text-align: left;
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-level2);
padding: 18px 20px;
color: var(--color-neutral-800, #182230);
}
.kxp-ai__answer--error {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.kxp-ai__answerhead {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.kxp-ai__degraded {
font-size: 12px;
font-weight: 600;
color: var(--color-warning);
background: var(--color-warning-bg);
padding: 3px 9px;
border-radius: 999px;
}
.kxp-ai__text {
font-size: 15px;
line-height: 1.65;
color: var(--color-neutral-900);
margin: 0 0 12px;
white-space: pre-line;
}
.kxp-ai__muted {
color: var(--color-neutral-500);
}
.kxp-ai__cards {
display: grid;
gap: 14px;
margin: 6px 0 14px;
}
.kxp-ai__group {
border-top: 1px dashed var(--color-neutral-200);
padding-top: 12px;
}
.kxp-ai__grouptitle {
font-size: 13px;
font-weight: 700;
color: var(--color-neutral-700);
margin: 0 0 10px;
}
.kxp-ai__grouprows {
display: grid;
gap: 8px;
}
.kxp-ai__evcard {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border: var(--border-card);
border-radius: var(--radius-lg);
}
.kxp-ai__evposter {
flex: none;
width: 44px;
height: 60px;
border-radius: 6px;
overflow: hidden;
background: linear-gradient(135deg, #0066b3 0%, #004c86 100%);
display: flex;
align-items: center;
justify-content: center;
}
.kxp-ai__evposter img {
width: 100%;
height: 100%;
object-fit: cover;
}
.kxp-ai__evfallback {
color: var(--color-white);
font-weight: 800;
font-size: 15px;
}
.kxp-ai__evbody {
flex: 1 1 auto;
min-width: 0;
}
.kxp-ai__evname {
font-weight: 700;
font-size: 14px;
color: var(--color-neutral-900);
margin-bottom: 4px;
}
.kxp-ai__evmeta,
.kxp-ai__infodesc {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
font-size: 12.5px;
color: var(--color-neutral-500);
font-variant-numeric: tabular-nums;
}
.kxp-ai__evmeta span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.kxp-ai__inforow {
display: flex;
align-items: flex-start;
gap: 10px;
}
.kxp-ai__ic {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: var(--color-primary-050);
color: var(--color-primary-700);
}
.kxp-ai__infotitle {
font-weight: 700;
font-size: 14px;
color: var(--color-neutral-900);
}
.kxp-ai__followups {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 6px 0 12px;
}
.kxp-ai__cites {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding-top: 10px;
border-top: 1px solid var(--color-neutral-100);
}
.kxp-ai__citelabel {
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-500);
}
.kxp-ai__cite {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--color-primary-700);
background: var(--color-primary-050);
padding: 3px 9px;
border-radius: 999px;
}
a.kxp-ai__cite:hover {
text-decoration: underline;
}
.kxp-ai__disclaimer {
margin: 12px 0 0;
font-size: 11.5px;
color: var(--color-neutral-500);
}
/* ── 2. AI 기능 쇼케이스 타일 ── */
.kxp-aitiles {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
@media (min-width: 640px) {
.kxp-aitiles {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.kxp-aitiles {
grid-template-columns: repeat(3, 1fr);
}
}
.kxp-aitile {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
background: var(--color-ai-surface);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-lg);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.kxp-aitile:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-level2);
}
.kxp-aitile__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.kxp-aitile__soon {
font-size: 11px;
font-weight: 700;
color: var(--color-neutral-500);
background: var(--color-neutral-100);
padding: 2px 8px;
border-radius: 999px;
}
.kxp-aitile__ic {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 10px;
background: var(--color-white);
color: var(--color-ai-accent);
margin-top: 4px;
}
.kxp-aitile__title {
font-weight: 800;
font-size: 16px;
color: var(--color-neutral-900);
}
.kxp-aitile__desc {
font-size: 13.5px;
color: var(--color-neutral-500);
line-height: 1.5;
}
.kxp-aitile__go {
position: absolute;
top: 18px;
right: 18px;
color: var(--color-ai-accent);
opacity: 0;
transition: opacity 0.15s ease;
}
.kxp-aitile:hover .kxp-aitile__go {
opacity: 1;
}
/* ── 3. AI 기획 브리핑 ── */
.kxp-briefing__head {
align-items: center;
}
.kxp-briefing__toggle {
display: inline-flex;
gap: 4px;
padding: 4px;
background: var(--color-neutral-100);
border-radius: 999px;
}
.kxp-seg {
border: 0;
background: transparent;
padding: 8px 18px;
border-radius: 999px;
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-500);
cursor: pointer;
}
.kxp-seg.is-active {
background: var(--color-white);
color: var(--color-primary-700);
box-shadow: var(--shadow-level2);
}
.kxp-briefing__ai {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 14px 16px;
background: var(--color-ai-surface);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-lg);
margin-bottom: 18px;
}
.kxp-briefing__aitext {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--color-neutral-900);
line-height: 1.55;
}
.kxp-briefing__timeline {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 6px;
align-items: end;
height: 180px;
padding: 12px;
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
}
.kxp-briefing__lane {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 6px;
height: 100%;
border: 0;
background: transparent;
cursor: pointer;
}
.kxp-briefing__count {
font-size: 11px;
font-weight: 700;
color: var(--color-neutral-500);
font-variant-numeric: tabular-nums;
}
.kxp-briefing__bar {
width: 60%;
min-height: 6px;
border-radius: 6px 6px 0 0;
background: var(--color-primary-600);
transition: background 0.15s ease;
}
.kxp-briefing__lane:hover .kxp-briefing__bar {
background: var(--color-primary-700);
}
.kxp-briefing__lane.is-peak .kxp-briefing__bar {
background: var(--color-ai-accent);
}
.kxp-briefing__mo {
font-size: 11px;
color: var(--color-neutral-500);
font-variant-numeric: tabular-nums;
}
.kxp-briefing__timeline--skel .kxp-briefing__bar {
background: var(--color-neutral-100);
}
.kxp-briefing__nav {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.kxp-briefing__navbtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: var(--border-card);
border-radius: 8px;
background: var(--color-white);
color: var(--color-neutral-700);
cursor: pointer;
}
.kxp-briefing__navbtn:hover {
border-color: var(--color-primary-600);
color: var(--color-primary-600);
}
.kxp-briefing__navlabel {
font-size: 18px;
font-weight: 800;
color: var(--color-neutral-900);
font-variant-numeric: tabular-nums;
}
.kxp-briefing__list {
display: grid;
gap: 8px;
}
.kxp-briefing__row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
}
.kxp-briefing__rowmain {
flex: 1 1 auto;
min-width: 0;
}
.kxp-briefing__rowname {
font-weight: 700;
font-size: 15px;
color: var(--color-neutral-900);
margin-bottom: 5px;
}
.kxp-briefing__rowmeta {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
align-items: center;
font-size: 13px;
color: var(--color-neutral-500);
font-variant-numeric: tabular-nums;
}
.kxp-briefing__rowmeta span {
display: inline-flex;
align-items: center;
gap: 5px;
}
/* ── 5. 관심사 추천 칩 ── */
.kxp-vinterests {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
}
.kxp-vinterests .kxp-ai__chip {
border-color: var(--color-neutral-200);
background: var(--color-white);
color: var(--color-neutral-700);
}
.kxp-vinterests .kxp-ai__chip.is-active {
border-color: var(--color-ai-accent);
background: var(--color-ai-surface);
color: var(--color-ai-accent);
}
.kxp-inlineai {
display: inline-flex;
vertical-align: middle;
}
/* ── 6. 교통 스트립 ── */
.kxp-transtrip {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
margin-bottom: 20px;
}
@media (min-width: 640px) {
.kxp-transtrip {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.kxp-transtrip {
grid-template-columns: repeat(3, 1fr);
}
}
.kxp-transtrip__item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
background: var(--color-primary-050);
border-radius: var(--radius-lg);
}
.kxp-transtrip__ic {
flex: none;
display: inline-flex;
color: var(--color-primary-700);
}
.kxp-transtrip__name {
font-weight: 700;
font-size: 14px;
color: var(--color-neutral-900);
}
.kxp-transtrip__desc {
font-size: 12.5px;
color: var(--color-neutral-500);
font-variant-numeric: tabular-nums;
}
/* ── 7. 여정 3-스텝 ── */
.kxp-journey {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
@media (min-width: 768px) {
.kxp-journey {
grid-template-columns: repeat(3, 1fr);
}
}
.kxp-journey__step {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 22px;
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
}
.kxp-journey__num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-primary-600);
color: var(--color-white);
font-weight: 800;
font-size: 14px;
}
.kxp-journey__ic {
display: inline-flex;
color: var(--color-primary-700);
}
.kxp-journey__title {
font-weight: 800;
font-size: 16px;
color: var(--color-neutral-900);
}
.kxp-journey__desc {
font-size: 13.5px;
color: var(--color-neutral-500);
line-height: 1.5;
margin: 0;
}
.kxp-journey__arrow {
display: none;
}
@media (min-width: 768px) {
.kxp-journey__arrow {
display: inline-flex;
position: absolute;
right: -17px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
color: var(--color-neutral-300, #d0d5dd);
}
}
/* ── 9. 전시장 소개 밴드 ── */
.kxp-venue {
display: grid;
grid-template-columns: 1fr;
gap: 24px;
align-items: center;
}
@media (min-width: 900px) {
.kxp-venue {
grid-template-columns: 1fr 1.4fr;
}
}
.kxp-venue__intro {
display: flex;
gap: 14px;
align-items: flex-start;
}
.kxp-venue__ic {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 12px;
background: var(--color-primary-050);
color: var(--color-primary-700);
}
.kxp-venue__stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin: 0;
}
@media (min-width: 640px) {
.kxp-venue__stats {
grid-template-columns: repeat(4, 1fr);
}
}
.kxp-venue__stat {
padding: 16px;
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
text-align: center;
}
.kxp-venue__stat dt {
font-size: 12.5px;
color: var(--color-neutral-500);
margin-bottom: 6px;
}
.kxp-venue__stat dd {
margin: 0;
font-size: 20px;
font-weight: 800;
color: var(--color-primary-700);
}
.kxp-tabnum {
font-variant-numeric: tabular-nums;
}

View File

@ -49,6 +49,76 @@ export interface PublicNotice {
createdAt?: string | null;
}
// ── 관람 안내 · AI 도우미 (백엔드 PublicGuideDtos / VisitorAssistantDtos, V43 라이브) ──
/** 연/월 캘린더 행사 카드 (v_event_calendar). lifecycle ∈ UPCOMING|ONGOING|ENDED|UNKNOWN. */
export interface PublicCalendarEvent {
id: string;
name: string;
category: string | null;
hallLabel: string | null;
posterUrl: string | null;
startDate: string | null;
endDate: string | null;
eventYear: number | null;
eventMonth: number | null;
lifecycle: string | null;
}
/** 월별 전시 건수 요약 (v_event_monthly_summary) — 연간 기획 요약 근거. */
export interface MonthlySummary {
eventYear: number | null;
eventMonth: number | null;
eventCount: number | null;
}
/** 관람객 방문가이드 항목 (visitor_guide). */
export interface VisitorGuide {
id: string;
category: string | null;
title: string | null;
body: string | null;
summary: string | null;
displayOrder: number | null;
sourceUrl: string | null;
}
/** 교통수단 구조화 항목 (transport_info). */
export interface TransportInfo {
id: string;
mode: string | null;
lineName: string | null;
route: string | null;
fromPoint: string | null;
travelTime: string | null;
note: string | null;
displayOrder: number | null;
sourceUrl: string | null;
}
/** AI 관람 도우미 인용 출처 — type ∈ EVENT|TRANSPORT|GUIDE. */
export interface AssistantCitation {
type: string;
id: string;
title: string;
sourceUrl: string | null;
}
/**
* AI (POST /api/public/ai/visitor-assistant).
* grounded=false DB ( "찾지 못함"). provider claude|ollama|search|none.
*/
export interface AssistantResult {
question: string;
answer: string;
events: PublicCalendarEvent[];
transport: TransportInfo[];
guide: VisitorGuide[];
citations: AssistantCitation[];
grounded: boolean;
provider: string;
degraded: boolean;
}
// ── 문의 접수 (백엔드 InquiryRequest / InquiryReceiptDto) ────────────────────
export interface InquiryPayload {
inquiryType?: string;
@ -134,4 +204,36 @@ export const publicApi = {
/** M17 공개 공지(게시분) — 최근순. 백엔드 미배포 시 에러(빈/안내로 처리). */
listNotices: (limit = 5) =>
api.get<PublicNotice[]>(`/api/public/cms/NOTICE${qs({ limit: String(limit) })}`, opt),
// ── 관람 안내 · AI 도우미 (V43 라이브) ──────────────────────────────────────
/** AI 관람 도우미 — 자연어 질문을 크롤 적재 DB 근거로 답변(비인증). */
askVisitorAssistant: (question: string) =>
api.post<AssistantResult>('/api/public/ai/visitor-assistant', { question }, opt),
/** 연/월/상태 캘린더(v_event_calendar). lifecycle ∈ UPCOMING|ONGOING|ENDED. */
listCalendar: (params: { year?: number; month?: number; lifecycle?: string; limit?: number } = {}) =>
api.get<PublicCalendarEvent[]>(
`/api/public/events/calendar${qs({
year: params.year != null ? String(params.year) : undefined,
month: params.month != null ? String(params.month) : undefined,
lifecycle: params.lifecycle,
limit: params.limit != null ? String(params.limit) : undefined,
})}`,
opt,
),
/** 월별 전시 건수 집계(v_event_monthly_summary) — 연간 기획 요약. */
listMonthlySummary: (year?: number) =>
api.get<MonthlySummary[]>(
`/api/public/events/monthly-summary${qs({ year: year != null ? String(year) : undefined })}`,
opt,
),
/** 방문가이드(교통/주차/입장/편의/접근성/개요). */
listVisitorGuide: (category?: string) =>
api.get<VisitorGuide[]>(`/api/public/visitor-guide${qs({ category })}`, opt),
/** 교통수단 구조화(SUBWAY/GTX/BUS/CAR/AIRPORT/KTX). */
listTransport: (mode?: string) =>
api.get<TransportInfo[]>(`/api/public/transport${qs({ mode })}`, opt),
};