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:
parent
433eb83a16
commit
214b6df1ef
40
CLAUDE.md
40
CLAUDE.md
@ -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 이식).
|
||||
|
||||
@ -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)·우선순위·나노바나나 파이프라인 로직 불변** — 스택 표기 정합화만 수행 |
|
||||
|
||||
131
docs/analysis/mobile-benchmark-exhibition-performance.md
Normal file
131
docs/analysis/mobile-benchmark-exhibition-performance.md
Normal 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). 별점폭탄 아티팩트 해석 주의 명기 |
|
||||
@ -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 후속. |
|
||||
|
||||
373
docs/architecture/sso-hr-integration.md
Normal file
373
docs/architecture/sso-hr-integration.md
Normal 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 교차참조 |
|
||||
@ -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
|
||||
|
||||
@ -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.05–09.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 Jan–Dec with colored event bars/dots grouped by category and highlighted peak seasons), with a prominent purple "AI 요약" headline card reading "2026년은 상반기 스마트팩토리·로봇, 하반기 헬스케어·친환경 전시가 축을 이룹니다" and 3–5 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.05–09.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` |
|
||||
|
||||
138
docs/security/mobile-security-audit.md
Normal file
138
docs/security/mobile-security-audit.md
Normal 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` 통과 확인.
|
||||
85
docs/security/mobile-security-checklist.md
Normal file
85
docs/security/mobile-security-checklist.md
Normal 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**
|
||||
@ -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, 푸시 알림.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}>
|
||||
{/* 내 정보 진입 — 프로필/생체인식 잠금/사진 등록 (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 },
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
688
mobile/app/profile.tsx
Normal 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 },
|
||||
});
|
||||
@ -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);
|
||||
|
||||
BIN
mobile/assets/adaptive-icon.png
Normal file
BIN
mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
mobile/assets/favicon.png
Normal file
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
BIN
mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
mobile/assets/splash.png
Normal file
BIN
mobile/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
72
mobile/components/Avatar.tsx
Normal file
72
mobile/components/Avatar.tsx
Normal 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' },
|
||||
});
|
||||
101
mobile/context/SecureScreenContext.tsx
Normal file
101
mobile/context/SecureScreenContext.tsx
Normal 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' },
|
||||
});
|
||||
@ -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
92
mobile/lib/biometric.ts
Normal 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
99
mobile/lib/imagePick.ts
Normal 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
143
mobile/lib/integrity.ts
Normal 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
81
mobile/lib/profile.ts
Normal 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: '' };
|
||||
}
|
||||
44
mobile/lib/screenCapture.ts
Normal file
44
mobile/lib/screenCapture.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
56
mobile/package-lock.json
generated
56
mobile/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
"무결성 검증기 미구성(온프레미스) — 서버 검증 골격만 동작합니다. 접근은 차단하지 않습니다.");
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.zioinfo.kintex.auth.profile.dto;
|
||||
|
||||
/** 아바타 업로드 응답 — 서빙 URL. 모바일은 캐시버스터(?_t=...)를 부착해 사용한다. */
|
||||
public record PhotoUploadResponse(String photoUrl) {
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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(Claude→Ollama) 미가용 시 검색 요약으로 자동 폴백한다. 외부 실시간 크롤 호출은 하지 않는다.
|
||||
*/
|
||||
@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()));
|
||||
}
|
||||
}
|
||||
@ -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}(Claude→Ollama)로
|
||||
* <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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
15
src/backend/src/main/resources/application-prod.yml
Normal file
15
src/backend/src/main/resources/application-prod.yml
Normal 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}
|
||||
@ -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와 합의한 계약
|
||||
|
||||
2705
src/backend/src/main/resources/db/migration/V42__bulk_demo_seed.sql
Normal file
2705
src/backend/src/main/resources/db/migration/V42__bulk_demo_seed.sql
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 '월별 전시 건수 집계 — 연간 기획 요약(월별 개최 수) 근거.';
|
||||
@ -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;
|
||||
@ -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)';
|
||||
@ -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>
|
||||
|
||||
@ -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/"));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
BIN
src/frontend/public/favicon.ico
Normal file
BIN
src/frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
src/frontend/public/favicon.png
Normal file
BIN
src/frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@ -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 />} />
|
||||
|
||||
61
src/frontend/src/api/auctionApi.ts
Normal file
61
src/frontend/src/api/auctionApi.ts
Normal 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,
|
||||
};
|
||||
161
src/frontend/src/api/auctionTypes.ts
Normal file
161
src/frontend/src/api/auctionTypes.ts
Normal 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;
|
||||
}
|
||||
@ -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?"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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": "すでに登録業者ですか?"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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": "이미 등록된 업체이신가요?"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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": "已是注册企业?"
|
||||
}
|
||||
}
|
||||
}
|
||||
303
src/frontend/src/screens/public/AiAssistant.tsx
Normal file
303
src/frontend/src/screens/public/AiAssistant.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
src/frontend/src/screens/public/AiPlanningBriefing.tsx
Normal file
253
src/frontend/src/screens/public/AiPlanningBriefing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,30 +118,41 @@ 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')}
|
||||
{/* ── 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>
|
||||
<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">
|
||||
<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">
|
||||
@ -96,24 +160,87 @@ export function VisitorTrackPage() {
|
||||
</Link>
|
||||
</div>
|
||||
</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,8 +248,48 @@ 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 }) => (
|
||||
|
||||
{/* 교통 요약 스트립(라이브 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>
|
||||
))}
|
||||
</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} />
|
||||
@ -130,9 +297,9 @@ export function VisitorTrackPage() {
|
||||
<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}>
|
||||
<LinkOrAnchor to={to} className="kxp-guide__link">
|
||||
{t(`track.visitor.guide.${key}.link`)} <IconChevronRight width={15} height={15} />
|
||||
</GuideLink>
|
||||
</LinkOrAnchor>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@ -140,7 +307,43 @@ export function VisitorTrackPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user