Compare commits
No commits in common. "23284189909b9fa0c03e9bbbaf83ee31fdef5dba" and "397b5de9854c94663a8d76653a884424b8bca495" have entirely different histories.
2328418990
...
397b5de985
@ -84,7 +84,6 @@ GUARDiA 표준 프레임워크(Spring Boot 3.5 + React 19 + MyBatis + PostgreSQL
|
|||||||
| 2026-07-11 | WISE 웹 기능 레퍼런스 강화 — kintex-frontend-dev에 uiws frontend(pages 공통 업무기능) 차용 원칙, 오케스트레이터에 웹 기능 화면 WISE 패턴·Stitch 디자인 경유·모바일 경계 명시 + 로스터에 kintex-mobile-dev(경계) 추가 | kintex-frontend-dev·kintex-impl-orchestrator | "웹도 wise 기능 참고" 요청 |
|
| 2026-07-11 | WISE 웹 기능 레퍼런스 강화 — kintex-frontend-dev에 uiws frontend(pages 공통 업무기능) 차용 원칙, 오케스트레이터에 웹 기능 화면 WISE 패턴·Stitch 디자인 경유·모바일 경계 명시 + 로스터에 kintex-mobile-dev(경계) 추가 | kintex-frontend-dev·kintex-impl-orchestrator | "웹도 wise 기능 참고" 요청 |
|
||||||
| 2026-07-12 | **세션 대량 구현·배포** — visitor 메인(AI 관람 도우미 홈·기능타일·연월 브리핑, 근거=크롤 적재 DB)·3-트랙 관문/서브홈·역할별 랜딩+메뉴 게이트(plan_role_routing)·공개 AI/캘린더 API(V43)·정산 데이터 정렬(V47)·포스터 이미지 빌드내장(V46)·성능 인덱스(V44)·아바타 API(V45). AI 토큰최소화 기획(PLANNING v3.4 §8A)·데이터표준(FK최소화·공통코드·view/mview·배치, data.md)·Open SSO+HR 연동 설계(sso-hr-integration.md). 배포 커밋 214b6df→3077cb0, health 게이트 통과 | 전체 | 소유자 라이브 피드백 연속 반영. **전 지시 로그: `docs/OWNER_FEEDBACK.md`(권위)** |
|
| 2026-07-12 | **세션 대량 구현·배포** — visitor 메인(AI 관람 도우미 홈·기능타일·연월 브리핑, 근거=크롤 적재 DB)·3-트랙 관문/서브홈·역할별 랜딩+메뉴 게이트(plan_role_routing)·공개 AI/캘린더 API(V43)·정산 데이터 정렬(V47)·포스터 이미지 빌드내장(V46)·성능 인덱스(V44)·아바타 API(V45). AI 토큰최소화 기획(PLANNING v3.4 §8A)·데이터표준(FK최소화·공통코드·view/mview·배치, data.md)·Open SSO+HR 연동 설계(sso-hr-integration.md). 배포 커밋 214b6df→3077cb0, health 게이트 통과 | 전체 | 소유자 라이브 피드백 연속 반영. **전 지시 로그: `docs/OWNER_FEEDBACK.md`(권위)** |
|
||||||
| 2026-07-12 | **선행 게이트 해소·검증 교훈** — G1 나노바나나 Gemini 라이브·G2 kintex.zioinfo.co.kr:8021 CI/CD 라이브. 배포 전 필수: **Flyway 마이그레이션 라이브 dry-run(BEGIN…ROLLBACK)**·시크릿 fail-fast 프로파일 확인·파일단위 커밋(공유 트리 교차 방지)·health 게이트. Stitch 불안정 시 design.md 스펙 직접 구현(문서화 폴백) | 전체·kintex-devops-dev | 다수 배포 롤백/캐시/교차커밋 사고 후 표준화 |
|
| 2026-07-12 | **선행 게이트 해소·검증 교훈** — G1 나노바나나 Gemini 라이브·G2 kintex.zioinfo.co.kr:8021 CI/CD 라이브. 배포 전 필수: **Flyway 마이그레이션 라이브 dry-run(BEGIN…ROLLBACK)**·시크릿 fail-fast 프로파일 확인·파일단위 커밋(공유 트리 교차 방지)·health 게이트. Stitch 불안정 시 design.md 스펙 직접 구현(문서화 폴백) | 전체·kintex-devops-dev | 다수 배포 롤백/캐시/교차커밋 사고 후 표준화 |
|
||||||
| 2026-07-23 | **시연 준비 대량 웨이브** — 시연 15항목+공개사이트 7항목+백로그 전부 구현 지시. 완료: UI 배치1(2FA 로고·대시보드 짤림/세로글자 근본수정·전체보기·달력 split·그리드 폰트 토큰 스윕)·테스트데이터 V68~V73(참가업체24·옥션10·물류 도크 H1 신설·스폰서십 이행·BI 다연도)·CMS hero_url(V75)+로그인 슬라이드 10장. 진행: UI 배치2(플로어플랜 DWG 디폴트/팔레트/확대팝업/AI배치 UX·홀 평면도·뱃지편집·스폰서십)·공개사이트(루트→/visitor·CI_01 로고·행사/참가/관람/교통·검색 10ch·버튼 흰글자)·트랙 히어로 5장 로테이션(신규 크롤)·Jasper grid-pdf(WISE 패턴)·옥션 WS 순위·주차/혼잡/sectors·CMS 잔여(예약게시·EDM·구독알림·라이브공지)·모바일 EAS 빌드→QR. 정책: **Nifty/Stitch 엄수 재강조(강피드백)**·타이틀 선 아이콘 스윕·**배포/전달은 zioinfo:hermes 경유**. 지시 로그: OWNER_FEEDBACK.md 세션 6 | 전체 | 소유자 시연 준비 지시(2026-07-23) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -98,4 +98,3 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 2026-07-11 | 백로그 v2.0 최초 재작성 |
|
| 2026-07-11 | 백로그 v2.0 최초 재작성 |
|
||||||
| 2026-07-11 | Phase F 신설 — 티켓·관람객 벤치마킹 유래 항목 14개(ⓑ 보강 6 F-B1~B6 · ⓒ 신규 8 F-C1~C8) 추가. 근거 `docs/analysis/ticketing-app-benchmark.md`. 기존 항목 무수정 |
|
| 2026-07-11 | Phase F 신설 — 티켓·관람객 벤치마킹 유래 항목 14개(ⓑ 보강 6 F-B1~B6 · ⓒ 신규 8 F-C1~C8) 추가. 근거 `docs/analysis/ticketing-app-benchmark.md`. 기존 항목 무수정 |
|
||||||
| 2026-07-23 | **Phase F 전량 착수(소유자 "백로그 전부 구현" 지시)** — F-B1~B6·F-C1~C8 웨이브 편성: 백엔드 선행(F-C1·B6=CMS/알림 에이전트, F-C2·C3=백엔드 에이전트, mock 어댑터로 외부 게이트 준수) → 관람객 풀스택(F-B1·B2·B4·B5·C4~C8) → 모바일 반영 후 재빌드. 진행 로그: OWNER_FEEDBACK.md 세션 6 |
|
|
||||||
|
|||||||
@ -364,47 +364,3 @@ K2(H6~H10) 트렌치 실측 완료 — 65채널·941포인트(H6 9채널·H7/H8/
|
|||||||
### 세션 5 추가 — K1 밴드↔홀 번호 확증·교정 (2026-07-16, 소유자 지시 "K1 밴드↔홀 번호 확증 진행해")
|
### 세션 5 추가 — K1 밴드↔홀 번호 확증·교정 (2026-07-16, 소유자 지시 "K1 밴드↔홀 번호 확증 진행해")
|
||||||
|
|
||||||
전기 회로 라벨 832건 전수로 **band n = 홀 (6-n) 확정**(기존 가정 역방향 — H1↔H5·H2↔H4 뒤바뀜, 홀3만 우연 일치). V67로 K1 실측 시드 전면 재배정 + DWG 탭 PNG 스왑 + 생성기 5종 매핑 정본화. JPG 거울상 특징(노치·출입구)과도 일치. 게이트 통과 → push→배포.
|
전기 회로 라벨 832건 전수로 **band n = 홀 (6-n) 확정**(기존 가정 역방향 — H1↔H5·H2↔H4 뒤바뀜, 홀3만 우연 일치). V67로 K1 실측 시드 전면 재배정 + DWG 탭 PNG 스왑 + 생성기 5종 매핑 정본화. JPG 거울상 특징(노치·출입구)과도 일치. 게이트 통과 → push→배포.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 세션 6 — 시스템 시연 준비 (2026-07-23)
|
|
||||||
|
|
||||||
**소유자 지시 (전량 접수·에이전트 팀 병렬 처리):**
|
|
||||||
|
|
||||||
| # | 지시 | 처리 | 상태 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | 2차 인증 아이콘 → KINTEX 로고(크기 조절) | OtpChallengePanel·OtpSetupPage 로고 교체(.kx-otp__logo 32px) | ✅ 배치1 |
|
|
||||||
| 2 | 대시보드 진행단계 짤림·참가업체 현황 데이터·알림 세로글자·10줄+전체보기 | 타임라인 minmax·피드 minmax(0,1fr) 근본수정·전체보기(N) 버튼·V68 참가업체 24사 | ✅ 배치1+V68 |
|
|
||||||
| 3 | 참가업체관리 폰트 통일(회사명 과대) | .kx-exh__company --fs-body 고정 | ✅ 배치1 |
|
|
||||||
| 4 | 전시 일정 오른쪽 달력 깨짐·짤림 | split minmax(0,1fr)+minmax(280,340) 정렬 | ✅ 배치1 |
|
|
||||||
| 5 | 홀 현장운영 면적 비중 이상·평면도(DWG) 표출 | UI 배치2 진행 | 🔷 |
|
|
||||||
| 6 | 플로어플랜: DWG 디폴트·부스 팔레트·평면도 확대 팝업·AI 자동배치(팝업 시각 우선→배치안은 버튼) | UI 배치2 진행 | 🔷 |
|
|
||||||
| 7 | 공사·장치 옥션 테스트데이터 | V69 옥션 10건·응찰 25·낙찰 6 | ✅ |
|
|
||||||
| 8 | 수주 부스·옥션 참여·알림 피드 테스트데이터 | V69 (수주 7·응찰 9·피드 파생 16) | ✅ |
|
|
||||||
| 9 | 물류 허브 슬롯 테스트데이터 | V70 (근본원인: 도크 H7 전용→H1 신설, 2주 슬롯 20) | ✅ |
|
|
||||||
| 10 | 관람객 뱃지편집 안됨 | UI 배치2 진행 | 🔷 |
|
|
||||||
| 11 | 스폰서십 전체일정보기 안됨·스마트오토메이션 이행 데이터 | V71 이행 8항목+파이프라인 3 / 화면은 배치2 | ✅데이터·🔷화면 |
|
|
||||||
| 12 | CMS 웹크롤링 이미지 (특히 메인페이지) | cms_content.hero_url 신설+11장 연결(V75)·메인 채움 진행 | ✅1차·🔷메인 |
|
|
||||||
| 13 | 경영분석 4종 차트 테스트데이터 | V68·V72·V73 (업종별은 백엔드 sectors 집계 구현 병행) | ✅데이터·🔷sectors |
|
|
||||||
| 14 | 킨텍스 전시 앱 다운로드 — 앱 빌드 | EAS 클라우드 빌드→개발/운영 APK 재주입→QR 매니페스트 (G3 기승인) | 🔷 빌드 중 |
|
|
||||||
| 15 | 로그인 슬라이드용 이미지 10장 크롤→소스 | public/login-slides/login-slide-01~10.jpg (kintex.com 자산, ≤500KB) | ✅ |
|
|
||||||
| 공통 | 그리드 폰트·크기 동일 | .kx-table 토큰 전 화면 스윕(위반 1건 토큰화) | ✅ 배치1 |
|
|
||||||
| 공통 | 수정 후 push→CI/CD·모바일 개발→운영→QR | 통합 게이트 후 일괄 | ⏳ |
|
|
||||||
|
|
||||||
**공개사이트 추가 지시 (2026-07-23):**
|
|
||||||
|
|
||||||
| # | 지시 | 처리 | 상태 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| P1 | kintex.wise.ai.kr 루트 디폴트 → /visitor | 공개 배치 진행 (★기존 '루트=제품소개' 원칙 대체) | 🔷 |
|
|
||||||
| P2 | /visitor·/business·/agency 로고 → ci/CI_JPG/CI_01.jpg | 공개 배치 진행 | 🔷 |
|
|
||||||
| P3 | 행사·참가안내·관람안내·교통 기능 안됨 | 공개 배치 — 페이지/배선 판정 후 구현 | 🔷 |
|
|
||||||
| P4 | 행사검색 로고 옆 배치·입력폭 10자 | 공개 배치 진행 | 🔷 |
|
|
||||||
| P5 | 유색 배경 버튼 글자 흰색 | 공개 배치 전 버튼 스윕 | 🔷 |
|
|
||||||
| P6 | 그리드에 항상 PDF출력 버튼 = JasperReports | 범용 grid-pdf 백엔드(WISE Jasper 패턴)+프론트 공용 버튼 | 🔷 |
|
|
||||||
| P7 | 트랙 3페이지 메인이미지 5장 로테이션 — **이미지는 신규 크롤로** | 크롤 에이전트 진행 (트랙별 셋 차별화) | 🔷 |
|
|
||||||
|
|
||||||
**전역 지시:** ①"백로그 전부 구현할 것" — Phase F 14건 + UNDEVELOPED_BACKLOG 잔여(WS 순위·예약게시·EDM·구독알림·sectors·SCR-16 실전환·멀티테넌시 2단계·MDI 셸·아이콘 교체·NFR 스윕) 웨이브 착수. ②"타이틀 앞에 선으로 그린 아이콘" — 전 화면 페이지 타이틀 선 SVG 아이콘 스윕(UI 스윕 웨이브). ③**"제발 스티치 좀 이용해라. 아니면 NIFTY 컴포넌트 스타일로 변경"(강한 피드백)** — 전 프론트 에이전트에 Nifty 문자 그대로/Stitch 산출물 우선 이식 의무화(메모리 갱신). ④"필요한 MD파일 수정·추가·업데이트" — 본 로그·CLAUDE.md·백로그 2종 갱신, PLANNING/design은 planner/designer 경유.
|
|
||||||
|
|
||||||
**추가 지시(2026-07-23):** "개발은 헤르메스 에이전트 사용" — 배포/전달 단계(commit·push→CI/CD→배포 검증→릴리즈 노트→알림)는 zioinfo:hermes(전령) 에이전트 경유로 수행.
|
|
||||||
|
|
||||||
**세션 6 결과(2026-07-24):** 시연 15항목·공개사이트 7항목·백로그 웨이브 전체 구현 완료(상세: UNDEVELOPED_BACKLOG 결과표·_workspace/impl_* 요약 12종). 로고 CI_01 확정·전수 통일(웹 BrandLogo 규격 header28/auth32/footer22/print38·모바일 로그인/스플래시). 게이트: 백엔드 전체 테스트·프론트 tsc/build·i18n 4로케일 대조 0 누락·마이그레이션 V68~V106 중복 0. 잔여: 커스텀 그리드 22 PDF 배선(마감 중)→통합 게이트→헤르메스 push/배포→모바일 v0.2.2 빌드·QR.
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# 킨텍스 AI 전시·행사시스템 (KINTEX AI Exhibition & Event System) 기획서
|
# 킨텍스 AI 전시·행사시스템 (KINTEX AI Exhibition & Event System) 기획서
|
||||||
|
|
||||||
> 작성: 기획 에이전트(planner) · 작성일: 2026-07-11 · 최종 갱신: 2026-07-23 · 버전: **v3.6**
|
> 작성: 기획 에이전트(planner) · 작성일: 2026-07-11 · 최종 갱신: 2026-07-14 · 버전: **v3.5**
|
||||||
> **제품 공식 명칭(소유자 확정 2026-07-12): "KINTEX AI 전시·행사시스템"** — 기존 "자동전시시스템(Exhibition Automation Platform)"을 대체한다. 전시(exhibition)뿐 아니라 행사·이벤트(event) 전반을 포괄한다는 의미를 명칭에 반영. 본 명칭이 정본이며, 이하 본문·3-트랙 기획·타 문서 후속 반영 시 이 명칭을 사용한다(기존 "자동전시시스템" 표기는 문맥 보존을 위해 삭제하지 않고 잔존하나, 신규 표기는 정본 명칭을 따른다).
|
> **제품 공식 명칭(소유자 확정 2026-07-12): "KINTEX AI 전시·행사시스템"** — 기존 "자동전시시스템(Exhibition Automation Platform)"을 대체한다. 전시(exhibition)뿐 아니라 행사·이벤트(event) 전반을 포괄한다는 의미를 명칭에 반영. 본 명칭이 정본이며, 이하 본문·3-트랙 기획·타 문서 후속 반영 시 이 명칭을 사용한다(기존 "자동전시시스템" 표기는 문맥 보존을 위해 삭제하지 않고 잔존하나, 신규 표기는 정본 명칭을 따른다).
|
||||||
> 근거 문서: `docs/analysis/kintex-website.md` (킨텍스 웹사이트 25개 페이지 + 전시주최자매뉴얼 PDF + 참가업체 매뉴얼 PDF 전수 분석, 2026-07-11)
|
> 근거 문서: `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-트랙 재구성 확정 근거
|
> 근거 문서: `docs/analysis/coex-website.md` (코엑스 VISITOR/BUSINESS/CYBER 3-사이트 IA 분석 + 킨텍스 대비표, 2026-07-12) — §2-2 대외 접점 3-트랙 재구성 확정 근거
|
||||||
@ -13,7 +13,6 @@
|
|||||||
> **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.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 후속).
|
> **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 후속).
|
||||||
> **v3.5 M2 자동 배치 이지 플로우(3단계 위저드)**: 소유자 지시(2026-07-14) — "사용자가 쉽게" 원칙으로 M2 부스 자동 배치 UX를 재구성. **§M2-1 신설**: ①평면도 안 생성·표시(홀 도면 JPG 캘리브레이션 좌표계) ②평면도 트리 선택(전시장>홀>구역, `hall_zone` V60) + 구역 경계 내 패킹 ③S7 조감 나노바나나 **버튼 클릭 시에만 생성**(자동 발행 금지 — §6-3 S7 트리거 개정) ④생성 이미지 확대 팝업·다운로드·`render_job` 영속·렌더 갤러리 재열람·옥션 참고 이미지 재사용 ⑤3단계 위저드(어디에→어떻게→3안 비교·선택) + `apply-option` 실제 저장(현행 프론트 미배선 결함 명시). 기존 M2 본문(3안 생성·선택/병합·규정 검증)·타 절 보존, UX 계약만 순증.
|
> **v3.5 M2 자동 배치 이지 플로우(3단계 위저드)**: 소유자 지시(2026-07-14) — "사용자가 쉽게" 원칙으로 M2 부스 자동 배치 UX를 재구성. **§M2-1 신설**: ①평면도 안 생성·표시(홀 도면 JPG 캘리브레이션 좌표계) ②평면도 트리 선택(전시장>홀>구역, `hall_zone` V60) + 구역 경계 내 패킹 ③S7 조감 나노바나나 **버튼 클릭 시에만 생성**(자동 발행 금지 — §6-3 S7 트리거 개정) ④생성 이미지 확대 팝업·다운로드·`render_job` 영속·렌더 갤러리 재열람·옥션 참고 이미지 재사용 ⑤3단계 위저드(어디에→어떻게→3안 비교·선택) + `apply-option` 실제 저장(현행 프론트 미배선 결함 명시). 기존 M2 본문(3안 생성·선택/병합·규정 검증)·타 절 보존, UX 계약만 순증.
|
||||||
> **v3.6 세션 6 소유자 확정(2026-07-23)**: 공개사이트 진입·브랜딩·정보 페이지·검색, 범용 그리드 PDF 출력, Phase F(벤치마킹 유래 14건) 전량 착수 및 UNDEVELOPED 잔여, UI 표준 재확정, 배포·전달 헤르메스 경유를 확정한다. 기존 스코프(M1~M18·§1A·§2 3-트랙·§2-3 랜딩 규칙·§5B 공통레이어·§6 나노바나나·§8 스택·화면 SCR-*·딥 라우트)를 **전부 보존**하고 정책·절만 순증한다 — **§2-4 신설**(루트 디폴트=/visitor·정식 CI 로고·트랙 히어로 크롤 5장·정보 페이지 4종·행사검색·UI 표준), **§2-3-3 순서0 갱신**, **M12 갱신**, **§5B-2 범용 그리드 PDF 표준**, **§9 Phase F 신설**. 근거·진행 로그 = `docs/OWNER_FEEDBACK.md` 세션 6. src·design.md 미수정(designer/frontend/도메인 에이전트 후속).
|
|
||||||
> **문서 소유권**: 본 PLANNING.md는 planner만 수정한다. design.md·타 문서는 이 문서 변경 시 "designer/planner 후속 반영 필요"로만 표기하고 직접 수정하지 않는다.
|
> **문서 소유권**: 본 PLANNING.md는 planner만 수정한다. design.md·타 문서는 이 문서 변경 시 "designer/planner 후속 반영 필요"로만 표기하고 직접 수정하지 않는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -291,7 +290,7 @@
|
|||||||
|
|
||||||
| 순서 | 상태·조건(신호) | 트랙 | 메인페이지 | 라우트(권고·안 B) | 화면 |
|
| 순서 | 상태·조건(신호) | 트랙 | 메인페이지 | 라우트(권고·안 B) | 화면 |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| 0 | **미인증(토큰 없음)** | visitor | 관람객 서브홈 | `/` → **`/visitor` 디폴트(v3.6, §2-4-1 — 기존 '제품 소개 랜딩' 대체)** | SCR-T1(visitor 서브홈)·SCR-T0 |
|
| 0 | **미인증(토큰 없음)** | visitor | 공개 관람 랜딩 | `/` (현행 `/login` 리다이렉트 → visitor 랜딩으로) | **SCR-T0(신규)** |
|
||||||
| 1 | `globalRole=ADMIN` | 내부-admin | 관리 메인(**또는** 비즈니스 메인+시스템관리) | `/admin`(또는 `/home`) | SCR-16(+SCR-T2) |
|
| 1 | `globalRole=ADMIN` | 내부-admin | 관리 메인(**또는** 비즈니스 메인+시스템관리) | `/admin`(또는 `/home`) | SCR-16(+SCR-T2) |
|
||||||
| 2 | `globalRole=MANAGER` OR HALL_MANAGER ws | 내부-ops | 운영 메인(비즈니스 메인 운영 변형) | `/home` | SCR-T2(운영 변형) |
|
| 2 | `globalRole=MANAGER` OR HALL_MANAGER ws | 내부-ops | 운영 메인(비즈니스 메인 운영 변형) | `/home` | SCR-T2(운영 변형) |
|
||||||
| 3 | ORGANIZER/EXHIBITOR ws | business | 비즈니스 메인 | `/home` | **SCR-T2(신규)** |
|
| 3 | ORGANIZER/EXHIBITOR ws | business | 비즈니스 메인 | `/home` | **SCR-T2(신규)** |
|
||||||
@ -365,38 +364,6 @@ design.md §2-2 모바일 하단 탭 4개(홈/내 부스·승인/알림/더보
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2-4. 공개사이트 진입·브랜딩·정보 페이지·검색 정책 (v3.6, 소유자 확정 2026-07-23)
|
|
||||||
|
|
||||||
> **소유자 확정(2026-07-23)**: 공개 사이트(`kintex.wise.ai.kr` 대외 접점)의 루트 진입·브랜딩·정보 페이지·검색을 아래로 확정한다. 기존 §2-2 3-트랙·§2-3 랜딩 규칙·M12 공개 홍보 사이트는 **보존**하고, 그 위에 진입 디폴트·정보 페이지 정식화·검색 배치·UI 표준만 순증·정밀화한다. src·design.md 미수정(frontend/designer 후속).
|
|
||||||
|
|
||||||
### 2-4-1. 루트 진입 디폴트 = /visitor + 공개 셸 브랜딩
|
|
||||||
|
|
||||||
- **미인증 루트(`/`) 디폴트 = visitor 트랙(관람객 서브홈 `/visitor`)** 으로 확정한다. 이는 기존 "미인증 루트 = 제품 소개 히어로 랜딩"(v3.3 §2-3-3 순서0 · MEMORY [[product-identity-first]]) 원칙을 **대체**한다 — 관람객이 절대다수라는 코엑스식 판단(§2-2-3 A)의 실행. 제품 소개(About) 콘텐츠는 삭제하지 않고 별도 정보 페이지·business 서브홈으로 이관.
|
|
||||||
- **공개 셸 로고 = 킨텍스 정식 CI**: visitor/business/agency 3-트랙 공개 셸 로고를 정식 CI 자산(`ci/CI_JPG/CI_01.jpg`)으로 통일(임시 텍스트·플레이스홀더 로고 대체).
|
|
||||||
- **트랙 3페이지 히어로 = 크롤 실이미지 5장 로테이션**: visitor·business·agency 각 서브홈 히어로를 킨텍스 자산 크롤 실이미지 5장 로테이션으로 구성(트랙별 이미지 셋 차별화). 이미지 원천은 **신규 크롤**(kintex.com 공개 자산, 용량·라이선스 준수) — 데이터 소스는 §7-2/크롤 파이프라인(benchmark-crawler 트랙).
|
|
||||||
|
|
||||||
### 2-4-2. 공개 정보 페이지 4종 (공개 사이트 정식 기능)
|
|
||||||
|
|
||||||
공개 사이트에 아래 4종 정보 페이지를 **정식 기능**으로 확정한다(M12 공개 홍보 사이트 하위, 비로그인 접근, DB 콘텐츠 렌더 — 신규 LLM 호출 없음, §8A 정합).
|
|
||||||
|
|
||||||
| 페이지 | 내용 | 데이터 소스 |
|
|
||||||
|---|---|---|
|
|
||||||
| **행사** | 전시·행사 일정·상세(연월 캘린더·포스터·상세) | 공개 행사 API(`v_event_calendar` 등) · CMS 콘텐츠 |
|
|
||||||
| **참가안내** | 주최자 대관·참가업체 참가 절차·요금·서류 안내 | CMS 콘텐츠 + M1 요율 마스터 |
|
|
||||||
| **관람안내** | 관람 시간·편의시설·접근성·유의사항 | `visitor_guide` 시드(V43) · CMS |
|
|
||||||
| **교통** | 오시는 길(GTX-A 킨텍스역·주차 iparking·대중교통) | `transport` 시드(V43) · CMS |
|
|
||||||
|
|
||||||
### 2-4-3. 행사검색 — 공개 셸 로고 옆 상시 검색
|
|
||||||
|
|
||||||
- 공개 셸 상단 **로고 바로 옆에 상시 노출 행사검색 입력**(입력폭 ≈10자)을 배치한다. 통합검색(§5B-2 search)의 **공개 스코프(행사·전시 대상)** 를 재사용하며 비로그인 접근 가능.
|
|
||||||
|
|
||||||
### 2-4-4. UI 표준 재확정 (강피드백 2026-07-23)
|
|
||||||
|
|
||||||
- **전 화면 Nifty 컴포넌트 / Stitch 산출물 기준**(문자 그대로 이식·자체 재해석 금지 — MEMORY [[nifty-design-system]]·[[design-always-via-stitch]]·[[ui-follow-wise-strictly]]).
|
|
||||||
- **페이지 타이틀 앞 선(stroke) SVG 아이콘 표준**: 전 화면 페이지 타이틀 좌측에 선으로 그린 SVG 아이콘(`fill:none`·stroke, 이모지 금지)을 표준으로 둔다. — **design.md/designer 후속 반영**(planner는 정책만 확정).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 현행(As-Is) 프로세스 분석
|
## 3. 현행(As-Is) 프로세스 분석
|
||||||
|
|
||||||
### 3-1. 주최자 타임라인과 병목
|
### 3-1. 주최자 타임라인과 병목
|
||||||
@ -745,7 +712,6 @@ sequenceDiagram
|
|||||||
### M12. 마케팅·EDM·공개 홍보 사이트(일반 대중) — **P1**
|
### M12. 마케팅·EDM·공개 홍보 사이트(일반 대중) — **P1**
|
||||||
|
|
||||||
- **현재**: 킨텍스 공식 사이트는 정보·서식 다운로드 중심, 참가업체 전용 GNB 없음.
|
- **현재**: 킨텍스 공식 사이트는 정보·서식 다운로드 중심, 참가업체 전용 GNB 없음.
|
||||||
- **공개 정보 페이지·검색 (v3.6 확정, §2-4)**: **행사·참가안내·관람안내·교통 4종 정보 페이지**를 공개 사이트 정식 기능으로 제공(데이터 = 공개 행사 API·CMS·`visitor_guide`/`transport` 시드, 비로그인). **공개 셸 로고 옆 상시 행사검색**(입력폭 ≈10자, §5B-2 search 공개 스코프 재사용). 루트 진입 디폴트 = `/visitor` · 공개 셸 로고 = 정식 CI(`ci/CI_JPG/CI_01.jpg`) · 트랙 히어로 = 크롤 실이미지 5장 로테이션(§2-4-1).
|
|
||||||
- **AI 자동화 후**: (1) **공개 홍보 사이트**(불특정 다수 대상) — 행사 소개·일정·교통·사전등록 유도, **SEO·다국어(한/영/중/일)** 최적화, 공개 인터랙티브 플로어플랜(M2 데이터 부산물). (2) **EDM/마케팅 자동화** — 세그먼트별(사전등록자·과거 관람객·바이어) 캠페인, 리마인더, 리드 팔로업(M10). AI 카피 초안·이미지(나노바나나 부스 예상샷 활용) 지원. 콘텐츠는 M17 CMS로 관리.
|
- **AI 자동화 후**: (1) **공개 홍보 사이트**(불특정 다수 대상) — 행사 소개·일정·교통·사전등록 유도, **SEO·다국어(한/영/중/일)** 최적화, 공개 인터랙티브 플로어플랜(M2 데이터 부산물). (2) **EDM/마케팅 자동화** — 세그먼트별(사전등록자·과거 관람객·바이어) 캠페인, 리마인더, 리드 팔로업(M10). AI 카피 초안·이미지(나노바나나 부스 예상샷 활용) 지원. 콘텐츠는 M17 CMS로 관리.
|
||||||
- **기대 효과**: 관람객 유치·재방문, 참가업체 노출, 킨텍스 브랜드 채널 통합.
|
- **기대 효과**: 관람객 유치·재방문, 참가업체 노출, 킨텍스 브랜드 채널 통합.
|
||||||
- **한계**: 발송 규정(정보통신망법·수신동의) 준수, 다국어 번역 품질 검수 필요.
|
- **한계**: 발송 규정(정보통신망법·수신동의) 준수, 다국어 번역 품질 검수 필요.
|
||||||
@ -859,8 +825,6 @@ UIWS 표준 공통 모듈을 이식해 전 포털이 공유. 킨텍스 도메인
|
|||||||
| notification(알림센터) | 통합 알림 | 마감 리마인더(D-데이)·낙찰·승인·결제 알림, WebSocket |
|
| notification(알림센터) | 통합 알림 | 마감 리마인더(D-데이)·낙찰·승인·결제 알림, WebSocket |
|
||||||
| preference(개인화) | 즐겨찾기·최근방문 | 역할별 대시보드 개인화 |
|
| preference(개인화) | 즐겨찾기·최근방문 | 역할별 대시보드 개인화 |
|
||||||
|
|
||||||
> **범용 그리드 PDF 출력 (v3.6, 소유자 확정 2026-07-23)**: 전 그리드(목록/표) 화면에 **JasperReports 기반 PDF 출력 버튼을 표준**으로 둔다 — WISE Jasper 패턴, 범용 계약 **`POST /api/reports/grid-pdf`**(그리드 컬럼·필터·정렬·데이터를 받아 서버에서 PDF 생성). 공통 `report` 모듈이 소유하며 각 도메인 그리드는 공용 버튼·계약만 배선(중복 구현 금지). 기존 도메인 리포트(회의록·업무보고·정산·M16 BI)와 별개의 **목록 스냅샷 출력 표준**이다.
|
|
||||||
|
|
||||||
> **경계(중복 제거)**: 경영·수익 지표는 **M16 BI**가 권위, 일상 업무보고·통계는 공통 `report/stats`가 담당. 콘텐츠/공지 발행은 **M17 CMS**가 권위, 사내 알림성 공지는 공통 `notice`. 알림 발송 채널은 공통 `notification` 단일화(M10·M12·M15가 이벤트 발행).
|
> **경계(중복 제거)**: 경영·수익 지표는 **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` 경유).
|
> **공통 AI 보조(§8A 정합)**: 전 업무 화면의 AI 보조(search·meeting 액션아이템·report 요약·초안 생성 등)는 **§8A 단일 AI 계약(`/ai/ask`)·사용성 표준·토큰 최소화 원칙**을 공유한다 — 사실조회는 결정론/DB 직답(LLM 미호출), 종합은 소형모델 우선·Claude 승급(`AiTextRouter`)·RAG 발췌·캐시·구조화 출력. 신규 LLM 직접 호출 경로 신설 금지(중앙 `/ai/ask` 경유).
|
||||||
@ -1307,22 +1271,6 @@ graph TB
|
|||||||
| 범위 | M8(반입/반출 슬롯·QR 통행증), kxwp 정식 API 연동(협의 성사 시), 정밀 조도 시뮬레이션, 다국어(영·중·일) 규정 챗봇, 관람객 플로어플랜 공개, 회의실·리깅 구조 사전 체크 확장 |
|
| 범위 | M8(반입/반출 슬롯·QR 통행증), kxwp 정식 API 연동(협의 성사 시), 정밀 조도 시뮬레이션, 다국어(영·중·일) 규정 챗봇, 관람객 플로어플랜 공개, 회의실·리깅 구조 사전 체크 확장 |
|
||||||
| 산출물 | 현장 모바일 운영 도구, 제3전시장(2028) 대비 홀 마스터 확장 구조 |
|
| 산출물 | 현장 모바일 운영 도구, 제3전시장(2028) 대비 홀 마스터 확장 구조 |
|
||||||
|
|
||||||
### Phase F — 벤치마킹 유래 + UNDEVELOPED 잔여 전량 착수 (v3.6, 소유자 확정 2026-07-23)
|
|
||||||
|
|
||||||
> 소유자 지시(2026-07-23) — "백로그 전부 구현". 벤치마킹 유래 14건(F-B1~B6·F-C1~C8)과 UNDEVELOPED 잔여를 웨이브로 **전량 착수**한다. 상세 항목·수용기준은 `docs/BACKLOG.md`·`docs/UNDEVELOPED_BACKLOG.md`(권위), 진행 로그는 `docs/OWNER_FEEDBACK.md` 세션 6.
|
|
||||||
|
|
||||||
| 그룹 | 범위 | 비고 |
|
|
||||||
|---|---|---|
|
|
||||||
| **F-B1~B6 · F-C1~C8** (벤치마킹 유래 14건) | 전시·공연 앱/공개사이트 벤치마킹에서 도출된 기능 전량 | 외부 연동(PG 결제·iparking 주차 등)은 **mock 어댑터 게이트 방식** — 실연동 전까지 mock 어댑터로 개발·시연, 실키/실계약 확보 시 어댑터만 교체(§7-2·코드 경로 불변) |
|
|
||||||
| **UNDEVELOPED 잔여** | M15 옥션 **WebSocket 실시간 순위** · M17 **예약 게시** · **EDM 발송**(SMTP 기승인·화이트리스트 dry-run) · **티켓 오픈 구독 알림** · **라이브 공지** · 경영분석 **업종별(sectors) 집계**(백엔드 sectors 집계) · 관람객 **주차/혼잡 API** | AI 원칙(§8A)·알림 채널 공통화(§5B-2 notification)·데이터마트(M16-1) 정합 |
|
|
||||||
|
|
||||||
- **외부 연동 mock 게이트 원칙**: 결제(PG)·주차(iparking) 등 외부 서비스는 개발·시연 단계에서 **mock 어댑터**로 구현하되, 오버셀 차단·조건부 재고차감 등 **서버 권위 로직은 실동작**시킨다. 실연동은 소유자 승인·실계약 후 어댑터만 교체(코드 경로 불변).
|
|
||||||
- **EDM 발송**: SMTP 채널은 소유자 기승인(MEMORY [[smtp-email-approval]]), 실발송은 **화이트리스트 dry-run**(테스트 대상 한정) 후 운영 실발송(이중 확인) — §5B-2 notification·M12 EDM 정합.
|
|
||||||
|
|
||||||
### 배포·전달 절차 표준 (v3.6, 소유자 확정 2026-07-23)
|
|
||||||
|
|
||||||
- **배포/전달 단계는 헤르메스(전령) 에이전트 경유**: commit·push → CI/CD → 배포 검증(산출물 mtime·health 게이트, MEMORY [[deploy-gate-artifact-mtime]]) → 릴리즈 노트 → 알림을 `zioinfo:hermes` 에이전트가 수행한다. 개발은 각 `kintex-*` 에이전트, **전달은 hermes 단일 창구**로 일원화한다.
|
|
||||||
|
|
||||||
각 Phase 종료 시 reviewer 에이전트 교차 검증(기획-디자인-구현 정합성) — CLAUDE.md 워크플로 준수.
|
각 Phase 종료 시 reviewer 에이전트 교차 검증(기획-디자인-구현 정합성) — CLAUDE.md 워크플로 준수.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -1350,7 +1298,6 @@ graph TB
|
|||||||
|
|
||||||
| 버전 | 일자 | 작성자 | 내용 |
|
| 버전 | 일자 | 작성자 | 내용 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **v3.6** | 2026-07-23 | planner | **세션 6 소유자 확정 반영(2026-07-23) — 공개사이트 진입/브랜딩/정보페이지/검색·범용 그리드 PDF·Phase F 전량 착수·UNDEVELOPED 잔여·UI 표준·헤르메스 배포.** 기존 스코프·화면·라우트 전부 보존, 정책·절만 순증. ①헤더 v3.6 라인 + 버전 상향(최종 갱신 2026-07-23) ②**§2-4 신설** — (2-4-1) 미인증 루트 디폴트 = `/visitor`(기존 '제품 소개 랜딩'·MEMORY [[product-identity-first]] **대체**)·공개 셸 로고 = 정식 CI(`ci/CI_JPG/CI_01.jpg`)·트랙 3페이지 히어로 = 신규 크롤 실이미지 5장 로테이션(트랙별 차별화) (2-4-2) 공개 정보 페이지 4종(행사·참가안내·관람안내·교통, 소스 = 공개 행사 API·CMS·`visitor_guide`/`transport` 시드, 비로그인 DB 렌더) (2-4-3) 행사검색 로고 옆 상시(폭 ≈10자, §5B-2 search 공개 스코프) (2-4-4) UI 표준 재확정(Nifty 컴포넌트·Stitch 산출물 기준·페이지 타이틀 앞 선 SVG 아이콘) ③**§2-3-3 순서0 갱신**(미인증 루트 → `/visitor` 디폴트) ④**M12 갱신**(정보 페이지 4종·행사검색·CI·히어로 링크) ⑤**§5B-2 범용 그리드 PDF 출력 표준**(JasperReports·`POST /api/reports/grid-pdf`·WISE Jasper 패턴, 공통 report 모듈 소유·목록 스냅샷) ⑥**§9 Phase F 신설** — 벤치마킹 유래 14건(F-B1~B6·F-C1~C8, 외부연동 PG·iparking = mock 어댑터 게이트) + UNDEVELOPED 잔여(M15 WS 실시간 순위·M17 예약게시·EDM·구독알림·라이브공지·sectors 집계·주차/혼잡 API) 전량 착수 + **배포·전달 헤르메스(전령) 에이전트 경유 표준**. **src·design.md 미수정** — designer(선 SVG 타이틀 아이콘·정보 페이지·트랙 히어로 Stitch)·frontend/도메인 에이전트 후속. 근거·진행 = `docs/OWNER_FEEDBACK.md` 세션 6 |
|
|
||||||
| **v3.5** | 2026-07-14 | planner | **M2 자동 배치 이지 플로우 — 3단계 위저드 신설(소유자 지시 2026-07-14, 이미 구현 진행 중 사항의 권위 반영).** 기존 M2 본문(3안 생성·선택/병합·규정 검증·버전 기록)·타 절 전부 보존, 진입 UX 계약만 순증. ①헤더 v3.5 라인 + 버전 상향 ②**§M2-1 신설** — (1) 주최자 3단계 위저드(①어디에=평면도 트리 선택 → ②어떻게=자연어 한 줄 + AI 해석 폼(해석만 LLM·패킹은 결정론 엔진, §8A P1 정합) → ③3안 비교·선택 → "이 안으로 편집 시작") (2) 권위 원칙 5건: 평면도 안 생성·표시(홀 도면 JPG 캘리브레이션 좌표계, 추상 캔버스 금지)·평면도 트리(전시장>홀>구역, `hall_zone` V60·구역 폴리곤 경계 내 패킹 `zoneId`)·**S7 조감 나노바나나 버튼 클릭 시에만 생성(자동 발행 금지, Gemini 쿼터 보호 — §6-3 S7 트리거 표 개정)**·생성 이미지 라이프사이클(확대 팝업·다운로드·`render_job` 영속·렌더 갤러리 `boothRef=HALL-{hallId}` 재열람·M15 옥션 `MaterialPackage.aiImageUrl` 재사용)·**`apply-option` 저장 계약("이 안으로 편집 시작" 시에만 실제 저장 — 현행 프론트 미배선 P0 결함 명시, 구현 하네스 인계)** (3) 화면 계약: 위저드=SCR-03 진입 모드 편입·3안 비교=SCR-04 재사용(+CTA/S7 버튼/팝업 계약) (4) 데이터 계약 요약표. **src·design.md 미수정** — designer(SCR-03/04 위저드·팝업 반영)·frontend(apply-option 배선) 후속 |
|
| **v3.5** | 2026-07-14 | planner | **M2 자동 배치 이지 플로우 — 3단계 위저드 신설(소유자 지시 2026-07-14, 이미 구현 진행 중 사항의 권위 반영).** 기존 M2 본문(3안 생성·선택/병합·규정 검증·버전 기록)·타 절 전부 보존, 진입 UX 계약만 순증. ①헤더 v3.5 라인 + 버전 상향 ②**§M2-1 신설** — (1) 주최자 3단계 위저드(①어디에=평면도 트리 선택 → ②어떻게=자연어 한 줄 + AI 해석 폼(해석만 LLM·패킹은 결정론 엔진, §8A P1 정합) → ③3안 비교·선택 → "이 안으로 편집 시작") (2) 권위 원칙 5건: 평면도 안 생성·표시(홀 도면 JPG 캘리브레이션 좌표계, 추상 캔버스 금지)·평면도 트리(전시장>홀>구역, `hall_zone` V60·구역 폴리곤 경계 내 패킹 `zoneId`)·**S7 조감 나노바나나 버튼 클릭 시에만 생성(자동 발행 금지, Gemini 쿼터 보호 — §6-3 S7 트리거 표 개정)**·생성 이미지 라이프사이클(확대 팝업·다운로드·`render_job` 영속·렌더 갤러리 `boothRef=HALL-{hallId}` 재열람·M15 옥션 `MaterialPackage.aiImageUrl` 재사용)·**`apply-option` 저장 계약("이 안으로 편집 시작" 시에만 실제 저장 — 현행 프론트 미배선 P0 결함 명시, 구현 하네스 인계)** (3) 화면 계약: 위저드=SCR-03 진입 모드 편입·3안 비교=SCR-04 재사용(+CTA/S7 버튼/팝업 계약) (4) 데이터 계약 요약표. **src·design.md 미수정** — designer(SCR-03/04 위저드·팝업 반영)·frontend(apply-option 배선) 후속 |
|
||||||
| **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(라우팅·캐시·티어) 후속 |
|
| **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.0 | 2026-07-11 | planner | 최초 작성 — kintex-website.md 분석 기반 전체 기획. ReRoomAI 소스 분석(reroomai-source.md)은 추가 시 6장 갱신 예정 |
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
# KINTEX AI 전시·행사시스템 — 릴리즈 노트
|
|
||||||
|
|
||||||
> 검증된 변경만 기록한다. 실패·스킵·보류는 축소 없이 명시한다.
|
|
||||||
> 전달 원본 이력은 `docs/OWNER_FEEDBACK.md`(소유자 지시 권위 로그)를 참조.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-07-24 — 시연 준비 릴리즈 (세션 6)
|
|
||||||
|
|
||||||
**범위:** 시연 15항목 + 공개사이트 7항목 + 백로그 웨이브. 12개 에이전트 트랙 산출물.
|
|
||||||
**게이트:** 백엔드 gradlew test 전체 통과 · 프론트 tsc/vite build 통과 · i18n 4로케일 대조 0 누락 · 마이그레이션 V68~V106 중복 0 · QA 종결 리포트 PASS(blocker 0 · major 0 · minor 2 비차단).
|
|
||||||
**마이그레이션:** V68–V73·V75(데모/테스트데이터·CMS hero), V86·V87·V90·V94–V101·V106(도메인 기능).
|
|
||||||
|
|
||||||
### 시연 항목 (소유자 지시 1~15)
|
|
||||||
- 2차 인증 화면 KINTEX 로고 적용(OTP 챌린지·셋업).
|
|
||||||
- 대시보드: 진행단계 타임라인 짤림 근본수정, 참가업체 현황 데이터(24사), 알림 피드 세로글자 해소 + 전체보기(N).
|
|
||||||
- 참가업체 관리 회사명 폰트 통일.
|
|
||||||
- 전시 일정 우측 달력 정렬(split 그리드) 수정.
|
|
||||||
- 홀 현장운영 면적 비중·평면도(DWG) 표출.
|
|
||||||
- 플로어플랜: DWG 디폴트·부스 팔레트·평면도 확대 팝업·AI 자동배치(시각 우선→배치안 버튼).
|
|
||||||
- 공사·장치 옥션 테스트데이터(옥션 10·응찰 25·낙찰 6).
|
|
||||||
- 수주 부스·옥션 참여·알림 피드 테스트데이터.
|
|
||||||
- 물류 허브 도크 슬롯 테스트데이터(H1 도크 신설, 2주 슬롯).
|
|
||||||
- 관람객 배지 편집.
|
|
||||||
- 스폰서십 전체일정보기·스마트오토메이션 이행 데이터.
|
|
||||||
- CMS 웹크롤 이미지(메인 hero 포함) — cms_content.hero_url 신설·연결.
|
|
||||||
- 경영분석 4종 차트 데이터 + 업종별(sectors) 집계 백엔드.
|
|
||||||
- 킨텍스 전시 앱 다운로드(EAS 빌드·APK·QR 매니페스트) — 모바일 v0.2.2 준비.
|
|
||||||
- 로그인 슬라이드 이미지 10장.
|
|
||||||
|
|
||||||
### 공개사이트 항목 (P1~P7)
|
|
||||||
- kintex.wise.ai.kr 루트 디폴트 → /visitor.
|
|
||||||
- 3트랙(/visitor·/business·/agency) 로고 CI_01 통일.
|
|
||||||
- 행사·참가안내·관람안내·교통 공개 정보 페이지 구현·배선.
|
|
||||||
- 행사검색 로고 옆 배치·입력폭 조정.
|
|
||||||
- 유색 배경 버튼 흰색 글자 스윕.
|
|
||||||
- 그리드 상시 PDF 출력 버튼 = JasperReports(범용 grid-pdf 백엔드 + 프론트 공용 버튼).
|
|
||||||
- 트랙 3페이지 메인이미지 5장 로테이션(신규 크롤 자산).
|
|
||||||
|
|
||||||
### 백로그 웨이브
|
|
||||||
- 도메인 신규: 이벤트 구독·라이브 공지, 주차/혼잡, 세션 RSVP, 스마트 티켓 TOTP 가드, 티켓 취소 정책, 관람객 멤버십/연락처/알림/결제수단/피드.
|
|
||||||
- 멀티테넌시 2단계(데모 테넌트 V106 + 테넌트 스위처).
|
|
||||||
- WISE MDI 셸·페이지 타이틀 선 SVG 아이콘·Nifty 정렬·전 화면 반응형 스윕.
|
|
||||||
|
|
||||||
### 보류 / 후속 (축소 없이 명시)
|
|
||||||
- **I-1 (minor):** compliance 룰셋 RIGGING_RANGE `between` semantics — 구간 진입 시 경고 미발생. 규정 소유자 결정 대기(회귀 아님).
|
|
||||||
- **I-2 (minor):** 운영 배포 시 `KINTEX_OTP_ENC_KEY` env 주입 필수(dev 파생키 경고). 배포 체크리스트 게이트.
|
|
||||||
- **APK 게시:** EAS 클라우드 빌드(G3 승인) — 모바일 v0.2.2 빌드·QR 재주입 후속.
|
|
||||||
- **운영(kintex.wise.ai.kr):** 개발 검증 후 승격.
|
|
||||||
@ -92,43 +92,3 @@ B-01(조립부스 옵션 UI)·B-04(화면 수 표기)·B-05(정산 메뉴 IA)·B
|
|||||||
| 일자 | 갱신 |
|
| 일자 | 갱신 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 2026-07-11 | 최초 작성 — 세션 대화 전체 스캔(신규 5화면 파생 잔여·커밋 대기·NFR·대형 트랙·도메인·티켓·게이트) |
|
| 2026-07-11 | 최초 작성 — 세션 대화 전체 스캔(신규 5화면 파생 잔여·커밋 대기·NFR·대형 트랙·도메인·티켓·게이트) |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-07-23 상태 갱신 — "백로그 전부 구현" 웨이브 (소유자 지시)
|
|
||||||
|
|
||||||
시연 준비와 병행해 잔여 백로그 전량 착수. 상태 변경(진행 로그: OWNER_FEEDBACK.md 세션 6):
|
|
||||||
|
|
||||||
| 항목 | 이전 | 현재 |
|
|
||||||
|---|---|---|
|
|
||||||
| M15 옥션 WebSocket 실시간 순위 | 🔶 폴링 | 🔷 진행(STOMP+폴링 폴백) |
|
|
||||||
| M17 예약게시 Redis 트리거 | 🔶 | 🔷 진행(@Scheduled 폴백 포함) |
|
|
||||||
| M10/12 EDM 실 발송 게이트웨이 | 🔶 | 🔷 진행(SMTP 기승인 채널·화이트리스트 dry-run 기본) |
|
|
||||||
| 경영분석 sectors 집계(코드 부재) | (신규 발견) | 🔷 진행 |
|
|
||||||
| Phase F 벤치마킹 14건(F-B1~B6·F-C1~C8) | ⬜ | 🔷 웨이브 편성(백엔드 선행→관람객 풀스택→모바일) |
|
|
||||||
| SCR-16 관리자 집계 실전환 | 🔶 | ⏭ 다음 웨이브(관리자) |
|
|
||||||
| 멀티테넌시 2단계(전 매퍼 스코핑·JWT tid·스위처) | 🔶 1단계 | ⏭ 다음 웨이브(관리자) |
|
|
||||||
| Stitch 아이콘 세트 교체 + 타이틀 선 아이콘(신규 지시) | ⬜ | ⏭ UI 스윕 웨이브 |
|
|
||||||
| MDI 셸 전환 | ⬜ | ⏭ 최종 프론트 웨이브(QA 게이트 후) |
|
|
||||||
| NFR 스윕(hex·인증후 i18n·접근성·반응형·시큐어코딩) | 🔶 | ⏭ UI 스윕 웨이브 |
|
|
||||||
| 모바일 B2C 탭 셸 | ⬜ | ⏭ 모바일 2차(재빌드 전) |
|
|
||||||
| 100대 기능 갭(신규 19·부분 18) | ⬜ | ⏸ 대형 트랙 — 시연 후 별도 웨이브 필요(단일 세션 범위 초과) |
|
|
||||||
|
|
||||||
### 결과 (2026-07-24 웨이브 완료)
|
|
||||||
|
|
||||||
| 항목 | 결과 |
|
|
||||||
|---|---|
|
|
||||||
| M15 옥션 WS 실시간 순위 | ✅ **기구현 확인**(STOMP /topic/auctions/{id}/ranking, 커밋 후 브로드캐스트·폴링 폴백) + 봉인 안전성 회귀 테스트 4건 추가 |
|
|
||||||
| M17 예약게시 트리거 | ✅ 기구현 확인(CmsScheduledPublisher 30초 폴러) — 무변경 |
|
|
||||||
| EDM 실 발송 게이트웨이 | ✅ 기구현 확인(MailService+3중 게이트: live·test-mode·MAIL_ENABLED) — 무변경 |
|
|
||||||
| F-C1 구독·오픈알림 / F-B6 라이브 공지 | ✅ 신규(V86·V87) + 프론트(구독 폼·해지 랜딩·피드·발행 콘솔 /cms/live-notices) + 모바일 피드 |
|
|
||||||
| 경영분석 sectors 집계 | ✅ 구현(booth↔company category 실집계, shape 불변) |
|
|
||||||
| Phase F 14건 | ✅ **전량 완료** — F-B1 회전QR·F-B2 5구간 환불·F-B4 RSVP·F-B5 간편결제·F-B6 라이브공지·F-C1 구독·F-C2 주차(mock)·F-C3 혼잡·F-C4 QR명함·F-C5 대기열·F-C6 멤버십·F-C7 최근본 피드·F-C8 키오스크(+F-B3 다국어는 i18n 스윕 49키 병합으로 커버, 티켓·배지 화면 확장분 잔여) — V90·V94~V101 |
|
|
||||||
| SCR-16 관리자 집계 실전환 | ✅ 완료(체크인 실집계·혼잡 pill·감사로그 최근활동, 가짜 숫자 0) |
|
|
||||||
| 멀티테넌시 2단계 | ✅ 완료(JWT tid 권위·fail-closed 403·스코핑 적용/보류 목록·ADMIN 스위처·DEMO 테넌트 격리 3중 증명, V106) — 보류 매퍼는 요약서 목록 참조 |
|
|
||||||
| MDI 셸 전환 | ✅ **stale 정정** — 이미 구현돼 있었음(mdiStore·MdiTabBar·mdiLabels, design.md §2.7 v1.2). §4의 "미착수" 표기 무효 |
|
|
||||||
| Stitch 아이콘/타이틀 선 아이콘 | ✅ 타이틀 아이콘 40화면 신규(기존 31 포함 전 화면)·이모지 12곳 교체(잔여 2건 기록) |
|
|
||||||
| NFR hex 스윕 | 🔶 81곳 토큰화(잔여 ~242는 비-토큰·차트 의도 유지 문서화) |
|
|
||||||
| 모바일 B2C 탭 셸 | ✅ 완료(app/(visitor) 5탭·티켓 딥링크 호환·주차/혼잡/라이브공지) |
|
|
||||||
| 범용 그리드 PDF(JasperReports) | ✅ 백엔드+KxDataTable 8 자동 — 커스텀 그리드 22 배선 마감 진행 |
|
|
||||||
| 100대 기능 갭(37) | ⏸ 유지 — 별도 대형 트랙 |
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# 킨텍스 AI 전시관리 시스템 — UI 디자인 스펙 (design.md)
|
# 킨텍스 AI 전시관리 시스템 — UI 디자인 스펙 (design.md)
|
||||||
|
|
||||||
> 작성: 디자인 에이전트(designer) · 작성일: 2026-07-23 · 버전: **v2.7**
|
> 작성: 디자인 에이전트(designer) · 작성일: 2026-07-14 · 버전: **v2.6**
|
||||||
> 근거 문서: `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변형) 학습·정합
|
> 근거 문서: `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)은 시각 참조로만 사용하며, 상충 시 본 문서를 따른다.
|
> **단일 출처(Single Source of Truth)**: 프론트 구현 토큰·컴포넌트 계약은 본 문서가 권위다. Stitch 화면(code.html/screen.png)은 시각 참조로만 사용하며, 상충 시 본 문서를 따른다.
|
||||||
> 용도: **각 화면의 "Stitch 프롬프트" 코드블록을 Google Stitch(stitch.withgoogle.com)에 그대로 붙여넣어 화면을 생성**한다. 문서는 한국어, Stitch 프롬프트만 영어.
|
> 용도: **각 화면의 "Stitch 프롬프트" 코드블록을 Google Stitch(stitch.withgoogle.com)에 그대로 붙여넣어 화면을 생성**한다. 문서는 한국어, Stitch 프롬프트만 영어.
|
||||||
@ -67,17 +67,6 @@
|
|||||||
- **그리드·간격**: 12컬럼 · 컨테이너 최대 `1440px` · 4px 베이스 · 거터 24px · 데스크톱 페이지 마진 48px(태블릿 32px, 모바일 16px). 속성/견적 패널 등 고밀도 영역 패딩은 8~12px로 타이트하게.
|
- **그리드·간격**: 12컬럼 · 컨테이너 최대 `1440px` · 4px 베이스 · 거터 24px · 데스크톱 페이지 마진 48px(태블릿 32px, 모바일 16px). 속성/견적 패널 등 고밀도 영역 패딩은 8~12px로 타이트하게.
|
||||||
- **데이터 테이블**: 긴 목록은 지브라 스트라이프(홀수행 `neutral-050 #F9FAFB`), 헤더는 sticky + `label` 타이포 + 옅은 회색 배경, 행 hover `#F1F5F9`. 복잡 테이블은 컬럼 숨김보다 가로 스크롤 우선.
|
- **데이터 테이블**: 긴 목록은 지브라 스트라이프(홀수행 `neutral-050 #F9FAFB`), 헤더는 sticky + `label` 타이포 + 옅은 회색 배경, 행 hover `#F1F5F9`. 복잡 테이블은 컬럼 숨김보다 가로 스크롤 우선.
|
||||||
|
|
||||||
### 1-4A. 전역 컴포넌트 표준 (v2.7 신설 — 2026-07-23 세션 6 소유자 확정)
|
|
||||||
|
|
||||||
> 아래 6개 표준은 **전 화면 공통 강제 규칙**이다. 개별 화면 스펙은 이를 재정의하지 않으며, 신규/기존 화면 모두 준수한다. 근거: `docs/OWNER_FEEDBACK.md` 세션 6.
|
|
||||||
|
|
||||||
- **G1. 디자인 시스템 재확정 — Nifty 문자 그대로 / Stitch 산출물 우선(강한 반복 피드백)**: 전 프론트 화면은 **Nifty 컴포넌트(`docs/DESIGN_SYSTEM_NIFTY.md`)를 문자 그대로** 사용한다(카드 `.kx-card`·테이블 `.kx-table`·버튼·드롭다운·List Group·타이포/모달/Offcanvas 등 UI-elements 전체, 자체 재해석 금지). **신규 화면은 Stitch 산출물(`stitch_kintex_ai_system_architect/`)을 먼저 이식**하고 그 위에 Nifty 토큰으로 수렴시킨다 — 손그림·임의 스타일 금지. 본 문서 §1 토큰이 단일 출처이며, Nifty 참조는 §1 토큰으로 번역해 적용한다(`docs/analysis/nifty-design-refs.md`).
|
|
||||||
- **G2. 페이지 타이틀 선(stroke) SVG 아이콘 의무**: **모든 페이지·섹션 타이틀 앞에는 선으로 그린(stroke, `fill:none`, `stroke:currentColor`) SVG 아이콘을 붙인다** — 공용 세트 `icons.tsx`에서 조달(화면별 의미 아이콘). **이모지 절대 금지.** 타이틀-아이콘 정렬은 좌측 정렬·아이콘 20~24px·타이틀과 8px 간격. 기존 이모지/무아이콘 타이틀은 전 화면 스윕으로 교체.
|
|
||||||
- **G3. 모든 그리드에 PDF 출력 버튼(JasperReports)**: **데이터 그리드(테이블)를 노출하는 모든 화면은 그리드 상단 우측 툴바에 "PDF 출력" 버튼을 표준 제공**한다 — 서버 사이드 **JasperReports(WISE Jasper 패턴)** 로 현재 필터·정렬·검색 상태를 반영한 리포트를 생성·다운로드(범용 `grid-pdf` 백엔드 계약, `application/pdf`). 버튼은 선 SVG 문서 아이콘 + 라벨 "PDF 출력", CSV/Excel 내보내기와 병렬. 로딩 중 "생성 중…" 비활성, 실패 시 인라인 재시도. 빈 그리드는 비활성.
|
|
||||||
- **G4. 목록 위젯 10줄 프리뷰 + "전체보기(N)" 표준**: **대시보드·홈의 목록형 위젯(내 할 일·공지·최근 활동·알림 피드·타임라인 등)은 최대 10줄만 프리뷰**하고, 항목이 초과하면 하단에 **"전체보기(N)"** 버튼(N=전체 건수)을 노출해 전용 목록/센터로 이동한다(무한 확장·위젯 내 스크롤 폭주 금지). 0건은 안내 문구 + 행동 버튼(CTA) 조합(§SCR-HOME 밀도 원칙 ④).
|
|
||||||
- **G5. 목록 오버플로 규격 — `minmax(0,1fr)`**: **알림 피드·타임라인·목록 위젯이 그리드/플렉스 자식일 때 트랙을 `minmax(0,1fr)`로 잡아** 긴 텍스트·세로 글자 짤림·컬럼 블로우아웃을 원천 차단한다(`min-width:0`/`min-height:0` 수렴). 항목 텍스트는 말줄임(ellipsis) 또는 줄바꿈으로 처리하고 컨테이너를 넘기지 않는다.
|
|
||||||
- **G6. 유색 배경 버튼 글자 = `--color-on-accent`(흰색) 전역**: **primary·AI·success·warning·error 등 유색(솔리드) 배경 버튼의 텍스트는 항상 `--color-on-accent`(흰색 `#FFFFFF`, 다크 토큰에서도 흰색 고정)** 를 사용한다(공개 사이트 포함 전 화면). 옅은 ghost/outline 버튼만 예외(색 텍스트). 유색 배경 위 텍스트 하드코딩 hex 금지 — CSS 변수 주입.
|
|
||||||
|
|
||||||
### 1-5. 반응형 기준·다크모드
|
### 1-5. 반응형 기준·다크모드
|
||||||
|
|
||||||
- **웹 1440px**(설계·에디터·대시보드 기준 해상도) / **모바일 390px**(조회·승인·현장 시나리오). 태블릿은 웹 레이아웃 축소 대응.
|
- **웹 1440px**(설계·에디터·대시보드 기준 해상도) / **모바일 390px**(조회·승인·현장 시나리오). 태블릿은 웹 레이아웃 축소 대응.
|
||||||
@ -472,7 +461,6 @@ An enterprise MDI (multiple document interface) application shell for "KINTEX AI
|
|||||||
1. 좌우 분할: 좌측 55% 브랜드 패널 — 나노바나나 생성 부스 예상 사진 배경 + 카피 "신청서를 내는 순간, 시공 후 사진을 먼저 봅니다" + AI 이미지 고지 캡션
|
1. 좌우 분할: 좌측 55% 브랜드 패널 — 나노바나나 생성 부스 예상 사진 배경 + 카피 "신청서를 내는 순간, 시공 후 사진을 먼저 봅니다" + AI 이미지 고지 캡션
|
||||||
2. 우측 45% 로그인 카드: 이메일/비밀번호, 이메일 인증 로그인, 킨텍스 내부 계정(SSO) 버튼 분리 + 하단 링크 "초대 코드로 참여하기"(코드 입력 모달)
|
2. 우측 45% 로그인 카드: 이메일/비밀번호, 이메일 인증 로그인, 킨텍스 내부 계정(SSO) 버튼 분리 + 하단 링크 "초대 코드로 참여하기"(코드 입력 모달)
|
||||||
- **핵심 컴포넌트/상태**: 로그인 실패 인라인 에러 · 에러: 미등록 장치업체 초대 수락 시 차단 모달("킨텍스 등록업체만 참여할 수 있습니다"). **역할 배지·워크스페이스 카드 그리드·"참여 중인 행사가 없습니다" 빈 상태·초대 코드 안내는 SCR-HOME으로 이관**(아래 §SCR-HOME 참조).
|
- **핵심 컴포넌트/상태**: 로그인 실패 인라인 에러 · 에러: 미등록 장치업체 초대 수락 시 차단 모달("킨텍스 등록업체만 참여할 수 있습니다"). **역할 배지·워크스페이스 카드 그리드·"참여 중인 행사가 없습니다" 빈 상태·초대 코드 안내는 SCR-HOME으로 이관**(아래 §SCR-HOME 참조).
|
||||||
- **2차 인증 패널 로고(v2.7 — 세션 6)**: 로그인 2단계 OTP 챌린지(`OtpChallengePanel`)와 OTP 설정(SCR-51)의 상단 로고는 **KINTEX 워드마크(32px, `.kx-otp__logo`)** 로 통일 — 방패/자물쇠 보안 아이콘 대체 금지(SCR-51 브랜드 아이콘 확정 참조).
|
|
||||||
- **Stitch 프롬프트**:
|
- **Stitch 프롬프트**:
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -512,8 +500,7 @@ A B2B SaaS login page for "KINTEX AI 전시관리", a Korean exhibition manageme
|
|||||||
- **빈 상태(전부 안내+행동 버튼 조합 — 밀도 원칙 ④)**: 배너 미등록 시 기본 그라디언트 + 브랜드 카피 폴백(SCR-01 카피 재사용) + CTA "행사 둘러보기" · **쇼케이스 탭 0건 "해당 기간 전시가 없습니다" + "전체 일정 보기"**(개별 카드의 포스터 부재는 그라디언트+행사명 폴백 카드) · **내 할 일 0건 "모두 완료" 상태(success 체크 + "처리할 작업이 없습니다") + 행동 버튼("결재함 열기" 등 역할별)** · 공지 0건 "등록된 공지가 없습니다" + "전체 공지 보기"(권한자는 "공지 작성") · 워크스페이스 0건 초대 안내 + 초대 코드 입력 필드 + "초대 코드 확인" 버튼 · 일정 0건 "예정된 전시가 없습니다" + "전체 일정 보기" · 최근 활동 0건 "최근 활동이 없습니다" + "알림 설정".
|
- **빈 상태(전부 안내+행동 버튼 조합 — 밀도 원칙 ④)**: 배너 미등록 시 기본 그라디언트 + 브랜드 카피 폴백(SCR-01 카피 재사용) + CTA "행사 둘러보기" · **쇼케이스 탭 0건 "해당 기간 전시가 없습니다" + "전체 일정 보기"**(개별 카드의 포스터 부재는 그라디언트+행사명 폴백 카드) · **내 할 일 0건 "모두 완료" 상태(success 체크 + "처리할 작업이 없습니다") + 행동 버튼("결재함 열기" 등 역할별)** · 공지 0건 "등록된 공지가 없습니다" + "전체 공지 보기"(권한자는 "공지 작성") · 워크스페이스 0건 초대 안내 + 초대 코드 입력 필드 + "초대 코드 확인" 버튼 · 일정 0건 "예정된 전시가 없습니다" + "전체 일정 보기" · 최근 활동 0건 "최근 활동이 없습니다" + "알림 설정".
|
||||||
- **에러**: 영역별 인라인 에러 카드 + 재시도(전면 에러 금지 — 한 영역 실패가 홈 전체를 막지 않음). KPI/캘린더 API 실패 시 해당 카드만 "불러오지 못했습니다 · 다시 시도".
|
- **에러**: 영역별 인라인 에러 카드 + 재시도(전면 에러 금지 — 한 영역 실패가 홈 전체를 막지 않음). KPI/캘린더 API 실패 시 해당 카드만 "불러오지 못했습니다 · 다시 시도".
|
||||||
- **데이터 소스 매핑**: ①KPI = 기존 실 API 재사용(진행 전시=이벤트 목록, 오늘 체크인=M10 체크인 집계, D-임박=이벤트 캘린더 파생, 미결 결재=승인/결재 집계) ②배너 = **기존 login-slides 자산/API 재사용**(SCR-01과 단일 출처) ③일정 = `/api/events/calendar` + `/api/public/events`, **전시장 필터 옵션 = 홀 마스터 조회 API(센터/전시장 구분 그룹, distinct·정렬 순)** — 세그먼트 옵션은 이 API 응답으로 동적 구성(하드코딩 금지, 3관·4관 확장 대비), 선택값은 캘린더·리스트 조회에 홀/센터 필터 파라미터로 전달 ④**쇼케이스 = event 테이블 `poster_url`(V32 마이그레이션 크롤 적재분·34건) + 기간(start/end) + `hall_label`** — 탭별 필터(진행 중=오늘 포함 기간 / 이번 달·다음 달=해당 월 개장), `poster_url` NULL이면 그라디언트+행사명 폴백 카드 ⑤**내 할 일 = 결재함(§5B approval)·서류 마감(M6)·옥션(M15)·정산(M9) 집계 API 조합** — 프론트 개별 호출 조합 대신 **신규 통합 API `GET /api/home/todos` 계약 제안**: 응답 `{ total: number, items: [{ type: 'approval'|'document'|'auction'|'settlement'|'system', title, target, dueDate?, dday?, severity: 'normal'|'warning'|'overdue', route }] }` — 역할(JWT)별 서버 사이드 구성·기한 임박순 정렬·최대 6건+total(백엔드 kintex-backend-dev 소관, 계약만 정의) ⑥공지 = §5B notice 최근 5건(`/work/notice` 목록 API) ⑦빠른 작업 = 역할별 정적 정의(라우트 레지스트리 — API 불필요) ⑧최근 활동 = 기존 알림/이벤트 피드 API 재사용(SCR-47 알림센터와 동일 원천, 최근 5건) ⑨워크스페이스 = 기존 워크스페이스(참여 행사) 데이터. 미연동 지표는 SCR-02 원칙대로 샘플 표기.
|
- **데이터 소스 매핑**: ①KPI = 기존 실 API 재사용(진행 전시=이벤트 목록, 오늘 체크인=M10 체크인 집계, D-임박=이벤트 캘린더 파생, 미결 결재=승인/결재 집계) ②배너 = **기존 login-slides 자산/API 재사용**(SCR-01과 단일 출처) ③일정 = `/api/events/calendar` + `/api/public/events`, **전시장 필터 옵션 = 홀 마스터 조회 API(센터/전시장 구분 그룹, distinct·정렬 순)** — 세그먼트 옵션은 이 API 응답으로 동적 구성(하드코딩 금지, 3관·4관 확장 대비), 선택값은 캘린더·리스트 조회에 홀/센터 필터 파라미터로 전달 ④**쇼케이스 = event 테이블 `poster_url`(V32 마이그레이션 크롤 적재분·34건) + 기간(start/end) + `hall_label`** — 탭별 필터(진행 중=오늘 포함 기간 / 이번 달·다음 달=해당 월 개장), `poster_url` NULL이면 그라디언트+행사명 폴백 카드 ⑤**내 할 일 = 결재함(§5B approval)·서류 마감(M6)·옥션(M15)·정산(M9) 집계 API 조합** — 프론트 개별 호출 조합 대신 **신규 통합 API `GET /api/home/todos` 계약 제안**: 응답 `{ total: number, items: [{ type: 'approval'|'document'|'auction'|'settlement'|'system', title, target, dueDate?, dday?, severity: 'normal'|'warning'|'overdue', route }] }` — 역할(JWT)별 서버 사이드 구성·기한 임박순 정렬·최대 6건+total(백엔드 kintex-backend-dev 소관, 계약만 정의) ⑥공지 = §5B notice 최근 5건(`/work/notice` 목록 API) ⑦빠른 작업 = 역할별 정적 정의(라우트 레지스트리 — API 불필요) ⑧최근 활동 = 기존 알림/이벤트 피드 API 재사용(SCR-47 알림센터와 동일 원천, 최근 5건) ⑨워크스페이스 = 기존 워크스페이스(참여 행사) 데이터. 미연동 지표는 SCR-02 원칙대로 샘플 표기.
|
||||||
- **목록 위젯 표준(v2.7 — 세션 6 소유자 확정 · §1-4A G4·G5 적용)**: **내 할 일·공지·최근 활동** 3개 목록 위젯은 **최대 10줄 프리뷰 + 하단 "전체보기(N)" 버튼**(N=전체 건수)으로 통일한다(기존 "6행/5건 + 더보기" 표기를 **10줄 표준**으로 갱신 — 내 할 일 6행→10줄, 공지·최근 활동 5건→10줄). 각 위젯 컨테이너는 **`minmax(0,1fr)` 트랙**으로 잡아 항목 텍스트가 세로로 짤리거나 컬럼을 밀어내지 않게 하고(최근 활동 타임라인 포함), 항목은 말줄임/줄바꿈으로 처리. "더보기"/"전체 보기" 라벨은 "전체보기(N)"로 정합(공지→`/work/notice`, 최근 활동→알림센터, 내 할 일→역할별 작업 목록).
|
- **접근성·반응형·국제화**: 1440 기준, 태블릿 본문 2열→1열, 모바일은 SCR-M3(모바일 홈)로 분기. 라이트/다크 토큰(CSS 변수 주입·하드코딩 hex 금지). i18n **`home.` 네임스페이스** 전제(예 `home.greeting`·`home.kpi.*`·`home.showcase.*`·`home.todo.*`·`home.notice.more`·`home.workspace.empty`). **이모지 금지 — 전 아이콘 선 SVG**(stroke, `fill:none`). 캐러셀 자동재생은 일시정지 컨트롤 제공·`prefers-reduced-motion` 준수, 캘린더 점유는 색 외 텍스트/패턴 병기, KPI·리스트 `aria-label`, 포커스 링 `primary-600` 2px, 명도 대비 WCAG AA 이상.
|
||||||
- **접근성·반응형·국제화**: 1440 기준, 태블릿 본문 2열→1열, 모바일은 SCR-M3(모바일 홈)로 분기. 라이트/다크 토큰(CSS 변수 주입·하드코딩 hex 금지). i18n **`home.` 네임스페이스** 전제(예 `home.greeting`·`home.kpi.*`·`home.showcase.*`·`home.todo.*`·`home.notice.more`·`home.workspace.empty`). **이모지 금지 — 전 아이콘 선 SVG**(stroke, `fill:none`, §1-4A G2 — 각 섹션 타이틀 앞 선 SVG 아이콘). 캐러셀 자동재생은 일시정지 컨트롤 제공·`prefers-reduced-motion` 준수, 캘린더 점유는 색 외 텍스트/패턴 병기, KPI·리스트 `aria-label`, 포커스 링 `primary-600` 2px, 명도 대비 WCAG AA 이상.
|
|
||||||
- **Stitch 프롬프트**:
|
- **Stitch 프롬프트**:
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -531,7 +518,6 @@ A post-login landing home dashboard for "KINTEX AI 전시관리", a Korean B2B e
|
|||||||
2. **히어로 — 마일스톤 타임라인 (가로 전폭)**: D-150 홀 배정 → D-30 사전업무협의 → D-25 유틸리티 신청 마감 → D-7 신고서류 제출 → D-0 개장. 완료 노드는 녹색 체크, 현재 구간은 파란 진행 바, 임박 노드는 주황 펄스. 각 노드 아래 카드에 잔여 항목 수("신고서류 3/7 완료")
|
2. **히어로 — 마일스톤 타임라인 (가로 전폭)**: D-150 홀 배정 → D-30 사전업무협의 → D-25 유틸리티 신청 마감 → D-7 신고서류 제출 → D-0 개장. 완료 노드는 녹색 체크, 현재 구간은 파란 진행 바, 임박 노드는 주황 펄스. 각 노드 아래 카드에 잔여 항목 수("신고서류 3/7 완료")
|
||||||
3. KPI 카드 4개: 참가업체 신청 128/160 · 부스 배치 확정률 92% · 유틸리티 신청 완료 74% (D-25 임박 경고) · 규정 위반 미해결 5건
|
3. KPI 카드 4개: 참가업체 신청 128/160 · 부스 배치 확정률 92% · 유틸리티 신청 완료 74% (D-25 임박 경고) · 규정 위반 미해결 5건
|
||||||
4. 좌측 2/3: 참가업체 현황 테이블(부스번호, 업체명, 설계 상태 배지, 유틸리티 상태, 예상 사진 썸네일) / 우측 1/3: 알림 피드(승인·반려·마감 이벤트) + "홀 전경 조감(S7)" 미니 이미지 카드
|
4. 좌측 2/3: 참가업체 현황 테이블(부스번호, 업체명, 설계 상태 배지, 유틸리티 상태, 예상 사진 썸네일) / 우측 1/3: 알림 피드(승인·반려·마감 이벤트) + "홀 전경 조감(S7)" 미니 이미지 카드
|
||||||
- **오버플로·목록 규격(v2.7 — 세션 6 소유자 확정 · §1-4A G4·G5)**: ①**마일스톤 타임라인(진행 단계)이 짤리지 않도록** 히어로 타임라인 트랙은 `minmax(0,1fr)`로 잡고 노드 카드가 컨테이너를 넘기지 않게 한다(D-노드 라벨 말줄임·5노드 균등폭). ②**우측 알림 피드**는 그리드 자식 트랙 `minmax(0,1fr)`로 잡아 **세로 글자 짤림/컬럼 블로우아웃을 원천 차단**(텍스트 말줄임), **최대 10줄 프리뷰 + "전체보기(N)"**(알림센터 이동)로 통일. ③참가업체 현황 테이블은 §1-4A G3(그리드 PDF 출력 버튼) 적용. **데이터**: 참가업체 현황·알림 피드는 V68(참가업체 24사)·V69(옥션·수주·피드 파생) 시드로 채움(빈 화면 방지).
|
|
||||||
- **상태**: 로딩 — KPI 스켈레톤 · 빈 상태 — 참가업체 0명 시 "초대 링크 발급" CTA · 에러 — 데이터 로드 실패 배너+재시도.
|
- **상태**: 로딩 — KPI 스켈레톤 · 빈 상태 — 참가업체 0명 시 "초대 링크 발급" CTA · 에러 — 데이터 로드 실패 배너+재시도.
|
||||||
- **Stitch 프롬프트**:
|
- **Stitch 프롬프트**:
|
||||||
|
|
||||||
@ -546,11 +532,10 @@ An enterprise dashboard for an exhibition organizer, part of "KINTEX AI 전시
|
|||||||
- **대상**: 주최자 (홀매니저 읽기 전용) · **진입 경로**: 대시보드 → 플로어플랜 스튜디오 / AI 자동배치 위저드 ③단계 "이 안으로 편집 시작"(apply-option 저장 완료 후 캔버스 진입 — SCR-04)
|
- **대상**: 주최자 (홀매니저 읽기 전용) · **진입 경로**: 대시보드 → 플로어플랜 스튜디오 / AI 자동배치 위저드 ③단계 "이 안으로 편집 시작"(apply-option 저장 완료 후 캔버스 진입 — SCR-04)
|
||||||
- **레이아웃**:
|
- **레이아웃**:
|
||||||
1. 상단 툴바: **홀 선택 셀렉트**(전시장별 optgroup 그룹핑 — "제2전시장 · 홀7", 변경 시 해당 홀 배치 라우트로 전환, v2.6 M2-1 정합), 배치안 버전 드롭다운("배치안 B v3"), 저장 상태, **보라색 AI 버튼 "AI 자동배치"(클릭 시 3단계 위저드 다이얼로그 오픈 — 아래 M2-1 절)**, "규정 검증 실행", "홀 전경 생성(S7)", 공유
|
1. 상단 툴바: **홀 선택 셀렉트**(전시장별 optgroup 그룹핑 — "제2전시장 · 홀7", 변경 시 해당 홀 배치 라우트로 전환, v2.6 M2-1 정합), 배치안 버전 드롭다운("배치안 B v3"), 저장 상태, **보라색 AI 버튼 "AI 자동배치"(클릭 시 3단계 위저드 다이얼로그 오픈 — 아래 M2-1 절)**, "규정 검증 실행", "홀 전경 생성(S7)", 공유
|
||||||
2. 좌측 도구 패널(240px): **도면 툴그룹(JPG / DWG 탭 — 디폴트 = DWG, v2.7 세션 6)**, **부스 팔레트**(표준 규격 선택/드래그: 기본 3×3m / 프리미엄 6×3m / 아일랜드 — 카드 클릭 선택 후 캔버스 드래그앤드롭 배치), 시설 요소(무대·라운지·주출입구), 레이어 토글(트렌치 그리드 / 기둥·셔터·비상구 / 위반 오버레이)
|
2. 좌측 도구 패널(240px): 부스 팔레트(기본 3×3m / 프리미엄 6×3m / 아일랜드), 시설 요소(무대·라운지·주출입구), 레이어 토글(트렌치 그리드 / 기둥·셔터·비상구 / 위반 오버레이)
|
||||||
3. **중앙 캔버스(다크 `#1C2536`)**: **실측 홀 도면 JPG를 배경으로 로드하고, 도면 내 전시 바닥면 캘리브레이션 영역(FLOOR_REGIONS 분율 좌표 — 구현 정본 `hallFloorplan.ts`)에 홀 좌표계(홀7 기준 126×90m, 축척 표시)를 정렬**해 부스 폴리곤이 **평면도 안에** 표시·드래그앤드롭된다(도면 없는 추상 캔버스에 배치 후 도면 덧그리기 금지 — PLANNING v3.5 M2-1 원칙 1). 부스는 파란 채움+부스번호, 선택 시 핸들. 트렌치 그리드는 점선, 비상구는 녹색 아이콘. **위반 오버레이**: 통로 폭 미달 구간 빨간 반투명 밴드 + 번호 핀(차단 2건 — 핀 1·2), 바닥하중 초과 부스 주황 빗금(경고 3건 — 핀 3·4·5) — 우측 [검증] 탭 요약 "차단 2 · 경고 3"과 핀이 1:1 일치(v2.5 B-07 정정)
|
3. **중앙 캔버스(다크 `#1C2536`)**: **실측 홀 도면 JPG를 배경으로 로드하고, 도면 내 전시 바닥면 캘리브레이션 영역(FLOOR_REGIONS 분율 좌표 — 구현 정본 `hallFloorplan.ts`)에 홀 좌표계(홀7 기준 126×90m, 축척 표시)를 정렬**해 부스 폴리곤이 **평면도 안에** 표시·드래그앤드롭된다(도면 없는 추상 캔버스에 배치 후 도면 덧그리기 금지 — PLANNING v3.5 M2-1 원칙 1). 부스는 파란 채움+부스번호, 선택 시 핸들. 트렌치 그리드는 점선, 비상구는 녹색 아이콘. **위반 오버레이**: 통로 폭 미달 구간 빨간 반투명 밴드 + 번호 핀(차단 2건 — 핀 1·2), 바닥하중 초과 부스 주황 빗금(경고 3건 — 핀 3·4·5) — 우측 [검증] 탭 요약 "차단 2 · 경고 3"과 핀이 1:1 일치(v2.5 B-07 정정)
|
||||||
4. 우측 패널(360px) 탭 2개: [속성] 선택 부스 좌표·크기·타입·배정 업체 / [검증] 위반 목록 카드(핀 번호, 규정 근거 "피난 통로 3m 미만", 심각도, "위치 보기") + 요약 "차단 2 · 경고 3"
|
4. 우측 패널(360px) 탭 2개: [속성] 선택 부스 좌표·크기·타입·배정 업체 / [검증] 위반 목록 카드(핀 번호, 규정 근거 "피난 통로 3m 미만", 심각도, "위치 보기") + 요약 "차단 2 · 경고 3"
|
||||||
5. 하단 상태 바: 부스 수 486/510 · 판매 면적 · 줌 컨트롤
|
5. 하단 상태 바: 부스 수 486/510 · 판매 면적 · 줌 컨트롤
|
||||||
- **플로어플랜 표준 확정(v2.7 — 세션 6 소유자 확정)**: ①**도면 탭 디폴트 = DWG** — 좌측 도면 툴그룹은 JPG/DWG 2탭이며 **기본 선택은 DWG**(CAD 렌더 PNG `/media/floorplans/dwg/hall{1~10}.png`, 미보유 홀은 탭 비활성·JPG 폴백). ②**부스 팔레트** — 표준 규격 카드를 클릭해 선택하고 캔버스로 드래그앤드롭 배치(규격은 마스터 부스표준 기준). ③**평면도 확대 팝업(`.kx-floorplan-lightbox`, 신설)** — 캔버스 상단 "확대" 버튼(또는 도면 더블클릭) 시 **대형 모달**로 현재 도면(DWG/JPG)을 크게 열람(`role="dialog"` `aria-modal`, 배경 클릭·Esc 닫기, 모달 내 휠 줌·드래그 이동). AI 자동배치 완료 결과도 이 확대 팝업에 우선 표시(아래 SCR-04 AI UX 참조).
|
|
||||||
- **상태**: AI 자동배치 생성 중 — **위저드 다이얼로그 내 진행 화면**("제약조건 풀이 중… 복수 안 생성" — 캔버스 오버레이 아님, v2.6 정정) · 검증 통과 시 녹색 배너 "위반 0건 — 제출 가능" · 빈 캔버스 — "AI 자동배치(3단계 위저드)로 시작하세요" CTA(위저드 ①단계 진입).
|
- **상태**: AI 자동배치 생성 중 — **위저드 다이얼로그 내 진행 화면**("제약조건 풀이 중… 복수 안 생성" — 캔버스 오버레이 아님, v2.6 정정) · 검증 통과 시 녹색 배너 "위반 0건 — 제출 가능" · 빈 캔버스 — "AI 자동배치(3단계 위저드)로 시작하세요" CTA(위저드 ①단계 진입).
|
||||||
- **M2-1 자동배치 3단계 위저드 진입 모드 (v2.6 신설 — PLANNING v3.5 M2-1 정합, 구현 정본 `AutoLayoutDialog.tsx`)**: 위저드는 SCR-03의 **진입 모드**(모달 다이얼로그 `.kx-auto`)로 편입 — 배치 미존재 시 ①단계부터 시작, 기존 배치 존재 시 캔버스 직행(+툴바 "AI 자동배치"로 위저드 재실행). 단계 표시기(`① 어디에 → ② 조건 → ③ 배치안 선택`, 완료 단계 체크 표시) 공통.
|
- **M2-1 자동배치 3단계 위저드 진입 모드 (v2.6 신설 — PLANNING v3.5 M2-1 정합, 구현 정본 `AutoLayoutDialog.tsx`)**: 위저드는 SCR-03의 **진입 모드**(모달 다이얼로그 `.kx-auto`)로 편입 — 배치 미존재 시 ①단계부터 시작, 기존 배치 존재 시 캔버스 직행(+툴바 "AI 자동배치"로 위저드 재실행). 단계 표시기(`① 어디에 → ② 조건 → ③ 배치안 선택`, 완료 단계 체크 표시) 공통.
|
||||||
- **① 어디에**: **셀렉트 3단 캐스케이드** — 대분류(전시장: 제1/제2전시장) > 중분류(홀 H1~H10, 라벨에 실측 치수 "…— 63×171m" 병기) > 소분류(구역, 기본값 **"홀 전체"**). 소스 = `GET /api/halls/tree`(V60 `hall_zone` 계층). 상위 변경 시 하위 초기화. 트리 로드 실패 시 degraded 노트("⚠ 평면도 목록을 불러오지 못해 현재 홀 기준으로 진행합니다")로 계속 진행. 액션: "취소" / "다음 — 조건 입력".
|
- **① 어디에**: **셀렉트 3단 캐스케이드** — 대분류(전시장: 제1/제2전시장) > 중분류(홀 H1~H10, 라벨에 실측 치수 "…— 63×171m" 병기) > 소분류(구역, 기본값 **"홀 전체"**). 소스 = `GET /api/halls/tree`(V60 `hall_zone` 계층). 상위 변경 시 하위 초기화. 트리 로드 실패 시 degraded 노트("⚠ 평면도 목록을 불러오지 못해 현재 홀 기준으로 진행합니다")로 계속 진행. 액션: "취소" / "다음 — 조건 입력".
|
||||||
@ -559,7 +544,7 @@ An enterprise dashboard for an exhibition organizer, part of "KINTEX AI 전시
|
|||||||
- **Stitch 프롬프트**:
|
- **Stitch 프롬프트**:
|
||||||
|
|
||||||
```
|
```
|
||||||
A professional floor plan editor screen for exhibition booth layout, in a Korean B2B web app "KINTEX AI 전시관리". Desktop 1440px. All UI text in Korean. Three-column editor layout. Top toolbar: a hall selector dropdown "제2전시장 · 홀7" (grouped by exhibition center), version dropdown "배치안 B · v3", autosave label "저장됨", a distinctive purple #6D4AFF button with sparkle icon "AI 자동배치", outlined buttons "규정 검증 실행" and "홀 전경 생성", and an icon button with a share icon labeled "공유" at the right end of the toolbar. Left tool panel 240px on white: a "도면" tool group with two tabs "JPG / DWG" where the DWG tab is selected by default (a crisp CAD-rendered hall floor plan), a booth palette with small square previews to pick and drag onto the canvas ("기본 부스 3×3m", "프리미엄 6×3m", "아일랜드 부스"), facility items ("무대", "라운지", "주출입구"), and layer toggles with switches ("트렌치 그리드", "기둥·비상구", "위반 오버레이" turned on). Center: large dark navy canvas #1C2536 with an "확대" (expand) icon button at its top-right corner that opens a large full-screen modal of the drawing; the canvas shows the DWG-rendered top-down exhibition hall floor-plan as the base layer, with the booth layout aligned inside the drawing's exhibition floor area (hall outline 126×90m with scale ruler) — dozens of small blue booth rectangles arranged in rows with white booth numbers like "A-101" sitting inside the floor plan, dashed grid lines for floor trenches, green emergency-exit icons on walls; overlay violations: two semi-transparent red bands across an aisle with numbered red pins "1", "2", and three booths hatched in orange with numbered orange pins "3", "4", "5". Right panel 360px with two tabs "속성 | 검증", showing the 검증 tab: summary chips "차단 2 · 경고 3", a list of violation cards each with pin number, red or orange severity dot, Korean text like "피난 통로 폭 3m 미만 (규정 §4.2)" and a small link "위치 보기". Bottom status bar: "부스 486 / 510 · 판매면적 4,374㎡ · 100%" with zoom controls. Dense engineering-tool aesthetic, light UI chrome around a dark canvas.
|
A professional floor plan editor screen for exhibition booth layout, in a Korean B2B web app "KINTEX AI 전시관리". Desktop 1440px. All UI text in Korean. Three-column editor layout. Top toolbar: a hall selector dropdown "제2전시장 · 홀7" (grouped by exhibition center), version dropdown "배치안 B · v3", autosave label "저장됨", a distinctive purple #6D4AFF button with sparkle icon "AI 자동배치", outlined buttons "규정 검증 실행" and "홀 전경 생성", and an icon button with a share icon labeled "공유" at the right end of the toolbar. Left tool panel 240px on white: booth palette items with small square previews ("기본 부스 3×3m", "프리미엄 6×3m", "아일랜드 부스"), facility items ("무대", "라운지", "주출입구"), and layer toggles with switches ("트렌치 그리드", "기둥·비상구", "위반 오버레이" turned on). Center: large dark navy canvas #1C2536 showing a scanned top-down exhibition hall floor-plan drawing (JPG blueprint style) as the base layer, with the booth layout aligned inside the drawing's exhibition floor area (hall outline 126×90m with scale ruler) — dozens of small blue booth rectangles arranged in rows with white booth numbers like "A-101" sitting inside the floor plan, dashed grid lines for floor trenches, green emergency-exit icons on walls; overlay violations: two semi-transparent red bands across an aisle with numbered red pins "1", "2", and three booths hatched in orange with numbered orange pins "3", "4", "5". Right panel 360px with two tabs "속성 | 검증", showing the 검증 tab: summary chips "차단 2 · 경고 3", a list of violation cards each with pin number, red or orange severity dot, Korean text like "피난 통로 폭 3m 미만 (규정 §4.2)" and a small link "위치 보기". Bottom status bar: "부스 486 / 510 · 판매면적 4,374㎡ · 100%" with zoom controls. Dense engineering-tool aesthetic, light UI chrome around a dark canvas.
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -573,7 +558,6 @@ A professional floor plan editor screen for exhibition booth layout, in a Korean
|
|||||||
3. 카드 본문: 안 라벨(1안/2안/3안), 지표 리스트(부스 수/목표 · 판매면적㎡ · 최소 통로폭m · 위반 건수 — 위반>0 빨강, 0건 녹색) + **WISE AI 요약**(AiLabel + 안 특성 한 줄, 예: "통로 가시성을 우선한 안입니다")
|
3. 카드 본문: 안 라벨(1안/2안/3안), 지표 리스트(부스 수/목표 · 판매면적㎡ · 최소 통로폭m · 위반 건수 — 위반>0 빨강, 0건 녹색) + **WISE AI 요약**(AiLabel + 안 특성 한 줄, 예: "통로 가시성을 우선한 안입니다")
|
||||||
4. 그리드 하단 안내 문구: **"생성한 조감 이미지는 자동 저장되어 시각화 갤러리에서 다시 보거나 공사 옥션 참고 이미지로 쓸 수 있습니다"** — 이미지는 `render_job` 영속(일회성 미리보기로 휘발 금지), 렌더 갤러리(SCR-12) `boothRef=HALL-{hallId}` 재열람 + M15 옥션 자료 패키지 `MaterialPackage.aiImageUrl` 재사용(M2-1 원칙 4)
|
4. 그리드 하단 안내 문구: **"생성한 조감 이미지는 자동 저장되어 시각화 갤러리에서 다시 보거나 공사 옥션 참고 이미지로 쓸 수 있습니다"** — 이미지는 `render_job` 영속(일회성 미리보기로 휘발 금지), 렌더 갤러리(SCR-12) `boothRef=HALL-{hallId}` 재열람 + M15 옥션 자료 패키지 `MaterialPackage.aiImageUrl` 재사용(M2-1 원칙 4)
|
||||||
5. 하단 액션: 좌 "조건 수정"(②단계 복귀) / 우 주 CTA **"이 안으로 편집 시작"** — 클릭 시 **`POST …/apply-option`으로 선택안을 실제 배치안 버전으로 저장**(3안은 비교 단계까지 미저장 미리보기 — M2-1 원칙 5. 저장 중 버튼 라벨 **"저장 중…"** 비활성) 후 다이얼로그 닫힘 → 에디터 캔버스(SCR-03) 진입(적용 홀이 현재 라우트와 다르면 해당 홀 배치 라우트로 이동)
|
5. 하단 액션: 좌 "조건 수정"(②단계 복귀) / 우 주 CTA **"이 안으로 편집 시작"** — 클릭 시 **`POST …/apply-option`으로 선택안을 실제 배치안 버전으로 저장**(3안은 비교 단계까지 미저장 미리보기 — M2-1 원칙 5. 저장 중 버튼 라벨 **"저장 중…"** 비활성) 후 다이얼로그 닫힘 → 에디터 캔버스(SCR-03) 진입(적용 홀이 현재 라우트와 다르면 해당 홀 배치 라우트로 이동)
|
||||||
- **AI 자동배치 결과 UX — 시각 우선(v2.7 — 세션 6 소유자 확정)**: **AI 자동배치(②단계 "배치안 생성") 완료 시, 배치 결과를 먼저 시각적으로 보여준다** — ③단계 진입과 동시에 **평면도 확대 팝업(`.kx-floorplan-lightbox`, SCR-03 신설 모달)에 대표안(1안)의 배치 결과를 도면 위에 크게 렌더**해 사용자가 "평면도 안에 부스가 어떻게 놓였는지"를 한눈에 확인하게 한다(시각 우선 원칙). **배치안(3안)의 지표·비교 상세는 그 다음, 버튼 클릭 시 노출** — 확대 팝업 하단/우측 "배치안 비교 보기(3안)" 버튼을 누르면 3안 카드 그리드(지표·AI 요약·조감 CTA)로 전개된다(자동으로 상세 표를 먼저 펼치지 않음). 팝업 안에서 안 전환(1안/2안/3안 토글) 시 도면 배치 렌더가 즉시 갱신. 조감(S7) 이미지는 여전히 온디맨드(원칙 3) — 배치 결과 시각은 도면 위 부스 배치 렌더(무료·즉시)이고, 포토리얼 조감은 버튼 생성.
|
|
||||||
- **이미지 확대 라이트박스(`.kx-lightbox`, v2.6 신설)**: 생성 완료된 조감 이미지 클릭 → 확대 팝업(`role="dialog"` `aria-modal`, 배경 클릭·Esc 닫기) — 원본 크기 이미지 + **워터마크 고지 문구 유지**("AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다. 계약·심사 서류에 사용 불가.") + 액션 2개: **"이미지 저장"**(`<a download>` 다운로드) / "닫기".
|
- **이미지 확대 라이트박스(`.kx-lightbox`, v2.6 신설)**: 생성 완료된 조감 이미지 클릭 → 확대 팝업(`role="dialog"` `aria-modal`, 배경 클릭·Esc 닫기) — 원본 크기 이미지 + **워터마크 고지 문구 유지**("AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다. 계약·심사 서류에 사용 불가.") + 액션 2개: **"이미지 저장"**(`<a download>` 다운로드) / "닫기".
|
||||||
- **샘플 지표 산술 정합(v2.5 — B-08 정정)**: 판매면적 샘플은 부스 수 × 기본 3×3m(9㎡) 기준으로 통일 — **510부스 = 4,590㎡**(SCR-03 하단 상태 바의 486부스 = 4,374㎡와 동일 산식). 기존 4,420㎡ 표기는 산술 오류로 폐기.
|
- **샘플 지표 산술 정합(v2.5 — B-08 정정)**: 판매면적 샘플은 부스 수 × 기본 3×3m(9㎡) 기준으로 통일 — **510부스 = 4,590㎡**(SCR-03 하단 상태 바의 486부스 = 4,374㎡와 동일 산식). 기존 4,420㎡ 표기는 산술 오류로 폐기.
|
||||||
- **상태**: S7 미요청(기본) — 점선 CTA 박스(스켈레톤 아님) · 생성 요청 중 — 버튼 "요청 중…" 비활성 · 생성 중 — AiImage QUEUED/RUNNING 스피너 · 생성 실패/큐 미가용 — 오류 노트("이미지 생성 큐가 준비되지 않았습니다…") 표시하되 **배치안 선택·적용은 계속 가능** · apply-option 저장 실패 — 오류 노트("배치안 저장 실패: …") + 재시도.
|
- **상태**: S7 미요청(기본) — 점선 CTA 박스(스켈레톤 아님) · 생성 요청 중 — 버튼 "요청 중…" 비활성 · 생성 중 — AiImage QUEUED/RUNNING 스피너 · 생성 실패/큐 미가용 — 오류 노트("이미지 생성 큐가 준비되지 않았습니다…") 표시하되 **배치안 선택·적용은 계속 가능** · apply-option 저장 실패 — 오류 노트("배치안 저장 실패: …") + 재시도.
|
||||||
@ -770,8 +754,7 @@ An enterprise Business Intelligence analytics dashboard for a venue operator, pa
|
|||||||
- **모듈·우선순위**: M14 현장운영 · **P2**(PLANNING §5A M14). 초기 범위는 **M4(전력 부하)·M10(체크인 혼잡) 파생 데이터 + 수기 입력** 결합이며, CCTV/센서/IoT 실시간은 킨텍스 시설 인프라 의존(후속).
|
- **모듈·우선순위**: M14 현장운영 · **P2**(PLANNING §5A M14). 초기 범위는 **M4(전력 부하)·M10(체크인 혼잡) 파생 데이터 + 수기 입력** 결합이며, CCTV/센서/IoT 실시간은 킨텍스 시설 인프라 의존(후속).
|
||||||
- **레이아웃 (위→아래)**:
|
- **레이아웃 (위→아래)**:
|
||||||
1. 헤더: 타이틀 "홀 현장운영" + 부제 "실시간 시설 모니터링 및 자원 최적화" + 우측 [실시간] / [이력 조회] 토글
|
1. 헤더: 타이틀 "홀 현장운영" + 부제 "실시간 시설 모니터링 및 자원 최적화" + 우측 [실시간] / [이력 조회] 토글
|
||||||
2. **12컬럼 벤토 그리드 — 히어로: 혼잡도 맵(span 8, 높이 500)**: **홀 도면 배경 = DWG 렌더 평면도**(`/media/floorplans/dwg/hall{1~10}.png`, 홀 선택 시 해당 홀 도면 표출 — v2.7 세션 6, 추상 격자 대신 실제 CAD 렌더 평면도 위에 혼잡 히트 오버레이) + 혼잡 히트(홀별 셀 Hall1~10, 임계 홀은 error 하이라이트 "홀3(혼잡)"), 하단 범례(원활 녹 / 보통 주황 / 혼잡 빨강)와 총 추정 인원. 전시장1/전시장2 홀 그룹 셀렉트. **데이터 미연동 시 상단 배지 "센서 미연동 · 체크인(M10) 파생/샘플"**
|
2. **12컬럼 벤토 그리드 — 히어로: 혼잡도 맵(span 8, 높이 500)**: 홀 도면 위 혼잡 히트(홀별 셀 Hall1~5, 임계 홀은 error 하이라이트 "홀3(혼잡)"), 하단 범례(원활 녹 / 보통 주황 / 혼잡 빨강)와 총 추정 인원. 전시장1/전시장2 홀 그룹 셀렉트. **데이터 미연동 시 상단 배지 "센서 미연동 · 체크인(M10) 파생/샘플"**
|
||||||
- **홀 카드·상세 평면도 표출(v2.7 — 세션 6 소유자 확정)**: 홀 목록 카드 및 홀 상세는 **DWG 렌더 평면도 썸네일/뷰어(hall1~10.png)** 를 표출한다(홀별 도면 시각 확인). 미보유 홀은 JPG 폴백 또는 "도면 준비중" 플레이스홀더. 도면 클릭 시 확대 팝업(SCR-03 `.kx-floorplan-lightbox` 재사용).
|
|
||||||
3. 우측(span 4) — **에너지·환경**: HVAC 최적화 카드(평균 온도 22.5°C, 전력 부하 4.2MW, 상태 배지 "최적"), 조명존 카드(전시홀 85% / 공용부 100%, 자동 디밍 토글) — 게이지·진행 바
|
3. 우측(span 4) — **에너지·환경**: HVAC 최적화 카드(평균 온도 22.5°C, 전력 부하 4.2MW, 상태 배지 "최적"), 조명존 카드(전시홀 85% / 공용부 100%, 자동 디밍 토글) — 게이지·진행 바
|
||||||
4. 하단(span 12) — **주차 점유**: 4개 존 카드(존A 지하 98% error / 존B 옥외남측 75% warning / 존C 옥외북측 42% success / 하역장 20%), 각 카드 하단 점유율 바 + "N / 총면수" — iparking 연계 표기
|
4. 하단(span 12) — **주차 점유**: 4개 존 카드(존A 지하 98% error / 존B 옥외남측 75% warning / 존C 옥외북측 42% success / 하역장 20%), 각 카드 하단 점유율 바 + "N / 총면수" — iparking 연계 표기
|
||||||
- **핵심 컴포넌트/상태**: 히트맵(도면 오버레이·범례) · 게이지/진행 바(Recharts `RadialBar` 또는 progress) · 존 점유 카드(임계 상태색 §1-4) · 이상 시 알림 배지. 로딩 — 위젯 스켈레톤 · 빈 상태 — "진행 중인 행사가 없습니다 — 운영 데이터는 개장 시 활성화됩니다" · 에러 — 위젯별 "데이터 원본 연결 안 됨" 안내.
|
- **핵심 컴포넌트/상태**: 히트맵(도면 오버레이·범례) · 게이지/진행 바(Recharts `RadialBar` 또는 progress) · 존 점유 카드(임계 상태색 §1-4) · 이상 시 알림 배지. 로딩 — 위젯 스켈레톤 · 빈 상태 — "진행 중인 행사가 없습니다 — 운영 데이터는 개장 시 활성화됩니다" · 에러 — 위젯별 "데이터 원본 연결 안 됨" 안내.
|
||||||
@ -779,7 +762,7 @@ An enterprise Business Intelligence analytics dashboard for a venue operator, pa
|
|||||||
- **Stitch 프롬프트**:
|
- **Stitch 프롬프트**:
|
||||||
|
|
||||||
```
|
```
|
||||||
A real-time hall operations dashboard for a KINTEX exhibition-center facility manager, Korean B2B internal tool, desktop 1440px, light theme, blue #0066B3, Korean sans-serif. All UI text in Korean. Left sidebar 240px (홀 운영 highlighted). Header: title "홀 현장운영" with subtitle "실시간 시설 모니터링 및 자원 최적화" and a segmented toggle "[실시간] / [이력 조회]". A 12-column bento grid. Hero card (span 8, tall ~500px): "실시간 혼잡도 히트맵" — a DWG-rendered CAD floor plan of the selected hall (crisp architectural line drawing) as the background, with soft colored heat blobs (green/orange/red) blurred over it, Hall 3 highlighted red with label "홀3 (혼잡)", a hall-group select "홀 1-10 (제1·제2전시장) ▾", a small top-left badge "센서 미연동 · 체크인 파생/샘플", and a bottom legend "원활 / 보통 / 혼잡" plus "총 추정 인원 14,250". Below or beside it, hall cards each show a small DWG-rendered floor-plan thumbnail. Right column (span 4): a "HVAC 최적화" card showing "22.5°C 평균온도", a progress bar, "전력 부하 4.2MW", and a green status pill "최적"; below it a "조명 존" card with rows "전시홀 85%", "공용부 100%" with bars and an "자동 디밍" toggle switch. Bottom (span 12): "주차 점유 현황" with 4 zone cards — "존A(지하) 98%" red, "존B(옥외남) 75%" amber, "존C(옥외북) 42%" green, "하역장 20%", each with an occupancy bar and "1,176 / 1,200" style counts. Dense real-time operations monitoring aesthetic.
|
A real-time hall operations dashboard for a KINTEX exhibition-center facility manager, Korean B2B internal tool, desktop 1440px, light theme, blue #0066B3, Korean sans-serif. All UI text in Korean. Left sidebar 240px (홀 운영 highlighted). Header: title "홀 현장운영" with subtitle "실시간 시설 모니터링 및 자원 최적화" and a segmented toggle "[실시간] / [이력 조회]". A 12-column bento grid. Hero card (span 8, tall ~500px): "실시간 혼잡도 히트맵" — a top-down hall layout with a 5-cell grid labeled Hall 1–5, soft colored heat blobs (green/orange/red) blurred over it, Hall 3 highlighted red with label "홀3 (혼잡)", a hall-group select "홀 1-5 (제1전시장) ▾", a small top-left badge "센서 미연동 · 체크인 파생/샘플", and a bottom legend "원활 / 보통 / 혼잡" plus "총 추정 인원 14,250". Right column (span 4): a "HVAC 최적화" card showing "22.5°C 평균온도", a progress bar, "전력 부하 4.2MW", and a green status pill "최적"; below it a "조명 존" card with rows "전시홀 85%", "공용부 100%" with bars and an "자동 디밍" toggle switch. Bottom (span 12): "주차 점유 현황" with 4 zone cards — "존A(지하) 98%" red, "존B(옥외남) 75%" amber, "존C(옥외북) 42%" green, "하역장 20%", each with an occupancy bar and "1,176 / 1,200" style counts. Dense real-time operations monitoring aesthetic.
|
||||||
```
|
```
|
||||||
|
|
||||||
- **라우트**: `/ops/operations` (테넌트·운영 전역, 홀매니저) · 행사 진행 중 스코프 `/events/:eventId/operations`. MDI 적용(홀매니저 — §2.7-6).
|
- **라우트**: `/ops/operations` (테넌트·운영 전역, 홀매니저) · 행사 진행 중 스코프 `/events/:eventId/operations`. MDI 적용(홀매니저 — §2.7-6).
|
||||||
@ -795,13 +778,12 @@ A real-time hall operations dashboard for a KINTEX exhibition-center facility ma
|
|||||||
2. 필터/컨트롤 바: 홀 셀렉트(전체 홀 1-10 / 홀별) · 카테고리(전시/문화·예술/무역·산업/기술) · 상태(예정/진행중/종료) + 우측 [목록] / [캘린더] 뷰 토글
|
2. 필터/컨트롤 바: 홀 셀렉트(전체 홀 1-10 / 홀별) · 카테고리(전시/문화·예술/무역·산업/기술) · 상태(예정/진행중/종료) + 우측 [목록] / [캘린더] 뷰 토글
|
||||||
3. **좌측 2/3 — 행사 리스트**: 행사 카드(포스터 썸네일, 행사명, 상태 배지 §1-4 "진행중/예정/종료", 카테고리, 기간, 배정 홀, 추정 인원, [상세 편집]·[반입/물류 보기] 버튼)
|
3. **좌측 2/3 — 행사 리스트**: 행사 카드(포스터 썸네일, 행사명, 상태 배지 §1-4 "진행중/예정/종료", 카테고리, 기간, 배정 홀, 추정 인원, [상세 편집]·[반입/물류 보기] 버튼)
|
||||||
4. 우측 1/3 — **미니 캘린더**(월 이동, 오늘·행사일 하이라이트) + **홀 가동률 요약**(홀1-3 85% · 홀4-5 40% 진행 바)
|
4. 우측 1/3 — **미니 캘린더**(월 이동, 오늘·행사일 하이라이트) + **홀 가동률 요약**(홀1-3 85% · 홀4-5 40% 진행 바)
|
||||||
- **레이아웃 규격 확정(v2.7 — 세션 6 소유자 확정, "오른쪽 달력 깨짐·짤림" 해소)**: 본문 2단 스플릿은 **좌 목록 `minmax(0,1fr)` + 우측 미니 캘린더 사이드바 `minmax(280px, 340px)`** 트랙으로 고정한다 — 우측 사이드바가 280px 미만으로 찌그러지거나 340px를 넘겨 캘린더 격자가 깨지지 않게 한다. 미니 캘린더는 이 폭 안에서 7열 격자가 균등 분할(셀 정사각 근사)되고, 좁은 뷰포트(태블릿 이하)에서는 사이드바가 하단으로 접혀 전폭 배치. 좌측 목록 트랙은 `min-width:0`(G5)로 블로우아웃 방지.
|
- **핵심 컴포넌트/상태**: 행사 카드 리스트·미니 캘린더(월 그리드)·가동률 진행 바·StatusBadge 재사용·DdayChip(개장 임박). 로딩 — 카드 스켈레톤 · 빈 상태 — "등록된 행사가 없습니다 — 새 행사를 생성하세요" CTA · 에러 — 리스트 로드 실패 배너.
|
||||||
- **핵심 컴포넌트/상태**: 행사 카드 리스트·미니 캘린더(월 그리드)·가동률 진행 바·StatusBadge 재사용·DdayChip(개장 임박). 행사 리스트 그리드에 §1-4A G3 PDF 출력 버튼. 로딩 — 카드 스켈레톤 · 빈 상태 — "등록된 행사가 없습니다 — 새 행사를 생성하세요" CTA · 에러 — 리스트 로드 실패 배너.
|
|
||||||
- **데이터 원천 (실/샘플 경계면)**: **부분 실 API + 부분 샘플.** 행사(Event) 목록·기간·배정 홀은 **실 API 가능**(Event 엔티티 존재). 반면 **홀 가동률 집계·외부 행사일정 시스템 연동은 미구현 → 샘플**. 캘린더 점유 매트릭스는 배정(M1) 데이터 정합 후 실 전환.
|
- **데이터 원천 (실/샘플 경계면)**: **부분 실 API + 부분 샘플.** 행사(Event) 목록·기간·배정 홀은 **실 API 가능**(Event 엔티티 존재). 반면 **홀 가동률 집계·외부 행사일정 시스템 연동은 미구현 → 샘플**. 캘린더 점유 매트릭스는 배정(M1) 데이터 정합 후 실 전환.
|
||||||
- **Stitch 프롬프트**:
|
- **Stitch 프롬프트**:
|
||||||
|
|
||||||
```
|
```
|
||||||
An exhibition schedule management screen for a venue operator, Korean B2B web app "KINTEX AI 전시관리", desktop 1440px, light theme, blue #0066B3, Korean sans-serif. All UI text in Korean. Left sidebar 240px (전시 일정 highlighted). Header: title "전시 일정" with subtitle "킨텍스 전 홀 행사 일정 관리·모니터링". A filter bar: three selects "전체 홀 (1-10) ▾", "전체 카테고리 ▾", "전체 상태 ▾", and on the right a view toggle "[목록] / [캘린더]". Main grid: left 2/3 a vertical list of event cards, each card with a poster thumbnail on the left and details on the right — event title "스마트팩토리 코리아", a status pill ("진행중" green or "예정" amber), category "무역·산업 전시", a row of meta with icons "2026.09.05–09.08", "홀 4, 5", "추정 25,000명", and two small buttons "상세 편집" and "반입/물류 보기". Right 1/3 column is a fixed sidebar between 280px and 340px wide (never squeezed narrower or stretched wider): a mini month calendar "2026년 8월" with prev/next chevrons and a highlighted date "15" whose 7-column grid divides evenly into near-square cells, and below it a "홀 가동률 (8월)" card with progress bars "홀 1-3 85%" and "홀 4-5 40%". The left list column must not blow out (min-width 0, text ellipsis). Clean scheduling/console aesthetic, dense but organized.
|
An exhibition schedule management screen for a venue operator, Korean B2B web app "KINTEX AI 전시관리", desktop 1440px, light theme, blue #0066B3, Korean sans-serif. All UI text in Korean. Left sidebar 240px (전시 일정 highlighted). Header: title "전시 일정" with subtitle "킨텍스 전 홀 행사 일정 관리·모니터링". A filter bar: three selects "전체 홀 (1-10) ▾", "전체 카테고리 ▾", "전체 상태 ▾", and on the right a view toggle "[목록] / [캘린더]". Main grid: left 2/3 a vertical list of event cards, each card with a poster thumbnail on the left and details on the right — event title "스마트팩토리 코리아", a status pill ("진행중" green or "예정" amber), category "무역·산업 전시", a row of meta with icons "2026.09.05–09.08", "홀 4, 5", "추정 25,000명", and two small buttons "상세 편집" and "반입/물류 보기". Right 1/3 column: a mini month calendar "2026년 8월" with prev/next chevrons and a highlighted date "15", and below it a "홀 가동률 (8월)" card with progress bars "홀 1-3 85%" and "홀 4-5 40%". Clean scheduling/console aesthetic, dense but organized.
|
||||||
```
|
```
|
||||||
|
|
||||||
- **라우트**: `/schedule` (테넌트 전역, 운영·주최자) · 캘린더 딥링크 `/schedule/calendar`. MDI 적용(주최자·운영 — §2.7-6).
|
- **라우트**: `/schedule` (테넌트 전역, 운영·주최자) · 캘린더 딥링크 `/schedule/calendar`. MDI 적용(주최자·운영 — §2.7-6).
|
||||||
@ -1344,10 +1326,9 @@ A password reset screen for "KINTEX AI 전시관리", Korean B2B web app, deskto
|
|||||||
|
|
||||||
- **대상**: 관리자·홀매니저·주최자·업체(필수)·관람객(선택) · **P1** · **진입 경로**: 최초 로그인 강제 / 마이페이지 보안 탭
|
- **대상**: 관리자·홀매니저·주최자·업체(필수)·관람객(선택) · **P1** · **진입 경로**: 최초 로그인 강제 / 마이페이지 보안 탭
|
||||||
- **레이아웃**: QR(authenticator 등록)·시크릿 키·6자리 코드 검증. 재설정·해제(관리자는 사용자 OTP 초기화). TOTP RFC6238 설명. 상태: 코드 불일치·성공. 참조: UIWS `auth/OtpSetupPage`.
|
- **레이아웃**: QR(authenticator 등록)·시크릿 키·6자리 코드 검증. 재설정·해제(관리자는 사용자 OTP 초기화). TOTP RFC6238 설명. 상태: 코드 불일치·성공. 참조: UIWS `auth/OtpSetupPage`.
|
||||||
- **브랜드 아이콘 확정(v2.7 — 세션 6 소유자 확정)**: 카드 상단 **로고는 방패(shield) 아이콘이 아니라 KINTEX 워드마크 로고**를 사용한다 — 크기 **32px**(구현 정본 `.kx-otp__logo`, `OtpSetupPage`·`OtpChallengePanel` 공통). 방패/자물쇠형 보안 아이콘으로 대체 금지. 로고 아래 화면 제목("2차 인증(OTP) 등록")은 §1-4A G2에 따라 선 SVG 아이콘 + 텍스트.
|
|
||||||
- **Stitch 프롬프트**:
|
- **Stitch 프롬프트**:
|
||||||
```
|
```
|
||||||
A two-factor authentication (OTP) setup screen for "KINTEX AI 전시관리", Korean B2B web app, desktop 1440px, light theme, blue #0066B3, on #F9FAFB. All UI text in Korean. Center card: at the TOP a KINTEX WORDMARK logo (about 32px tall, blue #0066B3) — NOT a shield or padlock security icon. Card titled "2차 인증(OTP) 등록": step 1 "인증 앱으로 QR을 스캔하세요" with a QR code image and a secret key text with copy button, step 2 "6자리 코드 입력" with a 6-digit OTP input, and a primary "등록 완료" button; a helper "Google Authenticator 등 TOTP 앱 지원". Use thin line (stroke) SVG icons only, no emoji. Security-focused, calm aesthetic.
|
A two-factor authentication (OTP) setup screen for "KINTEX AI 전시관리", Korean B2B web app, desktop 1440px, light theme, blue #0066B3, on #F9FAFB. All UI text in Korean. Center card titled "2차 인증(OTP) 등록": step 1 "인증 앱으로 QR을 스캔하세요" with a QR code image and a secret key text with copy button, step 2 "6자리 코드 입력" with a 6-digit OTP input, and a primary "등록 완료" button; a helper "Google Authenticator 등 TOTP 앱 지원". Security-focused, calm aesthetic.
|
||||||
```
|
```
|
||||||
|
|
||||||
- **라우트**: `/otp-setup`. MDI 비적용(로그인 게이트).
|
- **라우트**: `/otp-setup`. MDI 비적용(로그인 게이트).
|
||||||
@ -1623,22 +1604,14 @@ Directly below, three large branching track cards in one row that lift on hover
|
|||||||
### 3C-0. 공개 셸(PublicShell) + 3-트랙 스위처 (전 SCR-T*·SCR-P* 공통 크롬)
|
### 3C-0. 공개 셸(PublicShell) + 3-트랙 스위처 (전 SCR-T*·SCR-P* 공통 크롬)
|
||||||
|
|
||||||
- **재사용 전제**: SCR-P*가 쓰는 **PublicShell**(전통 페이지 헤더 GNB·푸터·SSR/CDN·MDI 비적용)을 그대로 재사용하되, **상단에 3-트랙 전환 탭(TrackSwitcher)을 추가**한다. 관문(SCR-T0)은 트랙 미선택 상태, 서브홈(T1~T3)은 해당 트랙 탭 활성.
|
- **재사용 전제**: SCR-P*가 쓰는 **PublicShell**(전통 페이지 헤더 GNB·푸터·SSR/CDN·MDI 비적용)을 그대로 재사용하되, **상단에 3-트랙 전환 탭(TrackSwitcher)을 추가**한다. 관문(SCR-T0)은 트랙 미선택 상태, 서브홈(T1~T3)은 해당 트랙 탭 활성.
|
||||||
- **루트 디폴트 변경(v2.7 — 세션 6 소유자 확정 P1)**: **공개 루트 `/`는 `/visitor`로 리다이렉트(디폴트)** 한다 — kintex.wise.ai.kr 접속 시 관람객 메인(SCR-T1)이 첫 화면. ★기존 "루트 `/` = 제품 소개 관문(SCR-T0)" 원칙을 **대체**한다(메모리 [[product-identity-first]] 공개 루트 한정 갱신). SCR-T0 관문 랜딩은 라우트로 보존하되 루트 진입점에서 내리고, 제품 소개는 TrackSwitcher·푸터·about 링크로 접근. 인증 세션 시 `/home` 우선 리다이렉트는 유지.
|
- **TrackSwitcher 사양**(헤더 최상단 유틸 행 또는 GNB 좌측): 세그먼트 3탭 `관람 · 비즈니스 · 협력사`(라벨=`track.shell.tab.visitor|business|agency`), 각 탭 클릭 시 `/visitor`·`/business`·`/agency` 이동. 좌측에 **KINTEX AI 전시·행사시스템** 워드마크(클릭 시 `/` 관문 복귀). 우측 유틸: 언어 전환(한/영/중/일 `track.shell.lang.*`)·`로그인`(→ SCR-01)·인증 세션 시 `내 홈`(→ `/home`).
|
||||||
- **셸 로고 확정(v2.7 — P2)**: TrackSwitcher 좌측 로고는 **킨텍스 CI 이미지 `ci/CI_JPG/CI_01.jpg`** 를 사용한다(워드마크 텍스트 대체 — `/visitor`·`/business`·`/agency` 전 트랙 공통, 클릭 시 `/`→`/visitor` 복귀). 배경 대비 확보(밝은 헤더 위 원본, 어두운 헤더는 화이트 변형 필요 시 CSS 필터/대체 자산). `alt="KINTEX"`.
|
|
||||||
- **로고 옆 행사검색(v2.7 — P4)**: 로고 **바로 우측에 행사검색 입력창**을 배치 — 입력폭 **10자(`width: 10ch` 기준, 최소 폭)**, placeholder "행사 검색", 선 SVG 돋보기 아이콘, Enter/버튼 시 행사 검색 결과(공개 행사 목록 필터)로 이동. 좁은 뷰포트에서는 아이콘 버튼으로 축약(탭 시 확장). `role="search"`.
|
|
||||||
- **공개 정보 페이지 4종(v2.7 — P3)**: GNB는 **행사 · 참가안내 · 관람안내 · 교통** 4개 공개 정보 페이지를 **실동작 링크**로 노출한다(기존 "안됨" 해소) — ①**행사**=공개 행사 목록/캘린더(SCR-P1 그리드·`/api/public/events`) ②**참가안내**=비즈니스 트랙 참가 안내(SCR-T2·SCR-P6) ③**관람안내**=관람객 방문 가이드(SCR-T1 방문 가이드 허브·`visitor_guide`) ④**교통**=오시는 길(GTX-A 킨텍스역·주차, `transport_info`/`parking_info`). 각 항목은 트랙 서브홈/기존 SCR로 딥링크(신규 화면 없이 재사용), 언어(한/영/중/일) 병기.
|
|
||||||
- **TrackSwitcher 사양**(헤더 최상단 유틸 행 또는 GNB 좌측): 세그먼트 3탭 `관람 · 비즈니스 · 협력사`(라벨=`track.shell.tab.visitor|business|agency`), 각 탭 클릭 시 `/visitor`·`/business`·`/agency` 이동. 좌측에 **CI 로고(CI_01.jpg)** + **행사검색 입력(10ch)**(위 P2·P4). 우측 유틸: 언어 전환(한/영/중/일 `track.shell.lang.*`)·`로그인`(→ SCR-01)·인증 세션 시 `내 홈`(→ `/home`). **유색 배경 버튼 글자는 §1-4A G6 `--color-on-accent`(흰색) 전역 준수**(P5).
|
|
||||||
- **트랙 히어로 5장 로테이션(v2.7 — 세션 6 소유자 확정 P7 · SCR-T1·T2·T3 공통)**: 3개 트랙 페이지(관람/비즈니스/협력사)의 **히어로 배경은 각 5장 이미지 로테이션(슬라이드쇼)** 으로 한다.
|
|
||||||
- **이미지 = 신규 크롤 실이미지**(kintex.com 등 실자산 크롤 — 나노바나나 AI 생성 배경이 아닌 실사진, 세션 6 지시). 빌드 내장 `/media/hero/<track>/hero-<track>-01~05.jpg`(+`.webp`·`@2x`, `.jpg` 폴백). **트랙별 이미지 셋 차별화** — 관람=관람객·전시장 전경·행사 현장, 비즈니스=부스·상담·계약/시공, 협력사=시공 현장·장비·안전 톤(셋이 서로 구분되게).
|
|
||||||
- **로테이션 규격**: 5초 간격 자동 전환(크로스페이드), **하단 도트 인디케이터**(5개, 현재 활성 강조·클릭 이동), hover/포커스 시 일시정지. **`prefers-reduced-motion` 시 자동 로테이션 정지**(첫 장 고정·도트로 수동 전환만). **CLS 0**(고정 종횡비 프레임·이미지 크기 예약·레이아웃 시프트 0), 첫 장 preload·나머지 lazy. AI 생성 이미지가 아니므로 "AI 생성 예상 이미지" 워터마크 미부착(실사진). 접근성: `role="group"`·`aria-roledescription="carousel"`·도트 `aria-label`.
|
|
||||||
- **활성 표시**: 활성 트랙은 `primary-600` 밑줄+굵게, 색 외 `aria-current="page"` 병기(색만으로 전달 금지). 모바일(390px)은 탭을 상단 가로 세그먼트로 유지(스크롤 없이 3탭 균등폭).
|
- **활성 표시**: 활성 트랙은 `primary-600` 밑줄+굵게, 색 외 `aria-current="page"` 병기(색만으로 전달 금지). 모바일(390px)은 탭을 상단 가로 세그먼트로 유지(스크롤 없이 3탭 균등폭).
|
||||||
- **테넌트 정합(§1A-3)**: 셸은 서브도메인 테넌트 컨텍스트 하에서 해당 테넌트 콘텐츠만 노출(워드마크·브랜딩 분기). 트랙 셸은 `tenant_id` 격리 위에서 동작.
|
- **테넌트 정합(§1A-3)**: 셸은 서브도메인 테넌트 컨텍스트 하에서 해당 테넌트 콘텐츠만 노출(워드마크·브랜딩 분기). 트랙 셸은 `tenant_id` 격리 위에서 동작.
|
||||||
- **접근성**: TrackSwitcher는 `role="tablist"`(수평)·로빙 탭인덱스·포커스 링 `primary-600` 2px. 푸터 `role="contentinfo"`. **i18n 네임스페이스 `track.shell.*`**.
|
- **접근성**: TrackSwitcher는 `role="tablist"`(수평)·로빙 탭인덱스·포커스 링 `primary-600` 2px. 푸터 `role="contentinfo"`. **i18n 네임스페이스 `track.shell.*`**.
|
||||||
|
|
||||||
### SCR-T0 트랙 관문 랜딩 (3-트랙 분기 관문) [공통 · 공개 · 라우트 `/gateway`]
|
### SCR-T0 트랙 관문 랜딩 (3-트랙 분기 관문) [공통 · 공개 · 루트 `/`]
|
||||||
|
|
||||||
- **루트 디폴트 변경(v2.7 — 세션 6 소유자 확정 P1)**: **공개 루트 `/`는 이제 `/visitor`(SCR-T1)로 리다이렉트**한다(관람객 메인이 첫 화면). SCR-T0 관문은 라우트로 **보존하되 루트 진입점에서 내림**(직접/`/gateway` 접근·about 링크·TrackSwitcher로 도달). 이하 스펙(제품 히어로·3-트랙 카드)은 유지.
|
- **대상**: 일반 대중·대외 유입 전원(미인증) · **모듈/P**: 공통 대외 접점(M12 일부 재사용) · P1 · **진입 경로**: 루트 도메인 `/` 직접 진입·검색·광고·QR. 인증 세션 시 `/home` 우선 리다이렉트(위 SCR-HOME 관계 참조)
|
||||||
- **대상**: 일반 대중·대외 유입 전원(미인증) · **모듈/P**: 공통 대외 접점(M12 일부 재사용) · P1 · **진입 경로**: 관문 라우트 직접 진입·about 링크. **루트 `/` 진입은 `/visitor`로 리다이렉트**(위 변경). 인증 세션 시 `/home` 우선 리다이렉트(위 SCR-HOME 관계 참조)
|
|
||||||
- **레이아웃**(위→아래, PublicShell·풀블리드 마케팅 밀도):
|
- **레이아웃**(위→아래, PublicShell·풀블리드 마케팅 밀도):
|
||||||
1. **PublicShell 헤더 + TrackSwitcher**(관문=트랙 미선택, 워드마크·언어·로그인)
|
1. **PublicShell 헤더 + TrackSwitcher**(관문=트랙 미선택, 워드마크·언어·로그인)
|
||||||
2. **제품 히어로**: 풀폭 히어로 — 나노바나나 전시 전경 이미지(워터마크 "AI 생성 예상 이미지") 배경 + 대형 헤드라인 "**KINTEX AI 전시·행사시스템**" + 서브카피(전시 기획→시공→관람 전 과정 AI 자동화) + 보조 CTA "관람 사전등록"·"참가 문의". **관람객 시각 1순위**(코엑스 VISITOR 메인 톤)
|
2. **제품 히어로**: 풀폭 히어로 — 나노바나나 전시 전경 이미지(워터마크 "AI 생성 예상 이미지") 배경 + 대형 헤드라인 "**KINTEX AI 전시·행사시스템**" + 서브카피(전시 기획→시공→관람 전 과정 AI 자동화) + 보조 CTA "관람 사전등록"·"참가 문의". **관람객 시각 1순위**(코엑스 VISITOR 메인 톤)
|
||||||
@ -1663,7 +1636,7 @@ A public landing "track gateway" home page for the product "KINTEX AI 전시·
|
|||||||
- **레이아웃**(위→아래, PublicShell·풀블리드 마케팅 밀도·반응형 풀스크린):
|
- **레이아웃**(위→아래, PublicShell·풀블리드 마케팅 밀도·반응형 풀스크린):
|
||||||
1. **PublicShell 헤더 + TrackSwitcher**("관람" 활성·`aria-current`) + 우측 유틸(언어 한/영/중/일·**라이트/다크 테마 토글**·`로그인`·인증 시 `내 홈`)
|
1. **PublicShell 헤더 + TrackSwitcher**("관람" 활성·`aria-current`) + 우측 유틸(언어 한/영/중/일·**라이트/다크 테마 토글**·`로그인`·인증 시 `내 홈`)
|
||||||
2. **풀블리드 히어로 + AI 관람 도우미(중심 요소)**:
|
2. **풀블리드 히어로 + AI 관람 도우미(중심 요소)**:
|
||||||
- 배경 = **크롤 실이미지 5장 로테이션**(§3C-0 "트랙 히어로 5장 로테이션" P7 — 관람 트랙 셋 `/media/hero/visitor/*`, 도트 인디케이터·5초 자동전환·`prefers-reduced-motion` 정지·CLS 0). 실사진이므로 AI 워터마크 미부착. 텍스트 대비용 딤 오버레이 유지. **v2.7 변경**: 기존 "나노바나나 AI 생성 배경"을 크롤 실이미지 로테이션으로 대체(나노바나나 시공 예상 이미지는 AI 능력 쇼케이스 타일에서만 홍보). 헤드라인 "**무엇이든 물어보세요 — AI가 관람을 안내합니다**"(H1/Display) + 서브카피 "전시 일정·오시는 길·입장권·부스 위치를 대화로 한 번에".
|
- 배경 = 대형 전시장 전경 비주얼(나노바나나 AI 생성, 워터마크 "AI 생성 예상 이미지" 상시·텍스트 대비용 딤 오버레이). 헤드라인 "**무엇이든 물어보세요 — AI가 관람을 안내합니다**"(H1/Display) + 서브카피 "전시 일정·오시는 길·입장권·부스 위치를 대화로 한 번에".
|
||||||
- **AI 관람 도우미 대화 입력창(히어로 정중앙·대형)**: 폭 넓은 자연어 질문 인풋(placeholder 로테이션 "이번 주말 로봇 전시 있어?" → "킨텍스 주차 어디에 해요?" → "가족과 볼만한 전시 추천") + 우측 전송 버튼(선 SVG send 아이콘) + 좌측 AiLabel(보라 `ai-accent` "AI 도우미"). 인풋 하단 **예시 질문칩(자주 묻는 것)** 5개: `이번 주말 로봇 전시 있어?` · `킨텍스 주차 어디에 해요?` · `가족과 볼만한 전시 추천` · `사전등록 어떻게 해요?` · `GTX-A로 얼마나 걸려요?`(칩 클릭=즉시 질의).
|
- **AI 관람 도우미 대화 입력창(히어로 정중앙·대형)**: 폭 넓은 자연어 질문 인풋(placeholder 로테이션 "이번 주말 로봇 전시 있어?" → "킨텍스 주차 어디에 해요?" → "가족과 볼만한 전시 추천") + 우측 전송 버튼(선 SVG send 아이콘) + 좌측 AiLabel(보라 `ai-accent` "AI 도우미"). 인풋 하단 **예시 질문칩(자주 묻는 것)** 5개: `이번 주말 로봇 전시 있어?` · `킨텍스 주차 어디에 해요?` · `가족과 볼만한 전시 추천` · `사전등록 어떻게 해요?` · `GTX-A로 얼마나 걸려요?`(칩 클릭=즉시 질의).
|
||||||
- **AI 답변 패널(질의 시 인풋 아래 확장·대화형)**: 답변 텍스트 + **구조화 결과 카드**(매칭 전시=포스터·행사명·기간·홀·D-day·"사전등록"/교통=경로·소요시간·주차 카드/입장=사전등록·입장권 CTA/부스·wayfinding 딥링크) + **후속 질문칩**("무료인가요?"·"길찾기 열기"·"티켓 예매") + 하단 고지문 "AI 생성 답변 — 정확한 정보는 공식 안내를 확인하세요"(제거 불가) + 근거 출처 링크. AI 산출물은 §1-4 AiLabel(보라)로 구분.
|
- **AI 답변 패널(질의 시 인풋 아래 확장·대화형)**: 답변 텍스트 + **구조화 결과 카드**(매칭 전시=포스터·행사명·기간·홀·D-day·"사전등록"/교통=경로·소요시간·주차 카드/입장=사전등록·입장권 CTA/부스·wayfinding 딥링크) + **후속 질문칩**("무료인가요?"·"길찾기 열기"·"티켓 예매") + 하단 고지문 "AI 생성 답변 — 정확한 정보는 공식 안내를 확인하세요"(제거 불가) + 근거 출처 링크. AI 산출물은 §1-4 AiLabel(보라)로 구분.
|
||||||
- 보조 CTA(인풋 하단·항상 노출): "사전등록"·"입장권 예매"(→ SCR-P4·P7).
|
- 보조 CTA(인풋 하단·항상 노출): "사전등록"·"입장권 예매"(→ SCR-P4·P7).
|
||||||
@ -1711,7 +1684,7 @@ All exhibition, transport, parking, entry and amenity data shown here is served
|
|||||||
- **대상**: 전시주최자·참가업체(미인증 대외) · **모듈/P**: M1(대관·자동견적)·M3/M4(부스 신청·유틸리티)·M6(서류) 안내 · P1 · **진입 경로**: 관문 SCR-T0 "비즈니스 트랙" · TrackSwitcher "비즈니스" · 직접 `/business`
|
- **대상**: 전시주최자·참가업체(미인증 대외) · **모듈/P**: M1(대관·자동견적)·M3/M4(부스 신청·유틸리티)·M6(서류) 안내 · P1 · **진입 경로**: 관문 SCR-T0 "비즈니스 트랙" · TrackSwitcher "비즈니스" · 직접 `/business`
|
||||||
- **레이아웃**(위→아래, PublicShell·SCR-P6 확장·코엑스 BUSINESS+CYBER 차용):
|
- **레이아웃**(위→아래, PublicShell·SCR-P6 확장·코엑스 BUSINESS+CYBER 차용):
|
||||||
1. **PublicShell 헤더 + TrackSwitcher**("비즈니스" 활성)
|
1. **PublicShell 헤더 + TrackSwitcher**("비즈니스" 활성)
|
||||||
2. **비즈니스 히어로**: B2B 실무 톤 헤드라인 "전시 개최·참가, 온라인 신청 일원화" + "로그인"·"신청/문의" CTA. **배경 = 크롤 실이미지 5장 로테이션**(§3C-0 P7 — 비즈니스 트랙 셋 `/media/hero/business/*`: 부스·상담·계약/시공 톤, 도트 인디케이터·`prefers-reduced-motion` 정지·CLS 0, v2.7)
|
2. **비즈니스 히어로**: B2B 실무 톤 헤드라인 "전시 개최·참가, 온라인 신청 일원화" + "로그인"·"신청/문의" CTA
|
||||||
3. **2-분기 가치 제안 카드**(2열 대형): ① **주최자** — 홀 임대 절차(문의→가견적→계약→배정) + "자동견적(M1)"·"홀 배정·플로어플랜(M2)" 안내 + 요금/시설 규격 개요 + CTA "대관 문의·로그인" ② **참가업체** — 부스 신청·서류·시공 시안(부스 유형·유틸리티 신청 M4·규정 사전검증·나노바나나 시공 시안 M5) + CTA "참가 신청·로그인". **온라인 신청 일원화 강조**(코엑스 CYBER 차용 — "모든 신청·결제를 온라인에서")
|
3. **2-분기 가치 제안 카드**(2열 대형): ① **주최자** — 홀 임대 절차(문의→가견적→계약→배정) + "자동견적(M1)"·"홀 배정·플로어플랜(M2)" 안내 + 요금/시설 규격 개요 + CTA "대관 문의·로그인" ② **참가업체** — 부스 신청·서류·시공 시안(부스 유형·유틸리티 신청 M4·규정 사전검증·나노바나나 시공 시안 M5) + CTA "참가 신청·로그인". **온라인 신청 일원화 강조**(코엑스 CYBER 차용 — "모든 신청·결제를 온라인에서")
|
||||||
4. **임대 절차 흐름 스텝**(코엑스 BUSINESS 임대절차): 가로 스텝 인디케이터 "문의 → 가견적 → 계약 → 홀 배정 → 부스 신청 → 시공·검수"(각 스텝 1줄 설명, 해당 모듈 배지)
|
4. **임대 절차 흐름 스텝**(코엑스 BUSINESS 임대절차): 가로 스텝 인디케이터 "문의 → 가견적 → 계약 → 홀 배정 → 부스 신청 → 시공·검수"(각 스텝 1줄 설명, 해당 모듈 배지)
|
||||||
5. **요금·서류 안내**: 부스 유형·요율 개요(SCR-P6 재사용) + 주요 서류(배정신청서·방염·구조계산서) 다운로드/안내
|
5. **요금·서류 안내**: 부스 유형·요율 개요(SCR-P6 재사용) + 주요 서류(배정신청서·방염·구조계산서) 다운로드/안내
|
||||||
@ -1731,7 +1704,7 @@ A public business sub-home / entry page for the product "KINTEX AI 전시·행
|
|||||||
- **대상**: 공사·장치·협력업체(미인증 대외)·등록업체 · **모듈/P**: M15(공사 옥션·역경매)·안전경영(작업신고)·등록업체 검증 · P1 · **진입 경로**: 관문 SCR-T0 "협력사 트랙" · TrackSwitcher "협력사" · 직접 `/agency`
|
- **대상**: 공사·장치·협력업체(미인증 대외)·등록업체 · **모듈/P**: M15(공사 옥션·역경매)·안전경영(작업신고)·등록업체 검증 · P1 · **진입 경로**: 관문 SCR-T0 "협력사 트랙" · TrackSwitcher "협력사" · 직접 `/agency`
|
||||||
- **레이아웃**(위→아래, PublicShell·코엑스 서비스협력업체+안전경영 차용·킨텍스 옥션 차별점 강조):
|
- **레이아웃**(위→아래, PublicShell·코엑스 서비스협력업체+안전경영 차용·킨텍스 옥션 차별점 강조):
|
||||||
1. **PublicShell 헤더 + TrackSwitcher**("협력사" 활성)
|
1. **PublicShell 헤더 + TrackSwitcher**("협력사" 활성)
|
||||||
2. **에이전시 히어로**: "킨텍스 공식 등록업체로 전시 시공에 참여하세요" + "등록 안내"·"등록업체 로그인" CTA. **킨텍스 차별점(옥션) 강조** 서브카피. **배경 = 크롤 실이미지 5장 로테이션**(§3C-0 P7 — 협력사 트랙 셋 `/media/hero/agency/*`: 시공 현장·장비·안전 톤, 도트 인디케이터·`prefers-reduced-motion` 정지·CLS 0, v2.7)
|
2. **에이전시 히어로**: "킨텍스 공식 등록업체로 전시 시공에 참여하세요" + "등록 안내"·"등록업체 로그인" CTA. **킨텍스 차별점(옥션) 강조** 서브카피
|
||||||
3. **등록업체 안내 카드**: 등록업체 현황(739개사·14개 분류) + **미등록 업체 시공 엄금·자체시공 불가 필수 고지**(warning 톤 안내) + 등록 절차(신청→자격심사→등록→수주 자격) + CTA "등록 신청"
|
3. **등록업체 안내 카드**: 등록업체 현황(739개사·14개 분류) + **미등록 업체 시공 엄금·자체시공 불가 필수 고지**(warning 톤 안내) + 등록 절차(신청→자격심사→등록→수주 자격) + CTA "등록 신청"
|
||||||
4. **진행 중 공사 옥션 공고 리스트(M15 · 역경매)**: 카드/행 리스트 — 옥션명·대상 부스/행사·예산 범위·마감 D-day(DdayChip)·응찰 현황(참여 업체 수)·상태 배지. 각 행 "상세·응찰"(로그인 유도). **킨텍스 차별점(역경매 옥션) 히어로 다음으로 크게** 배치
|
4. **진행 중 공사 옥션 공고 리스트(M15 · 역경매)**: 카드/행 리스트 — 옥션명·대상 부스/행사·예산 범위·마감 D-day(DdayChip)·응찰 현황(참여 업체 수)·상태 배지. 각 행 "상세·응찰"(로그인 유도). **킨텍스 차별점(역경매 옥션) 히어로 다음으로 크게** 배치
|
||||||
5. **작업신고·규정 안내**(코엑스 안전경영 온라인작업신고): 현장 작업신고 절차·안전 규정(금지작업·소음·리깅 구조계산서)·필수 서류(방염·보험) 안내 카드 + "작업신고 하기"(로그인)
|
5. **작업신고·규정 안내**(코엑스 안전경영 온라인작업신고): 현장 작업신고 절차·안전 규정(금지작업·소음·리깅 구조계산서)·필수 서류(방염·보험) 안내 카드 + "작업신고 하기"(로그인)
|
||||||
@ -1967,7 +1940,6 @@ A mobile "my ticket wallet" screen for an exhibition visitor, 390px width, light
|
|||||||
|
|
||||||
| 버전 | 일자 | 작성자 | 내용 |
|
| 버전 | 일자 | 작성자 | 내용 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **v2.7** | 2026-07-23 | designer | **세션 6 시연 준비 소유자 확정 사항 반영(`docs/OWNER_FEEDBACK.md` 세션 6). 순증·정정만(신규 컬러 토큰 0·전면 재작성 없음·PLANNING/src 무수정).** ①**§1-4A 전역 컴포넌트 표준 신설(G1~G6)**: G1 Nifty 문자 그대로/Stitch 산출물 우선 이식(강피드백 — `DESIGN_SYSTEM_NIFTY.md`), G2 전 페이지·섹션 타이틀 앞 선 SVG 아이콘 의무(`icons.tsx`·이모지 금지), G3 모든 그리드 PDF 출력 버튼(JasperReports 범용 `grid-pdf`), G4 목록 위젯 10줄 프리뷰+"전체보기(N)", G5 목록/피드/타임라인 오버플로 `minmax(0,1fr)`, G6 유색 배경 버튼 글자 `--color-on-accent`(흰색) 전역. ②**SCR-51·SCR-01**: 2차 인증(OTP) 로고 = 방패 아이콘 → **KINTEX 워드마크 32px**(`.kx-otp__logo`, OtpSetup·OtpChallenge 공통), Stitch 프롬프트 갱신. ③**SCR-HOME**: 내 할 일·공지·최근 활동 목록 위젯을 10줄+전체보기(N)·`minmax(0,1fr)` 표준으로 갱신(6행/5건→10줄). ④**SCR-02**: 마일스톤 타임라인·알림 피드 오버플로 `minmax(0,1fr)`(진행단계 짤림·세로글자 해소), 참가업체 그리드 PDF 버튼, V68/V69 시드. ⑤**SCR-15**: 본문 스플릿 = 좌 `minmax(0,1fr)` + 우측 미니 캘린더 사이드바 **`minmax(280px,340px)`**(달력 깨짐·짤림 해소), Stitch 프롬프트 갱신. ⑥**공개 사이트(§3C-0·SCR-T0~T3)**: 루트 `/`→`/visitor` 디폴트 리다이렉트(기존 '루트=제품소개' 대체), 셸 로고 = `ci/CI_JPG/CI_01.jpg`, 로고 옆 행사검색 입력(10ch), 공개 정보 페이지 4종(행사·참가안내·관람안내·교통) 실링크, **트랙 3페이지 히어로 5장 로테이션**(신규 크롤 실이미지·트랙별 셋 차별화·도트 인디케이터·`prefers-reduced-motion` 정지·CLS 0). ⑦**SCR-03/04**: 도면 탭 디폴트=**DWG**, 부스 팔레트(표준 규격 선택/드래그), **평면도 확대 팝업(`.kx-floorplan-lightbox`)**, AI 자동배치 결과 **시각 우선**(완료 시 확대 팝업에 배치 결과 렌더 → 배치안 3안 상세는 버튼 클릭), Stitch 프롬프트 갱신. ⑧**SCR-14**: 홀 히어로·홀 카드/상세에 **DWG 렌더 평면도(hall1~10.png)** 표출, Stitch 프롬프트 갱신 |
|
|
||||||
| **v2.6** | 2026-07-14 | designer | **SCR-03/04 — PLANNING v3.5 §M2-1 "자동 배치 이지 플로우(3단계 위저드)" 정합(구현 정본 `AutoLayoutDialog.tsx`·`BoothLayoutEditorPage.tsx`·`FloorplanCanvas.tsx`·`hallFloorplan.ts`에 맞춘 스펙 문서화).** ①**SCR-03**: "AI 자동배치" 버튼 = **3단계 위저드 모달(`.kx-auto`) 진입 모드** 신설 절 추가 — ① 어디에(전시장>홀>구역 **셀렉트 3단 캐스케이드**, 소스 `GET /api/halls/tree`(V60 hall_zone)·"홀 전체" 기본·트리 실패 degraded 진행) → ② 조건(자연어 한 줄+"AI 해석" `…/auto-generate/interpret`→**AI 해석 폼**이 최종 권위, degraded 수동 입력) → ③ 배치안 선택(SCR-04). 툴바에 **홀 선택 셀렉트**(전시장 optgroup) 추가, 캔버스는 **실측 홀 도면 JPG의 전시 바닥면 캘리브레이션 영역(FLOOR_REGIONS)에 홀 좌표계 정렬**(부스가 평면도 안에 표시 — M2-1 원칙 1) 명문화, 생성 중 상태를 캔버스 오버레이→위저드 내 진행 화면으로 정정, Stitch 프롬프트에 홀 셀렉트·도면 JPG 베이스 반영 ②**SCR-04**: 별도 페이지→**위저드 ③단계 화면으로 실현** 재정의 — 3안 카드 그리드에서 **조감(S7)은 자동 생성 금지, 카드별 점선 CTA "조감 이미지 생성" 클릭 시에만 `…/auto-generate/preview` RenderJob 발행**(원칙 3), 생성 후 AiImage(워터마크+고지) 표시, **이미지 클릭→확대 라이트박스(`.kx-lightbox`) 신설**(고지 문구 유지+"이미지 저장" 다운로드+닫기, 원칙 4)·`render_job` 영속→시각화 갤러리 `boothRef=HALL-{hallId}` 재열람·M15 옥션 참고 이미지 재사용 안내 문구, **"이 안으로 편집 시작" = `apply-option` 실저장**(저장 중 "저장 중…" 표기) 후 에디터 진입(원칙 5), 하단 비교 테이블 항목은 카드 내 지표+AI 요약으로 흡수, 상태 6종 재정의(S7 미요청 점선 CTA 기본·생성 실패에도 선택/적용 계속 가능), **Stitch 프롬프트 위저드 3단계 구조로 전면 갱신**(단계 표시기·점선 CTA 2장+생성 완료 1장·산술 정합 508부스=4,572㎡ 동일 산식). 신규 토큰 0·타 절 무수정·PLANNING.md·src 무수정 |
|
| **v2.6** | 2026-07-14 | designer | **SCR-03/04 — PLANNING v3.5 §M2-1 "자동 배치 이지 플로우(3단계 위저드)" 정합(구현 정본 `AutoLayoutDialog.tsx`·`BoothLayoutEditorPage.tsx`·`FloorplanCanvas.tsx`·`hallFloorplan.ts`에 맞춘 스펙 문서화).** ①**SCR-03**: "AI 자동배치" 버튼 = **3단계 위저드 모달(`.kx-auto`) 진입 모드** 신설 절 추가 — ① 어디에(전시장>홀>구역 **셀렉트 3단 캐스케이드**, 소스 `GET /api/halls/tree`(V60 hall_zone)·"홀 전체" 기본·트리 실패 degraded 진행) → ② 조건(자연어 한 줄+"AI 해석" `…/auto-generate/interpret`→**AI 해석 폼**이 최종 권위, degraded 수동 입력) → ③ 배치안 선택(SCR-04). 툴바에 **홀 선택 셀렉트**(전시장 optgroup) 추가, 캔버스는 **실측 홀 도면 JPG의 전시 바닥면 캘리브레이션 영역(FLOOR_REGIONS)에 홀 좌표계 정렬**(부스가 평면도 안에 표시 — M2-1 원칙 1) 명문화, 생성 중 상태를 캔버스 오버레이→위저드 내 진행 화면으로 정정, Stitch 프롬프트에 홀 셀렉트·도면 JPG 베이스 반영 ②**SCR-04**: 별도 페이지→**위저드 ③단계 화면으로 실현** 재정의 — 3안 카드 그리드에서 **조감(S7)은 자동 생성 금지, 카드별 점선 CTA "조감 이미지 생성" 클릭 시에만 `…/auto-generate/preview` RenderJob 발행**(원칙 3), 생성 후 AiImage(워터마크+고지) 표시, **이미지 클릭→확대 라이트박스(`.kx-lightbox`) 신설**(고지 문구 유지+"이미지 저장" 다운로드+닫기, 원칙 4)·`render_job` 영속→시각화 갤러리 `boothRef=HALL-{hallId}` 재열람·M15 옥션 참고 이미지 재사용 안내 문구, **"이 안으로 편집 시작" = `apply-option` 실저장**(저장 중 "저장 중…" 표기) 후 에디터 진입(원칙 5), 하단 비교 테이블 항목은 카드 내 지표+AI 요약으로 흡수, 상태 6종 재정의(S7 미요청 점선 CTA 기본·생성 실패에도 선택/적용 계속 가능), **Stitch 프롬프트 위저드 3단계 구조로 전면 갱신**(단계 표시기·점선 CTA 2장+생성 완료 1장·산술 정합 508부스=4,572㎡ 동일 산식). 신규 토큰 0·타 절 무수정·PLANNING.md·src 무수정 |
|
||||||
| **v2.5** | 2026-07-14 | designer | **BACKLOG designer 잔여 티켓 일괄 해소(B-01·B-04~B-08·B-10) — 소유자 지시 "백로그 미진행 사항 진행".** 기존 구조·번호 체계·토큰 불변(신규 토큰 0), 스펙 정정·순증만. ①**B-01(Major)**: SCR-06에 **조립부스 모드** 신설(PLANNING M3 Phase 1 범위 — 신규 SCR 없이 모드 분기) — 좌측 옵션 패널 3그룹(간판 문구·서체·색/가구 표준 자재 카탈로그 그리드(수량 스테퍼+기본 제공·추가 ₩ 칩)/조명 스포트 스테퍼·주간야간), 옵션 변경 시 3D 프리뷰·S1 예상 사진 즉시 갱신, Before/After "기본형↔옵션 적용", 옵션 견적 요약+CTA "옵션 신청 제출"(장치업체 컨펌 미적용 — 주최측 시공), 상태(마감 잠금·문구 미입력 비활성·오탈자 배지) + **전용 영어 Stitch 프롬프트 1종**(견적 산술 정합 30,000+70,000+15,000+40,000=155,000). SCR-05에 조립부스 분기(헤더 표기·옵션 마감 할 일 카드·장치업체 초대 미노출·스튜디오 진입 분기) 순증 ②**B-04**: 화면 수 표기 정정 — §3 헤더 "총 14개(웹12+모바일2)"→**P0 코어 15개(웹 13 = SCR-01·HOME·02~12 + 모바일 2)**(SCR-HOME 신설 반영), §2.8-2 집계 문장의 SCR-T0~T3 4화면 누락 정정 **85→총 89화면**(✅Stitch 83·미생성 6 상태 정합 — v2.2 변경이력과 일치화) ③**B-05**: SCR-02 사이드바 "정산" 메뉴 IA 불일치 해소 — §2-1 주최자 사이트맵에 "정산·결제 [M9, P1] — Phase 2 (SCR-21)" 추가 + SCR-02에 정합 메모(프롬프트 메뉴 문구 유지) ④**B-06**: SCR-01 Stitch 프롬프트에 스펙에만 있던 "이메일 인증으로 로그인" 버튼 추가 ⑤**B-07**: SCR-03 프롬프트에 툴바 "공유" 버튼 추가 + 위반 요약(차단2·경고3)↔캔버스 핀 불일치 정정(주황 빗금 부스 1→3개, 핀 3·4·5 — 스펙 레이아웃 ③에 핀·건수 명시로 1:1 고정) ⑥**B-08**: SCR-04 샘플 지표 산술 오류 정정 — 판매면적 4,420㎡→**4,590㎡**(510부스×9㎡, SCR-03 486부스=4,374㎡와 동일 산식 명문화) ⑦**B-10**: SCR-10/11 헤딩·메모에 **Phase 2**(PLANNING §9 홀매니저 대시보드), SCR-M1/M2에 **Phase 3**(현장 모바일 운영 도구) 로드맵 라벨 부착(화면·구현 선행 확보 명시 — P0 우선순위 컬럼과 구분) + **조명 배치안 제안(M4a) 전용 UI 검토 메모**를 SCR-06에 신설(Phase 1은 조명 프리셋+S2 야간 컷으로 흡수, 조도 히트맵·기구 배치 캔버스 전용 SCR은 정밀 조도 시뮬레이션(P2)과 함께 분리 검토 — 현 시점 신규 화면 없음). PLANNING.md·src 무수정 |
|
| **v2.5** | 2026-07-14 | designer | **BACKLOG designer 잔여 티켓 일괄 해소(B-01·B-04~B-08·B-10) — 소유자 지시 "백로그 미진행 사항 진행".** 기존 구조·번호 체계·토큰 불변(신규 토큰 0), 스펙 정정·순증만. ①**B-01(Major)**: SCR-06에 **조립부스 모드** 신설(PLANNING M3 Phase 1 범위 — 신규 SCR 없이 모드 분기) — 좌측 옵션 패널 3그룹(간판 문구·서체·색/가구 표준 자재 카탈로그 그리드(수량 스테퍼+기본 제공·추가 ₩ 칩)/조명 스포트 스테퍼·주간야간), 옵션 변경 시 3D 프리뷰·S1 예상 사진 즉시 갱신, Before/After "기본형↔옵션 적용", 옵션 견적 요약+CTA "옵션 신청 제출"(장치업체 컨펌 미적용 — 주최측 시공), 상태(마감 잠금·문구 미입력 비활성·오탈자 배지) + **전용 영어 Stitch 프롬프트 1종**(견적 산술 정합 30,000+70,000+15,000+40,000=155,000). SCR-05에 조립부스 분기(헤더 표기·옵션 마감 할 일 카드·장치업체 초대 미노출·스튜디오 진입 분기) 순증 ②**B-04**: 화면 수 표기 정정 — §3 헤더 "총 14개(웹12+모바일2)"→**P0 코어 15개(웹 13 = SCR-01·HOME·02~12 + 모바일 2)**(SCR-HOME 신설 반영), §2.8-2 집계 문장의 SCR-T0~T3 4화면 누락 정정 **85→총 89화면**(✅Stitch 83·미생성 6 상태 정합 — v2.2 변경이력과 일치화) ③**B-05**: SCR-02 사이드바 "정산" 메뉴 IA 불일치 해소 — §2-1 주최자 사이트맵에 "정산·결제 [M9, P1] — Phase 2 (SCR-21)" 추가 + SCR-02에 정합 메모(프롬프트 메뉴 문구 유지) ④**B-06**: SCR-01 Stitch 프롬프트에 스펙에만 있던 "이메일 인증으로 로그인" 버튼 추가 ⑤**B-07**: SCR-03 프롬프트에 툴바 "공유" 버튼 추가 + 위반 요약(차단2·경고3)↔캔버스 핀 불일치 정정(주황 빗금 부스 1→3개, 핀 3·4·5 — 스펙 레이아웃 ③에 핀·건수 명시로 1:1 고정) ⑥**B-08**: SCR-04 샘플 지표 산술 오류 정정 — 판매면적 4,420㎡→**4,590㎡**(510부스×9㎡, SCR-03 486부스=4,374㎡와 동일 산식 명문화) ⑦**B-10**: SCR-10/11 헤딩·메모에 **Phase 2**(PLANNING §9 홀매니저 대시보드), SCR-M1/M2에 **Phase 3**(현장 모바일 운영 도구) 로드맵 라벨 부착(화면·구현 선행 확보 명시 — P0 우선순위 컬럼과 구분) + **조명 배치안 제안(M4a) 전용 UI 검토 메모**를 SCR-06에 신설(Phase 1은 조명 프리셋+S2 야간 컷으로 흡수, 조도 히트맵·기구 배치 캔버스 전용 SCR은 정밀 조도 시뮬레이션(P2)과 함께 분리 검토 — 현 시점 신규 화면 없음). PLANNING.md·src 무수정 |
|
||||||
| **v2.4** | 2026-07-12 | designer | **공개 사이트 비주얼·모션 전면 리뉴얼 스펙 신설(소유자 확정: "코엑스처럼 세련되게 — 히어로 배경·애니메이션·비주얼, 지금 착수").** §3C에 **3C-V 공개 사이트 비주얼·모션 스펙** 절 순증 — 기존 SCR-T0~T3·SCR-P* **구조는 불변**, 그 위에 비주얼/모션 레이어만 격상(구조 재설계 아님). ①**비주얼 폴리시(V-1)** 풀블리드·공백 없음·섹션 리듬(밴드↔카드 교대·96/64/40px)·여백·라운드 8px·소프트 앰비언트 그림자·타이포 위계 강화·선 SVG·이모지 금지·다크/라이트·2:3 포스터 카드. ②**모션 토큰(V-2)** duration(`--motion-fast 150`/`base 240`/`slow 400`/`hero 600`/`countup 1200`)·easing(`--ease-emphasized/standard/decelerate`) 신설(컬러 신규 토큰 0). ③**섹션별 배경·애니메이션 트리거 매핑(V-3)** 풀블리드 히어로(라이트 파랄랙스+인트로 리빌)·통계 카운트업·AI 타일 스태거·쇼케이스 hover 리프트/이미지 줌·행사 레일 오토스크롤·기획 브리핑 grow·공통 스크롤 리빌(IO threshold 0.15·once). ④**이미지 소스 매핑(V-4)** build 내장 `/media/`(hero·bands·poster·patterns 계약)·부재 시 그라디언트+패턴 폴백(레이아웃 시프트 0)·preload/lazy/webp/srcset. ⑤**접근성(V-5)** `prefers-reduced-motion` 필수 — 파랄랙스/오토스크롤 정지·카운트업 즉시·리빌 instant·모션은 전부 장식(정보 비의존)·양 테마 WCAG AA. ⑥**성능(V-6)** CSS 우선·`transform`/`opacity`만·`will-change` 절제·60fps·강등 순서. ⑦**프론트 계약(V-7)** `.pub-reveal`/`--stagger`·`.pub-hero__bg[data-parallax]`·`.pub-card--lift`·`.pub-card__media img` 줌·`.pub-countup`·`.pub-rail--auto`·폴백 유틸, **PublicShell 하위만 스코프**(AppShell/MDI·SCR-A*·모바일 미주입). ⑧**영어 Stitch 프롬프트 2종(V-8·V-9)** — 프리미엄 전시장 홈페이지(SCR-T1 시네마틱 히어로·스크롤 리빌·카운트업·hover 줌·오토스크롤·폴백·reduced-motion, 제품명 정확·NO emoji·선 SVG·라이트/다크) + 3-트랙 관문(SCR-T0 동일 톤 격상). **경계 준수**: visitor(T1)만 풀 시네마틱, business/agency는 B2B 톤 유지+공통 모션 절제 적용. 신규 컬러 토큰 0·PLANNING.md·src 무수정. |
|
| **v2.4** | 2026-07-12 | designer | **공개 사이트 비주얼·모션 전면 리뉴얼 스펙 신설(소유자 확정: "코엑스처럼 세련되게 — 히어로 배경·애니메이션·비주얼, 지금 착수").** §3C에 **3C-V 공개 사이트 비주얼·모션 스펙** 절 순증 — 기존 SCR-T0~T3·SCR-P* **구조는 불변**, 그 위에 비주얼/모션 레이어만 격상(구조 재설계 아님). ①**비주얼 폴리시(V-1)** 풀블리드·공백 없음·섹션 리듬(밴드↔카드 교대·96/64/40px)·여백·라운드 8px·소프트 앰비언트 그림자·타이포 위계 강화·선 SVG·이모지 금지·다크/라이트·2:3 포스터 카드. ②**모션 토큰(V-2)** duration(`--motion-fast 150`/`base 240`/`slow 400`/`hero 600`/`countup 1200`)·easing(`--ease-emphasized/standard/decelerate`) 신설(컬러 신규 토큰 0). ③**섹션별 배경·애니메이션 트리거 매핑(V-3)** 풀블리드 히어로(라이트 파랄랙스+인트로 리빌)·통계 카운트업·AI 타일 스태거·쇼케이스 hover 리프트/이미지 줌·행사 레일 오토스크롤·기획 브리핑 grow·공통 스크롤 리빌(IO threshold 0.15·once). ④**이미지 소스 매핑(V-4)** build 내장 `/media/`(hero·bands·poster·patterns 계약)·부재 시 그라디언트+패턴 폴백(레이아웃 시프트 0)·preload/lazy/webp/srcset. ⑤**접근성(V-5)** `prefers-reduced-motion` 필수 — 파랄랙스/오토스크롤 정지·카운트업 즉시·리빌 instant·모션은 전부 장식(정보 비의존)·양 테마 WCAG AA. ⑥**성능(V-6)** CSS 우선·`transform`/`opacity`만·`will-change` 절제·60fps·강등 순서. ⑦**프론트 계약(V-7)** `.pub-reveal`/`--stagger`·`.pub-hero__bg[data-parallax]`·`.pub-card--lift`·`.pub-card__media img` 줌·`.pub-countup`·`.pub-rail--auto`·폴백 유틸, **PublicShell 하위만 스코프**(AppShell/MDI·SCR-A*·모바일 미주입). ⑧**영어 Stitch 프롬프트 2종(V-8·V-9)** — 프리미엄 전시장 홈페이지(SCR-T1 시네마틱 히어로·스크롤 리빌·카운트업·hover 줌·오토스크롤·폴백·reduced-motion, 제품명 정확·NO emoji·선 SVG·라이트/다크) + 3-트랙 관문(SCR-T0 동일 톤 격상). **경계 준수**: visitor(T1)만 풀 시네마틱, business/agency는 B2B 톤 유지+공통 모션 절제 적용. 신규 컬러 토큰 0·PLANNING.md·src 무수정. |
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"slug": "kintex",
|
"slug": "kintex",
|
||||||
"owner": "zioinfo",
|
"owner": "zioinfo",
|
||||||
"scheme": "kintex",
|
"scheme": "kintex",
|
||||||
"version": "0.2.2",
|
"version": "0.2.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
@ -28,7 +28,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "kr.co.zioinfo.kintex",
|
"package": "kr.co.zioinfo.kintex",
|
||||||
"versionCode": 4,
|
"versionCode": 2,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
"backgroundColor": "#0066B3"
|
"backgroundColor": "#0066B3"
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
/*
|
|
||||||
* 관람객(B2C) 하단 탭 셸 — 운영(B2B) (tabs) 셸과 분리된 관람객 전용 트랙.
|
|
||||||
* 탭: 홈 · 행사 · 티켓 · 현장(혼잡/주차) · 마이. design.md §4 SCR-M5/M14/M15 관람객 톤.
|
|
||||||
* 진입: roleTrack visitor 랜딩(/(visitor)) 또는 게스트. 인증 가드는 (tabs)와 동일(로그인 필요).
|
|
||||||
*/
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { Redirect, Tabs } from 'expo-router';
|
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { colors } from '../../theme';
|
|
||||||
|
|
||||||
export default function VisitorTabsLayout() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { ready, token } = useAuth();
|
|
||||||
if (ready && !token) return <Redirect href="/login" />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
screenOptions={{
|
|
||||||
tabBarActiveTintColor: colors.primary600,
|
|
||||||
tabBarInactiveTintColor: colors.neutral500,
|
|
||||||
headerStyle: { backgroundColor: colors.white },
|
|
||||||
headerTitleStyle: { fontWeight: '600', color: colors.neutral900 },
|
|
||||||
tabBarStyle: { borderTopColor: colors.neutral200 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
title: t('vnav.tabHome'),
|
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" color={color} size={size} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="events"
|
|
||||||
options={{
|
|
||||||
title: t('vnav.tabEvents'),
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons name="calendar-outline" color={color} size={size} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="tickets"
|
|
||||||
options={{
|
|
||||||
title: t('vnav.tabTickets'),
|
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="ticket-outline" color={color} size={size} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="onsite"
|
|
||||||
options={{
|
|
||||||
title: t('vnav.tabOnsite'),
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons name="navigate-outline" color={color} size={size} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="my"
|
|
||||||
options={{
|
|
||||||
title: t('vnav.tabMy'),
|
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="person-outline" color={color} size={size} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
/*
|
|
||||||
* [모바일] 관람객(B2C) 행사 — 참여 행사 목록 + 선택 행사 라이브 공지 피드(전체).
|
|
||||||
* 참여 행사는 로그인 워크스페이스에서 수신(계약 발명 없음). 없으면 데모 라이브 행사로 공개 공지 노출.
|
|
||||||
* 라이브 공지 = 공개 API(cms_backlog_contract.md).
|
|
||||||
*/
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { Banner } from '../../components/Banner';
|
|
||||||
import { Card } from '../../components/Card';
|
|
||||||
import { DdayChip } from '../../components/DdayChip';
|
|
||||||
import { LiveNoticeFeed } from '../../components/LiveNoticeFeed';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { DEFAULT_PUBLIC_EVENT_ID } from '../../lib/config';
|
|
||||||
import { colors, radius, spacing, type } from '../../theme';
|
|
||||||
|
|
||||||
export default function VisitorEventsScreen() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { workspaces, activeWorkspace } = useAuth();
|
|
||||||
const [selected, setSelected] = useState<string | null>(
|
|
||||||
activeWorkspace?.eventId ?? workspaces[0]?.eventId ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedWs = workspaces.find((w) => w.eventId === selected) ?? null;
|
|
||||||
const feedEventId = selected ?? DEFAULT_PUBLIC_EVENT_ID;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={styles.scroll}>
|
|
||||||
<Text style={styles.title}>{t('vevents.title')}</Text>
|
|
||||||
|
|
||||||
{workspaces.length === 0 ? (
|
|
||||||
<Banner tone="info">{t('vevents.empty')}</Banner>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* 참여 행사 선택 칩 */}
|
|
||||||
<View style={styles.chips}>
|
|
||||||
{workspaces.map((w) => {
|
|
||||||
const on = w.eventId === selected;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={w.eventId}
|
|
||||||
onPress={() => setSelected(w.eventId)}
|
|
||||||
style={[styles.chip, on && styles.chipOn]}
|
|
||||||
>
|
|
||||||
<Text style={[styles.chipText, on && styles.chipTextOn]} numberOfLines={1}>
|
|
||||||
{w.eventName}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 선택 행사 요약 카드 */}
|
|
||||||
{selectedWs ? (
|
|
||||||
<Card accent="none">
|
|
||||||
<View style={styles.eventTop}>
|
|
||||||
<Text style={styles.eventName} numberOfLines={2}>
|
|
||||||
{selectedWs.eventName}
|
|
||||||
</Text>
|
|
||||||
<DdayChip dday={selectedWs.dday} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.eventMeta}>
|
|
||||||
{selectedWs.hallLabel} · {selectedWs.startDate} ~ {selectedWs.endDate}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 라이브 공지(전체) */}
|
|
||||||
<LiveNoticeFeed eventId={feedEventId} />
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 40 },
|
|
||||||
title: { fontSize: type.h2.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
|
||||||
chip: {
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: radius.pill,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
maxWidth: 240,
|
|
||||||
},
|
|
||||||
chipOn: { backgroundColor: colors.primary100, borderColor: colors.primary600 },
|
|
||||||
chipText: { color: colors.neutral700, fontSize: type.caption.fontSize },
|
|
||||||
chipTextOn: { color: colors.primary700, fontWeight: '700' },
|
|
||||||
eventTop: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 },
|
|
||||||
eventName: { flex: 1, fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
eventMeta: { marginTop: 4, fontSize: type.caption.fontSize, color: colors.neutral500 },
|
|
||||||
});
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
/*
|
|
||||||
* SCR-M5 계열 [모바일] 관람객(B2C) 홈 — 친근한 소비자 톤.
|
|
||||||
* 인사 + 활성 행사 요약 + 라이브 공지 피드(상단 요약 3건) + 빠른 이동 타일(티켓·행사·현장) + AI 관람 도우미 안내.
|
|
||||||
* design.md §4 SCR-M5. 라이브 공지 = 공개 API(cms_backlog_contract.md).
|
|
||||||
*/
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { Banner } from '../../components/Banner';
|
|
||||||
import { Card } from '../../components/Card';
|
|
||||||
import { DdayChip } from '../../components/DdayChip';
|
|
||||||
import { LiveNoticeFeed } from '../../components/LiveNoticeFeed';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { DEFAULT_PUBLIC_EVENT_ID } from '../../lib/config';
|
|
||||||
import { colors, radius, spacing, type } from '../../theme';
|
|
||||||
|
|
||||||
export default function VisitorHomeScreen() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { user, activeWorkspace } = useAuth();
|
|
||||||
const eventId = activeWorkspace?.eventId ?? DEFAULT_PUBLIC_EVENT_ID;
|
|
||||||
|
|
||||||
const tiles: {
|
|
||||||
key: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
label: string;
|
|
||||||
desc: string;
|
|
||||||
onPress: () => void;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
key: 'tickets',
|
|
||||||
icon: 'ticket-outline',
|
|
||||||
label: t('vhome.tileTickets'),
|
|
||||||
desc: t('vhome.tileTicketsDesc'),
|
|
||||||
onPress: () => router.push('/(visitor)/tickets'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'events',
|
|
||||||
icon: 'calendar-outline',
|
|
||||||
label: t('vhome.tileEvents'),
|
|
||||||
desc: t('vhome.tileEventsDesc'),
|
|
||||||
onPress: () => router.push('/(visitor)/events'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'onsite',
|
|
||||||
icon: 'navigate-outline',
|
|
||||||
label: t('vhome.tileOnsite'),
|
|
||||||
desc: t('vhome.tileOnsiteDesc'),
|
|
||||||
onPress: () => router.push('/(visitor)/onsite'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={styles.scroll}>
|
|
||||||
<Text style={styles.hello}>
|
|
||||||
{t('vhome.hello', { name: user?.displayName ?? t('common.user') })}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.sub}>{t('vhome.subtitle')}</Text>
|
|
||||||
|
|
||||||
{/* 활성 행사 요약(참여 행사가 있을 때) */}
|
|
||||||
{activeWorkspace ? (
|
|
||||||
<Card accent="none">
|
|
||||||
<View style={styles.eventTop}>
|
|
||||||
<Text style={styles.eventName} numberOfLines={1}>
|
|
||||||
{activeWorkspace.eventName}
|
|
||||||
</Text>
|
|
||||||
<DdayChip dday={activeWorkspace.dday} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.eventMeta}>
|
|
||||||
{activeWorkspace.hallLabel} · {activeWorkspace.startDate} ~ {activeWorkspace.endDate}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Banner tone="info">{t('vhome.noEvent')}</Banner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 라이브 공지 요약(상단 3건) */}
|
|
||||||
<LiveNoticeFeed eventId={eventId} max={3} />
|
|
||||||
|
|
||||||
{/* 빠른 이동 타일 */}
|
|
||||||
<View style={styles.tiles}>
|
|
||||||
{tiles.map((tile) => (
|
|
||||||
<Pressable key={tile.key} style={styles.tile} onPress={tile.onPress}>
|
|
||||||
<View style={styles.tileIcon}>
|
|
||||||
<Ionicons name={tile.icon} size={24} color={colors.primary600} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.tileLabel}>{tile.label}</Text>
|
|
||||||
<Text style={styles.tileDesc}>{tile.desc}</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* AI 관람 도우미 안내 */}
|
|
||||||
<Card accent="ai">
|
|
||||||
<View style={styles.aiRow}>
|
|
||||||
<Ionicons name="sparkles-outline" size={20} color={colors.aiAccent} />
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={styles.aiTitle}>{t('vhome.aiTitle')}</Text>
|
|
||||||
<Text style={styles.aiDesc}>{t('vhome.aiDesc')}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 40 },
|
|
||||||
hello: { fontSize: type.h2.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
sub: { marginTop: -8, fontSize: type.caption.fontSize, color: colors.neutral500 },
|
|
||||||
eventTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
|
|
||||||
eventName: { flex: 1, fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
eventMeta: { marginTop: 4, fontSize: type.caption.fontSize, color: colors.neutral500 },
|
|
||||||
tiles: { flexDirection: 'row', gap: spacing.sm },
|
|
||||||
tile: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
padding: spacing.sm,
|
|
||||||
gap: 6,
|
|
||||||
minHeight: 104,
|
|
||||||
},
|
|
||||||
tileIcon: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: radius.sm,
|
|
||||||
backgroundColor: colors.primary050,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
tileLabel: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
tileDesc: { fontSize: 11, color: colors.neutral500, lineHeight: 15 },
|
|
||||||
aiRow: { flexDirection: 'row', gap: 10, alignItems: 'flex-start' },
|
|
||||||
aiTitle: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.aiAccent },
|
|
||||||
aiDesc: { marginTop: 2, fontSize: type.caption.fontSize, color: colors.neutral700, lineHeight: 18 },
|
|
||||||
});
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
/*
|
|
||||||
* [모바일] 관람객(B2C) 마이 — 내정보·내 티켓·내 주차권·언어·서버 상태·로그아웃.
|
|
||||||
* 운영(B2B) 더보기(SCR-M3 more)의 관람객 경량판. 공통 키(more.*) 재사용 + vmy.* 최소 신설.
|
|
||||||
*/
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { Avatar } from '../../components/Avatar';
|
|
||||||
import { Card } from '../../components/Card';
|
|
||||||
import { LanguageSelector } from '../../components/LanguageSelector';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { api, isDegraded } from '../../lib/api';
|
|
||||||
import { API_BASE } from '../../lib/config';
|
|
||||||
import type { HealthDto } from '../../lib/types';
|
|
||||||
import { colors, radius, spacing, touch, type } from '../../theme';
|
|
||||||
|
|
||||||
export default function VisitorMyScreen() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { user, signOut } = useAuth();
|
|
||||||
const [health, setHealth] = useState<string>(t('common.loading'));
|
|
||||||
|
|
||||||
const checkHealth = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get<HealthDto>('/health', { anonymous: true });
|
|
||||||
setHealth(res?.status === 'UP' ? t('more.statusUp') : (res?.status ?? t('common.unknown')));
|
|
||||||
} catch (e) {
|
|
||||||
setHealth(isDegraded(e) ? t('more.statusDown') : t('common.error'));
|
|
||||||
}
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkHealth();
|
|
||||||
}, [checkHealth]);
|
|
||||||
|
|
||||||
async function onSignOut() {
|
|
||||||
await signOut();
|
|
||||||
router.replace('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={styles.scroll}>
|
|
||||||
{/* 내 정보 진입 */}
|
|
||||||
<Pressable
|
|
||||||
onPress={() => router.push('/profile')}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={t('more.openProfile')}
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<View style={styles.meRow}>
|
|
||||||
<Avatar name={user?.displayName} size={48} />
|
|
||||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
|
||||||
<Text style={styles.name}>{user?.displayName ?? t('common.user')}</Text>
|
|
||||||
<Text style={styles.meta}>{t('more.accountGeneral')}</Text>
|
|
||||||
</View>
|
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.neutral500} />
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* 바로가기 — 내 티켓 · 내 주차권 */}
|
|
||||||
<NavRow
|
|
||||||
icon="ticket-outline"
|
|
||||||
title={t('vmy.tickets')}
|
|
||||||
desc={t('vmy.ticketsDesc')}
|
|
||||||
onPress={() => router.push('/(visitor)/tickets')}
|
|
||||||
/>
|
|
||||||
<NavRow
|
|
||||||
icon="car-outline"
|
|
||||||
title={t('vmy.parking')}
|
|
||||||
desc={t('vmy.parkingDesc')}
|
|
||||||
onPress={() => router.push('/(visitor)/onsite')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 언어 */}
|
|
||||||
<LanguageSelector />
|
|
||||||
|
|
||||||
{/* 서버 상태 */}
|
|
||||||
<Card>
|
|
||||||
<Row label={t('more.apiServer')} value={API_BASE} />
|
|
||||||
<View style={styles.divider} />
|
|
||||||
<Pressable style={styles.healthRow} onPress={checkHealth}>
|
|
||||||
<Row label={t('more.serverStatus')} value={health} />
|
|
||||||
<Ionicons name="refresh" size={18} color={colors.primary600} />
|
|
||||||
</Pressable>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Pressable style={styles.signOut} onPress={onSignOut}>
|
|
||||||
<Ionicons name="log-out-outline" size={20} color={colors.error} />
|
|
||||||
<Text style={styles.signOutText}>{t('more.signOut')}</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Text style={styles.version}>{t('more.version')}</Text>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavRow({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
desc,
|
|
||||||
onPress,
|
|
||||||
}: {
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
title: string;
|
|
||||||
desc: string;
|
|
||||||
onPress: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Pressable style={styles.navRow} onPress={onPress}>
|
|
||||||
<View style={styles.navIcon}>
|
|
||||||
<Ionicons name={icon} size={22} color={colors.primary600} />
|
|
||||||
</View>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={styles.navTitle}>{title}</Text>
|
|
||||||
<Text style={styles.navDesc}>{desc}</Text>
|
|
||||||
</View>
|
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.neutral500} />
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<View style={styles.row}>
|
|
||||||
<Text style={styles.rowLabel}>{label}</Text>
|
|
||||||
<Text style={styles.rowValue} numberOfLines={1}>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 40 },
|
|
||||||
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 },
|
|
||||||
navRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
padding: spacing.md,
|
|
||||||
},
|
|
||||||
navIcon: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: radius.sm,
|
|
||||||
backgroundColor: colors.primary050,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
navTitle: { fontSize: type.h3.fontSize, fontWeight: '600', color: colors.neutral900 },
|
|
||||||
navDesc: { fontSize: type.caption.fontSize, color: colors.neutral500 },
|
|
||||||
row: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, paddingVertical: 4 },
|
|
||||||
rowLabel: { color: colors.neutral500, fontSize: type.body.fontSize },
|
|
||||||
rowValue: { flex: 1, textAlign: 'right', color: colors.neutral900, fontSize: type.body.fontSize },
|
|
||||||
divider: { height: 1, backgroundColor: colors.neutral200, marginVertical: 6 },
|
|
||||||
healthRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
||||||
signOut: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 8,
|
|
||||||
minHeight: touch.min,
|
|
||||||
borderRadius: radius.sm,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.error,
|
|
||||||
},
|
|
||||||
signOutText: { color: colors.error, fontSize: type.h3.fontSize, fontWeight: '600' },
|
|
||||||
version: { textAlign: 'center', color: colors.neutral500, fontSize: type.caption.fontSize },
|
|
||||||
});
|
|
||||||
@ -1,672 +0,0 @@
|
|||||||
/*
|
|
||||||
* F-C2/F-C3 [모바일] 관람객(B2C) 현장 — 실시간 혼잡 + 주차 현황 + 사전 주차권(mock).
|
|
||||||
* 세그먼트: 혼잡도 | 주차. 혼잡/주차 현황은 공개 조회, 주차권은 인증(본인).
|
|
||||||
* 계약: _workspace/parking_congestion_contract.md. PII(차량번호)는 서버 마스킹 — 원문 미저장.
|
|
||||||
*/
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
Modal,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View,
|
|
||||||
} from 'react-native';
|
|
||||||
import { Banner } from '../../components/Banner';
|
|
||||||
import { CongestionPill } from '../../components/CongestionPill';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { isDegraded } from '../../lib/api';
|
|
||||||
import { DEFAULT_PUBLIC_EVENT_ID } from '../../lib/config';
|
|
||||||
import type {
|
|
||||||
CongestionAreaDto,
|
|
||||||
CongestionOverviewDto,
|
|
||||||
ParkingLotStatusDto,
|
|
||||||
ParkingPassDto,
|
|
||||||
} from '../../lib/types';
|
|
||||||
import {
|
|
||||||
getCongestion,
|
|
||||||
getMyParkingPasses,
|
|
||||||
getParkingLots,
|
|
||||||
purchaseParkingPass,
|
|
||||||
} from '../../lib/visitor';
|
|
||||||
import { colors, radius, spacing, touch, type } from '../../theme';
|
|
||||||
|
|
||||||
type Seg = 'congestion' | 'parking';
|
|
||||||
|
|
||||||
function won(n: number): string {
|
|
||||||
return `${n.toLocaleString('ko-KR')}`;
|
|
||||||
}
|
|
||||||
function ymd(d: Date): string {
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VisitorOnsiteScreen() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { token, activeWorkspace } = useAuth();
|
|
||||||
const eventId = activeWorkspace?.eventId ?? DEFAULT_PUBLIC_EVENT_ID;
|
|
||||||
|
|
||||||
const [seg, setSeg] = useState<Seg>('congestion');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.flex}>
|
|
||||||
{/* 세그먼트 */}
|
|
||||||
<View style={styles.segment}>
|
|
||||||
{(['congestion', 'parking'] as Seg[]).map((s) => {
|
|
||||||
const on = seg === s;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={s}
|
|
||||||
accessibilityRole="tab"
|
|
||||||
accessibilityState={{ selected: on }}
|
|
||||||
style={[styles.segBtn, on && styles.segBtnOn]}
|
|
||||||
onPress={() => setSeg(s)}
|
|
||||||
>
|
|
||||||
<Text style={[styles.segText, on && styles.segTextOn]}>
|
|
||||||
{t(s === 'congestion' ? 'onsite.segCongestion' : 'onsite.segParking')}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{seg === 'congestion' ? (
|
|
||||||
<CongestionView eventId={eventId} />
|
|
||||||
) : (
|
|
||||||
<ParkingView eventId={eventId} hasToken={!!token} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 혼잡도 ──
|
|
||||||
function CongestionView({ eventId }: { eventId: string }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [data, setData] = useState<CongestionOverviewDto | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [degraded, setDegraded] = useState(false);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await getCongestion(eventId);
|
|
||||||
setData(res);
|
|
||||||
setDegraded(false);
|
|
||||||
} catch (e) {
|
|
||||||
setDegraded(isDegraded(e));
|
|
||||||
setData(null);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [eventId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<View style={styles.loading}>
|
|
||||||
<ActivityIndicator color={colors.primary600} size="large" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!data) {
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={styles.scroll}>
|
|
||||||
<Banner tone={degraded ? 'degraded' : 'info'}>
|
|
||||||
{degraded ? t('congestion.degraded') : t('congestion.empty')}
|
|
||||||
</Banner>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={styles.scroll}>
|
|
||||||
{/* 종합 */}
|
|
||||||
<View style={styles.overallCard}>
|
|
||||||
<View style={styles.overallTop}>
|
|
||||||
<Text style={styles.overallLabel}>{t('congestion.overall')}</Text>
|
|
||||||
<CongestionPill level={data.overallLevel} label={data.overallLabel} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.overallMetric}>
|
|
||||||
<Ionicons name="people-outline" size={18} color={colors.neutral500} />
|
|
||||||
<Text style={styles.overallCount}>
|
|
||||||
{t('congestion.onSite')} {t('congestion.people', { n: won(data.onSiteCount) })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<AreaGroup title={t('congestion.gates')} icon="enter-outline" areas={data.entryGates} />
|
|
||||||
<AreaGroup title={t('congestion.popular')} icon="star-outline" areas={data.popularSessions} />
|
|
||||||
<AreaGroup title={t('congestion.parking')} icon="car-outline" areas={data.parking} />
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AreaGroup({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
areas,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
areas: CongestionAreaDto[];
|
|
||||||
}) {
|
|
||||||
if (!areas || areas.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<View style={styles.group}>
|
|
||||||
<View style={styles.groupHeader}>
|
|
||||||
<Ionicons name={icon} size={16} color={colors.primary600} />
|
|
||||||
<Text style={styles.groupTitle}>{title}</Text>
|
|
||||||
</View>
|
|
||||||
{areas.map((a) => (
|
|
||||||
<View key={a.id} style={styles.areaRow}>
|
|
||||||
<Text style={styles.areaLabel} numberOfLines={1}>
|
|
||||||
{a.label}
|
|
||||||
</Text>
|
|
||||||
<CongestionPill level={a.level} label={a.levelLabel} percent={a.occupancyPercent} size="sm" />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 주차 ──
|
|
||||||
function ParkingView({ eventId, hasToken }: { eventId: string; hasToken: boolean }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [lots, setLots] = useState<ParkingLotStatusDto[] | null>(null);
|
|
||||||
const [degraded, setDegraded] = useState(false);
|
|
||||||
const [passes, setPasses] = useState<ParkingPassDto[] | null>(null);
|
|
||||||
const [purchaseFor, setPurchaseFor] = useState<ParkingLotStatusDto | null>(null);
|
|
||||||
|
|
||||||
const loadLots = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await getParkingLots(eventId);
|
|
||||||
setLots(res ?? []);
|
|
||||||
setDegraded(false);
|
|
||||||
} catch (e) {
|
|
||||||
setDegraded(isDegraded(e));
|
|
||||||
setLots([]);
|
|
||||||
}
|
|
||||||
}, [eventId]);
|
|
||||||
|
|
||||||
const loadPasses = useCallback(async () => {
|
|
||||||
if (!hasToken) {
|
|
||||||
setPasses(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await getMyParkingPasses();
|
|
||||||
setPasses(res?.passes ?? []);
|
|
||||||
} catch {
|
|
||||||
setPasses([]); // degraded/미배포 시 빈 목록(정직)
|
|
||||||
}
|
|
||||||
}, [hasToken]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadLots();
|
|
||||||
loadPasses();
|
|
||||||
}, [loadLots, loadPasses]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={styles.scroll}>
|
|
||||||
{/* 주차 현황 */}
|
|
||||||
<View style={styles.groupHeader}>
|
|
||||||
<Ionicons name="car-outline" size={16} color={colors.primary600} />
|
|
||||||
<Text style={styles.groupTitle}>{t('parking.title')}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{lots == null ? (
|
|
||||||
<View style={styles.loading}>
|
|
||||||
<ActivityIndicator color={colors.primary600} />
|
|
||||||
</View>
|
|
||||||
) : lots.length === 0 ? (
|
|
||||||
<Banner tone={degraded ? 'degraded' : 'info'}>
|
|
||||||
{degraded ? t('parking.degraded') : t('parking.empty')}
|
|
||||||
</Banner>
|
|
||||||
) : (
|
|
||||||
lots.map((lot) => (
|
|
||||||
<LotCard key={lot.lotId} lot={lot} onBuy={() => setPurchaseFor(lot)} canBuy={hasToken} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 내 주차권 */}
|
|
||||||
<View style={[styles.groupHeader, { marginTop: spacing.sm }]}>
|
|
||||||
<Ionicons name="pricetag-outline" size={16} color={colors.primary600} />
|
|
||||||
<Text style={styles.groupTitle}>{t('parking.myPasses')}</Text>
|
|
||||||
</View>
|
|
||||||
{!hasToken ? (
|
|
||||||
<Pressable style={styles.loginNote} onPress={() => router.push('/login')}>
|
|
||||||
<Text style={styles.loginNoteText}>{t('parking.loginRequired')}</Text>
|
|
||||||
<Ionicons name="chevron-forward" size={16} color={colors.primary600} />
|
|
||||||
</Pressable>
|
|
||||||
) : passes == null ? (
|
|
||||||
<View style={styles.loading}>
|
|
||||||
<ActivityIndicator color={colors.primary600} />
|
|
||||||
</View>
|
|
||||||
) : passes.length === 0 ? (
|
|
||||||
<Text style={styles.emptyNote}>{t('parking.noPasses')}</Text>
|
|
||||||
) : (
|
|
||||||
passes.map((p) => <PassCard key={p.passNo} pass={p} />)
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PurchaseModal
|
|
||||||
lot={purchaseFor}
|
|
||||||
onClose={() => setPurchaseFor(null)}
|
|
||||||
onPurchased={() => {
|
|
||||||
setPurchaseFor(null);
|
|
||||||
loadPasses();
|
|
||||||
loadLots();
|
|
||||||
}}
|
|
||||||
eventId={eventId}
|
|
||||||
/>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LotCard({
|
|
||||||
lot,
|
|
||||||
onBuy,
|
|
||||||
canBuy,
|
|
||||||
}: {
|
|
||||||
lot: ParkingLotStatusDto;
|
|
||||||
onBuy: () => void;
|
|
||||||
canBuy: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const pct = Math.max(0, Math.min(100, lot.occupancyPercent));
|
|
||||||
return (
|
|
||||||
<View style={styles.lotCard}>
|
|
||||||
<View style={styles.lotTop}>
|
|
||||||
<Text style={styles.lotName} numberOfLines={1}>
|
|
||||||
{lot.name}
|
|
||||||
</Text>
|
|
||||||
<CongestionPill level={lot.congestionLevel} label={lot.congestionLabel} percent={pct} size="sm" />
|
|
||||||
</View>
|
|
||||||
{/* 점유율 바 */}
|
|
||||||
<View style={styles.bar}>
|
|
||||||
<View style={[styles.barFill, { width: `${pct}%` }]} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.lotMetaRow}>
|
|
||||||
<Text style={styles.lotMeta}>
|
|
||||||
{t('parking.available', { n: won(lot.available) })} · {t('parking.capacity', { n: won(lot.totalCapacity) })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.lotMetaRow}>
|
|
||||||
<Text style={styles.lotMeta}>
|
|
||||||
{t('parking.rate', { n: won(lot.hourlyRate) })}
|
|
||||||
{lot.dailyMax != null ? ` · ${t('parking.daily', { n: won(lot.dailyMax) })}` : ''}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={[styles.buyBtn, !canBuy && styles.buyBtnGhost]}
|
|
||||||
onPress={onBuy}
|
|
||||||
disabled={!canBuy}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="ticket-outline"
|
|
||||||
size={16}
|
|
||||||
color={canBuy ? colors.white : colors.neutral500}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.buyText, !canBuy && { color: colors.neutral500 }]}>
|
|
||||||
{t('parking.buyPass')} · {t('parking.passPrice', { n: won(lot.passPrice) })}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
{!canBuy ? <Text style={styles.buyHint}>{t('parking.loginRequired')}</Text> : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const PASS_STATUS_TONE: Record<ParkingPassDto['status'], { color: string; bg: string }> = {
|
|
||||||
PAID: { color: colors.success, bg: '#E7F6EF' },
|
|
||||||
USED: { color: colors.neutral700, bg: colors.neutral050 },
|
|
||||||
CANCELLED: { color: colors.error, bg: '#FEF3F2' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function PassCard({ pass }: { pass: ParkingPassDto }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const tone = PASS_STATUS_TONE[pass.status] ?? PASS_STATUS_TONE.USED;
|
|
||||||
const dim = pass.status !== 'PAID';
|
|
||||||
return (
|
|
||||||
<View style={[styles.passCard, dim && styles.passDim]}>
|
|
||||||
<View style={styles.passTop}>
|
|
||||||
<Text style={styles.passLot} numberOfLines={1}>
|
|
||||||
{pass.lotName}
|
|
||||||
</Text>
|
|
||||||
<View style={[styles.passBadge, { backgroundColor: tone.bg }]}>
|
|
||||||
<Text style={[styles.passBadgeText, { color: tone.color }]}>
|
|
||||||
{t(`parking.passStatus.${pass.status}` as const)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.passMeta}>
|
|
||||||
{pass.useDate} · {pass.passNo}
|
|
||||||
</Text>
|
|
||||||
<View style={styles.passBottom}>
|
|
||||||
<Text style={styles.passMeta}>
|
|
||||||
{pass.vehiclePlateMasked ?? '-'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.passAmount}>{t('parking.passPrice', { n: won(pass.amount) })}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PurchaseModal({
|
|
||||||
lot,
|
|
||||||
eventId,
|
|
||||||
onClose,
|
|
||||||
onPurchased,
|
|
||||||
}: {
|
|
||||||
lot: ParkingLotStatusDto | null;
|
|
||||||
eventId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onPurchased: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [dayOffset, setDayOffset] = useState(1); // 내일 기본(오늘 이후)
|
|
||||||
const [plate, setPlate] = useState('');
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [err, setErr] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const useDate = useMemo(() => {
|
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() + dayOffset);
|
|
||||||
return d;
|
|
||||||
}, [dayOffset]);
|
|
||||||
|
|
||||||
// 모달이 열릴 때마다 초기화
|
|
||||||
useEffect(() => {
|
|
||||||
if (lot) {
|
|
||||||
setDayOffset(1);
|
|
||||||
setPlate('');
|
|
||||||
setErr(null);
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}, [lot]);
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (!lot) return;
|
|
||||||
setBusy(true);
|
|
||||||
setErr(null);
|
|
||||||
try {
|
|
||||||
await purchaseParkingPass({
|
|
||||||
lotId: lot.lotId,
|
|
||||||
useDate: ymd(useDate),
|
|
||||||
eventId,
|
|
||||||
vehiclePlate: plate.trim() ? plate.trim() : undefined,
|
|
||||||
payMethod: 'card',
|
|
||||||
});
|
|
||||||
onPurchased();
|
|
||||||
} catch (e) {
|
|
||||||
setErr((e as Error).message || t('parking.purchaseErr'));
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal visible={!!lot} transparent animationType="slide" onRequestClose={onClose}>
|
|
||||||
<View style={styles.modalBackdrop}>
|
|
||||||
<View style={styles.modalSheet}>
|
|
||||||
<View style={styles.modalHeader}>
|
|
||||||
<Text style={styles.modalTitle}>{t('parking.purchaseTitle')}</Text>
|
|
||||||
<Pressable onPress={onClose} accessibilityLabel={t('common.close')} hitSlop={8}>
|
|
||||||
<Ionicons name="close" size={22} color={colors.neutral500} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{lot ? (
|
|
||||||
<>
|
|
||||||
{/* 선택 주차장 */}
|
|
||||||
<Text style={styles.modalLabel}>{t('parking.selectLot')}</Text>
|
|
||||||
<View style={styles.modalLotBox}>
|
|
||||||
<Text style={styles.modalLotName}>{lot.name}</Text>
|
|
||||||
<Text style={styles.modalLotPrice}>{t('parking.passPrice', { n: won(lot.passPrice) })}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 이용일 스텝퍼 */}
|
|
||||||
<Text style={styles.modalLabel}>{t('parking.useDate')}</Text>
|
|
||||||
<View style={styles.dateRow}>
|
|
||||||
<Pressable
|
|
||||||
style={styles.dateBtn}
|
|
||||||
disabled={dayOffset <= 1}
|
|
||||||
onPress={() => setDayOffset((v) => Math.max(1, v - 1))}
|
|
||||||
accessibilityLabel={t('parking.prevDay')}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="chevron-back"
|
|
||||||
size={20}
|
|
||||||
color={dayOffset <= 1 ? colors.neutral200 : colors.neutral700}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<Text style={styles.dateText}>{ymd(useDate)}</Text>
|
|
||||||
<Pressable
|
|
||||||
style={styles.dateBtn}
|
|
||||||
disabled={dayOffset >= 14}
|
|
||||||
onPress={() => setDayOffset((v) => Math.min(14, v + 1))}
|
|
||||||
accessibilityLabel={t('parking.nextDay')}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="chevron-forward"
|
|
||||||
size={20}
|
|
||||||
color={dayOffset >= 14 ? colors.neutral200 : colors.neutral700}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 차량번호(선택) */}
|
|
||||||
<Text style={styles.modalLabel}>{t('parking.plate')}</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={plate}
|
|
||||||
onChangeText={setPlate}
|
|
||||||
placeholder={t('parking.platePlaceholder')}
|
|
||||||
placeholderTextColor={colors.neutral500}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
<Text style={styles.modalNote}>{t('parking.plateNote')}</Text>
|
|
||||||
<Text style={styles.modalNote}>{t('parking.payNote')}</Text>
|
|
||||||
|
|
||||||
{err ? <Banner tone="error">{err}</Banner> : null}
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
style={[styles.confirmBtn, busy && styles.confirmBusy]}
|
|
||||||
onPress={submit}
|
|
||||||
disabled={busy}
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
{busy ? (
|
|
||||||
<ActivityIndicator color={colors.white} />
|
|
||||||
) : (
|
|
||||||
<Text style={styles.confirmText}>{t('parking.confirm')}</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
flex: { flex: 1, backgroundColor: colors.neutral050 },
|
|
||||||
scroll: { padding: spacing.md, gap: spacing.sm, paddingBottom: 48 },
|
|
||||||
loading: { paddingVertical: spacing.xl, alignItems: 'center' },
|
|
||||||
segment: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
margin: spacing.md,
|
|
||||||
marginBottom: 0,
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
padding: 4,
|
|
||||||
gap: 4,
|
|
||||||
},
|
|
||||||
segBtn: {
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 40,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: radius.sm,
|
|
||||||
},
|
|
||||||
segBtnOn: { backgroundColor: colors.primary050 },
|
|
||||||
segText: { fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral500 },
|
|
||||||
segTextOn: { color: colors.primary700 },
|
|
||||||
// 혼잡
|
|
||||||
overallCard: {
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
padding: spacing.md,
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
overallTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
||||||
overallLabel: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
overallMetric: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
||||||
overallCount: { fontSize: type.body.fontSize, color: colors.neutral700, fontWeight: '600' },
|
|
||||||
group: {
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
padding: spacing.md,
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
groupHeader: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
||||||
groupTitle: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
areaRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 10,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: colors.neutral200,
|
|
||||||
paddingTop: 8,
|
|
||||||
},
|
|
||||||
areaLabel: { flex: 1, fontSize: type.body.fontSize, color: colors.neutral700 },
|
|
||||||
// 주차 lot
|
|
||||||
lotCard: {
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
padding: spacing.md,
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
lotTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
|
|
||||||
lotName: { flex: 1, fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
bar: {
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: colors.neutral200,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
barFill: { height: 8, borderRadius: 4, backgroundColor: colors.primary600 },
|
|
||||||
lotMetaRow: { flexDirection: 'row' },
|
|
||||||
lotMeta: { fontSize: type.caption.fontSize, color: colors.neutral500 },
|
|
||||||
buyBtn: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 6,
|
|
||||||
minHeight: touch.min,
|
|
||||||
borderRadius: radius.sm,
|
|
||||||
backgroundColor: colors.primary600,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
buyBtnGhost: { backgroundColor: colors.neutral050, borderWidth: 1, borderColor: colors.neutral200 },
|
|
||||||
buyText: { color: colors.white, fontSize: type.body.fontSize, fontWeight: '700' },
|
|
||||||
buyHint: { fontSize: 11, color: colors.neutral500, textAlign: 'center' },
|
|
||||||
// 주차권
|
|
||||||
loginNote: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
backgroundColor: colors.primary050,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 12,
|
|
||||||
},
|
|
||||||
loginNoteText: { fontSize: type.body.fontSize, color: colors.primary700, fontWeight: '600' },
|
|
||||||
emptyNote: { fontSize: type.caption.fontSize, color: colors.neutral500, paddingVertical: spacing.sm },
|
|
||||||
passCard: {
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
padding: spacing.md,
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
passDim: { opacity: 0.6 },
|
|
||||||
passTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
|
|
||||||
passLot: { flex: 1, fontSize: type.body.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
passBadge: { borderRadius: radius.pill, paddingHorizontal: 10, paddingVertical: 3 },
|
|
||||||
passBadgeText: { fontSize: 11, fontWeight: '700' },
|
|
||||||
passMeta: { fontSize: type.caption.fontSize, color: colors.neutral500 },
|
|
||||||
passBottom: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
||||||
passAmount: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.primary700 },
|
|
||||||
// 모달
|
|
||||||
modalBackdrop: { flex: 1, backgroundColor: 'rgba(16,24,40,0.45)', justifyContent: 'flex-end' },
|
|
||||||
modalSheet: {
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
padding: spacing.md,
|
|
||||||
paddingBottom: spacing.xl,
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
||||||
modalTitle: { fontSize: type.h2.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
modalLabel: { fontSize: type.caption.fontSize, color: colors.neutral500, fontWeight: '600', marginTop: 6 },
|
|
||||||
modalLotBox: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
backgroundColor: colors.neutral050,
|
|
||||||
borderRadius: radius.sm,
|
|
||||||
padding: 12,
|
|
||||||
},
|
|
||||||
modalLotName: { flex: 1, fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral900 },
|
|
||||||
modalLotPrice: { fontSize: type.body.fontSize, fontWeight: '700', color: colors.primary700 },
|
|
||||||
dateRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
backgroundColor: colors.neutral050,
|
|
||||||
borderRadius: radius.sm,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
},
|
|
||||||
dateBtn: { width: touch.min, height: touch.min, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
dateText: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
input: {
|
|
||||||
minHeight: touch.min,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
borderRadius: radius.sm,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
fontSize: type.body.fontSize,
|
|
||||||
color: colors.neutral900,
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
},
|
|
||||||
modalNote: { fontSize: 11, color: colors.neutral500, lineHeight: 16 },
|
|
||||||
confirmBtn: {
|
|
||||||
minHeight: 52,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
backgroundColor: colors.primary600,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
confirmBusy: { opacity: 0.7 },
|
|
||||||
confirmText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' },
|
|
||||||
});
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
/*
|
|
||||||
* SCR-M15 [모바일] 관람객(B2C) 티켓 탭 — 공용 TicketWallet 본문 재사용.
|
|
||||||
* 헤더는 탭 레이아웃이 소유. 기존 /tickets 라우트와 동일 본문(편입).
|
|
||||||
*/
|
|
||||||
import React from 'react';
|
|
||||||
import { TicketWallet } from '../../components/tickets/TicketWallet';
|
|
||||||
|
|
||||||
export default function VisitorTicketsScreen() {
|
|
||||||
return <TicketWallet />;
|
|
||||||
}
|
|
||||||
@ -30,7 +30,6 @@ function RootStack() {
|
|||||||
<Stack.Screen name="register" options={{ title: t('nav.register') }} />
|
<Stack.Screen name="register" options={{ title: t('nav.register') }} />
|
||||||
<Stack.Screen name="forgot-password" options={{ title: t('nav.forgotPassword') }} />
|
<Stack.Screen name="forgot-password" options={{ title: t('nav.forgotPassword') }} />
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="(visitor)" options={{ headerShown: false }} />
|
|
||||||
<Stack.Screen name="profile" options={{ title: t('nav.profile') }} />
|
<Stack.Screen name="profile" options={{ title: t('nav.profile') }} />
|
||||||
<Stack.Screen name="checklist" options={{ title: t('nav.checklist') }} />
|
<Stack.Screen name="checklist" options={{ title: t('nav.checklist') }} />
|
||||||
<Stack.Screen name="inspection" options={{ title: t('nav.inspection') }} />
|
<Stack.Screen name="inspection" options={{ title: t('nav.inspection') }} />
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { Link, router } from 'expo-router';
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Image,
|
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
@ -105,15 +104,9 @@ export default function LoginScreen() {
|
|||||||
contentContainerStyle={[styles.scroll, { paddingTop: insets.top + spacing.lg }]}
|
contentContainerStyle={[styles.scroll, { paddingTop: insets.top + spacing.lg }]}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
{/* 브랜드 패널 — 정식 CI 워드마크(CI_01, 딥블루 패널용 화이트 변형) */}
|
{/* 브랜드 패널 */}
|
||||||
<View style={styles.brand}>
|
<View style={styles.brand}>
|
||||||
<Image
|
<Text style={styles.brandMark}>KINTEX</Text>
|
||||||
source={require('../assets/logo-wordmark-white.png')}
|
|
||||||
style={styles.brandMark}
|
|
||||||
resizeMode="contain"
|
|
||||||
accessibilityRole="image"
|
|
||||||
accessibilityLabel="KINTEX"
|
|
||||||
/>
|
|
||||||
<Text style={styles.brandTitle}>{t('login.brandTitle')}</Text>
|
<Text style={styles.brandTitle}>{t('login.brandTitle')}</Text>
|
||||||
<Text style={styles.brandCaption}>{t('login.brandCaption')}</Text>
|
<Text style={styles.brandCaption}>{t('login.brandCaption')}</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -200,7 +193,7 @@ const styles = StyleSheet.create({
|
|||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
gap: 8,
|
gap: 8,
|
||||||
},
|
},
|
||||||
brandMark: { width: 178, height: 32, marginBottom: 2 },
|
brandMark: { color: colors.white, fontSize: 22, fontWeight: '800', letterSpacing: 1 },
|
||||||
brandTitle: { color: colors.white, fontSize: type.h2.fontSize, fontWeight: '700', lineHeight: 30 },
|
brandTitle: { color: colors.white, fontSize: type.h2.fontSize, fontWeight: '700', lineHeight: 30 },
|
||||||
brandCaption: { color: 'rgba(255,255,255,0.75)', fontSize: type.caption.fontSize },
|
brandCaption: { color: 'rgba(255,255,255,0.75)', fontSize: type.caption.fontSize },
|
||||||
card: {
|
card: {
|
||||||
|
|||||||
@ -1,18 +1,191 @@
|
|||||||
/*
|
/*
|
||||||
* SCR-M15 [모바일] 내 티켓 지갑 — 레거시 스택 라우트(/tickets).
|
* SCR-M15 [모바일] 내 티켓 지갑 — 관람객(B2C) 트랙.
|
||||||
* 본문은 공용 TicketWallet 컴포넌트(관람객 탭 (visitor)/tickets와 동일). 딥링크·예매 완료 복귀 호환용.
|
* 필터 탭(진행중·예정·지난) + 티켓 카드 리스트 + QR 풀스크린 뷰어.
|
||||||
|
* 배지 전환 안내 배너 + 오프라인 표시 배지. 사용됨/취소 카드 흐리게.
|
||||||
|
* ※ M10(티켓)·M9(환불) 백엔드 미구현 → 샘플 데이터("샘플" 배지). API 호출 없음.
|
||||||
*/
|
*/
|
||||||
import { Stack } from 'expo-router';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import React from 'react';
|
import { router, Stack } from 'expo-router';
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { TicketWallet } from '../../components/tickets/TicketWallet';
|
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 {
|
||||||
|
FILTER_TABS,
|
||||||
|
SAMPLE_TICKETS,
|
||||||
|
type SampleTicket,
|
||||||
|
type TicketFilter,
|
||||||
|
} from '../../components/tickets/sampleTickets';
|
||||||
|
import { colors, radius, spacing, type } from '../../theme';
|
||||||
|
|
||||||
export default function TicketWalletScreen() {
|
export default function TicketWalletScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// 티켓 QR 화면 — 캡처 차단(QR 재사용 방지) + 백그라운드 마스킹(B4/B7).
|
||||||
|
useSecureScreen('tickets');
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState<TicketFilter>('active');
|
||||||
|
const [qrOpen, setQrOpen] = useState(false);
|
||||||
|
const [qrIndex, setQrIndex] = useState(0);
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => SAMPLE_TICKETS.filter((t) => t.filter === filter),
|
||||||
|
[filter],
|
||||||
|
);
|
||||||
|
|
||||||
|
// QR 뷰어 대상 = 현재 필터 내 사용가능 티켓만(스와이프 전환 범위)
|
||||||
|
const usableInView = useMemo(
|
||||||
|
() => filtered.filter((t) => t.status === 'usable'),
|
||||||
|
[filtered],
|
||||||
|
);
|
||||||
|
|
||||||
|
function openQr(t: SampleTicket) {
|
||||||
|
const i = usableInView.findIndex((x) => x.id === t.id);
|
||||||
|
setQrIndex(i < 0 ? 0 : i);
|
||||||
|
setQrOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(ticket: SampleTicket) {
|
||||||
|
// SCR-P8(예매 확인·취소) 미구현 → 안내만(샘플)
|
||||||
|
Alert.alert(
|
||||||
|
t('tickets.detailTitle'),
|
||||||
|
`${ticket.eventName}\n${ticket.bookingNoMasked}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View style={styles.flex}>
|
||||||
<Stack.Screen options={{ title: t('nav.ticketsIndex'), headerTitleAlign: 'center' }} />
|
<Stack.Screen options={{ title: t('nav.ticketsIndex'), headerTitleAlign: 'center' }} />
|
||||||
<TicketWallet />
|
|
||||||
</>
|
<ScrollView contentContainerStyle={styles.scroll}>
|
||||||
|
{/* 오프라인 표시 배지 */}
|
||||||
|
<View style={styles.offlineWrap}>
|
||||||
|
<View style={styles.offlineBadge}>
|
||||||
|
<View style={styles.offlineDot} />
|
||||||
|
<Text style={styles.offlineText}>{t('tickets.offline')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 필터 탭(세그먼트) */}
|
||||||
|
<View style={styles.segment}>
|
||||||
|
{FILTER_TABS.map((tab) => {
|
||||||
|
const on = filter === tab.key;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={tab.key}
|
||||||
|
accessibilityRole="tab"
|
||||||
|
accessibilityState={{ selected: on }}
|
||||||
|
style={[styles.segBtn, on && styles.segBtnOn]}
|
||||||
|
onPress={() => setFilter(tab.key)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.segText, on && styles.segTextOn]}>{tab.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 배지 전환 안내 배너 */}
|
||||||
|
<Banner tone="info">{t('tickets.badgeNotice')}</Banner>
|
||||||
|
|
||||||
|
{/* 티켓 리스트 / 빈 상태 */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
filtered.map((tk) => (
|
||||||
|
<TicketCard key={tk.id} ticket={tk} onOpenQr={openQr} onDetail={openDetail} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.footNote}>{t('tickets.footNote')}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<QrViewerModal
|
||||||
|
visible={qrOpen}
|
||||||
|
tickets={usableInView}
|
||||||
|
index={qrIndex}
|
||||||
|
onChangeIndex={setQrIndex}
|
||||||
|
onClose={() => setQrOpen(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Ionicons name="ticket-outline" size={44} color={colors.neutral200} />
|
||||||
|
<Text style={styles.emptyTitle}>{t('tickets.emptyTitle')}</Text>
|
||||||
|
<Text style={styles.emptyBody}>{t('tickets.emptyBody')}</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={styles.emptyBtn}
|
||||||
|
onPress={() => router.push('/tickets/select')}
|
||||||
|
>
|
||||||
|
<Text style={styles.emptyBtnText}>{t('tickets.emptyBtn')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
flex: { flex: 1, backgroundColor: colors.neutral050 },
|
||||||
|
scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 40 },
|
||||||
|
offlineWrap: { alignItems: 'center' },
|
||||||
|
offlineBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
backgroundColor: colors.white,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.neutral200,
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 5,
|
||||||
|
},
|
||||||
|
offlineDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.primary600 },
|
||||||
|
offlineText: { fontSize: 11, color: colors.neutral500, fontWeight: '500' },
|
||||||
|
segment: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: colors.white,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.neutral200,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
padding: 4,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
segBtn: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
},
|
||||||
|
segBtnOn: { backgroundColor: colors.primary050 },
|
||||||
|
segText: { fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral500 },
|
||||||
|
segTextOn: { color: colors.primary700 },
|
||||||
|
footNote: { textAlign: 'center', fontSize: 11, color: colors.neutral500, marginTop: 4 },
|
||||||
|
empty: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
paddingVertical: spacing.xl,
|
||||||
|
backgroundColor: colors.white,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.neutral200,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
},
|
||||||
|
emptyTitle: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
||||||
|
emptyBody: { fontSize: type.caption.fontSize, color: colors.neutral500, textAlign: 'center' },
|
||||||
|
emptyBtn: {
|
||||||
|
marginTop: 8,
|
||||||
|
minHeight: 48,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.primary600,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
},
|
||||||
|
emptyBtnText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' },
|
||||||
|
});
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 30 KiB |
@ -1,51 +0,0 @@
|
|||||||
/*
|
|
||||||
* 혼잡도 필 — 여유(FREE)/보통(NORMAL)/혼잡(BUSY) 3단계.
|
|
||||||
* 색 + 텍스트 병기(WCAG: 색만으로 정보 전달 금지). 점유율(%) 선택 노출.
|
|
||||||
*/
|
|
||||||
import React from 'react';
|
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
|
||||||
import type { CongestionLevel } from '../lib/types';
|
|
||||||
import { colors, radius, type } from '../theme';
|
|
||||||
|
|
||||||
const LEVEL_STYLE: Record<CongestionLevel, { color: string; bg: string }> = {
|
|
||||||
FREE: { color: colors.success, bg: '#E7F6EF' },
|
|
||||||
NORMAL: { color: colors.warning, bg: '#FFF7ED' },
|
|
||||||
BUSY: { color: colors.error, bg: '#FEF3F2' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CongestionPill({
|
|
||||||
level,
|
|
||||||
label,
|
|
||||||
percent,
|
|
||||||
size = 'md',
|
|
||||||
}: {
|
|
||||||
level: CongestionLevel;
|
|
||||||
label: string;
|
|
||||||
percent?: number | null;
|
|
||||||
size?: 'sm' | 'md';
|
|
||||||
}) {
|
|
||||||
const s = LEVEL_STYLE[level] ?? LEVEL_STYLE.NORMAL;
|
|
||||||
const text = percent != null ? `${label} ${percent}%` : label;
|
|
||||||
return (
|
|
||||||
<View style={[styles.pill, { backgroundColor: s.bg }, size === 'sm' && styles.pillSm]}>
|
|
||||||
<View style={[styles.dot, { backgroundColor: s.color }]} />
|
|
||||||
<Text style={[styles.text, { color: s.color }, size === 'sm' && styles.textSm]}>{text}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
pill: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 5,
|
|
||||||
borderRadius: radius.pill,
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingVertical: 4,
|
|
||||||
alignSelf: 'flex-start',
|
|
||||||
},
|
|
||||||
pillSm: { paddingHorizontal: 8, paddingVertical: 2 },
|
|
||||||
dot: { width: 7, height: 7, borderRadius: 4 },
|
|
||||||
text: { fontSize: type.caption.fontSize, fontWeight: '700' },
|
|
||||||
textSm: { fontSize: 11 },
|
|
||||||
});
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
/*
|
|
||||||
* 라이브 공지 피드 — 공개 GET /api/public/events/{id}/live-notices.
|
|
||||||
* 고정(pinned) 상단 우선 → 최신순(서버 정렬 신뢰). 긴급(URGENT) 강조. 30초 폴링.
|
|
||||||
* 상태: 로딩·빈·degraded(마지막 데이터 유지 + 안내). 홈/행사 상세 공용.
|
|
||||||
* 근거: src/backend/_workspace/cms_backlog_contract.md (공개 라이브 공지).
|
|
||||||
*/
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { isDegraded } from '../lib/api';
|
|
||||||
import { getLiveNotices } from '../lib/visitor';
|
|
||||||
import type { LiveNoticeCategory, LiveNoticeDto } from '../lib/types';
|
|
||||||
import { colors, radius, spacing, type } from '../theme';
|
|
||||||
|
|
||||||
const POLL_MS = 30_000;
|
|
||||||
|
|
||||||
/** 카테고리 → 배지 색(계약 권장 매핑). */
|
|
||||||
const CATEGORY_STYLE: Record<LiveNoticeCategory, { color: string; bg: string }> = {
|
|
||||||
URGENT: { color: colors.error, bg: '#FEF3F2' },
|
|
||||||
PROGRAM: { color: colors.warning, bg: '#FFF7ED' },
|
|
||||||
INFO: { color: colors.primary700, bg: colors.primary050 },
|
|
||||||
GENERAL: { color: colors.neutral700, bg: colors.neutral050 },
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
eventId: string;
|
|
||||||
/** 노출 최대 건수(홈=3 요약, 행사=전체). */
|
|
||||||
max?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LiveNoticeFeed({ eventId, max }: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [items, setItems] = useState<LiveNoticeDto[] | null>(null);
|
|
||||||
const [degraded, setDegraded] = useState(false);
|
|
||||||
const mounted = useRef(true);
|
|
||||||
|
|
||||||
const catLabel = useCallback(
|
|
||||||
(c: LiveNoticeCategory): string => t(`notices.cat.${c}` as const),
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await getLiveNotices(eventId, 100);
|
|
||||||
if (!mounted.current) return;
|
|
||||||
// 서버가 pinned 우선→최신 정렬. 방어적으로 pinned 재정렬(안정 정렬).
|
|
||||||
const sorted = [...(res ?? [])].sort((a, b) => Number(b.pinned) - Number(a.pinned));
|
|
||||||
setItems(sorted);
|
|
||||||
setDegraded(false);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted.current) return;
|
|
||||||
setDegraded(isDegraded(e));
|
|
||||||
setItems((prev) => prev ?? []); // 최초 실패는 빈 배열로(무한 로딩 방지)
|
|
||||||
}
|
|
||||||
}, [eventId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
mounted.current = true;
|
|
||||||
load();
|
|
||||||
const timer = setInterval(load, POLL_MS);
|
|
||||||
return () => {
|
|
||||||
mounted.current = false;
|
|
||||||
clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const visible = max != null ? (items ?? []).slice(0, max) : items ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.wrap}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<View style={styles.headerLeft}>
|
|
||||||
<Ionicons name="megaphone-outline" size={18} color={colors.primary600} />
|
|
||||||
<Text style={styles.title}>{t('notices.title')}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.liveDot}>
|
|
||||||
<View style={styles.liveDotInner} />
|
|
||||||
<Text style={styles.liveText}>{t('notices.live')}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{items == null ? (
|
|
||||||
<View style={styles.loading}>
|
|
||||||
<ActivityIndicator color={colors.primary600} />
|
|
||||||
</View>
|
|
||||||
) : visible.length === 0 ? (
|
|
||||||
<Text style={styles.empty}>
|
|
||||||
{degraded ? t('notices.degraded') : t('notices.empty')}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<View style={styles.list}>
|
|
||||||
{degraded ? <Text style={styles.degradedNote}>{t('notices.degraded')}</Text> : null}
|
|
||||||
{visible.map((n) => (
|
|
||||||
<NoticeRow key={n.id} notice={n} catLabel={catLabel} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoticeRow({
|
|
||||||
notice,
|
|
||||||
catLabel,
|
|
||||||
}: {
|
|
||||||
notice: LiveNoticeDto;
|
|
||||||
catLabel: (c: LiveNoticeCategory) => string;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const urgent = notice.category === 'URGENT';
|
|
||||||
const cat = CATEGORY_STYLE[notice.category] ?? CATEGORY_STYLE.GENERAL;
|
|
||||||
return (
|
|
||||||
<View style={[styles.row, urgent && styles.rowUrgent]}>
|
|
||||||
<View style={styles.rowTop}>
|
|
||||||
<View style={[styles.catBadge, { backgroundColor: cat.bg }]}>
|
|
||||||
<Text style={[styles.catText, { color: cat.color }]}>{catLabel(notice.category)}</Text>
|
|
||||||
</View>
|
|
||||||
{notice.pinned ? (
|
|
||||||
<View style={styles.pinBadge}>
|
|
||||||
<Ionicons name="bookmark" size={10} color={colors.primary700} />
|
|
||||||
<Text style={styles.pinText}>{t('notices.pinned')}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<Text style={[styles.rowTitle, urgent && { color: colors.error }]} numberOfLines={2}>
|
|
||||||
{notice.title}
|
|
||||||
</Text>
|
|
||||||
{notice.body ? (
|
|
||||||
<Text style={styles.rowBody} numberOfLines={2}>
|
|
||||||
{notice.body}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
{notice.authorName ? <Text style={styles.rowMeta}>{notice.authorName}</Text> : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
wrap: {
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
padding: spacing.md,
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
||||||
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
||||||
title: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
liveDot: { flexDirection: 'row', alignItems: 'center', gap: 5 },
|
|
||||||
liveDotInner: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.error },
|
|
||||||
liveText: { fontSize: 11, fontWeight: '700', color: colors.error },
|
|
||||||
loading: { paddingVertical: spacing.md, alignItems: 'center' },
|
|
||||||
empty: { fontSize: type.caption.fontSize, color: colors.neutral500, paddingVertical: spacing.sm },
|
|
||||||
degradedNote: { fontSize: 11, color: colors.neutral500 },
|
|
||||||
list: { gap: spacing.sm },
|
|
||||||
row: {
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: colors.neutral200,
|
|
||||||
paddingTop: spacing.sm,
|
|
||||||
gap: 4,
|
|
||||||
},
|
|
||||||
rowUrgent: { borderLeftWidth: 3, borderLeftColor: colors.error, paddingLeft: 8 },
|
|
||||||
rowTop: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
||||||
catBadge: { borderRadius: radius.sm, paddingHorizontal: 6, paddingVertical: 2 },
|
|
||||||
catText: { fontSize: 10, fontWeight: '700' },
|
|
||||||
pinBadge: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
|
||||||
pinText: { fontSize: 10, fontWeight: '600', color: colors.primary700 },
|
|
||||||
rowTitle: { fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral900 },
|
|
||||||
rowBody: { fontSize: type.caption.fontSize, color: colors.neutral700, lineHeight: 18 },
|
|
||||||
rowMeta: { fontSize: 11, color: colors.neutral500 },
|
|
||||||
});
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
/*
|
|
||||||
* SCR-M15 내 티켓 지갑 (본문 컴포넌트) — 관람객(B2C) 트랙.
|
|
||||||
* 필터 탭(진행중·예정·지난) + 티켓 카드 + QR 풀스크린 뷰어 + 배지 전환 안내 + 오프라인 배지.
|
|
||||||
* 헤더(Stack/Tab)는 호출 측이 소유 — 이 컴포넌트는 본문만 렌더(탭·스택 양쪽 재사용).
|
|
||||||
* ※ M10(티켓)·M9(환불) 백엔드 미구현 → 샘플 데이터. API 호출 없음.
|
|
||||||
*/
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { useSecureScreen } from '../../context/SecureScreenContext';
|
|
||||||
import { colors, radius, spacing, type } from '../../theme';
|
|
||||||
import { Banner } from '../Banner';
|
|
||||||
import { QrViewerModal } from './QrViewerModal';
|
|
||||||
import { TicketCard } from './TicketCard';
|
|
||||||
import {
|
|
||||||
FILTER_TABS,
|
|
||||||
SAMPLE_TICKETS,
|
|
||||||
type SampleTicket,
|
|
||||||
type TicketFilter,
|
|
||||||
} from './sampleTickets';
|
|
||||||
|
|
||||||
export function TicketWallet() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// 티켓 QR 화면 — 캡처 차단(QR 재사용 방지) + 백그라운드 마스킹(B4/B7).
|
|
||||||
useSecureScreen('tickets');
|
|
||||||
|
|
||||||
const [filter, setFilter] = useState<TicketFilter>('active');
|
|
||||||
const [qrOpen, setQrOpen] = useState(false);
|
|
||||||
const [qrIndex, setQrIndex] = useState(0);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => SAMPLE_TICKETS.filter((tk) => tk.filter === filter), [filter]);
|
|
||||||
const usableInView = useMemo(() => filtered.filter((tk) => tk.status === 'usable'), [filtered]);
|
|
||||||
|
|
||||||
function openQr(tk: SampleTicket) {
|
|
||||||
const i = usableInView.findIndex((x) => x.id === tk.id);
|
|
||||||
setQrIndex(i < 0 ? 0 : i);
|
|
||||||
setQrOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDetail(ticket: SampleTicket) {
|
|
||||||
Alert.alert(t('tickets.detailTitle'), `${ticket.eventName}\n${ticket.bookingNoMasked}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.flex}>
|
|
||||||
<ScrollView contentContainerStyle={styles.scroll}>
|
|
||||||
{/* 오프라인 표시 배지 */}
|
|
||||||
<View style={styles.offlineWrap}>
|
|
||||||
<View style={styles.offlineBadge}>
|
|
||||||
<View style={styles.offlineDot} />
|
|
||||||
<Text style={styles.offlineText}>{t('tickets.offline')}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 필터 탭(세그먼트) */}
|
|
||||||
<View style={styles.segment}>
|
|
||||||
{FILTER_TABS.map((tab) => {
|
|
||||||
const on = filter === tab.key;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={tab.key}
|
|
||||||
accessibilityRole="tab"
|
|
||||||
accessibilityState={{ selected: on }}
|
|
||||||
style={[styles.segBtn, on && styles.segBtnOn]}
|
|
||||||
onPress={() => setFilter(tab.key)}
|
|
||||||
>
|
|
||||||
<Text style={[styles.segText, on && styles.segTextOn]}>{tab.label}</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 배지 전환 안내 배너 */}
|
|
||||||
<Banner tone="info">{t('tickets.badgeNotice')}</Banner>
|
|
||||||
|
|
||||||
{/* 티켓 리스트 / 빈 상태 */}
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<EmptyState />
|
|
||||||
) : (
|
|
||||||
filtered.map((tk) => (
|
|
||||||
<TicketCard key={tk.id} ticket={tk} onOpenQr={openQr} onDetail={openDetail} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text style={styles.footNote}>{t('tickets.footNote')}</Text>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<QrViewerModal
|
|
||||||
visible={qrOpen}
|
|
||||||
tickets={usableInView}
|
|
||||||
index={qrIndex}
|
|
||||||
onChangeIndex={setQrIndex}
|
|
||||||
onClose={() => setQrOpen(false)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyState() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<View style={styles.empty}>
|
|
||||||
<Ionicons name="ticket-outline" size={44} color={colors.neutral200} />
|
|
||||||
<Text style={styles.emptyTitle}>{t('tickets.emptyTitle')}</Text>
|
|
||||||
<Text style={styles.emptyBody}>{t('tickets.emptyBody')}</Text>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={styles.emptyBtn}
|
|
||||||
onPress={() => router.push('/tickets/select')}
|
|
||||||
>
|
|
||||||
<Text style={styles.emptyBtnText}>{t('tickets.emptyBtn')}</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
flex: { flex: 1, backgroundColor: colors.neutral050 },
|
|
||||||
scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 40 },
|
|
||||||
offlineWrap: { alignItems: 'center' },
|
|
||||||
offlineBadge: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
borderRadius: radius.pill,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 5,
|
|
||||||
},
|
|
||||||
offlineDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.primary600 },
|
|
||||||
offlineText: { fontSize: 11, color: colors.neutral500, fontWeight: '500' },
|
|
||||||
segment: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
padding: 4,
|
|
||||||
gap: 4,
|
|
||||||
},
|
|
||||||
segBtn: {
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 40,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: radius.sm,
|
|
||||||
},
|
|
||||||
segBtnOn: { backgroundColor: colors.primary050 },
|
|
||||||
segText: { fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral500 },
|
|
||||||
segTextOn: { color: colors.primary700 },
|
|
||||||
footNote: { textAlign: 'center', fontSize: 11, color: colors.neutral500, marginTop: 4 },
|
|
||||||
empty: {
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
paddingVertical: spacing.xl,
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.neutral200,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
},
|
|
||||||
emptyTitle: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
||||||
emptyBody: { fontSize: type.caption.fontSize, color: colors.neutral500, textAlign: 'center' },
|
|
||||||
emptyBtn: {
|
|
||||||
marginTop: 8,
|
|
||||||
minHeight: 48,
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: colors.primary600,
|
|
||||||
borderRadius: radius.sm,
|
|
||||||
},
|
|
||||||
emptyBtnText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' },
|
|
||||||
});
|
|
||||||
@ -13,10 +13,3 @@ export const API_BASE: string =
|
|||||||
/** AI 생성 이미지 기본 고지문 (백엔드 watermarkText 부재 시 폴백 — 계약 §0-3). */
|
/** AI 생성 이미지 기본 고지문 (백엔드 watermarkText 부재 시 폴백 — 계약 §0-3). */
|
||||||
export const AI_IMAGE_NOTICE =
|
export const AI_IMAGE_NOTICE =
|
||||||
'AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다';
|
'AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다';
|
||||||
|
|
||||||
/**
|
|
||||||
* 공개(B2C) 조회 폴백 행사 ID — 로그인 관람객이 참여 워크스페이스가 없거나
|
|
||||||
* 게스트로 공개 혼잡/주차/공지를 조회할 때 사용하는 데모 라이브 행사.
|
|
||||||
* 시크릿 아님(공개 식별자). 활성 워크스페이스가 있으면 그것을 우선한다.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_PUBLIC_EVENT_ID = 'e-2026-live';
|
|
||||||
|
|||||||
@ -201,91 +201,4 @@ export const en: Translations = {
|
|||||||
emptyBtn: 'Book entry pass',
|
emptyBtn: 'Book entry pass',
|
||||||
detailTitle: 'Booking detail · cancel',
|
detailTitle: 'Booking detail · cancel',
|
||||||
},
|
},
|
||||||
vnav: {
|
|
||||||
tabHome: 'Home',
|
|
||||||
tabEvents: 'Events',
|
|
||||||
tabTickets: 'Tickets',
|
|
||||||
tabOnsite: 'On-site',
|
|
||||||
tabMy: 'My',
|
|
||||||
},
|
|
||||||
vhome: {
|
|
||||||
hello: 'Welcome, {{name}}',
|
|
||||||
subtitle: "Explore today's exhibition with ease",
|
|
||||||
noEvent: 'You are not part of any event — book a pass and it will appear here.',
|
|
||||||
tileTickets: 'My Tickets',
|
|
||||||
tileTicketsDesc: 'Entry QR · badge',
|
|
||||||
tileEvents: 'Events',
|
|
||||||
tileEventsDesc: 'Notices · schedule',
|
|
||||||
tileOnsite: 'On-site',
|
|
||||||
tileOnsiteDesc: 'Crowd · parking',
|
|
||||||
aiTitle: 'AI Visit Assistant',
|
|
||||||
aiDesc: 'Live crowd, parking and notices. Booth search and wayfinding coming soon.',
|
|
||||||
},
|
|
||||||
vevents: {
|
|
||||||
title: 'Events',
|
|
||||||
empty: 'You are not part of any event — showing public notices.',
|
|
||||||
},
|
|
||||||
notices: {
|
|
||||||
title: 'Live Notices',
|
|
||||||
live: 'LIVE',
|
|
||||||
pinned: 'Pinned',
|
|
||||||
empty: 'No notices posted.',
|
|
||||||
degraded: 'Could not load notices (check connection).',
|
|
||||||
cat: {
|
|
||||||
URGENT: 'Urgent',
|
|
||||||
PROGRAM: 'Program',
|
|
||||||
GENERAL: 'General',
|
|
||||||
INFO: 'Info',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onsite: {
|
|
||||||
segCongestion: 'Crowd',
|
|
||||||
segParking: 'Parking',
|
|
||||||
},
|
|
||||||
congestion: {
|
|
||||||
overall: 'Overall crowd',
|
|
||||||
onSite: 'On-site',
|
|
||||||
people: '{{n}} people',
|
|
||||||
gates: 'Entry gates',
|
|
||||||
parking: 'Parking',
|
|
||||||
popular: 'Popular areas',
|
|
||||||
empty: 'No crowd data to show.',
|
|
||||||
degraded: 'Could not load crowd data (check connection).',
|
|
||||||
},
|
|
||||||
parking: {
|
|
||||||
title: 'Parking status',
|
|
||||||
empty: 'No parking data.',
|
|
||||||
degraded: 'Could not load parking data (check connection).',
|
|
||||||
available: '{{n}} free',
|
|
||||||
capacity: '{{n}} total',
|
|
||||||
rate: '{{n}} KRW/hr',
|
|
||||||
daily: 'daily max {{n}} KRW',
|
|
||||||
passPrice: '{{n}} KRW',
|
|
||||||
buyPass: 'Buy pass',
|
|
||||||
myPasses: 'My passes',
|
|
||||||
noPasses: 'You have no parking passes.',
|
|
||||||
loginRequired: 'Sign in to buy or view parking passes.',
|
|
||||||
purchaseTitle: 'Buy parking pass',
|
|
||||||
selectLot: 'Parking lot',
|
|
||||||
useDate: 'Use date',
|
|
||||||
plate: 'Vehicle plate (optional)',
|
|
||||||
platePlaceholder: 'e.g. 123가4567',
|
|
||||||
plateNote: 'The plate is masked and not stored.',
|
|
||||||
payNote: 'Payment is simulated (mock) — no real charge.',
|
|
||||||
confirm: 'Buy',
|
|
||||||
purchaseErr: 'Failed to buy the parking pass.',
|
|
||||||
prevDay: 'Previous day',
|
|
||||||
nextDay: 'Next day',
|
|
||||||
passStatus: {
|
|
||||||
PAID: 'Paid',
|
|
||||||
USED: 'Used',
|
|
||||||
CANCELLED: 'Cancelled',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vmy: {
|
|
||||||
tickets: 'My Tickets',
|
|
||||||
ticketsDesc: 'Entry QR · badge switch',
|
|
||||||
parking: 'My Passes',
|
|
||||||
parkingDesc: 'Parking status · pre-pass',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -201,91 +201,4 @@ export const ja: Translations = {
|
|||||||
emptyBtn: '入場券を予約',
|
emptyBtn: '入場券を予約',
|
||||||
detailTitle: '予約詳細 · キャンセル',
|
detailTitle: '予約詳細 · キャンセル',
|
||||||
},
|
},
|
||||||
vnav: {
|
|
||||||
tabHome: 'ホーム',
|
|
||||||
tabEvents: 'イベント',
|
|
||||||
tabTickets: 'チケット',
|
|
||||||
tabOnsite: '現場',
|
|
||||||
tabMy: 'マイ',
|
|
||||||
},
|
|
||||||
vhome: {
|
|
||||||
hello: '{{name}}さん、ようこそ',
|
|
||||||
subtitle: '今日の展示会を気軽に見て回りましょう',
|
|
||||||
noEvent: '参加中のイベントがありません — 入場券を予約するとここに表示されます。',
|
|
||||||
tileTickets: 'マイチケット',
|
|
||||||
tileTicketsDesc: '入場QR · バッジ',
|
|
||||||
tileEvents: 'イベント',
|
|
||||||
tileEventsDesc: 'お知らせ · 日程',
|
|
||||||
tileOnsite: '現場',
|
|
||||||
tileOnsiteDesc: '混雑 · 駐車',
|
|
||||||
aiTitle: 'AI 観覧アシスタント',
|
|
||||||
aiDesc: '混雑・駐車・お知らせをリアルタイムに案内します。ブース検索・道案内も近日追加。',
|
|
||||||
},
|
|
||||||
vevents: {
|
|
||||||
title: 'イベント',
|
|
||||||
empty: '参加中のイベントがありません — 公開のお知らせを表示します。',
|
|
||||||
},
|
|
||||||
notices: {
|
|
||||||
title: 'ライブお知らせ',
|
|
||||||
live: 'LIVE',
|
|
||||||
pinned: '固定',
|
|
||||||
empty: '登録されたお知らせがありません。',
|
|
||||||
degraded: 'お知らせを取得できませんでした(接続を確認)。',
|
|
||||||
cat: {
|
|
||||||
URGENT: '緊急',
|
|
||||||
PROGRAM: 'プログラム',
|
|
||||||
GENERAL: '一般',
|
|
||||||
INFO: '案内',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onsite: {
|
|
||||||
segCongestion: '混雑度',
|
|
||||||
segParking: '駐車',
|
|
||||||
},
|
|
||||||
congestion: {
|
|
||||||
overall: '総合混雑度',
|
|
||||||
onSite: '現場人数',
|
|
||||||
people: '{{n}}人',
|
|
||||||
gates: '入場ゲート',
|
|
||||||
parking: '駐車場',
|
|
||||||
popular: '人気エリア',
|
|
||||||
empty: '表示できる混雑情報がありません。',
|
|
||||||
degraded: '混雑情報を取得できませんでした(接続を確認)。',
|
|
||||||
},
|
|
||||||
parking: {
|
|
||||||
title: '駐車状況',
|
|
||||||
empty: '駐車場情報がありません。',
|
|
||||||
degraded: '駐車情報を取得できませんでした(接続を確認)。',
|
|
||||||
available: '空き {{n}}台',
|
|
||||||
capacity: '全 {{n}}台',
|
|
||||||
rate: '1時間 {{n}}ウォン',
|
|
||||||
daily: '1日上限 {{n}}ウォン',
|
|
||||||
passPrice: '{{n}}ウォン',
|
|
||||||
buyPass: '駐車券購入',
|
|
||||||
myPasses: 'マイ駐車券',
|
|
||||||
noPasses: '保有する駐車券がありません。',
|
|
||||||
loginRequired: '駐車券の購入・照会にはログインが必要です。',
|
|
||||||
purchaseTitle: '事前駐車券の購入',
|
|
||||||
selectLot: '駐車場',
|
|
||||||
useDate: '利用日',
|
|
||||||
plate: '車両番号(任意)',
|
|
||||||
platePlaceholder: '例:123가4567',
|
|
||||||
plateNote: '車両番号はマスキングされ保存されません。',
|
|
||||||
payNote: '決済はシミュレーション(mock)です — 実際に請求されません。',
|
|
||||||
confirm: '購入',
|
|
||||||
purchaseErr: '駐車券の購入に失敗しました。',
|
|
||||||
prevDay: '前の日',
|
|
||||||
nextDay: '次の日',
|
|
||||||
passStatus: {
|
|
||||||
PAID: '決済完了',
|
|
||||||
USED: '使用済み',
|
|
||||||
CANCELLED: 'キャンセル',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vmy: {
|
|
||||||
tickets: 'マイチケット',
|
|
||||||
ticketsDesc: '入場QR · バッジ切替',
|
|
||||||
parking: 'マイ駐車券',
|
|
||||||
parkingDesc: '駐車状況 · 事前駐車券',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -203,93 +203,6 @@ export const ko = {
|
|||||||
emptyBtn: '입장권 예매',
|
emptyBtn: '입장권 예매',
|
||||||
detailTitle: '예매 상세·취소',
|
detailTitle: '예매 상세·취소',
|
||||||
},
|
},
|
||||||
vnav: {
|
|
||||||
tabHome: '홈',
|
|
||||||
tabEvents: '행사',
|
|
||||||
tabTickets: '티켓',
|
|
||||||
tabOnsite: '현장',
|
|
||||||
tabMy: '마이',
|
|
||||||
},
|
|
||||||
vhome: {
|
|
||||||
hello: '{{name}}님, 환영합니다',
|
|
||||||
subtitle: '오늘의 전시를 편하게 둘러보세요',
|
|
||||||
noEvent: '참여 중인 행사가 없습니다 — 입장권을 예매하면 여기에 표시됩니다.',
|
|
||||||
tileTickets: '내 티켓',
|
|
||||||
tileTicketsDesc: '입장 QR · 배지',
|
|
||||||
tileEvents: '행사',
|
|
||||||
tileEventsDesc: '공지 · 일정',
|
|
||||||
tileOnsite: '현장',
|
|
||||||
tileOnsiteDesc: '혼잡 · 주차',
|
|
||||||
aiTitle: 'AI 관람 도우미',
|
|
||||||
aiDesc: '혼잡·주차·공지를 실시간으로 안내합니다. 곧 부스 검색·길찾기도 추가됩니다.',
|
|
||||||
},
|
|
||||||
vevents: {
|
|
||||||
title: '행사',
|
|
||||||
empty: '참여 중인 행사가 없습니다 — 공개 공지를 표시합니다.',
|
|
||||||
},
|
|
||||||
notices: {
|
|
||||||
title: '라이브 공지',
|
|
||||||
live: 'LIVE',
|
|
||||||
pinned: '고정',
|
|
||||||
empty: '등록된 공지가 없습니다.',
|
|
||||||
degraded: '공지를 불러오지 못했습니다 (연결 확인).',
|
|
||||||
cat: {
|
|
||||||
URGENT: '긴급',
|
|
||||||
PROGRAM: '프로그램',
|
|
||||||
GENERAL: '일반',
|
|
||||||
INFO: '안내',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onsite: {
|
|
||||||
segCongestion: '혼잡도',
|
|
||||||
segParking: '주차',
|
|
||||||
},
|
|
||||||
congestion: {
|
|
||||||
overall: '종합 혼잡도',
|
|
||||||
onSite: '현장 인원',
|
|
||||||
people: '{{n}}명',
|
|
||||||
gates: '입장 게이트',
|
|
||||||
parking: '주차장',
|
|
||||||
popular: '인기 공간',
|
|
||||||
empty: '표시할 혼잡 정보가 없습니다.',
|
|
||||||
degraded: '혼잡 정보를 불러오지 못했습니다 (연결 확인).',
|
|
||||||
},
|
|
||||||
parking: {
|
|
||||||
title: '주차 현황',
|
|
||||||
empty: '주차장 정보가 없습니다.',
|
|
||||||
degraded: '주차 정보를 불러오지 못했습니다 (연결 확인).',
|
|
||||||
available: '여유 {{n}}면',
|
|
||||||
capacity: '전체 {{n}}면',
|
|
||||||
rate: '시간당 {{n}}원',
|
|
||||||
daily: '일 최대 {{n}}원',
|
|
||||||
passPrice: '{{n}}원',
|
|
||||||
buyPass: '주차권 구매',
|
|
||||||
myPasses: '내 주차권',
|
|
||||||
noPasses: '보유한 주차권이 없습니다.',
|
|
||||||
loginRequired: '주차권 구매·조회는 로그인이 필요합니다.',
|
|
||||||
purchaseTitle: '사전 주차권 구매',
|
|
||||||
selectLot: '주차장',
|
|
||||||
useDate: '이용일',
|
|
||||||
plate: '차량번호 (선택)',
|
|
||||||
platePlaceholder: '예: 123가4567',
|
|
||||||
plateNote: '차량번호는 마스킹되어 저장되지 않습니다.',
|
|
||||||
payNote: '결제는 시뮬레이션(mock)입니다 — 실제 청구되지 않습니다.',
|
|
||||||
confirm: '구매',
|
|
||||||
purchaseErr: '주차권 구매에 실패했습니다.',
|
|
||||||
prevDay: '이전 날짜',
|
|
||||||
nextDay: '다음 날짜',
|
|
||||||
passStatus: {
|
|
||||||
PAID: '결제완료',
|
|
||||||
USED: '사용됨',
|
|
||||||
CANCELLED: '취소',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vmy: {
|
|
||||||
tickets: '내 티켓',
|
|
||||||
ticketsDesc: '입장 QR · 배지 전환',
|
|
||||||
parking: '내 주차권',
|
|
||||||
parkingDesc: '주차 현황 · 사전 주차권',
|
|
||||||
},
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Resource = typeof ko;
|
export type Resource = typeof ko;
|
||||||
|
|||||||
@ -200,91 +200,4 @@ export const zh: Translations = {
|
|||||||
emptyBtn: '预订入场券',
|
emptyBtn: '预订入场券',
|
||||||
detailTitle: '预订详情 · 取消',
|
detailTitle: '预订详情 · 取消',
|
||||||
},
|
},
|
||||||
vnav: {
|
|
||||||
tabHome: '首页',
|
|
||||||
tabEvents: '展会',
|
|
||||||
tabTickets: '门票',
|
|
||||||
tabOnsite: '现场',
|
|
||||||
tabMy: '我的',
|
|
||||||
},
|
|
||||||
vhome: {
|
|
||||||
hello: '{{name}},欢迎',
|
|
||||||
subtitle: '轻松逛今天的展会',
|
|
||||||
noEvent: '您尚未参加任何展会 — 预订门票后将显示在此处。',
|
|
||||||
tileTickets: '我的门票',
|
|
||||||
tileTicketsDesc: '入场二维码 · 徽章',
|
|
||||||
tileEvents: '展会',
|
|
||||||
tileEventsDesc: '公告 · 日程',
|
|
||||||
tileOnsite: '现场',
|
|
||||||
tileOnsiteDesc: '拥挤 · 停车',
|
|
||||||
aiTitle: 'AI 观展助手',
|
|
||||||
aiDesc: '实时提供拥挤、停车与公告信息。展位搜索与导航即将上线。',
|
|
||||||
},
|
|
||||||
vevents: {
|
|
||||||
title: '展会',
|
|
||||||
empty: '您尚未参加任何展会 — 显示公开公告。',
|
|
||||||
},
|
|
||||||
notices: {
|
|
||||||
title: '实时公告',
|
|
||||||
live: 'LIVE',
|
|
||||||
pinned: '置顶',
|
|
||||||
empty: '暂无公告。',
|
|
||||||
degraded: '无法加载公告(请检查网络)。',
|
|
||||||
cat: {
|
|
||||||
URGENT: '紧急',
|
|
||||||
PROGRAM: '节目',
|
|
||||||
GENERAL: '一般',
|
|
||||||
INFO: '提示',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onsite: {
|
|
||||||
segCongestion: '拥挤度',
|
|
||||||
segParking: '停车',
|
|
||||||
},
|
|
||||||
congestion: {
|
|
||||||
overall: '综合拥挤度',
|
|
||||||
onSite: '现场人数',
|
|
||||||
people: '{{n}}人',
|
|
||||||
gates: '入场闸口',
|
|
||||||
parking: '停车场',
|
|
||||||
popular: '热门区域',
|
|
||||||
empty: '暂无拥挤信息。',
|
|
||||||
degraded: '无法加载拥挤信息(请检查网络)。',
|
|
||||||
},
|
|
||||||
parking: {
|
|
||||||
title: '停车现况',
|
|
||||||
empty: '暂无停车场信息。',
|
|
||||||
degraded: '无法加载停车信息(请检查网络)。',
|
|
||||||
available: '空位 {{n}}',
|
|
||||||
capacity: '共 {{n}}',
|
|
||||||
rate: '每小时 {{n}} 韩元',
|
|
||||||
daily: '每日最高 {{n}} 韩元',
|
|
||||||
passPrice: '{{n}} 韩元',
|
|
||||||
buyPass: '购买停车券',
|
|
||||||
myPasses: '我的停车券',
|
|
||||||
noPasses: '暂无停车券。',
|
|
||||||
loginRequired: '购买或查看停车券需要登录。',
|
|
||||||
purchaseTitle: '购买预约停车券',
|
|
||||||
selectLot: '停车场',
|
|
||||||
useDate: '使用日期',
|
|
||||||
plate: '车牌号(可选)',
|
|
||||||
platePlaceholder: '例如:123가4567',
|
|
||||||
plateNote: '车牌号将被掩码且不予保存。',
|
|
||||||
payNote: '支付为模拟(mock)— 不会实际收费。',
|
|
||||||
confirm: '购买',
|
|
||||||
purchaseErr: '停车券购买失败。',
|
|
||||||
prevDay: '前一天',
|
|
||||||
nextDay: '后一天',
|
|
||||||
passStatus: {
|
|
||||||
PAID: '已支付',
|
|
||||||
USED: '已使用',
|
|
||||||
CANCELLED: '已取消',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vmy: {
|
|
||||||
tickets: '我的门票',
|
|
||||||
ticketsDesc: '入场二维码 · 徽章转换',
|
|
||||||
parking: '我的停车券',
|
|
||||||
parkingDesc: '停车现况 · 预约停车券',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -52,14 +52,14 @@ export function resolveLandingTrack({ user, workspaces }: LandingInput): Track {
|
|||||||
* 트랙 → 로그인 직후 랜딩 라우트(모바일).
|
* 트랙 → 로그인 직후 랜딩 라우트(모바일).
|
||||||
* · admin/ops/business → 탭 홈(/(tabs)) — 모바일엔 별도 admin 화면 없음, 홈이 역할 진입 허브
|
* · admin/ops/business → 탭 홈(/(tabs)) — 모바일엔 별도 admin 화면 없음, 홈이 역할 진입 허브
|
||||||
* · agency(장치·시공) → 현장 탭(/(tabs)/field) — 체크리스트 허브
|
* · agency(장치·시공) → 현장 탭(/(tabs)/field) — 체크리스트 허브
|
||||||
* · visitor(관람객) → 관람객(B2C) 탭 셸(/(visitor)) — 홈·행사·티켓·현장·마이
|
* · visitor(관람객) → 티켓 지갑(/tickets) — B2C 진입
|
||||||
*/
|
*/
|
||||||
export function landingPathForTrack(track: Track): string {
|
export function landingPathForTrack(track: Track): string {
|
||||||
switch (track) {
|
switch (track) {
|
||||||
case 'agency':
|
case 'agency':
|
||||||
return '/(tabs)/field';
|
return '/(tabs)/field';
|
||||||
case 'visitor':
|
case 'visitor':
|
||||||
return '/(visitor)';
|
return '/tickets';
|
||||||
case 'admin':
|
case 'admin':
|
||||||
case 'ops':
|
case 'ops':
|
||||||
case 'business':
|
case 'business':
|
||||||
|
|||||||
@ -129,94 +129,3 @@ export interface OtpStatusDto {
|
|||||||
export interface AvatarUploadResponse {
|
export interface AvatarUploadResponse {
|
||||||
photoUrl: string;
|
photoUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── B2C 관람객: 혼잡·주차 (parking_congestion_contract.md) ──
|
|
||||||
/** 혼잡/점유 수준. 여유/보통/혼잡 3단계. */
|
|
||||||
export type CongestionLevel = 'FREE' | 'NORMAL' | 'BUSY';
|
|
||||||
|
|
||||||
/** 주차장 현황 — GET /api/public/parking/lots (공개). */
|
|
||||||
export interface ParkingLotStatusDto {
|
|
||||||
lotId: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
exhibitionCenter: number | null; // 1|2|null(공용)
|
|
||||||
totalCapacity: number;
|
|
||||||
occupied: number;
|
|
||||||
available: number;
|
|
||||||
occupancyPercent: number;
|
|
||||||
congestionLevel: CongestionLevel;
|
|
||||||
congestionLabel: string; // 여유|보통|혼잡
|
|
||||||
hourlyRate: number;
|
|
||||||
dailyMax: number | null;
|
|
||||||
passPrice: number; // 사전 주차권(1일권) 가격
|
|
||||||
note?: string | null;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 사전 주차권 구매 요청 — POST /api/parking/passes (인증). */
|
|
||||||
export interface PurchasePassRequest {
|
|
||||||
lotId: string;
|
|
||||||
useDate: string; // YYYY-MM-DD, 오늘 이후
|
|
||||||
eventId?: string;
|
|
||||||
vehiclePlate?: string; // 마스킹 후 폐기·미저장
|
|
||||||
payMethod?: 'card' | 'easy' | 'bank';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 주차권 — POST/GET 응답 공통 shape. */
|
|
||||||
export interface ParkingPassDto {
|
|
||||||
passNo: string;
|
|
||||||
status: 'PAID' | 'CANCELLED' | 'USED';
|
|
||||||
lotId: string;
|
|
||||||
lotName: string;
|
|
||||||
eventId: string | null;
|
|
||||||
useDate: string;
|
|
||||||
vehiclePlateMasked: string | null; // 원문 미저장(마스킹만)
|
|
||||||
amount: number;
|
|
||||||
payMethod: string | null;
|
|
||||||
payApprovalNo: string | null;
|
|
||||||
issuedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 주차권 — GET /api/parking/passes/me. */
|
|
||||||
export interface MyPassesDto {
|
|
||||||
passes: ParkingPassDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 혼잡 영역 — 게이트/주차/인기 공간 공통. occupancyPercent는 계측 부재 시 null. */
|
|
||||||
export interface CongestionAreaDto {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
level: CongestionLevel;
|
|
||||||
levelLabel: string;
|
|
||||||
occupancyPercent: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 혼잡 요약 — GET /api/public/congestion?eventId= (공개). */
|
|
||||||
export interface CongestionOverviewDto {
|
|
||||||
eventId: string;
|
|
||||||
overallLevel: CongestionLevel;
|
|
||||||
overallLabel: string;
|
|
||||||
onSiteCount: number;
|
|
||||||
entryGates: CongestionAreaDto[];
|
|
||||||
parking: CongestionAreaDto[];
|
|
||||||
popularSessions: CongestionAreaDto[];
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── B2C 관람객: 라이브 공지 (cms_backlog_contract.md) ──
|
|
||||||
/** 공지 카테고리 — 배지 색상 매핑(URGENT=red·PROGRAM=amber·INFO=blue·GENERAL=slate). */
|
|
||||||
export type LiveNoticeCategory = 'URGENT' | 'PROGRAM' | 'GENERAL' | 'INFO';
|
|
||||||
|
|
||||||
/** 라이브 공지 — GET /api/public/events/{eventId}/live-notices (공개). */
|
|
||||||
export interface LiveNoticeDto {
|
|
||||||
id: string;
|
|
||||||
eventId: string;
|
|
||||||
category: LiveNoticeCategory;
|
|
||||||
title: string;
|
|
||||||
body?: string | null;
|
|
||||||
pinned: boolean;
|
|
||||||
status: string; // published|archived
|
|
||||||
authorName?: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
/*
|
|
||||||
* 관람객(B2C) API 배선 — 공개 혼잡/주차/공지 + 인증 주차권.
|
|
||||||
* 계약: _workspace/parking_congestion_contract.md · src/backend/_workspace/cms_backlog_contract.md.
|
|
||||||
* 봉투 언랩·인증 헤더는 lib/api.ts가 처리. 공개 조회는 anonymous(토큰 미첨부).
|
|
||||||
* 주차권 구매/조회는 인증(JWT). PII(차량번호)는 서버가 마스킹해 반환 — 원문 미저장.
|
|
||||||
*/
|
|
||||||
import { api } from './api';
|
|
||||||
import type {
|
|
||||||
CongestionOverviewDto,
|
|
||||||
LiveNoticeDto,
|
|
||||||
MyPassesDto,
|
|
||||||
ParkingLotStatusDto,
|
|
||||||
ParkingPassDto,
|
|
||||||
PurchasePassRequest,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
/** 주차 현황 — 공개(비로그인). eventId는 선택. */
|
|
||||||
export function getParkingLots(eventId?: string): Promise<ParkingLotStatusDto[]> {
|
|
||||||
const q = eventId ? `?eventId=${encodeURIComponent(eventId)}` : '';
|
|
||||||
return api.get<ParkingLotStatusDto[]>(`/api/public/parking/lots${q}`, { anonymous: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 혼잡 요약 — 공개. eventId 필수. */
|
|
||||||
export function getCongestion(eventId: string): Promise<CongestionOverviewDto> {
|
|
||||||
return api.get<CongestionOverviewDto>(
|
|
||||||
`/api/public/congestion?eventId=${encodeURIComponent(eventId)}`,
|
|
||||||
{ anonymous: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 라이브 공지 — 공개. 게시(pinned 우선→최신) 건만. */
|
|
||||||
export function getLiveNotices(eventId: string, limit = 100): Promise<LiveNoticeDto[]> {
|
|
||||||
return api.get<LiveNoticeDto[]>(
|
|
||||||
`/api/public/events/${encodeURIComponent(eventId)}/live-notices?limit=${limit}`,
|
|
||||||
{ anonymous: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 사전 주차권 구매(mock 결제) — 인증. 구매자 = JWT 주체. */
|
|
||||||
export function purchaseParkingPass(req: PurchasePassRequest): Promise<ParkingPassDto> {
|
|
||||||
return api.post<ParkingPassDto>('/api/parking/passes', req);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 주차권 조회 — 인증(본인 소유분만). */
|
|
||||||
export function getMyParkingPasses(): Promise<MyPassesDto> {
|
|
||||||
return api.get<MyPassesDto>('/api/parking/passes/me');
|
|
||||||
}
|
|
||||||
@ -49,13 +49,6 @@ dependencies {
|
|||||||
// --- 서류 PDF 생성(M6 G-05) — HTML→PDF 경량 렌더러. NanumGothic 폰트 임베드(한글 보존). ---
|
// --- 서류 PDF 생성(M6 G-05) — HTML→PDF 경량 렌더러. NanumGothic 폰트 임베드(한글 보존). ---
|
||||||
implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10'
|
implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10'
|
||||||
|
|
||||||
// --- 범용 그리드 PDF 출력(전 그리드 공용, JasperReports) — WISE/UIWS 패턴 이식. ---
|
|
||||||
// 6.21.x는 PDF(iText) 익스포터가 코어에 포함. 한글은 fonts.xml 폰트확장으로 Identity-H 임베딩.
|
|
||||||
implementation('net.sf.jasperreports:jasperreports:6.21.3') {
|
|
||||||
// Spring Boot 관리 버전과 충돌 방지 — commons-logging 전이 의존성 정리.
|
|
||||||
exclude group: 'commons-logging', module: 'commons-logging'
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- JWT (행사 단위 RBAC 인증) ---
|
// --- JWT (행사 단위 RBAC 인증) ---
|
||||||
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
|
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
|
||||||
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
|
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
|
||||||
|
|||||||
@ -2,10 +2,6 @@ package com.zioinfo.kintex.admin;
|
|||||||
|
|
||||||
import com.zioinfo.kintex.admin.dto.AdminDashboardDto;
|
import com.zioinfo.kintex.admin.dto.AdminDashboardDto;
|
||||||
import com.zioinfo.kintex.admin.dto.AdminStatsDto;
|
import com.zioinfo.kintex.admin.dto.AdminStatsDto;
|
||||||
import com.zioinfo.kintex.common.audit.AuditLogDto;
|
|
||||||
import com.zioinfo.kintex.common.audit.AuditLogService;
|
|
||||||
import com.zioinfo.kintex.ops.OpsService;
|
|
||||||
import com.zioinfo.kintex.ops.dto.OpsDto;
|
|
||||||
import com.zioinfo.kintex.tenant.TenantContext;
|
import com.zioinfo.kintex.tenant.TenantContext;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@ -16,21 +12,14 @@ import java.util.Map;
|
|||||||
/**
|
/**
|
||||||
* 관리자 대시보드 서비스 — 실 테이블 집계로 KPI·라이브 행사를 조립.
|
* 관리자 대시보드 서비스 — 실 테이블 집계로 KPI·라이브 행사를 조립.
|
||||||
* 관람객 추이는 센서/집계 인프라 부재 → 빈배열. 테넌트는 실 데이터(KINTEX)만 반환.
|
* 관람객 추이는 센서/집계 인프라 부재 → 빈배열. 테넌트는 실 데이터(KINTEX)만 반환.
|
||||||
* <p>SCR-16 실전환: 라이브 행사에 M10 체크인 실인원·M14 혼잡 등급(OpsService 재사용, 수정 없이 호출만),
|
|
||||||
* 최근 활동은 AuditLogService(audit_log 실 기록) 재사용.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class AdminDashboardService {
|
public class AdminDashboardService {
|
||||||
|
|
||||||
private final AdminDashboardMapper mapper;
|
private final AdminDashboardMapper mapper;
|
||||||
private final OpsService opsService;
|
|
||||||
private final AuditLogService auditLogService;
|
|
||||||
|
|
||||||
public AdminDashboardService(AdminDashboardMapper mapper, OpsService opsService,
|
public AdminDashboardService(AdminDashboardMapper mapper) {
|
||||||
AuditLogService auditLogService) {
|
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.opsService = opsService;
|
|
||||||
this.auditLogService = auditLogService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public AdminDashboardDto getDashboard(String tenant) {
|
public AdminDashboardDto getDashboard(String tenant) {
|
||||||
@ -56,22 +45,9 @@ public class AdminDashboardService {
|
|||||||
long booths = lng(r.get("boothCount"));
|
long booths = lng(r.get("boothCount"));
|
||||||
long capacity = lng(r.get("capacity"));
|
long capacity = lng(r.get("capacity"));
|
||||||
int occupancy = capacity > 0 ? (int) Math.min(100, Math.round(booths * 100.0 / capacity)) : 0;
|
int occupancy = capacity > 0 ? (int) Math.min(100, Math.round(booths * 100.0 / capacity)) : 0;
|
||||||
String eventId = str(r.get("eventId"));
|
|
||||||
|
|
||||||
// M10 체크인 실인원 + M14 혼잡 등급 — OpsService(SCR-14) 파생 재사용. 한 행사 실패가 전체를 막지 않도록 방어.
|
|
||||||
long onSite = 0L;
|
|
||||||
String heat = "smooth";
|
|
||||||
try {
|
|
||||||
OpsDto ops = opsService.getOps(eventId);
|
|
||||||
onSite = ops.onSiteCount();
|
|
||||||
heat = eventHeat(ops);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
// 파생 실패 시 정직한 기본값(0/smooth) 유지 — 가짜 수치 금지.
|
|
||||||
}
|
|
||||||
|
|
||||||
live.add(new AdminDashboardDto.LiveEvent(
|
live.add(new AdminDashboardDto.LiveEvent(
|
||||||
eventId, str(r.get("name")), str(r.get("hall")),
|
str(r.get("eventId")), str(r.get("name")), str(r.get("hall")),
|
||||||
occupancy, "ongoing", onSite, heat));
|
occupancy, "ongoing"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,39 +55,7 @@ public class AdminDashboardService {
|
|||||||
List<AdminDashboardDto.VisitorPoint> visitorTrend = new ArrayList<>();
|
List<AdminDashboardDto.VisitorPoint> visitorTrend = new ArrayList<>();
|
||||||
List<String> tenants = List.of("KINTEX");
|
List<String> tenants = List.of("KINTEX");
|
||||||
|
|
||||||
return new AdminDashboardDto(kpis, visitorTrend, live, tenants, recentActivity());
|
return new AdminDashboardDto(kpis, visitorTrend, live, tenants);
|
||||||
}
|
|
||||||
|
|
||||||
/** 라이브 행사 혼잡 등급 = 배정 홀 중 최고 혼잡(busy>moderate>smooth). 인원 0/홀 부재 시 smooth. */
|
|
||||||
private static String eventHeat(OpsDto ops) {
|
|
||||||
int worst = 0; // 0=smooth 1=moderate 2=busy
|
|
||||||
if (ops != null && ops.halls() != null) {
|
|
||||||
for (OpsDto.Hall h : ops.halls()) {
|
|
||||||
worst = Math.max(worst, heatRank(h.heat()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return worst == 2 ? "busy" : worst == 1 ? "moderate" : "smooth";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int heatRank(String heat) {
|
|
||||||
if ("busy".equals(heat)) return 2;
|
|
||||||
if ("moderate".equals(heat)) return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 최근 관리 활동 8건 — audit_log 실 기록(AuditLogService 재사용). 실패 시 빈배열(정직). */
|
|
||||||
private List<AdminDashboardDto.Activity> recentActivity() {
|
|
||||||
List<AdminDashboardDto.Activity> out = new ArrayList<>();
|
|
||||||
try {
|
|
||||||
List<AuditLogDto> rows = auditLogService.search(null, null, null, 0, 8).items();
|
|
||||||
for (AuditLogDto a : rows) {
|
|
||||||
out.add(new AdminDashboardDto.Activity(
|
|
||||||
a.action(), a.actorName(), a.targetType(), a.summary(), a.result(), a.createdAt()));
|
|
||||||
}
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
// 조회 실패 시 빈배열 — 화면은 빈 상태(정직) 표기.
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -3,17 +3,14 @@ package com.zioinfo.kintex.admin.dto;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 백오피스 대시보드 (SCR-14/16 / 06_backend_api_gaps §4).
|
* 관리자 백오피스 대시보드 (SCR-14 / 06_backend_api_gaps §4).
|
||||||
* 집계원: event(진행/라이브)·hall_assignment·app_user·company.
|
* 집계원: event(진행/라이브)·hall_assignment·app_user·company. 관람객 추이는 센서/집계 부재 → 빈배열(정직).
|
||||||
* <p>실전환(SCR-16, 2026-07): ①라이브 행사에 M10 실 체크인 인원(onSiteCount)·M14 혼잡 등급(heat, OpsService 재사용) 부가,
|
|
||||||
* ②최근 활동(recentActivity)은 audit_log 실 기록. 관람객 시간대 추이는 센서/집계 부재 → 빈배열(정직).
|
|
||||||
*/
|
*/
|
||||||
public record AdminDashboardDto(
|
public record AdminDashboardDto(
|
||||||
List<Kpi> kpis,
|
List<Kpi> kpis,
|
||||||
List<VisitorPoint> visitorTrend,
|
List<VisitorPoint> visitorTrend,
|
||||||
List<LiveEvent> liveEvents,
|
List<LiveEvent> liveEvents,
|
||||||
List<String> tenants,
|
List<String> tenants
|
||||||
List<Activity> recentActivity
|
|
||||||
) {
|
) {
|
||||||
public record Kpi(String label, String value, String sub) {
|
public record Kpi(String label, String value, String sub) {
|
||||||
}
|
}
|
||||||
@ -22,16 +19,7 @@ public record AdminDashboardDto(
|
|||||||
public record VisitorPoint(String hour, long count) {
|
public record VisitorPoint(String hour, long count) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 라이브(진행 중) 행사 — 점유율=부스수/홀 수용. */
|
||||||
* 라이브(진행 중) 행사 — 점유율=부스수/홀 수용(occupancy),
|
public record LiveEvent(String eventId, String name, String hall, int occupancy, String status) {
|
||||||
* onSiteCount=M10 현장 체크인 실집계, heat=M14 혼잡 등급(smooth/moderate/busy, OpsService 파생).
|
|
||||||
*/
|
|
||||||
public record LiveEvent(String eventId, String name, String hall, int occupancy, String status,
|
|
||||||
long onSiteCount, String heat) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 최근 관리 활동 — audit_log append-only 실 기록(민감정보·스택트레이스 미포함). */
|
|
||||||
public record Activity(String action, String actorName, String targetType, String summary,
|
|
||||||
String result, String createdAt) {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,27 +53,6 @@ public interface AnalyticsMapper {
|
|||||||
""")
|
""")
|
||||||
Double findUtilityRevenue(@Param("eventId") String eventId);
|
Double findUtilityRevenue(@Param("eventId") String eventId);
|
||||||
|
|
||||||
/**
|
|
||||||
* 업종별 구성(sectors) — 참가업체(부스 배정사) 업종 분포. 원천 = booth.assigned_company_name
|
|
||||||
* ↔ company.name(대소문자·공백 무시 매칭) → company.category. 매칭 실패/미분류는 '기타' 버킷.
|
|
||||||
* <p>버킷별 부스 수·판매면적(㎡). 매출/점유율은 서비스가 면적×요율로 환산·정규화한다.
|
|
||||||
* <p>plain @Select(<script> 미사용) — {@code <>} 는 XML 파싱되지 않음(findRetentionSummary 동일 패턴).
|
|
||||||
*/
|
|
||||||
@Select("""
|
|
||||||
SELECT COALESCE(NULLIF(btrim(c.category), ''), '기타') AS "sector",
|
|
||||||
count(b.id) AS "boothCount",
|
|
||||||
COALESCE(sum(ST_Area(b.geom)), 0) AS "areaM2"
|
|
||||||
FROM booth b
|
|
||||||
JOIN layout l ON l.id = b.layout_id
|
|
||||||
LEFT JOIN company c ON lower(btrim(c.name)) = lower(btrim(b.assigned_company_name))
|
|
||||||
WHERE l.event_id = #{eventId}
|
|
||||||
AND b.assigned_company_name IS NOT NULL
|
|
||||||
AND btrim(b.assigned_company_name) <> ''
|
|
||||||
GROUP BY COALESCE(NULLIF(btrim(c.category), ''), '기타')
|
|
||||||
ORDER BY sum(ST_Area(b.geom)) DESC
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> findSectorAggregation(@Param("eventId") String eventId);
|
|
||||||
|
|
||||||
// ── 테넌트 전역(행사 횡단) BI 집계 — M16 잔여 갭 ──────────────────────────
|
// ── 테넌트 전역(행사 횡단) BI 집계 — M16 잔여 갭 ──────────────────────────
|
||||||
// ★멀티테넌시 2단계: 행사 횡단 집계는 tenant_id(event/hall) 선행 필터로 격리(§1A-2 · §8-2).
|
// ★멀티테넌시 2단계: 행사 횡단 집계는 tenant_id(event/hall) 선행 필터로 격리(§1A-2 · §8-2).
|
||||||
// booth/layout/utility_order는 tenant_id 컬럼이 없으므로 event 조인으로 간접 스코프(신규 컬럼 금지).
|
// booth/layout/utility_order는 tenant_id 컬럼이 없으므로 event 조인으로 간접 스코프(신규 컬럼 금지).
|
||||||
|
|||||||
@ -53,46 +53,20 @@ public class AnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 손익(operator 관점) — 실 매출 항목만.
|
// 손익(operator 관점) — 실 매출 항목만. 업종 분류 원천 부재 → sectors 미집계.
|
||||||
List<AnalyticsDto.PnlRow> pnl = new ArrayList<>();
|
List<AnalyticsDto.PnlRow> pnl = new ArrayList<>();
|
||||||
|
List<AnalyticsDto.Sector> sectors = new ArrayList<>();
|
||||||
if (!exhibitor) {
|
if (!exhibitor) {
|
||||||
pnl.add(new AnalyticsDto.PnlRow("부스 임대", rentalRevenue, grade(rentalRevenue)));
|
pnl.add(new AnalyticsDto.PnlRow("부스 임대", rentalRevenue, grade(rentalRevenue)));
|
||||||
pnl.add(new AnalyticsDto.PnlRow("유틸리티", utilityRevenue, grade(utilityRevenue)));
|
pnl.add(new AnalyticsDto.PnlRow("유틸리티", utilityRevenue, grade(utilityRevenue)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업종별 구성(sectors) — 참가업체(부스 배정사) 업종(company.category) 실집계. 운영사/참가업체 공통.
|
|
||||||
List<AnalyticsDto.Sector> sectors = buildSectors(eventId, ratePerM2);
|
|
||||||
|
|
||||||
// 참가업체 성과 — 방문자 리드(M13) 원천 부재 → 미집계(빈배열).
|
// 참가업체 성과 — 방문자 리드(M13) 원천 부재 → 미집계(빈배열).
|
||||||
List<AnalyticsDto.ExhibitorPerf> exhibitorPerf = new ArrayList<>();
|
List<AnalyticsDto.ExhibitorPerf> exhibitorPerf = new ArrayList<>();
|
||||||
|
|
||||||
return new AnalyticsDto(kpis, trend, sectors, pnl, exhibitorPerf);
|
return new AnalyticsDto(kpis, trend, sectors, pnl, exhibitorPerf);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 업종별 구성 집계 — 버킷별 판매면적을 매출(면적×요율)로 환산하고 전체 대비 점유율(%)로 정규화.
|
|
||||||
* 점유율 기준은 판매면적(=요율 단일 하 매출과 동률)이며 면적 내림차순(매퍼 정렬)을 보존한다.
|
|
||||||
* 원천(배정 부스) 부재 시 빈배열(정직) — 프론트는 빈 상태를 표시한다.
|
|
||||||
*/
|
|
||||||
private List<AnalyticsDto.Sector> buildSectors(String eventId, double ratePerM2) {
|
|
||||||
List<AnalyticsDto.Sector> out = new ArrayList<>();
|
|
||||||
List<Map<String, Object>> rows = mapper.findSectorAggregation(eventId);
|
|
||||||
if (rows == null || rows.isEmpty()) {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
double totalArea = 0;
|
|
||||||
for (Map<String, Object> r : rows) {
|
|
||||||
totalArea += dbl(r.get("areaM2"));
|
|
||||||
}
|
|
||||||
for (Map<String, Object> r : rows) {
|
|
||||||
double area = dbl(r.get("areaM2"));
|
|
||||||
double share = totalArea > 0 ? round1(area * 100.0 / totalArea) : 0.0;
|
|
||||||
long revenue = Math.round(area * ratePerM2);
|
|
||||||
out.add(new AnalyticsDto.Sector(str(r.get("sector")), share, revenue));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 테넌트 전역(행사 횡단) BI — M16 잔여 갭 ───────────────────────────────
|
// ── 테넌트 전역(행사 횡단) BI — M16 잔여 갭 ───────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -5,8 +5,7 @@ import java.util.List;
|
|||||||
/**
|
/**
|
||||||
* 경영분석 BI 집계 (SCR-13 / 06_backend_api_gaps §3).
|
* 경영분석 BI 집계 (SCR-13 / 06_backend_api_gaps §3).
|
||||||
* 집계원: booth 면적(ST_Area)×임대 요율(master_data)·utility_order.quote·booth 월별 추이.
|
* 집계원: booth 면적(ST_Area)×임대 요율(master_data)·utility_order.quote·booth 월별 추이.
|
||||||
* 업종(sectors)은 참가업체(booth.assigned_company_name↔company.category) 실집계.
|
* 업종(sectors)·리드(exhibitorPerf)는 원천(M13 방문자/업종 분류) 부재 → 빈배열(정직 반환).
|
||||||
* 리드(exhibitorPerf)는 원천(M13 방문자 분류) 부재 → 빈배열(정직 반환).
|
|
||||||
*/
|
*/
|
||||||
public record AnalyticsDto(
|
public record AnalyticsDto(
|
||||||
List<Kpi> kpis,
|
List<Kpi> kpis,
|
||||||
|
|||||||
@ -17,7 +17,7 @@ public interface CmsMapper {
|
|||||||
<script>
|
<script>
|
||||||
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
|
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
|
||||||
to_char(scheduled_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "scheduledAt",
|
to_char(scheduled_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "scheduledAt",
|
||||||
signage, mailing, author_name AS "authorName", hero_url AS "heroUrl",
|
signage, mailing, author_name AS "authorName",
|
||||||
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
|
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
|
||||||
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
|
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
|
||||||
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
|
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
|
||||||
@ -50,7 +50,7 @@ public interface CmsMapper {
|
|||||||
@Select("""
|
@Select("""
|
||||||
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
|
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
|
||||||
to_char(scheduled_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "scheduledAt",
|
to_char(scheduled_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "scheduledAt",
|
||||||
signage, mailing, author_name AS "authorName", hero_url AS "heroUrl",
|
signage, mailing, author_name AS "authorName",
|
||||||
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
|
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
|
||||||
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
|
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
|
||||||
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
|
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
|
||||||
@ -184,7 +184,6 @@ public interface CmsMapper {
|
|||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
|
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
|
||||||
hero_url AS "heroUrl",
|
|
||||||
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
|
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
|
||||||
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
|
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
|
||||||
FROM cms_content
|
FROM cms_content
|
||||||
|
|||||||
@ -497,7 +497,7 @@ public class CmsService {
|
|||||||
str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")),
|
str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")),
|
||||||
str(r.get("scheduledAt")), bool(r.get("signage")), bool(r.get("mailing")),
|
str(r.get("scheduledAt")), bool(r.get("signage")), bool(r.get("mailing")),
|
||||||
str(r.get("authorName")), str(r.get("publishedAt")),
|
str(r.get("authorName")), str(r.get("publishedAt")),
|
||||||
str(r.get("createdAt")), str(r.get("updatedAt")), str(r.get("heroUrl")));
|
str(r.get("createdAt")), str(r.get("updatedAt")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 공개 응답 — 저자/예약/플래그 등 운영 필드는 노출하지 않는다. */
|
/** 공개 응답 — 저자/예약/플래그 등 운영 필드는 노출하지 않는다. */
|
||||||
@ -506,7 +506,7 @@ public class CmsService {
|
|||||||
str(r.get("id")), str(r.get("eventId")), str(r.get("contentType")),
|
str(r.get("id")), str(r.get("eventId")), str(r.get("contentType")),
|
||||||
str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")),
|
str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")),
|
||||||
null, false, false, null, str(r.get("publishedAt")),
|
null, false, false, null, str(r.get("publishedAt")),
|
||||||
null, str(r.get("updatedAt")), str(r.get("heroUrl")));
|
null, str(r.get("updatedAt")));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CmsTranslationDto toTranslationDto(Map<String, Object> r) {
|
private static CmsTranslationDto toTranslationDto(Map<String, Object> r) {
|
||||||
|
|||||||
@ -15,6 +15,5 @@ public record CmsContentDto(
|
|||||||
String authorName,
|
String authorName,
|
||||||
String publishedAt,
|
String publishedAt,
|
||||||
String createdAt,
|
String createdAt,
|
||||||
String updatedAt,
|
String updatedAt) {
|
||||||
String heroUrl) {
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,8 +70,8 @@ public class SecurityConfig {
|
|||||||
ApiResponse.fail(new ApiError(ErrorCode.UNAUTHORIZED.name(),
|
ApiResponse.fail(new ApiError(ErrorCode.UNAUTHORIZED.name(),
|
||||||
ErrorCode.UNAUTHORIZED.defaultMessage()))));
|
ErrorCode.UNAUTHORIZED.defaultMessage()))));
|
||||||
}))
|
}))
|
||||||
// 테넌트 컨텍스트 해소(§1A-3) — 인증 요청은 JWT tid 권위(§1A-3-2, 2단계). 미해소 시 기준 테넌트 폴백(회귀 0).
|
// 테넌트 컨텍스트 해소(§1A-3) → 이후 JWT 인증. 미해소 시 기준 테넌트 폴백(회귀 0).
|
||||||
.addFilterBefore(new TenantContextFilter(tenantResolver, objectMapper, jwtService),
|
.addFilterBefore(new TenantContextFilter(tenantResolver, objectMapper),
|
||||||
UsernamePasswordAuthenticationFilter.class)
|
UsernamePasswordAuthenticationFilter.class)
|
||||||
.addFilterBefore(new JwtAuthenticationFilter(jwtService),
|
.addFilterBefore(new JwtAuthenticationFilter(jwtService),
|
||||||
UsernamePasswordAuthenticationFilter.class);
|
UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|||||||
@ -1,116 +0,0 @@
|
|||||||
package com.zioinfo.kintex.congestion;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.congestion.dto.CongestionDtos.CongestionAreaDto;
|
|
||||||
import com.zioinfo.kintex.congestion.dto.CongestionDtos.CongestionOverviewDto;
|
|
||||||
import com.zioinfo.kintex.ops.OpsService;
|
|
||||||
import com.zioinfo.kintex.ops.dto.OpsDto;
|
|
||||||
import com.zioinfo.kintex.parking.ParkingService;
|
|
||||||
import com.zioinfo.kintex.parking.dto.ParkingDtos.ParkingLotStatusDto;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관람객 혼잡 안내 서비스(F-C3) — M14 현장운영(OpsService, 체크인 파생 혼잡) 실집계를 재사용해
|
|
||||||
* 입장 게이트(홀 입장)·주차·인기 공간(세션) 혼잡을 관람객 3단계(여유/보통/혼잡)로 재구성한다.
|
|
||||||
*
|
|
||||||
* <p>재사용 원칙: 혼잡 산출 로직을 중복 구현하지 않고 {@link OpsService}(홀 혼잡)와
|
|
||||||
* {@link ParkingService}(주차 점유)의 결과를 관람객 관점으로 매핑한다. 신규 계측·하드웨어 없음.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class CongestionService {
|
|
||||||
|
|
||||||
/** 인기 공간(세션) 노출 상한 — 현장 인원 상위 N개 홀. */
|
|
||||||
private static final int POPULAR_LIMIT = 3;
|
|
||||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
|
||||||
|
|
||||||
private final OpsService opsService;
|
|
||||||
private final ParkingService parkingService;
|
|
||||||
|
|
||||||
public CongestionService(OpsService opsService, ParkingService parkingService) {
|
|
||||||
this.opsService = opsService;
|
|
||||||
this.parkingService = parkingService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CongestionOverviewDto getPublicCongestion(String eventId) {
|
|
||||||
OpsDto ops = opsService.getOps(eventId);
|
|
||||||
|
|
||||||
// 입장 게이트 = 홀 입장 혼잡(OpsService heat 재사용). 게이트 점유율은 계측 부재 → null.
|
|
||||||
List<CongestionAreaDto> gates = new ArrayList<>();
|
|
||||||
for (OpsDto.Hall h : ops.halls()) {
|
|
||||||
String level = mapHeat(h.heat());
|
|
||||||
gates.add(new CongestionAreaDto(h.hallId(), h.label(), level, label(level), null));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 인기 공간(세션) = 현장 인원 상위 홀(혼잡 홀). 세션 단위 계측 부재 → 홀 파생 근사.
|
|
||||||
List<CongestionAreaDto> popular = ops.halls().stream()
|
|
||||||
.filter(h -> h.estPeople() > 0)
|
|
||||||
.sorted(Comparator.comparingLong(OpsDto.Hall::estPeople).reversed())
|
|
||||||
.limit(POPULAR_LIMIT)
|
|
||||||
.map(h -> {
|
|
||||||
String level = mapHeat(h.heat());
|
|
||||||
return new CongestionAreaDto(h.hallId(), h.label(), level, label(level), null);
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// 주차 = ParkingService 점유율(시뮬레이션 어댑터). 점유율 그대로 노출.
|
|
||||||
List<CongestionAreaDto> parking = new ArrayList<>();
|
|
||||||
for (ParkingLotStatusDto lot : parkingService.getLotStatus(eventId)) {
|
|
||||||
parking.add(new CongestionAreaDto(lot.lotId(), lot.name(),
|
|
||||||
lot.congestionLevel(), lot.congestionLabel(), lot.occupancyPercent()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전체 = 게이트 + 주차 중 가장 혼잡한 단계(관람객 안전 안내는 보수적으로).
|
|
||||||
String overall = worstLevel(gates, parking);
|
|
||||||
return new CongestionOverviewDto(eventId, overall, label(overall), ops.onSiteCount(),
|
|
||||||
gates, parking, popular, LocalDateTime.now().format(TS));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** OpsService heat(smooth/moderate/busy) → 관람객 3단계 코드. */
|
|
||||||
private static String mapHeat(String heat) {
|
|
||||||
if (heat == null) {
|
|
||||||
return "FREE";
|
|
||||||
}
|
|
||||||
return switch (heat) {
|
|
||||||
case "busy" -> "BUSY";
|
|
||||||
case "moderate" -> "NORMAL";
|
|
||||||
default -> "FREE";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String label(String level) {
|
|
||||||
return switch (level) {
|
|
||||||
case "BUSY" -> "혼잡";
|
|
||||||
case "NORMAL" -> "보통";
|
|
||||||
default -> "여유";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 여러 영역 중 가장 혼잡한 단계(FREE<NORMAL<BUSY). 비면 FREE. */
|
|
||||||
@SafeVarargs
|
|
||||||
private static String worstLevel(List<CongestionAreaDto>... groups) {
|
|
||||||
int worst = 0; // 0=FREE,1=NORMAL,2=BUSY
|
|
||||||
for (List<CongestionAreaDto> g : groups) {
|
|
||||||
for (CongestionAreaDto a : g) {
|
|
||||||
worst = Math.max(worst, rank(a.level()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return switch (worst) {
|
|
||||||
case 2 -> "BUSY";
|
|
||||||
case 1 -> "NORMAL";
|
|
||||||
default -> "FREE";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int rank(String level) {
|
|
||||||
return switch (level == null ? "FREE" : level) {
|
|
||||||
case "BUSY" -> 2;
|
|
||||||
case "NORMAL" -> 1;
|
|
||||||
default -> 0;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
package com.zioinfo.kintex.congestion;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
|
||||||
import com.zioinfo.kintex.congestion.dto.CongestionDtos.CongestionOverviewDto;
|
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 공개/관람객 혼잡 안내 API (F-C3) — 비인증 공개({@code /api/public/**} permitAll).
|
|
||||||
* <p>관람객 웹/앱(공개 또는 로그인)에서 조회 가능. 입장 게이트·주차·인기 공간 혼잡을 3단계로 안내.
|
|
||||||
* <p>M14 현장운영 혼잡 실집계(체크인 파생)를 재사용하되, 관람객 관점 요약만 노출한다(운영 상세 미노출).
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/public/congestion")
|
|
||||||
public class PublicCongestionController {
|
|
||||||
|
|
||||||
private final CongestionService service;
|
|
||||||
|
|
||||||
public PublicCongestionController(CongestionService service) {
|
|
||||||
this.service = service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/public/congestion?eventId= — 관람객 혼잡 요약(전체 + 게이트/주차/인기 공간). */
|
|
||||||
@GetMapping
|
|
||||||
public ApiResponse<CongestionOverviewDto> congestion(@RequestParam String eventId) {
|
|
||||||
if (eventId == null || eventId.isBlank()) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "행사 식별자가 필요합니다.");
|
|
||||||
}
|
|
||||||
return ApiResponse.ok(service.getPublicCongestion(eventId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
package com.zioinfo.kintex.congestion.dto;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 공개/관람객 혼잡 안내(F-C3) DTO. M14 현장운영(체크인 파생 혼잡) 실집계를 관람객 관점으로 재구성.
|
|
||||||
* <p>혼잡 3단계: level ∈ FREE|NORMAL|BUSY (여유/보통/혼잡). 관람객 웹/앱 표시 전용.
|
|
||||||
*/
|
|
||||||
public final class CongestionDtos {
|
|
||||||
|
|
||||||
private CongestionDtos() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관람객 혼잡 요약 — 전체 + 입장 게이트/주차/인기 공간(세션) 3영역.
|
|
||||||
* <p>onSiteCount = 현장 체크인 인원(M10 파생). updatedAt = 산출 시각.
|
|
||||||
*/
|
|
||||||
public record CongestionOverviewDto(
|
|
||||||
String eventId,
|
|
||||||
String overallLevel,
|
|
||||||
String overallLabel,
|
|
||||||
long onSiteCount,
|
|
||||||
List<CongestionAreaDto> entryGates,
|
|
||||||
List<CongestionAreaDto> parking,
|
|
||||||
List<CongestionAreaDto> popularSessions,
|
|
||||||
String updatedAt) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 혼잡 영역 단위 — 게이트(홀 입장)·주차장·인기 공간 공통.
|
|
||||||
* occupancyPercent 는 점유율 산출 가능한 영역(주차)만 채우고, 게이트/공간은 null(계측 부재).
|
|
||||||
*/
|
|
||||||
public record CongestionAreaDto(
|
|
||||||
String id,
|
|
||||||
String label,
|
|
||||||
String level,
|
|
||||||
String levelLabel,
|
|
||||||
Integer occupancyPercent) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
package com.zioinfo.kintex.livenotice;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.auth.EventAccessGuard;
|
|
||||||
import com.zioinfo.kintex.auth.EventRole;
|
|
||||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
|
||||||
import com.zioinfo.kintex.livenotice.dto.LiveNoticeDtos.LiveNoticeCreateRequest;
|
|
||||||
import com.zioinfo.kintex.livenotice.dto.LiveNoticeDtos.LiveNoticeDto;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 행사 라이브 공지(F-B6) 주최자 API — 발행/목록/보관/고정. 행사 ORGANIZER(및 관리자·홀매니저) 권한.
|
|
||||||
* <p>발행 시 §5B 알림 모듈로 행사 멤버 in-app 팬아웃(서비스에서 처리).
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/events/{eventId}/live-notices")
|
|
||||||
public class LiveNoticeController {
|
|
||||||
|
|
||||||
private final LiveNoticeService service;
|
|
||||||
private final EventAccessGuard guard;
|
|
||||||
|
|
||||||
public LiveNoticeController(LiveNoticeService service, EventAccessGuard guard) {
|
|
||||||
this.service = service;
|
|
||||||
this.guard = guard;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET — 주최자 목록(전체 상태, status 필터 선택). */
|
|
||||||
@GetMapping
|
|
||||||
public ApiResponse<List<LiveNoticeDto>> list(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@PathVariable String eventId,
|
|
||||||
@RequestParam(required = false) String status,
|
|
||||||
@RequestParam(required = false, defaultValue = "100") int limit) {
|
|
||||||
guard.requireEventAccess(principal, eventId);
|
|
||||||
return ApiResponse.ok(service.organizerList(eventId, status, limit));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST — 라이브 공지 발행(ORGANIZER/관리자/홀매니저). */
|
|
||||||
@PostMapping
|
|
||||||
public ApiResponse<LiveNoticeDto> publish(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@PathVariable String eventId,
|
|
||||||
@RequestBody LiveNoticeCreateRequest req) {
|
|
||||||
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
|
|
||||||
return ApiResponse.ok(service.publish(principal, eventId, req));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /{id}/archive — 공지 보관. */
|
|
||||||
@PostMapping("/{id}/archive")
|
|
||||||
public ApiResponse<LiveNoticeDto> archive(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@PathVariable String eventId,
|
|
||||||
@PathVariable String id) {
|
|
||||||
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
|
|
||||||
return ApiResponse.ok(service.archive(principal, eventId, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /{id}/pin — 고정 토글({pinned:true|false}). */
|
|
||||||
@PostMapping("/{id}/pin")
|
|
||||||
public ApiResponse<LiveNoticeDto> pin(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@PathVariable String eventId,
|
|
||||||
@PathVariable String id,
|
|
||||||
@RequestBody(required = false) Map<String, Object> body) {
|
|
||||||
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
|
|
||||||
boolean pinned = body != null && Boolean.TRUE.equals(body.get("pinned"));
|
|
||||||
return ApiResponse.ok(service.setPinned(principal, eventId, id, pinned));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
package com.zioinfo.kintex.livenotice;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Insert;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
import org.apache.ibatis.annotations.Update;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 행사 라이브 공지(F-B6) 매퍼.
|
|
||||||
* <p>★ Map 반환 @Select 는 camelCase 별칭에 쌍따옴표({@code AS "x"})를 사용한다(PG lower-fold 방지).
|
|
||||||
* <p>공개 피드는 status='published' 만, 주최자 목록은 전체.
|
|
||||||
*/
|
|
||||||
@Mapper
|
|
||||||
public interface LiveNoticeMapper {
|
|
||||||
|
|
||||||
@Select("SELECT count(*) FROM event WHERE id = #{eventId}")
|
|
||||||
int eventExists(@Param("eventId") String eventId);
|
|
||||||
|
|
||||||
/** 발행. */
|
|
||||||
@Insert("""
|
|
||||||
INSERT INTO event_live_notice
|
|
||||||
(tenant_id, id, event_id, category, title, body, pinned, status, author_name)
|
|
||||||
VALUES ('KINTEX', #{id}, #{eventId}, #{category}, #{title}, #{body},
|
|
||||||
#{pinned}, 'published', #{authorName})
|
|
||||||
""")
|
|
||||||
int insert(Map<String, Object> p);
|
|
||||||
|
|
||||||
/** 단건 조회(테넌트 스코프). */
|
|
||||||
@Select("""
|
|
||||||
SELECT id, event_id AS "eventId", category, title, body, pinned, status,
|
|
||||||
author_name AS "authorName",
|
|
||||||
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
|
|
||||||
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
|
|
||||||
FROM event_live_notice
|
|
||||||
WHERE tenant_id = 'KINTEX' AND id = #{id}
|
|
||||||
""")
|
|
||||||
Map<String, Object> findById(@Param("id") String id);
|
|
||||||
|
|
||||||
/** 공개 피드 — 게시 건만. 고정(pinned) 우선, 최신순. */
|
|
||||||
@Select("""
|
|
||||||
SELECT id, event_id AS "eventId", category, title, body, pinned, status,
|
|
||||||
author_name AS "authorName",
|
|
||||||
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
|
|
||||||
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
|
|
||||||
FROM event_live_notice
|
|
||||||
WHERE tenant_id = 'KINTEX' AND event_id = #{eventId} AND status = 'published'
|
|
||||||
ORDER BY pinned DESC, created_at DESC
|
|
||||||
LIMIT #{limit}
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> findPublicFeed(@Param("eventId") String eventId, @Param("limit") int limit);
|
|
||||||
|
|
||||||
/** 주최자 목록 — 전체 상태(status 필터 선택). */
|
|
||||||
@Select("""
|
|
||||||
<script>
|
|
||||||
SELECT id, event_id AS "eventId", category, title, body, pinned, status,
|
|
||||||
author_name AS "authorName",
|
|
||||||
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
|
|
||||||
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
|
|
||||||
FROM event_live_notice
|
|
||||||
WHERE tenant_id = 'KINTEX' AND event_id = #{eventId}
|
|
||||||
<if test="status != null and status != ''">AND status = #{status}</if>
|
|
||||||
ORDER BY pinned DESC, created_at DESC
|
|
||||||
LIMIT #{limit}
|
|
||||||
</script>
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> findForOrganizer(@Param("eventId") String eventId,
|
|
||||||
@Param("status") String status,
|
|
||||||
@Param("limit") int limit);
|
|
||||||
|
|
||||||
/** 보관(archive) — 게시 건만 전이. */
|
|
||||||
@Update("""
|
|
||||||
UPDATE event_live_notice SET status = 'archived', updated_at = now()
|
|
||||||
WHERE tenant_id = 'KINTEX' AND id = #{id} AND event_id = #{eventId} AND status = 'published'
|
|
||||||
""")
|
|
||||||
int archive(@Param("id") String id, @Param("eventId") String eventId);
|
|
||||||
|
|
||||||
/** 고정 토글. */
|
|
||||||
@Update("""
|
|
||||||
UPDATE event_live_notice SET pinned = #{pinned}, updated_at = now()
|
|
||||||
WHERE tenant_id = 'KINTEX' AND id = #{id} AND event_id = #{eventId}
|
|
||||||
""")
|
|
||||||
int setPinned(@Param("id") String id, @Param("eventId") String eventId, @Param("pinned") boolean pinned);
|
|
||||||
|
|
||||||
/** §5B 알림 팬아웃 대상 — 행사 멤버 user_id 목록(발행 시 in-app 알림). */
|
|
||||||
@Select("""
|
|
||||||
SELECT DISTINCT user_id AS "userId"
|
|
||||||
FROM event_member
|
|
||||||
WHERE event_id = #{eventId}
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> findEventMemberUserIds(@Param("eventId") String eventId);
|
|
||||||
}
|
|
||||||
@ -1,189 +0,0 @@
|
|||||||
package com.zioinfo.kintex.livenotice;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
|
||||||
import com.zioinfo.kintex.common.audit.AuditLogService;
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
|
||||||
import com.zioinfo.kintex.common.text.HtmlSanitizer;
|
|
||||||
import com.zioinfo.kintex.livenotice.dto.LiveNoticeDtos.LiveNoticeCreateRequest;
|
|
||||||
import com.zioinfo.kintex.livenotice.dto.LiveNoticeDtos.LiveNoticeDto;
|
|
||||||
import com.zioinfo.kintex.work.notification.NotificationService;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 행사 라이브 공지(F-B6) 서비스 — 주최자 발행 + 공개/관람객 조회 + 보관/고정.
|
|
||||||
*
|
|
||||||
* <p><b>§5B 알림 통합(중복 신설 금지)</b>: 발행 시 별도 per-user 푸시 인프라를 만들지 않고 기존
|
|
||||||
* {@link NotificationService}(공통 알림 모듈)로 행사 멤버에게 in-app 알림을 팬아웃한다. 공개 피드는 본
|
|
||||||
* 모듈의 {@code event_live_notice} 테이블이 정본(비로그인 관람객 대상 broadcast).
|
|
||||||
*
|
|
||||||
* <p>본문/제목은 {@link HtmlSanitizer}로 sanitize(XSS 방어). 발행/보관은 감사 로그를 남긴다.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class LiveNoticeService {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(LiveNoticeService.class);
|
|
||||||
|
|
||||||
private static final Set<String> CATEGORIES = Set.of("URGENT", "PROGRAM", "GENERAL", "INFO");
|
|
||||||
private static final int MAX_TITLE = 200;
|
|
||||||
private static final int MAX_BODY = 2000;
|
|
||||||
private static final int DEFAULT_LIMIT = 100;
|
|
||||||
/** 발행 시 §5B 알림 팬아웃 상한(대형 행사 폭주 방지). 초과분은 공개 피드로만 노출. */
|
|
||||||
private static final int FANOUT_LIMIT = 500;
|
|
||||||
|
|
||||||
private final LiveNoticeMapper mapper;
|
|
||||||
private final NotificationService notificationService;
|
|
||||||
private final AuditLogService audit;
|
|
||||||
|
|
||||||
public LiveNoticeService(LiveNoticeMapper mapper, NotificationService notificationService,
|
|
||||||
AuditLogService audit) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
this.notificationService = notificationService;
|
|
||||||
this.audit = audit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 공개 피드(비인증) — 게시 건만, 고정 우선·최신순. */
|
|
||||||
public List<LiveNoticeDto> publicFeed(String eventId, int limit) {
|
|
||||||
int lim = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, 500);
|
|
||||||
return mapper.findPublicFeed(eventId, lim).stream().map(LiveNoticeService::toDto).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 주최자 목록 — 전체 상태(status 필터 선택). */
|
|
||||||
public List<LiveNoticeDto> organizerList(String eventId, String status, int limit) {
|
|
||||||
int lim = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, 500);
|
|
||||||
return mapper.findForOrganizer(eventId, blankToNull(status), lim)
|
|
||||||
.stream().map(LiveNoticeService::toDto).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 발행(주최자) — 검증·sanitize·저장 후 §5B 알림 팬아웃 + 감사. */
|
|
||||||
@Transactional
|
|
||||||
public LiveNoticeDto publish(KintexPrincipal principal, String eventId, LiveNoticeCreateRequest req) {
|
|
||||||
if (mapper.eventExists(eventId) == 0) {
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 행사입니다.");
|
|
||||||
}
|
|
||||||
String category = req == null ? null : upper(req.category());
|
|
||||||
if (category == null || !CATEGORIES.contains(category)) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 공지 분류입니다(URGENT/PROGRAM/GENERAL/INFO).");
|
|
||||||
}
|
|
||||||
String title = req.title() == null ? null : req.title().trim();
|
|
||||||
if (title == null || title.isEmpty()) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "공지 제목을 입력해 주세요.");
|
|
||||||
}
|
|
||||||
if (title.length() > MAX_TITLE) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "제목이 너무 깁니다(최대 " + MAX_TITLE + "자).");
|
|
||||||
}
|
|
||||||
String body = req.body() == null ? null : req.body();
|
|
||||||
if (body != null && body.length() > MAX_BODY) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "본문이 너무 깁니다(최대 " + MAX_BODY + "자).");
|
|
||||||
}
|
|
||||||
String safeTitle = HtmlSanitizer.sanitize(title);
|
|
||||||
String safeBody = body == null ? null : HtmlSanitizer.sanitize(body);
|
|
||||||
boolean pinned = Boolean.TRUE.equals(req.pinned());
|
|
||||||
|
|
||||||
String id = "ln-" + UUID.randomUUID().toString().replace("-", "").substring(0, 18);
|
|
||||||
Map<String, Object> p = new HashMap<>();
|
|
||||||
p.put("id", id);
|
|
||||||
p.put("eventId", eventId);
|
|
||||||
p.put("category", category);
|
|
||||||
p.put("title", safeTitle);
|
|
||||||
p.put("body", safeBody);
|
|
||||||
p.put("pinned", pinned);
|
|
||||||
p.put("authorName", principal.displayName());
|
|
||||||
mapper.insert(p);
|
|
||||||
|
|
||||||
// §5B 알림 팬아웃 — 행사 멤버에게 in-app 알림(기존 알림 모듈 재사용, 인프라 신설 없음).
|
|
||||||
fanoutToMembers(eventId, id, category, safeTitle);
|
|
||||||
|
|
||||||
audit.record(principal.userId(), principal.displayName(), "LIVE_NOTICE_PUBLISH",
|
|
||||||
"event_live_notice", id, eventId,
|
|
||||||
"라이브 공지 발행(" + category + ")", "SUCCESS");
|
|
||||||
|
|
||||||
return toDto(mapper.findById(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 보관(주최자). */
|
|
||||||
@Transactional
|
|
||||||
public LiveNoticeDto archive(KintexPrincipal principal, String eventId, String id) {
|
|
||||||
Map<String, Object> cur = mapper.findById(id);
|
|
||||||
if (cur == null || !eventId.equals(str(cur.get("eventId")))) {
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
mapper.archive(id, eventId);
|
|
||||||
audit.record(principal.userId(), principal.displayName(), "LIVE_NOTICE_ARCHIVE",
|
|
||||||
"event_live_notice", id, eventId, "라이브 공지 보관", "SUCCESS");
|
|
||||||
return toDto(mapper.findById(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 고정 토글(주최자). */
|
|
||||||
@Transactional
|
|
||||||
public LiveNoticeDto setPinned(KintexPrincipal principal, String eventId, String id, boolean pinned) {
|
|
||||||
Map<String, Object> cur = mapper.findById(id);
|
|
||||||
if (cur == null || !eventId.equals(str(cur.get("eventId")))) {
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
mapper.setPinned(id, eventId, pinned);
|
|
||||||
return toDto(mapper.findById(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ──
|
|
||||||
private void fanoutToMembers(String eventId, String noticeId, String category, String title) {
|
|
||||||
try {
|
|
||||||
List<Map<String, Object>> members = mapper.findEventMemberUserIds(eventId);
|
|
||||||
if (members == null || members.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String link = "/events/" + eventId + "/live-notices";
|
|
||||||
int sent = 0;
|
|
||||||
for (Map<String, Object> m : members) {
|
|
||||||
if (sent >= FANOUT_LIMIT) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
String uid = str(m.get("userId"));
|
|
||||||
if (uid == null || uid.isBlank()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// notiType 은 §5B 공통 코드(SYSTEM) 사용 — 라이브 공지는 시스템성 알림.
|
|
||||||
notificationService.notify(uid, eventId, "SYSTEM",
|
|
||||||
"[라이브] " + title, "행사 라이브 공지(" + category + ")가 발행되었습니다.", link);
|
|
||||||
sent++;
|
|
||||||
}
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
// 팬아웃 실패는 발행을 막지 않는다(공개 피드가 정본). 민감정보 미노출.
|
|
||||||
log.warn("라이브 공지 §5B 알림 팬아웃 실패(무시): {}", e.getClass().getSimpleName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LiveNoticeDto toDto(Map<String, Object> r) {
|
|
||||||
if (r == null) {
|
|
||||||
throw new ApiException(ErrorCode.INTERNAL);
|
|
||||||
}
|
|
||||||
return new LiveNoticeDto(
|
|
||||||
str(r.get("id")), str(r.get("eventId")), str(r.get("category")),
|
|
||||||
str(r.get("title")), str(r.get("body")),
|
|
||||||
Boolean.TRUE.equals(r.get("pinned")) || "true".equals(str(r.get("pinned"))),
|
|
||||||
str(r.get("status")), str(r.get("authorName")),
|
|
||||||
str(r.get("createdAt")), str(r.get("updatedAt")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String upper(String s) {
|
|
||||||
return s == null || s.isBlank() ? null : s.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String blankToNull(String s) {
|
|
||||||
return s == null || s.isBlank() ? null : s.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String str(Object o) {
|
|
||||||
return o == null ? null : String.valueOf(o);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
package com.zioinfo.kintex.livenotice;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
|
||||||
import com.zioinfo.kintex.livenotice.dto.LiveNoticeDtos.LiveNoticeDto;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 행사 라이브 공지(F-B6) 공개 조회 API — 비인증({@code /api/public/**} permitAll).
|
|
||||||
* <p>관람객·공개 사이트가 행사 진행 중 라이브 공지 피드를 조회한다. 게시(published) 건만·고정 우선·최신순.
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/public/events/{eventId}/live-notices")
|
|
||||||
public class PublicLiveNoticeController {
|
|
||||||
|
|
||||||
private final LiveNoticeService service;
|
|
||||||
|
|
||||||
public PublicLiveNoticeController(LiveNoticeService service) {
|
|
||||||
this.service = service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/public/events/{eventId}/live-notices — 공개 라이브 공지 피드. */
|
|
||||||
@GetMapping
|
|
||||||
public ApiResponse<List<LiveNoticeDto>> feed(@PathVariable String eventId,
|
|
||||||
@RequestParam(required = false, defaultValue = "100") int limit) {
|
|
||||||
return ApiResponse.ok(service.publicFeed(eventId, limit));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
package com.zioinfo.kintex.livenotice.dto;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 행사 라이브 공지(F-B6) DTO 모음.
|
|
||||||
*/
|
|
||||||
public final class LiveNoticeDtos {
|
|
||||||
|
|
||||||
private LiveNoticeDtos() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 라이브 공지 발행 요청(주최자). category: URGENT/PROGRAM/GENERAL/INFO. */
|
|
||||||
public record LiveNoticeCreateRequest(String category, String title, String body, Boolean pinned) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 라이브 공지 뷰(공개·주최자 공용). status/archived 는 주최자 목록에만 의미. */
|
|
||||||
public record LiveNoticeDto(String id, String eventId, String category, String title, String body,
|
|
||||||
boolean pinned, String status, String authorName,
|
|
||||||
String createdAt, String updatedAt) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -43,28 +43,6 @@ public final class MailTemplates {
|
|||||||
+ WRAP_FOOT;
|
+ WRAP_FOOT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 티켓 오픈 알림(관심 행사 구독자 대상, F-C1) — 행사명 + 예매 안내 + 원클릭 수신거부 링크.
|
|
||||||
* 광고성 대량 발송이 아니라 사용자가 명시 구독한 트랜잭션성 알림이지만, 수신거부 링크는 항상 포함한다.
|
|
||||||
*/
|
|
||||||
public static String ticketOpen(String eventName, String bookUrl, String unsubUrl) {
|
|
||||||
String unsubLine = (unsubUrl == null || unsubUrl.isBlank())
|
|
||||||
? ""
|
|
||||||
: "<p style=\"font-size:12px;color:#9ca3af\">알림 수신을 원치 않으시면 "
|
|
||||||
+ "<a href=\"" + esc(unsubUrl) + "\" style=\"color:#6b7280\">수신거부</a> 하실 수 있습니다.</p>";
|
|
||||||
String cta = (bookUrl == null || bookUrl.isBlank())
|
|
||||||
? ""
|
|
||||||
: "<p style=\"margin-top:16px\"><a href=\"" + esc(bookUrl) + "\" "
|
|
||||||
+ "style=\"display:inline-block;background:#1f29fc;color:#fff;padding:10px 18px;"
|
|
||||||
+ "border-radius:8px;text-decoration:none\">예매하러 가기</a></p>";
|
|
||||||
return WRAP_HEAD
|
|
||||||
+ "<h2 style=\"font-size:18px\">관심 행사의 티켓 예매가 시작되었습니다</h2>"
|
|
||||||
+ "<p><strong>행사</strong> " + esc(eventName) + "</p>"
|
|
||||||
+ "<p>구독하신 행사의 입장권 판매가 개시되었습니다. 조기 매진이 예상되니 서둘러 예매해 주세요.</p>"
|
|
||||||
+ cta
|
|
||||||
+ WRAP_FOOT.replace("</div>", "") + unsubLine + "</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** EDM 광고 본문(수신거부 링크 없음 — 하위호환). 원클릭 수신거부는 {@link #edm(String, String, String)} 사용. */
|
/** EDM 광고 본문(수신거부 링크 없음 — 하위호환). 원클릭 수신거부는 {@link #edm(String, String, String)} 사용. */
|
||||||
public static String edm(String campaignName, String eventName) {
|
public static String edm(String campaignName, String eventName) {
|
||||||
return edm(campaignName, eventName, null);
|
return edm(campaignName, eventName, null);
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import com.zioinfo.kintex.module.m2.dto.LayoutDto;
|
|||||||
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretRequest;
|
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretRequest;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutVersionDto;
|
|
||||||
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
|
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
|
||||||
import com.zioinfo.kintex.rules.ComplianceReport;
|
import com.zioinfo.kintex.rules.ComplianceReport;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@ -46,15 +45,6 @@ public class FloorplanController {
|
|||||||
return ApiResponse.ok(service.getLayout(eventId, hallId, version));
|
return ApiResponse.ok(service.getLayout(eventId, hallId, version));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /versions — 배치안 버전 이력 목록(자동생성 proposal·draft·applied). 행사 멤버/홀매니저 열람. */
|
|
||||||
@GetMapping("/versions")
|
|
||||||
public ApiResponse<List<LayoutVersionDto>> versions(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@PathVariable String eventId,
|
|
||||||
@PathVariable String hallId) {
|
|
||||||
guard.requireEventAccess(principal, eventId);
|
|
||||||
return ApiResponse.ok(service.listVersions(eventId, hallId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PUT — 배치 저장(주최자). */
|
/** PUT — 배치 저장(주최자). */
|
||||||
@PutMapping
|
@PutMapping
|
||||||
public ApiResponse<LayoutDto> save(@AuthenticationPrincipal KintexPrincipal principal,
|
public ApiResponse<LayoutDto> save(@AuthenticationPrincipal KintexPrincipal principal,
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest;
|
|||||||
import com.zioinfo.kintex.module.m2.dto.LayoutDto;
|
import com.zioinfo.kintex.module.m2.dto.LayoutDto;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutVersionDto;
|
|
||||||
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
|
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
|
||||||
import com.zioinfo.kintex.rules.ComplianceReport;
|
import com.zioinfo.kintex.rules.ComplianceReport;
|
||||||
|
|
||||||
@ -18,9 +17,6 @@ public interface FloorplanService {
|
|||||||
/** 배치안 조회(version=null이면 최신). */
|
/** 배치안 조회(version=null이면 최신). */
|
||||||
LayoutDto getLayout(String eventId, String hallId, Integer version);
|
LayoutDto getLayout(String eventId, String hallId, Integer version);
|
||||||
|
|
||||||
/** 배치안 버전 이력 목록(version·name·status·boothCount·updatedAt, 최신 순). */
|
|
||||||
List<LayoutVersionDto> listVersions(String eventId, String hallId);
|
|
||||||
|
|
||||||
/** 배치 저장 → 저장된 배치안. 저장 시 규정 검증도 함께 산출해 summary에 반영. */
|
/** 배치 저장 → 저장된 배치안. 저장 시 규정 검증도 함께 산출해 summary에 반영. */
|
||||||
LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request);
|
LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request);
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import com.zioinfo.kintex.module.m2.dto.LayoutDto;
|
|||||||
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutSummary;
|
import com.zioinfo.kintex.module.m2.dto.LayoutSummary;
|
||||||
import com.zioinfo.kintex.module.m2.dto.LayoutVersionDto;
|
|
||||||
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
|
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
|
||||||
import com.zioinfo.kintex.module.m2.mapper.BoothMapper;
|
import com.zioinfo.kintex.module.m2.mapper.BoothMapper;
|
||||||
import com.zioinfo.kintex.module.m2.mapper.HallMapper;
|
import com.zioinfo.kintex.module.m2.mapper.HallMapper;
|
||||||
@ -86,20 +85,20 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request) {
|
public LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request) {
|
||||||
return persistBooths(eventId, hallId, request.name(), request.version(), request.booths(), "draft");
|
return persistBooths(eventId, hallId, request.name(), request.version(), request.booths());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request) {
|
public LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request) {
|
||||||
// 적용/병합은 항상 새 버전으로 저장(version=null → 다음 버전) 후 규정 재검증. 적용본은 status='applied'.
|
// 적용/병합은 항상 새 버전으로 저장(version=null → 다음 버전) 후 규정 재검증.
|
||||||
String name = request.name() == null ? "자동배치 적용" : request.name();
|
String name = request.name() == null ? "자동배치 적용" : request.name();
|
||||||
return persistBooths(eventId, hallId, name, null, request.booths(), "applied");
|
return persistBooths(eventId, hallId, name, null, request.booths());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 배치안 upsert + 부스 폴리곤 교체 저장 → 규정 검증 반영된 LayoutDto. status = draft|applied|proposal. */
|
/** 배치안 upsert + 부스 폴리곤 교체 저장 → 규정 검증 반영된 LayoutDto. */
|
||||||
private LayoutDto persistBooths(String eventId, String hallId, String name,
|
private LayoutDto persistBooths(String eventId, String hallId, String name,
|
||||||
Integer requestedVersion, List<BoothDto> booths, String status) {
|
Integer requestedVersion, List<BoothDto> booths) {
|
||||||
int version = requestedVersion != null ? requestedVersion : nextVersion(eventId, hallId);
|
int version = requestedVersion != null ? requestedVersion : nextVersion(eventId, hallId);
|
||||||
|
|
||||||
Map<String, Object> params = new LinkedHashMap<>();
|
Map<String, Object> params = new LinkedHashMap<>();
|
||||||
@ -107,7 +106,7 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
params.put("hallId", hallId);
|
params.put("hallId", hallId);
|
||||||
params.put("version", version);
|
params.put("version", version);
|
||||||
params.put("name", name);
|
params.put("name", name);
|
||||||
params.put("status", status);
|
params.put("status", "draft");
|
||||||
String layoutId = boothMapper.upsertLayout(params);
|
String layoutId = boothMapper.upsertLayout(params);
|
||||||
|
|
||||||
List<Map<String, Object>> boothRows = new ArrayList<>();
|
List<Map<String, Object>> boothRows = new ArrayList<>();
|
||||||
@ -324,9 +323,6 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
String label = "배치안 " + tag + (zoneLabel != null ? " · " + zoneLabel : "");
|
String label = "배치안 " + tag + (zoneLabel != null ? " · " + zoneLabel : "");
|
||||||
options.add(new AutoLayoutOption("opt-" + tag, label, summary, booths, null));
|
options.add(new AutoLayoutOption("opt-" + tag, label, summary, booths, null));
|
||||||
}
|
}
|
||||||
// 생성 안 영속화 — 각 안을 status='proposal' 버전으로 저장(배치 이력 재열람·적용 근거, 소유자 지시 2026-07-23).
|
|
||||||
options = persistProposals(eventId, hallId, options);
|
|
||||||
|
|
||||||
log.info("자동배치 생성: event={} hall={} zone={} options={} target={} ai={}",
|
log.info("자동배치 생성: event={} hall={} zone={} options={} target={} ai={}",
|
||||||
eventId, hallId, request.zoneId(), options.size(), request.targetBoothCount(), ai);
|
eventId, hallId, request.zoneId(), options.size(), request.targetBoothCount(), ai);
|
||||||
|
|
||||||
@ -334,41 +330,6 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
return ai ? attachAiSummaries(options, hall) : options;
|
return ai ? attachAiSummaries(options, hall) : options;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 생성 배치안을 status='proposal' layout 버전으로 영속화한다(응답 유실 방지, 배치 이력 조회 근거).
|
|
||||||
* <b>best-effort</b> — 개별 영속 실패는 무회귀(해당 안은 version 없이 반환)로 자동배치 응답 자체는 항상 성공.
|
|
||||||
* 적용(applyOption) 시 status='applied' 로 전이, 수동 저장(saveLayout)은 'draft'.
|
|
||||||
*/
|
|
||||||
private List<AutoLayoutOption> persistProposals(String eventId, String hallId, List<AutoLayoutOption> options) {
|
|
||||||
List<AutoLayoutOption> out = new ArrayList<>(options.size());
|
|
||||||
for (AutoLayoutOption o : options) {
|
|
||||||
try {
|
|
||||||
LayoutDto saved = persistBooths(eventId, hallId, o.label(), null, o.booths(), "proposal");
|
|
||||||
out.add(o.withVersion(saved.version()));
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
log.warn("배치안 proposal 영속 생략(무회귀): event={} hall={} 사유={}",
|
|
||||||
eventId, hallId, e.getClass().getSimpleName());
|
|
||||||
out.add(o);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<LayoutVersionDto> listVersions(String eventId, String hallId) {
|
|
||||||
List<Map<String, Object>> rows = boothMapper.findLayoutVersions(eventId, hallId);
|
|
||||||
List<LayoutVersionDto> out = new ArrayList<>();
|
|
||||||
if (rows == null) {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
for (Map<String, Object> r : rows) {
|
|
||||||
out.add(new LayoutVersionDto(
|
|
||||||
intOrNull(r.get("version")), str(r.get("name")), str(r.get("status")),
|
|
||||||
intOrNull(r.get("boothCount")), str(r.get("updatedAt"))));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 각 배치안에 AI 장단점 요약을 부가한다 — <b>엔진이 계산한 metrics만</b> 근거(환각 차단).
|
* 각 배치안에 AI 장단점 요약을 부가한다 — <b>엔진이 계산한 metrics만</b> 근거(환각 차단).
|
||||||
* 단일 AI 호출로 optionId→요약 JSON 을 받아 매핑한다. AI 미가용/파싱 실패 시 원본 옵션(요약 없음)을 그대로 반환.
|
* 단일 AI 호출로 optionId→요약 JSON 을 받아 매핑한다. AI 미가용/파싱 실패 시 원본 옵션(요약 없음)을 그대로 반환.
|
||||||
@ -653,9 +614,11 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private int nextVersion(String eventId, String hallId) {
|
private int nextVersion(String eventId, String hallId) {
|
||||||
// proposal 포함 최대 버전 + 1 — 작업본 우선 조회(findLayout)와 분리해 버전 충돌 방지.
|
Map<String, Object> latest = boothMapper.findLayout(eventId, hallId, null);
|
||||||
Integer max = boothMapper.maxVersion(eventId, hallId);
|
if (latest == null) {
|
||||||
return (max == null ? 0 : max) + 1;
|
return 1;
|
||||||
|
}
|
||||||
|
return intVal(latest.get("version")) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------- 좌표/형변환 -----
|
// --------------------------------------------------------- 좌표/형변환 -----
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import java.util.List;
|
|||||||
* @param booths 후보 부스 배열 — "이 안으로 편집 시작"/병합 소스(계약 확장, 프론트 gap #3 해소, NON_NULL)
|
* @param booths 후보 부스 배열 — "이 안으로 편집 시작"/병합 소스(계약 확장, 프론트 gap #3 해소, NON_NULL)
|
||||||
* @param s7RenderJobId S7 홀 전경(조감) 생성 잡 ID — 완료 시 WebSocket 푸시로 카드 이미지 교체
|
* @param s7RenderJobId S7 홀 전경(조감) 생성 잡 ID — 완료 시 WebSocket 푸시로 카드 이미지 교체
|
||||||
* @param aiSummary 배치안 AI 장단점 요약(ai=true 시에만, 엔진 산출 metrics 근거 2~3줄) — 실패/미요청 시 null(NON_NULL 로 응답 제외)
|
* @param aiSummary 배치안 AI 장단점 요약(ai=true 시에만, 엔진 산출 metrics 근거 2~3줄) — 실패/미요청 시 null(NON_NULL 로 응답 제외)
|
||||||
* @param version 생성 시 proposal 로 영속된 layout 버전(배치안 이력 조회·재열람 근거) — 미영속 시 null(NON_NULL 제외)
|
|
||||||
*/
|
*/
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public record AutoLayoutOption(
|
public record AutoLayoutOption(
|
||||||
@ -22,22 +21,16 @@ public record AutoLayoutOption(
|
|||||||
LayoutSummary summary,
|
LayoutSummary summary,
|
||||||
List<BoothDto> booths,
|
List<BoothDto> booths,
|
||||||
String s7RenderJobId,
|
String s7RenderJobId,
|
||||||
String aiSummary,
|
String aiSummary
|
||||||
Integer version
|
|
||||||
) {
|
) {
|
||||||
/** 기존 4필드 + s7 생성자 호환 — aiSummary·version 미부여(null). */
|
/** 기존 4필드 + s7 생성자 호환 — aiSummary 미부여(null). */
|
||||||
public AutoLayoutOption(String optionId, String label, LayoutSummary summary,
|
public AutoLayoutOption(String optionId, String label, LayoutSummary summary,
|
||||||
List<BoothDto> booths, String s7RenderJobId) {
|
List<BoothDto> booths, String s7RenderJobId) {
|
||||||
this(optionId, label, summary, booths, s7RenderJobId, null, null);
|
this(optionId, label, summary, booths, s7RenderJobId, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** AI 요약을 덧입힌 사본 반환(불변, version 보존). */
|
/** AI 요약을 덧입힌 사본 반환(불변). */
|
||||||
public AutoLayoutOption withAiSummary(String summary) {
|
public AutoLayoutOption withAiSummary(String summary) {
|
||||||
return new AutoLayoutOption(optionId, label, this.summary, booths, s7RenderJobId, summary, version);
|
return new AutoLayoutOption(optionId, label, this.summary, booths, s7RenderJobId, summary);
|
||||||
}
|
|
||||||
|
|
||||||
/** 영속된 proposal 버전을 덧입힌 사본 반환(불변, aiSummary 보존). */
|
|
||||||
public AutoLayoutOption withVersion(Integer version) {
|
|
||||||
return new AutoLayoutOption(optionId, label, summary, booths, s7RenderJobId, aiSummary, version);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
package com.zioinfo.kintex.module.m2.dto;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배치안 버전 이력 항목(SCR-03 배치 이력 패널).
|
|
||||||
*
|
|
||||||
* @param version 버전 번호((event,hall) 내 유니크)
|
|
||||||
* @param name 배치안 이름
|
|
||||||
* @param status 상태 — draft(수동 저장) / applied(자동배치 적용) / proposal(자동 생성 안, 미적용)
|
|
||||||
* @param boothCount 부스 수
|
|
||||||
* @param updatedAt 갱신 시각(ISO-8601 UTC)
|
|
||||||
*/
|
|
||||||
public record LayoutVersionDto(
|
|
||||||
Integer version,
|
|
||||||
String name,
|
|
||||||
String status,
|
|
||||||
Integer boothCount,
|
|
||||||
String updatedAt
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@ -14,18 +14,11 @@ import java.util.Map;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface BoothMapper {
|
public interface BoothMapper {
|
||||||
|
|
||||||
/** 배치안 헤더 조회(layoutId, version, name, status, updatedAt). version=null → 최신(작업본 우선). */
|
/** 배치안 헤더 조회(layoutId, version, name, status, updatedAt). */
|
||||||
Map<String, Object> findLayout(@Param("eventId") String eventId,
|
Map<String, Object> findLayout(@Param("eventId") String eventId,
|
||||||
@Param("hallId") String hallId,
|
@Param("hallId") String hallId,
|
||||||
@Param("version") Integer version);
|
@Param("version") Integer version);
|
||||||
|
|
||||||
/** 현재 최대 버전(proposal 포함) — 다음 버전 번호 산정용. 없으면 null. */
|
|
||||||
Integer maxVersion(@Param("eventId") String eventId, @Param("hallId") String hallId);
|
|
||||||
|
|
||||||
/** 배치안 버전 이력 목록(version·name·status·boothCount·updatedAt) — 최신 순. */
|
|
||||||
List<Map<String, Object>> findLayoutVersions(@Param("eventId") String eventId,
|
|
||||||
@Param("hallId") String hallId);
|
|
||||||
|
|
||||||
/** 배치안의 부스 목록 조회(폴리곤은 ST_AsGeoJSON 또는 좌표 배열로 반환). */
|
/** 배치안의 부스 목록 조회(폴리곤은 ST_AsGeoJSON 또는 좌표 배열로 반환). */
|
||||||
List<Map<String, Object>> findBooths(@Param("layoutId") String layoutId);
|
List<Map<String, Object>> findBooths(@Param("layoutId") String layoutId);
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,6 @@ public interface OpsMapper {
|
|||||||
@Select("""
|
@Select("""
|
||||||
SELECT h.id AS "hallId", h.label AS "label",
|
SELECT h.id AS "hallId", h.label AS "label",
|
||||||
COALESCE(ar.sales_area, 0) AS "salesArea",
|
COALESCE(ar.sales_area, 0) AS "salesArea",
|
||||||
COALESCE(h.area_m2, 0) AS "physicalArea",
|
|
||||||
pw.req_kw AS "powerKw"
|
pw.req_kw AS "powerKw"
|
||||||
FROM hall_assignment ha
|
FROM hall_assignment ha
|
||||||
JOIN hall h ON h.id = ha.hall_id
|
JOIN hall h ON h.id = ha.hall_id
|
||||||
|
|||||||
@ -35,36 +35,30 @@ public class OpsService {
|
|||||||
rows = new ArrayList<>();
|
rows = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 분배 가중치: 홀별로 판매면적(부스 배치 실적)이 있으면 그 값을, 없으면 물리 바닥면적을 쓴다.
|
double totalArea = 0;
|
||||||
// 판매면적만 쓰면 데모/초기처럼 한 홀에만 배치가 있을 때 그 홀에 현장 인원이 100% 몰려
|
|
||||||
// "면적 비중 분배가 이상"해 보인다 → 배정된 모든 홀이 실제 규모에 비례해 분산되도록 폴백.
|
|
||||||
double totalWeight = 0;
|
|
||||||
for (Map<String, Object> r : rows) {
|
for (Map<String, Object> r : rows) {
|
||||||
totalWeight += hallWeight(r);
|
totalArea += dbl(r.get("salesArea"));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<OpsDto.Hall> halls = new ArrayList<>();
|
List<OpsDto.Hall> halls = new ArrayList<>();
|
||||||
Double powerTotal = null;
|
Double powerTotal = null;
|
||||||
int hallCount = rows.size();
|
int hallCount = rows.size();
|
||||||
for (Map<String, Object> r : rows) {
|
for (Map<String, Object> r : rows) {
|
||||||
double salesArea = dbl(r.get("salesArea"));
|
double area = dbl(r.get("salesArea"));
|
||||||
double weight = hallWeight(r);
|
|
||||||
Double powerKw = r.get("powerKw") == null ? null : dbl(r.get("powerKw"));
|
Double powerKw = r.get("powerKw") == null ? null : dbl(r.get("powerKw"));
|
||||||
|
|
||||||
long est = 0L;
|
long est = 0L;
|
||||||
if (onSite > 0) {
|
if (onSite > 0) {
|
||||||
if (totalWeight > 0) {
|
if (totalArea > 0) {
|
||||||
est = Math.round(onSite * (weight / totalWeight));
|
est = Math.round(onSite * (area / totalArea));
|
||||||
} else if (hallCount > 0) {
|
} else if (hallCount > 0) {
|
||||||
// 면적 정보 전무 → 균등 분배(가중 불가).
|
// 부스 미배치 → 균등 분배(면적 가중 불가).
|
||||||
est = Math.round((double) onSite / hallCount);
|
est = Math.round((double) onSite / hallCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 혼잡 밀도 기준면적: 판매면적 우선(사람은 부스 통로에 있음), 없으면 물리면적.
|
|
||||||
double densityArea = salesArea > 0 ? salesArea : dbl(r.get("physicalArea"));
|
|
||||||
halls.add(new OpsDto.Hall(
|
halls.add(new OpsDto.Hall(
|
||||||
str(r.get("hallId")), str(r.get("label")), est, heatFor(est, densityArea), powerKw));
|
str(r.get("hallId")), str(r.get("label")), est, heatFor(est, area), powerKw));
|
||||||
|
|
||||||
if (powerKw != null) {
|
if (powerKw != null) {
|
||||||
powerTotal = (powerTotal == null ? 0d : powerTotal) + powerKw;
|
powerTotal = (powerTotal == null ? 0d : powerTotal) + powerKw;
|
||||||
@ -84,12 +78,6 @@ public class OpsService {
|
|||||||
return new OpsDto(halls, hvac, new ArrayList<>(), new ArrayList<>(), onSite, powerTotal, series);
|
return new OpsDto(halls, hvac, new ArrayList<>(), new ArrayList<>(), onSite, powerTotal, series);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 홀 분배 가중치 — 판매면적(부스 배치 실적) 우선, 없으면 물리 바닥면적. */
|
|
||||||
private static double hallWeight(Map<String, Object> r) {
|
|
||||||
double salesArea = dbl(r.get("salesArea"));
|
|
||||||
return salesArea > 0 ? salesArea : dbl(r.get("physicalArea"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 인원 밀도(명/㎡) 기준 혼잡 등급. 인원 0 또는 면적 부재 시 보수적 처리. */
|
/** 인원 밀도(명/㎡) 기준 혼잡 등급. 인원 0 또는 면적 부재 시 보수적 처리. */
|
||||||
private static String heatFor(long est, double area) {
|
private static String heatFor(long est, double area) {
|
||||||
if (est <= 0) {
|
if (est <= 0) {
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
package com.zioinfo.kintex.parking;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
|
||||||
import com.zioinfo.kintex.parking.dto.ParkingDtos.MyPassesDto;
|
|
||||||
import com.zioinfo.kintex.parking.dto.ParkingDtos.ParkingLotStatusDto;
|
|
||||||
import com.zioinfo.kintex.parking.dto.ParkingDtos.ParkingPassDto;
|
|
||||||
import com.zioinfo.kintex.parking.dto.ParkingDtos.PurchasePassRequest;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
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.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관람객 주차 연계 API (F-C2).
|
|
||||||
*
|
|
||||||
* <p>권한 설계:
|
|
||||||
* <ul>
|
|
||||||
* <li>주차 현황 {@code GET /api/public/parking/lots} — 공개(비로그인, {@code /api/public/**} permitAll).</li>
|
|
||||||
* <li>사전 주차권 구매/조회 {@code /api/parking/passes} — 인증 필요(로그인 관람객, 본인 소유분만).</li>
|
|
||||||
* </ul>
|
|
||||||
* 보안(§0-3): 응답에 차량번호 원문 미노출(마스킹만) · 스택트레이스 미노출(GlobalExceptionHandler).
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
public class ParkingController {
|
|
||||||
|
|
||||||
private final ParkingService service;
|
|
||||||
|
|
||||||
public ParkingController(ParkingService service) {
|
|
||||||
this.service = service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/public/parking/lots?eventId= — 주차장 실시간 현황(공개). 잔여·점유율·혼잡도·요금. */
|
|
||||||
@GetMapping("/api/public/parking/lots")
|
|
||||||
public ApiResponse<List<ParkingLotStatusDto>> lots(
|
|
||||||
@RequestParam(required = false) String eventId) {
|
|
||||||
return ApiResponse.ok(service.getLotStatus(eventId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/parking/passes — 사전 주차권 구매(인증, mock 결제). */
|
|
||||||
@PostMapping("/api/parking/passes")
|
|
||||||
public ApiResponse<ParkingPassDto> purchase(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@Valid @RequestBody PurchasePassRequest req) {
|
|
||||||
return ApiResponse.ok(service.purchasePass(requireUserId(principal), req));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/parking/passes/me — 내 주차권 목록(인증, 본인 소유분만). */
|
|
||||||
@GetMapping("/api/parking/passes/me")
|
|
||||||
public ApiResponse<MyPassesDto> myPasses(@AuthenticationPrincipal KintexPrincipal principal) {
|
|
||||||
return ApiResponse.ok(new MyPassesDto(service.myPasses(requireUserId(principal))));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String requireUserId(KintexPrincipal principal) {
|
|
||||||
if (principal == null || principal.userId() == null || principal.userId().isBlank()) {
|
|
||||||
throw new ApiException(ErrorCode.UNAUTHORIZED, "로그인이 필요합니다.");
|
|
||||||
}
|
|
||||||
return principal.userId();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
package com.zioinfo.kintex.parking;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Insert;
|
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 주차 도메인 매퍼 — 주차장 마스터 read-only + 주차권 발급/조회. tenant_id='KINTEX' 고정.
|
|
||||||
* <p>★ Map 반환 @Select 는 camelCase 별칭에 반드시 쌍따옴표({@code AS "x"})를 사용한다(PG lower-fold 방지).
|
|
||||||
* <p>실시간 점유는 DB에 없다 — {@link SimulatedParkingService} 시뮬레이션 어댑터가 산출(외부 미연동).
|
|
||||||
*/
|
|
||||||
@Mapper
|
|
||||||
public interface ParkingMapper {
|
|
||||||
|
|
||||||
/** 활성 주차장 마스터 목록 — 표시순. */
|
|
||||||
@Select("""
|
|
||||||
SELECT id AS "lotId",
|
|
||||||
code,
|
|
||||||
name,
|
|
||||||
exhibition_center AS "exhibitionCenter",
|
|
||||||
total_capacity AS "totalCapacity",
|
|
||||||
hourly_rate AS "hourlyRate",
|
|
||||||
daily_max AS "dailyMax",
|
|
||||||
pass_price AS "passPrice",
|
|
||||||
note,
|
|
||||||
sort_order AS "sortOrder"
|
|
||||||
FROM parking_lot
|
|
||||||
WHERE tenant_id = 'KINTEX' AND status = 'active'
|
|
||||||
ORDER BY sort_order, id
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> findActiveLots();
|
|
||||||
|
|
||||||
/** 단일 주차장(구매 검증용). 없으면 null. */
|
|
||||||
@Select("""
|
|
||||||
SELECT id AS "lotId",
|
|
||||||
code,
|
|
||||||
name,
|
|
||||||
pass_price AS "passPrice",
|
|
||||||
status
|
|
||||||
FROM parking_lot
|
|
||||||
WHERE tenant_id = 'KINTEX' AND id = #{lotId}
|
|
||||||
""")
|
|
||||||
Map<String, Object> findLot(@Param("lotId") String lotId);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 주차장·이용일의 유효(PAID/USED) 사전 주차권 수 — 시뮬레이션 점유 가중치.
|
|
||||||
* 사전 예약분이 실시간 잔여에 반영되도록 한다.
|
|
||||||
*/
|
|
||||||
@Select("""
|
|
||||||
SELECT count(*)
|
|
||||||
FROM parking_pass
|
|
||||||
WHERE tenant_id = 'KINTEX'
|
|
||||||
AND lot_id = #{lotId}
|
|
||||||
AND use_date = CAST(#{useDate} AS date)
|
|
||||||
AND status IN ('PAID', 'USED')
|
|
||||||
""")
|
|
||||||
long countActivePasses(@Param("lotId") String lotId, @Param("useDate") String useDate);
|
|
||||||
|
|
||||||
/** 주차권 발급 저장. */
|
|
||||||
@Insert("""
|
|
||||||
INSERT INTO parking_pass
|
|
||||||
(tenant_id, id, pass_no, event_id, lot_id, lot_name, user_id, use_date,
|
|
||||||
vehicle_plate_masked, amount, status, pay_method, pay_approval_no)
|
|
||||||
VALUES
|
|
||||||
('KINTEX', #{id}, #{passNo}, #{eventId}, #{lotId}, #{lotName}, #{userId},
|
|
||||||
CAST(#{useDate} AS date), #{vehiclePlateMasked}, #{amount}, #{status},
|
|
||||||
#{payMethod}, #{payApprovalNo})
|
|
||||||
""")
|
|
||||||
void insertPass(Map<String, Object> pass);
|
|
||||||
|
|
||||||
/** 발급번호 중복 확인(유니크 보증). */
|
|
||||||
@Select("SELECT count(*) FROM parking_pass WHERE pass_no = #{passNo}")
|
|
||||||
long countPassNo(@Param("passNo") String passNo);
|
|
||||||
|
|
||||||
/** 내 주차권 목록 — 소유자 본인만. 이용일 최신순. */
|
|
||||||
@Select("""
|
|
||||||
SELECT pass_no AS "passNo",
|
|
||||||
status,
|
|
||||||
lot_id AS "lotId",
|
|
||||||
lot_name AS "lotName",
|
|
||||||
event_id AS "eventId",
|
|
||||||
to_char(use_date, 'YYYY-MM-DD') AS "useDate",
|
|
||||||
vehicle_plate_masked AS "vehiclePlateMasked",
|
|
||||||
amount,
|
|
||||||
pay_method AS "payMethod",
|
|
||||||
pay_approval_no AS "payApprovalNo",
|
|
||||||
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "issuedAt"
|
|
||||||
FROM parking_pass
|
|
||||||
WHERE tenant_id = 'KINTEX' AND user_id = #{userId}
|
|
||||||
ORDER BY use_date DESC, created_at DESC
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> findPassesByUser(@Param("userId") String userId);
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
package com.zioinfo.kintex.parking;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.parking.dto.ParkingDtos.ParkingLotStatusDto;
|
|
||||||
import com.zioinfo.kintex.parking.dto.ParkingDtos.ParkingPassDto;
|
|
||||||
import com.zioinfo.kintex.parking.dto.ParkingDtos.PurchasePassRequest;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관람객 주차 연계(F-C2) 서비스 계약.
|
|
||||||
*
|
|
||||||
* <p>어댑터 패턴: 실시간 주차 현황(잔여·점유)의 원천은 외부 시스템(iparking 등)이나 현재 미연동이다.
|
|
||||||
* 기본 구현({@link SimulatedParkingService})은 결정론적 시뮬레이션으로 현황을 산출하며,
|
|
||||||
* 운영 전환 시 외부 실연동 어댑터를 이 인터페이스의 대체 {@code @Service} 로 주입한다(코드 변경 최소).
|
|
||||||
*/
|
|
||||||
public interface ParkingService {
|
|
||||||
|
|
||||||
/** 주차장별 실시간 현황(공개) — 잔여·점유율·혼잡도·요금. eventId 는 선택(사전권 가중 기준일 산정용). */
|
|
||||||
List<ParkingLotStatusDto> getLotStatus(String eventId);
|
|
||||||
|
|
||||||
/** 사전 주차권 구매(인증 관람객) — mock 결제 승인 후 발급. userId 는 JWT 주체. */
|
|
||||||
ParkingPassDto purchasePass(String userId, PurchasePassRequest req);
|
|
||||||
|
|
||||||
/** 내 주차권 목록(인증 관람객) — 본인 소유분만. */
|
|
||||||
List<ParkingPassDto> myPasses(String userId);
|
|
||||||
}
|
|
||||||
@ -1,263 +0,0 @@
|
|||||||
package com.zioinfo.kintex.parking;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
|
||||||
import com.zioinfo.kintex.parking.dto.ParkingDtos.ParkingLotStatusDto;
|
|
||||||
import com.zioinfo.kintex.parking.dto.ParkingDtos.ParkingPassDto;
|
|
||||||
import com.zioinfo.kintex.parking.dto.ParkingDtos.PurchasePassRequest;
|
|
||||||
import com.zioinfo.kintex.ticket.payment.PaymentGateway;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 주차 서비스 기본 구현 — 결정론적 시뮬레이션 어댑터(F-C2).
|
|
||||||
*
|
|
||||||
* <p>실시간 점유는 외부 시스템(iparking 등) 미연동 → 시각·주차장별 결정론 곡선 + 발급된 사전 주차권 수로
|
|
||||||
* 산출한다(하드웨어/외부 호출 없음). 구매는 mock 결제({@link PaymentGateway})로 승인한다.
|
|
||||||
*
|
|
||||||
* <p>TODO(게이트): 외부 주차관제(iparking) 실연동은 게이트 대상 — 승인 후 별도 어댑터
|
|
||||||
* ({@code IparkingParkingService implements ParkingService})로 이 빈을 대체한다.
|
|
||||||
* 외부 API 키/엔드포인트는 env 주입, 실패 시 본 시뮬레이션으로 폴백한다. 지금은 외부 호출 금지.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class SimulatedParkingService implements ParkingService {
|
|
||||||
|
|
||||||
/** 혼잡 임계(점유율 %) — 여유<60 ≤ 보통<90 ≤ 혼잡. */
|
|
||||||
private static final int PCT_BUSY = 90;
|
|
||||||
private static final int PCT_NORMAL = 60;
|
|
||||||
|
|
||||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
|
||||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
|
||||||
|
|
||||||
private final SecureRandom random = new SecureRandom();
|
|
||||||
private final ParkingMapper mapper;
|
|
||||||
private final PaymentGateway paymentGateway;
|
|
||||||
|
|
||||||
public SimulatedParkingService(ParkingMapper mapper, PaymentGateway paymentGateway) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
this.paymentGateway = paymentGateway;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 주차 현황(공개) ─────────────────────────────────────────────────────────
|
|
||||||
@Override
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<ParkingLotStatusDto> getLotStatus(String eventId) {
|
|
||||||
String today = LocalDate.now().format(DATE);
|
|
||||||
int hour = LocalDateTime.now().getHour();
|
|
||||||
String now = LocalDateTime.now().format(TS);
|
|
||||||
|
|
||||||
List<ParkingLotStatusDto> out = new ArrayList<>();
|
|
||||||
for (Map<String, Object> r : mapper.findActiveLots()) {
|
|
||||||
String lotId = str(r.get("lotId"));
|
|
||||||
String code = str(r.get("code"));
|
|
||||||
int capacity = toInt(r.get("totalCapacity"), 0);
|
|
||||||
|
|
||||||
// 결정론 점유 = 시각 곡선 + 주차장 시드 편차, 사전 주차권 수를 가산(cap).
|
|
||||||
long reserved = mapper.countActivePasses(lotId, today);
|
|
||||||
int occupied = simulateOccupancy(code, capacity, hour, reserved);
|
|
||||||
int available = Math.max(0, capacity - occupied);
|
|
||||||
int pct = capacity > 0 ? (int) Math.round(occupied * 100.0 / capacity) : 0;
|
|
||||||
|
|
||||||
out.add(new ParkingLotStatusDto(
|
|
||||||
lotId, code, str(r.get("name")), toIntOrNull(r.get("exhibitionCenter")),
|
|
||||||
capacity, occupied, available, pct,
|
|
||||||
levelCode(pct), levelLabel(pct),
|
|
||||||
toLong(r.get("hourlyRate"), 0), toLongOrNull(r.get("dailyMax")),
|
|
||||||
toLong(r.get("passPrice"), 0), str(r.get("note")), now));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 결정론 점유 시뮬레이션 — 시각 벨커브(정오 피크) + 주차장 코드 해시 편차. 사전 예약분 가산 후 cap.
|
|
||||||
* 외부 실측이 아닌 표시용 근사(정직). 같은 입력에 같은 결과.
|
|
||||||
*/
|
|
||||||
private static int simulateOccupancy(String code, int capacity, int hour, long reserved) {
|
|
||||||
if (capacity <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
// 시각 곡선: 10~16시 피크(~0.85), 심야 저점(~0.05).
|
|
||||||
double peakDist = Math.abs(hour - 13); // 13시 피크 기준 거리
|
|
||||||
double base = Math.max(0.05, 0.85 - peakDist * 0.07);
|
|
||||||
// 주차장별 편차(-0.08 ~ +0.07) — 코드 해시 결정론.
|
|
||||||
int seed = Math.floorMod(code.hashCode(), 15);
|
|
||||||
double ratio = base + (seed - 7) / 100.0;
|
|
||||||
ratio = Math.max(0.02, Math.min(0.98, ratio));
|
|
||||||
int simulated = (int) Math.round(capacity * ratio);
|
|
||||||
return Math.min(capacity, simulated + (int) Math.min(reserved, capacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 사전 주차권 구매(인증) ───────────────────────────────────────────────────
|
|
||||||
@Override
|
|
||||||
@Transactional
|
|
||||||
public ParkingPassDto purchasePass(String userId, PurchasePassRequest req) {
|
|
||||||
if (userId == null || userId.isBlank()) {
|
|
||||||
throw new ApiException(ErrorCode.UNAUTHORIZED, "로그인이 필요합니다.");
|
|
||||||
}
|
|
||||||
LocalDate useDate = parseUseDate(req.useDate());
|
|
||||||
if (useDate.isBefore(LocalDate.now())) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "이용일은 오늘 이후로 선택해 주세요.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> lot = mapper.findLot(req.lotId());
|
|
||||||
if (lot == null || !"active".equals(str(lot.get("status")))) {
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND, "선택한 주차장을 찾을 수 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
long amount = toLong(lot.get("passPrice"), 0);
|
|
||||||
String lotName = str(lot.get("name"));
|
|
||||||
String passNo = generatePassNo();
|
|
||||||
String payMethod = normalizeMethod(req.payMethod(), amount);
|
|
||||||
|
|
||||||
// Mock PG 승인 — 실패 시 예외 → 트랜잭션 롤백.
|
|
||||||
PaymentGateway.PaymentResult pay = paymentGateway.authorize(payMethod, amount, passNo);
|
|
||||||
if (!pay.approved()) {
|
|
||||||
throw new ApiException(ErrorCode.CONFLICT, "결제 승인에 실패했습니다. 다시 시도해 주세요.");
|
|
||||||
}
|
|
||||||
|
|
||||||
String plateMasked = maskPlate(req.vehiclePlate());
|
|
||||||
String id = "pp-" + UUID.randomUUID();
|
|
||||||
String useDateStr = useDate.format(DATE);
|
|
||||||
|
|
||||||
Map<String, Object> pass = new HashMap<>();
|
|
||||||
pass.put("id", id);
|
|
||||||
pass.put("passNo", passNo);
|
|
||||||
pass.put("eventId", nullIfBlank(req.eventId()));
|
|
||||||
pass.put("lotId", req.lotId());
|
|
||||||
pass.put("lotName", lotName);
|
|
||||||
pass.put("userId", userId);
|
|
||||||
pass.put("useDate", useDateStr);
|
|
||||||
pass.put("vehiclePlateMasked", plateMasked);
|
|
||||||
pass.put("amount", amount);
|
|
||||||
pass.put("status", "PAID");
|
|
||||||
pass.put("payMethod", payMethod);
|
|
||||||
pass.put("payApprovalNo", pay.approvalNo());
|
|
||||||
mapper.insertPass(pass);
|
|
||||||
|
|
||||||
return new ParkingPassDto(passNo, "PAID", req.lotId(), lotName, nullIfBlank(req.eventId()),
|
|
||||||
useDateStr, plateMasked, amount, payMethod, pay.approvalNo(),
|
|
||||||
LocalDateTime.now().format(TS));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 내 주차권 조회(인증) ─────────────────────────────────────────────────────
|
|
||||||
@Override
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<ParkingPassDto> myPasses(String userId) {
|
|
||||||
if (userId == null || userId.isBlank()) {
|
|
||||||
throw new ApiException(ErrorCode.UNAUTHORIZED, "로그인이 필요합니다.");
|
|
||||||
}
|
|
||||||
return mapper.findPassesByUser(userId).stream().map(SimulatedParkingService::toPass).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────
|
|
||||||
private static String levelCode(int pct) {
|
|
||||||
if (pct >= PCT_BUSY) {
|
|
||||||
return "BUSY";
|
|
||||||
}
|
|
||||||
if (pct >= PCT_NORMAL) {
|
|
||||||
return "NORMAL";
|
|
||||||
}
|
|
||||||
return "FREE";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String levelLabel(int pct) {
|
|
||||||
if (pct >= PCT_BUSY) {
|
|
||||||
return "혼잡";
|
|
||||||
}
|
|
||||||
if (pct >= PCT_NORMAL) {
|
|
||||||
return "보통";
|
|
||||||
}
|
|
||||||
return "여유";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String generatePassNo() {
|
|
||||||
int year = java.time.Year.now().getValue();
|
|
||||||
for (int attempt = 0; attempt < 6; attempt++) {
|
|
||||||
String no = "PK-" + year + "-" + String.format("%06d", random.nextInt(1_000_000));
|
|
||||||
if (mapper.countPassNo(no) == 0) {
|
|
||||||
return no;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "PK-" + year + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LocalDate parseUseDate(String s) {
|
|
||||||
if (s == null || s.isBlank()) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "이용일을 선택해 주세요.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return LocalDate.parse(s.trim());
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "이용일 형식이 올바르지 않습니다(YYYY-MM-DD).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeMethod(String method, long amount) {
|
|
||||||
if (amount == 0) {
|
|
||||||
return "free";
|
|
||||||
}
|
|
||||||
if (method == null || method.isBlank()) {
|
|
||||||
return "card";
|
|
||||||
}
|
|
||||||
String m = method.trim().toLowerCase(java.util.Locale.ROOT);
|
|
||||||
return switch (m) {
|
|
||||||
case "card", "easy", "bank" -> m;
|
|
||||||
default -> "card";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 차량번호 마스킹 — 앞2·뒤2만 노출(원문 미저장). "123가4567" → "12****67". null 안전. */
|
|
||||||
private static String maskPlate(String plate) {
|
|
||||||
if (plate == null || plate.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String p = plate.replaceAll("\\s", "");
|
|
||||||
int len = p.length();
|
|
||||||
if (len <= 4) {
|
|
||||||
return "*".repeat(Math.max(1, len));
|
|
||||||
}
|
|
||||||
return p.substring(0, 2) + "*".repeat(len - 4) + p.substring(len - 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ParkingPassDto toPass(Map<String, Object> m) {
|
|
||||||
return new ParkingPassDto(
|
|
||||||
str(m.get("passNo")), str(m.get("status")), str(m.get("lotId")), str(m.get("lotName")),
|
|
||||||
str(m.get("eventId")), str(m.get("useDate")), str(m.get("vehiclePlateMasked")),
|
|
||||||
toLong(m.get("amount"), 0), str(m.get("payMethod")), str(m.get("payApprovalNo")),
|
|
||||||
str(m.get("issuedAt")));
|
|
||||||
}
|
|
||||||
|
|
||||||
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 int toInt(Object o, int def) {
|
|
||||||
return (o instanceof Number n) ? n.intValue() : def;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Integer toIntOrNull(Object o) {
|
|
||||||
return (o instanceof Number n) ? n.intValue() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long toLong(Object o, long def) {
|
|
||||||
return (o instanceof Number n) ? n.longValue() : def;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Long toLongOrNull(Object o) {
|
|
||||||
return (o instanceof Number n) ? n.longValue() : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
package com.zioinfo.kintex.parking.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관람객 주차 연계(F-C2) DTO 모음. 주차 현황(공개) · 사전 주차권 구매/조회(인증).
|
|
||||||
* <p>보안(§0-3): 차량번호 원문 미노출 — 응답은 마스킹 필드만. 소유자 식별은 내부 user_id.
|
|
||||||
*/
|
|
||||||
public final class ParkingDtos {
|
|
||||||
|
|
||||||
private ParkingDtos() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 주차장 실시간 현황(공개) — 잔여·점유율·혼잡도·요금. 점유는 시뮬레이션 어댑터 산출값.
|
|
||||||
* congestionLevel ∈ FREE|NORMAL|BUSY (여유/보통/혼잡).
|
|
||||||
*/
|
|
||||||
public record ParkingLotStatusDto(
|
|
||||||
String lotId,
|
|
||||||
String code,
|
|
||||||
String name,
|
|
||||||
Integer exhibitionCenter,
|
|
||||||
int totalCapacity,
|
|
||||||
int occupied,
|
|
||||||
int available,
|
|
||||||
int occupancyPercent,
|
|
||||||
String congestionLevel,
|
|
||||||
String congestionLabel,
|
|
||||||
long hourlyRate,
|
|
||||||
Long dailyMax,
|
|
||||||
long passPrice,
|
|
||||||
String note,
|
|
||||||
String updatedAt) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 사전 주차권 구매 요청 — 1일권. 차량번호는 마스킹 저장(원문 미보관). */
|
|
||||||
public record PurchasePassRequest(
|
|
||||||
@NotBlank String lotId,
|
|
||||||
@NotBlank String useDate, // YYYY-MM-DD
|
|
||||||
String eventId,
|
|
||||||
String vehiclePlate, // 원문(마스킹 후 폐기, 미저장)
|
|
||||||
String payMethod) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 발급된 주차권 — 소유자(내부 user_id) 본인만 조회. 차량번호 마스킹만 노출. */
|
|
||||||
public record ParkingPassDto(
|
|
||||||
String passNo,
|
|
||||||
String status,
|
|
||||||
String lotId,
|
|
||||||
String lotName,
|
|
||||||
String eventId,
|
|
||||||
String useDate,
|
|
||||||
String vehiclePlateMasked,
|
|
||||||
long amount,
|
|
||||||
String payMethod,
|
|
||||||
String payApprovalNo,
|
|
||||||
String issuedAt) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 주차권 목록 봉투(향후 확장 여지). */
|
|
||||||
public record MyPassesDto(List<ParkingPassDto> passes) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
package com.zioinfo.kintex.publicsite.subscription;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
|
||||||
import com.zioinfo.kintex.publicsite.subscription.dto.SubscriptionDtos.SubscribeRequest;
|
|
||||||
import com.zioinfo.kintex.publicsite.subscription.dto.SubscriptionDtos.SubscribeResult;
|
|
||||||
import com.zioinfo.kintex.publicsite.subscription.dto.SubscriptionDtos.UnsubscribeResult;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
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.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관심 행사 구독(F-C1) 공개 API — 전 경로 비인증({@code /api/public/**} permitAll).
|
|
||||||
* <p>구독 신청/해지만 제공. 구독 목록·원문 이메일 조회는 공개하지 않는다(§0-3).
|
|
||||||
* 티켓 오픈 시 알림 발송은 {@link TicketOpenNotifier}(스케줄러)가 담당한다.
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/public")
|
|
||||||
public class EventSubscriptionController {
|
|
||||||
|
|
||||||
private final EventSubscriptionService service;
|
|
||||||
|
|
||||||
public EventSubscriptionController(EventSubscriptionService service) {
|
|
||||||
this.service = service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/public/events/{eventId}/subscribe — 관심 행사 구독(이메일·개인정보 동의). */
|
|
||||||
@PostMapping("/events/{eventId}/subscribe")
|
|
||||||
public ApiResponse<SubscribeResult> subscribe(@PathVariable String eventId,
|
|
||||||
@RequestBody SubscribeRequest req) {
|
|
||||||
return ApiResponse.ok(service.subscribe(eventId, req));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/public/subscriptions/unsubscribe?token= — 원클릭 수신거부(멱등). */
|
|
||||||
@PostMapping("/subscriptions/unsubscribe")
|
|
||||||
public ApiResponse<UnsubscribeResult> unsubscribe(@RequestParam String token) {
|
|
||||||
return ApiResponse.ok(service.unsubscribe(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/public/subscriptions/unsubscribe?token= — 메일 링크(GET) 호환. */
|
|
||||||
@GetMapping("/subscriptions/unsubscribe")
|
|
||||||
public ApiResponse<UnsubscribeResult> unsubscribeGet(@RequestParam String token) {
|
|
||||||
return ApiResponse.ok(service.unsubscribe(token));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
package com.zioinfo.kintex.publicsite.subscription;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Insert;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
import org.apache.ibatis.annotations.Update;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관심 행사 구독(F-C1) 매퍼.
|
|
||||||
* <p>★ Map 반환 @Select 는 camelCase 별칭에 쌍따옴표({@code AS "x"})를 사용한다(PG lower-fold 방지).
|
|
||||||
* <p>★ MyBatis 함정 회피: raw {@code <}/{@code <=} 미사용 — 시각 비교는 {@code now() >= sale_start} 로 표현.
|
|
||||||
* <p>원문 email 은 발송 경로(스케줄러)로만 반환 — 서비스가 응답/로그에 노출하지 않는다.
|
|
||||||
*/
|
|
||||||
@Mapper
|
|
||||||
public interface EventSubscriptionMapper {
|
|
||||||
|
|
||||||
/** 대상 행사 존재 여부(공개 구독 검증). */
|
|
||||||
@Select("SELECT count(*) FROM event WHERE id = #{eventId}")
|
|
||||||
int eventExists(@Param("eventId") String eventId);
|
|
||||||
|
|
||||||
/** 행사 종료 여부 — 종료된 행사는 구독 불가(오픈 예정/진행 대상만). end_date 없으면 진행중 간주. */
|
|
||||||
@Select("""
|
|
||||||
SELECT CASE WHEN end_date IS NOT NULL AND end_date < current_date
|
|
||||||
THEN 1 ELSE 0 END
|
|
||||||
FROM event WHERE id = #{eventId}
|
|
||||||
""")
|
|
||||||
Integer isEnded(@Param("eventId") String eventId);
|
|
||||||
|
|
||||||
/** 동일 행사+이메일 기존 구독 조회(대소문자 무시). */
|
|
||||||
@Select("""
|
|
||||||
SELECT id, status, unsub_token AS "unsubToken",
|
|
||||||
to_char(notified_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "notifiedAt"
|
|
||||||
FROM event_subscription
|
|
||||||
WHERE tenant_id = 'KINTEX' AND event_id = #{eventId} AND lower(email) = lower(#{email})
|
|
||||||
""")
|
|
||||||
Map<String, Object> findByEventEmail(@Param("eventId") String eventId,
|
|
||||||
@Param("email") String email);
|
|
||||||
|
|
||||||
/** 동일 이메일 최근 window(초) 내 구독 건수 — 단순 rate 제어. */
|
|
||||||
@Select("""
|
|
||||||
SELECT count(*)::int FROM event_subscription
|
|
||||||
WHERE lower(email) = lower(#{email})
|
|
||||||
AND created_at > now() - make_interval(secs => #{windowSeconds})
|
|
||||||
""")
|
|
||||||
int countRecentByEmail(@Param("email") String email, @Param("windowSeconds") int windowSeconds);
|
|
||||||
|
|
||||||
/** 신규 구독 저장. */
|
|
||||||
@Insert("""
|
|
||||||
INSERT INTO event_subscription
|
|
||||||
(tenant_id, id, event_id, email, email_masked, unsub_token, status)
|
|
||||||
VALUES ('KINTEX', #{id}, #{eventId}, #{email}, #{emailMasked}, #{unsubToken}, 'active')
|
|
||||||
""")
|
|
||||||
int insert(@Param("id") String id,
|
|
||||||
@Param("eventId") String eventId,
|
|
||||||
@Param("email") String email,
|
|
||||||
@Param("emailMasked") String emailMasked,
|
|
||||||
@Param("unsubToken") String unsubToken);
|
|
||||||
|
|
||||||
/** 수신거부되었던 구독 재활성화(토큰 유지). 티켓 오픈이 아직이면 재알림 대상이 되도록 notified_at 은 유지. */
|
|
||||||
@Update("""
|
|
||||||
UPDATE event_subscription SET status = 'active'
|
|
||||||
WHERE tenant_id = 'KINTEX' AND id = #{id} AND status <> 'active'
|
|
||||||
""")
|
|
||||||
int reactivate(@Param("id") String id);
|
|
||||||
|
|
||||||
/** 원클릭 수신거부(멱등). 활성 건만 전이. */
|
|
||||||
@Update("""
|
|
||||||
UPDATE event_subscription SET status = 'unsubscribed'
|
|
||||||
WHERE unsub_token = #{token} AND status = 'active'
|
|
||||||
""")
|
|
||||||
int unsubscribeByToken(@Param("token") String token);
|
|
||||||
|
|
||||||
/** 토큰 존재 여부(이미 처리된 건 포함) — 멱등 성공/실패 구분용. */
|
|
||||||
@Select("SELECT count(*) FROM event_subscription WHERE unsub_token = #{token}")
|
|
||||||
int tokenExists(@Param("token") String token);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 티켓 판매가 개시된(오픈) 행사의 미통지 활성 구독 — 오픈 감지 스케줄러 대상.
|
|
||||||
* 오픈 판정: 해당 행사에 status='active', total_qty>0, {@code now() >= sale_start} 인 티켓 상품이 1건 이상.
|
|
||||||
* email 원문은 발송 경로 한정(서비스가 노출 금지).
|
|
||||||
*/
|
|
||||||
@Select("""
|
|
||||||
SELECT s.id, s.event_id AS "eventId", s.email, s.unsub_token AS "unsubToken",
|
|
||||||
e.name AS "eventName"
|
|
||||||
FROM event_subscription s
|
|
||||||
JOIN event e ON e.id = s.event_id
|
|
||||||
WHERE s.tenant_id = 'KINTEX'
|
|
||||||
AND s.status = 'active'
|
|
||||||
AND s.notified_at IS NULL
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM ticket_product tp
|
|
||||||
WHERE tp.event_id = s.event_id
|
|
||||||
AND tp.status = 'active'
|
|
||||||
AND tp.total_qty > 0
|
|
||||||
AND tp.sale_start IS NOT NULL
|
|
||||||
AND now() >= tp.sale_start
|
|
||||||
)
|
|
||||||
ORDER BY s.created_at
|
|
||||||
LIMIT #{limit}
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> findPendingTicketOpen(@Param("limit") int limit);
|
|
||||||
|
|
||||||
/** 통지 완료 마킹(조건부 — 이미 통지된 건은 0). 중복 발송 방지. */
|
|
||||||
@Update("""
|
|
||||||
UPDATE event_subscription SET notified_at = now()
|
|
||||||
WHERE tenant_id = 'KINTEX' AND id = #{id} AND notified_at IS NULL
|
|
||||||
""")
|
|
||||||
int markNotified(@Param("id") String id);
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
package com.zioinfo.kintex.publicsite.subscription;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
|
||||||
import com.zioinfo.kintex.publicsite.subscription.dto.SubscriptionDtos.SubscribeRequest;
|
|
||||||
import com.zioinfo.kintex.publicsite.subscription.dto.SubscriptionDtos.SubscribeResult;
|
|
||||||
import com.zioinfo.kintex.publicsite.subscription.dto.SubscriptionDtos.UnsubscribeResult;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관심 행사 구독(F-C1) 서비스 — 공개(비인증) 구독/해지. 티켓 오픈 알림 발송은 {@link TicketOpenNotifier}(스케줄러).
|
|
||||||
* <p>PII 최소화(§0-3): 원문 email 은 저장·발송 경로 한정이며 응답에는 email_masked 만 담는다.
|
|
||||||
* 구독 응답에도 원문·전체 목록은 노출하지 않는다(공개 엔드포인트).
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class EventSubscriptionService {
|
|
||||||
|
|
||||||
private static final int RATE_WINDOW_SECONDS = 60; // 동일 이메일 1분 1건
|
|
||||||
private static final Pattern EMAIL = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
|
|
||||||
|
|
||||||
private final EventSubscriptionMapper mapper;
|
|
||||||
|
|
||||||
public EventSubscriptionService(EventSubscriptionMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관심 행사 구독(공개). 개인정보 동의 필수 · 이메일 형식 검증 · 종료 행사 거부 · 동일 이메일 1분 rate 제어.
|
|
||||||
* 멱등: 기존 활성 구독이면 alreadySubscribed=true. 해지되었던 건은 재활성화한다.
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public SubscribeResult subscribe(String eventId, SubscribeRequest req) {
|
|
||||||
if (req == null || !Boolean.TRUE.equals(req.agreePrivacy())) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "개인정보 수집·이용 동의(필수)가 필요합니다.");
|
|
||||||
}
|
|
||||||
String email = nullIfBlank(req.email());
|
|
||||||
if (email == null || !EMAIL.matcher(email).matches()) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "이메일 형식이 올바르지 않습니다.");
|
|
||||||
}
|
|
||||||
if (mapper.eventExists(eventId) == 0) {
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 행사입니다.");
|
|
||||||
}
|
|
||||||
Integer ended = mapper.isEnded(eventId);
|
|
||||||
if (ended != null && ended == 1) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "이미 종료된 행사는 구독할 수 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
String masked = maskEmail(email);
|
|
||||||
|
|
||||||
// 기존 구독 확인(멱등) — 활성이면 그대로, 해지 상태면 재활성화.
|
|
||||||
Map<String, Object> existing = mapper.findByEventEmail(eventId, email);
|
|
||||||
if (existing != null) {
|
|
||||||
String status = str(existing.get("status"));
|
|
||||||
if ("active".equals(status)) {
|
|
||||||
return new SubscribeResult(eventId, null, masked, "active", true);
|
|
||||||
}
|
|
||||||
mapper.reactivate(str(existing.get("id")));
|
|
||||||
return new SubscribeResult(eventId, null, masked, "active", false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 신규 — rate 제어(동일 이메일 1분 1건)
|
|
||||||
if (mapper.countRecentByEmail(email, RATE_WINDOW_SECONDS) > 0) {
|
|
||||||
throw new ApiException(ErrorCode.CONFLICT,
|
|
||||||
"같은 이메일로 잠시 전 구독 요청이 있었습니다. 1분 후 다시 시도해 주세요.");
|
|
||||||
}
|
|
||||||
String id = "es-" + UUID.randomUUID().toString().replace("-", "").substring(0, 18);
|
|
||||||
String token = "estok-" + UUID.randomUUID().toString().replace("-", "");
|
|
||||||
mapper.insert(id, eventId, email, masked, token);
|
|
||||||
return new SubscribeResult(eventId, null, masked, "active", false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원클릭 수신거부(공개·비인증). 불투명 토큰으로 멱등 해지. 토큰이 존재하면(이미 처리 포함) 성공 안내.
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public UnsubscribeResult unsubscribe(String token) {
|
|
||||||
String t = nullIfBlank(token);
|
|
||||||
if (t == null) {
|
|
||||||
return new UnsubscribeResult(false, "유효하지 않은 요청입니다.");
|
|
||||||
}
|
|
||||||
int updated = mapper.unsubscribeByToken(t);
|
|
||||||
if (updated > 0) {
|
|
||||||
return new UnsubscribeResult(true, "구독이 해지되었습니다.");
|
|
||||||
}
|
|
||||||
// 이미 해지되었거나(멱등) 토큰 존재 여부로 성공/실패 구분.
|
|
||||||
if (mapper.tokenExists(t) > 0) {
|
|
||||||
return new UnsubscribeResult(true, "이미 수신거부 처리된 구독입니다.");
|
|
||||||
}
|
|
||||||
return new UnsubscribeResult(false, "유효하지 않은 수신거부 토큰입니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ──
|
|
||||||
/** 이메일 마스킹 — wat***@domain.com. 로그·응답 노출 안전 형식. */
|
|
||||||
static String maskEmail(String email) {
|
|
||||||
if (email == null || email.isBlank()) {
|
|
||||||
return "***";
|
|
||||||
}
|
|
||||||
int at = email.indexOf('@');
|
|
||||||
if (at <= 0) {
|
|
||||||
return "***";
|
|
||||||
}
|
|
||||||
String local = email.substring(0, at);
|
|
||||||
String domain = email.substring(at);
|
|
||||||
if (local.length() <= 3) {
|
|
||||||
return local.charAt(0) + "***" + domain;
|
|
||||||
}
|
|
||||||
return local.substring(0, 3) + "***" + domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String nullIfBlank(String s) {
|
|
||||||
return (s == null || s.isBlank()) ? null : s.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String str(Object o) {
|
|
||||||
return o == null ? null : String.valueOf(o);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
package com.zioinfo.kintex.publicsite.subscription;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.mail.MailProperties;
|
|
||||||
import com.zioinfo.kintex.mail.MailService;
|
|
||||||
import com.zioinfo.kintex.mail.MailTemplates;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 티켓 오픈 감지 스케줄러(F-C1) — 관심 행사 구독자에게 티켓 판매 개시 알림을 1회 발송한다.
|
|
||||||
*
|
|
||||||
* <p><b>동작</b>: 60초 폴러가 {@link EventSubscriptionMapper#findPendingTicketOpen}(오픈된 행사의 미통지 활성 구독)을
|
|
||||||
* 조회하고, 각 건을 <b>조건부 markNotified</b>(이중 발송 방지)로 선점한 뒤 자체 SMTP({@link MailService})로 발송한다.
|
|
||||||
*
|
|
||||||
* <p><b>데모 안전장치(EDM 재사용)</b>: {@link MailProperties#isEdmTestMode()}(기본 true)면 <b>dry-run 모드</b> —
|
|
||||||
* 실 구독자에게 보내지 않고 화이트리스트({@code EDM_TEST_RECIPIENTS})로만 발송(비면 카운트만)한다. 실 대량 발송은
|
|
||||||
* env 로 {@code kintex.mail.edm-test-mode=false} + {@code MAIL_ENABLED=true} 설정 시에만 열린다. 모드와 무관하게
|
|
||||||
* 처리 건은 notified 로 마킹하여 스케줄러 재처리(로그 폭주)를 막는다.
|
|
||||||
*
|
|
||||||
* <p><b>PII(§0-3)</b>: 원문 이메일은 발송 경로 한정 — 로그·응답에는 건수만 남긴다({@link MailService}가 마스킹 이력).
|
|
||||||
* <p>{@code @Scheduled} 전역 활성화는 기존 {@code WebhookSchedulingConfig(@EnableScheduling)} 가 담당한다.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class TicketOpenNotifier {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(TicketOpenNotifier.class);
|
|
||||||
|
|
||||||
/** 1회 스윕 처리 상한(폭주 방지). */
|
|
||||||
private static final int SWEEP_LIMIT = 300;
|
|
||||||
|
|
||||||
private final EventSubscriptionMapper mapper;
|
|
||||||
private final MailService mailService;
|
|
||||||
private final MailProperties mailProperties;
|
|
||||||
|
|
||||||
public TicketOpenNotifier(EventSubscriptionMapper mapper,
|
|
||||||
MailService mailService,
|
|
||||||
MailProperties mailProperties) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
this.mailService = mailService;
|
|
||||||
this.mailProperties = mailProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 오픈 감지 폴러 — 기동 45초 후 최초 실행, 이후 60초 고정 지연(이전 스윕 종료 기준). */
|
|
||||||
@Scheduled(initialDelay = 45_000, fixedDelay = 60_000)
|
|
||||||
public void sweep() {
|
|
||||||
try {
|
|
||||||
runOnce();
|
|
||||||
} catch (Exception e) {
|
|
||||||
// 스택트레이스·민감정보 미노출. 다음 주기에 재시도.
|
|
||||||
log.warn("F-C1 티켓 오픈 알림 스윕 실패: {}", e.getClass().getSimpleName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 오픈된 행사의 미통지 구독을 발송 처리한다.
|
|
||||||
* @return 이번 스윕에서 통지 처리(마킹)한 구독 건수.
|
|
||||||
*/
|
|
||||||
public int runOnce() {
|
|
||||||
List<Map<String, Object>> pending;
|
|
||||||
try {
|
|
||||||
pending = mapper.findPendingTicketOpen(SWEEP_LIMIT);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
log.warn("ticket-open sweep skipped: {}", e.getClass().getSimpleName());
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (pending == null || pending.isEmpty()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean testMode = mailProperties.isEdmTestMode();
|
|
||||||
List<String> whitelist = sanitize(mailProperties.getEdmTestRecipients());
|
|
||||||
String baseUrl = trimTrailingSlash(mailProperties.getWebBaseUrl());
|
|
||||||
|
|
||||||
int processed = 0;
|
|
||||||
for (Map<String, Object> row : pending) {
|
|
||||||
String id = str(row.get("id"));
|
|
||||||
if (id == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 조건부 선점 — 이미 통지된 건(경쟁)은 0 → 스킵(이중 발송 방지).
|
|
||||||
if (mapper.markNotified(id) == 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
processed++;
|
|
||||||
|
|
||||||
String eventName = str(row.get("eventName"));
|
|
||||||
String token = str(row.get("unsubToken"));
|
|
||||||
String bookUrl = baseUrl + "/public/events/" + urlEnc(str(row.get("eventId"))) + "/tickets";
|
|
||||||
String unsubUrl = baseUrl + "/api/public/subscriptions/unsubscribe?token=" + urlEnc(token);
|
|
||||||
String subject = "[KINTEX] 관심 행사 티켓 예매 시작 — " + (eventName == null ? "행사" : eventName);
|
|
||||||
String html = MailTemplates.ticketOpen(eventName, bookUrl, unsubUrl);
|
|
||||||
|
|
||||||
if (testMode) {
|
|
||||||
// dry-run — 실 구독자 미발송. 화이트리스트로만 발송(비면 카운트만).
|
|
||||||
for (String to : whitelist) {
|
|
||||||
mailService.send("TICKET_OPEN_TEST", id, to, "[TEST] " + subject, html);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 운영 실발송 — 구독자 원문 이메일(발송 경로 한정, 로그 미노출).
|
|
||||||
mailService.send("TICKET_OPEN", id, str(row.get("email")), subject, html);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (processed > 0) {
|
|
||||||
log.info("F-C1 티켓 오픈 알림 처리 {}건{}", processed, testMode ? " (테스트/dry-run)" : "");
|
|
||||||
}
|
|
||||||
return processed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ──
|
|
||||||
private static List<String> sanitize(List<String> in) {
|
|
||||||
List<String> out = new ArrayList<>();
|
|
||||||
if (in != null) {
|
|
||||||
for (String s : in) {
|
|
||||||
if (s != null && !s.isBlank()) {
|
|
||||||
out.add(s.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String trimTrailingSlash(String s) {
|
|
||||||
if (s == null || s.isBlank()) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
String t = s.trim();
|
|
||||||
return t.endsWith("/") ? t.substring(0, t.length() - 1) : t;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String urlEnc(String s) {
|
|
||||||
return URLEncoder.encode(s == null ? "" : s, StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String str(Object o) {
|
|
||||||
return o == null ? null : String.valueOf(o);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
package com.zioinfo.kintex.publicsite.subscription.dto;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관심 행사 구독(F-C1) DTO 모음 — 공개 사이트 계약. 원문 이메일은 요청에서만 받고 응답에는 담지 않는다(§0-3).
|
|
||||||
*/
|
|
||||||
public final class SubscriptionDtos {
|
|
||||||
|
|
||||||
private SubscriptionDtos() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 구독 요청 — 이메일 + 개인정보 수집·이용 동의(필수). */
|
|
||||||
public record SubscribeRequest(String email, Boolean agreePrivacy) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구독 결과(원문 이메일 미포함). alreadySubscribed=true 면 기존 활성 구독이 있었음.
|
|
||||||
* emailMasked 는 확인용 마스킹 값(wat***@example.com).
|
|
||||||
*/
|
|
||||||
public record SubscribeResult(String eventId, String eventName, String emailMasked,
|
|
||||||
String status, boolean alreadySubscribed) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 수신거부 결과 — ok=true(처리/이미처리, 토큰 유효), false(토큰 불일치). */
|
|
||||||
public record UnsubscribeResult(boolean ok, String message) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
package com.zioinfo.kintex.report;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.report.dto.GridPdfRequest;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import org.springframework.http.ContentDisposition;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
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;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 범용 그리드 PDF 출력 API — 전 그리드(테이블) 공용 "PDF 출력" 버튼 백엔드(JasperReports).
|
|
||||||
*
|
|
||||||
* <p>인증: 기존 JWT 필터 하(로그인 사용자면 접근 가능, RBAC 세분화 불요 — {@code SecurityConfig}
|
|
||||||
* {@code anyRequest().authenticated()}). 프론트 그리드가 현재 표시 중인 컬럼·행을 그대로 보내면
|
|
||||||
* 서버가 Jasper 로 동적 컬럼 리포트를 렌더해 {@code application/pdf} 로 스트림한다.
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/reports")
|
|
||||||
public class GridReportController {
|
|
||||||
|
|
||||||
private final GridReportService service;
|
|
||||||
|
|
||||||
public GridReportController(GridReportService service) {
|
|
||||||
this.service = service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/reports/grid-pdf — 그리드 → PDF. 응답은 바이너리 PDF(봉투 아님).
|
|
||||||
* 파일명은 title 기반(한글은 RFC 5987 filename*=UTF-8'' 인코딩). 민감정보·스택트레이스 미포함.
|
|
||||||
*/
|
|
||||||
@PostMapping(value = "/grid-pdf", produces = MediaType.APPLICATION_PDF_VALUE)
|
|
||||||
public ResponseEntity<byte[]> gridPdf(@Valid @RequestBody GridPdfRequest request) {
|
|
||||||
byte[] pdf = service.render(request);
|
|
||||||
String fileName = safeFileBase(request.title()) + "_" + LocalDate.now().toString().replace("-", "") + ".pdf";
|
|
||||||
ContentDisposition cd = ContentDisposition.attachment()
|
|
||||||
.filename(fileName, StandardCharsets.UTF_8)
|
|
||||||
.build();
|
|
||||||
return ResponseEntity.ok()
|
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, cd.toString())
|
|
||||||
.contentType(MediaType.APPLICATION_PDF)
|
|
||||||
.body(pdf);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 파일명 base(한글 허용, 경로/구분자·제어문자만 제거). 빈 값은 "grid". */
|
|
||||||
private static String safeFileBase(String s) {
|
|
||||||
if (s == null || s.isBlank()) {
|
|
||||||
return "grid";
|
|
||||||
}
|
|
||||||
String cleaned = s.replaceAll("[\\\\/:*?\"<>|\\r\\n\\t]", "").trim();
|
|
||||||
return cleaned.isEmpty() ? "grid" : cleaned;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,298 +0,0 @@
|
|||||||
package com.zioinfo.kintex.report;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
|
||||||
import com.zioinfo.kintex.report.dto.GridPdfRequest;
|
|
||||||
import net.sf.jasperreports.engine.JasperCompileManager;
|
|
||||||
import net.sf.jasperreports.engine.JasperExportManager;
|
|
||||||
import net.sf.jasperreports.engine.JasperFillManager;
|
|
||||||
import net.sf.jasperreports.engine.JasperPrint;
|
|
||||||
import net.sf.jasperreports.engine.JasperReport;
|
|
||||||
import net.sf.jasperreports.engine.data.JRMapCollectionDataSource;
|
|
||||||
import net.sf.jasperreports.engine.design.JRDesignBand;
|
|
||||||
import net.sf.jasperreports.engine.design.JRDesignExpression;
|
|
||||||
import net.sf.jasperreports.engine.design.JRDesignField;
|
|
||||||
import net.sf.jasperreports.engine.design.JRDesignStaticText;
|
|
||||||
import net.sf.jasperreports.engine.design.JRDesignStyle;
|
|
||||||
import net.sf.jasperreports.engine.design.JRDesignTextField;
|
|
||||||
import net.sf.jasperreports.engine.design.JasperDesign;
|
|
||||||
import net.sf.jasperreports.engine.type.HorizontalTextAlignEnum;
|
|
||||||
import net.sf.jasperreports.engine.type.ModeEnum;
|
|
||||||
import net.sf.jasperreports.engine.type.PositionTypeEnum;
|
|
||||||
import net.sf.jasperreports.engine.type.VerticalTextAlignEnum;
|
|
||||||
import net.sf.jasperreports.engine.xml.JRXmlLoader;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.awt.Color;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 범용 그리드 PDF 렌더 서비스(전 그리드 공용) — WISE/UIWS JasperReports 패턴 이식.
|
|
||||||
*
|
|
||||||
* <p><b>동적 컬럼 처리:</b> 정적 jrxml({@code reports/grid_report.jrxml})은 로고·타이틀·페이지번호 등
|
|
||||||
* "골격"만 담고, 요청 {@link GridPdfRequest#columns()} 개수에 맞춰 {@link JasperDesign} API 로
|
|
||||||
* columnHeader(머리글 {@link JRDesignStaticText})·detail(셀 {@link JRDesignTextField}) 요소를 런타임에
|
|
||||||
* 주입한 뒤 컴파일한다. Jasper 정적 컬럼 한계를 DynamicJasper 없이 가로 가중/균등 분할 자체 구현으로 회피한다.
|
|
||||||
*
|
|
||||||
* <p><b>필드 매핑:</b> 컬럼 키가 임의 문자열(공백·중복 가능)이어도 안전하도록 내부 필드명을 {@code c0..cN}
|
|
||||||
* 로 재부여하고, 데이터소스 행 맵도 동일 키로 재구성한다(표현식 파손·중복 필드 예외 방지).
|
|
||||||
*
|
|
||||||
* <p><b>한글:</b> {@code fonts.xml} 폰트확장(NanumGothic, Identity-H, 임베딩)으로 PDF 한글 깨짐 방지.
|
|
||||||
* 스타일 {@code fontName="NanumGothic"} 을 머리글/셀 스타일에 지정한다.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class GridReportService {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(GridReportService.class);
|
|
||||||
|
|
||||||
private static final String TEMPLATE = "reports/grid_report.jrxml";
|
|
||||||
private static final String LOGO = "reports/kintex_ci.jpg";
|
|
||||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
|
||||||
|
|
||||||
/** A4 가로 컨텐츠 폭(pageWidth 842 - 좌우 여백 30*2). jrxml 과 일치. */
|
|
||||||
private static final int CONTENT_WIDTH = 782;
|
|
||||||
private static final int HEADER_HEIGHT = 22;
|
|
||||||
private static final int ROW_HEIGHT = 16;
|
|
||||||
|
|
||||||
private static final Color BRAND = new Color(0x1F, 0x29, 0xFC);
|
|
||||||
private static final Color GRID_LINE = new Color(0xD0, 0xD5, 0xE0);
|
|
||||||
private static final Color HEADER_BG = new Color(0x1F, 0x29, 0xFC);
|
|
||||||
|
|
||||||
/** 방어적 상한(요청 DTO 검증과 이중). */
|
|
||||||
private static final int MAX_COLUMNS = 30;
|
|
||||||
private static final int MAX_ROWS = 5000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 그리드 요청 → PDF 바이트.
|
|
||||||
*
|
|
||||||
* @param req 컬럼·행 정의. null·빈 컬럼·초과 크기는 {@code VALIDATION}.
|
|
||||||
* @return PDF byte[]
|
|
||||||
*/
|
|
||||||
public byte[] render(GridPdfRequest req) {
|
|
||||||
if (req == null || req.columns() == null || req.columns().isEmpty()) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "출력할 컬럼이 없습니다.");
|
|
||||||
}
|
|
||||||
List<GridPdfRequest.Column> columns = req.columns();
|
|
||||||
if (columns.size() > MAX_COLUMNS) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "컬럼은 최대 " + MAX_COLUMNS + "개까지 지원합니다.");
|
|
||||||
}
|
|
||||||
List<Map<String, Object>> rows = req.rows() == null ? List.of() : req.rows();
|
|
||||||
if (rows.size() > MAX_ROWS) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "행은 최대 " + MAX_ROWS + "행까지 지원합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
JasperDesign design = loadDesign();
|
|
||||||
int[] widths = computeWidths(columns);
|
|
||||||
injectColumns(design, columns, widths);
|
|
||||||
|
|
||||||
JasperReport report = JasperCompileManager.compileReport(design);
|
|
||||||
|
|
||||||
Map<String, Object> params = new HashMap<>();
|
|
||||||
params.put("P_TITLE", nz(req.title()));
|
|
||||||
params.put("P_SUBTITLE", nz(req.subtitle()));
|
|
||||||
params.put("P_CREATED_AT", LocalDateTime.now().format(TS));
|
|
||||||
params.put("P_TOTAL_ROWS", String.format("%,d", rows.size()));
|
|
||||||
InputStream logo = logoStream();
|
|
||||||
if (logo != null) {
|
|
||||||
params.put("P_LOGO", logo);
|
|
||||||
}
|
|
||||||
|
|
||||||
JasperPrint print = JasperFillManager.fillReport(report, params, dataSource(columns, rows));
|
|
||||||
return JasperExportManager.exportReportToPdf(print);
|
|
||||||
} catch (ApiException e) {
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
// 스택트레이스 미노출 — 요약 메시지만(보안 불변).
|
|
||||||
log.error("grid pdf render failed: {}", e.getMessage());
|
|
||||||
throw new ApiException(ErrorCode.INTERNAL, "PDF 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 디자인 조립 ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private JasperDesign loadDesign() throws Exception {
|
|
||||||
// 요청마다 새 디자인(컬럼이 달라 캐시 불가) — 골격 jrxml 을 매번 로드해 주입한다.
|
|
||||||
try (InputStream is = new ClassPathResource(TEMPLATE).getInputStream()) {
|
|
||||||
return JRXmlLoader.load(is);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컬럼 폭 산출 — width(가중치)가 있으면 비례, 없으면 균등. 반올림 누적오차는 마지막 컬럼에서 보정.
|
|
||||||
*/
|
|
||||||
private int[] computeWidths(List<GridPdfRequest.Column> columns) {
|
|
||||||
int n = columns.size();
|
|
||||||
double totalWeight = 0;
|
|
||||||
double[] weights = new double[n];
|
|
||||||
for (int i = 0; i < n; i++) {
|
|
||||||
Integer w = columns.get(i).width();
|
|
||||||
double weight = (w != null && w > 0) ? w : 1.0;
|
|
||||||
weights[i] = weight;
|
|
||||||
totalWeight += weight;
|
|
||||||
}
|
|
||||||
int[] widths = new int[n];
|
|
||||||
int used = 0;
|
|
||||||
for (int i = 0; i < n; i++) {
|
|
||||||
if (i == n - 1) {
|
|
||||||
widths[i] = CONTENT_WIDTH - used; // 마지막 컬럼이 잔여 폭 흡수
|
|
||||||
} else {
|
|
||||||
widths[i] = (int) Math.round(CONTENT_WIDTH * (weights[i] / totalWeight));
|
|
||||||
used += widths[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 최소 폭 보호(음수/과소 방지) — 극단 가중치 대비.
|
|
||||||
for (int i = 0; i < n; i++) {
|
|
||||||
if (widths[i] < 20) {
|
|
||||||
widths[i] = 20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return widths;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void injectColumns(JasperDesign design, List<GridPdfRequest.Column> columns, int[] widths)
|
|
||||||
throws Exception {
|
|
||||||
JRDesignStyle headerStyle = headerStyle();
|
|
||||||
JRDesignStyle cellStyle = cellStyle();
|
|
||||||
design.addStyle(headerStyle);
|
|
||||||
design.addStyle(cellStyle);
|
|
||||||
|
|
||||||
JRDesignBand columnHeader = (JRDesignBand) design.getColumnHeader();
|
|
||||||
columnHeader.setHeight(HEADER_HEIGHT);
|
|
||||||
JRDesignBand detail = (JRDesignBand) design.getDetailSection().getBands()[0];
|
|
||||||
detail.setHeight(ROW_HEIGHT);
|
|
||||||
|
|
||||||
int x = 0;
|
|
||||||
for (int i = 0; i < columns.size(); i++) {
|
|
||||||
GridPdfRequest.Column col = columns.get(i);
|
|
||||||
int w = widths[i];
|
|
||||||
String fieldName = "c" + i;
|
|
||||||
|
|
||||||
// 필드 선언(내부 안전 키). 값 클래스 String.
|
|
||||||
JRDesignField field = new JRDesignField();
|
|
||||||
field.setName(fieldName);
|
|
||||||
field.setValueClass(String.class);
|
|
||||||
design.addField(field);
|
|
||||||
|
|
||||||
// 머리글(정적 텍스트)
|
|
||||||
JRDesignStaticText header = new JRDesignStaticText();
|
|
||||||
header.setX(x);
|
|
||||||
header.setY(0);
|
|
||||||
header.setWidth(w);
|
|
||||||
header.setHeight(HEADER_HEIGHT);
|
|
||||||
header.setText(nz(col.label()));
|
|
||||||
header.setStyle(headerStyle);
|
|
||||||
header.setPositionType(PositionTypeEnum.FIX_RELATIVE_TO_TOP);
|
|
||||||
columnHeader.addElement(header);
|
|
||||||
|
|
||||||
// 셀(텍스트필드 $F{cN})
|
|
||||||
JRDesignTextField cell = new JRDesignTextField();
|
|
||||||
cell.setX(x);
|
|
||||||
cell.setY(0);
|
|
||||||
cell.setWidth(w);
|
|
||||||
cell.setHeight(ROW_HEIGHT);
|
|
||||||
cell.setStyle(cellStyle);
|
|
||||||
cell.setBlankWhenNull(true);
|
|
||||||
cell.setStretchWithOverflow(false);
|
|
||||||
cell.setHorizontalTextAlign(align(col.align()));
|
|
||||||
cell.setPositionType(PositionTypeEnum.FIX_RELATIVE_TO_TOP);
|
|
||||||
JRDesignExpression expr = new JRDesignExpression();
|
|
||||||
expr.setText("$F{" + fieldName + "}");
|
|
||||||
cell.setExpression(expr);
|
|
||||||
detail.addElement(cell);
|
|
||||||
|
|
||||||
x += w;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 스타일 ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private JRDesignStyle headerStyle() {
|
|
||||||
JRDesignStyle s = new JRDesignStyle();
|
|
||||||
s.setName("gridHeaderStyle");
|
|
||||||
s.setFontName("NanumGothic");
|
|
||||||
s.setFontSize(Float.valueOf(8.5f));
|
|
||||||
s.setBold(true);
|
|
||||||
s.setForecolor(Color.WHITE);
|
|
||||||
s.setBackcolor(HEADER_BG);
|
|
||||||
s.setMode(ModeEnum.OPAQUE);
|
|
||||||
s.setHorizontalTextAlign(HorizontalTextAlignEnum.CENTER);
|
|
||||||
s.setVerticalTextAlign(VerticalTextAlignEnum.MIDDLE);
|
|
||||||
applyBox(s);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
private JRDesignStyle cellStyle() {
|
|
||||||
JRDesignStyle s = new JRDesignStyle();
|
|
||||||
s.setName("gridCellStyle");
|
|
||||||
s.setFontName("NanumGothic");
|
|
||||||
s.setFontSize(Float.valueOf(8f));
|
|
||||||
s.setForecolor(new Color(0x1A, 0x1A, 0x1A));
|
|
||||||
s.setVerticalTextAlign(VerticalTextAlignEnum.MIDDLE);
|
|
||||||
applyBox(s);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void applyBox(JRDesignStyle s) {
|
|
||||||
s.getLineBox().getPen().setLineWidth(0.5f);
|
|
||||||
s.getLineBox().getPen().setLineColor(GRID_LINE);
|
|
||||||
s.getLineBox().setLeftPadding(4);
|
|
||||||
s.getLineBox().setRightPadding(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
private HorizontalTextAlignEnum align(String a) {
|
|
||||||
if (a == null) {
|
|
||||||
return HorizontalTextAlignEnum.LEFT;
|
|
||||||
}
|
|
||||||
return switch (a.trim().toLowerCase()) {
|
|
||||||
case "center" -> HorizontalTextAlignEnum.CENTER;
|
|
||||||
case "right" -> HorizontalTextAlignEnum.RIGHT;
|
|
||||||
default -> HorizontalTextAlignEnum.LEFT;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 데이터소스 ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** 각 행을 내부 필드명(c0..cN)→문자열 값 맵으로 재구성. 누락 키는 빈 문자열. */
|
|
||||||
private JRMapCollectionDataSource dataSource(List<GridPdfRequest.Column> columns,
|
|
||||||
List<Map<String, Object>> rows) {
|
|
||||||
List<Map<String, ?>> data = new ArrayList<>(rows.size());
|
|
||||||
for (Map<String, Object> row : rows) {
|
|
||||||
Map<String, Object> mapped = new HashMap<>();
|
|
||||||
for (int i = 0; i < columns.size(); i++) {
|
|
||||||
Object v = row == null ? null : row.get(columns.get(i).key());
|
|
||||||
mapped.put("c" + i, stringify(v));
|
|
||||||
}
|
|
||||||
data.add(mapped);
|
|
||||||
}
|
|
||||||
return new JRMapCollectionDataSource(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 헬퍼 ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private InputStream logoStream() {
|
|
||||||
try {
|
|
||||||
ClassPathResource r = new ClassPathResource(LOGO);
|
|
||||||
return r.exists() ? r.getInputStream() : null;
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null; // 로고 없으면 이미지 미표시(onErrorType="Blank")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String stringify(Object v) {
|
|
||||||
return v == null ? "" : String.valueOf(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String nz(String s) {
|
|
||||||
return s == null ? "" : s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
package com.zioinfo.kintex.report.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 범용 그리드 PDF 출력 요청 — 프론트 그리드가 <b>현재 표시 중인 컬럼·행을 그대로</b> 전송한다.
|
|
||||||
* 서버는 컬럼 정의에 맞춰 JasperReports 동적 컬럼 리포트를 렌더해 application/pdf 로 응답한다.
|
|
||||||
*
|
|
||||||
* <p>방어적 제한: 컬럼 최대 30개, 행 최대 5,000개(초과 시 {@code VALIDATION} 400).
|
|
||||||
* PII·자격증명 컬럼이 오더라도 서버는 받은 그대로 렌더하므로(전 그리드 공용) 별도 마스킹은 하지 않는다.
|
|
||||||
*
|
|
||||||
* @param title 리포트 상단 제목(필수). 예: "부스 배치 현황"
|
|
||||||
* @param subtitle 제목 아래 부제(선택). 예: "2026 국제전시 · K1 홀"
|
|
||||||
* @param columns 표시 컬럼 정의(순서=화면 표시 순서). 최소 1개.
|
|
||||||
* @param rows 행 목록. 각 행은 {@code key → 값} 맵. 값은 문자열로 변환되어 렌더된다.
|
|
||||||
*/
|
|
||||||
public record GridPdfRequest(
|
|
||||||
@NotBlank(message = "title 은 필수입니다.")
|
|
||||||
@Size(max = 200, message = "title 은 200자 이하여야 합니다.")
|
|
||||||
String title,
|
|
||||||
|
|
||||||
@Size(max = 300, message = "subtitle 은 300자 이하여야 합니다.")
|
|
||||||
String subtitle,
|
|
||||||
|
|
||||||
@NotEmpty(message = "columns 는 최소 1개 이상이어야 합니다.")
|
|
||||||
@Size(max = 30, message = "columns 는 최대 30개까지 지원합니다.")
|
|
||||||
@Valid
|
|
||||||
List<Column> columns,
|
|
||||||
|
|
||||||
@NotNull(message = "rows 는 필수입니다(빈 배열 허용).")
|
|
||||||
@Size(max = 5000, message = "rows 는 최대 5,000행까지 지원합니다.")
|
|
||||||
List<Map<String, Object>> rows
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컬럼 정의.
|
|
||||||
*
|
|
||||||
* @param key 행 맵에서 값을 조회할 키(필수). 예: "boothNo"
|
|
||||||
* @param label 헤더에 표시할 라벨(필수). 예: "부스 번호"
|
|
||||||
* @param width 상대 폭 가중치(선택, >0). null/0 이면 균등 분할. 예: 2 → 다른 1 컬럼의 2배 폭.
|
|
||||||
* @param align 셀 정렬(선택): "left"(기본) | "center" | "right".
|
|
||||||
*/
|
|
||||||
public record Column(
|
|
||||||
@NotBlank(message = "column.key 는 필수입니다.")
|
|
||||||
@Size(max = 100)
|
|
||||||
String key,
|
|
||||||
|
|
||||||
@NotBlank(message = "column.label 은 필수입니다.")
|
|
||||||
@Size(max = 100)
|
|
||||||
String label,
|
|
||||||
|
|
||||||
Integer width,
|
|
||||||
String align
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
package com.zioinfo.kintex.session;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
|
||||||
import com.zioinfo.kintex.session.SessionDtos.MyRsvpDto;
|
|
||||||
import com.zioinfo.kintex.session.SessionDtos.RsvpResultDto;
|
|
||||||
import com.zioinfo.kintex.session.SessionDtos.SessionDto;
|
|
||||||
import com.zioinfo.kintex.visitor.VisitorAuth;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B4 세션 정원 RSVP + F-C5 대기열 API.
|
|
||||||
* <ul>
|
|
||||||
* <li>공개 조회 {@code GET /api/public/events/{eventId}/sessions} — 로그인 시 내 상태 포함.</li>
|
|
||||||
* <li>신청/취소/내신청 {@code /api/visitor/sessions/**} — 인증 필요.</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
public class SessionController {
|
|
||||||
|
|
||||||
private final SessionService service;
|
|
||||||
|
|
||||||
public SessionController(SessionService service) {
|
|
||||||
this.service = service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 공개 세션 목록 — Bearer 동봉 시 myStatus 포함(permitAll 경로라 익명 허용). */
|
|
||||||
@GetMapping("/api/public/events/{eventId}/sessions")
|
|
||||||
public ApiResponse<List<SessionDto>> publicSessions(@PathVariable String eventId,
|
|
||||||
@AuthenticationPrincipal KintexPrincipal principal) {
|
|
||||||
String userId = principal == null ? null : principal.userId();
|
|
||||||
return ApiResponse.ok(service.list(eventId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 인증 세션 목록(내 상태 포함). */
|
|
||||||
@GetMapping("/api/visitor/events/{eventId}/sessions")
|
|
||||||
public ApiResponse<List<SessionDto>> mySessions(@PathVariable String eventId,
|
|
||||||
@AuthenticationPrincipal KintexPrincipal principal) {
|
|
||||||
return ApiResponse.ok(service.list(eventId, VisitorAuth.requireUserId(principal)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 RSVP 목록. */
|
|
||||||
@GetMapping("/api/visitor/sessions/mine")
|
|
||||||
public ApiResponse<List<MyRsvpDto>> mine(@AuthenticationPrincipal KintexPrincipal principal) {
|
|
||||||
return ApiResponse.ok(service.mine(VisitorAuth.requireUserId(principal)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 세션 신청(확정/대기). */
|
|
||||||
@PostMapping("/api/visitor/sessions/{sessionId}/rsvp")
|
|
||||||
public ApiResponse<RsvpResultDto> rsvp(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@PathVariable String sessionId) {
|
|
||||||
return ApiResponse.ok(service.rsvp(VisitorAuth.requireUserId(principal), sessionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 세션 신청 취소(대기 승격 트리거). */
|
|
||||||
@DeleteMapping("/api/visitor/sessions/{sessionId}/rsvp")
|
|
||||||
public ApiResponse<RsvpResultDto> cancel(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@PathVariable String sessionId) {
|
|
||||||
return ApiResponse.ok(service.cancel(VisitorAuth.requireUserId(principal), sessionId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
package com.zioinfo.kintex.session;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B4 세션 정원 RSVP + F-C5 대기열 DTO.
|
|
||||||
*/
|
|
||||||
public final class SessionDtos {
|
|
||||||
|
|
||||||
private SessionDtos() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 세션 카드 — 정원·잔여·마감·내 상태. remaining/myStatus 는 nullable. */
|
|
||||||
public record SessionDto(
|
|
||||||
String id,
|
|
||||||
String title,
|
|
||||||
String speaker,
|
|
||||||
String room,
|
|
||||||
String track,
|
|
||||||
String startsAt,
|
|
||||||
String endsAt,
|
|
||||||
int capacity,
|
|
||||||
int seatTaken,
|
|
||||||
Integer remaining,
|
|
||||||
boolean full,
|
|
||||||
String status,
|
|
||||||
String myStatus) { // null | CONFIRMED | WAITLIST
|
|
||||||
}
|
|
||||||
|
|
||||||
/** RSVP 결과 — 확정 또는 대기(순번). */
|
|
||||||
public record RsvpResultDto(
|
|
||||||
String sessionId,
|
|
||||||
String status, // CONFIRMED | WAITLIST
|
|
||||||
Integer waitlistPos, // WAITLIST 시 순번
|
|
||||||
String message) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 RSVP 항목. */
|
|
||||||
public record MyRsvpDto(
|
|
||||||
String sessionId,
|
|
||||||
String title,
|
|
||||||
String room,
|
|
||||||
String track,
|
|
||||||
String startsAt,
|
|
||||||
String status,
|
|
||||||
Integer waitlistPos) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
package com.zioinfo.kintex.session;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Insert;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
import org.apache.ibatis.annotations.Update;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B4 세션 정원 RSVP + F-C5 대기열 매퍼. tenant_id='KINTEX' 고정.
|
|
||||||
* <p>★ camelCase 별칭 쌍따옴표. ★ 확정 좌석 증가는 조건부 UPDATE(오버부킹 원자 차단).
|
|
||||||
*/
|
|
||||||
@Mapper
|
|
||||||
public interface SessionMapper {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 행사 세션 목록 + 내 RSVP 상태(userId null 이면 myStatus null). remaining/full 은 서버 계산.
|
|
||||||
*/
|
|
||||||
@Select("""
|
|
||||||
SELECT s.id,
|
|
||||||
s.title,
|
|
||||||
s.speaker,
|
|
||||||
s.room,
|
|
||||||
s.track,
|
|
||||||
to_char(s.starts_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "startsAt",
|
|
||||||
to_char(s.ends_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "endsAt",
|
|
||||||
s.capacity,
|
|
||||||
s.seat_taken AS "seatTaken",
|
|
||||||
CASE WHEN s.capacity = 0 THEN NULL ELSE (s.capacity - s.seat_taken) END AS "remaining",
|
|
||||||
CASE WHEN s.capacity > 0 AND s.seat_taken >= s.capacity THEN true ELSE false END AS "full",
|
|
||||||
s.status,
|
|
||||||
r.status AS "myStatus"
|
|
||||||
FROM event_session s
|
|
||||||
LEFT JOIN session_rsvp r
|
|
||||||
ON r.session_id = s.id AND r.user_id = #{userId} AND r.status <> 'CANCELLED'
|
|
||||||
WHERE s.tenant_id = 'KINTEX' AND s.event_id = #{eventId}
|
|
||||||
ORDER BY s.sort_order, s.starts_at
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> listSessions(@Param("eventId") String eventId, @Param("userId") String userId);
|
|
||||||
|
|
||||||
/** 단일 세션(RSVP 검증). 없으면 null. */
|
|
||||||
@Select("""
|
|
||||||
SELECT id, event_id AS "eventId", title, capacity, seat_taken AS "seatTaken", status
|
|
||||||
FROM event_session
|
|
||||||
WHERE tenant_id = 'KINTEX' AND id = #{sessionId}
|
|
||||||
""")
|
|
||||||
Map<String, Object> findSession(@Param("sessionId") String sessionId);
|
|
||||||
|
|
||||||
/** 조건부 좌석 확보 — 정원 무제한(0) 또는 여유 있을 때만 +1. 갱신행수 1이면 확보. */
|
|
||||||
@Update("""
|
|
||||||
UPDATE event_session
|
|
||||||
SET seat_taken = seat_taken + 1
|
|
||||||
WHERE tenant_id = 'KINTEX' AND id = #{sessionId} AND status = 'open'
|
|
||||||
AND (capacity = 0 OR seat_taken < capacity)
|
|
||||||
""")
|
|
||||||
int takeSeat(@Param("sessionId") String sessionId);
|
|
||||||
|
|
||||||
/** 좌석 반환(취소/승격 재계산). */
|
|
||||||
@Update("""
|
|
||||||
UPDATE event_session
|
|
||||||
SET seat_taken = GREATEST(0, seat_taken - 1)
|
|
||||||
WHERE tenant_id = 'KINTEX' AND id = #{sessionId}
|
|
||||||
""")
|
|
||||||
void releaseSeat(@Param("sessionId") String sessionId);
|
|
||||||
|
|
||||||
/** 내 RSVP(세션·사용자). 없으면 null. */
|
|
||||||
@Select("""
|
|
||||||
SELECT id, session_id AS "sessionId", status, waitlist_pos AS "waitlistPos"
|
|
||||||
FROM session_rsvp
|
|
||||||
WHERE tenant_id = 'KINTEX' AND session_id = #{sessionId} AND user_id = #{userId}
|
|
||||||
""")
|
|
||||||
Map<String, Object> findRsvp(@Param("sessionId") String sessionId, @Param("userId") String userId);
|
|
||||||
|
|
||||||
@Insert("""
|
|
||||||
INSERT INTO session_rsvp (tenant_id, id, session_id, event_id, user_id, status, waitlist_pos)
|
|
||||||
VALUES ('KINTEX', #{id}, #{sessionId}, #{eventId}, #{userId}, #{status}, #{waitlistPos})
|
|
||||||
""")
|
|
||||||
void insertRsvp(Map<String, Object> row);
|
|
||||||
|
|
||||||
@Update("""
|
|
||||||
UPDATE session_rsvp
|
|
||||||
SET status = #{status}, waitlist_pos = #{waitlistPos}, updated_at = now()
|
|
||||||
WHERE tenant_id = 'KINTEX' AND id = #{id}
|
|
||||||
""")
|
|
||||||
void updateRsvp(@Param("id") String id, @Param("status") String status,
|
|
||||||
@Param("waitlistPos") Integer waitlistPos);
|
|
||||||
|
|
||||||
/** 다음 대기자(최소 순번). 없으면 null. */
|
|
||||||
@Select("""
|
|
||||||
SELECT id, user_id AS "userId"
|
|
||||||
FROM session_rsvp
|
|
||||||
WHERE tenant_id = 'KINTEX' AND session_id = #{sessionId} AND status = 'WAITLIST'
|
|
||||||
ORDER BY waitlist_pos ASC, created_at ASC
|
|
||||||
LIMIT 1
|
|
||||||
""")
|
|
||||||
Map<String, Object> findNextWaitlist(@Param("sessionId") String sessionId);
|
|
||||||
|
|
||||||
/** 현재 최대 대기 순번(신규 대기 순번 부여). 없으면 0. */
|
|
||||||
@Select("""
|
|
||||||
SELECT COALESCE(MAX(waitlist_pos), 0)
|
|
||||||
FROM session_rsvp
|
|
||||||
WHERE tenant_id = 'KINTEX' AND session_id = #{sessionId} AND status = 'WAITLIST'
|
|
||||||
""")
|
|
||||||
int maxWaitlistPos(@Param("sessionId") String sessionId);
|
|
||||||
|
|
||||||
/** 내 RSVP 목록(진행 중 우선). */
|
|
||||||
@Select("""
|
|
||||||
SELECT r.id,
|
|
||||||
r.session_id AS "sessionId",
|
|
||||||
r.status,
|
|
||||||
r.waitlist_pos AS "waitlistPos",
|
|
||||||
s.title,
|
|
||||||
s.room,
|
|
||||||
s.track,
|
|
||||||
to_char(s.starts_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "startsAt"
|
|
||||||
FROM session_rsvp r
|
|
||||||
JOIN event_session s ON s.id = r.session_id
|
|
||||||
WHERE r.tenant_id = 'KINTEX' AND r.user_id = #{userId} AND r.status <> 'CANCELLED'
|
|
||||||
ORDER BY s.starts_at
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> myRsvps(@Param("userId") String userId);
|
|
||||||
}
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
package com.zioinfo.kintex.session;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
|
||||||
import com.zioinfo.kintex.session.SessionDtos.MyRsvpDto;
|
|
||||||
import com.zioinfo.kintex.session.SessionDtos.RsvpResultDto;
|
|
||||||
import com.zioinfo.kintex.session.SessionDtos.SessionDto;
|
|
||||||
import com.zioinfo.kintex.visitor.notification.VisitorNotificationService;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B4 세션 정원 RSVP + F-C5 대기열·취소표 서비스.
|
|
||||||
*
|
|
||||||
* <p>신청: 정원 여유 → CONFIRMED(조건부 좌석 확보로 오버부킹 차단), 소진 → WAITLIST(순번 부여).
|
|
||||||
* <p>취소: 확정 취소 시 좌석 반환 후 대기 1순위를 자동 승격(CONFIRMED)하고 인앱 알림 발행(취소표 알림).
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class SessionService {
|
|
||||||
|
|
||||||
private final SessionMapper mapper;
|
|
||||||
private final VisitorNotificationService notifications;
|
|
||||||
|
|
||||||
public SessionService(SessionMapper mapper, VisitorNotificationService notifications) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
this.notifications = notifications;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 행사 세션 목록(userId null 이면 내 상태 없음 — 공개 조회). */
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<SessionDto> list(String eventId, String userId) {
|
|
||||||
if (eventId == null || eventId.isBlank()) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "행사 식별자가 필요합니다.");
|
|
||||||
}
|
|
||||||
// 익명(공개) 조회는 sentinel('')로 — null JDBC 바인딩 회피(PG 파라미터 타입 추론 안전).
|
|
||||||
String uid = userId == null ? "" : userId;
|
|
||||||
return mapper.listSessions(eventId, uid).stream().map(SessionService::toSession).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 RSVP 목록. */
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<MyRsvpDto> mine(String userId) {
|
|
||||||
return mapper.myRsvps(userId).stream()
|
|
||||||
.map(m -> new MyRsvpDto(str(m.get("sessionId")), str(m.get("title")), str(m.get("room")),
|
|
||||||
str(m.get("track")), str(m.get("startsAt")), str(m.get("status")),
|
|
||||||
intOrNull(m.get("waitlistPos"))))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 세션 신청 — 정원 여유 시 확정, 소진 시 대기 등록. */
|
|
||||||
@Transactional
|
|
||||||
public RsvpResultDto rsvp(String userId, String sessionId) {
|
|
||||||
Map<String, Object> s = mapper.findSession(sessionId);
|
|
||||||
if (s == null) {
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND, "세션을 찾을 수 없습니다.");
|
|
||||||
}
|
|
||||||
if (!"open".equals(str(s.get("status")))) {
|
|
||||||
throw new ApiException(ErrorCode.CONFLICT, "신청이 마감된 세션입니다.");
|
|
||||||
}
|
|
||||||
Map<String, Object> existing = mapper.findRsvp(sessionId, userId);
|
|
||||||
if (existing != null && !"CANCELLED".equals(str(existing.get("status")))) {
|
|
||||||
throw new ApiException(ErrorCode.CONFLICT, "이미 신청한 세션입니다.");
|
|
||||||
}
|
|
||||||
String eventId = str(s.get("eventId"));
|
|
||||||
|
|
||||||
boolean confirmed = mapper.takeSeat(sessionId) == 1;
|
|
||||||
String status = confirmed ? "CONFIRMED" : "WAITLIST";
|
|
||||||
Integer waitPos = confirmed ? null : mapper.maxWaitlistPos(sessionId) + 1;
|
|
||||||
|
|
||||||
if (existing != null) {
|
|
||||||
// 취소 후 재신청 — 기존 행 재사용.
|
|
||||||
mapper.updateRsvp(str(existing.get("id")), status, waitPos);
|
|
||||||
} else {
|
|
||||||
Map<String, Object> row = new java.util.HashMap<>();
|
|
||||||
row.put("id", "rsvp-" + UUID.randomUUID());
|
|
||||||
row.put("sessionId", sessionId);
|
|
||||||
row.put("eventId", eventId);
|
|
||||||
row.put("userId", userId);
|
|
||||||
row.put("status", status);
|
|
||||||
row.put("waitlistPos", waitPos);
|
|
||||||
mapper.insertRsvp(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
String msg = confirmed ? "좌석이 확정되었습니다." : "정원이 마감되어 대기열에 등록되었습니다(취소 발생 시 알림).";
|
|
||||||
return new RsvpResultDto(sessionId, status, waitPos, msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 세션 신청 취소 — 확정이었으면 좌석 반환 + 대기 1순위 자동 승격 + 알림. */
|
|
||||||
@Transactional
|
|
||||||
public RsvpResultDto cancel(String userId, String sessionId) {
|
|
||||||
Map<String, Object> mine = mapper.findRsvp(sessionId, userId);
|
|
||||||
if (mine == null || "CANCELLED".equals(str(mine.get("status")))) {
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND, "취소할 신청 내역이 없습니다.");
|
|
||||||
}
|
|
||||||
boolean wasConfirmed = "CONFIRMED".equals(str(mine.get("status")));
|
|
||||||
mapper.updateRsvp(str(mine.get("id")), "CANCELLED", null);
|
|
||||||
|
|
||||||
if (wasConfirmed) {
|
|
||||||
// 좌석 반환 후 대기 1순위 승격 시도.
|
|
||||||
mapper.releaseSeat(sessionId);
|
|
||||||
Map<String, Object> next = mapper.findNextWaitlist(sessionId);
|
|
||||||
if (next != null && mapper.takeSeat(sessionId) == 1) {
|
|
||||||
mapper.updateRsvp(str(next.get("id")), "CONFIRMED", null);
|
|
||||||
String promotedUser = str(next.get("userId"));
|
|
||||||
Map<String, Object> sess = mapper.findSession(sessionId);
|
|
||||||
String title = sess == null ? "세션" : str(sess.get("title"));
|
|
||||||
notifications.publish(promotedUser, "WAITLIST_PROMOTED",
|
|
||||||
"대기하신 세션 좌석이 확정되었습니다",
|
|
||||||
"‘" + title + "’ 세션에 취소표가 발생하여 좌석이 확정되었습니다.",
|
|
||||||
"SESSION", sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new RsvpResultDto(sessionId, "CANCELLED", null, "신청이 취소되었습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ──
|
|
||||||
private static SessionDto toSession(Map<String, Object> m) {
|
|
||||||
return new SessionDto(
|
|
||||||
str(m.get("id")), str(m.get("title")), str(m.get("speaker")), str(m.get("room")),
|
|
||||||
str(m.get("track")), str(m.get("startsAt")), str(m.get("endsAt")),
|
|
||||||
toInt(m.get("capacity"), 0), toInt(m.get("seatTaken"), 0),
|
|
||||||
intOrNull(m.get("remaining")), bool(m.get("full")), str(m.get("status")),
|
|
||||||
str(m.get("myStatus")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String str(Object o) {
|
|
||||||
return o == null ? null : o.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean bool(Object o) {
|
|
||||||
return o instanceof Boolean b && b;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int toInt(Object o, int def) {
|
|
||||||
return o instanceof Number n ? n.intValue() : def;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Integer intOrNull(Object o) {
|
|
||||||
return o instanceof Number n ? n.intValue() : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,10 +6,8 @@ import com.zioinfo.kintex.common.audit.Audited;
|
|||||||
import com.zioinfo.kintex.system.SystemAccessGuard;
|
import com.zioinfo.kintex.system.SystemAccessGuard;
|
||||||
import com.zioinfo.kintex.tenant.dto.TenantAdminDto;
|
import com.zioinfo.kintex.tenant.dto.TenantAdminDto;
|
||||||
import com.zioinfo.kintex.tenant.dto.TenantCreateRequest;
|
import com.zioinfo.kintex.tenant.dto.TenantCreateRequest;
|
||||||
import com.zioinfo.kintex.tenant.dto.TenantSwitchResponse;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@ -49,16 +47,4 @@ public class TenantAdminController {
|
|||||||
guard.requireAdmin(principal);
|
guard.requireAdmin(principal);
|
||||||
return ApiResponse.ok(service.create(req));
|
return ApiResponse.ok(service.create(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /{code}/switch — 플랫폼 관리자 테넌트 스위치. 대상 테넌트 tid 가 박힌 신규 토큰을 반환한다(§1A-3-2).
|
|
||||||
* 프론트 스위처가 응답 토큰으로 세션을 교체하면 이후 전 요청이 대상 테넌트로 스코프된다.
|
|
||||||
*/
|
|
||||||
@Audited(action = "TENANT_SWITCH", targetType = "tenant")
|
|
||||||
@PostMapping("/{code}/switch")
|
|
||||||
public ApiResponse<TenantSwitchResponse> switchTenant(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@PathVariable String code) {
|
|
||||||
guard.requireAdmin(principal);
|
|
||||||
return ApiResponse.ok(service.switchTenant(principal, code));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,9 @@
|
|||||||
package com.zioinfo.kintex.tenant;
|
package com.zioinfo.kintex.tenant;
|
||||||
|
|
||||||
import com.zioinfo.kintex.auth.JwtService;
|
|
||||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
import com.zioinfo.kintex.common.error.ApiException;
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||||
import com.zioinfo.kintex.tenant.dto.TenantAdminDto;
|
import com.zioinfo.kintex.tenant.dto.TenantAdminDto;
|
||||||
import com.zioinfo.kintex.tenant.dto.TenantCreateRequest;
|
import com.zioinfo.kintex.tenant.dto.TenantCreateRequest;
|
||||||
import com.zioinfo.kintex.tenant.dto.TenantSwitchResponse;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@ -28,12 +25,10 @@ public class TenantAdminService {
|
|||||||
|
|
||||||
private final TenantMapper mapper;
|
private final TenantMapper mapper;
|
||||||
private final TenantResolver resolver;
|
private final TenantResolver resolver;
|
||||||
private final JwtService jwtService;
|
|
||||||
|
|
||||||
public TenantAdminService(TenantMapper mapper, TenantResolver resolver, JwtService jwtService) {
|
public TenantAdminService(TenantMapper mapper, TenantResolver resolver) {
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.resolver = resolver;
|
this.resolver = resolver;
|
||||||
this.jwtService = jwtService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 전 테넌트 목록(행사·사용자 수 포함). */
|
/** 전 테넌트 목록(행사·사용자 수 포함). */
|
||||||
@ -74,21 +69,6 @@ public class TenantAdminService {
|
|||||||
return new TenantAdminDto(code, req.name().trim(), domain, status, 0L, 0L, null);
|
return new TenantAdminDto(code, req.name().trim(), domain, status, 0L, 0L, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 플랫폼 관리자 테넌트 스위치 — 대상 테넌트 {@code tid}가 박힌 신규 토큰 재발급(§1A-3-2).
|
|
||||||
* 접근 게이트(requireAdmin)는 컨트롤러가 이미 수행. 대상 테넌트가 미존재면 400.
|
|
||||||
* 사용자 신원·역할(eventRoles·hallManager·roleCode)은 그대로 유지하고 테넌트만 교체한다(선택 클레임 재발급).
|
|
||||||
*/
|
|
||||||
public TenantSwitchResponse switchTenant(KintexPrincipal principal, String code) {
|
|
||||||
String target = TenantContext.normalize(code);
|
|
||||||
if (target == null || !resolver.exists(target)) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 테넌트입니다.");
|
|
||||||
}
|
|
||||||
String token = jwtService.issue(principal.userId(), principal.displayName(),
|
|
||||||
principal.eventRoles(), principal.hallManager(), principal.roleCode(), target);
|
|
||||||
return new TenantSwitchResponse(token, jwtService.ttlSeconds(), target);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String str(Object o) {
|
private static String str(Object o) {
|
||||||
return o == null ? null : String.valueOf(o);
|
return o == null ? null : String.valueOf(o);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
package com.zioinfo.kintex.tenant;
|
package com.zioinfo.kintex.tenant;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.zioinfo.kintex.auth.JwtService;
|
|
||||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
import com.zioinfo.kintex.common.ApiResponse;
|
||||||
import com.zioinfo.kintex.common.ApiResponse.ApiError;
|
import com.zioinfo.kintex.common.ApiResponse.ApiError;
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||||
@ -17,76 +15,40 @@ import java.io.IOException;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 테넌트 컨텍스트 해소 필터 (PLANNING §1A-3 · §8-2 필터 계층).
|
* 테넌트 컨텍스트 해소 필터 (PLANNING §1A-3 · §8-2 필터 계층).
|
||||||
*
|
* <p>해소 우선순위: ① {@code X-Tenant-Id} 헤더 → ② Host 도메인 매칭 → ③ 기준 테넌트('kintex').
|
||||||
* <p><b>2단계(JWT tid 권위)</b> — 인증 요청은 JWT {@code tid} 클레임을 신뢰 원천으로 삼는다(§1A-3-2):
|
* 명시적 {@code X-Tenant-Id}가 존재하지 않는 테넌트를 가리키면 <b>400</b>(오접속·위조 차단).
|
||||||
* <ul>
|
* 그 외(헤더 없음·도메인 미매칭)는 기준 테넌트로 폴백해 기존 동작을 완전히 보존한다(회귀 0, 1단계).
|
||||||
* <li><b>인증 요청</b>(유효 Bearer): 테넌트 = JWT {@code tid}. 헤더 {@code X-Tenant-Id}가 tid와
|
* <p>2단계에서 fail-closed(미해소 거부)·JWT 테넌트 클레임 검증으로 강화 — 설계 노트 참조.
|
||||||
* <b>다른</b> 테넌트를 명시하면 → 플랫폼 관리자(전역 ADMIN 또는 홀매니저)만 대상 테넌트로 스위치 허용
|
|
||||||
* (감사 대상 크로스-테넌트), 그 외 주체는 <b>403</b>(오접속/하이재킹 차단, fail-closed). 스위치 대상이
|
|
||||||
* 미존재 테넌트면 <b>400</b>.</li>
|
|
||||||
* <li><b>미인증 요청</b>(공개 경로): 1단계 해소 유지 — ① {@code X-Tenant-Id}(미존재 시 400) →
|
|
||||||
* ② Host 도메인 매칭 → ③ 기준 테넌트('KINTEX').</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>회귀 0: 기존 인증 클라이언트는 tid=KINTEX 토큰 + 헤더 미전송 → 테넌트 KINTEX(동일). tid 부재 레거시
|
|
||||||
* 토큰도 {@link JwtService#verify}가 KINTEX로 정규화 → 동일. 공개 경로는 1단계와 동일 해소.
|
|
||||||
*
|
|
||||||
* <p>필터 순서 비의존: JWT를 자체 파싱하므로 {@code JwtAuthenticationFilter} 와의 등록 순서에 무관하게 동작한다.
|
|
||||||
*/
|
*/
|
||||||
public class TenantContextFilter extends OncePerRequestFilter {
|
public class TenantContextFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
private static final String HEADER = "X-Tenant-Id";
|
private static final String HEADER = "X-Tenant-Id";
|
||||||
private static final String BEARER = "Bearer ";
|
|
||||||
|
|
||||||
private final TenantResolver resolver;
|
private final TenantResolver resolver;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final JwtService jwtService;
|
|
||||||
|
|
||||||
public TenantContextFilter(TenantResolver resolver, ObjectMapper objectMapper, JwtService jwtService) {
|
public TenantContextFilter(TenantResolver resolver, ObjectMapper objectMapper) {
|
||||||
this.resolver = resolver;
|
this.resolver = resolver;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.jwtService = jwtService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
FilterChain chain) throws ServletException, IOException {
|
FilterChain chain) throws ServletException, IOException {
|
||||||
String headerTenant = trimToNull(request.getHeader(HEADER));
|
|
||||||
KintexPrincipal principal = tryPrincipal(request);
|
|
||||||
|
|
||||||
String tenantId;
|
String tenantId;
|
||||||
if (principal != null) {
|
String headerTenant = request.getHeader(HEADER);
|
||||||
// 인증 요청 — JWT tid 권위(§1A-3-2).
|
if (headerTenant != null && !headerTenant.isBlank()) {
|
||||||
String jwtTenant = principal.tenantId(); // 이미 KINTEX 기본 정규화됨
|
String candidate = headerTenant.trim();
|
||||||
if (headerTenant != null
|
if (!resolver.exists(candidate)) {
|
||||||
&& !equalsTenant(headerTenant, jwtTenant)) {
|
|
||||||
// 명시적 크로스-테넌트 스위치 시도.
|
|
||||||
boolean platformAdmin = principal.isAdmin() || principal.hallManager();
|
|
||||||
if (!platformAdmin) {
|
|
||||||
writeForbiddenTenant(response); // 403 — 하이재킹/오접속 차단
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!resolver.exists(headerTenant)) {
|
|
||||||
writeBadTenant(response); // 400 — 미존재 테넌트
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tenantId = headerTenant; // 관리자 명시 스위치(감사 로깅은 컨트롤러/감사 레이어)
|
|
||||||
} else {
|
|
||||||
tenantId = jwtTenant; // 기본: JWT tid
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 미인증(공개 경로) — 1단계 해소 유지: 헤더 → 도메인 → 기준 테넌트.
|
|
||||||
if (headerTenant != null) {
|
|
||||||
if (!resolver.exists(headerTenant)) {
|
|
||||||
writeBadTenant(response);
|
writeBadTenant(response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tenantId = headerTenant;
|
tenantId = candidate;
|
||||||
} else {
|
} else {
|
||||||
|
// 도메인 매칭(§1A-3) → 폴백 기준 테넌트.
|
||||||
String byDomain = resolver.resolveByDomain(request.getHeader("Host"));
|
String byDomain = resolver.resolveByDomain(request.getHeader("Host"));
|
||||||
tenantId = (byDomain != null) ? byDomain : TenantContext.DEFAULT_TENANT;
|
tenantId = (byDomain != null) ? byDomain : TenantContext.DEFAULT_TENANT;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
TenantContext.set(tenantId);
|
TenantContext.set(tenantId);
|
||||||
@ -96,46 +58,12 @@ public class TenantContextFilter extends OncePerRequestFilter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bearer 토큰이 유효하면 principal 복원, 없거나 무효면 null(공개 경로 취급). 접근 제어는 하지 않는다. */
|
|
||||||
private KintexPrincipal tryPrincipal(HttpServletRequest request) {
|
|
||||||
String header = request.getHeader("Authorization");
|
|
||||||
if (header != null && header.startsWith(BEARER)) {
|
|
||||||
try {
|
|
||||||
return jwtService.verify(header.substring(BEARER.length()));
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// 무효 토큰 → 미인증으로 취급(도메인/기준 테넌트 해소). 실제 401은 인증 필터/엔트리포인트가 처리.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean equalsTenant(String a, String b) {
|
|
||||||
String na = TenantContext.normalize(a);
|
|
||||||
String nb = TenantContext.normalize(b);
|
|
||||||
return na == null ? nb == null : na.equals(nb);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String trimToNull(String v) {
|
|
||||||
if (v == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String t = v.trim();
|
|
||||||
return t.isEmpty() ? null : t;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeBadTenant(HttpServletResponse response) throws IOException {
|
private void writeBadTenant(HttpServletResponse response) throws IOException {
|
||||||
writeError(response, ErrorCode.VALIDATION, "알 수 없는 테넌트입니다.");
|
response.setStatus(ErrorCode.VALIDATION.status().value());
|
||||||
}
|
|
||||||
|
|
||||||
private void writeForbiddenTenant(HttpServletResponse response) throws IOException {
|
|
||||||
writeError(response, ErrorCode.FORBIDDEN, "요청한 테넌트에 접근할 수 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeError(HttpServletResponse response, ErrorCode code, String message) throws IOException {
|
|
||||||
response.setStatus(code.status().value());
|
|
||||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
response.setCharacterEncoding("UTF-8");
|
response.setCharacterEncoding("UTF-8");
|
||||||
response.getWriter().write(objectMapper.writeValueAsString(
|
response.getWriter().write(objectMapper.writeValueAsString(
|
||||||
ApiResponse.fail(new ApiError(code.name(), message))));
|
ApiResponse.fail(new ApiError(ErrorCode.VALIDATION.name(),
|
||||||
|
"알 수 없는 테넌트입니다."))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
package com.zioinfo.kintex.tenant.dto;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 플랫폼 관리자 테넌트 스위치 응답 — 대상 테넌트 {@code tid}가 박힌 신규 액세스 토큰(§1A-3-2).
|
|
||||||
* 프론트 스위처가 {@code accessToken}으로 세션 토큰을 교체하면 이후 전 요청이 대상 테넌트로 스코프된다.
|
|
||||||
*/
|
|
||||||
public record TenantSwitchResponse(
|
|
||||||
String accessToken,
|
|
||||||
long expiresIn,
|
|
||||||
String tenant
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
package com.zioinfo.kintex.ticket.cancel;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B2 티켓 취소·환불 산정(순수 함수 — 단위테스트 대상). 서버 권위(클라이언트 금액 미신뢰).
|
|
||||||
*
|
|
||||||
* <p>산식:
|
|
||||||
* <ul>
|
|
||||||
* <li>ticketRefund = round(ticketAmount × 구간환불율%)</li>
|
|
||||||
* <li>adminFee = round(ticketRefund × 취소관리수수료%) — 환불액에서 공제</li>
|
|
||||||
* <li>bookingFeeRefund = (D-일 ≥ 예매수수료 환불 최소일) ? bookingFee : 0 (예매수수료 별도 규정)</li>
|
|
||||||
* <li>refund = ticketRefund − adminFee + bookingFeeRefund</li>
|
|
||||||
* <li>forfeit = (ticketAmount + bookingFee) − refund</li>
|
|
||||||
* </ul>
|
|
||||||
* D-일이 음수(개막 당일·이후)면 매칭 구간 0% → 티켓 환불 0.
|
|
||||||
*/
|
|
||||||
public final class TicketCancelCalc {
|
|
||||||
|
|
||||||
private TicketCancelCalc() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 다구간 — minDaysBefore 이상 남았을 때 refundRatePercent 적용. */
|
|
||||||
public record Bracket(int minDaysBefore, double refundRatePercent, String label) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 산정 결과(스냅샷). */
|
|
||||||
public record Result(
|
|
||||||
int daysBefore,
|
|
||||||
long ticketAmount,
|
|
||||||
long bookingFee,
|
|
||||||
double refundRatePercent,
|
|
||||||
String bracketLabel,
|
|
||||||
long ticketRefund,
|
|
||||||
long adminFee,
|
|
||||||
long bookingFeeRefund,
|
|
||||||
long refundAmount,
|
|
||||||
long forfeitAmount) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** D-일에 해당하는 구간(조건 만족 중 minDaysBefore 최대). 없으면 null. */
|
|
||||||
public static Bracket bracketFor(int daysBefore, List<Bracket> brackets) {
|
|
||||||
if (brackets == null || brackets.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
Bracket best = null;
|
|
||||||
for (Bracket b : brackets) {
|
|
||||||
if (daysBefore >= b.minDaysBefore() && (best == null || b.minDaysBefore() > best.minDaysBefore())) {
|
|
||||||
best = b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 환불 산정.
|
|
||||||
*
|
|
||||||
* @param daysBefore 개막까지 남은 일수(음수=개막 당일·이후, null 미허용 — 미확정은 호출부가 0 처리)
|
|
||||||
* @param ticketAmount 환불 대상 티켓액(예매수수료 제외)
|
|
||||||
* @param bookingFee 예매수수료 총액
|
|
||||||
* @param brackets 구간 목록
|
|
||||||
* @param bookingFeeRefundMinDays 예매수수료 환불 최소 D-일
|
|
||||||
* @param cancelAdminFeePercent 취소 관리수수료(%)
|
|
||||||
*/
|
|
||||||
public static Result compute(int daysBefore, long ticketAmount, long bookingFee,
|
|
||||||
List<Bracket> brackets, int bookingFeeRefundMinDays,
|
|
||||||
double cancelAdminFeePercent) {
|
|
||||||
long tAmt = Math.max(0, ticketAmount);
|
|
||||||
long bFee = Math.max(0, bookingFee);
|
|
||||||
Bracket br = bracketFor(daysBefore, brackets);
|
|
||||||
double rate = br == null ? 0.0 : clampPct(br.refundRatePercent());
|
|
||||||
String label = br == null ? "환불 불가 구간" : br.label();
|
|
||||||
|
|
||||||
long ticketRefund = Math.round(tAmt * rate / 100.0);
|
|
||||||
long adminFee = Math.round(ticketRefund * clampPct(cancelAdminFeePercent) / 100.0);
|
|
||||||
long bookingFeeRefund = daysBefore >= bookingFeeRefundMinDays ? bFee : 0L;
|
|
||||||
long refund = Math.max(0L, ticketRefund - adminFee + bookingFeeRefund);
|
|
||||||
long forfeit = Math.max(0L, (tAmt + bFee) - refund);
|
|
||||||
|
|
||||||
return new Result(daysBefore, tAmt, bFee, rate, label,
|
|
||||||
ticketRefund, adminFee, bookingFeeRefund, refund, forfeit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double clampPct(double p) {
|
|
||||||
if (p < 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.min(p, 100.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
package com.zioinfo.kintex.ticket.cancel;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
|
||||||
import com.zioinfo.kintex.ticket.cancel.TicketCancelDtos.CancelRequest;
|
|
||||||
import com.zioinfo.kintex.ticket.cancel.TicketCancelDtos.RefundQuoteDto;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B2 티켓 취소·환불 공개 API — 전 경로 {@code /api/public/**} permitAll.
|
|
||||||
* <p>본인확인(예매번호+연락처). 예상 환불액은 서버 권위 산출.
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/public/tickets")
|
|
||||||
public class TicketCancelController {
|
|
||||||
|
|
||||||
private final TicketCancelService service;
|
|
||||||
|
|
||||||
public TicketCancelController(TicketCancelService service) {
|
|
||||||
this.service = service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /refund-quote — 취소 전 예상 환불 산정(조회만). */
|
|
||||||
@PostMapping("/refund-quote")
|
|
||||||
public ApiResponse<RefundQuoteDto> quote(@Valid @RequestBody CancelRequest req) {
|
|
||||||
return ApiResponse.ok(service.quote(req.orderNo(), req.contact()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /cancel — 실제 취소 처리(상태 전이·재고 원복·환불 스냅샷). */
|
|
||||||
@PostMapping("/cancel")
|
|
||||||
public ApiResponse<RefundQuoteDto> cancel(@Valid @RequestBody CancelRequest req) {
|
|
||||||
return ApiResponse.ok(service.cancel(req.orderNo(), req.contact()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
package com.zioinfo.kintex.ticket.cancel;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B2 티켓 취소·환불 DTO. 전 경로 공개(/api/public/**) — 티켓은 비로그인.
|
|
||||||
* <p>보안(§0-3): 구매자 PII 원문 미노출. 금액은 전부 서버 산출값.
|
|
||||||
*/
|
|
||||||
public final class TicketCancelDtos {
|
|
||||||
|
|
||||||
private TicketCancelDtos() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 예상 환불 조회/취소 요청 — 예매번호 + 연락처(본인확인). */
|
|
||||||
public record CancelRequest(
|
|
||||||
@NotBlank String orderNo,
|
|
||||||
@NotBlank String contact) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 규정 구간(표시용). */
|
|
||||||
public record BracketDto(int minDaysBefore, double refundRatePercent, String label) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 예상 환불 산정 결과(서버 권위). preview=true 는 미확정(조회만), false 는 실제 취소 반영.
|
|
||||||
*/
|
|
||||||
public record RefundQuoteDto(
|
|
||||||
String orderNo,
|
|
||||||
String status, // PAID/CANCELLED
|
|
||||||
String productName,
|
|
||||||
int qty,
|
|
||||||
long ticketAmount, // 환불 대상 티켓액
|
|
||||||
long bookingFee, // 예매수수료 총액
|
|
||||||
int daysBefore,
|
|
||||||
double refundRatePercent,
|
|
||||||
String bracketLabel,
|
|
||||||
long ticketRefund,
|
|
||||||
long adminFee,
|
|
||||||
long bookingFeeRefund,
|
|
||||||
long refundAmount, // 최종 환불 예상액
|
|
||||||
long forfeitAmount, // 공제(위약)액
|
|
||||||
String policyVersion,
|
|
||||||
boolean cancellable, // 취소 가능 여부(PAID·미취소)
|
|
||||||
List<BracketDto> brackets) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
package com.zioinfo.kintex.ticket.cancel;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Insert;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
import org.apache.ibatis.annotations.Update;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B2 티켓 취소·환불 매퍼. tenant_id='KINTEX' 고정.
|
|
||||||
* <p>★ camelCase 별칭 쌍따옴표({@code AS "x"}). ★ 날짜 비교는 파라미터 캐스팅.
|
|
||||||
* <p>보안(§0-3): 조회에서 구매자 원문 컬럼은 SELECT 하지 않는다(마스킹/해시만).
|
|
||||||
*/
|
|
||||||
@Mapper
|
|
||||||
public interface TicketCancelMapper {
|
|
||||||
|
|
||||||
/** 주문번호+연락처 해시 대조 → 취소 산정에 필요한 주문 원시값. 없으면 null. */
|
|
||||||
@Select("""
|
|
||||||
SELECT id AS "id",
|
|
||||||
order_no AS "orderNo",
|
|
||||||
event_id AS "eventId",
|
|
||||||
product_id AS "productId",
|
|
||||||
product_name AS "productName",
|
|
||||||
qty,
|
|
||||||
unit_price AS "unitPrice",
|
|
||||||
total_amount AS "totalAmount",
|
|
||||||
status
|
|
||||||
FROM ticket_order
|
|
||||||
WHERE order_no = #{orderNo} AND buyer_contact_hash = #{contactHash}
|
|
||||||
LIMIT 1
|
|
||||||
""")
|
|
||||||
Map<String, Object> findOrderForCancel(@Param("orderNo") String orderNo,
|
|
||||||
@Param("contactHash") String contactHash);
|
|
||||||
|
|
||||||
/** 행사 개막일(YYYY-MM-DD). 없으면 null. */
|
|
||||||
@Select("SELECT to_char(start_date, 'YYYY-MM-DD') FROM event WHERE id = #{eventId}")
|
|
||||||
String findEventStartDate(@Param("eventId") String eventId);
|
|
||||||
|
|
||||||
/** 행사 특화 취소 구간(없으면 빈 목록 → 기본 구간 사용). */
|
|
||||||
@Select("""
|
|
||||||
SELECT min_days_before AS "minDaysBefore",
|
|
||||||
refund_rate_percent AS "refundRatePercent",
|
|
||||||
label
|
|
||||||
FROM ticket_cancel_bracket
|
|
||||||
WHERE tenant_id = 'KINTEX' AND event_id = #{eventId}
|
|
||||||
ORDER BY min_days_before DESC
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> findEventBrackets(@Param("eventId") String eventId);
|
|
||||||
|
|
||||||
/** 기본 취소 구간(event_id IS NULL). */
|
|
||||||
@Select("""
|
|
||||||
SELECT min_days_before AS "minDaysBefore",
|
|
||||||
refund_rate_percent AS "refundRatePercent",
|
|
||||||
label
|
|
||||||
FROM ticket_cancel_bracket
|
|
||||||
WHERE tenant_id = 'KINTEX' AND event_id IS NULL
|
|
||||||
ORDER BY min_days_before DESC
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> findDefaultBrackets();
|
|
||||||
|
|
||||||
/** 수수료 정책(행사 우선, 없으면 __default__). 없으면 null. */
|
|
||||||
@Select("""
|
|
||||||
SELECT booking_fee_per_ticket AS "bookingFeePerTicket",
|
|
||||||
booking_fee_refund_min_days AS "bookingFeeRefundMinDays",
|
|
||||||
cancel_admin_fee_percent AS "cancelAdminFeePercent",
|
|
||||||
version
|
|
||||||
FROM ticket_fee_policy
|
|
||||||
WHERE tenant_id = 'KINTEX' AND event_id = #{eventId}
|
|
||||||
""")
|
|
||||||
Map<String, Object> findFeePolicy(@Param("eventId") String eventId);
|
|
||||||
|
|
||||||
/** 이미 취소된 주문 여부. */
|
|
||||||
@Select("SELECT count(*) FROM ticket_cancellation WHERE tenant_id='KINTEX' AND order_id = #{orderId}")
|
|
||||||
int countCancellation(@Param("orderId") String orderId);
|
|
||||||
|
|
||||||
/** 주문 상태 → CANCELLED(조건부: PAID 만). 갱신행수 1이면 성공. */
|
|
||||||
@Update("""
|
|
||||||
UPDATE ticket_order
|
|
||||||
SET status = 'CANCELLED', cancelled_at = now()
|
|
||||||
WHERE tenant_id = 'KINTEX' AND id = #{orderId} AND status = 'PAID'
|
|
||||||
""")
|
|
||||||
int cancelOrder(@Param("orderId") String orderId);
|
|
||||||
|
|
||||||
/** 재고 원복(취소). */
|
|
||||||
@Update("""
|
|
||||||
UPDATE ticket_product
|
|
||||||
SET sold_qty = GREATEST(0, sold_qty - #{qty})
|
|
||||||
WHERE tenant_id = 'KINTEX' AND id = #{productId}
|
|
||||||
""")
|
|
||||||
void restock(@Param("productId") String productId, @Param("qty") int qty);
|
|
||||||
|
|
||||||
/** 취소 산정 스냅샷 저장(감사). */
|
|
||||||
@Insert("""
|
|
||||||
INSERT INTO ticket_cancellation
|
|
||||||
(tenant_id, id, order_id, order_no, days_before, ticket_amount, booking_fee,
|
|
||||||
refund_rate, admin_fee, refund_amount, forfeit_amount, policy_version)
|
|
||||||
VALUES
|
|
||||||
('KINTEX', #{id}, #{orderId}, #{orderNo}, #{daysBefore}, #{ticketAmount}, #{bookingFee},
|
|
||||||
#{refundRate}, #{adminFee}, #{refundAmount}, #{forfeitAmount}, #{policyVersion})
|
|
||||||
""")
|
|
||||||
void insertCancellation(Map<String, Object> row);
|
|
||||||
}
|
|
||||||
@ -1,181 +0,0 @@
|
|||||||
package com.zioinfo.kintex.ticket.cancel;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
|
||||||
import com.zioinfo.kintex.ticket.cancel.TicketCancelDtos.BracketDto;
|
|
||||||
import com.zioinfo.kintex.ticket.cancel.TicketCancelDtos.RefundQuoteDto;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HexFormat;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B2 티켓 취소·환불 서비스 — 다구간 취소수수료 + 예매수수료 별도 규정, 서버 권위 산출.
|
|
||||||
*
|
|
||||||
* <p>예상 환불({@link #quote})은 조회만(멱등), 실제 취소({@link #cancel})는 상태 전이·재고 원복·스냅샷 저장.
|
|
||||||
* 금액 산식은 {@link TicketCancelCalc}(순수 함수) 위임 — 클라이언트 금액을 신뢰하지 않는다.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class TicketCancelService {
|
|
||||||
|
|
||||||
private static final String DEFAULT_POLICY = "__default__";
|
|
||||||
|
|
||||||
private final TicketCancelMapper mapper;
|
|
||||||
|
|
||||||
public TicketCancelService(TicketCancelMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 예상 환불 조회(취소 전) — 본인확인(예매번호+연락처). */
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public RefundQuoteDto quote(String orderNo, String contact) {
|
|
||||||
return build(loadOrder(orderNo, contact), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 실제 취소 처리 — 상태 CANCELLED 전이·재고 원복·환불 스냅샷 저장. */
|
|
||||||
@Transactional
|
|
||||||
public RefundQuoteDto cancel(String orderNo, String contact) {
|
|
||||||
Map<String, Object> order = loadOrder(orderNo, contact);
|
|
||||||
String orderId = str(order.get("id"));
|
|
||||||
if (!"PAID".equals(str(order.get("status")))) {
|
|
||||||
throw new ApiException(ErrorCode.CONFLICT, "취소할 수 없는 예매입니다(이미 취소되었거나 사용됨).");
|
|
||||||
}
|
|
||||||
if (mapper.countCancellation(orderId) > 0) {
|
|
||||||
throw new ApiException(ErrorCode.CONFLICT, "이미 취소 처리된 예매입니다.");
|
|
||||||
}
|
|
||||||
RefundQuoteDto q = build(order, true);
|
|
||||||
|
|
||||||
int updated = mapper.cancelOrder(orderId);
|
|
||||||
if (updated != 1) {
|
|
||||||
throw new ApiException(ErrorCode.CONFLICT, "취소 처리에 실패했습니다. 다시 시도해 주세요.");
|
|
||||||
}
|
|
||||||
mapper.restock(str(order.get("productId")), toInt(order.get("qty"), 0));
|
|
||||||
|
|
||||||
Map<String, Object> row = new java.util.HashMap<>();
|
|
||||||
row.put("id", "tc-" + UUID.randomUUID());
|
|
||||||
row.put("orderId", orderId);
|
|
||||||
row.put("orderNo", q.orderNo());
|
|
||||||
row.put("daysBefore", q.daysBefore());
|
|
||||||
row.put("ticketAmount", q.ticketAmount());
|
|
||||||
row.put("bookingFee", q.bookingFee());
|
|
||||||
row.put("refundRate", q.refundRatePercent());
|
|
||||||
row.put("adminFee", q.adminFee());
|
|
||||||
row.put("refundAmount", q.refundAmount());
|
|
||||||
row.put("forfeitAmount", q.forfeitAmount());
|
|
||||||
row.put("policyVersion", q.policyVersion());
|
|
||||||
mapper.insertCancellation(row);
|
|
||||||
|
|
||||||
// 상태를 CANCELLED 로 갱신해 응답.
|
|
||||||
return new RefundQuoteDto(q.orderNo(), "CANCELLED", q.productName(), q.qty(),
|
|
||||||
q.ticketAmount(), q.bookingFee(), q.daysBefore(), q.refundRatePercent(), q.bracketLabel(),
|
|
||||||
q.ticketRefund(), q.adminFee(), q.bookingFeeRefund(), q.refundAmount(), q.forfeitAmount(),
|
|
||||||
q.policyVersion(), false, q.brackets());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 내부 산정 ──
|
|
||||||
private RefundQuoteDto build(Map<String, Object> order, boolean applying) {
|
|
||||||
String eventId = str(order.get("eventId"));
|
|
||||||
int qty = toInt(order.get("qty"), 0);
|
|
||||||
long ticketAmount = toLong(order.get("totalAmount"), 0);
|
|
||||||
|
|
||||||
// 정책 로드(행사 우선 → 기본).
|
|
||||||
Map<String, Object> policy = mapper.findFeePolicy(eventId);
|
|
||||||
if (policy == null) {
|
|
||||||
policy = mapper.findFeePolicy(DEFAULT_POLICY);
|
|
||||||
}
|
|
||||||
long bookingFeePerTicket = policy == null ? 0 : toLong(policy.get("bookingFeePerTicket"), 0);
|
|
||||||
int bookingFeeRefundMinDays = policy == null ? 7 : toInt(policy.get("bookingFeeRefundMinDays"), 7);
|
|
||||||
double adminPct = policy == null ? 0 : toDouble(policy.get("cancelAdminFeePercent"), 0);
|
|
||||||
String version = policy == null ? "cancel-v1.0" : str(policy.getOrDefault("version", "cancel-v1.0"));
|
|
||||||
long bookingFee = bookingFeePerTicket * qty;
|
|
||||||
|
|
||||||
// 구간 로드(행사 특화 → 기본).
|
|
||||||
List<Map<String, Object>> raw = mapper.findEventBrackets(eventId);
|
|
||||||
if (raw == null || raw.isEmpty()) {
|
|
||||||
raw = mapper.findDefaultBrackets();
|
|
||||||
}
|
|
||||||
List<TicketCancelCalc.Bracket> brackets = new ArrayList<>();
|
|
||||||
List<BracketDto> bracketDtos = new ArrayList<>();
|
|
||||||
for (Map<String, Object> b : raw) {
|
|
||||||
int min = toInt(b.get("minDaysBefore"), 0);
|
|
||||||
double rate = toDouble(b.get("refundRatePercent"), 0);
|
|
||||||
String label = str(b.get("label"));
|
|
||||||
brackets.add(new TicketCancelCalc.Bracket(min, rate, label));
|
|
||||||
bracketDtos.add(new BracketDto(min, rate, label));
|
|
||||||
}
|
|
||||||
|
|
||||||
int daysBefore = daysBefore(eventId);
|
|
||||||
TicketCancelCalc.Result r = TicketCancelCalc.compute(
|
|
||||||
daysBefore, ticketAmount, bookingFee, brackets, bookingFeeRefundMinDays, adminPct);
|
|
||||||
|
|
||||||
boolean cancellable = "PAID".equals(str(order.get("status")));
|
|
||||||
return new RefundQuoteDto(
|
|
||||||
str(order.get("orderNo")), str(order.get("status")), str(order.get("productName")), qty,
|
|
||||||
r.ticketAmount(), r.bookingFee(), r.daysBefore(), r.refundRatePercent(), r.bracketLabel(),
|
|
||||||
r.ticketRefund(), r.adminFee(), r.bookingFeeRefund(), r.refundAmount(), r.forfeitAmount(),
|
|
||||||
version, cancellable, bracketDtos);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> loadOrder(String orderNo, String contact) {
|
|
||||||
if (blank(orderNo) || blank(contact)) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "예매번호와 연락처를 입력해 주세요.");
|
|
||||||
}
|
|
||||||
Map<String, Object> order = mapper.findOrderForCancel(orderNo.trim(), contactHash(contact));
|
|
||||||
if (order == null) {
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND, "일치하는 예매 내역이 없습니다.");
|
|
||||||
}
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 개막까지 남은 일수 — 미확정이면 0(환불 불가 안전값). */
|
|
||||||
private int daysBefore(String eventId) {
|
|
||||||
String start = mapper.findEventStartDate(eventId);
|
|
||||||
if (start == null || start.isBlank()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
LocalDate s = LocalDate.parse(start, DateTimeFormatter.ISO_LOCAL_DATE);
|
|
||||||
return (int) (s.toEpochDay() - LocalDate.now().toEpochDay());
|
|
||||||
} catch (Exception e) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ──
|
|
||||||
private static boolean blank(String s) {
|
|
||||||
return s == null || s.isBlank();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String contactHash(String contact) {
|
|
||||||
try {
|
|
||||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
|
||||||
return HexFormat.of().formatHex(md.digest(contact.replaceAll("[^0-9]", "").getBytes(StandardCharsets.UTF_8)));
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new ApiException(ErrorCode.INTERNAL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String str(Object o) {
|
|
||||||
return o == null ? null : o.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int toInt(Object o, int def) {
|
|
||||||
return o instanceof Number n ? n.intValue() : def;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long toLong(Object o, long def) {
|
|
||||||
return o instanceof Number n ? n.longValue() : def;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double toDouble(Object o, double def) {
|
|
||||||
return o instanceof Number n ? n.doubleValue() : def;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
package com.zioinfo.kintex.ticket.smart;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
|
||||||
import com.zioinfo.kintex.ticket.smart.SmartTicketDtos.ActivateRequest;
|
|
||||||
import com.zioinfo.kintex.ticket.smart.SmartTicketDtos.ActivateResultDto;
|
|
||||||
import com.zioinfo.kintex.ticket.smart.SmartTicketDtos.RotatingTokenDto;
|
|
||||||
import com.zioinfo.kintex.ticket.smart.SmartTicketDtos.VerifyRequest;
|
|
||||||
import com.zioinfo.kintex.ticket.smart.SmartTicketDtos.VerifyResultDto;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
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.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B1 스마트티켓(회전 QR) 공개 API — 전 경로 {@code /api/public/**} permitAll.
|
|
||||||
* <p>보안: 응답에 secret·deviceId 원문 미포함. 검증은 실패도 200(valid=false)로 정보 노출 최소화.
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/public/tickets/smart")
|
|
||||||
public class SmartTicketController {
|
|
||||||
|
|
||||||
private final SmartTicketService service;
|
|
||||||
|
|
||||||
public SmartTicketController(SmartTicketService service) {
|
|
||||||
this.service = service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /activate — 예매번호+연락처 본인확인 후 티켓을 기기에 바인딩. */
|
|
||||||
@PostMapping("/activate")
|
|
||||||
public ApiResponse<ActivateResultDto> activate(@Valid @RequestBody ActivateRequest req) {
|
|
||||||
return ApiResponse.ok(service.activate(req.orderNo(), req.contact(), req.ticketCode(), req.deviceId()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /token?ticketCode=&deviceId= — 바인딩 기기의 현재 회전 토큰(QR 페이로드). */
|
|
||||||
@GetMapping("/token")
|
|
||||||
public ApiResponse<RotatingTokenDto> token(@RequestParam String ticketCode,
|
|
||||||
@RequestParam String deviceId) {
|
|
||||||
return ApiResponse.ok(service.currentToken(ticketCode, deviceId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /verify — 게이트 스캐너 QR 검증(회전 토큰 유효성). */
|
|
||||||
@PostMapping("/verify")
|
|
||||||
public ApiResponse<VerifyResultDto> verify(@Valid @RequestBody VerifyRequest req) {
|
|
||||||
return ApiResponse.ok(service.verify(req.qrPayload()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
package com.zioinfo.kintex.ticket.smart;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B1 스마트티켓(회전 QR) DTO. 전 경로 공개(/api/public/**) — 티켓은 비로그인 예매.
|
|
||||||
* <p>보안(§0-3): deviceId 원문 미보관(sha256 해시만) · secret 미노출. 응답 티켓코드 외 PII 없음.
|
|
||||||
*/
|
|
||||||
public final class SmartTicketDtos {
|
|
||||||
|
|
||||||
private SmartTicketDtos() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 스마트티켓 활성화(디바이스 바인딩) — 예매번호+연락처 본인확인 후 특정 기기에 바인딩. */
|
|
||||||
public record ActivateRequest(
|
|
||||||
@NotBlank String orderNo,
|
|
||||||
@NotBlank String contact,
|
|
||||||
@NotBlank String ticketCode,
|
|
||||||
@NotBlank String deviceId) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 활성화 결과. */
|
|
||||||
public record ActivateResultDto(String ticketCode, boolean activated, int windowSeconds) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 회전 토큰 발급 결과 — QR 페이로드 = {@code ticketCode|token}. */
|
|
||||||
public record RotatingTokenDto(
|
|
||||||
String ticketCode,
|
|
||||||
String token,
|
|
||||||
String qrPayload,
|
|
||||||
int windowSeconds,
|
|
||||||
int remainingSeconds) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 게이트 검증 요청 — 스캐너가 읽은 QR 페이로드(ticketCode|token). */
|
|
||||||
public record VerifyRequest(@NotBlank String qrPayload) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 검증 결과. */
|
|
||||||
public record VerifyResultDto(boolean valid, String ticketCode, String reason) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
package com.zioinfo.kintex.ticket.smart;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
import org.apache.ibatis.annotations.Update;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B1 스마트티켓(회전 QR) 매퍼 — 주문·발권 대조 + 디바이스 바인딩 upsert/조회.
|
|
||||||
* <p>★ camelCase 별칭은 반드시 쌍따옴표({@code AS "x"})(PG lower-fold 방지).
|
|
||||||
* <p>보안(§0-3): device_hash·secret 는 서버 전용. 조회 응답에 secret 을 노출하지 않는다(서비스가 통제).
|
|
||||||
*/
|
|
||||||
@Mapper
|
|
||||||
public interface SmartTicketMapper {
|
|
||||||
|
|
||||||
/** 주문번호 + 연락처 해시 대조 → 주문 id(둘 다 일치해야). 없으면 null. */
|
|
||||||
@Select("""
|
|
||||||
SELECT id AS "orderId"
|
|
||||||
FROM ticket_order
|
|
||||||
WHERE order_no = #{orderNo} AND buyer_contact_hash = #{contactHash}
|
|
||||||
LIMIT 1
|
|
||||||
""")
|
|
||||||
Map<String, Object> findOrderIdForLookup(@Param("orderNo") String orderNo,
|
|
||||||
@Param("contactHash") String contactHash);
|
|
||||||
|
|
||||||
/** 발권 티켓이 해당 주문 소속인지(대조). 1이면 소속. */
|
|
||||||
@Select("""
|
|
||||||
SELECT count(*)
|
|
||||||
FROM ticket_issue
|
|
||||||
WHERE tenant_id = 'KINTEX' AND order_id = #{orderId} AND ticket_code = #{ticketCode}
|
|
||||||
""")
|
|
||||||
int ticketBelongsToOrder(@Param("orderId") String orderId, @Param("ticketCode") String ticketCode);
|
|
||||||
|
|
||||||
/** 바인딩 upsert — 디바이스 재바인딩(기기 변경) 허용, secret 재발급으로 이전 기기 토큰 무효화. */
|
|
||||||
@Update("""
|
|
||||||
INSERT INTO ticket_smart_binding (tenant_id, ticket_code, device_hash, secret)
|
|
||||||
VALUES ('KINTEX', #{ticketCode}, #{deviceHash}, #{secret})
|
|
||||||
ON CONFLICT (tenant_id, ticket_code)
|
|
||||||
DO UPDATE SET device_hash = EXCLUDED.device_hash,
|
|
||||||
secret = EXCLUDED.secret,
|
|
||||||
bound_at = now()
|
|
||||||
""")
|
|
||||||
void upsertBinding(@Param("ticketCode") String ticketCode,
|
|
||||||
@Param("deviceHash") String deviceHash,
|
|
||||||
@Param("secret") String secret);
|
|
||||||
|
|
||||||
/** 바인딩 조회(서비스 전용 — secret 포함). 없으면 null. */
|
|
||||||
@Select("""
|
|
||||||
SELECT ticket_code AS "ticketCode",
|
|
||||||
device_hash AS "deviceHash",
|
|
||||||
secret AS "secret"
|
|
||||||
FROM ticket_smart_binding
|
|
||||||
WHERE tenant_id = 'KINTEX' AND ticket_code = #{ticketCode}
|
|
||||||
""")
|
|
||||||
Map<String, Object> findBinding(@Param("ticketCode") String ticketCode);
|
|
||||||
|
|
||||||
/** 검증 시 사용 마킹(감사). */
|
|
||||||
@Update("UPDATE ticket_smart_binding SET last_used_at = now() WHERE tenant_id='KINTEX' AND ticket_code = #{ticketCode}")
|
|
||||||
void markUsed(@Param("ticketCode") String ticketCode);
|
|
||||||
}
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
package com.zioinfo.kintex.ticket.smart;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
|
||||||
import com.zioinfo.kintex.ticket.smart.SmartTicketDtos.ActivateResultDto;
|
|
||||||
import com.zioinfo.kintex.ticket.smart.SmartTicketDtos.RotatingTokenDto;
|
|
||||||
import com.zioinfo.kintex.ticket.smart.SmartTicketDtos.VerifyResultDto;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.util.HexFormat;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B1 스마트티켓 서비스 — 디바이스 바인딩 + 30초 회전 QR 토큰 발급/검증.
|
|
||||||
*
|
|
||||||
* <p>흐름: ①activate(예매번호+연락처 본인확인 → 티켓을 기기에 바인딩, 서버 시크릿 발급)
|
|
||||||
* → ②token(바인딩 기기에서만 현재 회전 토큰 발급) → ③verify(게이트 스캐너가 QR 검증).
|
|
||||||
* 캡처 스크린샷은 토큰 회전으로 30초(+직전 1창) 후 무효 → 양도/재사용 차단.
|
|
||||||
*
|
|
||||||
* <p>보안(§0-3): deviceId 원문 미보관(sha256), secret 은 응답·로그에 절대 노출하지 않는다.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class SmartTicketService {
|
|
||||||
|
|
||||||
/** 검증 유예 창 수(현재+직전 N) — 전송/시계 오차 흡수. */
|
|
||||||
private static final int VERIFY_WINDOW_BACK = 1;
|
|
||||||
|
|
||||||
private final SmartTicketMapper mapper;
|
|
||||||
private final SecureRandom random = new SecureRandom();
|
|
||||||
|
|
||||||
public SmartTicketService(SmartTicketMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 스마트티켓 활성화 — 본인확인(예매번호+연락처) 후 발권 티켓을 기기에 바인딩. */
|
|
||||||
@Transactional
|
|
||||||
public ActivateResultDto activate(String orderNo, String contact, String ticketCode, String deviceId) {
|
|
||||||
if (blank(orderNo) || blank(contact) || blank(ticketCode) || blank(deviceId)) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "활성화에 필요한 정보가 부족합니다.");
|
|
||||||
}
|
|
||||||
Map<String, Object> order = mapper.findOrderIdForLookup(orderNo.trim(), contactHash(contact));
|
|
||||||
if (order == null) {
|
|
||||||
// 존재 여부 노출 금지 — 불일치도 NOT_FOUND 통일.
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND, "예매 정보를 확인할 수 없습니다. 예매번호와 연락처를 확인해 주세요.");
|
|
||||||
}
|
|
||||||
String orderId = String.valueOf(order.get("orderId"));
|
|
||||||
if (mapper.ticketBelongsToOrder(orderId, ticketCode.trim()) != 1) {
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND, "해당 예매의 티켓이 아닙니다.");
|
|
||||||
}
|
|
||||||
String secret = newSecret();
|
|
||||||
mapper.upsertBinding(ticketCode.trim(), deviceHash(deviceId), secret);
|
|
||||||
return new ActivateResultDto(ticketCode.trim(), true, Totp.STEP_SECONDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 회전 토큰 발급 — 바인딩된 기기에서만. QR 페이로드 = ticketCode|token. */
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public RotatingTokenDto currentToken(String ticketCode, String deviceId) {
|
|
||||||
if (blank(ticketCode) || blank(deviceId)) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "티켓/기기 정보가 필요합니다.");
|
|
||||||
}
|
|
||||||
Map<String, Object> b = mapper.findBinding(ticketCode.trim());
|
|
||||||
if (b == null) {
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND, "활성화되지 않은 티켓입니다. 스마트티켓을 먼저 활성화해 주세요.");
|
|
||||||
}
|
|
||||||
if (!deviceHash(deviceId).equals(String.valueOf(b.get("deviceHash")))) {
|
|
||||||
// 다른 기기 — 양도/캡처 방지. 존재 노출 없이 권한 거절.
|
|
||||||
throw new ApiException(ErrorCode.FORBIDDEN, "이 기기에서 발급할 수 없는 티켓입니다.");
|
|
||||||
}
|
|
||||||
long now = nowSeconds();
|
|
||||||
String token = Totp.code(String.valueOf(b.get("secret")), now);
|
|
||||||
return new RotatingTokenDto(
|
|
||||||
ticketCode.trim(), token, ticketCode.trim() + "|" + token,
|
|
||||||
Totp.STEP_SECONDS, Totp.remainingSeconds(now));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 게이트 검증 — QR 페이로드(ticketCode|token)의 회전 토큰 유효성. */
|
|
||||||
@Transactional
|
|
||||||
public VerifyResultDto verify(String qrPayload) {
|
|
||||||
if (blank(qrPayload) || !qrPayload.contains("|")) {
|
|
||||||
return new VerifyResultDto(false, null, "형식이 올바르지 않은 코드입니다.");
|
|
||||||
}
|
|
||||||
int sep = qrPayload.lastIndexOf('|');
|
|
||||||
String ticketCode = qrPayload.substring(0, sep).trim();
|
|
||||||
String token = qrPayload.substring(sep + 1).trim();
|
|
||||||
Map<String, Object> b = mapper.findBinding(ticketCode);
|
|
||||||
if (b == null) {
|
|
||||||
return new VerifyResultDto(false, null, "미등록 또는 만료된 티켓입니다.");
|
|
||||||
}
|
|
||||||
boolean ok = Totp.verify(String.valueOf(b.get("secret")), token, nowSeconds(), VERIFY_WINDOW_BACK);
|
|
||||||
if (!ok) {
|
|
||||||
return new VerifyResultDto(false, ticketCode, "만료된 코드입니다. 앱에서 최신 QR을 다시 제시해 주세요.");
|
|
||||||
}
|
|
||||||
mapper.markUsed(ticketCode);
|
|
||||||
return new VerifyResultDto(true, ticketCode, "정상 입장");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ──
|
|
||||||
protected long nowSeconds() {
|
|
||||||
return System.currentTimeMillis() / 1000L;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String newSecret() {
|
|
||||||
byte[] b = new byte[24];
|
|
||||||
random.nextBytes(b);
|
|
||||||
return HexFormat.of().formatHex(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String deviceHash(String deviceId) {
|
|
||||||
return sha256Hex(deviceId.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String contactHash(String contact) {
|
|
||||||
return sha256Hex(contact.replaceAll("[^0-9]", ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean blank(String s) {
|
|
||||||
return s == null || s.isBlank();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String sha256Hex(String s) {
|
|
||||||
try {
|
|
||||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
|
||||||
return HexFormat.of().formatHex(md.digest(s.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new ApiException(ErrorCode.INTERNAL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
package com.zioinfo.kintex.ticket.smart;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import javax.crypto.Mac;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-B1 회전 QR 토큰(TOTP 계열, 순수 함수 — 단위테스트 대상).
|
|
||||||
*
|
|
||||||
* <p>토큰 = base32(HMAC-SHA256(secret, floor(epochSeconds / STEP))[0..5]) 8자.
|
|
||||||
* 30초 스텝마다 토큰이 회전하므로 캡처(스크린샷)한 토큰은 해당 창(+직전 1창 유예)에서만 유효하다 →
|
|
||||||
* 양도/재사용 차단. 시크릿은 서버 전용(응답·로그 미노출)이며, 클라이언트에는 현재 토큰만 내려간다.
|
|
||||||
*/
|
|
||||||
public final class Totp {
|
|
||||||
|
|
||||||
/** 회전 주기(초). */
|
|
||||||
public static final int STEP_SECONDS = 30;
|
|
||||||
|
|
||||||
private static final char[] B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray();
|
|
||||||
|
|
||||||
private Totp() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 지정 시각(epoch 초)의 회전 토큰(8자). */
|
|
||||||
public static String code(String secret, long epochSeconds) {
|
|
||||||
long counter = Math.floorDiv(epochSeconds, STEP_SECONDS);
|
|
||||||
byte[] mac = hmac(secret, counter);
|
|
||||||
StringBuilder sb = new StringBuilder(8);
|
|
||||||
for (int i = 0; i < 5; i++) {
|
|
||||||
sb.append(B32[(mac[i] & 0xFF) % 32]);
|
|
||||||
}
|
|
||||||
// 5바이트 → 8자 채움(추가 3자는 후속 바이트 저비트).
|
|
||||||
for (int i = 0; i < 3; i++) {
|
|
||||||
sb.append(B32[(mac[5 + i] & 0x1F)]);
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 남은 초(현재 창 만료까지). */
|
|
||||||
public static int remainingSeconds(long epochSeconds) {
|
|
||||||
return STEP_SECONDS - (int) Math.floorMod(epochSeconds, STEP_SECONDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 토큰 유효성 — 현재 창과 직전 {@code windowBack}개 창을 허용(시계 오차·전송 지연 유예).
|
|
||||||
* 대소문자·공백 무시.
|
|
||||||
*/
|
|
||||||
public static boolean verify(String secret, String token, long epochSeconds, int windowBack) {
|
|
||||||
if (secret == null || token == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String t = token.trim().toUpperCase(java.util.Locale.ROOT);
|
|
||||||
for (int w = 0; w <= Math.max(0, windowBack); w++) {
|
|
||||||
if (code(secret, epochSeconds - (long) w * STEP_SECONDS).equals(t)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] hmac(String secret, long counter) {
|
|
||||||
try {
|
|
||||||
Mac mac = Mac.getInstance("HmacSHA256");
|
|
||||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
|
||||||
return mac.doFinal(ByteBuffer.allocate(Long.BYTES).putLong(counter).array());
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException("HMAC 산출 실패", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
package com.zioinfo.kintex.visitor;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인증 관람객 컨트롤러 공용 — JWT 주체(userId) 필수 추출. 미로그인 시 UNAUTHORIZED.
|
|
||||||
* <p>관람객 개인화 기능(멤버십·컨택트·결제수단·피드·알림·RSVP)은 본인(userId) 소유분만 접근한다.
|
|
||||||
*/
|
|
||||||
public final class VisitorAuth {
|
|
||||||
|
|
||||||
private VisitorAuth() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String requireUserId(KintexPrincipal principal) {
|
|
||||||
if (principal == null || principal.userId() == null || principal.userId().isBlank()) {
|
|
||||||
throw new ApiException(ErrorCode.UNAUTHORIZED, "로그인이 필요합니다.");
|
|
||||||
}
|
|
||||||
return principal.userId();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
package com.zioinfo.kintex.visitor.contact;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
|
||||||
import com.zioinfo.kintex.visitor.VisitorAuth;
|
|
||||||
import com.zioinfo.kintex.visitor.contact.ContactDtos.ContactDto;
|
|
||||||
import com.zioinfo.kintex.visitor.contact.ContactDtos.ExchangeRequest;
|
|
||||||
import com.zioinfo.kintex.visitor.contact.ContactDtos.MyCardDto;
|
|
||||||
import com.zioinfo.kintex.visitor.contact.ContactDtos.SaveCardRequest;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-C4 관람객 QR 명함 교환·컨택트 지갑 API — 인증(본인 지갑만).
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/visitor/contacts")
|
|
||||||
public class ContactController {
|
|
||||||
|
|
||||||
private final ContactService service;
|
|
||||||
|
|
||||||
public ContactController(ContactService service) {
|
|
||||||
this.service = service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /me/card — 내 명함(QR 공유 토큰). 없으면 자동 생성. */
|
|
||||||
@GetMapping("/me/card")
|
|
||||||
public ApiResponse<MyCardDto> myCard(@AuthenticationPrincipal KintexPrincipal principal) {
|
|
||||||
return ApiResponse.ok(service.myCard(VisitorAuth.requireUserId(principal), principal.displayName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PUT /me/card — 내 명함 저장/수정. */
|
|
||||||
@PutMapping("/me/card")
|
|
||||||
public ApiResponse<MyCardDto> saveCard(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@Valid @RequestBody SaveCardRequest req) {
|
|
||||||
return ApiResponse.ok(service.saveCard(VisitorAuth.requireUserId(principal), req));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /exchange — 상대 QR 스캔 교환(상호 저장). */
|
|
||||||
@PostMapping("/exchange")
|
|
||||||
public ApiResponse<ContactDto> exchange(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@Valid @RequestBody ExchangeRequest req) {
|
|
||||||
return ApiResponse.ok(service.exchange(
|
|
||||||
VisitorAuth.requireUserId(principal), principal.displayName(), req.qrPayload()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET / — 내 컨택트 지갑. */
|
|
||||||
@GetMapping
|
|
||||||
public ApiResponse<List<ContactDto>> list(@AuthenticationPrincipal KintexPrincipal principal) {
|
|
||||||
return ApiResponse.ok(service.list(VisitorAuth.requireUserId(principal)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
public ApiResponse<Void> delete(@AuthenticationPrincipal KintexPrincipal principal, @PathVariable String id) {
|
|
||||||
service.delete(VisitorAuth.requireUserId(principal), id);
|
|
||||||
return ApiResponse.ok(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
package com.zioinfo.kintex.visitor.contact;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-C4 관람객 명함/컨택트 지갑 DTO. 인증(본인 지갑만).
|
|
||||||
* <p>보안(§0-3): 연락처·이메일은 마스킹만 노출.
|
|
||||||
*/
|
|
||||||
public final class ContactDtos {
|
|
||||||
|
|
||||||
private ContactDtos() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 명함 저장 요청 — 연락처/이메일은 서버가 마스킹 후 저장. */
|
|
||||||
public record SaveCardRequest(
|
|
||||||
@NotBlank String displayName,
|
|
||||||
String company,
|
|
||||||
String title,
|
|
||||||
String email,
|
|
||||||
String phone) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 명함 카드(QR 공유 대상). qrPayload = CONTACT|shareToken. */
|
|
||||||
public record MyCardDto(
|
|
||||||
String displayName,
|
|
||||||
String company,
|
|
||||||
String title,
|
|
||||||
String emailMasked,
|
|
||||||
String phoneMasked,
|
|
||||||
String shareToken,
|
|
||||||
String qrPayload) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** QR 교환 요청 — 스캔한 상대 명함 QR 페이로드. */
|
|
||||||
public record ExchangeRequest(@NotBlank String qrPayload) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 저장된 컨택트. */
|
|
||||||
public record ContactDto(
|
|
||||||
String id,
|
|
||||||
String displayName,
|
|
||||||
String company,
|
|
||||||
String title,
|
|
||||||
String emailMasked,
|
|
||||||
String phoneMasked,
|
|
||||||
String memo,
|
|
||||||
String savedAt) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
package com.zioinfo.kintex.visitor.contact;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Delete;
|
|
||||||
import org.apache.ibatis.annotations.Insert;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
import org.apache.ibatis.annotations.Update;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-C4 관람객 명함/컨택트 지갑 매퍼. tenant_id='KINTEX' 고정. 본인(owner) 지갑만.
|
|
||||||
* <p>보안(§0-3): 연락처·이메일은 마스킹 저장(원문 미보관).
|
|
||||||
*/
|
|
||||||
@Mapper
|
|
||||||
public interface ContactMapper {
|
|
||||||
|
|
||||||
/** 내 명함 카드. 없으면 null. */
|
|
||||||
@Select("""
|
|
||||||
SELECT user_id AS "userId",
|
|
||||||
display_name AS "displayName",
|
|
||||||
company,
|
|
||||||
title,
|
|
||||||
email_masked AS "emailMasked",
|
|
||||||
phone_masked AS "phoneMasked",
|
|
||||||
share_token AS "shareToken"
|
|
||||||
FROM visitor_contact_card
|
|
||||||
WHERE tenant_id = 'KINTEX' AND user_id = #{userId}
|
|
||||||
""")
|
|
||||||
Map<String, Object> findMyCard(@Param("userId") String userId);
|
|
||||||
|
|
||||||
/** 명함 카드 upsert. */
|
|
||||||
@Update("""
|
|
||||||
INSERT INTO visitor_contact_card
|
|
||||||
(tenant_id, user_id, display_name, company, title, email_masked, phone_masked, share_token, updated_at)
|
|
||||||
VALUES
|
|
||||||
('KINTEX', #{userId}, #{displayName}, #{company}, #{title}, #{emailMasked}, #{phoneMasked}, #{shareToken}, now())
|
|
||||||
ON CONFLICT (tenant_id, user_id)
|
|
||||||
DO UPDATE SET display_name = EXCLUDED.display_name,
|
|
||||||
company = EXCLUDED.company,
|
|
||||||
title = EXCLUDED.title,
|
|
||||||
email_masked = EXCLUDED.email_masked,
|
|
||||||
phone_masked = EXCLUDED.phone_masked,
|
|
||||||
updated_at = now()
|
|
||||||
""")
|
|
||||||
void upsertMyCard(Map<String, Object> row);
|
|
||||||
|
|
||||||
/** 공유 토큰으로 상대 명함 조회(교환 매개). 없으면 null. */
|
|
||||||
@Select("""
|
|
||||||
SELECT user_id AS "userId",
|
|
||||||
display_name AS "displayName",
|
|
||||||
company,
|
|
||||||
title,
|
|
||||||
email_masked AS "emailMasked",
|
|
||||||
phone_masked AS "phoneMasked"
|
|
||||||
FROM visitor_contact_card
|
|
||||||
WHERE tenant_id = 'KINTEX' AND share_token = #{shareToken}
|
|
||||||
""")
|
|
||||||
Map<String, Object> findCardByShareToken(@Param("shareToken") String shareToken);
|
|
||||||
|
|
||||||
/** 컨택트 저장(교환). 중복(owner,contact)은 무시. */
|
|
||||||
@Insert("""
|
|
||||||
INSERT INTO visitor_contact
|
|
||||||
(tenant_id, id, owner_user_id, contact_user_id, display_name, company, title, email_masked, phone_masked, memo)
|
|
||||||
VALUES
|
|
||||||
('KINTEX', #{id}, #{ownerUserId}, #{contactUserId}, #{displayName}, #{company}, #{title}, #{emailMasked}, #{phoneMasked}, #{memo})
|
|
||||||
ON CONFLICT (owner_user_id, contact_user_id) DO NOTHING
|
|
||||||
""")
|
|
||||||
void insertContact(Map<String, Object> row);
|
|
||||||
|
|
||||||
/** 내 지갑 목록(최신순). */
|
|
||||||
@Select("""
|
|
||||||
SELECT id,
|
|
||||||
contact_user_id AS "contactUserId",
|
|
||||||
display_name AS "displayName",
|
|
||||||
company,
|
|
||||||
title,
|
|
||||||
email_masked AS "emailMasked",
|
|
||||||
phone_masked AS "phoneMasked",
|
|
||||||
memo,
|
|
||||||
to_char(saved_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "savedAt"
|
|
||||||
FROM visitor_contact
|
|
||||||
WHERE tenant_id = 'KINTEX' AND owner_user_id = #{userId}
|
|
||||||
ORDER BY saved_at DESC
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> listContacts(@Param("userId") String userId);
|
|
||||||
|
|
||||||
@Delete("DELETE FROM visitor_contact WHERE tenant_id='KINTEX' AND owner_user_id = #{userId} AND id = #{id}")
|
|
||||||
int deleteContact(@Param("userId") String userId, @Param("id") String id);
|
|
||||||
}
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
package com.zioinfo.kintex.visitor.contact;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
|
||||||
import com.zioinfo.kintex.common.text.Masking;
|
|
||||||
import com.zioinfo.kintex.visitor.contact.ContactDtos.ContactDto;
|
|
||||||
import com.zioinfo.kintex.visitor.contact.ContactDtos.MyCardDto;
|
|
||||||
import com.zioinfo.kintex.visitor.contact.ContactDtos.SaveCardRequest;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.util.HexFormat;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-C4 관람객 QR 명함 교환·컨택트 지갑 서비스.
|
|
||||||
*
|
|
||||||
* <p>흐름: ①내 명함 등록/조회(QR 공유 토큰) → ②상대 QR 스캔으로 교환(상호 지갑 저장) → ③지갑 목록/삭제.
|
|
||||||
* 리드캡처(업체→관람객)와 별개인 관람객 상호 교환.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class ContactService {
|
|
||||||
|
|
||||||
private static final String QR_PREFIX = "CONTACT|";
|
|
||||||
|
|
||||||
private final ContactMapper mapper;
|
|
||||||
private final SecureRandom random = new SecureRandom();
|
|
||||||
|
|
||||||
public ContactService(ContactMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 명함 조회 — 없으면 표시이름으로 최소 카드 자동 생성(공유 토큰 발급). */
|
|
||||||
@Transactional
|
|
||||||
public MyCardDto myCard(String userId, String fallbackDisplayName) {
|
|
||||||
Map<String, Object> card = mapper.findMyCard(userId);
|
|
||||||
if (card == null) {
|
|
||||||
String token = newShareToken();
|
|
||||||
Map<String, Object> row = new java.util.HashMap<>();
|
|
||||||
row.put("userId", userId);
|
|
||||||
row.put("displayName", fallbackDisplayName == null || fallbackDisplayName.isBlank()
|
|
||||||
? "관람객" : fallbackDisplayName);
|
|
||||||
row.put("company", null);
|
|
||||||
row.put("title", null);
|
|
||||||
row.put("emailMasked", null);
|
|
||||||
row.put("phoneMasked", null);
|
|
||||||
row.put("shareToken", token);
|
|
||||||
mapper.upsertMyCard(row);
|
|
||||||
card = mapper.findMyCard(userId);
|
|
||||||
}
|
|
||||||
return toCard(card);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 명함 저장/수정 — 연락처·이메일 마스킹 후 저장(원문 미보관). */
|
|
||||||
@Transactional
|
|
||||||
public MyCardDto saveCard(String userId, SaveCardRequest req) {
|
|
||||||
Map<String, Object> existing = mapper.findMyCard(userId);
|
|
||||||
String token = existing != null ? str(existing.get("shareToken")) : newShareToken();
|
|
||||||
Map<String, Object> row = new java.util.HashMap<>();
|
|
||||||
row.put("userId", userId);
|
|
||||||
row.put("displayName", req.displayName().trim());
|
|
||||||
row.put("company", blankToNull(req.company()));
|
|
||||||
row.put("title", blankToNull(req.title()));
|
|
||||||
row.put("emailMasked", req.email() == null || req.email().isBlank() ? null : Masking.maskEmail(req.email()));
|
|
||||||
row.put("phoneMasked", req.phone() == null || req.phone().isBlank() ? null : Masking.maskPhone(req.phone()));
|
|
||||||
row.put("shareToken", token);
|
|
||||||
mapper.upsertMyCard(row);
|
|
||||||
return toCard(mapper.findMyCard(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** QR 교환 — 상대 명함을 내 지갑에 저장하고, 내 명함도 상대 지갑에 저장(상호). */
|
|
||||||
@Transactional
|
|
||||||
public ContactDto exchange(String userId, String myDisplayName, String qrPayload) {
|
|
||||||
String token = parseToken(qrPayload);
|
|
||||||
Map<String, Object> other = mapper.findCardByShareToken(token);
|
|
||||||
if (other == null) {
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND, "명함을 찾을 수 없습니다. QR을 다시 확인해 주세요.");
|
|
||||||
}
|
|
||||||
String otherUserId = str(other.get("userId"));
|
|
||||||
if (userId.equals(otherUserId)) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "본인 명함은 교환할 수 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 내 지갑에 상대 저장.
|
|
||||||
String contactId = "vc-" + UUID.randomUUID();
|
|
||||||
Map<String, Object> mineRow = contactRow(contactId, userId, otherUserId, other);
|
|
||||||
mapper.insertContact(mineRow);
|
|
||||||
|
|
||||||
// 상대 지갑에 나(내 명함) 저장(상호). 내 명함이 없으면 자동 생성.
|
|
||||||
MyCardDto myCard = myCard(userId, myDisplayName);
|
|
||||||
Map<String, Object> myAsContact = new java.util.HashMap<>();
|
|
||||||
myAsContact.put("id", "vc-" + UUID.randomUUID());
|
|
||||||
myAsContact.put("ownerUserId", otherUserId);
|
|
||||||
myAsContact.put("contactUserId", userId);
|
|
||||||
myAsContact.put("displayName", myCard.displayName());
|
|
||||||
myAsContact.put("company", myCard.company());
|
|
||||||
myAsContact.put("title", myCard.title());
|
|
||||||
myAsContact.put("emailMasked", myCard.emailMasked());
|
|
||||||
myAsContact.put("phoneMasked", myCard.phoneMasked());
|
|
||||||
myAsContact.put("memo", null);
|
|
||||||
mapper.insertContact(myAsContact);
|
|
||||||
|
|
||||||
return new ContactDto(contactId, str(other.get("displayName")), str(other.get("company")),
|
|
||||||
str(other.get("title")), str(other.get("emailMasked")), str(other.get("phoneMasked")),
|
|
||||||
null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<ContactDto> list(String userId) {
|
|
||||||
return mapper.listContacts(userId).stream()
|
|
||||||
.map(m -> new ContactDto(str(m.get("id")), str(m.get("displayName")), str(m.get("company")),
|
|
||||||
str(m.get("title")), str(m.get("emailMasked")), str(m.get("phoneMasked")),
|
|
||||||
str(m.get("memo")), str(m.get("savedAt"))))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void delete(String userId, String id) {
|
|
||||||
if (mapper.deleteContact(userId, id) != 1) {
|
|
||||||
throw new ApiException(ErrorCode.NOT_FOUND, "컨택트를 찾을 수 없습니다.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ──
|
|
||||||
private static Map<String, Object> contactRow(String id, String owner, String contactUser, Map<String, Object> src) {
|
|
||||||
Map<String, Object> row = new java.util.HashMap<>();
|
|
||||||
row.put("id", id);
|
|
||||||
row.put("ownerUserId", owner);
|
|
||||||
row.put("contactUserId", contactUser);
|
|
||||||
row.put("displayName", src.get("displayName"));
|
|
||||||
row.put("company", src.get("company"));
|
|
||||||
row.put("title", src.get("title"));
|
|
||||||
row.put("emailMasked", src.get("emailMasked"));
|
|
||||||
row.put("phoneMasked", src.get("phoneMasked"));
|
|
||||||
row.put("memo", null);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MyCardDto toCard(Map<String, Object> m) {
|
|
||||||
String token = str(m.get("shareToken"));
|
|
||||||
return new MyCardDto(str(m.get("displayName")), str(m.get("company")), str(m.get("title")),
|
|
||||||
str(m.get("emailMasked")), str(m.get("phoneMasked")), token, QR_PREFIX + token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String parseToken(String qrPayload) {
|
|
||||||
if (qrPayload == null || qrPayload.isBlank()) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "QR 코드가 필요합니다.");
|
|
||||||
}
|
|
||||||
String p = qrPayload.trim();
|
|
||||||
return p.startsWith(QR_PREFIX) ? p.substring(QR_PREFIX.length()) : p;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String newShareToken() {
|
|
||||||
return "SHARE-" + HexFormat.of().formatHex(randomBytes()).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] randomBytes() {
|
|
||||||
byte[] b = new byte[8];
|
|
||||||
random.nextBytes(b);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String blankToNull(String s) {
|
|
||||||
return s == null || s.isBlank() ? null : s.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String str(Object o) {
|
|
||||||
return o == null ? null : o.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
package com.zioinfo.kintex.visitor.feed;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
|
||||||
import com.zioinfo.kintex.visitor.VisitorAuth;
|
|
||||||
import com.zioinfo.kintex.visitor.feed.FeedDtos.FeedItemDto;
|
|
||||||
import com.zioinfo.kintex.visitor.feed.FeedDtos.RecordViewRequest;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
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.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-C7 최근 본 행사·부스 피드 API — 인증(본인만).
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/visitor/feed")
|
|
||||||
public class FeedController {
|
|
||||||
|
|
||||||
private final FeedService service;
|
|
||||||
|
|
||||||
public FeedController(FeedService service) {
|
|
||||||
this.service = service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/visitor/feed/view — 상세 진입 조회 이력 기록. */
|
|
||||||
@PostMapping("/view")
|
|
||||||
public ApiResponse<Void> record(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@Valid @RequestBody RecordViewRequest req) {
|
|
||||||
service.record(VisitorAuth.requireUserId(principal), req);
|
|
||||||
return ApiResponse.ok(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/visitor/feed/recent?limit= — 최근 본 피드. */
|
|
||||||
@GetMapping("/recent")
|
|
||||||
public ApiResponse<List<FeedItemDto>> recent(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@RequestParam(defaultValue = "20") int limit) {
|
|
||||||
return ApiResponse.ok(service.recent(VisitorAuth.requireUserId(principal), limit));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
package com.zioinfo.kintex.visitor.feed;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-C7 최근 본 행사·부스 피드 DTO. 인증(본인만).
|
|
||||||
*/
|
|
||||||
public final class FeedDtos {
|
|
||||||
|
|
||||||
private FeedDtos() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 조회 이력 기록 요청 — 상세 진입 시 클라이언트가 호출. */
|
|
||||||
public record RecordViewRequest(
|
|
||||||
@NotBlank String itemType, // EVENT/BOOTH
|
|
||||||
@NotBlank String itemId,
|
|
||||||
@NotBlank String itemTitle,
|
|
||||||
String itemSub) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 최근 본 항목. */
|
|
||||||
public record FeedItemDto(
|
|
||||||
String id,
|
|
||||||
String itemType,
|
|
||||||
String itemId,
|
|
||||||
String itemTitle,
|
|
||||||
String itemSub,
|
|
||||||
String viewedAt) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
package com.zioinfo.kintex.visitor.feed;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Delete;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
import org.apache.ibatis.annotations.Update;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-C7 최근 본 행사·부스 피드 매퍼. tenant_id='KINTEX' 고정. 본인(user_id)만.
|
|
||||||
*/
|
|
||||||
@Mapper
|
|
||||||
public interface FeedMapper {
|
|
||||||
|
|
||||||
/** 조회 이력 upsert — 동일 (user,item) 재조회는 viewed_at·제목 갱신. */
|
|
||||||
@Update("""
|
|
||||||
INSERT INTO visitor_view_history
|
|
||||||
(tenant_id, id, user_id, item_type, item_id, item_title, item_sub, viewed_at)
|
|
||||||
VALUES
|
|
||||||
('KINTEX', #{id}, #{userId}, #{itemType}, #{itemId}, #{itemTitle}, #{itemSub}, now())
|
|
||||||
ON CONFLICT (user_id, item_type, item_id)
|
|
||||||
DO UPDATE SET item_title = EXCLUDED.item_title,
|
|
||||||
item_sub = EXCLUDED.item_sub,
|
|
||||||
viewed_at = now()
|
|
||||||
""")
|
|
||||||
void upsertView(Map<String, Object> row);
|
|
||||||
|
|
||||||
/** 최근 본 목록. */
|
|
||||||
@Select("""
|
|
||||||
SELECT id,
|
|
||||||
item_type AS "itemType",
|
|
||||||
item_id AS "itemId",
|
|
||||||
item_title AS "itemTitle",
|
|
||||||
item_sub AS "itemSub",
|
|
||||||
to_char(viewed_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "viewedAt"
|
|
||||||
FROM visitor_view_history
|
|
||||||
WHERE tenant_id = 'KINTEX' AND user_id = #{userId}
|
|
||||||
ORDER BY viewed_at DESC
|
|
||||||
LIMIT #{limit}
|
|
||||||
""")
|
|
||||||
List<Map<String, Object>> listRecent(@Param("userId") String userId, @Param("limit") int limit);
|
|
||||||
|
|
||||||
/** 상한 초과분 정리(최근 200건 유지). */
|
|
||||||
@Delete("""
|
|
||||||
DELETE FROM visitor_view_history
|
|
||||||
WHERE tenant_id = 'KINTEX' AND user_id = #{userId}
|
|
||||||
AND id NOT IN (
|
|
||||||
SELECT id FROM visitor_view_history
|
|
||||||
WHERE tenant_id = 'KINTEX' AND user_id = #{userId}
|
|
||||||
ORDER BY viewed_at DESC
|
|
||||||
LIMIT 200
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
void prune(@Param("userId") String userId);
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
package com.zioinfo.kintex.visitor.feed;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.common.error.ApiException;
|
|
||||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
|
||||||
import com.zioinfo.kintex.visitor.feed.FeedDtos.FeedItemDto;
|
|
||||||
import com.zioinfo.kintex.visitor.feed.FeedDtos.RecordViewRequest;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-C7 최근 본 행사·부스 피드 서비스 — 조회 이력 저장 + 개인화 홈 피드.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class FeedService {
|
|
||||||
|
|
||||||
private static final int DEFAULT_LIMIT = 20;
|
|
||||||
|
|
||||||
private final FeedMapper mapper;
|
|
||||||
|
|
||||||
public FeedService(FeedMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 상세 진입 조회 이력 기록(멱등 upsert). */
|
|
||||||
@Transactional
|
|
||||||
public void record(String userId, RecordViewRequest req) {
|
|
||||||
String type = normalizeType(req.itemType());
|
|
||||||
Map<String, Object> row = new java.util.HashMap<>();
|
|
||||||
row.put("id", "vvh-" + UUID.randomUUID());
|
|
||||||
row.put("userId", userId);
|
|
||||||
row.put("itemType", type);
|
|
||||||
row.put("itemId", req.itemId().trim());
|
|
||||||
row.put("itemTitle", req.itemTitle().trim());
|
|
||||||
row.put("itemSub", req.itemSub());
|
|
||||||
mapper.upsertView(row);
|
|
||||||
mapper.prune(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 최근 본 피드. */
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<FeedItemDto> recent(String userId, int limit) {
|
|
||||||
int lim = limit <= 0 || limit > 100 ? DEFAULT_LIMIT : limit;
|
|
||||||
return mapper.listRecent(userId, lim).stream()
|
|
||||||
.map(m -> new FeedItemDto(str(m.get("id")), str(m.get("itemType")), str(m.get("itemId")),
|
|
||||||
str(m.get("itemTitle")), str(m.get("itemSub")), str(m.get("viewedAt"))))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeType(String t) {
|
|
||||||
String u = t == null ? "" : t.trim().toUpperCase(Locale.ROOT);
|
|
||||||
if (!"EVENT".equals(u) && !"BOOTH".equals(u)) {
|
|
||||||
throw new ApiException(ErrorCode.VALIDATION, "지원하지 않는 항목 유형입니다.");
|
|
||||||
}
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String str(Object o) {
|
|
||||||
return o == null ? null : o.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
package com.zioinfo.kintex.visitor.membership;
|
|
||||||
|
|
||||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
|
||||||
import com.zioinfo.kintex.visitor.VisitorAuth;
|
|
||||||
import com.zioinfo.kintex.visitor.membership.MembershipDtos.MembershipDto;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-C6 관람객 멤버십 API — 인증(본인만).
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/visitor/membership")
|
|
||||||
public class MembershipController {
|
|
||||||
|
|
||||||
private final MembershipService service;
|
|
||||||
|
|
||||||
public MembershipController(MembershipService service) {
|
|
||||||
this.service = service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/visitor/membership — 내 등급·혜택. */
|
|
||||||
@GetMapping
|
|
||||||
public ApiResponse<MembershipDto> me(@AuthenticationPrincipal KintexPrincipal principal) {
|
|
||||||
return ApiResponse.ok(service.myMembership(VisitorAuth.requireUserId(principal)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/visitor/membership/buyer-verify?verified= — 바이어 인증 토글(데모). */
|
|
||||||
@PostMapping("/buyer-verify")
|
|
||||||
public ApiResponse<MembershipDto> buyerVerify(@AuthenticationPrincipal KintexPrincipal principal,
|
|
||||||
@RequestParam(defaultValue = "true") boolean verified) {
|
|
||||||
return ApiResponse.ok(service.setBuyerVerified(VisitorAuth.requireUserId(principal), verified));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
package com.zioinfo.kintex.visitor.membership;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F-C6 관람객 멤버십 DTO. 인증(본인만).
|
|
||||||
*/
|
|
||||||
public final class MembershipDtos {
|
|
||||||
|
|
||||||
private MembershipDtos() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 멤버십 — 등급·방문·혜택. tier/tierLabel 서버 산정. */
|
|
||||||
public record MembershipDto(
|
|
||||||
String tier, // GENERAL/BUYER/VIP
|
|
||||||
String tierLabel, // 일반/바이어/VIP
|
|
||||||
int visitCount,
|
|
||||||
boolean buyerVerified,
|
|
||||||
boolean vipGrant,
|
|
||||||
int points,
|
|
||||||
String lastVisitAt,
|
|
||||||
int visitsToNextTier,
|
|
||||||
List<String> benefits) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user