diff --git a/src/backend/build.gradle b/src/backend/build.gradle index 68f221b..b8a998c 100644 --- a/src/backend/build.gradle +++ b/src/backend/build.gradle @@ -49,6 +49,13 @@ dependencies { // --- 서류 PDF 생성(M6 G-05) — HTML→PDF 경량 렌더러. NanumGothic 폰트 임베드(한글 보존). --- 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 인증) --- implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}" runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardService.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardService.java index ddf9faa..ed9d17d 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardService.java @@ -2,6 +2,10 @@ package com.zioinfo.kintex.admin; import com.zioinfo.kintex.admin.dto.AdminDashboardDto; 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 org.springframework.stereotype.Service; @@ -12,14 +16,21 @@ import java.util.Map; /** * 관리자 대시보드 서비스 — 실 테이블 집계로 KPI·라이브 행사를 조립. * 관람객 추이는 센서/집계 인프라 부재 → 빈배열. 테넌트는 실 데이터(KINTEX)만 반환. + *

SCR-16 실전환: 라이브 행사에 M10 체크인 실인원·M14 혼잡 등급(OpsService 재사용, 수정 없이 호출만), + * 최근 활동은 AuditLogService(audit_log 실 기록) 재사용. */ @Service public class AdminDashboardService { private final AdminDashboardMapper mapper; + private final OpsService opsService; + private final AuditLogService auditLogService; - public AdminDashboardService(AdminDashboardMapper mapper) { + public AdminDashboardService(AdminDashboardMapper mapper, OpsService opsService, + AuditLogService auditLogService) { this.mapper = mapper; + this.opsService = opsService; + this.auditLogService = auditLogService; } public AdminDashboardDto getDashboard(String tenant) { @@ -45,9 +56,22 @@ public class AdminDashboardService { long booths = lng(r.get("boothCount")); long capacity = lng(r.get("capacity")); 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( - str(r.get("eventId")), str(r.get("name")), str(r.get("hall")), - occupancy, "ongoing")); + eventId, str(r.get("name")), str(r.get("hall")), + occupancy, "ongoing", onSite, heat)); } } @@ -55,7 +79,39 @@ public class AdminDashboardService { List visitorTrend = new ArrayList<>(); List tenants = List.of("KINTEX"); - return new AdminDashboardDto(kpis, visitorTrend, live, tenants); + return new AdminDashboardDto(kpis, visitorTrend, live, tenants, recentActivity()); + } + + /** 라이브 행사 혼잡 등급 = 배정 홀 중 최고 혼잡(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 recentActivity() { + List out = new ArrayList<>(); + try { + List 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; } /** diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/AdminDashboardDto.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/AdminDashboardDto.java index e13a836..ccee0dd 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/AdminDashboardDto.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/AdminDashboardDto.java @@ -3,14 +3,17 @@ package com.zioinfo.kintex.admin.dto; import java.util.List; /** - * 관리자 백오피스 대시보드 (SCR-14 / 06_backend_api_gaps §4). - * 집계원: event(진행/라이브)·hall_assignment·app_user·company. 관람객 추이는 센서/집계 부재 → 빈배열(정직). + * 관리자 백오피스 대시보드 (SCR-14/16 / 06_backend_api_gaps §4). + * 집계원: event(진행/라이브)·hall_assignment·app_user·company. + *

실전환(SCR-16, 2026-07): ①라이브 행사에 M10 실 체크인 인원(onSiteCount)·M14 혼잡 등급(heat, OpsService 재사용) 부가, + * ②최근 활동(recentActivity)은 audit_log 실 기록. 관람객 시간대 추이는 센서/집계 부재 → 빈배열(정직). */ public record AdminDashboardDto( List kpis, List visitorTrend, List liveEvents, - List tenants + List tenants, + List recentActivity ) { public record Kpi(String label, String value, String sub) { } @@ -19,7 +22,16 @@ public record AdminDashboardDto( public record VisitorPoint(String hour, long count) { } - /** 라이브(진행 중) 행사 — 점유율=부스수/홀 수용. */ - public record LiveEvent(String eventId, String name, String hall, int occupancy, String status) { + /** + * 라이브(진행 중) 행사 — 점유율=부스수/홀 수용(occupancy), + * 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) { } } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsMapper.java index 79bf812..4d205a7 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsMapper.java @@ -53,6 +53,27 @@ public interface AnalyticsMapper { """) Double findUtilityRevenue(@Param("eventId") String eventId); + /** + * 업종별 구성(sectors) — 참가업체(부스 배정사) 업종 분포. 원천 = booth.assigned_company_name + * ↔ company.name(대소문자·공백 무시 매칭) → company.category. 매칭 실패/미분류는 '기타' 버킷. + *

버킷별 부스 수·판매면적(㎡). 매출/점유율은 서비스가 면적×요율로 환산·정규화한다. + *

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> findSectorAggregation(@Param("eventId") String eventId); + // ── 테넌트 전역(행사 횡단) BI 집계 — M16 잔여 갭 ────────────────────────── // ★멀티테넌시 2단계: 행사 횡단 집계는 tenant_id(event/hall) 선행 필터로 격리(§1A-2 · §8-2). // booth/layout/utility_order는 tenant_id 컬럼이 없으므로 event 조인으로 간접 스코프(신규 컬럼 금지). diff --git a/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsService.java b/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsService.java index 6f22761..2d66c0a 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/analytics/AnalyticsService.java @@ -53,20 +53,46 @@ public class AnalyticsService { } } - // 손익(operator 관점) — 실 매출 항목만. 업종 분류 원천 부재 → sectors 미집계. + // 손익(operator 관점) — 실 매출 항목만. List pnl = new ArrayList<>(); - List sectors = new ArrayList<>(); if (!exhibitor) { pnl.add(new AnalyticsDto.PnlRow("부스 임대", rentalRevenue, grade(rentalRevenue))); pnl.add(new AnalyticsDto.PnlRow("유틸리티", utilityRevenue, grade(utilityRevenue))); } + // 업종별 구성(sectors) — 참가업체(부스 배정사) 업종(company.category) 실집계. 운영사/참가업체 공통. + List sectors = buildSectors(eventId, ratePerM2); + // 참가업체 성과 — 방문자 리드(M13) 원천 부재 → 미집계(빈배열). List exhibitorPerf = new ArrayList<>(); return new AnalyticsDto(kpis, trend, sectors, pnl, exhibitorPerf); } + /** + * 업종별 구성 집계 — 버킷별 판매면적을 매출(면적×요율)로 환산하고 전체 대비 점유율(%)로 정규화. + * 점유율 기준은 판매면적(=요율 단일 하 매출과 동률)이며 면적 내림차순(매퍼 정렬)을 보존한다. + * 원천(배정 부스) 부재 시 빈배열(정직) — 프론트는 빈 상태를 표시한다. + */ + private List buildSectors(String eventId, double ratePerM2) { + List out = new ArrayList<>(); + List> rows = mapper.findSectorAggregation(eventId); + if (rows == null || rows.isEmpty()) { + return out; + } + double totalArea = 0; + for (Map r : rows) { + totalArea += dbl(r.get("areaM2")); + } + for (Map 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 잔여 갭 ─────────────────────────────── /** diff --git a/src/backend/src/main/java/com/zioinfo/kintex/analytics/dto/AnalyticsDto.java b/src/backend/src/main/java/com/zioinfo/kintex/analytics/dto/AnalyticsDto.java index 6bd70af..2eb10f6 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/analytics/dto/AnalyticsDto.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/analytics/dto/AnalyticsDto.java @@ -5,7 +5,8 @@ import java.util.List; /** * 경영분석 BI 집계 (SCR-13 / 06_backend_api_gaps §3). * 집계원: booth 면적(ST_Area)×임대 요율(master_data)·utility_order.quote·booth 월별 추이. - * 업종(sectors)·리드(exhibitorPerf)는 원천(M13 방문자/업종 분류) 부재 → 빈배열(정직 반환). + * 업종(sectors)은 참가업체(booth.assigned_company_name↔company.category) 실집계. + * 리드(exhibitorPerf)는 원천(M13 방문자 분류) 부재 → 빈배열(정직 반환). */ public record AnalyticsDto( List kpis, diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java index e5499c2..6b02615 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java @@ -17,7 +17,7 @@ public interface CmsMapper { + """) + List> 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> findEventMemberUserIds(@Param("eventId") String eventId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/livenotice/LiveNoticeService.java b/src/backend/src/main/java/com/zioinfo/kintex/livenotice/LiveNoticeService.java new file mode 100644 index 0000000..de1f081 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/livenotice/LiveNoticeService.java @@ -0,0 +1,189 @@ +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) 서비스 — 주최자 발행 + 공개/관람객 조회 + 보관/고정. + * + *

§5B 알림 통합(중복 신설 금지): 발행 시 별도 per-user 푸시 인프라를 만들지 않고 기존 + * {@link NotificationService}(공통 알림 모듈)로 행사 멤버에게 in-app 알림을 팬아웃한다. 공개 피드는 본 + * 모듈의 {@code event_live_notice} 테이블이 정본(비로그인 관람객 대상 broadcast). + * + *

본문/제목은 {@link HtmlSanitizer}로 sanitize(XSS 방어). 발행/보관은 감사 로그를 남긴다. + */ +@Service +public class LiveNoticeService { + + private static final Logger log = LoggerFactory.getLogger(LiveNoticeService.class); + + private static final Set 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 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 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 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 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 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> members = mapper.findEventMemberUserIds(eventId); + if (members == null || members.isEmpty()) { + return; + } + String link = "/events/" + eventId + "/live-notices"; + int sent = 0; + for (Map 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 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); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/livenotice/PublicLiveNoticeController.java b/src/backend/src/main/java/com/zioinfo/kintex/livenotice/PublicLiveNoticeController.java new file mode 100644 index 0000000..a23f5c7 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/livenotice/PublicLiveNoticeController.java @@ -0,0 +1,33 @@ +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). + *

관람객·공개 사이트가 행사 진행 중 라이브 공지 피드를 조회한다. 게시(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> feed(@PathVariable String eventId, + @RequestParam(required = false, defaultValue = "100") int limit) { + return ApiResponse.ok(service.publicFeed(eventId, limit)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/livenotice/dto/LiveNoticeDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/livenotice/dto/LiveNoticeDtos.java new file mode 100644 index 0000000..da96466 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/livenotice/dto/LiveNoticeDtos.java @@ -0,0 +1,20 @@ +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) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/mail/MailTemplates.java b/src/backend/src/main/java/com/zioinfo/kintex/mail/MailTemplates.java index 7474bf2..92d289a 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/mail/MailTemplates.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/mail/MailTemplates.java @@ -43,6 +43,28 @@ public final class MailTemplates { + WRAP_FOOT; } + /** + * 티켓 오픈 알림(관심 행사 구독자 대상, F-C1) — 행사명 + 예매 안내 + 원클릭 수신거부 링크. + * 광고성 대량 발송이 아니라 사용자가 명시 구독한 트랜잭션성 알림이지만, 수신거부 링크는 항상 포함한다. + */ + public static String ticketOpen(String eventName, String bookUrl, String unsubUrl) { + String unsubLine = (unsubUrl == null || unsubUrl.isBlank()) + ? "" + : "

알림 수신을 원치 않으시면 " + + "수신거부 하실 수 있습니다.

"; + String cta = (bookUrl == null || bookUrl.isBlank()) + ? "" + : "

예매하러 가기

"; + return WRAP_HEAD + + "

관심 행사의 티켓 예매가 시작되었습니다

" + + "

행사 " + esc(eventName) + "

" + + "

구독하신 행사의 입장권 판매가 개시되었습니다. 조기 매진이 예상되니 서둘러 예매해 주세요.

" + + cta + + WRAP_FOOT.replace("", "") + unsubLine + ""; + } + /** EDM 광고 본문(수신거부 링크 없음 — 하위호환). 원클릭 수신거부는 {@link #edm(String, String, String)} 사용. */ public static String edm(String campaignName, String eventName) { return edm(campaignName, eventName, null); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanController.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanController.java index 6adf827..c4eff8c 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanController.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanController.java @@ -11,6 +11,7 @@ import com.zioinfo.kintex.module.m2.dto.LayoutDto; import com.zioinfo.kintex.module.m2.dto.LayoutInterpretRequest; import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult; 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.rules.ComplianceReport; import jakarta.validation.Valid; @@ -45,6 +46,15 @@ public class FloorplanController { return ApiResponse.ok(service.getLayout(eventId, hallId, version)); } + /** GET /versions — 배치안 버전 이력 목록(자동생성 proposal·draft·applied). 행사 멤버/홀매니저 열람. */ + @GetMapping("/versions") + public ApiResponse> versions(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @PathVariable String hallId) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.listVersions(eventId, hallId)); + } + /** PUT — 배치 저장(주최자). */ @PutMapping public ApiResponse save(@AuthenticationPrincipal KintexPrincipal principal, diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanService.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanService.java index 3e72c5b..7aa00db 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanService.java @@ -6,6 +6,7 @@ import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest; import com.zioinfo.kintex.module.m2.dto.LayoutDto; import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult; 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.rules.ComplianceReport; @@ -17,6 +18,9 @@ public interface FloorplanService { /** 배치안 조회(version=null이면 최신). */ LayoutDto getLayout(String eventId, String hallId, Integer version); + /** 배치안 버전 이력 목록(version·name·status·boothCount·updatedAt, 최신 순). */ + List listVersions(String eventId, String hallId); + /** 배치 저장 → 저장된 배치안. 저장 시 규정 검증도 함께 산출해 summary에 반영. */ LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java index 814e7fe..78d0512 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java @@ -16,6 +16,7 @@ import com.zioinfo.kintex.module.m2.dto.LayoutDto; import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult; import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest; 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.mapper.BoothMapper; import com.zioinfo.kintex.module.m2.mapper.HallMapper; @@ -85,20 +86,20 @@ public class FloorplanServiceImpl implements FloorplanService { @Override @Transactional public LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request) { - return persistBooths(eventId, hallId, request.name(), request.version(), request.booths()); + return persistBooths(eventId, hallId, request.name(), request.version(), request.booths(), "draft"); } @Override @Transactional public LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request) { - // 적용/병합은 항상 새 버전으로 저장(version=null → 다음 버전) 후 규정 재검증. + // 적용/병합은 항상 새 버전으로 저장(version=null → 다음 버전) 후 규정 재검증. 적용본은 status='applied'. String name = request.name() == null ? "자동배치 적용" : request.name(); - return persistBooths(eventId, hallId, name, null, request.booths()); + return persistBooths(eventId, hallId, name, null, request.booths(), "applied"); } - /** 배치안 upsert + 부스 폴리곤 교체 저장 → 규정 검증 반영된 LayoutDto. */ + /** 배치안 upsert + 부스 폴리곤 교체 저장 → 규정 검증 반영된 LayoutDto. status = draft|applied|proposal. */ private LayoutDto persistBooths(String eventId, String hallId, String name, - Integer requestedVersion, List booths) { + Integer requestedVersion, List booths, String status) { int version = requestedVersion != null ? requestedVersion : nextVersion(eventId, hallId); Map params = new LinkedHashMap<>(); @@ -106,7 +107,7 @@ public class FloorplanServiceImpl implements FloorplanService { params.put("hallId", hallId); params.put("version", version); params.put("name", name); - params.put("status", "draft"); + params.put("status", status); String layoutId = boothMapper.upsertLayout(params); List> boothRows = new ArrayList<>(); @@ -323,6 +324,9 @@ public class FloorplanServiceImpl implements FloorplanService { String label = "배치안 " + tag + (zoneLabel != null ? " · " + zoneLabel : ""); 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={}", eventId, hallId, request.zoneId(), options.size(), request.targetBoothCount(), ai); @@ -330,6 +334,41 @@ public class FloorplanServiceImpl implements FloorplanService { return ai ? attachAiSummaries(options, hall) : options; } + /** + * 생성 배치안을 status='proposal' layout 버전으로 영속화한다(응답 유실 방지, 배치 이력 조회 근거). + * best-effort — 개별 영속 실패는 무회귀(해당 안은 version 없이 반환)로 자동배치 응답 자체는 항상 성공. + * 적용(applyOption) 시 status='applied' 로 전이, 수동 저장(saveLayout)은 'draft'. + */ + private List persistProposals(String eventId, String hallId, List options) { + List 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 listVersions(String eventId, String hallId) { + List> rows = boothMapper.findLayoutVersions(eventId, hallId); + List out = new ArrayList<>(); + if (rows == null) { + return out; + } + for (Map 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 장단점 요약을 부가한다 — 엔진이 계산한 metrics만 근거(환각 차단). * 단일 AI 호출로 optionId→요약 JSON 을 받아 매핑한다. AI 미가용/파싱 실패 시 원본 옵션(요약 없음)을 그대로 반환. @@ -614,11 +653,9 @@ public class FloorplanServiceImpl implements FloorplanService { } private int nextVersion(String eventId, String hallId) { - Map latest = boothMapper.findLayout(eventId, hallId, null); - if (latest == null) { - return 1; - } - return intVal(latest.get("version")) + 1; + // proposal 포함 최대 버전 + 1 — 작업본 우선 조회(findLayout)와 분리해 버전 충돌 방지. + Integer max = boothMapper.maxVersion(eventId, hallId); + return (max == null ? 0 : max) + 1; } // --------------------------------------------------------- 좌표/형변환 ----- diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/AutoLayoutOption.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/AutoLayoutOption.java index 374b4f1..f6538f5 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/AutoLayoutOption.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/AutoLayoutOption.java @@ -13,6 +13,7 @@ import java.util.List; * @param booths 후보 부스 배열 — "이 안으로 편집 시작"/병합 소스(계약 확장, 프론트 gap #3 해소, NON_NULL) * @param s7RenderJobId S7 홀 전경(조감) 생성 잡 ID — 완료 시 WebSocket 푸시로 카드 이미지 교체 * @param aiSummary 배치안 AI 장단점 요약(ai=true 시에만, 엔진 산출 metrics 근거 2~3줄) — 실패/미요청 시 null(NON_NULL 로 응답 제외) + * @param version 생성 시 proposal 로 영속된 layout 버전(배치안 이력 조회·재열람 근거) — 미영속 시 null(NON_NULL 제외) */ @JsonInclude(JsonInclude.Include.NON_NULL) public record AutoLayoutOption( @@ -21,16 +22,22 @@ public record AutoLayoutOption( LayoutSummary summary, List booths, String s7RenderJobId, - String aiSummary + String aiSummary, + Integer version ) { - /** 기존 4필드 + s7 생성자 호환 — aiSummary 미부여(null). */ + /** 기존 4필드 + s7 생성자 호환 — aiSummary·version 미부여(null). */ public AutoLayoutOption(String optionId, String label, LayoutSummary summary, List booths, String s7RenderJobId) { - this(optionId, label, summary, booths, s7RenderJobId, null); + this(optionId, label, summary, booths, s7RenderJobId, null, null); } - /** AI 요약을 덧입힌 사본 반환(불변). */ + /** AI 요약을 덧입힌 사본 반환(불변, version 보존). */ public AutoLayoutOption withAiSummary(String summary) { - return new AutoLayoutOption(optionId, label, this.summary, booths, s7RenderJobId, summary); + return new AutoLayoutOption(optionId, label, this.summary, booths, s7RenderJobId, summary, version); + } + + /** 영속된 proposal 버전을 덧입힌 사본 반환(불변, aiSummary 보존). */ + public AutoLayoutOption withVersion(Integer version) { + return new AutoLayoutOption(optionId, label, summary, booths, s7RenderJobId, aiSummary, version); } } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/LayoutVersionDto.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/LayoutVersionDto.java new file mode 100644 index 0000000..182ca69 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/dto/LayoutVersionDto.java @@ -0,0 +1,19 @@ +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 +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/BoothMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/BoothMapper.java index 6c57024..80f1f51 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/BoothMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/mapper/BoothMapper.java @@ -14,11 +14,18 @@ import java.util.Map; @Mapper public interface BoothMapper { - /** 배치안 헤더 조회(layoutId, version, name, status, updatedAt). */ + /** 배치안 헤더 조회(layoutId, version, name, status, updatedAt). version=null → 최신(작업본 우선). */ Map findLayout(@Param("eventId") String eventId, @Param("hallId") String hallId, @Param("version") Integer version); + /** 현재 최대 버전(proposal 포함) — 다음 버전 번호 산정용. 없으면 null. */ + Integer maxVersion(@Param("eventId") String eventId, @Param("hallId") String hallId); + + /** 배치안 버전 이력 목록(version·name·status·boothCount·updatedAt) — 최신 순. */ + List> findLayoutVersions(@Param("eventId") String eventId, + @Param("hallId") String hallId); + /** 배치안의 부스 목록 조회(폴리곤은 ST_AsGeoJSON 또는 좌표 배열로 반환). */ List> findBooths(@Param("layoutId") String layoutId); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsMapper.java index bbf0066..a788a3d 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsMapper.java @@ -28,6 +28,7 @@ public interface OpsMapper { @Select(""" SELECT h.id AS "hallId", h.label AS "label", COALESCE(ar.sales_area, 0) AS "salesArea", + COALESCE(h.area_m2, 0) AS "physicalArea", pw.req_kw AS "powerKw" FROM hall_assignment ha JOIN hall h ON h.id = ha.hall_id diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsService.java b/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsService.java index caba59a..3e9e95d 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/ops/OpsService.java @@ -35,30 +35,36 @@ public class OpsService { rows = new ArrayList<>(); } - double totalArea = 0; + // 분배 가중치: 홀별로 판매면적(부스 배치 실적)이 있으면 그 값을, 없으면 물리 바닥면적을 쓴다. + // 판매면적만 쓰면 데모/초기처럼 한 홀에만 배치가 있을 때 그 홀에 현장 인원이 100% 몰려 + // "면적 비중 분배가 이상"해 보인다 → 배정된 모든 홀이 실제 규모에 비례해 분산되도록 폴백. + double totalWeight = 0; for (Map r : rows) { - totalArea += dbl(r.get("salesArea")); + totalWeight += hallWeight(r); } List halls = new ArrayList<>(); Double powerTotal = null; int hallCount = rows.size(); for (Map r : rows) { - double area = dbl(r.get("salesArea")); + double salesArea = dbl(r.get("salesArea")); + double weight = hallWeight(r); Double powerKw = r.get("powerKw") == null ? null : dbl(r.get("powerKw")); long est = 0L; if (onSite > 0) { - if (totalArea > 0) { - est = Math.round(onSite * (area / totalArea)); + if (totalWeight > 0) { + est = Math.round(onSite * (weight / totalWeight)); } else if (hallCount > 0) { - // 부스 미배치 → 균등 분배(면적 가중 불가). + // 면적 정보 전무 → 균등 분배(가중 불가). est = Math.round((double) onSite / hallCount); } } + // 혼잡 밀도 기준면적: 판매면적 우선(사람은 부스 통로에 있음), 없으면 물리면적. + double densityArea = salesArea > 0 ? salesArea : dbl(r.get("physicalArea")); halls.add(new OpsDto.Hall( - str(r.get("hallId")), str(r.get("label")), est, heatFor(est, area), powerKw)); + str(r.get("hallId")), str(r.get("label")), est, heatFor(est, densityArea), powerKw)); if (powerKw != null) { powerTotal = (powerTotal == null ? 0d : powerTotal) + powerKw; @@ -78,6 +84,12 @@ public class OpsService { return new OpsDto(halls, hvac, new ArrayList<>(), new ArrayList<>(), onSite, powerTotal, series); } + /** 홀 분배 가중치 — 판매면적(부스 배치 실적) 우선, 없으면 물리 바닥면적. */ + private static double hallWeight(Map r) { + double salesArea = dbl(r.get("salesArea")); + return salesArea > 0 ? salesArea : dbl(r.get("physicalArea")); + } + /** 인원 밀도(명/㎡) 기준 혼잡 등급. 인원 0 또는 면적 부재 시 보수적 처리. */ private static String heatFor(long est, double area) { if (est <= 0) { diff --git a/src/backend/src/main/java/com/zioinfo/kintex/parking/ParkingController.java b/src/backend/src/main/java/com/zioinfo/kintex/parking/ParkingController.java new file mode 100644 index 0000000..0789d41 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/parking/ParkingController.java @@ -0,0 +1,67 @@ +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). + * + *

권한 설계: + *

    + *
  • 주차 현황 {@code GET /api/public/parking/lots} — 공개(비로그인, {@code /api/public/**} permitAll).
  • + *
  • 사전 주차권 구매/조회 {@code /api/parking/passes} — 인증 필요(로그인 관람객, 본인 소유분만).
  • + *
+ * 보안(§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> lots( + @RequestParam(required = false) String eventId) { + return ApiResponse.ok(service.getLotStatus(eventId)); + } + + /** POST /api/parking/passes — 사전 주차권 구매(인증, mock 결제). */ + @PostMapping("/api/parking/passes") + public ApiResponse 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 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(); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/parking/ParkingMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/parking/ParkingMapper.java new file mode 100644 index 0000000..25f3ebb --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/parking/ParkingMapper.java @@ -0,0 +1,97 @@ +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' 고정. + *

★ Map 반환 @Select 는 camelCase 별칭에 반드시 쌍따옴표({@code AS "x"})를 사용한다(PG lower-fold 방지). + *

실시간 점유는 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> 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 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 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> findPassesByUser(@Param("userId") String userId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/parking/ParkingService.java b/src/backend/src/main/java/com/zioinfo/kintex/parking/ParkingService.java new file mode 100644 index 0000000..17dca31 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/parking/ParkingService.java @@ -0,0 +1,26 @@ +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) 서비스 계약. + * + *

어댑터 패턴: 실시간 주차 현황(잔여·점유)의 원천은 외부 시스템(iparking 등)이나 현재 미연동이다. + * 기본 구현({@link SimulatedParkingService})은 결정론적 시뮬레이션으로 현황을 산출하며, + * 운영 전환 시 외부 실연동 어댑터를 이 인터페이스의 대체 {@code @Service} 로 주입한다(코드 변경 최소). + */ +public interface ParkingService { + + /** 주차장별 실시간 현황(공개) — 잔여·점유율·혼잡도·요금. eventId 는 선택(사전권 가중 기준일 산정용). */ + List getLotStatus(String eventId); + + /** 사전 주차권 구매(인증 관람객) — mock 결제 승인 후 발급. userId 는 JWT 주체. */ + ParkingPassDto purchasePass(String userId, PurchasePassRequest req); + + /** 내 주차권 목록(인증 관람객) — 본인 소유분만. */ + List myPasses(String userId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/parking/SimulatedParkingService.java b/src/backend/src/main/java/com/zioinfo/kintex/parking/SimulatedParkingService.java new file mode 100644 index 0000000..91e7729 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/parking/SimulatedParkingService.java @@ -0,0 +1,263 @@ +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). + * + *

실시간 점유는 외부 시스템(iparking 등) 미연동 → 시각·주차장별 결정론 곡선 + 발급된 사전 주차권 수로 + * 산출한다(하드웨어/외부 호출 없음). 구매는 mock 결제({@link PaymentGateway})로 승인한다. + * + *

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 getLotStatus(String eventId) { + String today = LocalDate.now().format(DATE); + int hour = LocalDateTime.now().getHour(); + String now = LocalDateTime.now().format(TS); + + List out = new ArrayList<>(); + for (Map 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 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 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 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 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; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/parking/dto/ParkingDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/parking/dto/ParkingDtos.java new file mode 100644 index 0000000..efe186b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/parking/dto/ParkingDtos.java @@ -0,0 +1,65 @@ +package com.zioinfo.kintex.parking.dto; + +import jakarta.validation.constraints.NotBlank; + +import java.util.List; + +/** + * 관람객 주차 연계(F-C2) DTO 모음. 주차 현황(공개) · 사전 주차권 구매/조회(인증). + *

보안(§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 passes) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/EventSubscriptionController.java b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/EventSubscriptionController.java new file mode 100644 index 0000000..c2614ff --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/EventSubscriptionController.java @@ -0,0 +1,48 @@ +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). + *

구독 신청/해지만 제공. 구독 목록·원문 이메일 조회는 공개하지 않는다(§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 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 unsubscribe(@RequestParam String token) { + return ApiResponse.ok(service.unsubscribe(token)); + } + + /** GET /api/public/subscriptions/unsubscribe?token= — 메일 링크(GET) 호환. */ + @GetMapping("/subscriptions/unsubscribe") + public ApiResponse unsubscribeGet(@RequestParam String token) { + return ApiResponse.ok(service.unsubscribe(token)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/EventSubscriptionMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/EventSubscriptionMapper.java new file mode 100644 index 0000000..67ffc7f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/EventSubscriptionMapper.java @@ -0,0 +1,113 @@ +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) 매퍼. + *

★ Map 반환 @Select 는 camelCase 별칭에 쌍따옴표({@code AS "x"})를 사용한다(PG lower-fold 방지). + *

★ MyBatis 함정 회피: raw {@code <}/{@code <=} 미사용 — 시각 비교는 {@code now() >= sale_start} 로 표현. + *

원문 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 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> 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); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/EventSubscriptionService.java b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/EventSubscriptionService.java new file mode 100644 index 0000000..af75628 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/EventSubscriptionService.java @@ -0,0 +1,122 @@ +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}(스케줄러). + *

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 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); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/TicketOpenNotifier.java b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/TicketOpenNotifier.java new file mode 100644 index 0000000..26a247a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/TicketOpenNotifier.java @@ -0,0 +1,145 @@ +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회 발송한다. + * + *

동작: 60초 폴러가 {@link EventSubscriptionMapper#findPendingTicketOpen}(오픈된 행사의 미통지 활성 구독)을 + * 조회하고, 각 건을 조건부 markNotified(이중 발송 방지)로 선점한 뒤 자체 SMTP({@link MailService})로 발송한다. + * + *

데모 안전장치(EDM 재사용): {@link MailProperties#isEdmTestMode()}(기본 true)면 dry-run 모드 — + * 실 구독자에게 보내지 않고 화이트리스트({@code EDM_TEST_RECIPIENTS})로만 발송(비면 카운트만)한다. 실 대량 발송은 + * env 로 {@code kintex.mail.edm-test-mode=false} + {@code MAIL_ENABLED=true} 설정 시에만 열린다. 모드와 무관하게 + * 처리 건은 notified 로 마킹하여 스케줄러 재처리(로그 폭주)를 막는다. + * + *

PII(§0-3): 원문 이메일은 발송 경로 한정 — 로그·응답에는 건수만 남긴다({@link MailService}가 마스킹 이력). + *

{@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> 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 whitelist = sanitize(mailProperties.getEdmTestRecipients()); + String baseUrl = trimTrailingSlash(mailProperties.getWebBaseUrl()); + + int processed = 0; + for (Map 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 sanitize(List in) { + List 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); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/dto/SubscriptionDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/dto/SubscriptionDtos.java new file mode 100644 index 0000000..81767e2 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/subscription/dto/SubscriptionDtos.java @@ -0,0 +1,26 @@ +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) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/report/GridReportController.java b/src/backend/src/main/java/com/zioinfo/kintex/report/GridReportController.java new file mode 100644 index 0000000..13e75e2 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/report/GridReportController.java @@ -0,0 +1,59 @@ +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). + * + *

인증: 기존 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 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; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/report/GridReportService.java b/src/backend/src/main/java/com/zioinfo/kintex/report/GridReportService.java new file mode 100644 index 0000000..636221f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/report/GridReportService.java @@ -0,0 +1,298 @@ +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 패턴 이식. + * + *

동적 컬럼 처리: 정적 jrxml({@code reports/grid_report.jrxml})은 로고·타이틀·페이지번호 등 + * "골격"만 담고, 요청 {@link GridPdfRequest#columns()} 개수에 맞춰 {@link JasperDesign} API 로 + * columnHeader(머리글 {@link JRDesignStaticText})·detail(셀 {@link JRDesignTextField}) 요소를 런타임에 + * 주입한 뒤 컴파일한다. Jasper 정적 컬럼 한계를 DynamicJasper 없이 가로 가중/균등 분할 자체 구현으로 회피한다. + * + *

필드 매핑: 컬럼 키가 임의 문자열(공백·중복 가능)이어도 안전하도록 내부 필드명을 {@code c0..cN} + * 로 재부여하고, 데이터소스 행 맵도 동일 키로 재구성한다(표현식 파손·중복 필드 예외 방지). + * + *

한글: {@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 columns = req.columns(); + if (columns.size() > MAX_COLUMNS) { + throw new ApiException(ErrorCode.VALIDATION, "컬럼은 최대 " + MAX_COLUMNS + "개까지 지원합니다."); + } + List> 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 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 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 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 columns, + List> rows) { + List> data = new ArrayList<>(rows.size()); + for (Map row : rows) { + Map 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; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/report/dto/GridPdfRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/report/dto/GridPdfRequest.java new file mode 100644 index 0000000..654dd07 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/report/dto/GridPdfRequest.java @@ -0,0 +1,63 @@ +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 출력 요청 — 프론트 그리드가 현재 표시 중인 컬럼·행을 그대로 전송한다. + * 서버는 컬럼 정의에 맞춰 JasperReports 동적 컬럼 리포트를 렌더해 application/pdf 로 응답한다. + * + *

방어적 제한: 컬럼 최대 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 columns, + + @NotNull(message = "rows 는 필수입니다(빈 배열 허용).") + @Size(max = 5000, message = "rows 는 최대 5,000행까지 지원합니다.") + List> 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 + ) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/session/SessionController.java b/src/backend/src/main/java/com/zioinfo/kintex/session/SessionController.java new file mode 100644 index 0000000..18f6158 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/session/SessionController.java @@ -0,0 +1,68 @@ +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. + *

    + *
  • 공개 조회 {@code GET /api/public/events/{eventId}/sessions} — 로그인 시 내 상태 포함.
  • + *
  • 신청/취소/내신청 {@code /api/visitor/sessions/**} — 인증 필요.
  • + *
+ */ +@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> 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> mySessions(@PathVariable String eventId, + @AuthenticationPrincipal KintexPrincipal principal) { + return ApiResponse.ok(service.list(eventId, VisitorAuth.requireUserId(principal))); + } + + /** 내 RSVP 목록. */ + @GetMapping("/api/visitor/sessions/mine") + public ApiResponse> mine(@AuthenticationPrincipal KintexPrincipal principal) { + return ApiResponse.ok(service.mine(VisitorAuth.requireUserId(principal))); + } + + /** 세션 신청(확정/대기). */ + @PostMapping("/api/visitor/sessions/{sessionId}/rsvp") + public ApiResponse rsvp(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String sessionId) { + return ApiResponse.ok(service.rsvp(VisitorAuth.requireUserId(principal), sessionId)); + } + + /** 세션 신청 취소(대기 승격 트리거). */ + @DeleteMapping("/api/visitor/sessions/{sessionId}/rsvp") + public ApiResponse cancel(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String sessionId) { + return ApiResponse.ok(service.cancel(VisitorAuth.requireUserId(principal), sessionId)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/session/SessionDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/session/SessionDtos.java new file mode 100644 index 0000000..e4624bb --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/session/SessionDtos.java @@ -0,0 +1,46 @@ +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) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/session/SessionMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/session/SessionMapper.java new file mode 100644 index 0000000..35b0b99 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/session/SessionMapper.java @@ -0,0 +1,125 @@ +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' 고정. + *

★ 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> 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 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 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 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 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> myRsvps(@Param("userId") String userId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/session/SessionService.java b/src/backend/src/main/java/com/zioinfo/kintex/session/SessionService.java new file mode 100644 index 0000000..6e5efd4 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/session/SessionService.java @@ -0,0 +1,145 @@ +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 대기열·취소표 서비스. + * + *

신청: 정원 여유 → CONFIRMED(조건부 좌석 확보로 오버부킹 차단), 소진 → WAITLIST(순번 부여). + *

취소: 확정 취소 시 좌석 반환 후 대기 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 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 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 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 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 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 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 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 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 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; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminController.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminController.java index 9b93af6..a3c3466 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminController.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminController.java @@ -6,8 +6,10 @@ import com.zioinfo.kintex.common.audit.Audited; import com.zioinfo.kintex.system.SystemAccessGuard; import com.zioinfo.kintex.tenant.dto.TenantAdminDto; import com.zioinfo.kintex.tenant.dto.TenantCreateRequest; +import com.zioinfo.kintex.tenant.dto.TenantSwitchResponse; import org.springframework.security.core.annotation.AuthenticationPrincipal; 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; @@ -47,4 +49,16 @@ public class TenantAdminController { guard.requireAdmin(principal); return ApiResponse.ok(service.create(req)); } + + /** + * POST /{code}/switch — 플랫폼 관리자 테넌트 스위치. 대상 테넌트 tid 가 박힌 신규 토큰을 반환한다(§1A-3-2). + * 프론트 스위처가 응답 토큰으로 세션을 교체하면 이후 전 요청이 대상 테넌트로 스코프된다. + */ + @Audited(action = "TENANT_SWITCH", targetType = "tenant") + @PostMapping("/{code}/switch") + public ApiResponse switchTenant(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String code) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.switchTenant(principal, code)); + } } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminService.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminService.java index 0d469c6..baff5cc 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantAdminService.java @@ -1,9 +1,12 @@ 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.ErrorCode; import com.zioinfo.kintex.tenant.dto.TenantAdminDto; import com.zioinfo.kintex.tenant.dto.TenantCreateRequest; +import com.zioinfo.kintex.tenant.dto.TenantSwitchResponse; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,10 +28,12 @@ public class TenantAdminService { private final TenantMapper mapper; private final TenantResolver resolver; + private final JwtService jwtService; - public TenantAdminService(TenantMapper mapper, TenantResolver resolver) { + public TenantAdminService(TenantMapper mapper, TenantResolver resolver, JwtService jwtService) { this.mapper = mapper; this.resolver = resolver; + this.jwtService = jwtService; } /** 전 테넌트 목록(행사·사용자 수 포함). */ @@ -69,6 +74,21 @@ public class TenantAdminService { 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) { return o == null ? null : String.valueOf(o); } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantContextFilter.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantContextFilter.java index 61f9ec9..209b638 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantContextFilter.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/TenantContextFilter.java @@ -1,6 +1,8 @@ package com.zioinfo.kintex.tenant; 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.ApiError; import com.zioinfo.kintex.common.error.ErrorCode; @@ -15,39 +17,75 @@ import java.io.IOException; /** * 테넌트 컨텍스트 해소 필터 (PLANNING §1A-3 · §8-2 필터 계층). - *

해소 우선순위: ① {@code X-Tenant-Id} 헤더 → ② Host 도메인 매칭 → ③ 기준 테넌트('kintex'). - * 명시적 {@code X-Tenant-Id}가 존재하지 않는 테넌트를 가리키면 400(오접속·위조 차단). - * 그 외(헤더 없음·도메인 미매칭)는 기준 테넌트로 폴백해 기존 동작을 완전히 보존한다(회귀 0, 1단계). - *

2단계에서 fail-closed(미해소 거부)·JWT 테넌트 클레임 검증으로 강화 — 설계 노트 참조. + * + *

2단계(JWT tid 권위) — 인증 요청은 JWT {@code tid} 클레임을 신뢰 원천으로 삼는다(§1A-3-2): + *

    + *
  • 인증 요청(유효 Bearer): 테넌트 = JWT {@code tid}. 헤더 {@code X-Tenant-Id}가 tid와 + * 다른 테넌트를 명시하면 → 플랫폼 관리자(전역 ADMIN 또는 홀매니저)만 대상 테넌트로 스위치 허용 + * (감사 대상 크로스-테넌트), 그 외 주체는 403(오접속/하이재킹 차단, fail-closed). 스위치 대상이 + * 미존재 테넌트면 400.
  • + *
  • 미인증 요청(공개 경로): 1단계 해소 유지 — ① {@code X-Tenant-Id}(미존재 시 400) → + * ② Host 도메인 매칭 → ③ 기준 테넌트('KINTEX').
  • + *
+ * + *

회귀 0: 기존 인증 클라이언트는 tid=KINTEX 토큰 + 헤더 미전송 → 테넌트 KINTEX(동일). tid 부재 레거시 + * 토큰도 {@link JwtService#verify}가 KINTEX로 정규화 → 동일. 공개 경로는 1단계와 동일 해소. + * + *

필터 순서 비의존: JWT를 자체 파싱하므로 {@code JwtAuthenticationFilter} 와의 등록 순서에 무관하게 동작한다. */ public class TenantContextFilter extends OncePerRequestFilter { private static final String HEADER = "X-Tenant-Id"; + private static final String BEARER = "Bearer "; private final TenantResolver resolver; private final ObjectMapper objectMapper; + private final JwtService jwtService; - public TenantContextFilter(TenantResolver resolver, ObjectMapper objectMapper) { + public TenantContextFilter(TenantResolver resolver, ObjectMapper objectMapper, JwtService jwtService) { this.resolver = resolver; this.objectMapper = objectMapper; + this.jwtService = jwtService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { + String headerTenant = trimToNull(request.getHeader(HEADER)); + KintexPrincipal principal = tryPrincipal(request); + String tenantId; - String headerTenant = request.getHeader(HEADER); - if (headerTenant != null && !headerTenant.isBlank()) { - String candidate = headerTenant.trim(); - if (!resolver.exists(candidate)) { - writeBadTenant(response); - return; + if (principal != null) { + // 인증 요청 — JWT tid 권위(§1A-3-2). + String jwtTenant = principal.tenantId(); // 이미 KINTEX 기본 정규화됨 + if (headerTenant != null + && !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 } - tenantId = candidate; } else { - // 도메인 매칭(§1A-3) → 폴백 기준 테넌트. - String byDomain = resolver.resolveByDomain(request.getHeader("Host")); - tenantId = (byDomain != null) ? byDomain : TenantContext.DEFAULT_TENANT; + // 미인증(공개 경로) — 1단계 해소 유지: 헤더 → 도메인 → 기준 테넌트. + if (headerTenant != null) { + if (!resolver.exists(headerTenant)) { + writeBadTenant(response); + return; + } + tenantId = headerTenant; + } else { + String byDomain = resolver.resolveByDomain(request.getHeader("Host")); + tenantId = (byDomain != null) ? byDomain : TenantContext.DEFAULT_TENANT; + } } try { @@ -58,12 +96,46 @@ 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 { - response.setStatus(ErrorCode.VALIDATION.status().value()); + writeError(response, ErrorCode.VALIDATION, "알 수 없는 테넌트입니다."); + } + + 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.setCharacterEncoding("UTF-8"); response.getWriter().write(objectMapper.writeValueAsString( - ApiResponse.fail(new ApiError(ErrorCode.VALIDATION.name(), - "알 수 없는 테넌트입니다.")))); + ApiResponse.fail(new ApiError(code.name(), message)))); } } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/dto/TenantSwitchResponse.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/dto/TenantSwitchResponse.java new file mode 100644 index 0000000..0fc701b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/dto/TenantSwitchResponse.java @@ -0,0 +1,12 @@ +package com.zioinfo.kintex.tenant.dto; + +/** + * 플랫폼 관리자 테넌트 스위치 응답 — 대상 테넌트 {@code tid}가 박힌 신규 액세스 토큰(§1A-3-2). + * 프론트 스위처가 {@code accessToken}으로 세션 토큰을 교체하면 이후 전 요청이 대상 테넌트로 스코프된다. + */ +public record TenantSwitchResponse( + String accessToken, + long expiresIn, + String tenant +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelCalc.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelCalc.java new file mode 100644 index 0000000..fef213a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelCalc.java @@ -0,0 +1,90 @@ +package com.zioinfo.kintex.ticket.cancel; + +import java.util.List; + +/** + * F-B2 티켓 취소·환불 산정(순수 함수 — 단위테스트 대상). 서버 권위(클라이언트 금액 미신뢰). + * + *

산식: + *

    + *
  • ticketRefund = round(ticketAmount × 구간환불율%)
  • + *
  • adminFee = round(ticketRefund × 취소관리수수료%) — 환불액에서 공제
  • + *
  • bookingFeeRefund = (D-일 ≥ 예매수수료 환불 최소일) ? bookingFee : 0 (예매수수료 별도 규정)
  • + *
  • refund = ticketRefund − adminFee + bookingFeeRefund
  • + *
  • forfeit = (ticketAmount + bookingFee) − refund
  • + *
+ * 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 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 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); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelController.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelController.java new file mode 100644 index 0000000..9eee559 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelController.java @@ -0,0 +1,37 @@ +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. + *

본인확인(예매번호+연락처). 예상 환불액은 서버 권위 산출. + */ +@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 quote(@Valid @RequestBody CancelRequest req) { + return ApiResponse.ok(service.quote(req.orderNo(), req.contact())); + } + + /** POST /cancel — 실제 취소 처리(상태 전이·재고 원복·환불 스냅샷). */ + @PostMapping("/cancel") + public ApiResponse cancel(@Valid @RequestBody CancelRequest req) { + return ApiResponse.ok(service.cancel(req.orderNo(), req.contact())); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelDtos.java new file mode 100644 index 0000000..ec445cb --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelDtos.java @@ -0,0 +1,48 @@ +package com.zioinfo.kintex.ticket.cancel; + +import jakarta.validation.constraints.NotBlank; + +import java.util.List; + +/** + * F-B2 티켓 취소·환불 DTO. 전 경로 공개(/api/public/**) — 티켓은 비로그인. + *

보안(§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 brackets) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelMapper.java new file mode 100644 index 0000000..6e63a67 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelMapper.java @@ -0,0 +1,105 @@ +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' 고정. + *

★ camelCase 별칭 쌍따옴표({@code AS "x"}). ★ 날짜 비교는 파라미터 캐스팅. + *

보안(§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 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> 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> 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 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 row); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelService.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelService.java new file mode 100644 index 0000000..099080c --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/cancel/TicketCancelService.java @@ -0,0 +1,181 @@ +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 티켓 취소·환불 서비스 — 다구간 취소수수료 + 예매수수료 별도 규정, 서버 권위 산출. + * + *

예상 환불({@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 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 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 order, boolean applying) { + String eventId = str(order.get("eventId")); + int qty = toInt(order.get("qty"), 0); + long ticketAmount = toLong(order.get("totalAmount"), 0); + + // 정책 로드(행사 우선 → 기본). + Map 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> raw = mapper.findEventBrackets(eventId); + if (raw == null || raw.isEmpty()) { + raw = mapper.findDefaultBrackets(); + } + List brackets = new ArrayList<>(); + List bracketDtos = new ArrayList<>(); + for (Map 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 loadOrder(String orderNo, String contact) { + if (blank(orderNo) || blank(contact)) { + throw new ApiException(ErrorCode.VALIDATION, "예매번호와 연락처를 입력해 주세요."); + } + Map 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; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/SmartTicketController.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/SmartTicketController.java new file mode 100644 index 0000000..9232f18 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/SmartTicketController.java @@ -0,0 +1,49 @@ +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. + *

보안: 응답에 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 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 token(@RequestParam String ticketCode, + @RequestParam String deviceId) { + return ApiResponse.ok(service.currentToken(ticketCode, deviceId)); + } + + /** POST /verify — 게이트 스캐너 QR 검증(회전 토큰 유효성). */ + @PostMapping("/verify") + public ApiResponse verify(@Valid @RequestBody VerifyRequest req) { + return ApiResponse.ok(service.verify(req.qrPayload())); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/SmartTicketDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/SmartTicketDtos.java new file mode 100644 index 0000000..dc3190c --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/SmartTicketDtos.java @@ -0,0 +1,42 @@ +package com.zioinfo.kintex.ticket.smart; + +import jakarta.validation.constraints.NotBlank; + +/** + * F-B1 스마트티켓(회전 QR) DTO. 전 경로 공개(/api/public/**) — 티켓은 비로그인 예매. + *

보안(§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) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/SmartTicketMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/SmartTicketMapper.java new file mode 100644 index 0000000..c5b59fe --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/SmartTicketMapper.java @@ -0,0 +1,62 @@ +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/조회. + *

★ camelCase 별칭은 반드시 쌍따옴표({@code AS "x"})(PG lower-fold 방지). + *

보안(§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 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 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); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/SmartTicketService.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/SmartTicketService.java new file mode 100644 index 0000000..043e398 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/SmartTicketService.java @@ -0,0 +1,132 @@ +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 토큰 발급/검증. + * + *

흐름: ①activate(예매번호+연락처 본인확인 → 티켓을 기기에 바인딩, 서버 시크릿 발급) + * → ②token(바인딩 기기에서만 현재 회전 토큰 발급) → ③verify(게이트 스캐너가 QR 검증). + * 캡처 스크린샷은 토큰 회전으로 30초(+직전 1창) 후 무효 → 양도/재사용 차단. + * + *

보안(§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 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 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 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); + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/Totp.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/Totp.java new file mode 100644 index 0000000..3aaaf52 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/smart/Totp.java @@ -0,0 +1,71 @@ +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 계열, 순수 함수 — 단위테스트 대상). + * + *

토큰 = 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); + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorAuth.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorAuth.java new file mode 100644 index 0000000..3956513 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorAuth.java @@ -0,0 +1,22 @@ +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. + *

관람객 개인화 기능(멤버십·컨택트·결제수단·피드·알림·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(); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/contact/ContactController.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/contact/ContactController.java new file mode 100644 index 0000000..869c593 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/contact/ContactController.java @@ -0,0 +1,68 @@ +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 myCard(@AuthenticationPrincipal KintexPrincipal principal) { + return ApiResponse.ok(service.myCard(VisitorAuth.requireUserId(principal), principal.displayName())); + } + + /** PUT /me/card — 내 명함 저장/수정. */ + @PutMapping("/me/card") + public ApiResponse saveCard(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody SaveCardRequest req) { + return ApiResponse.ok(service.saveCard(VisitorAuth.requireUserId(principal), req)); + } + + /** POST /exchange — 상대 QR 스캔 교환(상호 저장). */ + @PostMapping("/exchange") + public ApiResponse 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(@AuthenticationPrincipal KintexPrincipal principal) { + return ApiResponse.ok(service.list(VisitorAuth.requireUserId(principal))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, @PathVariable String id) { + service.delete(VisitorAuth.requireUserId(principal), id); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/contact/ContactDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/contact/ContactDtos.java new file mode 100644 index 0000000..c376762 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/contact/ContactDtos.java @@ -0,0 +1,49 @@ +package com.zioinfo.kintex.visitor.contact; + +import jakarta.validation.constraints.NotBlank; + +/** + * F-C4 관람객 명함/컨택트 지갑 DTO. 인증(본인 지갑만). + *

보안(§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) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/contact/ContactMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/contact/ContactMapper.java new file mode 100644 index 0000000..dc4ed3c --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/contact/ContactMapper.java @@ -0,0 +1,92 @@ +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) 지갑만. + *

보안(§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 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 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 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 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> 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); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/contact/ContactService.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/contact/ContactService.java new file mode 100644 index 0000000..8250037 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/contact/ContactService.java @@ -0,0 +1,173 @@ +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 명함 교환·컨택트 지갑 서비스. + * + *

흐름: ①내 명함 등록/조회(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 card = mapper.findMyCard(userId); + if (card == null) { + String token = newShareToken(); + Map 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 existing = mapper.findMyCard(userId); + String token = existing != null ? str(existing.get("shareToken")) : newShareToken(); + Map 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 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 mineRow = contactRow(contactId, userId, otherUserId, other); + mapper.insertContact(mineRow); + + // 상대 지갑에 나(내 명함) 저장(상호). 내 명함이 없으면 자동 생성. + MyCardDto myCard = myCard(userId, myDisplayName); + Map 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 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 contactRow(String id, String owner, String contactUser, Map src) { + Map 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 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(); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/feed/FeedController.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/feed/FeedController.java new file mode 100644 index 0000000..e60c7d9 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/feed/FeedController.java @@ -0,0 +1,46 @@ +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 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> recent(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(defaultValue = "20") int limit) { + return ApiResponse.ok(service.recent(VisitorAuth.requireUserId(principal), limit)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/feed/FeedDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/feed/FeedDtos.java new file mode 100644 index 0000000..1037c65 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/feed/FeedDtos.java @@ -0,0 +1,30 @@ +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) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/feed/FeedMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/feed/FeedMapper.java new file mode 100644 index 0000000..4fc99a5 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/feed/FeedMapper.java @@ -0,0 +1,58 @@ +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 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> 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); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/feed/FeedService.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/feed/FeedService.java new file mode 100644 index 0000000..b96e4c4 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/feed/FeedService.java @@ -0,0 +1,65 @@ +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 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 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(); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipController.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipController.java new file mode 100644 index 0000000..5ce2b79 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipController.java @@ -0,0 +1,39 @@ +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 me(@AuthenticationPrincipal KintexPrincipal principal) { + return ApiResponse.ok(service.myMembership(VisitorAuth.requireUserId(principal))); + } + + /** POST /api/visitor/membership/buyer-verify?verified= — 바이어 인증 토글(데모). */ + @PostMapping("/buyer-verify") + public ApiResponse buyerVerify(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(defaultValue = "true") boolean verified) { + return ApiResponse.ok(service.setBuyerVerified(VisitorAuth.requireUserId(principal), verified)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipDtos.java new file mode 100644 index 0000000..fbb8245 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipDtos.java @@ -0,0 +1,25 @@ +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 benefits) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipMapper.java new file mode 100644 index 0000000..2083453 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipMapper.java @@ -0,0 +1,54 @@ +package com.zioinfo.kintex.visitor.membership; + +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-C6 관람객 멤버십 매퍼. tenant_id='KINTEX' 고정. 본인(user_id)만. + */ +@Mapper +public interface MembershipMapper { + + /** 내 멤버십(없으면 null → 서비스가 기본 GENERAL 생성). */ + @Select(""" + SELECT user_id AS "userId", + visit_count AS "visitCount", + buyer_verified AS "buyerVerified", + vip_grant AS "vipGrant", + points, + to_char(last_visit_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "lastVisitAt" + FROM visitor_membership + WHERE tenant_id = 'KINTEX' AND user_id = #{userId} + """) + Map find(@Param("userId") String userId); + + /** 멤버십 초기화(기본값). */ + @Update(""" + INSERT INTO visitor_membership (tenant_id, user_id, visit_count, buyer_verified, vip_grant, points) + VALUES ('KINTEX', #{userId}, 0, false, false, 0) + ON CONFLICT (tenant_id, user_id) DO NOTHING + """) + void ensure(@Param("userId") String userId); + + /** 바이어 인증(데모: 셀프 인증 토글). */ + @Update(""" + UPDATE visitor_membership + SET buyer_verified = #{verified}, updated_at = now() + WHERE tenant_id = 'KINTEX' AND user_id = #{userId} + """) + void setBuyerVerified(@Param("userId") String userId, @Param("verified") boolean verified); + + /** 등급별 혜택 목록. */ + @Select(""" + SELECT benefit + FROM membership_benefit + WHERE tenant_id = 'KINTEX' AND tier = #{tier} + ORDER BY seq + """) + List benefits(@Param("tier") String tier); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipService.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipService.java new file mode 100644 index 0000000..186253d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipService.java @@ -0,0 +1,63 @@ +package com.zioinfo.kintex.visitor.membership; + +import com.zioinfo.kintex.visitor.membership.MembershipDtos.MembershipDto; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +/** + * F-C6 관람객 멤버십 서비스 — 재방문·바이어 인증 기반 등급 산정 + 혜택 표시. + * 등급 규칙은 {@link MembershipTier}(순수 함수) 위임. + */ +@Service +public class MembershipService { + + private final MembershipMapper mapper; + + public MembershipService(MembershipMapper mapper) { + this.mapper = mapper; + } + + /** 내 멤버십 — 없으면 기본 GENERAL 생성 후 산정. */ + @Transactional + public MembershipDto myMembership(String userId) { + Map m = mapper.find(userId); + if (m == null) { + mapper.ensure(userId); + m = mapper.find(userId); + } + return build(m); + } + + /** 바이어 인증 토글(데모 셀프 인증) — 실제는 사업자/명함 검증 연계. */ + @Transactional + public MembershipDto setBuyerVerified(String userId, boolean verified) { + mapper.ensure(userId); + mapper.setBuyerVerified(userId, verified); + return build(mapper.find(userId)); + } + + private MembershipDto build(Map m) { + int visits = toInt(m.get("visitCount"), 0); + boolean buyer = bool(m.get("buyerVerified")); + boolean vip = bool(m.get("vipGrant")); + String tier = MembershipTier.of(visits, buyer, vip); + return new MembershipDto( + tier, MembershipTier.label(tier), visits, buyer, vip, + toInt(m.get("points"), 0), str(m.get("lastVisitAt")), + MembershipTier.visitsToNextTier(tier, visits), mapper.benefits(tier)); + } + + 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; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipTier.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipTier.java new file mode 100644 index 0000000..09a3398 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/membership/MembershipTier.java @@ -0,0 +1,42 @@ +package com.zioinfo.kintex.visitor.membership; + +/** + * F-C6 관람객 멤버십 등급 산정(순수 함수 — 단위테스트 대상). 서버 권위. + * + *

규칙: VIP = vipGrant 또는 방문 {@value #VIP_MIN_VISITS}회 이상 / BUYER = 바이어 인증 / 그 외 GENERAL. + */ +public final class MembershipTier { + + public static final int VIP_MIN_VISITS = 5; + + private MembershipTier() { + } + + /** 등급 코드(GENERAL/BUYER/VIP). */ + public static String of(int visitCount, boolean buyerVerified, boolean vipGrant) { + if (vipGrant || visitCount >= VIP_MIN_VISITS) { + return "VIP"; + } + if (buyerVerified) { + return "BUYER"; + } + return "GENERAL"; + } + + /** 등급 표시명. */ + public static String label(String tier) { + return switch (tier) { + case "VIP" -> "VIP"; + case "BUYER" -> "바이어"; + default -> "일반"; + }; + } + + /** 다음 등급까지 남은 방문 수(VIP는 0). GENERAL/BUYER → VIP 승급 기준(방문). */ + public static int visitsToNextTier(String tier, int visitCount) { + if ("VIP".equals(tier)) { + return 0; + } + return Math.max(0, VIP_MIN_VISITS - visitCount); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/notification/VisitorNotificationController.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/notification/VisitorNotificationController.java new file mode 100644 index 0000000..482d186 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/notification/VisitorNotificationController.java @@ -0,0 +1,54 @@ +package com.zioinfo.kintex.visitor.notification; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.visitor.VisitorAuth; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * F-C5 관람객 인앱 알림함 API — 인증(본인 소유분만). + */ +@RestController +@RequestMapping("/api/visitor/notifications") +public class VisitorNotificationController { + + private final VisitorNotificationService service; + + public VisitorNotificationController(VisitorNotificationService service) { + this.service = service; + } + + /** GET /api/visitor/notifications — 내 알림 목록. */ + @GetMapping + public ApiResponse>> list(@AuthenticationPrincipal KintexPrincipal principal) { + return ApiResponse.ok(service.list(VisitorAuth.requireUserId(principal))); + } + + /** GET /api/visitor/notifications/unread-count — 미읽음 수. */ + @GetMapping("/unread-count") + public ApiResponse> unread(@AuthenticationPrincipal KintexPrincipal principal) { + return ApiResponse.ok(Map.of("count", service.unreadCount(VisitorAuth.requireUserId(principal)))); + } + + /** POST /api/visitor/notifications/{id}/read — 단건 읽음. */ + @PostMapping("/{id}/read") + public ApiResponse read(@AuthenticationPrincipal KintexPrincipal principal, @PathVariable String id) { + service.markRead(VisitorAuth.requireUserId(principal), id); + return ApiResponse.ok(null); + } + + /** POST /api/visitor/notifications/read-all — 전체 읽음. */ + @PostMapping("/read-all") + public ApiResponse readAll(@AuthenticationPrincipal KintexPrincipal principal) { + service.markAllRead(VisitorAuth.requireUserId(principal)); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/notification/VisitorNotificationMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/notification/VisitorNotificationMapper.java new file mode 100644 index 0000000..7e187a0 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/notification/VisitorNotificationMapper.java @@ -0,0 +1,56 @@ +package com.zioinfo.kintex.visitor.notification; + +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-C5 관람객 인앱 알림함 매퍼. tenant_id='KINTEX' 고정. 본인(user_id) 알림만. + *

★ camelCase 별칭 쌍따옴표({@code AS "x"}). + */ +@Mapper +public interface VisitorNotificationMapper { + + /** 알림 삽입(대기 승격·RSVP·멤버십 등). */ + @Insert(""" + INSERT INTO visitor_notification + (tenant_id, id, user_id, kind, title, body, ref_type, ref_id) + VALUES + ('KINTEX', #{id}, #{userId}, #{kind}, #{title}, #{body}, #{refType}, #{refId}) + """) + void insert(Map row); + + /** 내 알림 목록(최신순). */ + @Select(""" + SELECT id, + kind, + title, + body, + ref_type AS "refType", + ref_id AS "refId", + is_read AS "read", + to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "createdAt" + FROM visitor_notification + WHERE tenant_id = 'KINTEX' AND user_id = #{userId} + ORDER BY created_at DESC + LIMIT 100 + """) + List> listByUser(@Param("userId") String userId); + + /** 미읽음 수. */ + @Select("SELECT count(*) FROM visitor_notification WHERE tenant_id='KINTEX' AND user_id = #{userId} AND is_read = false") + int unreadCount(@Param("userId") String userId); + + /** 단건 읽음(본인 소유분만). */ + @Update("UPDATE visitor_notification SET is_read = true WHERE tenant_id='KINTEX' AND id = #{id} AND user_id = #{userId}") + int markRead(@Param("id") String id, @Param("userId") String userId); + + /** 전체 읽음. */ + @Update("UPDATE visitor_notification SET is_read = true WHERE tenant_id='KINTEX' AND user_id = #{userId} AND is_read = false") + int markAllRead(@Param("userId") String userId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/notification/VisitorNotificationService.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/notification/VisitorNotificationService.java new file mode 100644 index 0000000..b072702 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/notification/VisitorNotificationService.java @@ -0,0 +1,55 @@ +package com.zioinfo.kintex.visitor.notification; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * F-C5 관람객 인앱 알림 서비스 — 발행(다른 서비스가 호출) + 조회/읽음. + *

외부 채널 발송이 아닌 관람객 대면 인앱 알림함(대기 승격·RSVP·멤버십 등). + */ +@Service +public class VisitorNotificationService { + + private final VisitorNotificationMapper mapper; + + public VisitorNotificationService(VisitorNotificationMapper mapper) { + this.mapper = mapper; + } + + /** 알림 발행 — 다른 도메인 서비스(세션 대기 승격 등)가 트랜잭션 내에서 호출. */ + public void publish(String userId, String kind, String title, String body, String refType, String refId) { + Map row = new java.util.HashMap<>(); + row.put("id", "vn-" + UUID.randomUUID()); + row.put("userId", userId); + row.put("kind", kind); + row.put("title", title); + row.put("body", body); + row.put("refType", refType); + row.put("refId", refId); + mapper.insert(row); + } + + @Transactional(readOnly = true) + public List> list(String userId) { + return mapper.listByUser(userId); + } + + @Transactional(readOnly = true) + public int unreadCount(String userId) { + return mapper.unreadCount(userId); + } + + @Transactional + public void markRead(String userId, String id) { + mapper.markRead(id, userId); + } + + @Transactional + public void markAllRead(String userId) { + mapper.markAllRead(userId); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/paymethod/PayMethodController.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/paymethod/PayMethodController.java new file mode 100644 index 0000000..a9dac66 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/paymethod/PayMethodController.java @@ -0,0 +1,66 @@ +package com.zioinfo.kintex.visitor.paymethod; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.visitor.VisitorAuth; +import com.zioinfo.kintex.visitor.paymethod.PayMethodDtos.PayMethodDto; +import com.zioinfo.kintex.visitor.paymethod.PayMethodDtos.QuickPayRequest; +import com.zioinfo.kintex.visitor.paymethod.PayMethodDtos.QuickPayResultDto; +import com.zioinfo.kintex.visitor.paymethod.PayMethodDtos.RegisterRequest; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * F-B5 저장 결제수단 API — 인증(본인 소유분만). PG는 mock 재사용. + */ +@RestController +@RequestMapping("/api/visitor/pay-methods") +public class PayMethodController { + + private final PayMethodService service; + + public PayMethodController(PayMethodService service) { + this.service = service; + } + + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal KintexPrincipal principal) { + return ApiResponse.ok(service.list(VisitorAuth.requireUserId(principal))); + } + + @PostMapping + public ApiResponse register(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody RegisterRequest req) { + return ApiResponse.ok(service.register(VisitorAuth.requireUserId(principal), req)); + } + + @PostMapping("/{id}/default") + public ApiResponse setDefault(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + service.setDefault(VisitorAuth.requireUserId(principal), id); + return ApiResponse.ok(null); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, @PathVariable String id) { + service.delete(VisitorAuth.requireUserId(principal), id); + return ApiResponse.ok(null); + } + + /** POST /quick-pay — 저장 수단으로 빠른결제(mock PG 승인). */ + @PostMapping("/quick-pay") + public ApiResponse quickPay(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody QuickPayRequest req) { + return ApiResponse.ok(service.quickPay( + VisitorAuth.requireUserId(principal), req.methodId(), req.amount(), req.memo())); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/paymethod/PayMethodDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/paymethod/PayMethodDtos.java new file mode 100644 index 0000000..a8f0b50 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/paymethod/PayMethodDtos.java @@ -0,0 +1,52 @@ +package com.zioinfo.kintex.visitor.paymethod; + +import jakarta.validation.constraints.NotBlank; + +/** + * F-B5 저장 결제수단 DTO. 인증(본인 소유분만). + *

보안(§0-3): 카드 원문(PAN·CVC·유효기간)은 요청에서만 잠시 쓰고 저장하지 않는다 — 응답은 마스킹만. + */ +public final class PayMethodDtos { + + private PayMethodDtos() { + } + + /** + * 결제수단 등록 요청 — cardNumber 는 last4 산출·mock 토큰화에만 사용(저장 금지). + */ + public record RegisterRequest( + @NotBlank String kind, // card/easy/bank + String brand, // 신한/현대/카카오페이 등 + String cardNumber, // 원문(저장 안 함) + String label, + boolean makeDefault) { + } + + /** 저장 결제수단(응답) — 마스킹 표시값 + mock 토큰 식별자(원문 아님). */ + public record PayMethodDto( + String id, + String kind, + String brand, + String last4, + boolean isDefault, + String label, + String createdAt) { + } + + /** 간편결제(빠른결제) 요청 — 저장 수단으로 금액 승인(mock PG). */ + public record QuickPayRequest( + @NotBlank String methodId, + long amount, + String memo) { + } + + /** 간편결제 결과 — mock 승인번호. */ + public record QuickPayResultDto( + boolean approved, + String approvalNo, + long amount, + String brand, + String last4, + String message) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/paymethod/PayMethodMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/paymethod/PayMethodMapper.java new file mode 100644 index 0000000..a858a66 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/paymethod/PayMethodMapper.java @@ -0,0 +1,57 @@ +package com.zioinfo.kintex.visitor.paymethod; + +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-B5 저장 결제수단 매퍼. tenant_id='KINTEX' 고정. 본인(user_id) 소유분만. + *

보안(§0-3): 카드 원문 미보관 — brand·last4·pg_token(mock 빌링키)만. + */ +@Mapper +public interface PayMethodMapper { + + @Select(""" + SELECT id, kind, brand, last4, + is_default AS "isDefault", label, + to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "createdAt" + FROM visitor_pay_method + WHERE tenant_id = 'KINTEX' AND user_id = #{userId} + ORDER BY is_default DESC, created_at DESC + """) + List> listByUser(@Param("userId") String userId); + + /** 단건(본인 소유 검증 포함). 없으면 null. */ + @Select(""" + SELECT id, kind, brand, last4, pg_token AS "pgToken", + is_default AS "isDefault", label + FROM visitor_pay_method + WHERE tenant_id = 'KINTEX' AND user_id = #{userId} AND id = #{id} + """) + Map findOwned(@Param("userId") String userId, @Param("id") String id); + + @Insert(""" + INSERT INTO visitor_pay_method + (tenant_id, id, user_id, kind, brand, last4, pg_token, is_default, label) + VALUES + ('KINTEX', #{id}, #{userId}, #{kind}, #{brand}, #{last4}, #{pgToken}, #{isDefault}, #{label}) + """) + void insert(Map row); + + /** 사용자 전 수단 기본 해제(단일 기본 보장). */ + @Update("UPDATE visitor_pay_method SET is_default = false WHERE tenant_id='KINTEX' AND user_id = #{userId}") + void clearDefault(@Param("userId") String userId); + + /** 지정 수단을 기본으로(본인 소유분만). */ + @Update("UPDATE visitor_pay_method SET is_default = true WHERE tenant_id='KINTEX' AND user_id = #{userId} AND id = #{id}") + int setDefault(@Param("userId") String userId, @Param("id") String id); + + @Delete("DELETE FROM visitor_pay_method WHERE tenant_id='KINTEX' AND user_id = #{userId} AND id = #{id}") + int delete(@Param("userId") String userId, @Param("id") String id); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/paymethod/PayMethodService.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/paymethod/PayMethodService.java new file mode 100644 index 0000000..d360332 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/paymethod/PayMethodService.java @@ -0,0 +1,142 @@ +package com.zioinfo.kintex.visitor.paymethod; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.ticket.payment.PaymentGateway; +import com.zioinfo.kintex.visitor.paymethod.PayMethodDtos.PayMethodDto; +import com.zioinfo.kintex.visitor.paymethod.PayMethodDtos.QuickPayResultDto; +import com.zioinfo.kintex.visitor.paymethod.PayMethodDtos.RegisterRequest; +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-B5 저장 결제수단 서비스 — mock PG 토큰화 등록 + 저장 수단 빠른결제. + * + *

보안(§0-3): 카드 원문(PAN)은 last4 산출·토큰화에만 잠시 사용하고 저장하지 않는다. + * 실제 승인은 {@link PaymentGateway}(mock) 재사용. + */ +@Service +public class PayMethodService { + + private final PayMethodMapper mapper; + private final PaymentGateway paymentGateway; + private final SecureRandom random = new SecureRandom(); + + public PayMethodService(PayMethodMapper mapper, PaymentGateway paymentGateway) { + this.mapper = mapper; + this.paymentGateway = paymentGateway; + } + + @Transactional(readOnly = true) + public List list(String userId) { + return mapper.listByUser(userId).stream().map(PayMethodService::toDto).toList(); + } + + /** 결제수단 등록 — last4 마스킹 + mock 토큰화(원문 미저장). */ + @Transactional + public PayMethodDto register(String userId, RegisterRequest req) { + String kind = normalizeKind(req.kind()); + String last4 = last4(req.cardNumber()); + String pgToken = "MOCKTOK-" + HexFormat.of().formatHex(randomBytes(6)).toUpperCase(); + boolean makeDefault = req.makeDefault() || mapper.listByUser(userId).isEmpty(); + + if (makeDefault) { + mapper.clearDefault(userId); + } + String id = "vpm-" + UUID.randomUUID(); + Map row = new java.util.HashMap<>(); + row.put("id", id); + row.put("userId", userId); + row.put("kind", kind); + row.put("brand", blankToNull(req.brand())); + row.put("last4", last4); + row.put("pgToken", pgToken); + row.put("isDefault", makeDefault); + row.put("label", blankToNull(req.label())); + mapper.insert(row); + + Map saved = mapper.findOwned(userId, id); + return new PayMethodDto(id, kind, str(saved.get("brand")), last4, makeDefault, str(saved.get("label")), null); + } + + @Transactional + public void setDefault(String userId, String id) { + if (mapper.findOwned(userId, id) == null) { + throw new ApiException(ErrorCode.NOT_FOUND, "결제수단을 찾을 수 없습니다."); + } + mapper.clearDefault(userId); + mapper.setDefault(userId, id); + } + + @Transactional + public void delete(String userId, String id) { + if (mapper.delete(userId, id) != 1) { + throw new ApiException(ErrorCode.NOT_FOUND, "결제수단을 찾을 수 없습니다."); + } + } + + /** 저장 수단 빠른결제 — mock PG 승인. */ + @Transactional + public QuickPayResultDto quickPay(String userId, String methodId, long amount, String memo) { + Map m = mapper.findOwned(userId, methodId); + if (m == null) { + throw new ApiException(ErrorCode.NOT_FOUND, "저장된 결제수단을 찾을 수 없습니다."); + } + if (amount < 0) { + throw new ApiException(ErrorCode.VALIDATION, "결제 금액이 올바르지 않습니다."); + } + String orderNo = "QP-" + HexFormat.of().formatHex(randomBytes(5)).toUpperCase(); + PaymentGateway.PaymentResult pay = paymentGateway.authorize(str(m.get("kind")), amount, orderNo); + if (!pay.approved()) { + throw new ApiException(ErrorCode.CONFLICT, "결제 승인에 실패했습니다."); + } + return new QuickPayResultDto(true, pay.approvalNo(), amount, + str(m.get("brand")), str(m.get("last4")), "간편결제 승인 완료(모의)"); + } + + // ── helpers ── + private static PayMethodDto toDto(Map m) { + return new PayMethodDto(str(m.get("id")), str(m.get("kind")), str(m.get("brand")), + str(m.get("last4")), bool(m.get("isDefault")), str(m.get("label")), str(m.get("createdAt"))); + } + + private static String normalizeKind(String kind) { + String k = kind == null ? "card" : kind.trim().toLowerCase(java.util.Locale.ROOT); + return switch (k) { + case "card", "easy", "bank" -> k; + default -> "card"; + }; + } + + private static String last4(String cardNumber) { + if (cardNumber == null) { + return null; + } + String digits = cardNumber.replaceAll("[^0-9]", ""); + return digits.length() >= 4 ? digits.substring(digits.length() - 4) : null; + } + + private byte[] randomBytes(int n) { + byte[] b = new byte[n]; + 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(); + } + + private static boolean bool(Object o) { + return o instanceof Boolean b && b; + } +} diff --git a/src/backend/src/main/resources/db/migration/V100__visitor_membership.sql b/src/backend/src/main/resources/db/migration/V100__visitor_membership.sql new file mode 100644 index 0000000..f71560d --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V100__visitor_membership.sql @@ -0,0 +1,45 @@ +-- ============================================================================ +-- V100 — F-C6 관람객 멤버십/등급 (일반/바이어/VIP) +-- 담당: kintex-visitor(VIS)·admin(ADM) · 작성: 2026-07-23 +-- ---------------------------------------------------------------------------- +-- 목적: 재방문 횟수·바이어 인증 기반으로 관람객 등급을 산정하고 등급별 혜택을 표시. +-- 등급 산정(서버 권위): VIP = visit_count>=5 또는 vip_grant / BUYER = buyer_verified / 그 외 GENERAL. +-- 원칙: tenant_id='KINTEX' 고정. 본인(user_id)만. 전부 멱등. +-- ============================================================================ +CREATE TABLE IF NOT EXISTS visitor_membership ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + user_id varchar(64) NOT NULL, + visit_count integer NOT NULL DEFAULT 0, -- 누적 방문(체크인) 횟수 + buyer_verified boolean NOT NULL DEFAULT false, -- 바이어 인증 여부 + vip_grant boolean NOT NULL DEFAULT false, -- 수동 VIP 부여(초청 등) + points integer NOT NULL DEFAULT 0, + last_visit_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_visitor_membership PRIMARY KEY (tenant_id, user_id) +); + +-- ── 등급별 혜택 마스터(표시용) ────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS membership_benefit ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + tier varchar(16) NOT NULL, -- GENERAL/BUYER/VIP + seq integer NOT NULL DEFAULT 0, + benefit varchar(200) NOT NULL, + CONSTRAINT pk_membership_benefit PRIMARY KEY (tenant_id, tier, seq) +); + +INSERT INTO membership_benefit (tenant_id, tier, seq, benefit) VALUES + ('KINTEX','GENERAL',0,'전시 입장 및 기본 세션 신청'), + ('KINTEX','GENERAL',1,'전자 배지·모바일 티켓 지갑'), + ('KINTEX','BUYER',0,'바이어 라운지 이용'), + ('KINTEX','BUYER',1,'비즈니스 매칭 우선 배정'), + ('KINTEX','BUYER',2,'인기 세션 사전 예약 우선권'), + ('KINTEX','VIP',0,'VIP 라운지·전용 주차 우선'), + ('KINTEX','VIP',1,'전 세션 예약 최우선 + 대기열 상위'), + ('KINTEX','VIP',2,'전용 컨시어지·초청 네트워킹') +ON CONFLICT (tenant_id, tier, seq) DO NOTHING; + +-- 데모 계정(admin) 멤버십 시드 — 바이어 인증 + 방문 3회(BUYER 등급). app_user 존재 시에만. +INSERT INTO visitor_membership (tenant_id, user_id, visit_count, buyer_verified, vip_grant, points, last_visit_at) +SELECT 'KINTEX','admin-d80a2603', 3, true, false, 1250, now()-interval '10 day' +WHERE EXISTS (SELECT 1 FROM app_user u WHERE u.id = 'admin-d80a2603') +ON CONFLICT (tenant_id, user_id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V101__visitor_view_history.sql b/src/backend/src/main/resources/db/migration/V101__visitor_view_history.sql new file mode 100644 index 0000000..444e953 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V101__visitor_view_history.sql @@ -0,0 +1,31 @@ +-- ============================================================================ +-- V101 — F-C7 최근 본 행사·부스 피드 (개인화 홈) +-- 담당: kintex-visitor(VIS) · 작성: 2026-07-23 +-- ---------------------------------------------------------------------------- +-- 목적: 관람객 조회 이력(행사/부스)을 서버에 저장하고 개인화 홈 피드로 재노출(재방문 전환). +-- 원칙: tenant_id='KINTEX' 고정. 본인(user_id)만. 최근 200건 상한(서비스에서 정리). +-- 동일 (user, item) 재조회는 viewed_at 갱신(멱등 UPSERT). 전부 멱등. +-- ============================================================================ +CREATE TABLE IF NOT EXISTS visitor_view_history ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + id varchar(64) NOT NULL, + user_id varchar(64) NOT NULL, + item_type varchar(16) NOT NULL, -- EVENT/BOOTH + item_id varchar(64) NOT NULL, + item_title varchar(200) NOT NULL, + item_sub varchar(200), -- 부제(홀·기간 등 표시용) + viewed_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_visitor_view_history PRIMARY KEY (tenant_id, id), + CONSTRAINT uq_visitor_view_history_item UNIQUE (user_id, item_type, item_id) +); +CREATE INDEX IF NOT EXISTS ix_visitor_view_history_user ON visitor_view_history (tenant_id, user_id, viewed_at DESC); + +-- 데모 계정(admin) 최근 본 이력 3건(멱등) — app_user 존재 시에만. +INSERT INTO visitor_view_history (tenant_id, id, user_id, item_type, item_id, item_title, item_sub, viewed_at) +SELECT * FROM (VALUES + ('KINTEX','vvh-admin-1','admin-d80a2603','EVENT','e-2026-live','2026 국제 스마트 전시 위크','제1전시장 홀 1~5 · D-2', now()-interval '1 hour'), + ('KINTEX','vvh-admin-2','admin-d80a2603','EVENT','e-2026-smf','스마트팩토리 코리아 2026','제2전시장 홀 7~8', now()-interval '5 hour'), + ('KINTEX','vvh-admin-3','admin-d80a2603','BOOTH','booth-a12','글로벌테크 부스 A-12','스마트팩토리 관', now()-interval '1 day') +) AS v(tenant_id,id,user_id,item_type,item_id,item_title,item_sub,viewed_at) +WHERE EXISTS (SELECT 1 FROM app_user u WHERE u.id = 'admin-d80a2603') +ON CONFLICT (user_id, item_type, item_id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V106__multitenancy_demo_tenant.sql b/src/backend/src/main/resources/db/migration/V106__multitenancy_demo_tenant.sql new file mode 100644 index 0000000..86b0a37 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V106__multitenancy_demo_tenant.sql @@ -0,0 +1,26 @@ +-- V106 멀티테넌시 2단계 — 격리 증명용 2번째 데모 테넌트(비활성 소량 데이터). +-- 목표: KINTEX 외 별도 테넌트 'DEMO'를 소량 데이터와 함께 시드해, 테넌트 스코핑이 실제로 +-- 격리(cross-tenant 불가시)함을 런타임/테스트로 증명한다(§1A · §8-2). +-- 불변: V1~V105 불변. 전부 멱등(ON CONFLICT DO NOTHING). 기존 'KINTEX'(e-2026-live 포함) 데이터 무손상 — +-- 신규 행만 삽입하며 어떤 UPDATE/DELETE도 하지 않는다. +-- 표준: 신규 테넌트 소유 행은 복합 PK (tenant_id, id) 선두 컬럼(V31 표준). 코드는 대문자. + +-- 1) 데모 테넌트 마스터(온보딩 상태 — 비활성 성격) ------------------------------- +INSERT INTO tenant (id, name, domain, status) +VALUES ('DEMO', 'DEMO Expo Center', 'demo.kintex.example', 'onboarding') +ON CONFLICT (id) DO NOTHING; + +-- 2) 데모 테넌트 소속 행사 2건(과거·종료 = 비활성) — KINTEX 카탈로그와 완전 분리 -------- +-- event 필수 컬럼: id·name(NOT NULL). tenant_id 는 복합 PK 선두(V31). status='ended'. +INSERT INTO event (tenant_id, id, name, start_date, end_date, status) +VALUES + ('DEMO', 'ev-demo-iso-001', 'DEMO 격리검증 전시 A', DATE '2024-03-01', DATE '2024-03-03', 'ended'), + ('DEMO', 'ev-demo-iso-002', 'DEMO 격리검증 전시 B', DATE '2024-06-10', DATE '2024-06-12', 'ended') +ON CONFLICT (tenant_id, id) DO NOTHING; + +-- 3) 데모 테넌트 소속 사용자 1건(로그인 불가 더미) — 사용자 목록 격리 증명용 ----------- +-- password_hash 는 NOT NULL — 일부러 매칭 불가한 더미 값(BCrypt.matches 항상 실패). 응답 미노출 컬럼. +INSERT INTO app_user (id, email, display_name, password_hash, hall_manager, status, tenant_id) +VALUES ('u-demo-iso-001', 'demo-iso@demo.kintex.example', 'DEMO 격리검증 사용자', + 'x-not-a-valid-hash-login-disabled', false, 'ACTIVE', 'DEMO') +ON CONFLICT (id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V86__event_subscription.sql b/src/backend/src/main/resources/db/migration/V86__event_subscription.sql new file mode 100644 index 0000000..498c40e --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V86__event_subscription.sql @@ -0,0 +1,61 @@ +-- ============================================================================ +-- V86 — 관심 행사 구독(오픈 예정 행사) + 티켓 오픈 알림 (F-C1, 공개 사이트) +-- ============================================================================ +-- 공개(비로그인) 관람객이 "오픈 예정" 행사를 이메일로 구독 → 티켓 판매가 개시되면(오픈 감지 스케줄러) +-- 이메일 알림을 1회 발송한다. 발송 채널은 자체 Postfix SMTP(기승인, 옥션통지·EDM·비번재설정 재사용). +-- 표준: tenant_id='KINTEX'(대문자, 복합 PK 선두). FK 최소화(컬럼만). 멱등(IF NOT EXISTS / ON CONFLICT). +-- PII 최소화(§0-3): email 원문은 발송 경로 한정 — 로그·API 응답에는 email_masked / 건수만 노출. +-- 불투명 unsub_token 으로 원클릭 수신거부(PII 미포함). +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS event_subscription ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + id varchar(64) NOT NULL, + event_id varchar(64) NOT NULL, + email varchar(200) NOT NULL, -- 발송 경로 한정 원문(로그/응답 미노출) + email_masked varchar(200), -- wat***@example.com (노출 안전) + unsub_token varchar(64) NOT NULL, -- 불투명 원클릭 수신거부 토큰 + status varchar(16) NOT NULL DEFAULT 'active', -- active / unsubscribed + notified_at timestamptz, -- 티켓 오픈 알림 발송 시각(중복 발송 방지) + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_event_subscription PRIMARY KEY (tenant_id, id) +); + +-- 행사 단위 이메일 유니크(중복 구독 방지) — 대소문자 무시. +CREATE UNIQUE INDEX IF NOT EXISTS uq_event_subscription_email + ON event_subscription (tenant_id, event_id, lower(email)); + +-- 오픈 감지 스케줄러 대상(활성·미통지)만 인덱싱 → 작고 선택도 높음. +CREATE INDEX IF NOT EXISTS ix_event_subscription_pending + ON event_subscription (tenant_id, event_id) + WHERE status = 'active' AND notified_at IS NULL; + +-- 원클릭 수신거부 토큰 조회. +CREATE INDEX IF NOT EXISTS ix_event_subscription_token + ON event_subscription (unsub_token); + +-- ── 데모 시드(소량) — "오픈 예정" 구독 폐루프 시연 ──────────────────────────── +-- 오픈예정 데모 행사(티켓 판매 개시 D+20) — 구독 '대기' 상태가 화면에 보이도록(아직 오픈 전). +INSERT INTO event (id, name, start_date, end_date, status) +VALUES ('e-2026-upcoming', '2026 미래모빌리티 위크(오픈예정)', + current_date + 60, current_date + 63, 'active') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO ticket_product + (tenant_id, id, event_id, code, kind, name, description, price, sale_start, sale_end, + total_qty, sold_qty, max_per_order, status, sort_order) +VALUES + ('KINTEX', 'tp-upcoming-gen', 'e-2026-upcoming', 'UPC-GEN', 'GENERAL', '일반권', + '티켓 판매 개시 예정 · 구독 시 오픈 알림', 12000, + now() + interval '20 day', now() + interval '55 day', 3000, 0, 10, 'active', 0) +ON CONFLICT (tenant_id, id) DO NOTHING; + +-- 데모 구독 2건: ① 오픈예정 행사(대기·notified_at NULL) ② e-2026-live(이미 오픈 → 통지완료 상태 시드) +INSERT INTO event_subscription + (tenant_id, id, event_id, email, email_masked, unsub_token, status, notified_at) +VALUES + ('KINTEX', 'es-demo-1', 'e-2026-upcoming', 'watcher1@example.com', + 'wat***@example.com', 'estok-demo-00000001', 'active', NULL), + ('KINTEX', 'es-demo-2', 'e-2026-live', 'watcher2@example.com', + 'wat***@example.com', 'estok-demo-00000002', 'active', now() - interval '1 day') +ON CONFLICT (tenant_id, id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V87__event_live_notice.sql b/src/backend/src/main/resources/db/migration/V87__event_live_notice.sql new file mode 100644 index 0000000..952225e --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V87__event_live_notice.sql @@ -0,0 +1,42 @@ +-- ============================================================================ +-- V87 — 행사 라이브 공지 피드 (F-B6) +-- ============================================================================ +-- 주최자(ORGANIZER/관리자)가 행사 진행 중 라이브 공지(긴급·프로그램 변경 등)를 발행하고, +-- 관람객·공개 사이트에서 조회한다. 발행 시 §5B notification 모듈로 팬아웃(행사 멤버 in-app 알림) — +-- 별도 per-user 푸시 인프라를 신설하지 않고 기존 알림 모듈을 재사용한다. +-- 표준: tenant_id='KINTEX'(대문자, 복합 PK 선두). 멱등(IF NOT EXISTS / ON CONFLICT). +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS event_live_notice ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + id varchar(64) NOT NULL, + event_id varchar(64) NOT NULL, + category varchar(16) NOT NULL DEFAULT 'GENERAL', -- URGENT / PROGRAM / GENERAL / INFO + title varchar(200) NOT NULL, + body varchar(2000), + pinned boolean NOT NULL DEFAULT false, + status varchar(16) NOT NULL DEFAULT 'published', -- published / archived + author_name varchar(120), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_event_live_notice PRIMARY KEY (tenant_id, id) +); + +-- 공개 피드 조회(게시·고정 우선·최신순). +CREATE INDEX IF NOT EXISTS ix_live_notice_feed + ON event_live_notice (tenant_id, event_id, status, pinned, created_at DESC); + +-- ── 데모 시드(소량) — e-2026-live 라이브 공지 폐루프 ────────────────────────── +INSERT INTO event_live_notice + (tenant_id, id, event_id, category, title, body, pinned, status, author_name) +VALUES + ('KINTEX', 'ln-live-1', 'e-2026-live', 'URGENT', '[긴급] 제1주차장 만차 안내', + '제1주차장이 만차입니다. 제3주차장 또는 대중교통(지하철 대화역) 이용을 권장합니다.', + true, 'published', '운영본부'), + ('KINTEX', 'ln-live-2', 'e-2026-live', 'PROGRAM', '오후 키노트 시작시간 변경', + '메인 스테이지 오후 키노트가 14:00 에서 14:30 으로 30분 순연되었습니다.', + false, 'published', '프로그램팀'), + ('KINTEX', 'ln-live-3', 'e-2026-live', 'GENERAL', '푸드코트 운영시간 안내', + '푸드코트는 11:00~19:00 운영합니다. 피크타임(12~13시) 혼잡이 예상됩니다.', + false, 'published', '운영본부') +ON CONFLICT (tenant_id, id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V90__parking_domain.sql b/src/backend/src/main/resources/db/migration/V90__parking_domain.sql new file mode 100644 index 0000000..0fbedef --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V90__parking_domain.sql @@ -0,0 +1,67 @@ +-- ============================================================================ +-- V90 — 관람객 주차 연계(F-C2): 주차장 마스터 + 사전 주차권(mock 결제) +-- 담당: kintex-backend-dev · 작성: 2026-07-23 +-- ---------------------------------------------------------------------------- +-- 목적: 관람객 앱/공개 사이트의 ①주차 현황(잔여·요금·혼잡) ②사전 주차권 구매(mock 결제) +-- ③내 주차권 조회를 위한 최소 스키마. +-- 원칙: 표준 tenant_id='KINTEX'(대문자)·복합 PK 선두. FK 최소화(컬럼만, 제약 없음). +-- 실시간 잔여/점유는 외부(iparking 등) 미연동 → ParkingService 시뮬레이션 어댑터가 산출. +-- DB에는 주차장 정착 마스터 + 발급된 주차권만 저장한다(실시간 점유 미보관). +-- PII(§0-3): 차량번호 원문 미보관 — vehicle_plate_masked(12가**56)만 저장. 소유자는 내부 user_id. +-- 전부 멱등(ON CONFLICT DO NOTHING / IF NOT EXISTS) — 재실행 안전. +-- ============================================================================ + +-- ── 1) 주차장 마스터 ───────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS parking_lot ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + id varchar(64) NOT NULL, + code varchar(32) NOT NULL, + name varchar(120) NOT NULL, + exhibition_center integer, -- 1=제1전시장, 2=제2전시장 (null=공용) + total_capacity integer NOT NULL DEFAULT 0, -- 총 주차면 + hourly_rate numeric(10,0) NOT NULL DEFAULT 0, -- 시간당 요금(원) + daily_max numeric(10,0), -- 1일 상한(원, null=상한없음) + pass_price numeric(10,0) NOT NULL DEFAULT 0, -- 사전 주차권(1일권) 가격(원) + note varchar(300), + status varchar(16) NOT NULL DEFAULT 'active', -- active/closed + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_parking_lot PRIMARY KEY (tenant_id, id), + CONSTRAINT uq_parking_lot_code UNIQUE (tenant_id, code) +); +CREATE INDEX IF NOT EXISTS ix_parking_lot_active ON parking_lot (tenant_id, status, sort_order); + +-- ── 2) 사전 주차권(발급) ───────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS parking_pass ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + id varchar(64) NOT NULL, + pass_no varchar(40) NOT NULL, + event_id varchar(64), -- 연계 행사(선택) + lot_id varchar(64) NOT NULL, + lot_name varchar(120) NOT NULL, -- 구매 시점 스냅샷 + user_id varchar(64) NOT NULL, -- 소유자(인증 관람객) + use_date date NOT NULL, -- 이용일(1일권) + vehicle_plate_masked varchar(24), -- 차량번호 마스킹(원문 미보관) + amount numeric(10,0) NOT NULL DEFAULT 0, + status varchar(16) NOT NULL DEFAULT 'PAID', -- PAID/CANCELLED/USED + pay_method varchar(24), -- card/easy/bank/free + pay_approval_no varchar(64), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_parking_pass PRIMARY KEY (tenant_id, id), + CONSTRAINT uq_parking_pass_no UNIQUE (pass_no), + CONSTRAINT ck_parking_pass_amount CHECK (amount >= 0) +); +CREATE INDEX IF NOT EXISTS ix_parking_pass_user ON parking_pass (tenant_id, user_id, use_date DESC); +CREATE INDEX IF NOT EXISTS ix_parking_pass_lot_date ON parking_pass (tenant_id, lot_id, use_date, status); + +-- ============================================================================ +-- 시드 — 킨텍스 주차장 마스터(최소). 실시간 점유는 시뮬레이션 어댑터가 산출. +-- ============================================================================ +INSERT INTO parking_lot + (tenant_id, id, code, name, exhibition_center, total_capacity, hourly_rate, daily_max, pass_price, note, status, sort_order) VALUES + ('KINTEX','pl-w1','W1','제1전시장 서측 주차장', 1, 1800, 1200, 12000, 10000, '제1전시장 최근접 · 대형 주차장', 'active', 0), + ('KINTEX','pl-e1','E1','제1전시장 동측 주차장', 1, 1200, 1200, 12000, 10000, '제1전시장 동문 인접', 'active', 1), + ('KINTEX','pl-w2','W2','제2전시장 서측 주차장', 2, 2000, 1200, 12000, 10000, '제2전시장 최근접', 'active', 2), + ('KINTEX','pl-e2','E2','제2전시장 동측 주차장', 2, 1500, 1200, 12000, 10000, '제2전시장 동문 인접', 'active', 3), + ('KINTEX','pl-out','OUT','야외 임시 주차장', NULL, 900, 1000, 10000, 8000, '성수기 임시 개방(도보 이동)', 'active', 4) +ON CONFLICT (tenant_id, id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V94__ticket_smart_guard.sql b/src/backend/src/main/resources/db/migration/V94__ticket_smart_guard.sql new file mode 100644 index 0000000..05a6b15 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V94__ticket_smart_guard.sql @@ -0,0 +1,23 @@ +-- ============================================================================ +-- V94 — F-B1 스마트티켓 부정입장 방지: 디바이스 바인딩 + 회전(TOTP) QR 시크릿 +-- 담당: kintex-visitor(VIS)·backend · 작성: 2026-07-23 +-- ---------------------------------------------------------------------------- +-- 목적: 정적 QR(캡처본 재사용 가능) → ①발권 티켓을 특정 디바이스에 바인딩하고 +-- ②30초 회전 TOTP 토큰을 발급/검증한다. 캡처 스크린샷은 해당 30초 창(+직전 1창 유예)만 +-- 유효하므로 양도/재사용을 차단한다. +-- 원칙: 표준 tenant_id='KINTEX'(대문자)·복합 PK 선두. FK 최소화(컬럼만). +-- 보안(§0-3): 디바이스 식별자 원문 미보관 — device_hash=sha256(deviceId) 만 저장. +-- 회전 시크릿(secret)은 서버 전용(응답·로그 미노출) — 토큰만 클라이언트로 나간다. +-- 전부 멱등(IF NOT EXISTS / ON CONFLICT) — 재실행 안전. +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS ticket_smart_binding ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + ticket_code varchar(48) NOT NULL, -- 발권 티켓(ticket_issue.ticket_code) + device_hash varchar(64) NOT NULL, -- sha256(deviceId) — 바인딩 기기 + secret varchar(64) NOT NULL, -- 회전 TOTP HMAC 시크릿(서버 전용) + bound_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz, + CONSTRAINT pk_ticket_smart_binding PRIMARY KEY (tenant_id, ticket_code) +); +CREATE INDEX IF NOT EXISTS ix_ticket_smart_binding_dev ON ticket_smart_binding (tenant_id, device_hash); diff --git a/src/backend/src/main/resources/db/migration/V95__ticket_cancel_policy.sql b/src/backend/src/main/resources/db/migration/V95__ticket_cancel_policy.sql new file mode 100644 index 0000000..0d9a8b0 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V95__ticket_cancel_policy.sql @@ -0,0 +1,72 @@ +-- ============================================================================ +-- V95 — F-B2 취소·환불 규정 정교화: 다구간 취소수수료 + 예매수수료 별도 규정 +-- 담당: kintex-visitor(VIS)·backend · 작성: 2026-07-23 +-- ---------------------------------------------------------------------------- +-- 목적: 현행 3구간(D-7 100% / D-3 50% / 당일 0%) → 다구간 취소수수료 규정을 서버 권위로 산정. +-- ① 티켓 금액(환불 대상)에는 구간별 환불율 적용. +-- ② 예매수수료(booking fee)는 별도 규정 — 취소 시점에 따라 환불/비환불 분리. +-- ③ 취소 관리수수료(cancel admin fee)는 환불액에서 공제. +-- 원칙: 코드가 아닌 데이터로 버전 관리(규정 개정 대응). 기본 정책(event_id NULL) + 행사별 오버라이드 여지. +-- tenant_id='KINTEX' 고정. 전부 멱등. +-- ============================================================================ + +-- ── 1) 취소 환불 구간(다구간) ──────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ticket_cancel_bracket ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + id varchar(64) NOT NULL, + event_id varchar(64), -- NULL=기본 정책(전 행사 적용) + min_days_before integer NOT NULL, -- 개막 D-일 이상 남으면 이 구간 + refund_rate_percent numeric(5,1) NOT NULL DEFAULT 0, -- 티켓액 환불율(%) + label varchar(80) NOT NULL, + sort_order integer NOT NULL DEFAULT 0, + CONSTRAINT pk_ticket_cancel_bracket PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS ix_ticket_cancel_bracket_ev ON ticket_cancel_bracket (tenant_id, event_id, min_days_before); + +-- ── 2) 수수료 정책(예매수수료 · 취소 관리수수료) ───────────────────────────── +CREATE TABLE IF NOT EXISTS ticket_fee_policy ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + event_id varchar(64) NOT NULL DEFAULT '__default__', -- '__default__'=기본 + booking_fee_per_ticket numeric(10,0) NOT NULL DEFAULT 0, -- 매당 예매수수료(원) + booking_fee_refund_min_days integer NOT NULL DEFAULT 7, -- 이 D-일 이상 취소 시에만 예매수수료 환불 + cancel_admin_fee_percent numeric(5,1) NOT NULL DEFAULT 0, -- 환불액에서 공제하는 취소 관리수수료(%) + version varchar(32) NOT NULL DEFAULT 'cancel-v1.0', + CONSTRAINT pk_ticket_fee_policy PRIMARY KEY (tenant_id, event_id) +); + +-- ── 3) 취소 이력(감사·환불 산정 스냅샷) ────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ticket_cancellation ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + id varchar(64) NOT NULL, + order_id varchar(64) NOT NULL, + order_no varchar(40) NOT NULL, + days_before integer, + ticket_amount numeric(12,0) NOT NULL DEFAULT 0, -- 환불 대상 티켓액(예매수수료 제외) + booking_fee numeric(12,0) NOT NULL DEFAULT 0, + refund_rate numeric(5,1) NOT NULL DEFAULT 0, + admin_fee numeric(12,0) NOT NULL DEFAULT 0, + refund_amount numeric(12,0) NOT NULL DEFAULT 0, -- 최종 환불액 + forfeit_amount numeric(12,0) NOT NULL DEFAULT 0, -- 공제(위약)액 + policy_version varchar(32), + cancelled_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_ticket_cancellation PRIMARY KEY (tenant_id, id), + CONSTRAINT uq_ticket_cancellation_order UNIQUE (order_id) +); + +-- ============================================================================ +-- 시드 — 기본 다구간 정책(멱등). D-일 큰 구간일수록 환불율 높음. +-- ============================================================================ +INSERT INTO ticket_cancel_bracket (tenant_id, id, event_id, min_days_before, refund_rate_percent, label, sort_order) VALUES + ('KINTEX','tcb-d10','__NULL__', 10, 100.0, '개막 10일 전까지 · 전액 환불', 0), + ('KINTEX','tcb-d7', '__NULL__', 7, 90.0, '개막 7일 전까지 · 90% 환불', 1), + ('KINTEX','tcb-d3', '__NULL__', 3, 70.0, '개막 3일 전까지 · 70% 환불', 2), + ('KINTEX','tcb-d1', '__NULL__', 1, 50.0, '개막 1일 전까지 · 50% 환불', 3), + ('KINTEX','tcb-d0', '__NULL__', 0, 0.0, '개막 당일·이후 · 환불 불가', 4) +ON CONFLICT (tenant_id, id) DO NOTHING; +-- event_id 는 기본 정책이므로 NULL 로 정정(문자 '__NULL__' 회피). +UPDATE ticket_cancel_bracket SET event_id = NULL WHERE event_id = '__NULL__'; + +INSERT INTO ticket_fee_policy + (tenant_id, event_id, booking_fee_per_ticket, booking_fee_refund_min_days, cancel_admin_fee_percent, version) VALUES + ('KINTEX','__default__', 1000, 7, 5.0, 'cancel-v1.0') +ON CONFLICT (tenant_id, event_id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V96__session_rsvp.sql b/src/backend/src/main/resources/db/migration/V96__session_rsvp.sql new file mode 100644 index 0000000..4b01d1b --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V96__session_rsvp.sql @@ -0,0 +1,76 @@ +-- ============================================================================ +-- V96 — F-B4 세션 정원 기반 사전등록(RSVP) + F-C5 대기열·취소표 +-- 담당: kintex-visitor(VIS)·backend · 작성: 2026-07-23 +-- ---------------------------------------------------------------------------- +-- 목적: 컨퍼런스 세션에 정원·마감·대기 상태를 부여한다. +-- ① 정원 여유 → CONFIRMED, ② 정원 소진 → WAITLIST(대기), ③ 취소 발생 → 대기 1명 자동 승격 + 알림. +-- 원칙: tenant_id='KINTEX' 고정. FK 최소화. 전부 멱등. +-- 동시성: 확정 수 증가는 조건부 UPDATE(seat_taken < capacity)로 오버부킹을 원자 차단. +-- ============================================================================ + +-- ── 1) 세션 마스터 ─────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS event_session ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + id varchar(64) NOT NULL, + event_id varchar(64) NOT NULL, + title varchar(200) NOT NULL, + speaker varchar(160), + room varchar(80), + track varchar(80), + starts_at timestamptz, + ends_at timestamptz, + capacity integer NOT NULL DEFAULT 0, -- 0=정원 무제한 + seat_taken integer NOT NULL DEFAULT 0, -- 확정 좌석 수(RSVP CONFIRMED) + status varchar(16) NOT NULL DEFAULT 'open', -- open/closed + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_event_session PRIMARY KEY (tenant_id, id), + CONSTRAINT ck_event_session_seat CHECK (seat_taken >= 0) +); +CREATE INDEX IF NOT EXISTS ix_event_session_event ON event_session (tenant_id, event_id, sort_order); + +-- ── 2) RSVP(관람객 세션 신청) ─────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS session_rsvp ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + id varchar(64) NOT NULL, + session_id varchar(64) NOT NULL, + event_id varchar(64) NOT NULL, + user_id varchar(64) NOT NULL, + status varchar(16) NOT NULL DEFAULT 'CONFIRMED', -- CONFIRMED/WAITLIST/CANCELLED + waitlist_pos integer, -- WAITLIST 순번(대기 진입 시점 seq) + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_session_rsvp PRIMARY KEY (tenant_id, id), + CONSTRAINT uq_session_rsvp_user UNIQUE (session_id, user_id) +); +CREATE INDEX IF NOT EXISTS ix_session_rsvp_user ON session_rsvp (tenant_id, user_id, status); +CREATE INDEX IF NOT EXISTS ix_session_rsvp_wait ON session_rsvp (tenant_id, session_id, status, waitlist_pos); + +-- ============================================================================ +-- 시드 — e-2026-live 세션 5종. sess-keynote 는 정원=확정(소진)으로 대기열 데모 유도. +-- ============================================================================ +INSERT INTO event_session + (tenant_id, id, event_id, title, speaker, room, track, starts_at, ends_at, capacity, seat_taken, status, sort_order) VALUES + ('KINTEX','sess-keynote','e-2026-live','기조연설 — 전시산업의 AI 전환','김전시 (킨텍스 원장)','컨퍼런스룸 A','Keynote', + now()+interval '2 day', now()+interval '2 day' + interval '1 hour', 120, 120, 'open', 0), + ('KINTEX','sess-ai','e-2026-live','부스 자동설계 실무 세션','이설계 (수석)','세미나실 1','Tech', + now()+interval '2 day' + interval '2 hour', now()+interval '2 day' + interval '3 hour', 60, 42, 'open', 1), + ('KINTEX','sess-buyer','e-2026-live','바이어 매칭 라운드테이블','박매칭 (매니저)','미팅룸 3','Business', + now()+interval '2 day' + interval '4 hour', now()+interval '2 day' + interval '5 hour', 30, 24, 'open', 2), + ('KINTEX','sess-safe','e-2026-live','현장 안전·시공 규정 브리핑','최안전 (홀매니저)','세미나실 2','Ops', + now()+interval '3 day', now()+interval '3 day' + interval '1 hour', 80, 15, 'open', 3), + ('KINTEX','sess-net','e-2026-live','참가업체 네트워킹 나이트','운영팀','로비 라운지','Networking', + now()+interval '3 day' + interval '6 hour', now()+interval '3 day' + interval '8 hour', 0, 0, 'open', 4) +ON CONFLICT (tenant_id, id) DO NOTHING; + +-- 대기열 데모: 기조연설(정원 소진)에 데모 대기자 2명 시드(멱등). +INSERT INTO session_rsvp (tenant_id, id, session_id, event_id, user_id, status, waitlist_pos) VALUES + ('KINTEX','rsvp-wait-1','sess-keynote','e-2026-live','demo-visitor-01','WAITLIST', 1), + ('KINTEX','rsvp-wait-2','sess-keynote','e-2026-live','demo-visitor-02','WAITLIST', 2) +ON CONFLICT (session_id, user_id) DO NOTHING; + +-- 데모 계정(admin) 확정 RSVP 1건(마이 화면 폐루프) — app_user 존재 시에만. +INSERT INTO session_rsvp (tenant_id, id, session_id, event_id, user_id, status) +SELECT 'KINTEX','rsvp-admin-safe','sess-safe','e-2026-live','admin-d80a2603','CONFIRMED' +WHERE EXISTS (SELECT 1 FROM app_user u WHERE u.id = 'admin-d80a2603') + AND NOT EXISTS (SELECT 1 FROM session_rsvp r WHERE r.session_id='sess-safe' AND r.user_id='admin-d80a2603'); diff --git a/src/backend/src/main/resources/db/migration/V97__visitor_pay_method.sql b/src/backend/src/main/resources/db/migration/V97__visitor_pay_method.sql new file mode 100644 index 0000000..39e51d6 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V97__visitor_pay_method.sql @@ -0,0 +1,32 @@ +-- ============================================================================ +-- V97 — F-B5 결제수단 사전등록·간편결제 (mock PG 토큰화) +-- 담당: kintex-visitor(VIS)·backend · 작성: 2026-07-23 +-- ---------------------------------------------------------------------------- +-- 목적: 로그인 관람객이 결제수단을 사전 등록(mock PG 토큰화)하고, 저장 수단으로 빠른결제. +-- 원칙: tenant_id='KINTEX' 고정. 본인(user_id) 소유분만. +-- 보안(§0-3): 카드 원문(PAN·유효기간·CVC) 절대 미보관 — 브랜드 + 마스킹 뒤4자리 + mock PG 토큰만 저장. +-- 전부 멱등. +-- ============================================================================ +CREATE TABLE IF NOT EXISTS visitor_pay_method ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + id varchar(64) NOT NULL, + user_id varchar(64) NOT NULL, + kind varchar(16) NOT NULL DEFAULT 'card', -- card/easy/bank + brand varchar(40), -- 신한/현대/카카오페이 등(표시용) + last4 varchar(4), -- 마스킹 뒤 4자리(표시용) + pg_token varchar(80) NOT NULL, -- mock PG 빌링 토큰(원문 카드정보 아님) + is_default boolean NOT NULL DEFAULT false, + label varchar(60), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_visitor_pay_method PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS ix_visitor_pay_method_user ON visitor_pay_method (tenant_id, user_id, is_default DESC, created_at DESC); + +-- 데모 계정(admin) 저장 결제수단 2종(멱등) — app_user 존재 시에만. +INSERT INTO visitor_pay_method (tenant_id, id, user_id, kind, brand, last4, pg_token, is_default, label) +SELECT * FROM (VALUES + ('KINTEX','vpm-admin-1','admin-d80a2603','card','신한카드','4821','MOCKTOK-SH-4821', true, '기본 결제카드'), + ('KINTEX','vpm-admin-2','admin-d80a2603','easy','카카오페이','0000','MOCKTOK-KKO-0000', false, '카카오페이') +) AS v(tenant_id,id,user_id,kind,brand,last4,pg_token,is_default,label) +WHERE EXISTS (SELECT 1 FROM app_user u WHERE u.id = 'admin-d80a2603') +ON CONFLICT (tenant_id, id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V98__visitor_contact.sql b/src/backend/src/main/resources/db/migration/V98__visitor_contact.sql new file mode 100644 index 0000000..1225d2c --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V98__visitor_contact.sql @@ -0,0 +1,56 @@ +-- ============================================================================ +-- V98 — F-C4 관람객↔관람객 QR 명함 교환·컨택트 지갑 +-- 담당: kintex-visitor(VIS) · 작성: 2026-07-23 +-- ---------------------------------------------------------------------------- +-- 목적: 리드캡처(업체→관람객) 외에 관람객 상호 QR 스캔으로 명함(컨택트)을 교환·지갑 저장. +-- 원칙: tenant_id='KINTEX' 고정. 본인(owner_user_id) 지갑만 조회. +-- 보안(§0-3): 저장 컨택트의 연락처·이메일은 마스킹 저장(원문 미보관). 공유 QR 토큰은 회전·재사용 제한 없이 +-- 단순 랜덤 식별자(교환 매개) — 명함 카드 자체가 공개 공유 대상이므로 PII는 소유자가 등록한 표시값만. +-- 전부 멱등. +-- ============================================================================ + +-- ── 1) 내 명함 카드(공유 대상) ─────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS visitor_contact_card ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + user_id varchar(64) NOT NULL, + display_name varchar(120) NOT NULL, + company varchar(160), + title varchar(120), + email_masked varchar(160), + phone_masked varchar(40), + share_token varchar(48) NOT NULL, -- QR 교환 토큰(명함 공유 매개) + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_visitor_contact_card PRIMARY KEY (tenant_id, user_id), + CONSTRAINT uq_visitor_contact_card_tok UNIQUE (share_token) +); + +-- ── 2) 컨택트 지갑(교환으로 저장된 상대 명함) ──────────────────────────────── +CREATE TABLE IF NOT EXISTS visitor_contact ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + id varchar(64) NOT NULL, + owner_user_id varchar(64) NOT NULL, -- 지갑 소유자 + contact_user_id varchar(64), -- 상대(교환 상대 user_id) + display_name varchar(120) NOT NULL, + company varchar(160), + title varchar(120), + email_masked varchar(160), + phone_masked varchar(40), + memo varchar(300), + saved_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_visitor_contact PRIMARY KEY (tenant_id, id), + CONSTRAINT uq_visitor_contact_pair UNIQUE (owner_user_id, contact_user_id) +); +CREATE INDEX IF NOT EXISTS ix_visitor_contact_owner ON visitor_contact (tenant_id, owner_user_id, saved_at DESC); + +-- ============================================================================ +-- 시드 — 데모 계정(admin) 명함 카드 + 교환된 컨택트 1건(멱등). app_user 존재 시에만. +-- ============================================================================ +INSERT INTO visitor_contact_card (tenant_id, user_id, display_name, company, title, email_masked, phone_masked, share_token) +SELECT 'KINTEX','admin-d80a2603','관람객(데모)','킨텍스','매니저','ad***@example.com','010-****-0001','SHARE-ADMIN-DEMO01' +WHERE EXISTS (SELECT 1 FROM app_user u WHERE u.id = 'admin-d80a2603') +ON CONFLICT (tenant_id, user_id) DO NOTHING; + +INSERT INTO visitor_contact (tenant_id, id, owner_user_id, contact_user_id, display_name, company, title, email_masked, phone_masked, memo) +SELECT 'KINTEX','vc-admin-1','admin-d80a2603','demo-visitor-01','김바이어','글로벌테크','구매팀장','ki***@example.com','010-****-3456','스마트팩토리 관 부스 A-12 미팅' +WHERE EXISTS (SELECT 1 FROM app_user u WHERE u.id = 'admin-d80a2603') +ON CONFLICT (owner_user_id, contact_user_id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V99__visitor_notification.sql b/src/backend/src/main/resources/db/migration/V99__visitor_notification.sql new file mode 100644 index 0000000..566cbed --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V99__visitor_notification.sql @@ -0,0 +1,28 @@ +-- ============================================================================ +-- V99 — F-C5 대기열·취소표 알림 (관람객 인앱 알림함) +-- 담당: kintex-visitor(VIS) · 작성: 2026-07-23 +-- ---------------------------------------------------------------------------- +-- 목적: 세션/미팅 대기 등록 후 취소표 발생 시 대기자 승격 알림을 인앱 알림함에 적재. +-- (외부 채널 발송은 별도 — 여기서는 관람객 대면 인앱 알림 저장/조회.) +-- 원칙: tenant_id='KINTEX' 고정. 본인(user_id) 알림만 조회. 전부 멱등. +-- ============================================================================ +CREATE TABLE IF NOT EXISTS visitor_notification ( + tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX', + id varchar(64) NOT NULL, + user_id varchar(64) NOT NULL, + kind varchar(32) NOT NULL DEFAULT 'INFO', -- WAITLIST_PROMOTED/RSVP/MEMBERSHIP/INFO + title varchar(160) NOT NULL, + body varchar(400), + ref_type varchar(32), -- SESSION/EVENT/… + ref_id varchar(64), + is_read boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT pk_visitor_notification PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS ix_visitor_notification_user ON visitor_notification (tenant_id, user_id, is_read, created_at DESC); + +-- 데모 알림 1건(멱등) — app_user 존재 시에만. +INSERT INTO visitor_notification (tenant_id, id, user_id, kind, title, body, ref_type, ref_id) +SELECT 'KINTEX','vn-admin-1','admin-d80a2603','RSVP','세션 신청이 확정되었습니다','현장 안전·시공 규정 브리핑 좌석이 확정되었습니다.','SESSION','sess-safe' +WHERE EXISTS (SELECT 1 FROM app_user u WHERE u.id = 'admin-d80a2603') +ON CONFLICT (tenant_id, id) DO NOTHING; diff --git a/src/backend/src/main/resources/fonts/NanumGothic-Bold.ttf b/src/backend/src/main/resources/fonts/NanumGothic-Bold.ttf new file mode 100644 index 0000000..c24b0e7 Binary files /dev/null and b/src/backend/src/main/resources/fonts/NanumGothic-Bold.ttf differ diff --git a/src/backend/src/main/resources/fonts/fonts.xml b/src/backend/src/main/resources/fonts/fonts.xml new file mode 100644 index 0000000..5c55fb3 --- /dev/null +++ b/src/backend/src/main/resources/fonts/fonts.xml @@ -0,0 +1,19 @@ + + + + + fonts/NanumGothic.ttf + fonts/NanumGothic-Bold.ttf + fonts/NanumGothic.ttf + fonts/NanumGothic-Bold.ttf + Identity-H + true + + 'NanumGothic', sans-serif + + + diff --git a/src/backend/src/main/resources/jasperreports_extension.properties b/src/backend/src/main/resources/jasperreports_extension.properties new file mode 100644 index 0000000..c021a9c --- /dev/null +++ b/src/backend/src/main/resources/jasperreports_extension.properties @@ -0,0 +1,2 @@ +net.sf.jasperreports.extension.registry.factory.simple.font.families=net.sf.jasperreports.engine.fonts.SimpleFontExtensionsRegistryFactory +net.sf.jasperreports.extension.simple.font.families.kintex=fonts/fonts.xml diff --git a/src/backend/src/main/resources/mybatis/mapper/BoothMapper.xml b/src/backend/src/main/resources/mybatis/mapper/BoothMapper.xml index 074b7c4..cf890bd 100644 --- a/src/backend/src/main/resources/mybatis/mapper/BoothMapper.xml +++ b/src/backend/src/main/resources/mybatis/mapper/BoothMapper.xml @@ -14,7 +14,7 @@ --> - + + + + + + +