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 @@
-->
-
+
SELECT id AS "layoutId",
version,
@@ -27,10 +27,31 @@
AND version = #{version}
- ORDER BY version DESC
+ ORDER BY (status = 'proposal'), version DESC
LIMIT 1
+
+
+ SELECT MAX(version)
+ FROM layout
+ WHERE event_id = #{eventId}
+ AND hall_id = #{hallId}
+
+
+
+
+ SELECT l.version AS "version",
+ l.name AS "name",
+ l.status AS "status",
+ (SELECT COUNT(*) FROM booth b WHERE b.layout_id = l.id) AS "boothCount",
+ to_char(l.updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
+ FROM layout l
+ WHERE l.event_id = #{eventId}
+ AND l.hall_id = #{hallId}
+ ORDER BY l.version DESC
+
+
SELECT id AS "boothId",
diff --git a/src/backend/src/main/resources/reports/grid_report.jrxml b/src/backend/src/main/resources/reports/grid_report.jrxml
new file mode 100644
index 0000000..d1c8d8f
--- /dev/null
+++ b/src/backend/src/main/resources/reports/grid_report.jrxml
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/backend/src/main/resources/reports/kintex_ci.jpg b/src/backend/src/main/resources/reports/kintex_ci.jpg
new file mode 100644
index 0000000..a9ef041
Binary files /dev/null and b/src/backend/src/main/resources/reports/kintex_ci.jpg differ
diff --git a/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java
index 2a8d38c..425f722 100644
--- a/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java
+++ b/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java
@@ -69,6 +69,34 @@ class AnalyticsServiceTest {
assertTrue(dto.exhibitorPerf().isEmpty());
}
+ @Test
+ void aggregatesSectorsFromCompanyCategoryAreaShare() {
+ when(mapper.findRentalRatePerM2()).thenReturn(2000.0);
+ when(mapper.findAreaAndCount("e1")).thenReturn(new HashMap<>());
+ when(mapper.findUtilityRevenue("e1")).thenReturn(0.0);
+ when(mapper.findMonthlyTrend("e1", 0)).thenReturn(List.of());
+
+ Map ict = new HashMap<>();
+ ict.put("sector", "ICT/소프트웨어");
+ ict.put("boothCount", 3L);
+ ict.put("areaM2", 60000.0); // 75%
+ Map mfg = new HashMap<>();
+ mfg.put("sector", "제조/자동화");
+ mfg.put("boothCount", 1L);
+ mfg.put("areaM2", 20000.0); // 25%
+ when(mapper.findSectorAggregation("e1")).thenReturn(List.of(ict, mfg));
+
+ AnalyticsDto dto = service.getAnalytics("e1", "operator", "event");
+
+ assertEquals(2, dto.sectors().size());
+ // 매퍼 정렬(면적 내림차순) 보존 + 점유율 = 면적 비중.
+ AnalyticsDto.Sector top = dto.sectors().get(0);
+ assertEquals("ICT/소프트웨어", top.name());
+ assertEquals(75.0, top.sharePercent());
+ assertEquals(120_000_000L, top.revenue()); // 60000 * 2000
+ assertEquals(25.0, dto.sectors().get(1).sharePercent());
+ }
+
@Test
void overviewComputesRetentionOccupancyAndForecast() {
// 행사 횡단 집계는 테넌트 스코프(currentTenantId 기본 'kintex') — 매처는 테넌트 무관 검증.
diff --git a/src/backend/src/test/java/com/zioinfo/kintex/auction/AuctionRealtimeRankingTest.java b/src/backend/src/test/java/com/zioinfo/kintex/auction/AuctionRealtimeRankingTest.java
new file mode 100644
index 0000000..2debebc
--- /dev/null
+++ b/src/backend/src/test/java/com/zioinfo/kintex/auction/AuctionRealtimeRankingTest.java
@@ -0,0 +1,76 @@
+package com.zioinfo.kintex.auction;
+
+import com.zioinfo.kintex.auction.dto.AuctionDtos.RankRow;
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * M15 실시간 순위 브로드캐스트(WebSocket {@code /topic/auctions/{id}/ranking})의 봉인 안전성 회귀 테스트.
+ *
+ * {@link AuctionRealtimeService}는 순위 변동(BID/CLOSE/AWARD) 시 {@link AuctionService#publicRanking}로
+ * 만든 공개 스냅샷을 모든 구독자에게 동일 전송한다. 이 페이로드가 어떤 뷰어별 개인화 정보(내 순위 표시·타사 금액·
+ * 업체 실명)도 담지 않음을 고정하여, 폴링→푸시 전환 후에도 노출 범위가 기존 공개 폴링과 동일함을 보장한다.
+ */
+class AuctionRealtimeRankingTest {
+
+ private static Map bid(long total) {
+ Map m = new LinkedHashMap<>();
+ m.put("total", total);
+ m.put("companyId", "c-secret-" + total); // 존재해도 스냅샷에 새어나가면 안 됨
+ m.put("companyName", "비밀장치㈜"); // 업체 실명 — 절대 미노출
+ return m;
+ }
+
+ @Test
+ void broadcastSnapshot_neverMarksIsMe() {
+ List ranking = AuctionService.publicRanking(List.of(bid(8_400_000L), bid(8_700_000L), bid(9_100_000L)));
+ // 브로드캐스트는 모든 구독자 공용 → 뷰어별 'isMe' 는 항상 false 여야 한다.
+ assertTrue(ranking.stream().noneMatch(RankRow::isMe), "브로드캐스트 순위는 isMe 를 담아선 안 된다(개인화 금지)");
+ }
+
+ @Test
+ void broadcastSnapshot_revealsOnlyLowestPrice() {
+ List ranking = AuctionService.publicRanking(List.of(bid(8_400_000L), bid(8_700_000L), bid(9_100_000L)));
+ assertEquals(3, ranking.size());
+
+ RankRow first = ranking.get(0);
+ assertTrue(first.isLowest(), "1위는 최저가여야 한다");
+ assertFalse(first.priceMasked(), "최저가만 공개(마스킹 해제)");
+ assertNotNull(first.price(), "최저가 금액은 노출된다(공개 폴링과 동일)");
+ assertEquals(8_400_000L, first.price());
+
+ // 2위 이하 타사 금액은 마스킹(null) — 봉인 유지.
+ for (int i = 1; i < ranking.size(); i++) {
+ RankRow r = ranking.get(i);
+ assertFalse(r.isLowest());
+ assertTrue(r.priceMasked(), "비최저 응찰가는 마스킹되어야 한다");
+ assertNull(r.price(), "타사 금액은 절대 노출되지 않는다");
+ }
+ }
+
+ @Test
+ void broadcastSnapshot_aliasHidesCompanyIdentity() {
+ List ranking = AuctionService.publicRanking(List.of(bid(8_400_000L), bid(8_700_000L)));
+ // alias 는 위치 라벨(순위/최저가)만 — 업체 실명·ID 파생 금지.
+ assertEquals("현재 최저가", ranking.get(0).alias());
+ for (RankRow r : ranking) {
+ assertFalse(r.alias().contains("비밀장치"), "업체 실명이 alias 로 새어나가면 안 된다");
+ assertFalse(r.alias().contains("c-secret"), "업체 ID 가 alias 로 새어나가면 안 된다");
+ }
+ }
+
+ @Test
+ void broadcastSnapshot_emptyRankingIsSafe() {
+ List ranking = AuctionService.publicRanking(List.of());
+ assertTrue(ranking.isEmpty(), "응찰 없음 → 빈 순위(널 안전)");
+ }
+}
diff --git a/src/backend/src/test/java/com/zioinfo/kintex/livenotice/LiveNoticeServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/livenotice/LiveNoticeServiceTest.java
new file mode 100644
index 0000000..11afa08
--- /dev/null
+++ b/src/backend/src/test/java/com/zioinfo/kintex/livenotice/LiveNoticeServiceTest.java
@@ -0,0 +1,114 @@
+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.livenotice.dto.LiveNoticeDtos.LiveNoticeCreateRequest;
+import com.zioinfo.kintex.livenotice.dto.LiveNoticeDtos.LiveNoticeDto;
+import com.zioinfo.kintex.work.notification.NotificationService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * F-B6 라이브 공지 서비스 단위테스트 — 검증·발행·§5B 알림 팬아웃·감사·보관.
+ */
+class LiveNoticeServiceTest {
+
+ private LiveNoticeMapper mapper;
+ private NotificationService notifications;
+ private AuditLogService audit;
+ private LiveNoticeService service;
+
+ private final KintexPrincipal organizer =
+ new KintexPrincipal("u1", "주최자", Map.of(), false);
+
+ @BeforeEach
+ void setUp() {
+ mapper = mock(LiveNoticeMapper.class);
+ notifications = mock(NotificationService.class);
+ audit = mock(AuditLogService.class);
+ service = new LiveNoticeService(mapper, notifications, audit);
+ when(mapper.eventExists("e1")).thenReturn(1);
+ }
+
+ private Map row(String id) {
+ return new java.util.HashMap<>(Map.of(
+ "id", id, "eventId", "e1", "category", "URGENT", "title", "제목",
+ "body", "본문", "pinned", true, "status", "published",
+ "authorName", "주최자", "createdAt", "2026-07-23T00:00:00Z",
+ "updatedAt", "2026-07-23T00:00:00Z"));
+ }
+
+ @Test
+ void publish_rejectsUnknownCategory() {
+ ApiException ex = assertThrows(ApiException.class, () -> service.publish(organizer, "e1",
+ new LiveNoticeCreateRequest("WEIRD", "제목", "본문", false)));
+ assertEquals(ErrorCode.VALIDATION, ex.getCode());
+ verify(mapper, never()).insert(any());
+ }
+
+ @Test
+ void publish_rejectsBlankTitle() {
+ assertThrows(ApiException.class, () -> service.publish(organizer, "e1",
+ new LiveNoticeCreateRequest("URGENT", " ", "본문", false)));
+ }
+
+ @Test
+ void publish_rejectsUnknownEvent() {
+ when(mapper.eventExists("e1")).thenReturn(0);
+ ApiException ex = assertThrows(ApiException.class, () -> service.publish(organizer, "e1",
+ new LiveNoticeCreateRequest("URGENT", "제목", "본문", true)));
+ assertEquals(ErrorCode.NOT_FOUND, ex.getCode());
+ }
+
+ @Test
+ void publish_insertsFansOutAndAudits() {
+ when(mapper.findEventMemberUserIds("e1")).thenReturn(List.of(
+ Map.of("userId", "m1"), Map.of("userId", "m2")));
+ when(mapper.findById(anyString())).thenReturn(row("ln-x"));
+
+ LiveNoticeDto dto = service.publish(organizer, "e1",
+ new LiveNoticeCreateRequest("urgent", "긴급 공지", "내용", true));
+
+ assertEquals("URGENT", dto.category());
+ verify(mapper).insert(any());
+ // §5B 알림 모듈로 멤버 2명 팬아웃(중복 인프라 없이 재사용).
+ verify(notifications, times(2)).notify(anyString(), eq("e1"), eq("SYSTEM"),
+ anyString(), anyString(), anyString());
+ verify(audit).record(eq("u1"), anyString(), eq("LIVE_NOTICE_PUBLISH"),
+ eq("event_live_notice"), anyString(), eq("e1"), anyString(), eq("SUCCESS"));
+ }
+
+ @Test
+ void publish_survivesFanoutFailure() {
+ when(mapper.findEventMemberUserIds("e1")).thenThrow(new RuntimeException("db down"));
+ when(mapper.findById(anyString())).thenReturn(row("ln-x"));
+ // 팬아웃 실패해도 발행은 성공(공개 피드가 정본).
+ assertNotNull(service.publish(organizer, "e1",
+ new LiveNoticeCreateRequest("GENERAL", "안내", "내용", false)));
+ verify(mapper).insert(any());
+ }
+
+ @Test
+ void archive_notFoundOnEventMismatch() {
+ when(mapper.findById("ln-x")).thenReturn(Map.of("id", "ln-x", "eventId", "OTHER"));
+ assertThrows(ApiException.class, () -> service.archive(organizer, "e1", "ln-x"));
+ verify(mapper, never()).archive(anyString(), anyString());
+ }
+
+ @Test
+ void publicFeed_mapsRows() {
+ when(mapper.findPublicFeed("e1", 100)).thenReturn(List.of(row("ln-1"), row("ln-2")));
+ List feed = service.publicFeed("e1", 0);
+ assertEquals(2, feed.size());
+ assertTrue(feed.get(0).pinned());
+ }
+}
diff --git a/src/backend/src/test/java/com/zioinfo/kintex/publicsite/subscription/EventSubscriptionServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/publicsite/subscription/EventSubscriptionServiceTest.java
new file mode 100644
index 0000000..1d1acd3
--- /dev/null
+++ b/src/backend/src/test/java/com/zioinfo/kintex/publicsite/subscription/EventSubscriptionServiceTest.java
@@ -0,0 +1,129 @@
+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.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * F-C1 관심 행사 구독 서비스 단위테스트 — 검증 가드·멱등·rate·수신거부·PII 마스킹.
+ */
+class EventSubscriptionServiceTest {
+
+ private EventSubscriptionMapper mapper;
+ private EventSubscriptionService service;
+
+ @BeforeEach
+ void setUp() {
+ mapper = mock(EventSubscriptionMapper.class);
+ service = new EventSubscriptionService(mapper);
+ when(mapper.eventExists(anyString())).thenReturn(1);
+ when(mapper.isEnded(anyString())).thenReturn(0);
+ }
+
+ @Test
+ void subscribe_requiresPrivacyConsent() {
+ ApiException ex = assertThrows(ApiException.class,
+ () -> service.subscribe("e1", new SubscribeRequest("a@b.com", false)));
+ assertEquals(ErrorCode.VALIDATION, ex.getCode());
+ verify(mapper, never()).insert(any(), any(), any(), any(), any());
+ }
+
+ @Test
+ void subscribe_rejectsInvalidEmail() {
+ assertThrows(ApiException.class,
+ () -> service.subscribe("e1", new SubscribeRequest("not-an-email", true)));
+ }
+
+ @Test
+ void subscribe_rejectsUnknownEvent() {
+ when(mapper.eventExists("e1")).thenReturn(0);
+ ApiException ex = assertThrows(ApiException.class,
+ () -> service.subscribe("e1", new SubscribeRequest("a@b.com", true)));
+ assertEquals(ErrorCode.NOT_FOUND, ex.getCode());
+ }
+
+ @Test
+ void subscribe_rejectsEndedEvent() {
+ when(mapper.isEnded("e1")).thenReturn(1);
+ assertThrows(ApiException.class,
+ () -> service.subscribe("e1", new SubscribeRequest("a@b.com", true)));
+ }
+
+ @Test
+ void subscribe_new_insertsAndMasksEmail() {
+ when(mapper.findByEventEmail("e1", "kim.gildong@example.com")).thenReturn(null);
+ when(mapper.countRecentByEmail(anyString(), anyInt())).thenReturn(0);
+
+ SubscribeResult r = service.subscribe("e1",
+ new SubscribeRequest("kim.gildong@example.com", true));
+
+ assertFalse(r.alreadySubscribed());
+ assertEquals("active", r.status());
+ assertEquals("kim***@example.com", r.emailMasked()); // 원문 미노출
+ verify(mapper).insert(anyString(), eq("e1"), eq("kim.gildong@example.com"),
+ eq("kim***@example.com"), anyString());
+ }
+
+ @Test
+ void subscribe_existingActive_isIdempotent() {
+ when(mapper.findByEventEmail("e1", "a@b.com"))
+ .thenReturn(Map.of("id", "es-1", "status", "active"));
+
+ SubscribeResult r = service.subscribe("e1", new SubscribeRequest("a@b.com", true));
+
+ assertTrue(r.alreadySubscribed());
+ verify(mapper, never()).insert(any(), any(), any(), any(), any());
+ }
+
+ @Test
+ void subscribe_reactivatesUnsubscribed() {
+ when(mapper.findByEventEmail("e1", "a@b.com"))
+ .thenReturn(Map.of("id", "es-1", "status", "unsubscribed"));
+
+ SubscribeResult r = service.subscribe("e1", new SubscribeRequest("a@b.com", true));
+
+ assertFalse(r.alreadySubscribed());
+ assertEquals("active", r.status());
+ verify(mapper).reactivate("es-1");
+ }
+
+ @Test
+ void subscribe_rateLimited() {
+ when(mapper.findByEventEmail(anyString(), anyString())).thenReturn(null);
+ when(mapper.countRecentByEmail(anyString(), anyInt())).thenReturn(1);
+ ApiException ex = assertThrows(ApiException.class,
+ () -> service.subscribe("e1", new SubscribeRequest("a@b.com", true)));
+ assertEquals(ErrorCode.CONFLICT, ex.getCode());
+ }
+
+ @Test
+ void unsubscribe_success() {
+ when(mapper.unsubscribeByToken("t")).thenReturn(1);
+ UnsubscribeResult r = service.unsubscribe("t");
+ assertTrue(r.ok());
+ }
+
+ @Test
+ void unsubscribe_idempotentWhenAlreadyDone() {
+ when(mapper.unsubscribeByToken("t")).thenReturn(0);
+ when(mapper.tokenExists("t")).thenReturn(1);
+ assertTrue(service.unsubscribe("t").ok());
+ }
+
+ @Test
+ void unsubscribe_failsOnUnknownToken() {
+ when(mapper.unsubscribeByToken("t")).thenReturn(0);
+ when(mapper.tokenExists("t")).thenReturn(0);
+ assertFalse(service.unsubscribe("t").ok());
+ }
+}
diff --git a/src/backend/src/test/java/com/zioinfo/kintex/publicsite/subscription/TicketOpenNotifierTest.java b/src/backend/src/test/java/com/zioinfo/kintex/publicsite/subscription/TicketOpenNotifierTest.java
new file mode 100644
index 0000000..c7b1c3a
--- /dev/null
+++ b/src/backend/src/test/java/com/zioinfo/kintex/publicsite/subscription/TicketOpenNotifierTest.java
@@ -0,0 +1,85 @@
+package com.zioinfo.kintex.publicsite.subscription;
+
+import com.zioinfo.kintex.mail.MailProperties;
+import com.zioinfo.kintex.mail.MailService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * F-C1 티켓 오픈 감지 스케줄러 단위테스트 — dry-run 기본·화이트리스트·운영 발송·중복 발송 방지.
+ */
+class TicketOpenNotifierTest {
+
+ private EventSubscriptionMapper mapper;
+ private MailService mailService;
+ private MailProperties props;
+ private TicketOpenNotifier notifier;
+
+ @BeforeEach
+ void setUp() {
+ mapper = mock(EventSubscriptionMapper.class);
+ mailService = mock(MailService.class);
+ props = new MailProperties(); // 실 객체(기본 edmTestMode=true)
+ notifier = new TicketOpenNotifier(mapper, mailService, props);
+ when(mapper.markNotified(anyString())).thenReturn(1); // 선점 성공(기본)
+ }
+
+ private static Map sub(String id, String email) {
+ return Map.of("id", id, "eventId", "e1", "email", email,
+ "unsubToken", "tok-" + id, "eventName", "데모행사");
+ }
+
+ @Test
+ void empty_noOp() {
+ when(mapper.findPendingTicketOpen(anyInt())).thenReturn(List.of());
+ assertEquals(0, notifier.runOnce());
+ verifyNoInteractions(mailService);
+ }
+
+ @Test
+ void testMode_dryRun_marksButDoesNotEmailSubscriber() {
+ // 기본 테스트 모드 + 화이트리스트 비어 있음 → 실 구독자 미발송(dry-run), 처리 마킹은 수행.
+ when(mapper.findPendingTicketOpen(anyInt())).thenReturn(List.of(sub("s1", "real@user.com")));
+ int n = notifier.runOnce();
+ assertEquals(1, n);
+ verify(mapper).markNotified("s1");
+ verifyNoInteractions(mailService); // 실 구독자에게 발송 안 함
+ }
+
+ @Test
+ void testMode_sendsOnlyToWhitelist() {
+ props.setEdmTestRecipients(List.of("qa@kintex.test"));
+ when(mapper.findPendingTicketOpen(anyInt())).thenReturn(List.of(sub("s1", "real@user.com")));
+ notifier.runOnce();
+ // 화이트리스트로만 발송, 실 구독자(real@user.com)로는 발송 안 함.
+ verify(mailService).send(eq("TICKET_OPEN_TEST"), eq("s1"), eq("qa@kintex.test"),
+ anyString(), anyString());
+ verify(mailService, never()).send(anyString(), anyString(), eq("real@user.com"),
+ anyString(), anyString());
+ }
+
+ @Test
+ void liveMode_sendsToSubscriber() {
+ props.setEdmTestMode(false); // 운영 실발송(설정으로만 개방)
+ when(mapper.findPendingTicketOpen(anyInt())).thenReturn(List.of(sub("s1", "real@user.com")));
+ notifier.runOnce();
+ verify(mailService).send(eq("TICKET_OPEN"), eq("s1"), eq("real@user.com"),
+ anyString(), anyString());
+ }
+
+ @Test
+ void skipsAlreadyClaimed() {
+ when(mapper.findPendingTicketOpen(anyInt())).thenReturn(List.of(sub("s1", "real@user.com")));
+ when(mapper.markNotified("s1")).thenReturn(0); // 경쟁 — 이미 통지됨
+ props.setEdmTestMode(false);
+ assertEquals(0, notifier.runOnce());
+ verifyNoInteractions(mailService);
+ }
+}
diff --git a/src/backend/src/test/java/com/zioinfo/kintex/report/GridReportServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/report/GridReportServiceTest.java
new file mode 100644
index 0000000..e6a3943
--- /dev/null
+++ b/src/backend/src/test/java/com/zioinfo/kintex/report/GridReportServiceTest.java
@@ -0,0 +1,68 @@
+package com.zioinfo.kintex.report;
+
+import com.zioinfo.kintex.common.error.ApiException;
+import com.zioinfo.kintex.report.dto.GridPdfRequest;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 범용 그리드 PDF 렌더 단위테스트 — 샘플 컬럼/행 → PDF 바이트 생성 + %PDF 매직 검증.
+ * (Spring 컨텍스트 없이 서비스 직접 인스턴스화 — jrxml·폰트확장은 클래스패스 리소스로 로드.)
+ */
+class GridReportServiceTest {
+
+ private final GridReportService service = new GridReportService();
+
+ @Test
+ void render_producesPdfBytes_withKoreanColumnsAndRows() {
+ List columns = List.of(
+ new GridPdfRequest.Column("boothNo", "부스 번호", 1, "center"),
+ new GridPdfRequest.Column("company", "참가업체", 2, "left"),
+ new GridPdfRequest.Column("areaM2", "면적(㎡)", 1, "right"),
+ new GridPdfRequest.Column("status", "상태", 1, "center"));
+
+ List