feat(backend): visitor/ticket/parking/livenotice/session domains, Jasper PDF, tenant switch (V86-V106)

- Jasper grid report (PDF), fonts + extension config
- Subscription/live-notice, parking congestion, session RSVP
- Smart ticket TOTP guard, ticket cancel policy
- Visitor membership/contact/notification/pay-method/feed
- Multitenancy demo tenant + tenant switch response
- M2 layout version DTO; unit/integration tests for above

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-24 00:41:37 +09:00
parent bbd4a63f5a
commit 92572df511
107 changed files with 6446 additions and 64 deletions

View File

@ -49,6 +49,13 @@ dependencies {
// --- PDF (M6 G-05) HTMLPDF . NanumGothic ( ). --- // --- PDF (M6 G-05) HTMLPDF . NanumGothic ( ). ---
implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10' implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10'
// --- PDF ( , JasperReports) WISE/UIWS . ---
// 6.21.x는 PDF(iText) . fonts.xml Identity-H .
implementation('net.sf.jasperreports:jasperreports:6.21.3') {
// Spring Boot commons-logging .
exclude group: 'commons-logging', module: 'commons-logging'
}
// --- JWT ( RBAC ) --- // --- JWT ( RBAC ) ---
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}" implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"

View File

@ -2,6 +2,10 @@ package com.zioinfo.kintex.admin;
import com.zioinfo.kintex.admin.dto.AdminDashboardDto; import com.zioinfo.kintex.admin.dto.AdminDashboardDto;
import com.zioinfo.kintex.admin.dto.AdminStatsDto; import com.zioinfo.kintex.admin.dto.AdminStatsDto;
import com.zioinfo.kintex.common.audit.AuditLogDto;
import com.zioinfo.kintex.common.audit.AuditLogService;
import com.zioinfo.kintex.ops.OpsService;
import com.zioinfo.kintex.ops.dto.OpsDto;
import com.zioinfo.kintex.tenant.TenantContext; import com.zioinfo.kintex.tenant.TenantContext;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -12,14 +16,21 @@ import java.util.Map;
/** /**
* 관리자 대시보드 서비스 테이블 집계로 KPI·라이브 행사를 조립. * 관리자 대시보드 서비스 테이블 집계로 KPI·라이브 행사를 조립.
* 관람객 추이는 센서/집계 인프라 부재 빈배열. 테넌트는 데이터(KINTEX) 반환. * 관람객 추이는 센서/집계 인프라 부재 빈배열. 테넌트는 데이터(KINTEX) 반환.
* <p>SCR-16 실전환: 라이브 행사에 M10 체크인 실인원·M14 혼잡 등급(OpsService 재사용, 수정 없이 호출만),
* 최근 활동은 AuditLogService(audit_log 기록) 재사용.
*/ */
@Service @Service
public class AdminDashboardService { public class AdminDashboardService {
private final AdminDashboardMapper mapper; private final AdminDashboardMapper mapper;
private final OpsService opsService;
private final AuditLogService auditLogService;
public AdminDashboardService(AdminDashboardMapper mapper) { public AdminDashboardService(AdminDashboardMapper mapper, OpsService opsService,
AuditLogService auditLogService) {
this.mapper = mapper; this.mapper = mapper;
this.opsService = opsService;
this.auditLogService = auditLogService;
} }
public AdminDashboardDto getDashboard(String tenant) { public AdminDashboardDto getDashboard(String tenant) {
@ -45,9 +56,22 @@ public class AdminDashboardService {
long booths = lng(r.get("boothCount")); long booths = lng(r.get("boothCount"));
long capacity = lng(r.get("capacity")); long capacity = lng(r.get("capacity"));
int occupancy = capacity > 0 ? (int) Math.min(100, Math.round(booths * 100.0 / capacity)) : 0; int occupancy = capacity > 0 ? (int) Math.min(100, Math.round(booths * 100.0 / capacity)) : 0;
String eventId = str(r.get("eventId"));
// M10 체크인 실인원 + M14 혼잡 등급 OpsService(SCR-14) 파생 재사용. 행사 실패가 전체를 막지 않도록 방어.
long onSite = 0L;
String heat = "smooth";
try {
OpsDto ops = opsService.getOps(eventId);
onSite = ops.onSiteCount();
heat = eventHeat(ops);
} catch (RuntimeException e) {
// 파생 실패 정직한 기본값(0/smooth) 유지 가짜 수치 금지.
}
live.add(new AdminDashboardDto.LiveEvent( live.add(new AdminDashboardDto.LiveEvent(
str(r.get("eventId")), str(r.get("name")), str(r.get("hall")), eventId, str(r.get("name")), str(r.get("hall")),
occupancy, "ongoing")); occupancy, "ongoing", onSite, heat));
} }
} }
@ -55,7 +79,39 @@ public class AdminDashboardService {
List<AdminDashboardDto.VisitorPoint> visitorTrend = new ArrayList<>(); List<AdminDashboardDto.VisitorPoint> visitorTrend = new ArrayList<>();
List<String> tenants = List.of("KINTEX"); List<String> tenants = List.of("KINTEX");
return new AdminDashboardDto(kpis, visitorTrend, live, tenants); 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<AdminDashboardDto.Activity> recentActivity() {
List<AdminDashboardDto.Activity> out = new ArrayList<>();
try {
List<AuditLogDto> rows = auditLogService.search(null, null, null, 0, 8).items();
for (AuditLogDto a : rows) {
out.add(new AdminDashboardDto.Activity(
a.action(), a.actorName(), a.targetType(), a.summary(), a.result(), a.createdAt()));
}
} catch (RuntimeException e) {
// 조회 실패 빈배열 화면은 상태(정직) 표기.
}
return out;
} }
/** /**

View File

@ -3,14 +3,17 @@ package com.zioinfo.kintex.admin.dto;
import java.util.List; import java.util.List;
/** /**
* 관리자 백오피스 대시보드 (SCR-14 / 06_backend_api_gaps §4). * 관리자 백오피스 대시보드 (SCR-14/16 / 06_backend_api_gaps §4).
* 집계원: event(진행/라이브)·hall_assignment·app_user·company. 관람객 추이는 센서/집계 부재 빈배열(정직). * 집계원: event(진행/라이브)·hall_assignment·app_user·company.
* <p>실전환(SCR-16, 2026-07): 라이브 행사에 M10 체크인 인원(onSiteCount)·M14 혼잡 등급(heat, OpsService 재사용) 부가,
* 최근 활동(recentActivity) audit_log 기록. 관람객 시간대 추이는 센서/집계 부재 빈배열(정직).
*/ */
public record AdminDashboardDto( public record AdminDashboardDto(
List<Kpi> kpis, List<Kpi> kpis,
List<VisitorPoint> visitorTrend, List<VisitorPoint> visitorTrend,
List<LiveEvent> liveEvents, List<LiveEvent> liveEvents,
List<String> tenants List<String> tenants,
List<Activity> recentActivity
) { ) {
public record Kpi(String label, String value, String sub) { public record Kpi(String label, String value, String sub) {
} }
@ -19,7 +22,16 @@ public record AdminDashboardDto(
public record VisitorPoint(String hour, long count) { 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) {
} }
} }

View File

@ -53,6 +53,27 @@ public interface AnalyticsMapper {
""") """)
Double findUtilityRevenue(@Param("eventId") String eventId); Double findUtilityRevenue(@Param("eventId") String eventId);
/**
* 업종별 구성(sectors) 참가업체(부스 배정사) 업종 분포. 원천 = booth.assigned_company_name
* company.name(대소문자·공백 무시 매칭) company.category. 매칭 실패/미분류는 '기타' 버킷.
* <p>버킷별 부스 ·판매면적(). 매출/점유율은 서비스가 면적×요율로 환산·정규화한다.
* <p>plain @Select(&lt;script&gt; 미사용) {@code <>} XML 파싱되지 않음(findRetentionSummary 동일 패턴).
*/
@Select("""
SELECT COALESCE(NULLIF(btrim(c.category), ''), '기타') AS "sector",
count(b.id) AS "boothCount",
COALESCE(sum(ST_Area(b.geom)), 0) AS "areaM2"
FROM booth b
JOIN layout l ON l.id = b.layout_id
LEFT JOIN company c ON lower(btrim(c.name)) = lower(btrim(b.assigned_company_name))
WHERE l.event_id = #{eventId}
AND b.assigned_company_name IS NOT NULL
AND btrim(b.assigned_company_name) <> ''
GROUP BY COALESCE(NULLIF(btrim(c.category), ''), '기타')
ORDER BY sum(ST_Area(b.geom)) DESC
""")
List<Map<String, Object>> findSectorAggregation(@Param("eventId") String eventId);
// 테넌트 전역(행사 횡단) BI 집계 M16 잔여 // 테넌트 전역(행사 횡단) BI 집계 M16 잔여
// 멀티테넌시 2단계: 행사 횡단 집계는 tenant_id(event/hall) 선행 필터로 격리(§1A-2 · §8-2). // 멀티테넌시 2단계: 행사 횡단 집계는 tenant_id(event/hall) 선행 필터로 격리(§1A-2 · §8-2).
// booth/layout/utility_order는 tenant_id 컬럼이 없으므로 event 조인으로 간접 스코프(신규 컬럼 금지). // booth/layout/utility_order는 tenant_id 컬럼이 없으므로 event 조인으로 간접 스코프(신규 컬럼 금지).

View File

@ -53,20 +53,46 @@ public class AnalyticsService {
} }
} }
// 손익(operator 관점) 매출 항목만. 업종 분류 원천 부재 sectors 미집계. // 손익(operator 관점) 매출 항목만.
List<AnalyticsDto.PnlRow> pnl = new ArrayList<>(); List<AnalyticsDto.PnlRow> pnl = new ArrayList<>();
List<AnalyticsDto.Sector> sectors = new ArrayList<>();
if (!exhibitor) { if (!exhibitor) {
pnl.add(new AnalyticsDto.PnlRow("부스 임대", rentalRevenue, grade(rentalRevenue))); pnl.add(new AnalyticsDto.PnlRow("부스 임대", rentalRevenue, grade(rentalRevenue)));
pnl.add(new AnalyticsDto.PnlRow("유틸리티", utilityRevenue, grade(utilityRevenue))); pnl.add(new AnalyticsDto.PnlRow("유틸리티", utilityRevenue, grade(utilityRevenue)));
} }
// 업종별 구성(sectors) 참가업체(부스 배정사) 업종(company.category) 실집계. 운영사/참가업체 공통.
List<AnalyticsDto.Sector> sectors = buildSectors(eventId, ratePerM2);
// 참가업체 성과 방문자 리드(M13) 원천 부재 미집계(빈배열). // 참가업체 성과 방문자 리드(M13) 원천 부재 미집계(빈배열).
List<AnalyticsDto.ExhibitorPerf> exhibitorPerf = new ArrayList<>(); List<AnalyticsDto.ExhibitorPerf> exhibitorPerf = new ArrayList<>();
return new AnalyticsDto(kpis, trend, sectors, pnl, exhibitorPerf); return new AnalyticsDto(kpis, trend, sectors, pnl, exhibitorPerf);
} }
/**
* 업종별 구성 집계 버킷별 판매면적을 매출(면적×요율) 환산하고 전체 대비 점유율(%) 정규화.
* 점유율 기준은 판매면적(=요율 단일 매출과 동률)이며 면적 내림차순(매퍼 정렬) 보존한다.
* 원천(배정 부스) 부재 빈배열(정직) 프론트는 상태를 표시한다.
*/
private List<AnalyticsDto.Sector> buildSectors(String eventId, double ratePerM2) {
List<AnalyticsDto.Sector> out = new ArrayList<>();
List<Map<String, Object>> rows = mapper.findSectorAggregation(eventId);
if (rows == null || rows.isEmpty()) {
return out;
}
double totalArea = 0;
for (Map<String, Object> r : rows) {
totalArea += dbl(r.get("areaM2"));
}
for (Map<String, Object> r : rows) {
double area = dbl(r.get("areaM2"));
double share = totalArea > 0 ? round1(area * 100.0 / totalArea) : 0.0;
long revenue = Math.round(area * ratePerM2);
out.add(new AnalyticsDto.Sector(str(r.get("sector")), share, revenue));
}
return out;
}
// 테넌트 전역(행사 횡단) BI M16 잔여 // 테넌트 전역(행사 횡단) BI M16 잔여
/** /**

View File

@ -5,7 +5,8 @@ import java.util.List;
/** /**
* 경영분석 BI 집계 (SCR-13 / 06_backend_api_gaps §3). * 경영분석 BI 집계 (SCR-13 / 06_backend_api_gaps §3).
* 집계원: booth 면적(ST_Area)×임대 요율(master_data)·utility_order.quote·booth 월별 추이. * 집계원: booth 면적(ST_Area)×임대 요율(master_data)·utility_order.quote·booth 월별 추이.
* 업종(sectors)·리드(exhibitorPerf) 원천(M13 방문자/업종 분류) 부재 빈배열(정직 반환). * 업종(sectors) 참가업체(booth.assigned_company_namecompany.category) 실집계.
* 리드(exhibitorPerf) 원천(M13 방문자 분류) 부재 빈배열(정직 반환).
*/ */
public record AnalyticsDto( public record AnalyticsDto(
List<Kpi> kpis, List<Kpi> kpis,

View File

@ -17,7 +17,7 @@ public interface CmsMapper {
<script> <script>
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang, SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
to_char(scheduled_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "scheduledAt", to_char(scheduled_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "scheduledAt",
signage, mailing, author_name AS "authorName", signage, mailing, author_name AS "authorName", hero_url AS "heroUrl",
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt", to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt", to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt" to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
@ -50,7 +50,7 @@ public interface CmsMapper {
@Select(""" @Select("""
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang, SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
to_char(scheduled_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "scheduledAt", to_char(scheduled_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "scheduledAt",
signage, mailing, author_name AS "authorName", signage, mailing, author_name AS "authorName", hero_url AS "heroUrl",
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt", to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt", to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt" to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
@ -184,6 +184,7 @@ public interface CmsMapper {
@Select(""" @Select("""
<script> <script>
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang, SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
hero_url AS "heroUrl",
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt", to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt" to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
FROM cms_content FROM cms_content

View File

@ -497,7 +497,7 @@ public class CmsService {
str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")), str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")),
str(r.get("scheduledAt")), bool(r.get("signage")), bool(r.get("mailing")), str(r.get("scheduledAt")), bool(r.get("signage")), bool(r.get("mailing")),
str(r.get("authorName")), str(r.get("publishedAt")), str(r.get("authorName")), str(r.get("publishedAt")),
str(r.get("createdAt")), str(r.get("updatedAt"))); str(r.get("createdAt")), str(r.get("updatedAt")), str(r.get("heroUrl")));
} }
/** 공개 응답 — 저자/예약/플래그 등 운영 필드는 노출하지 않는다. */ /** 공개 응답 — 저자/예약/플래그 등 운영 필드는 노출하지 않는다. */
@ -506,7 +506,7 @@ public class CmsService {
str(r.get("id")), str(r.get("eventId")), str(r.get("contentType")), str(r.get("id")), str(r.get("eventId")), str(r.get("contentType")),
str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")), str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")),
null, false, false, null, str(r.get("publishedAt")), null, false, false, null, str(r.get("publishedAt")),
null, str(r.get("updatedAt"))); null, str(r.get("updatedAt")), str(r.get("heroUrl")));
} }
private static CmsTranslationDto toTranslationDto(Map<String, Object> r) { private static CmsTranslationDto toTranslationDto(Map<String, Object> r) {

View File

@ -15,5 +15,6 @@ public record CmsContentDto(
String authorName, String authorName,
String publishedAt, String publishedAt,
String createdAt, String createdAt,
String updatedAt) { String updatedAt,
String heroUrl) {
} }

View File

@ -70,8 +70,8 @@ public class SecurityConfig {
ApiResponse.fail(new ApiError(ErrorCode.UNAUTHORIZED.name(), ApiResponse.fail(new ApiError(ErrorCode.UNAUTHORIZED.name(),
ErrorCode.UNAUTHORIZED.defaultMessage())))); ErrorCode.UNAUTHORIZED.defaultMessage()))));
})) }))
// 테넌트 컨텍스트 해소(§1A-3) 이후 JWT 인증. 미해소 기준 테넌트 폴백(회귀 0). // 테넌트 컨텍스트 해소(§1A-3) 인증 요청은 JWT tid 권위(§1A-3-2, 2단계). 미해소 기준 테넌트 폴백(회귀 0).
.addFilterBefore(new TenantContextFilter(tenantResolver, objectMapper), .addFilterBefore(new TenantContextFilter(tenantResolver, objectMapper, jwtService),
UsernamePasswordAuthenticationFilter.class) UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtAuthenticationFilter(jwtService), .addFilterBefore(new JwtAuthenticationFilter(jwtService),
UsernamePasswordAuthenticationFilter.class); UsernamePasswordAuthenticationFilter.class);

View File

@ -0,0 +1,116 @@
package com.zioinfo.kintex.congestion;
import com.zioinfo.kintex.congestion.dto.CongestionDtos.CongestionAreaDto;
import com.zioinfo.kintex.congestion.dto.CongestionDtos.CongestionOverviewDto;
import com.zioinfo.kintex.ops.OpsService;
import com.zioinfo.kintex.ops.dto.OpsDto;
import com.zioinfo.kintex.parking.ParkingService;
import com.zioinfo.kintex.parking.dto.ParkingDtos.ParkingLotStatusDto;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* 관람객 혼잡 안내 서비스(F-C3) M14 현장운영(OpsService, 체크인 파생 혼잡) 실집계를 재사용해
* 입장 게이트( 입장)·주차·인기 공간(세션) 혼잡을 관람객 3단계(여유/보통/혼잡) 재구성한다.
*
* <p>재사용 원칙: 혼잡 산출 로직을 중복 구현하지 않고 {@link OpsService}( 혼잡)
* {@link ParkingService}(주차 점유) 결과를 관람객 관점으로 매핑한다. 신규 계측·하드웨어 없음.
*/
@Service
public class CongestionService {
/** 인기 공간(세션) 노출 상한 — 현장 인원 상위 N개 홀. */
private static final int POPULAR_LIMIT = 3;
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
private final OpsService opsService;
private final ParkingService parkingService;
public CongestionService(OpsService opsService, ParkingService parkingService) {
this.opsService = opsService;
this.parkingService = parkingService;
}
public CongestionOverviewDto getPublicCongestion(String eventId) {
OpsDto ops = opsService.getOps(eventId);
// 입장 게이트 = 입장 혼잡(OpsService heat 재사용). 게이트 점유율은 계측 부재 null.
List<CongestionAreaDto> gates = new ArrayList<>();
for (OpsDto.Hall h : ops.halls()) {
String level = mapHeat(h.heat());
gates.add(new CongestionAreaDto(h.hallId(), h.label(), level, label(level), null));
}
// 인기 공간(세션) = 현장 인원 상위 (혼잡 ). 세션 단위 계측 부재 파생 근사.
List<CongestionAreaDto> popular = ops.halls().stream()
.filter(h -> h.estPeople() > 0)
.sorted(Comparator.comparingLong(OpsDto.Hall::estPeople).reversed())
.limit(POPULAR_LIMIT)
.map(h -> {
String level = mapHeat(h.heat());
return new CongestionAreaDto(h.hallId(), h.label(), level, label(level), null);
})
.toList();
// 주차 = ParkingService 점유율(시뮬레이션 어댑터). 점유율 그대로 노출.
List<CongestionAreaDto> parking = new ArrayList<>();
for (ParkingLotStatusDto lot : parkingService.getLotStatus(eventId)) {
parking.add(new CongestionAreaDto(lot.lotId(), lot.name(),
lot.congestionLevel(), lot.congestionLabel(), lot.occupancyPercent()));
}
// 전체 = 게이트 + 주차 가장 혼잡한 단계(관람객 안전 안내는 보수적으로).
String overall = worstLevel(gates, parking);
return new CongestionOverviewDto(eventId, overall, label(overall), ops.onSiteCount(),
gates, parking, popular, LocalDateTime.now().format(TS));
}
/** OpsService heat(smooth/moderate/busy) → 관람객 3단계 코드. */
private static String mapHeat(String heat) {
if (heat == null) {
return "FREE";
}
return switch (heat) {
case "busy" -> "BUSY";
case "moderate" -> "NORMAL";
default -> "FREE";
};
}
private static String label(String level) {
return switch (level) {
case "BUSY" -> "혼잡";
case "NORMAL" -> "보통";
default -> "여유";
};
}
/** 여러 영역 중 가장 혼잡한 단계(FREE<NORMAL<BUSY). 비면 FREE. */
@SafeVarargs
private static String worstLevel(List<CongestionAreaDto>... groups) {
int worst = 0; // 0=FREE,1=NORMAL,2=BUSY
for (List<CongestionAreaDto> g : groups) {
for (CongestionAreaDto a : g) {
worst = Math.max(worst, rank(a.level()));
}
}
return switch (worst) {
case 2 -> "BUSY";
case 1 -> "NORMAL";
default -> "FREE";
};
}
private static int rank(String level) {
return switch (level == null ? "FREE" : level) {
case "BUSY" -> 2;
case "NORMAL" -> 1;
default -> 0;
};
}
}

View File

@ -0,0 +1,35 @@
package com.zioinfo.kintex.congestion;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.congestion.dto.CongestionDtos.CongestionOverviewDto;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 공개/관람객 혼잡 안내 API (F-C3) 비인증 공개({@code /api/public/**} permitAll).
* <p>관람객 /(공개 또는 로그인)에서 조회 가능. 입장 게이트·주차·인기 공간 혼잡을 3단계로 안내.
* <p>M14 현장운영 혼잡 실집계(체크인 파생) 재사용하되, 관람객 관점 요약만 노출한다(운영 상세 미노출).
*/
@RestController
@RequestMapping("/api/public/congestion")
public class PublicCongestionController {
private final CongestionService service;
public PublicCongestionController(CongestionService service) {
this.service = service;
}
/** GET /api/public/congestion?eventId= — 관람객 혼잡 요약(전체 + 게이트/주차/인기 공간). */
@GetMapping
public ApiResponse<CongestionOverviewDto> congestion(@RequestParam String eventId) {
if (eventId == null || eventId.isBlank()) {
throw new ApiException(ErrorCode.VALIDATION, "행사 식별자가 필요합니다.");
}
return ApiResponse.ok(service.getPublicCongestion(eventId));
}
}

View File

@ -0,0 +1,40 @@
package com.zioinfo.kintex.congestion.dto;
import java.util.List;
/**
* 공개/관람객 혼잡 안내(F-C3) DTO. M14 현장운영(체크인 파생 혼잡) 실집계를 관람객 관점으로 재구성.
* <p>혼잡 3단계: level FREE|NORMAL|BUSY (여유/보통/혼잡). 관람객 / 표시 전용.
*/
public final class CongestionDtos {
private CongestionDtos() {
}
/**
* 관람객 혼잡 요약 전체 + 입장 게이트/주차/인기 공간(세션) 3영역.
* <p>onSiteCount = 현장 체크인 인원(M10 파생). updatedAt = 산출 시각.
*/
public record CongestionOverviewDto(
String eventId,
String overallLevel,
String overallLabel,
long onSiteCount,
List<CongestionAreaDto> entryGates,
List<CongestionAreaDto> parking,
List<CongestionAreaDto> popularSessions,
String updatedAt) {
}
/**
* 혼잡 영역 단위 게이트( 입장)·주차장·인기 공간 공통.
* occupancyPercent 점유율 산출 가능한 영역(주차) 채우고, 게이트/공간은 null(계측 부재).
*/
public record CongestionAreaDto(
String id,
String label,
String level,
String levelLabel,
Integer occupancyPercent) {
}
}

View File

@ -0,0 +1,69 @@
package com.zioinfo.kintex.livenotice;
import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.EventRole;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.livenotice.dto.LiveNoticeDtos.LiveNoticeCreateRequest;
import com.zioinfo.kintex.livenotice.dto.LiveNoticeDtos.LiveNoticeDto;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 행사 라이브 공지(F-B6) 주최자 API 발행/목록/보관/고정. 행사 ORGANIZER( 관리자·홀매니저) 권한.
* <p>발행 §5B 알림 모듈로 행사 멤버 in-app 팬아웃(서비스에서 처리).
*/
@RestController
@RequestMapping("/api/events/{eventId}/live-notices")
public class LiveNoticeController {
private final LiveNoticeService service;
private final EventAccessGuard guard;
public LiveNoticeController(LiveNoticeService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** GET — 주최자 목록(전체 상태, status 필터 선택). */
@GetMapping
public ApiResponse<List<LiveNoticeDto>> list(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestParam(required = false) String status,
@RequestParam(required = false, defaultValue = "100") int limit) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.organizerList(eventId, status, limit));
}
/** POST — 라이브 공지 발행(ORGANIZER/관리자/홀매니저). */
@PostMapping
public ApiResponse<LiveNoticeDto> publish(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestBody LiveNoticeCreateRequest req) {
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
return ApiResponse.ok(service.publish(principal, eventId, req));
}
/** POST /{id}/archive — 공지 보관. */
@PostMapping("/{id}/archive")
public ApiResponse<LiveNoticeDto> archive(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@PathVariable String id) {
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
return ApiResponse.ok(service.archive(principal, eventId, id));
}
/** POST /{id}/pin — 고정 토글({pinned:true|false}). */
@PostMapping("/{id}/pin")
public ApiResponse<LiveNoticeDto> pin(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@PathVariable String id,
@RequestBody(required = false) Map<String, Object> body) {
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
boolean pinned = body != null && Boolean.TRUE.equals(body.get("pinned"));
return ApiResponse.ok(service.setPinned(principal, eventId, id, pinned));
}
}

View File

@ -0,0 +1,95 @@
package com.zioinfo.kintex.livenotice;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Map;
/**
* 행사 라이브 공지(F-B6) 매퍼.
* <p> Map 반환 @Select camelCase 별칭에 쌍따옴표({@code AS "x"}) 사용한다(PG lower-fold 방지).
* <p>공개 피드는 status='published' , 주최자 목록은 전체.
*/
@Mapper
public interface LiveNoticeMapper {
@Select("SELECT count(*) FROM event WHERE id = #{eventId}")
int eventExists(@Param("eventId") String eventId);
/** 발행. */
@Insert("""
INSERT INTO event_live_notice
(tenant_id, id, event_id, category, title, body, pinned, status, author_name)
VALUES ('KINTEX', #{id}, #{eventId}, #{category}, #{title}, #{body},
#{pinned}, 'published', #{authorName})
""")
int insert(Map<String, Object> p);
/** 단건 조회(테넌트 스코프). */
@Select("""
SELECT id, event_id AS "eventId", category, title, body, pinned, status,
author_name AS "authorName",
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
FROM event_live_notice
WHERE tenant_id = 'KINTEX' AND id = #{id}
""")
Map<String, Object> findById(@Param("id") String id);
/** 공개 피드 — 게시 건만. 고정(pinned) 우선, 최신순. */
@Select("""
SELECT id, event_id AS "eventId", category, title, body, pinned, status,
author_name AS "authorName",
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
FROM event_live_notice
WHERE tenant_id = 'KINTEX' AND event_id = #{eventId} AND status = 'published'
ORDER BY pinned DESC, created_at DESC
LIMIT #{limit}
""")
List<Map<String, Object>> findPublicFeed(@Param("eventId") String eventId, @Param("limit") int limit);
/** 주최자 목록 — 전체 상태(status 필터 선택). */
@Select("""
<script>
SELECT id, event_id AS "eventId", category, title, body, pinned, status,
author_name AS "authorName",
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
FROM event_live_notice
WHERE tenant_id = 'KINTEX' AND event_id = #{eventId}
<if test="status != null and status != ''">AND status = #{status}</if>
ORDER BY pinned DESC, created_at DESC
LIMIT #{limit}
</script>
""")
List<Map<String, Object>> findForOrganizer(@Param("eventId") String eventId,
@Param("status") String status,
@Param("limit") int limit);
/** 보관(archive) — 게시 건만 전이. */
@Update("""
UPDATE event_live_notice SET status = 'archived', updated_at = now()
WHERE tenant_id = 'KINTEX' AND id = #{id} AND event_id = #{eventId} AND status = 'published'
""")
int archive(@Param("id") String id, @Param("eventId") String eventId);
/** 고정 토글. */
@Update("""
UPDATE event_live_notice SET pinned = #{pinned}, updated_at = now()
WHERE tenant_id = 'KINTEX' AND id = #{id} AND event_id = #{eventId}
""")
int setPinned(@Param("id") String id, @Param("eventId") String eventId, @Param("pinned") boolean pinned);
/** §5B 알림 팬아웃 대상 — 행사 멤버 user_id 목록(발행 시 in-app 알림). */
@Select("""
SELECT DISTINCT user_id AS "userId"
FROM event_member
WHERE event_id = #{eventId}
""")
List<Map<String, Object>> findEventMemberUserIds(@Param("eventId") String eventId);
}

View File

@ -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) 서비스 주최자 발행 + 공개/관람객 조회 + 보관/고정.
*
* <p><b>§5B 알림 통합(중복 신설 금지)</b>: 발행 별도 per-user 푸시 인프라를 만들지 않고 기존
* {@link NotificationService}(공통 알림 모듈) 행사 멤버에게 in-app 알림을 팬아웃한다. 공개 피드는
* 모듈의 {@code event_live_notice} 테이블이 정본(비로그인 관람객 대상 broadcast).
*
* <p>본문/제목은 {@link HtmlSanitizer} sanitize(XSS 방어). 발행/보관은 감사 로그를 남긴다.
*/
@Service
public class LiveNoticeService {
private static final Logger log = LoggerFactory.getLogger(LiveNoticeService.class);
private static final Set<String> CATEGORIES = Set.of("URGENT", "PROGRAM", "GENERAL", "INFO");
private static final int MAX_TITLE = 200;
private static final int MAX_BODY = 2000;
private static final int DEFAULT_LIMIT = 100;
/** 발행 시 §5B 알림 팬아웃 상한(대형 행사 폭주 방지). 초과분은 공개 피드로만 노출. */
private static final int FANOUT_LIMIT = 500;
private final LiveNoticeMapper mapper;
private final NotificationService notificationService;
private final AuditLogService audit;
public LiveNoticeService(LiveNoticeMapper mapper, NotificationService notificationService,
AuditLogService audit) {
this.mapper = mapper;
this.notificationService = notificationService;
this.audit = audit;
}
/** 공개 피드(비인증) — 게시 건만, 고정 우선·최신순. */
public List<LiveNoticeDto> publicFeed(String eventId, int limit) {
int lim = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, 500);
return mapper.findPublicFeed(eventId, lim).stream().map(LiveNoticeService::toDto).toList();
}
/** 주최자 목록 — 전체 상태(status 필터 선택). */
public List<LiveNoticeDto> organizerList(String eventId, String status, int limit) {
int lim = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, 500);
return mapper.findForOrganizer(eventId, blankToNull(status), lim)
.stream().map(LiveNoticeService::toDto).toList();
}
/** 발행(주최자) — 검증·sanitize·저장 후 §5B 알림 팬아웃 + 감사. */
@Transactional
public LiveNoticeDto publish(KintexPrincipal principal, String eventId, LiveNoticeCreateRequest req) {
if (mapper.eventExists(eventId) == 0) {
throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 행사입니다.");
}
String category = req == null ? null : upper(req.category());
if (category == null || !CATEGORIES.contains(category)) {
throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 공지 분류입니다(URGENT/PROGRAM/GENERAL/INFO).");
}
String title = req.title() == null ? null : req.title().trim();
if (title == null || title.isEmpty()) {
throw new ApiException(ErrorCode.VALIDATION, "공지 제목을 입력해 주세요.");
}
if (title.length() > MAX_TITLE) {
throw new ApiException(ErrorCode.VALIDATION, "제목이 너무 깁니다(최대 " + MAX_TITLE + "자).");
}
String body = req.body() == null ? null : req.body();
if (body != null && body.length() > MAX_BODY) {
throw new ApiException(ErrorCode.VALIDATION, "본문이 너무 깁니다(최대 " + MAX_BODY + "자).");
}
String safeTitle = HtmlSanitizer.sanitize(title);
String safeBody = body == null ? null : HtmlSanitizer.sanitize(body);
boolean pinned = Boolean.TRUE.equals(req.pinned());
String id = "ln-" + UUID.randomUUID().toString().replace("-", "").substring(0, 18);
Map<String, Object> p = new HashMap<>();
p.put("id", id);
p.put("eventId", eventId);
p.put("category", category);
p.put("title", safeTitle);
p.put("body", safeBody);
p.put("pinned", pinned);
p.put("authorName", principal.displayName());
mapper.insert(p);
// §5B 알림 팬아웃 행사 멤버에게 in-app 알림(기존 알림 모듈 재사용, 인프라 신설 없음).
fanoutToMembers(eventId, id, category, safeTitle);
audit.record(principal.userId(), principal.displayName(), "LIVE_NOTICE_PUBLISH",
"event_live_notice", id, eventId,
"라이브 공지 발행(" + category + ")", "SUCCESS");
return toDto(mapper.findById(id));
}
/** 보관(주최자). */
@Transactional
public LiveNoticeDto archive(KintexPrincipal principal, String eventId, String id) {
Map<String, Object> cur = mapper.findById(id);
if (cur == null || !eventId.equals(str(cur.get("eventId")))) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
mapper.archive(id, eventId);
audit.record(principal.userId(), principal.displayName(), "LIVE_NOTICE_ARCHIVE",
"event_live_notice", id, eventId, "라이브 공지 보관", "SUCCESS");
return toDto(mapper.findById(id));
}
/** 고정 토글(주최자). */
@Transactional
public LiveNoticeDto setPinned(KintexPrincipal principal, String eventId, String id, boolean pinned) {
Map<String, Object> cur = mapper.findById(id);
if (cur == null || !eventId.equals(str(cur.get("eventId")))) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
mapper.setPinned(id, eventId, pinned);
return toDto(mapper.findById(id));
}
// helpers
private void fanoutToMembers(String eventId, String noticeId, String category, String title) {
try {
List<Map<String, Object>> members = mapper.findEventMemberUserIds(eventId);
if (members == null || members.isEmpty()) {
return;
}
String link = "/events/" + eventId + "/live-notices";
int sent = 0;
for (Map<String, Object> m : members) {
if (sent >= FANOUT_LIMIT) {
break;
}
String uid = str(m.get("userId"));
if (uid == null || uid.isBlank()) {
continue;
}
// notiType §5B 공통 코드(SYSTEM) 사용 라이브 공지는 시스템성 알림.
notificationService.notify(uid, eventId, "SYSTEM",
"[라이브] " + title, "행사 라이브 공지(" + category + ")가 발행되었습니다.", link);
sent++;
}
} catch (RuntimeException e) {
// 팬아웃 실패는 발행을 막지 않는다(공개 피드가 정본). 민감정보 미노출.
log.warn("라이브 공지 §5B 알림 팬아웃 실패(무시): {}", e.getClass().getSimpleName());
}
}
private static LiveNoticeDto toDto(Map<String, Object> r) {
if (r == null) {
throw new ApiException(ErrorCode.INTERNAL);
}
return new LiveNoticeDto(
str(r.get("id")), str(r.get("eventId")), str(r.get("category")),
str(r.get("title")), str(r.get("body")),
Boolean.TRUE.equals(r.get("pinned")) || "true".equals(str(r.get("pinned"))),
str(r.get("status")), str(r.get("authorName")),
str(r.get("createdAt")), str(r.get("updatedAt")));
}
private static String upper(String s) {
return s == null || s.isBlank() ? null : s.trim().toUpperCase(Locale.ROOT);
}
private static String blankToNull(String s) {
return s == null || s.isBlank() ? null : s.trim();
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
}

View File

@ -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).
* <p>관람객·공개 사이트가 행사 진행 라이브 공지 피드를 조회한다. 게시(published) 건만·고정 우선·최신순.
*/
@RestController
@RequestMapping("/api/public/events/{eventId}/live-notices")
public class PublicLiveNoticeController {
private final LiveNoticeService service;
public PublicLiveNoticeController(LiveNoticeService service) {
this.service = service;
}
/** GET /api/public/events/{eventId}/live-notices — 공개 라이브 공지 피드. */
@GetMapping
public ApiResponse<List<LiveNoticeDto>> feed(@PathVariable String eventId,
@RequestParam(required = false, defaultValue = "100") int limit) {
return ApiResponse.ok(service.publicFeed(eventId, limit));
}
}

View File

@ -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) {
}
}

View File

@ -43,6 +43,28 @@ public final class MailTemplates {
+ WRAP_FOOT; + WRAP_FOOT;
} }
/**
* 티켓 오픈 알림(관심 행사 구독자 대상, F-C1) 행사명 + 예매 안내 + 원클릭 수신거부 링크.
* 광고성 대량 발송이 아니라 사용자가 명시 구독한 트랜잭션성 알림이지만, 수신거부 링크는 항상 포함한다.
*/
public static String ticketOpen(String eventName, String bookUrl, String unsubUrl) {
String unsubLine = (unsubUrl == null || unsubUrl.isBlank())
? ""
: "<p style=\"font-size:12px;color:#9ca3af\">알림 수신을 원치 않으시면 "
+ "<a href=\"" + esc(unsubUrl) + "\" style=\"color:#6b7280\">수신거부</a> 하실 수 있습니다.</p>";
String cta = (bookUrl == null || bookUrl.isBlank())
? ""
: "<p style=\"margin-top:16px\"><a href=\"" + esc(bookUrl) + "\" "
+ "style=\"display:inline-block;background:#1f29fc;color:#fff;padding:10px 18px;"
+ "border-radius:8px;text-decoration:none\">예매하러 가기</a></p>";
return WRAP_HEAD
+ "<h2 style=\"font-size:18px\">관심 행사의 티켓 예매가 시작되었습니다</h2>"
+ "<p><strong>행사</strong> " + esc(eventName) + "</p>"
+ "<p>구독하신 행사의 입장권 판매가 개시되었습니다. 조기 매진이 예상되니 서둘러 예매해 주세요.</p>"
+ cta
+ WRAP_FOOT.replace("</div>", "") + unsubLine + "</div>";
}
/** EDM 광고 본문(수신거부 링크 없음 — 하위호환). 원클릭 수신거부는 {@link #edm(String, String, String)} 사용. */ /** EDM 광고 본문(수신거부 링크 없음 — 하위호환). 원클릭 수신거부는 {@link #edm(String, String, String)} 사용. */
public static String edm(String campaignName, String eventName) { public static String edm(String campaignName, String eventName) {
return edm(campaignName, eventName, null); return edm(campaignName, eventName, null);

View File

@ -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.LayoutInterpretRequest;
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult; import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest; import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
import com.zioinfo.kintex.module.m2.dto.LayoutVersionDto;
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult; import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
import com.zioinfo.kintex.rules.ComplianceReport; import com.zioinfo.kintex.rules.ComplianceReport;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@ -45,6 +46,15 @@ public class FloorplanController {
return ApiResponse.ok(service.getLayout(eventId, hallId, version)); return ApiResponse.ok(service.getLayout(eventId, hallId, version));
} }
/** GET /versions — 배치안 버전 이력 목록(자동생성 proposal·draft·applied). 행사 멤버/홀매니저 열람. */
@GetMapping("/versions")
public ApiResponse<List<LayoutVersionDto>> versions(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@PathVariable String hallId) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.listVersions(eventId, hallId));
}
/** PUT — 배치 저장(주최자). */ /** PUT — 배치 저장(주최자). */
@PutMapping @PutMapping
public ApiResponse<LayoutDto> save(@AuthenticationPrincipal KintexPrincipal principal, public ApiResponse<LayoutDto> save(@AuthenticationPrincipal KintexPrincipal principal,

View File

@ -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.LayoutDto;
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult; import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest; import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
import com.zioinfo.kintex.module.m2.dto.LayoutVersionDto;
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult; import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
import com.zioinfo.kintex.rules.ComplianceReport; import com.zioinfo.kintex.rules.ComplianceReport;
@ -17,6 +18,9 @@ public interface FloorplanService {
/** 배치안 조회(version=null이면 최신). */ /** 배치안 조회(version=null이면 최신). */
LayoutDto getLayout(String eventId, String hallId, Integer version); LayoutDto getLayout(String eventId, String hallId, Integer version);
/** 배치안 버전 이력 목록(version·name·status·boothCount·updatedAt, 최신 순). */
List<LayoutVersionDto> listVersions(String eventId, String hallId);
/** 배치 저장 → 저장된 배치안. 저장 시 규정 검증도 함께 산출해 summary에 반영. */ /** 배치 저장 → 저장된 배치안. 저장 시 규정 검증도 함께 산출해 summary에 반영. */
LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request); LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request);

View File

@ -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.LayoutInterpretResult;
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest; import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
import com.zioinfo.kintex.module.m2.dto.LayoutSummary; import com.zioinfo.kintex.module.m2.dto.LayoutSummary;
import com.zioinfo.kintex.module.m2.dto.LayoutVersionDto;
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult; import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
import com.zioinfo.kintex.module.m2.mapper.BoothMapper; import com.zioinfo.kintex.module.m2.mapper.BoothMapper;
import com.zioinfo.kintex.module.m2.mapper.HallMapper; import com.zioinfo.kintex.module.m2.mapper.HallMapper;
@ -85,20 +86,20 @@ public class FloorplanServiceImpl implements FloorplanService {
@Override @Override
@Transactional @Transactional
public LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request) { public LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request) {
return persistBooths(eventId, hallId, request.name(), request.version(), request.booths()); return persistBooths(eventId, hallId, request.name(), request.version(), request.booths(), "draft");
} }
@Override @Override
@Transactional @Transactional
public LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request) { public LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request) {
// 적용/병합은 항상 버전으로 저장(version=null 다음 버전) 규정 재검증. // 적용/병합은 항상 버전으로 저장(version=null 다음 버전) 규정 재검증. 적용본은 status='applied'.
String name = request.name() == null ? "자동배치 적용" : request.name(); 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, private LayoutDto persistBooths(String eventId, String hallId, String name,
Integer requestedVersion, List<BoothDto> booths) { Integer requestedVersion, List<BoothDto> booths, String status) {
int version = requestedVersion != null ? requestedVersion : nextVersion(eventId, hallId); int version = requestedVersion != null ? requestedVersion : nextVersion(eventId, hallId);
Map<String, Object> params = new LinkedHashMap<>(); Map<String, Object> params = new LinkedHashMap<>();
@ -106,7 +107,7 @@ public class FloorplanServiceImpl implements FloorplanService {
params.put("hallId", hallId); params.put("hallId", hallId);
params.put("version", version); params.put("version", version);
params.put("name", name); params.put("name", name);
params.put("status", "draft"); params.put("status", status);
String layoutId = boothMapper.upsertLayout(params); String layoutId = boothMapper.upsertLayout(params);
List<Map<String, Object>> boothRows = new ArrayList<>(); List<Map<String, Object>> boothRows = new ArrayList<>();
@ -323,6 +324,9 @@ public class FloorplanServiceImpl implements FloorplanService {
String label = "배치안 " + tag + (zoneLabel != null ? " · " + zoneLabel : ""); String label = "배치안 " + tag + (zoneLabel != null ? " · " + zoneLabel : "");
options.add(new AutoLayoutOption("opt-" + tag, label, summary, booths, null)); options.add(new AutoLayoutOption("opt-" + tag, label, summary, booths, null));
} }
// 생성 영속화 안을 status='proposal' 버전으로 저장(배치 이력 재열람·적용 근거, 소유자 지시 2026-07-23).
options = persistProposals(eventId, hallId, options);
log.info("자동배치 생성: event={} hall={} zone={} options={} target={} ai={}", log.info("자동배치 생성: event={} hall={} zone={} options={} target={} ai={}",
eventId, hallId, request.zoneId(), options.size(), request.targetBoothCount(), ai); eventId, hallId, request.zoneId(), options.size(), request.targetBoothCount(), ai);
@ -330,6 +334,41 @@ public class FloorplanServiceImpl implements FloorplanService {
return ai ? attachAiSummaries(options, hall) : options; return ai ? attachAiSummaries(options, hall) : options;
} }
/**
* 생성 배치안을 status='proposal' layout 버전으로 영속화한다(응답 유실 방지, 배치 이력 조회 근거).
* <b>best-effort</b> 개별 영속 실패는 무회귀(해당 안은 version 없이 반환) 자동배치 응답 자체는 항상 성공.
* 적용(applyOption) status='applied' 전이, 수동 저장(saveLayout) 'draft'.
*/
private List<AutoLayoutOption> persistProposals(String eventId, String hallId, List<AutoLayoutOption> options) {
List<AutoLayoutOption> out = new ArrayList<>(options.size());
for (AutoLayoutOption o : options) {
try {
LayoutDto saved = persistBooths(eventId, hallId, o.label(), null, o.booths(), "proposal");
out.add(o.withVersion(saved.version()));
} catch (RuntimeException e) {
log.warn("배치안 proposal 영속 생략(무회귀): event={} hall={} 사유={}",
eventId, hallId, e.getClass().getSimpleName());
out.add(o);
}
}
return out;
}
@Override
public List<LayoutVersionDto> listVersions(String eventId, String hallId) {
List<Map<String, Object>> rows = boothMapper.findLayoutVersions(eventId, hallId);
List<LayoutVersionDto> out = new ArrayList<>();
if (rows == null) {
return out;
}
for (Map<String, Object> r : rows) {
out.add(new LayoutVersionDto(
intOrNull(r.get("version")), str(r.get("name")), str(r.get("status")),
intOrNull(r.get("boothCount")), str(r.get("updatedAt"))));
}
return out;
}
/** /**
* 배치안에 AI 장단점 요약을 부가한다 <b>엔진이 계산한 metrics만</b> 근거(환각 차단). * 배치안에 AI 장단점 요약을 부가한다 <b>엔진이 계산한 metrics만</b> 근거(환각 차단).
* 단일 AI 호출로 optionId요약 JSON 받아 매핑한다. AI 미가용/파싱 실패 원본 옵션(요약 없음) 그대로 반환. * 단일 AI 호출로 optionId요약 JSON 받아 매핑한다. AI 미가용/파싱 실패 원본 옵션(요약 없음) 그대로 반환.
@ -614,11 +653,9 @@ public class FloorplanServiceImpl implements FloorplanService {
} }
private int nextVersion(String eventId, String hallId) { private int nextVersion(String eventId, String hallId) {
Map<String, Object> latest = boothMapper.findLayout(eventId, hallId, null); // proposal 포함 최대 버전 + 1 작업본 우선 조회(findLayout) 분리해 버전 충돌 방지.
if (latest == null) { Integer max = boothMapper.maxVersion(eventId, hallId);
return 1; return (max == null ? 0 : max) + 1;
}
return intVal(latest.get("version")) + 1;
} }
// --------------------------------------------------------- 좌표/형변환 ----- // --------------------------------------------------------- 좌표/형변환 -----

View File

@ -13,6 +13,7 @@ import java.util.List;
* @param booths 후보 부스 배열 "이 안으로 편집 시작"/병합 소스(계약 확장, 프론트 gap #3 해소, NON_NULL) * @param booths 후보 부스 배열 "이 안으로 편집 시작"/병합 소스(계약 확장, 프론트 gap #3 해소, NON_NULL)
* @param s7RenderJobId S7 전경(조감) 생성 ID 완료 WebSocket 푸시로 카드 이미지 교체 * @param s7RenderJobId S7 전경(조감) 생성 ID 완료 WebSocket 푸시로 카드 이미지 교체
* @param aiSummary 배치안 AI 장단점 요약(ai=true 시에만, 엔진 산출 metrics 근거 2~3줄) 실패/미요청 null(NON_NULL 응답 제외) * @param aiSummary 배치안 AI 장단점 요약(ai=true 시에만, 엔진 산출 metrics 근거 2~3줄) 실패/미요청 null(NON_NULL 응답 제외)
* @param version 생성 proposal 영속된 layout 버전(배치안 이력 조회·재열람 근거) 미영속 null(NON_NULL 제외)
*/ */
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
public record AutoLayoutOption( public record AutoLayoutOption(
@ -21,16 +22,22 @@ public record AutoLayoutOption(
LayoutSummary summary, LayoutSummary summary,
List<BoothDto> booths, List<BoothDto> booths,
String s7RenderJobId, String s7RenderJobId,
String aiSummary String aiSummary,
Integer version
) { ) {
/** 기존 4필드 + s7 생성자 호환 — aiSummary 미부여(null). */ /** 기존 4필드 + s7 생성자 호환 — aiSummary·version 미부여(null). */
public AutoLayoutOption(String optionId, String label, LayoutSummary summary, public AutoLayoutOption(String optionId, String label, LayoutSummary summary,
List<BoothDto> booths, String s7RenderJobId) { List<BoothDto> booths, String s7RenderJobId) {
this(optionId, label, summary, booths, s7RenderJobId, null); this(optionId, label, summary, booths, s7RenderJobId, null, null);
} }
/** AI 요약을 덧입힌 사본 반환(불변). */ /** AI 요약을 덧입힌 사본 반환(불변, version 보존). */
public AutoLayoutOption withAiSummary(String summary) { 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);
} }
} }

View File

@ -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
) {
}

View File

@ -14,11 +14,18 @@ import java.util.Map;
@Mapper @Mapper
public interface BoothMapper { public interface BoothMapper {
/** 배치안 헤더 조회(layoutId, version, name, status, updatedAt). */ /** 배치안 헤더 조회(layoutId, version, name, status, updatedAt). version=null → 최신(작업본 우선). */
Map<String, Object> findLayout(@Param("eventId") String eventId, Map<String, Object> findLayout(@Param("eventId") String eventId,
@Param("hallId") String hallId, @Param("hallId") String hallId,
@Param("version") Integer version); @Param("version") Integer version);
/** 현재 최대 버전(proposal 포함) — 다음 버전 번호 산정용. 없으면 null. */
Integer maxVersion(@Param("eventId") String eventId, @Param("hallId") String hallId);
/** 배치안 버전 이력 목록(version·name·status·boothCount·updatedAt) — 최신 순. */
List<Map<String, Object>> findLayoutVersions(@Param("eventId") String eventId,
@Param("hallId") String hallId);
/** 배치안의 부스 목록 조회(폴리곤은 ST_AsGeoJSON 또는 좌표 배열로 반환). */ /** 배치안의 부스 목록 조회(폴리곤은 ST_AsGeoJSON 또는 좌표 배열로 반환). */
List<Map<String, Object>> findBooths(@Param("layoutId") String layoutId); List<Map<String, Object>> findBooths(@Param("layoutId") String layoutId);

View File

@ -28,6 +28,7 @@ public interface OpsMapper {
@Select(""" @Select("""
SELECT h.id AS "hallId", h.label AS "label", SELECT h.id AS "hallId", h.label AS "label",
COALESCE(ar.sales_area, 0) AS "salesArea", COALESCE(ar.sales_area, 0) AS "salesArea",
COALESCE(h.area_m2, 0) AS "physicalArea",
pw.req_kw AS "powerKw" pw.req_kw AS "powerKw"
FROM hall_assignment ha FROM hall_assignment ha
JOIN hall h ON h.id = ha.hall_id JOIN hall h ON h.id = ha.hall_id

View File

@ -35,30 +35,36 @@ public class OpsService {
rows = new ArrayList<>(); rows = new ArrayList<>();
} }
double totalArea = 0; // 분배 가중치: 홀별로 판매면적(부스 배치 실적) 있으면 값을, 없으면 물리 바닥면적을 쓴다.
// 판매면적만 쓰면 데모/초기처럼 홀에만 배치가 있을 홀에 현장 인원이 100% 몰려
// "면적 비중 분배가 이상" 보인다 배정된 모든 홀이 실제 규모에 비례해 분산되도록 폴백.
double totalWeight = 0;
for (Map<String, Object> r : rows) { for (Map<String, Object> r : rows) {
totalArea += dbl(r.get("salesArea")); totalWeight += hallWeight(r);
} }
List<OpsDto.Hall> halls = new ArrayList<>(); List<OpsDto.Hall> halls = new ArrayList<>();
Double powerTotal = null; Double powerTotal = null;
int hallCount = rows.size(); int hallCount = rows.size();
for (Map<String, Object> r : rows) { for (Map<String, Object> r : rows) {
double 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")); Double powerKw = r.get("powerKw") == null ? null : dbl(r.get("powerKw"));
long est = 0L; long est = 0L;
if (onSite > 0) { if (onSite > 0) {
if (totalArea > 0) { if (totalWeight > 0) {
est = Math.round(onSite * (area / totalArea)); est = Math.round(onSite * (weight / totalWeight));
} else if (hallCount > 0) { } else if (hallCount > 0) {
// 부스 미배치 균등 분배(면적 가중 불가). // 면적 정보 전무 균등 분배(가중 불가).
est = Math.round((double) onSite / hallCount); est = Math.round((double) onSite / hallCount);
} }
} }
// 혼잡 밀도 기준면적: 판매면적 우선(사람은 부스 통로에 있음), 없으면 물리면적.
double densityArea = salesArea > 0 ? salesArea : dbl(r.get("physicalArea"));
halls.add(new OpsDto.Hall( halls.add(new OpsDto.Hall(
str(r.get("hallId")), str(r.get("label")), est, heatFor(est, area), powerKw)); str(r.get("hallId")), str(r.get("label")), est, heatFor(est, densityArea), powerKw));
if (powerKw != null) { if (powerKw != null) {
powerTotal = (powerTotal == null ? 0d : powerTotal) + powerKw; 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); return new OpsDto(halls, hvac, new ArrayList<>(), new ArrayList<>(), onSite, powerTotal, series);
} }
/** 홀 분배 가중치 — 판매면적(부스 배치 실적) 우선, 없으면 물리 바닥면적. */
private static double hallWeight(Map<String, Object> r) {
double salesArea = dbl(r.get("salesArea"));
return salesArea > 0 ? salesArea : dbl(r.get("physicalArea"));
}
/** 인원 밀도(명/㎡) 기준 혼잡 등급. 인원 0 또는 면적 부재 시 보수적 처리. */ /** 인원 밀도(명/㎡) 기준 혼잡 등급. 인원 0 또는 면적 부재 시 보수적 처리. */
private static String heatFor(long est, double area) { private static String heatFor(long est, double area) {
if (est <= 0) { if (est <= 0) {

View File

@ -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).
*
* <p>권한 설계:
* <ul>
* <li>주차 현황 {@code GET /api/public/parking/lots} 공개(비로그인, {@code /api/public/**} permitAll).</li>
* <li>사전 주차권 구매/조회 {@code /api/parking/passes} 인증 필요(로그인 관람객, 본인 소유분만).</li>
* </ul>
* 보안(§0-3): 응답에 차량번호 원문 미노출(마스킹만) · 스택트레이스 미노출(GlobalExceptionHandler).
*/
@RestController
public class ParkingController {
private final ParkingService service;
public ParkingController(ParkingService service) {
this.service = service;
}
/** GET /api/public/parking/lots?eventId= — 주차장 실시간 현황(공개). 잔여·점유율·혼잡도·요금. */
@GetMapping("/api/public/parking/lots")
public ApiResponse<List<ParkingLotStatusDto>> lots(
@RequestParam(required = false) String eventId) {
return ApiResponse.ok(service.getLotStatus(eventId));
}
/** POST /api/parking/passes — 사전 주차권 구매(인증, mock 결제). */
@PostMapping("/api/parking/passes")
public ApiResponse<ParkingPassDto> purchase(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody PurchasePassRequest req) {
return ApiResponse.ok(service.purchasePass(requireUserId(principal), req));
}
/** GET /api/parking/passes/me — 내 주차권 목록(인증, 본인 소유분만). */
@GetMapping("/api/parking/passes/me")
public ApiResponse<MyPassesDto> myPasses(@AuthenticationPrincipal KintexPrincipal principal) {
return ApiResponse.ok(new MyPassesDto(service.myPasses(requireUserId(principal))));
}
private static String requireUserId(KintexPrincipal principal) {
if (principal == null || principal.userId() == null || principal.userId().isBlank()) {
throw new ApiException(ErrorCode.UNAUTHORIZED, "로그인이 필요합니다.");
}
return principal.userId();
}
}

View File

@ -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' 고정.
* <p> Map 반환 @Select camelCase 별칭에 반드시 쌍따옴표({@code AS "x"}) 사용한다(PG lower-fold 방지).
* <p>실시간 점유는 DB에 없다 {@link SimulatedParkingService} 시뮬레이션 어댑터가 산출(외부 미연동).
*/
@Mapper
public interface ParkingMapper {
/** 활성 주차장 마스터 목록 — 표시순. */
@Select("""
SELECT id AS "lotId",
code,
name,
exhibition_center AS "exhibitionCenter",
total_capacity AS "totalCapacity",
hourly_rate AS "hourlyRate",
daily_max AS "dailyMax",
pass_price AS "passPrice",
note,
sort_order AS "sortOrder"
FROM parking_lot
WHERE tenant_id = 'KINTEX' AND status = 'active'
ORDER BY sort_order, id
""")
List<Map<String, Object>> findActiveLots();
/** 단일 주차장(구매 검증용). 없으면 null. */
@Select("""
SELECT id AS "lotId",
code,
name,
pass_price AS "passPrice",
status
FROM parking_lot
WHERE tenant_id = 'KINTEX' AND id = #{lotId}
""")
Map<String, Object> findLot(@Param("lotId") String lotId);
/**
* 특정 주차장·이용일의 유효(PAID/USED) 사전 주차권 시뮬레이션 점유 가중치.
* 사전 예약분이 실시간 잔여에 반영되도록 한다.
*/
@Select("""
SELECT count(*)
FROM parking_pass
WHERE tenant_id = 'KINTEX'
AND lot_id = #{lotId}
AND use_date = CAST(#{useDate} AS date)
AND status IN ('PAID', 'USED')
""")
long countActivePasses(@Param("lotId") String lotId, @Param("useDate") String useDate);
/** 주차권 발급 저장. */
@Insert("""
INSERT INTO parking_pass
(tenant_id, id, pass_no, event_id, lot_id, lot_name, user_id, use_date,
vehicle_plate_masked, amount, status, pay_method, pay_approval_no)
VALUES
('KINTEX', #{id}, #{passNo}, #{eventId}, #{lotId}, #{lotName}, #{userId},
CAST(#{useDate} AS date), #{vehiclePlateMasked}, #{amount}, #{status},
#{payMethod}, #{payApprovalNo})
""")
void insertPass(Map<String, Object> pass);
/** 발급번호 중복 확인(유니크 보증). */
@Select("SELECT count(*) FROM parking_pass WHERE pass_no = #{passNo}")
long countPassNo(@Param("passNo") String passNo);
/** 내 주차권 목록 — 소유자 본인만. 이용일 최신순. */
@Select("""
SELECT pass_no AS "passNo",
status,
lot_id AS "lotId",
lot_name AS "lotName",
event_id AS "eventId",
to_char(use_date, 'YYYY-MM-DD') AS "useDate",
vehicle_plate_masked AS "vehiclePlateMasked",
amount,
pay_method AS "payMethod",
pay_approval_no AS "payApprovalNo",
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "issuedAt"
FROM parking_pass
WHERE tenant_id = 'KINTEX' AND user_id = #{userId}
ORDER BY use_date DESC, created_at DESC
""")
List<Map<String, Object>> findPassesByUser(@Param("userId") String userId);
}

View File

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

View File

@ -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).
*
* <p>실시간 점유는 외부 시스템(iparking ) 미연동 시각·주차장별 결정론 곡선 + 발급된 사전 주차권 수로
* 산출한다(하드웨어/외부 호출 없음). 구매는 mock 결제({@link PaymentGateway}) 승인한다.
*
* <p>TODO(게이트): 외부 주차관제(iparking) 실연동은 게이트 대상 승인 별도 어댑터
* ({@code IparkingParkingService implements ParkingService}) 빈을 대체한다.
* 외부 API /엔드포인트는 env 주입, 실패 시뮬레이션으로 폴백한다. 지금은 외부 호출 금지.
*/
@Service
public class SimulatedParkingService implements ParkingService {
/** 혼잡 임계(점유율 %) — 여유<60 ≤ 보통<90 ≤ 혼잡. */
private static final int PCT_BUSY = 90;
private static final int PCT_NORMAL = 60;
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final SecureRandom random = new SecureRandom();
private final ParkingMapper mapper;
private final PaymentGateway paymentGateway;
public SimulatedParkingService(ParkingMapper mapper, PaymentGateway paymentGateway) {
this.mapper = mapper;
this.paymentGateway = paymentGateway;
}
// 주차 현황(공개)
@Override
@Transactional(readOnly = true)
public List<ParkingLotStatusDto> getLotStatus(String eventId) {
String today = LocalDate.now().format(DATE);
int hour = LocalDateTime.now().getHour();
String now = LocalDateTime.now().format(TS);
List<ParkingLotStatusDto> out = new ArrayList<>();
for (Map<String, Object> r : mapper.findActiveLots()) {
String lotId = str(r.get("lotId"));
String code = str(r.get("code"));
int capacity = toInt(r.get("totalCapacity"), 0);
// 결정론 점유 = 시각 곡선 + 주차장 시드 편차, 사전 주차권 수를 가산(cap).
long reserved = mapper.countActivePasses(lotId, today);
int occupied = simulateOccupancy(code, capacity, hour, reserved);
int available = Math.max(0, capacity - occupied);
int pct = capacity > 0 ? (int) Math.round(occupied * 100.0 / capacity) : 0;
out.add(new ParkingLotStatusDto(
lotId, code, str(r.get("name")), toIntOrNull(r.get("exhibitionCenter")),
capacity, occupied, available, pct,
levelCode(pct), levelLabel(pct),
toLong(r.get("hourlyRate"), 0), toLongOrNull(r.get("dailyMax")),
toLong(r.get("passPrice"), 0), str(r.get("note")), now));
}
return out;
}
/**
* 결정론 점유 시뮬레이션 시각 벨커브(정오 피크) + 주차장 코드 해시 편차. 사전 예약분 가산 cap.
* 외부 실측이 아닌 표시용 근사(정직). 같은 입력에 같은 결과.
*/
private static int simulateOccupancy(String code, int capacity, int hour, long reserved) {
if (capacity <= 0) {
return 0;
}
// 시각 곡선: 10~16시 피크(~0.85), 심야 저점(~0.05).
double peakDist = Math.abs(hour - 13); // 13시 피크 기준 거리
double base = Math.max(0.05, 0.85 - peakDist * 0.07);
// 주차장별 편차(-0.08 ~ +0.07) 코드 해시 결정론.
int seed = Math.floorMod(code.hashCode(), 15);
double ratio = base + (seed - 7) / 100.0;
ratio = Math.max(0.02, Math.min(0.98, ratio));
int simulated = (int) Math.round(capacity * ratio);
return Math.min(capacity, simulated + (int) Math.min(reserved, capacity));
}
// 사전 주차권 구매(인증)
@Override
@Transactional
public ParkingPassDto purchasePass(String userId, PurchasePassRequest req) {
if (userId == null || userId.isBlank()) {
throw new ApiException(ErrorCode.UNAUTHORIZED, "로그인이 필요합니다.");
}
LocalDate useDate = parseUseDate(req.useDate());
if (useDate.isBefore(LocalDate.now())) {
throw new ApiException(ErrorCode.VALIDATION, "이용일은 오늘 이후로 선택해 주세요.");
}
Map<String, Object> lot = mapper.findLot(req.lotId());
if (lot == null || !"active".equals(str(lot.get("status")))) {
throw new ApiException(ErrorCode.NOT_FOUND, "선택한 주차장을 찾을 수 없습니다.");
}
long amount = toLong(lot.get("passPrice"), 0);
String lotName = str(lot.get("name"));
String passNo = generatePassNo();
String payMethod = normalizeMethod(req.payMethod(), amount);
// Mock PG 승인 실패 예외 트랜잭션 롤백.
PaymentGateway.PaymentResult pay = paymentGateway.authorize(payMethod, amount, passNo);
if (!pay.approved()) {
throw new ApiException(ErrorCode.CONFLICT, "결제 승인에 실패했습니다. 다시 시도해 주세요.");
}
String plateMasked = maskPlate(req.vehiclePlate());
String id = "pp-" + UUID.randomUUID();
String useDateStr = useDate.format(DATE);
Map<String, Object> pass = new HashMap<>();
pass.put("id", id);
pass.put("passNo", passNo);
pass.put("eventId", nullIfBlank(req.eventId()));
pass.put("lotId", req.lotId());
pass.put("lotName", lotName);
pass.put("userId", userId);
pass.put("useDate", useDateStr);
pass.put("vehiclePlateMasked", plateMasked);
pass.put("amount", amount);
pass.put("status", "PAID");
pass.put("payMethod", payMethod);
pass.put("payApprovalNo", pay.approvalNo());
mapper.insertPass(pass);
return new ParkingPassDto(passNo, "PAID", req.lotId(), lotName, nullIfBlank(req.eventId()),
useDateStr, plateMasked, amount, payMethod, pay.approvalNo(),
LocalDateTime.now().format(TS));
}
// 주차권 조회(인증)
@Override
@Transactional(readOnly = true)
public List<ParkingPassDto> myPasses(String userId) {
if (userId == null || userId.isBlank()) {
throw new ApiException(ErrorCode.UNAUTHORIZED, "로그인이 필요합니다.");
}
return mapper.findPassesByUser(userId).stream().map(SimulatedParkingService::toPass).toList();
}
// helpers
private static String levelCode(int pct) {
if (pct >= PCT_BUSY) {
return "BUSY";
}
if (pct >= PCT_NORMAL) {
return "NORMAL";
}
return "FREE";
}
private static String levelLabel(int pct) {
if (pct >= PCT_BUSY) {
return "혼잡";
}
if (pct >= PCT_NORMAL) {
return "보통";
}
return "여유";
}
private String generatePassNo() {
int year = java.time.Year.now().getValue();
for (int attempt = 0; attempt < 6; attempt++) {
String no = "PK-" + year + "-" + String.format("%06d", random.nextInt(1_000_000));
if (mapper.countPassNo(no) == 0) {
return no;
}
}
return "PK-" + year + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
private static LocalDate parseUseDate(String s) {
if (s == null || s.isBlank()) {
throw new ApiException(ErrorCode.VALIDATION, "이용일을 선택해 주세요.");
}
try {
return LocalDate.parse(s.trim());
} catch (RuntimeException e) {
throw new ApiException(ErrorCode.VALIDATION, "이용일 형식이 올바르지 않습니다(YYYY-MM-DD).");
}
}
private static String normalizeMethod(String method, long amount) {
if (amount == 0) {
return "free";
}
if (method == null || method.isBlank()) {
return "card";
}
String m = method.trim().toLowerCase(java.util.Locale.ROOT);
return switch (m) {
case "card", "easy", "bank" -> m;
default -> "card";
};
}
/** 차량번호 마스킹 — 앞2·뒤2만 노출(원문 미저장). "123가4567" → "12****67". null 안전. */
private static String maskPlate(String plate) {
if (plate == null || plate.isBlank()) {
return null;
}
String p = plate.replaceAll("\\s", "");
int len = p.length();
if (len <= 4) {
return "*".repeat(Math.max(1, len));
}
return p.substring(0, 2) + "*".repeat(len - 4) + p.substring(len - 2);
}
private static ParkingPassDto toPass(Map<String, Object> m) {
return new ParkingPassDto(
str(m.get("passNo")), str(m.get("status")), str(m.get("lotId")), str(m.get("lotName")),
str(m.get("eventId")), str(m.get("useDate")), str(m.get("vehiclePlateMasked")),
toLong(m.get("amount"), 0), str(m.get("payMethod")), str(m.get("payApprovalNo")),
str(m.get("issuedAt")));
}
private static String nullIfBlank(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
private static String str(Object o) {
return o == null ? null : o.toString();
}
private static int toInt(Object o, int def) {
return (o instanceof Number n) ? n.intValue() : def;
}
private static Integer toIntOrNull(Object o) {
return (o instanceof Number n) ? n.intValue() : null;
}
private static long toLong(Object o, long def) {
return (o instanceof Number n) ? n.longValue() : def;
}
private static Long toLongOrNull(Object o) {
return (o instanceof Number n) ? n.longValue() : null;
}
}

View File

@ -0,0 +1,65 @@
package com.zioinfo.kintex.parking.dto;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
/**
* 관람객 주차 연계(F-C2) DTO 모음. 주차 현황(공개) · 사전 주차권 구매/조회(인증).
* <p>보안(§0-3): 차량번호 원문 미노출 응답은 마스킹 필드만. 소유자 식별은 내부 user_id.
*/
public final class ParkingDtos {
private ParkingDtos() {
}
/**
* 주차장 실시간 현황(공개) 잔여·점유율·혼잡도·요금. 점유는 시뮬레이션 어댑터 산출값.
* congestionLevel FREE|NORMAL|BUSY (여유/보통/혼잡).
*/
public record ParkingLotStatusDto(
String lotId,
String code,
String name,
Integer exhibitionCenter,
int totalCapacity,
int occupied,
int available,
int occupancyPercent,
String congestionLevel,
String congestionLabel,
long hourlyRate,
Long dailyMax,
long passPrice,
String note,
String updatedAt) {
}
/** 사전 주차권 구매 요청 — 1일권. 차량번호는 마스킹 저장(원문 미보관). */
public record PurchasePassRequest(
@NotBlank String lotId,
@NotBlank String useDate, // YYYY-MM-DD
String eventId,
String vehiclePlate, // 원문(마스킹 폐기, 미저장)
String payMethod) {
}
/** 발급된 주차권 — 소유자(내부 user_id) 본인만 조회. 차량번호 마스킹만 노출. */
public record ParkingPassDto(
String passNo,
String status,
String lotId,
String lotName,
String eventId,
String useDate,
String vehiclePlateMasked,
long amount,
String payMethod,
String payApprovalNo,
String issuedAt) {
}
/** 내 주차권 목록 봉투(향후 확장 여지). */
public record MyPassesDto(List<ParkingPassDto> passes) {
}
}

View File

@ -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).
* <p>구독 신청/해지만 제공. 구독 목록·원문 이메일 조회는 공개하지 않는다(§0-3).
* 티켓 오픈 알림 발송은 {@link TicketOpenNotifier}(스케줄러) 담당한다.
*/
@RestController
@RequestMapping("/api/public")
public class EventSubscriptionController {
private final EventSubscriptionService service;
public EventSubscriptionController(EventSubscriptionService service) {
this.service = service;
}
/** POST /api/public/events/{eventId}/subscribe — 관심 행사 구독(이메일·개인정보 동의). */
@PostMapping("/events/{eventId}/subscribe")
public ApiResponse<SubscribeResult> subscribe(@PathVariable String eventId,
@RequestBody SubscribeRequest req) {
return ApiResponse.ok(service.subscribe(eventId, req));
}
/** POST /api/public/subscriptions/unsubscribe?token= — 원클릭 수신거부(멱등). */
@PostMapping("/subscriptions/unsubscribe")
public ApiResponse<UnsubscribeResult> unsubscribe(@RequestParam String token) {
return ApiResponse.ok(service.unsubscribe(token));
}
/** GET /api/public/subscriptions/unsubscribe?token= — 메일 링크(GET) 호환. */
@GetMapping("/subscriptions/unsubscribe")
public ApiResponse<UnsubscribeResult> unsubscribeGet(@RequestParam String token) {
return ApiResponse.ok(service.unsubscribe(token));
}
}

View File

@ -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) 매퍼.
* <p> Map 반환 @Select camelCase 별칭에 쌍따옴표({@code AS "x"}) 사용한다(PG lower-fold 방지).
* <p> MyBatis 함정 회피: raw {@code <}/{@code <=} 미사용 시각 비교는 {@code now() >= sale_start} 표현.
* <p>원문 email 발송 경로(스케줄러)로만 반환 서비스가 응답/로그에 노출하지 않는다.
*/
@Mapper
public interface EventSubscriptionMapper {
/** 대상 행사 존재 여부(공개 구독 검증). */
@Select("SELECT count(*) FROM event WHERE id = #{eventId}")
int eventExists(@Param("eventId") String eventId);
/** 행사 종료 여부 — 종료된 행사는 구독 불가(오픈 예정/진행 대상만). end_date 없으면 진행중 간주. */
@Select("""
SELECT CASE WHEN end_date IS NOT NULL AND end_date < current_date
THEN 1 ELSE 0 END
FROM event WHERE id = #{eventId}
""")
Integer isEnded(@Param("eventId") String eventId);
/** 동일 행사+이메일 기존 구독 조회(대소문자 무시). */
@Select("""
SELECT id, status, unsub_token AS "unsubToken",
to_char(notified_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "notifiedAt"
FROM event_subscription
WHERE tenant_id = 'KINTEX' AND event_id = #{eventId} AND lower(email) = lower(#{email})
""")
Map<String, Object> findByEventEmail(@Param("eventId") String eventId,
@Param("email") String email);
/** 동일 이메일 최근 window(초) 내 구독 건수 — 단순 rate 제어. */
@Select("""
SELECT count(*)::int FROM event_subscription
WHERE lower(email) = lower(#{email})
AND created_at > now() - make_interval(secs => #{windowSeconds})
""")
int countRecentByEmail(@Param("email") String email, @Param("windowSeconds") int windowSeconds);
/** 신규 구독 저장. */
@Insert("""
INSERT INTO event_subscription
(tenant_id, id, event_id, email, email_masked, unsub_token, status)
VALUES ('KINTEX', #{id}, #{eventId}, #{email}, #{emailMasked}, #{unsubToken}, 'active')
""")
int insert(@Param("id") String id,
@Param("eventId") String eventId,
@Param("email") String email,
@Param("emailMasked") String emailMasked,
@Param("unsubToken") String unsubToken);
/** 수신거부되었던 구독 재활성화(토큰 유지). 티켓 오픈이 아직이면 재알림 대상이 되도록 notified_at 은 유지. */
@Update("""
UPDATE event_subscription SET status = 'active'
WHERE tenant_id = 'KINTEX' AND id = #{id} AND status <> 'active'
""")
int reactivate(@Param("id") String id);
/** 원클릭 수신거부(멱등). 활성 건만 전이. */
@Update("""
UPDATE event_subscription SET status = 'unsubscribed'
WHERE unsub_token = #{token} AND status = 'active'
""")
int unsubscribeByToken(@Param("token") String token);
/** 토큰 존재 여부(이미 처리된 건 포함) — 멱등 성공/실패 구분용. */
@Select("SELECT count(*) FROM event_subscription WHERE unsub_token = #{token}")
int tokenExists(@Param("token") String token);
/**
* 티켓 판매가 개시된(오픈) 행사의 미통지 활성 구독 오픈 감지 스케줄러 대상.
* 오픈 판정: 해당 행사에 status='active', total_qty>0, {@code now() >= sale_start} 티켓 상품이 1건 이상.
* email 원문은 발송 경로 한정(서비스가 노출 금지).
*/
@Select("""
SELECT s.id, s.event_id AS "eventId", s.email, s.unsub_token AS "unsubToken",
e.name AS "eventName"
FROM event_subscription s
JOIN event e ON e.id = s.event_id
WHERE s.tenant_id = 'KINTEX'
AND s.status = 'active'
AND s.notified_at IS NULL
AND EXISTS (
SELECT 1 FROM ticket_product tp
WHERE tp.event_id = s.event_id
AND tp.status = 'active'
AND tp.total_qty > 0
AND tp.sale_start IS NOT NULL
AND now() >= tp.sale_start
)
ORDER BY s.created_at
LIMIT #{limit}
""")
List<Map<String, Object>> findPendingTicketOpen(@Param("limit") int limit);
/** 통지 완료 마킹(조건부 — 이미 통지된 건은 0). 중복 발송 방지. */
@Update("""
UPDATE event_subscription SET notified_at = now()
WHERE tenant_id = 'KINTEX' AND id = #{id} AND notified_at IS NULL
""")
int markNotified(@Param("id") String id);
}

View File

@ -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}(스케줄러).
* <p>PII 최소화(§0-3): 원문 email 저장·발송 경로 한정이며 응답에는 email_masked 담는다.
* 구독 응답에도 원문·전체 목록은 노출하지 않는다(공개 엔드포인트).
*/
@Service
public class EventSubscriptionService {
private static final int RATE_WINDOW_SECONDS = 60; // 동일 이메일 1분 1건
private static final Pattern EMAIL = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
private final EventSubscriptionMapper mapper;
public EventSubscriptionService(EventSubscriptionMapper mapper) {
this.mapper = mapper;
}
/**
* 관심 행사 구독(공개). 개인정보 동의 필수 · 이메일 형식 검증 · 종료 행사 거부 · 동일 이메일 1분 rate 제어.
* 멱등: 기존 활성 구독이면 alreadySubscribed=true. 해지되었던 건은 재활성화한다.
*/
@Transactional
public SubscribeResult subscribe(String eventId, SubscribeRequest req) {
if (req == null || !Boolean.TRUE.equals(req.agreePrivacy())) {
throw new ApiException(ErrorCode.VALIDATION, "개인정보 수집·이용 동의(필수)가 필요합니다.");
}
String email = nullIfBlank(req.email());
if (email == null || !EMAIL.matcher(email).matches()) {
throw new ApiException(ErrorCode.VALIDATION, "이메일 형식이 올바르지 않습니다.");
}
if (mapper.eventExists(eventId) == 0) {
throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 행사입니다.");
}
Integer ended = mapper.isEnded(eventId);
if (ended != null && ended == 1) {
throw new ApiException(ErrorCode.VALIDATION, "이미 종료된 행사는 구독할 수 없습니다.");
}
String masked = maskEmail(email);
// 기존 구독 확인(멱등) 활성이면 그대로, 해지 상태면 재활성화.
Map<String, Object> existing = mapper.findByEventEmail(eventId, email);
if (existing != null) {
String status = str(existing.get("status"));
if ("active".equals(status)) {
return new SubscribeResult(eventId, null, masked, "active", true);
}
mapper.reactivate(str(existing.get("id")));
return new SubscribeResult(eventId, null, masked, "active", false);
}
// 신규 rate 제어(동일 이메일 1분 1건)
if (mapper.countRecentByEmail(email, RATE_WINDOW_SECONDS) > 0) {
throw new ApiException(ErrorCode.CONFLICT,
"같은 이메일로 잠시 전 구독 요청이 있었습니다. 1분 후 다시 시도해 주세요.");
}
String id = "es-" + UUID.randomUUID().toString().replace("-", "").substring(0, 18);
String token = "estok-" + UUID.randomUUID().toString().replace("-", "");
mapper.insert(id, eventId, email, masked, token);
return new SubscribeResult(eventId, null, masked, "active", false);
}
/**
* 원클릭 수신거부(공개·비인증). 불투명 토큰으로 멱등 해지. 토큰이 존재하면(이미 처리 포함) 성공 안내.
*/
@Transactional
public UnsubscribeResult unsubscribe(String token) {
String t = nullIfBlank(token);
if (t == null) {
return new UnsubscribeResult(false, "유효하지 않은 요청입니다.");
}
int updated = mapper.unsubscribeByToken(t);
if (updated > 0) {
return new UnsubscribeResult(true, "구독이 해지되었습니다.");
}
// 이미 해지되었거나(멱등) 토큰 존재 여부로 성공/실패 구분.
if (mapper.tokenExists(t) > 0) {
return new UnsubscribeResult(true, "이미 수신거부 처리된 구독입니다.");
}
return new UnsubscribeResult(false, "유효하지 않은 수신거부 토큰입니다.");
}
// helpers
/** 이메일 마스킹 — wat***@domain.com. 로그·응답 노출 안전 형식. */
static String maskEmail(String email) {
if (email == null || email.isBlank()) {
return "***";
}
int at = email.indexOf('@');
if (at <= 0) {
return "***";
}
String local = email.substring(0, at);
String domain = email.substring(at);
if (local.length() <= 3) {
return local.charAt(0) + "***" + domain;
}
return local.substring(0, 3) + "***" + domain;
}
private static String nullIfBlank(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
}

View File

@ -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회 발송한다.
*
* <p><b>동작</b>: 60초 폴러가 {@link EventSubscriptionMapper#findPendingTicketOpen}(오픈된 행사의 미통지 활성 구독)
* 조회하고, 건을 <b>조건부 markNotified</b>(이중 발송 방지) 선점한 자체 SMTP({@link MailService}) 발송한다.
*
* <p><b>데모 안전장치(EDM 재사용)</b>: {@link MailProperties#isEdmTestMode()}(기본 true) <b>dry-run 모드</b>
* 구독자에게 보내지 않고 화이트리스트({@code EDM_TEST_RECIPIENTS})로만 발송(비면 카운트만)한다. 대량 발송은
* env {@code kintex.mail.edm-test-mode=false} + {@code MAIL_ENABLED=true} 설정 시에만 열린다. 모드와 무관하게
* 처리 건은 notified 마킹하여 스케줄러 재처리(로그 폭주) 막는다.
*
* <p><b>PII(§0-3)</b>: 원문 이메일은 발송 경로 한정 로그·응답에는 건수만 남긴다({@link MailService} 마스킹 이력).
* <p>{@code @Scheduled} 전역 활성화는 기존 {@code WebhookSchedulingConfig(@EnableScheduling)} 담당한다.
*/
@Component
public class TicketOpenNotifier {
private static final Logger log = LoggerFactory.getLogger(TicketOpenNotifier.class);
/** 1회 스윕 처리 상한(폭주 방지). */
private static final int SWEEP_LIMIT = 300;
private final EventSubscriptionMapper mapper;
private final MailService mailService;
private final MailProperties mailProperties;
public TicketOpenNotifier(EventSubscriptionMapper mapper,
MailService mailService,
MailProperties mailProperties) {
this.mapper = mapper;
this.mailService = mailService;
this.mailProperties = mailProperties;
}
/** 오픈 감지 폴러 — 기동 45초 후 최초 실행, 이후 60초 고정 지연(이전 스윕 종료 기준). */
@Scheduled(initialDelay = 45_000, fixedDelay = 60_000)
public void sweep() {
try {
runOnce();
} catch (Exception e) {
// 스택트레이스·민감정보 미노출. 다음 주기에 재시도.
log.warn("F-C1 티켓 오픈 알림 스윕 실패: {}", e.getClass().getSimpleName());
}
}
/**
* 오픈된 행사의 미통지 구독을 발송 처리한다.
* @return 이번 스윕에서 통지 처리(마킹) 구독 건수.
*/
public int runOnce() {
List<Map<String, Object>> pending;
try {
pending = mapper.findPendingTicketOpen(SWEEP_LIMIT);
} catch (RuntimeException e) {
log.warn("ticket-open sweep skipped: {}", e.getClass().getSimpleName());
return 0;
}
if (pending == null || pending.isEmpty()) {
return 0;
}
boolean testMode = mailProperties.isEdmTestMode();
List<String> whitelist = sanitize(mailProperties.getEdmTestRecipients());
String baseUrl = trimTrailingSlash(mailProperties.getWebBaseUrl());
int processed = 0;
for (Map<String, Object> row : pending) {
String id = str(row.get("id"));
if (id == null) {
continue;
}
// 조건부 선점 이미 통지된 (경쟁) 0 스킵(이중 발송 방지).
if (mapper.markNotified(id) == 0) {
continue;
}
processed++;
String eventName = str(row.get("eventName"));
String token = str(row.get("unsubToken"));
String bookUrl = baseUrl + "/public/events/" + urlEnc(str(row.get("eventId"))) + "/tickets";
String unsubUrl = baseUrl + "/api/public/subscriptions/unsubscribe?token=" + urlEnc(token);
String subject = "[KINTEX] 관심 행사 티켓 예매 시작 — " + (eventName == null ? "행사" : eventName);
String html = MailTemplates.ticketOpen(eventName, bookUrl, unsubUrl);
if (testMode) {
// dry-run 구독자 미발송. 화이트리스트로만 발송(비면 카운트만).
for (String to : whitelist) {
mailService.send("TICKET_OPEN_TEST", id, to, "[TEST] " + subject, html);
}
} else {
// 운영 실발송 구독자 원문 이메일(발송 경로 한정, 로그 미노출).
mailService.send("TICKET_OPEN", id, str(row.get("email")), subject, html);
}
}
if (processed > 0) {
log.info("F-C1 티켓 오픈 알림 처리 {}건{}", processed, testMode ? " (테스트/dry-run)" : "");
}
return processed;
}
// helpers
private static List<String> sanitize(List<String> in) {
List<String> out = new ArrayList<>();
if (in != null) {
for (String s : in) {
if (s != null && !s.isBlank()) {
out.add(s.trim());
}
}
}
return out;
}
private static String trimTrailingSlash(String s) {
if (s == null || s.isBlank()) {
return "";
}
String t = s.trim();
return t.endsWith("/") ? t.substring(0, t.length() - 1) : t;
}
private static String urlEnc(String s) {
return URLEncoder.encode(s == null ? "" : s, StandardCharsets.UTF_8);
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
}

View File

@ -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) {
}
}

View File

@ -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).
*
* <p>인증: 기존 JWT 필터 (로그인 사용자면 접근 가능, RBAC 세분화 불요 {@code SecurityConfig}
* {@code anyRequest().authenticated()}). 프론트 그리드가 현재 표시 중인 컬럼·행을 그대로 보내면
* 서버가 Jasper 동적 컬럼 리포트를 렌더해 {@code application/pdf} 스트림한다.
*/
@RestController
@RequestMapping("/api/reports")
public class GridReportController {
private final GridReportService service;
public GridReportController(GridReportService service) {
this.service = service;
}
/**
* POST /api/reports/grid-pdf 그리드 PDF. 응답은 바이너리 PDF(봉투 아님).
* 파일명은 title 기반(한글은 RFC 5987 filename*=UTF-8'' 인코딩). 민감정보·스택트레이스 미포함.
*/
@PostMapping(value = "/grid-pdf", produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> gridPdf(@Valid @RequestBody GridPdfRequest request) {
byte[] pdf = service.render(request);
String fileName = safeFileBase(request.title()) + "_" + LocalDate.now().toString().replace("-", "") + ".pdf";
ContentDisposition cd = ContentDisposition.attachment()
.filename(fileName, StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, cd.toString())
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
/** 파일명 base(한글 허용, 경로/구분자·제어문자만 제거). 빈 값은 "grid". */
private static String safeFileBase(String s) {
if (s == null || s.isBlank()) {
return "grid";
}
String cleaned = s.replaceAll("[\\\\/:*?\"<>|\\r\\n\\t]", "").trim();
return cleaned.isEmpty() ? "grid" : cleaned;
}
}

View File

@ -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 패턴 이식.
*
* <p><b>동적 컬럼 처리:</b> 정적 jrxml({@code reports/grid_report.jrxml}) 로고·타이틀·페이지번호
* "골격" 담고, 요청 {@link GridPdfRequest#columns()} 개수에 맞춰 {@link JasperDesign} API
* columnHeader(머리글 {@link JRDesignStaticText})·detail( {@link JRDesignTextField}) 요소를 런타임에
* 주입한 컴파일한다. Jasper 정적 컬럼 한계를 DynamicJasper 없이 가로 가중/균등 분할 자체 구현으로 회피한다.
*
* <p><b>필드 매핑:</b> 컬럼 키가 임의 문자열(공백·중복 가능)이어도 안전하도록 내부 필드명을 {@code c0..cN}
* 재부여하고, 데이터소스 맵도 동일 키로 재구성한다(표현식 파손·중복 필드 예외 방지).
*
* <p><b>한글:</b> {@code fonts.xml} 폰트확장(NanumGothic, Identity-H, 임베딩)으로 PDF 한글 깨짐 방지.
* 스타일 {@code fontName="NanumGothic"} 머리글/ 스타일에 지정한다.
*/
@Service
public class GridReportService {
private static final Logger log = LoggerFactory.getLogger(GridReportService.class);
private static final String TEMPLATE = "reports/grid_report.jrxml";
private static final String LOGO = "reports/kintex_ci.jpg";
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
/** A4 가로 컨텐츠 폭(pageWidth 842 - 좌우 여백 30*2). jrxml 과 일치. */
private static final int CONTENT_WIDTH = 782;
private static final int HEADER_HEIGHT = 22;
private static final int ROW_HEIGHT = 16;
private static final Color BRAND = new Color(0x1F, 0x29, 0xFC);
private static final Color GRID_LINE = new Color(0xD0, 0xD5, 0xE0);
private static final Color HEADER_BG = new Color(0x1F, 0x29, 0xFC);
/** 방어적 상한(요청 DTO 검증과 이중). */
private static final int MAX_COLUMNS = 30;
private static final int MAX_ROWS = 5000;
/**
* 그리드 요청 PDF 바이트.
*
* @param req 컬럼· 정의. null· 컬럼·초과 크기는 {@code VALIDATION}.
* @return PDF byte[]
*/
public byte[] render(GridPdfRequest req) {
if (req == null || req.columns() == null || req.columns().isEmpty()) {
throw new ApiException(ErrorCode.VALIDATION, "출력할 컬럼이 없습니다.");
}
List<GridPdfRequest.Column> columns = req.columns();
if (columns.size() > MAX_COLUMNS) {
throw new ApiException(ErrorCode.VALIDATION, "컬럼은 최대 " + MAX_COLUMNS + "개까지 지원합니다.");
}
List<Map<String, Object>> rows = req.rows() == null ? List.of() : req.rows();
if (rows.size() > MAX_ROWS) {
throw new ApiException(ErrorCode.VALIDATION, "행은 최대 " + MAX_ROWS + "행까지 지원합니다.");
}
try {
JasperDesign design = loadDesign();
int[] widths = computeWidths(columns);
injectColumns(design, columns, widths);
JasperReport report = JasperCompileManager.compileReport(design);
Map<String, Object> params = new HashMap<>();
params.put("P_TITLE", nz(req.title()));
params.put("P_SUBTITLE", nz(req.subtitle()));
params.put("P_CREATED_AT", LocalDateTime.now().format(TS));
params.put("P_TOTAL_ROWS", String.format("%,d", rows.size()));
InputStream logo = logoStream();
if (logo != null) {
params.put("P_LOGO", logo);
}
JasperPrint print = JasperFillManager.fillReport(report, params, dataSource(columns, rows));
return JasperExportManager.exportReportToPdf(print);
} catch (ApiException e) {
throw e;
} catch (Exception e) {
// 스택트레이스 미노출 요약 메시지만(보안 불변).
log.error("grid pdf render failed: {}", e.getMessage());
throw new ApiException(ErrorCode.INTERNAL, "PDF 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.");
}
}
// 디자인 조립
private JasperDesign loadDesign() throws Exception {
// 요청마다 디자인(컬럼이 달라 캐시 불가) 골격 jrxml 매번 로드해 주입한다.
try (InputStream is = new ClassPathResource(TEMPLATE).getInputStream()) {
return JRXmlLoader.load(is);
}
}
/**
* 컬럼 산출 width(가중치) 있으면 비례, 없으면 균등. 반올림 누적오차는 마지막 컬럼에서 보정.
*/
private int[] computeWidths(List<GridPdfRequest.Column> columns) {
int n = columns.size();
double totalWeight = 0;
double[] weights = new double[n];
for (int i = 0; i < n; i++) {
Integer w = columns.get(i).width();
double weight = (w != null && w > 0) ? w : 1.0;
weights[i] = weight;
totalWeight += weight;
}
int[] widths = new int[n];
int used = 0;
for (int i = 0; i < n; i++) {
if (i == n - 1) {
widths[i] = CONTENT_WIDTH - used; // 마지막 컬럼이 잔여 흡수
} else {
widths[i] = (int) Math.round(CONTENT_WIDTH * (weights[i] / totalWeight));
used += widths[i];
}
}
// 최소 보호(음수/과소 방지) 극단 가중치 대비.
for (int i = 0; i < n; i++) {
if (widths[i] < 20) {
widths[i] = 20;
}
}
return widths;
}
private void injectColumns(JasperDesign design, List<GridPdfRequest.Column> columns, int[] widths)
throws Exception {
JRDesignStyle headerStyle = headerStyle();
JRDesignStyle cellStyle = cellStyle();
design.addStyle(headerStyle);
design.addStyle(cellStyle);
JRDesignBand columnHeader = (JRDesignBand) design.getColumnHeader();
columnHeader.setHeight(HEADER_HEIGHT);
JRDesignBand detail = (JRDesignBand) design.getDetailSection().getBands()[0];
detail.setHeight(ROW_HEIGHT);
int x = 0;
for (int i = 0; i < columns.size(); i++) {
GridPdfRequest.Column col = columns.get(i);
int w = widths[i];
String fieldName = "c" + i;
// 필드 선언(내부 안전 ). 클래스 String.
JRDesignField field = new JRDesignField();
field.setName(fieldName);
field.setValueClass(String.class);
design.addField(field);
// 머리글(정적 텍스트)
JRDesignStaticText header = new JRDesignStaticText();
header.setX(x);
header.setY(0);
header.setWidth(w);
header.setHeight(HEADER_HEIGHT);
header.setText(nz(col.label()));
header.setStyle(headerStyle);
header.setPositionType(PositionTypeEnum.FIX_RELATIVE_TO_TOP);
columnHeader.addElement(header);
// (텍스트필드 $F{cN})
JRDesignTextField cell = new JRDesignTextField();
cell.setX(x);
cell.setY(0);
cell.setWidth(w);
cell.setHeight(ROW_HEIGHT);
cell.setStyle(cellStyle);
cell.setBlankWhenNull(true);
cell.setStretchWithOverflow(false);
cell.setHorizontalTextAlign(align(col.align()));
cell.setPositionType(PositionTypeEnum.FIX_RELATIVE_TO_TOP);
JRDesignExpression expr = new JRDesignExpression();
expr.setText("$F{" + fieldName + "}");
cell.setExpression(expr);
detail.addElement(cell);
x += w;
}
}
// 스타일
private JRDesignStyle headerStyle() {
JRDesignStyle s = new JRDesignStyle();
s.setName("gridHeaderStyle");
s.setFontName("NanumGothic");
s.setFontSize(Float.valueOf(8.5f));
s.setBold(true);
s.setForecolor(Color.WHITE);
s.setBackcolor(HEADER_BG);
s.setMode(ModeEnum.OPAQUE);
s.setHorizontalTextAlign(HorizontalTextAlignEnum.CENTER);
s.setVerticalTextAlign(VerticalTextAlignEnum.MIDDLE);
applyBox(s);
return s;
}
private JRDesignStyle cellStyle() {
JRDesignStyle s = new JRDesignStyle();
s.setName("gridCellStyle");
s.setFontName("NanumGothic");
s.setFontSize(Float.valueOf(8f));
s.setForecolor(new Color(0x1A, 0x1A, 0x1A));
s.setVerticalTextAlign(VerticalTextAlignEnum.MIDDLE);
applyBox(s);
return s;
}
private void applyBox(JRDesignStyle s) {
s.getLineBox().getPen().setLineWidth(0.5f);
s.getLineBox().getPen().setLineColor(GRID_LINE);
s.getLineBox().setLeftPadding(4);
s.getLineBox().setRightPadding(4);
}
private HorizontalTextAlignEnum align(String a) {
if (a == null) {
return HorizontalTextAlignEnum.LEFT;
}
return switch (a.trim().toLowerCase()) {
case "center" -> HorizontalTextAlignEnum.CENTER;
case "right" -> HorizontalTextAlignEnum.RIGHT;
default -> HorizontalTextAlignEnum.LEFT;
};
}
// 데이터소스
/** 각 행을 내부 필드명(c0..cN)→문자열 값 맵으로 재구성. 누락 키는 빈 문자열. */
private JRMapCollectionDataSource dataSource(List<GridPdfRequest.Column> columns,
List<Map<String, Object>> rows) {
List<Map<String, ?>> data = new ArrayList<>(rows.size());
for (Map<String, Object> row : rows) {
Map<String, Object> mapped = new HashMap<>();
for (int i = 0; i < columns.size(); i++) {
Object v = row == null ? null : row.get(columns.get(i).key());
mapped.put("c" + i, stringify(v));
}
data.add(mapped);
}
return new JRMapCollectionDataSource(data);
}
// 헬퍼
private InputStream logoStream() {
try {
ClassPathResource r = new ClassPathResource(LOGO);
return r.exists() ? r.getInputStream() : null;
} catch (Exception e) {
return null; // 로고 없으면 이미지 미표시(onErrorType="Blank")
}
}
private static String stringify(Object v) {
return v == null ? "" : String.valueOf(v);
}
private static String nz(String s) {
return s == null ? "" : s;
}
}

View File

@ -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 출력 요청 프론트 그리드가 <b>현재 표시 중인 컬럼·행을 그대로</b> 전송한다.
* 서버는 컬럼 정의에 맞춰 JasperReports 동적 컬럼 리포트를 렌더해 application/pdf 응답한다.
*
* <p>방어적 제한: 컬럼 최대 30개, 최대 5,000개(초과 {@code VALIDATION} 400).
* PII·자격증명 컬럼이 오더라도 서버는 받은 그대로 렌더하므로( 그리드 공용) 별도 마스킹은 하지 않는다.
*
* @param title 리포트 상단 제목(필수). : "부스 배치 현황"
* @param subtitle 제목 아래 부제(선택). : "2026 국제전시 · K1 홀"
* @param columns 표시 컬럼 정의(순서=화면 표시 순서). 최소 1개.
* @param rows 목록. 행은 {@code key } . 값은 문자열로 변환되어 렌더된다.
*/
public record GridPdfRequest(
@NotBlank(message = "title 은 필수입니다.")
@Size(max = 200, message = "title 은 200자 이하여야 합니다.")
String title,
@Size(max = 300, message = "subtitle 은 300자 이하여야 합니다.")
String subtitle,
@NotEmpty(message = "columns 는 최소 1개 이상이어야 합니다.")
@Size(max = 30, message = "columns 는 최대 30개까지 지원합니다.")
@Valid
List<Column> columns,
@NotNull(message = "rows 는 필수입니다(빈 배열 허용).")
@Size(max = 5000, message = "rows 는 최대 5,000행까지 지원합니다.")
List<Map<String, Object>> rows
) {
/**
* 컬럼 정의.
*
* @param key 맵에서 값을 조회할 (필수). : "boothNo"
* @param label 헤더에 표시할 라벨(필수). : "부스 번호"
* @param width 상대 가중치(선택, &gt;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
) {
}
}

View File

@ -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.
* <ul>
* <li>공개 조회 {@code GET /api/public/events/{eventId}/sessions} 로그인 상태 포함.</li>
* <li>신청/취소/내신청 {@code /api/visitor/sessions/**} 인증 필요.</li>
* </ul>
*/
@RestController
public class SessionController {
private final SessionService service;
public SessionController(SessionService service) {
this.service = service;
}
/** 공개 세션 목록 — Bearer 동봉 시 myStatus 포함(permitAll 경로라 익명 허용). */
@GetMapping("/api/public/events/{eventId}/sessions")
public ApiResponse<List<SessionDto>> publicSessions(@PathVariable String eventId,
@AuthenticationPrincipal KintexPrincipal principal) {
String userId = principal == null ? null : principal.userId();
return ApiResponse.ok(service.list(eventId, userId));
}
/** 인증 세션 목록(내 상태 포함). */
@GetMapping("/api/visitor/events/{eventId}/sessions")
public ApiResponse<List<SessionDto>> mySessions(@PathVariable String eventId,
@AuthenticationPrincipal KintexPrincipal principal) {
return ApiResponse.ok(service.list(eventId, VisitorAuth.requireUserId(principal)));
}
/** 내 RSVP 목록. */
@GetMapping("/api/visitor/sessions/mine")
public ApiResponse<List<MyRsvpDto>> mine(@AuthenticationPrincipal KintexPrincipal principal) {
return ApiResponse.ok(service.mine(VisitorAuth.requireUserId(principal)));
}
/** 세션 신청(확정/대기). */
@PostMapping("/api/visitor/sessions/{sessionId}/rsvp")
public ApiResponse<RsvpResultDto> rsvp(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String sessionId) {
return ApiResponse.ok(service.rsvp(VisitorAuth.requireUserId(principal), sessionId));
}
/** 세션 신청 취소(대기 승격 트리거). */
@DeleteMapping("/api/visitor/sessions/{sessionId}/rsvp")
public ApiResponse<RsvpResultDto> cancel(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String sessionId) {
return ApiResponse.ok(service.cancel(VisitorAuth.requireUserId(principal), sessionId));
}
}

View File

@ -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) {
}
}

View File

@ -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' 고정.
* <p> camelCase 별칭 쌍따옴표. 확정 좌석 증가는 조건부 UPDATE(오버부킹 원자 차단).
*/
@Mapper
public interface SessionMapper {
/**
* 행사 세션 목록 + RSVP 상태(userId null 이면 myStatus null). remaining/full 서버 계산.
*/
@Select("""
SELECT s.id,
s.title,
s.speaker,
s.room,
s.track,
to_char(s.starts_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "startsAt",
to_char(s.ends_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "endsAt",
s.capacity,
s.seat_taken AS "seatTaken",
CASE WHEN s.capacity = 0 THEN NULL ELSE (s.capacity - s.seat_taken) END AS "remaining",
CASE WHEN s.capacity > 0 AND s.seat_taken >= s.capacity THEN true ELSE false END AS "full",
s.status,
r.status AS "myStatus"
FROM event_session s
LEFT JOIN session_rsvp r
ON r.session_id = s.id AND r.user_id = #{userId} AND r.status <> 'CANCELLED'
WHERE s.tenant_id = 'KINTEX' AND s.event_id = #{eventId}
ORDER BY s.sort_order, s.starts_at
""")
List<Map<String, Object>> listSessions(@Param("eventId") String eventId, @Param("userId") String userId);
/** 단일 세션(RSVP 검증). 없으면 null. */
@Select("""
SELECT id, event_id AS "eventId", title, capacity, seat_taken AS "seatTaken", status
FROM event_session
WHERE tenant_id = 'KINTEX' AND id = #{sessionId}
""")
Map<String, Object> findSession(@Param("sessionId") String sessionId);
/** 조건부 좌석 확보 — 정원 무제한(0) 또는 여유 있을 때만 +1. 갱신행수 1이면 확보. */
@Update("""
UPDATE event_session
SET seat_taken = seat_taken + 1
WHERE tenant_id = 'KINTEX' AND id = #{sessionId} AND status = 'open'
AND (capacity = 0 OR seat_taken < capacity)
""")
int takeSeat(@Param("sessionId") String sessionId);
/** 좌석 반환(취소/승격 재계산). */
@Update("""
UPDATE event_session
SET seat_taken = GREATEST(0, seat_taken - 1)
WHERE tenant_id = 'KINTEX' AND id = #{sessionId}
""")
void releaseSeat(@Param("sessionId") String sessionId);
/** 내 RSVP(세션·사용자). 없으면 null. */
@Select("""
SELECT id, session_id AS "sessionId", status, waitlist_pos AS "waitlistPos"
FROM session_rsvp
WHERE tenant_id = 'KINTEX' AND session_id = #{sessionId} AND user_id = #{userId}
""")
Map<String, Object> findRsvp(@Param("sessionId") String sessionId, @Param("userId") String userId);
@Insert("""
INSERT INTO session_rsvp (tenant_id, id, session_id, event_id, user_id, status, waitlist_pos)
VALUES ('KINTEX', #{id}, #{sessionId}, #{eventId}, #{userId}, #{status}, #{waitlistPos})
""")
void insertRsvp(Map<String, Object> row);
@Update("""
UPDATE session_rsvp
SET status = #{status}, waitlist_pos = #{waitlistPos}, updated_at = now()
WHERE tenant_id = 'KINTEX' AND id = #{id}
""")
void updateRsvp(@Param("id") String id, @Param("status") String status,
@Param("waitlistPos") Integer waitlistPos);
/** 다음 대기자(최소 순번). 없으면 null. */
@Select("""
SELECT id, user_id AS "userId"
FROM session_rsvp
WHERE tenant_id = 'KINTEX' AND session_id = #{sessionId} AND status = 'WAITLIST'
ORDER BY waitlist_pos ASC, created_at ASC
LIMIT 1
""")
Map<String, Object> findNextWaitlist(@Param("sessionId") String sessionId);
/** 현재 최대 대기 순번(신규 대기 순번 부여). 없으면 0. */
@Select("""
SELECT COALESCE(MAX(waitlist_pos), 0)
FROM session_rsvp
WHERE tenant_id = 'KINTEX' AND session_id = #{sessionId} AND status = 'WAITLIST'
""")
int maxWaitlistPos(@Param("sessionId") String sessionId);
/** 내 RSVP 목록(진행 중 우선). */
@Select("""
SELECT r.id,
r.session_id AS "sessionId",
r.status,
r.waitlist_pos AS "waitlistPos",
s.title,
s.room,
s.track,
to_char(s.starts_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "startsAt"
FROM session_rsvp r
JOIN event_session s ON s.id = r.session_id
WHERE r.tenant_id = 'KINTEX' AND r.user_id = #{userId} AND r.status <> 'CANCELLED'
ORDER BY s.starts_at
""")
List<Map<String, Object>> myRsvps(@Param("userId") String userId);
}

View File

@ -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 대기열·취소표 서비스.
*
* <p>신청: 정원 여유 CONFIRMED(조건부 좌석 확보로 오버부킹 차단), 소진 WAITLIST(순번 부여).
* <p>취소: 확정 취소 좌석 반환 대기 1순위를 자동 승격(CONFIRMED)하고 인앱 알림 발행(취소표 알림).
*/
@Service
public class SessionService {
private final SessionMapper mapper;
private final VisitorNotificationService notifications;
public SessionService(SessionMapper mapper, VisitorNotificationService notifications) {
this.mapper = mapper;
this.notifications = notifications;
}
/** 행사 세션 목록(userId null 이면 내 상태 없음 — 공개 조회). */
@Transactional(readOnly = true)
public List<SessionDto> list(String eventId, String userId) {
if (eventId == null || eventId.isBlank()) {
throw new ApiException(ErrorCode.VALIDATION, "행사 식별자가 필요합니다.");
}
// 익명(공개) 조회는 sentinel('') null JDBC 바인딩 회피(PG 파라미터 타입 추론 안전).
String uid = userId == null ? "" : userId;
return mapper.listSessions(eventId, uid).stream().map(SessionService::toSession).toList();
}
/** 내 RSVP 목록. */
@Transactional(readOnly = true)
public List<MyRsvpDto> mine(String userId) {
return mapper.myRsvps(userId).stream()
.map(m -> new MyRsvpDto(str(m.get("sessionId")), str(m.get("title")), str(m.get("room")),
str(m.get("track")), str(m.get("startsAt")), str(m.get("status")),
intOrNull(m.get("waitlistPos"))))
.toList();
}
/** 세션 신청 — 정원 여유 시 확정, 소진 시 대기 등록. */
@Transactional
public RsvpResultDto rsvp(String userId, String sessionId) {
Map<String, Object> s = mapper.findSession(sessionId);
if (s == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "세션을 찾을 수 없습니다.");
}
if (!"open".equals(str(s.get("status")))) {
throw new ApiException(ErrorCode.CONFLICT, "신청이 마감된 세션입니다.");
}
Map<String, Object> existing = mapper.findRsvp(sessionId, userId);
if (existing != null && !"CANCELLED".equals(str(existing.get("status")))) {
throw new ApiException(ErrorCode.CONFLICT, "이미 신청한 세션입니다.");
}
String eventId = str(s.get("eventId"));
boolean confirmed = mapper.takeSeat(sessionId) == 1;
String status = confirmed ? "CONFIRMED" : "WAITLIST";
Integer waitPos = confirmed ? null : mapper.maxWaitlistPos(sessionId) + 1;
if (existing != null) {
// 취소 재신청 기존 재사용.
mapper.updateRsvp(str(existing.get("id")), status, waitPos);
} else {
Map<String, Object> row = new java.util.HashMap<>();
row.put("id", "rsvp-" + UUID.randomUUID());
row.put("sessionId", sessionId);
row.put("eventId", eventId);
row.put("userId", userId);
row.put("status", status);
row.put("waitlistPos", waitPos);
mapper.insertRsvp(row);
}
String msg = confirmed ? "좌석이 확정되었습니다." : "정원이 마감되어 대기열에 등록되었습니다(취소 발생 시 알림).";
return new RsvpResultDto(sessionId, status, waitPos, msg);
}
/** 세션 신청 취소 — 확정이었으면 좌석 반환 + 대기 1순위 자동 승격 + 알림. */
@Transactional
public RsvpResultDto cancel(String userId, String sessionId) {
Map<String, Object> mine = mapper.findRsvp(sessionId, userId);
if (mine == null || "CANCELLED".equals(str(mine.get("status")))) {
throw new ApiException(ErrorCode.NOT_FOUND, "취소할 신청 내역이 없습니다.");
}
boolean wasConfirmed = "CONFIRMED".equals(str(mine.get("status")));
mapper.updateRsvp(str(mine.get("id")), "CANCELLED", null);
if (wasConfirmed) {
// 좌석 반환 대기 1순위 승격 시도.
mapper.releaseSeat(sessionId);
Map<String, Object> next = mapper.findNextWaitlist(sessionId);
if (next != null && mapper.takeSeat(sessionId) == 1) {
mapper.updateRsvp(str(next.get("id")), "CONFIRMED", null);
String promotedUser = str(next.get("userId"));
Map<String, Object> sess = mapper.findSession(sessionId);
String title = sess == null ? "세션" : str(sess.get("title"));
notifications.publish(promotedUser, "WAITLIST_PROMOTED",
"대기하신 세션 좌석이 확정되었습니다",
"" + title + " 세션에 취소표가 발생하여 좌석이 확정되었습니다.",
"SESSION", sessionId);
}
}
return new RsvpResultDto(sessionId, "CANCELLED", null, "신청이 취소되었습니다.");
}
// helpers
private static SessionDto toSession(Map<String, Object> m) {
return new SessionDto(
str(m.get("id")), str(m.get("title")), str(m.get("speaker")), str(m.get("room")),
str(m.get("track")), str(m.get("startsAt")), str(m.get("endsAt")),
toInt(m.get("capacity"), 0), toInt(m.get("seatTaken"), 0),
intOrNull(m.get("remaining")), bool(m.get("full")), str(m.get("status")),
str(m.get("myStatus")));
}
private static String str(Object o) {
return o == null ? null : o.toString();
}
private static boolean bool(Object o) {
return o instanceof Boolean b && b;
}
private static int toInt(Object o, int def) {
return o instanceof Number n ? n.intValue() : def;
}
private static Integer intOrNull(Object o) {
return o instanceof Number n ? n.intValue() : null;
}
}

View File

@ -6,8 +6,10 @@ import com.zioinfo.kintex.common.audit.Audited;
import com.zioinfo.kintex.system.SystemAccessGuard; import com.zioinfo.kintex.system.SystemAccessGuard;
import com.zioinfo.kintex.tenant.dto.TenantAdminDto; import com.zioinfo.kintex.tenant.dto.TenantAdminDto;
import com.zioinfo.kintex.tenant.dto.TenantCreateRequest; import com.zioinfo.kintex.tenant.dto.TenantCreateRequest;
import com.zioinfo.kintex.tenant.dto.TenantSwitchResponse;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -47,4 +49,16 @@ public class TenantAdminController {
guard.requireAdmin(principal); guard.requireAdmin(principal);
return ApiResponse.ok(service.create(req)); return ApiResponse.ok(service.create(req));
} }
/**
* POST /{code}/switch 플랫폼 관리자 테넌트 스위치. 대상 테넌트 tid 박힌 신규 토큰을 반환한다(§1A-3-2).
* 프론트 스위처가 응답 토큰으로 세션을 교체하면 이후 요청이 대상 테넌트로 스코프된다.
*/
@Audited(action = "TENANT_SWITCH", targetType = "tenant")
@PostMapping("/{code}/switch")
public ApiResponse<TenantSwitchResponse> switchTenant(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String code) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.switchTenant(principal, code));
}
} }

View File

@ -1,9 +1,12 @@
package com.zioinfo.kintex.tenant; package com.zioinfo.kintex.tenant;
import com.zioinfo.kintex.auth.JwtService;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.error.ApiException; import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode; import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.tenant.dto.TenantAdminDto; import com.zioinfo.kintex.tenant.dto.TenantAdminDto;
import com.zioinfo.kintex.tenant.dto.TenantCreateRequest; import com.zioinfo.kintex.tenant.dto.TenantCreateRequest;
import com.zioinfo.kintex.tenant.dto.TenantSwitchResponse;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -25,10 +28,12 @@ public class TenantAdminService {
private final TenantMapper mapper; private final TenantMapper mapper;
private final TenantResolver resolver; private final TenantResolver resolver;
private final JwtService jwtService;
public TenantAdminService(TenantMapper mapper, TenantResolver resolver) { public TenantAdminService(TenantMapper mapper, TenantResolver resolver, JwtService jwtService) {
this.mapper = mapper; this.mapper = mapper;
this.resolver = resolver; 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); return new TenantAdminDto(code, req.name().trim(), domain, status, 0L, 0L, null);
} }
/**
* 플랫폼 관리자 테넌트 스위치 대상 테넌트 {@code tid} 박힌 신규 토큰 재발급(§1A-3-2).
* 접근 게이트(requireAdmin) 컨트롤러가 이미 수행. 대상 테넌트가 미존재면 400.
* 사용자 신원·역할(eventRoles·hallManager·roleCode) 그대로 유지하고 테넌트만 교체한다(선택 클레임 재발급).
*/
public TenantSwitchResponse switchTenant(KintexPrincipal principal, String code) {
String target = TenantContext.normalize(code);
if (target == null || !resolver.exists(target)) {
throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 테넌트입니다.");
}
String token = jwtService.issue(principal.userId(), principal.displayName(),
principal.eventRoles(), principal.hallManager(), principal.roleCode(), target);
return new TenantSwitchResponse(token, jwtService.ttlSeconds(), target);
}
private static String str(Object o) { private static String str(Object o) {
return o == null ? null : String.valueOf(o); return o == null ? null : String.valueOf(o);
} }

View File

@ -1,6 +1,8 @@
package com.zioinfo.kintex.tenant; package com.zioinfo.kintex.tenant;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.kintex.auth.JwtService;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse; import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.ApiResponse.ApiError; import com.zioinfo.kintex.common.ApiResponse.ApiError;
import com.zioinfo.kintex.common.error.ErrorCode; import com.zioinfo.kintex.common.error.ErrorCode;
@ -15,40 +17,76 @@ import java.io.IOException;
/** /**
* 테넌트 컨텍스트 해소 필터 (PLANNING §1A-3 · §8-2 필터 계층). * 테넌트 컨텍스트 해소 필터 (PLANNING §1A-3 · §8-2 필터 계층).
* <p>해소 우선순위: {@code X-Tenant-Id} 헤더 Host 도메인 매칭 기준 테넌트('kintex'). *
* 명시적 {@code X-Tenant-Id} 존재하지 않는 테넌트를 가리키면 <b>400</b>(오접속·위조 차단). * <p><b>2단계(JWT tid 권위)</b> 인증 요청은 JWT {@code tid} 클레임을 신뢰 원천으로 삼는다(§1A-3-2):
* (헤더 없음·도메인 미매칭) 기준 테넌트로 폴백해 기존 동작을 완전히 보존한다(회귀 0, 1단계). * <ul>
* <p>2단계에서 fail-closed(미해소 거부)·JWT 테넌트 클레임 검증으로 강화 설계 노트 참조. * <li><b>인증 요청</b>(유효 Bearer): 테넌트 = JWT {@code tid}. 헤더 {@code X-Tenant-Id} tid와
* <b>다른</b> 테넌트를 명시하면 플랫폼 관리자(전역 ADMIN 또는 홀매니저) 대상 테넌트로 스위치 허용
* (감사 대상 크로스-테넌트), 주체는 <b>403</b>(오접속/하이재킹 차단, fail-closed). 스위치 대상이
* 미존재 테넌트면 <b>400</b>.</li>
* <li><b>미인증 요청</b>(공개 경로): 1단계 해소 유지 {@code X-Tenant-Id}(미존재 400)
* Host 도메인 매칭 기준 테넌트('KINTEX').</li>
* </ul>
*
* <p>회귀 0: 기존 인증 클라이언트는 tid=KINTEX 토큰 + 헤더 미전송 테넌트 KINTEX(동일). tid 부재 레거시
* 토큰도 {@link JwtService#verify} KINTEX로 정규화 동일. 공개 경로는 1단계와 동일 해소.
*
* <p>필터 순서 비의존: JWT를 자체 파싱하므로 {@code JwtAuthenticationFilter} 와의 등록 순서에 무관하게 동작한다.
*/ */
public class TenantContextFilter extends OncePerRequestFilter { public class TenantContextFilter extends OncePerRequestFilter {
private static final String HEADER = "X-Tenant-Id"; private static final String HEADER = "X-Tenant-Id";
private static final String BEARER = "Bearer ";
private final TenantResolver resolver; private final TenantResolver resolver;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final JwtService jwtService;
public TenantContextFilter(TenantResolver resolver, ObjectMapper objectMapper) { public TenantContextFilter(TenantResolver resolver, ObjectMapper objectMapper, JwtService jwtService) {
this.resolver = resolver; this.resolver = resolver;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.jwtService = jwtService;
} }
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException { FilterChain chain) throws ServletException, IOException {
String headerTenant = trimToNull(request.getHeader(HEADER));
KintexPrincipal principal = tryPrincipal(request);
String tenantId; String tenantId;
String headerTenant = request.getHeader(HEADER); if (principal != null) {
if (headerTenant != null && !headerTenant.isBlank()) { // 인증 요청 JWT tid 권위(§1A-3-2).
String candidate = headerTenant.trim(); String jwtTenant = principal.tenantId(); // 이미 KINTEX 기본 정규화됨
if (!resolver.exists(candidate)) { 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
}
} else {
// 미인증(공개 경로) 1단계 해소 유지: 헤더 도메인 기준 테넌트.
if (headerTenant != null) {
if (!resolver.exists(headerTenant)) {
writeBadTenant(response); writeBadTenant(response);
return; return;
} }
tenantId = candidate; tenantId = headerTenant;
} else { } else {
// 도메인 매칭(§1A-3) 폴백 기준 테넌트.
String byDomain = resolver.resolveByDomain(request.getHeader("Host")); String byDomain = resolver.resolveByDomain(request.getHeader("Host"));
tenantId = (byDomain != null) ? byDomain : TenantContext.DEFAULT_TENANT; tenantId = (byDomain != null) ? byDomain : TenantContext.DEFAULT_TENANT;
} }
}
try { try {
TenantContext.set(tenantId); TenantContext.set(tenantId);
@ -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 { 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.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString( response.getWriter().write(objectMapper.writeValueAsString(
ApiResponse.fail(new ApiError(ErrorCode.VALIDATION.name(), ApiResponse.fail(new ApiError(code.name(), message))));
"알 수 없는 테넌트입니다."))));
} }
} }

View File

@ -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
) {
}

View File

@ -0,0 +1,90 @@
package com.zioinfo.kintex.ticket.cancel;
import java.util.List;
/**
* F-B2 티켓 취소·환불 산정(순수 함수 단위테스트 대상). 서버 권위(클라이언트 금액 미신뢰).
*
* <p>산식:
* <ul>
* <li>ticketRefund = round(ticketAmount × 구간환불율%)</li>
* <li>adminFee = round(ticketRefund × 취소관리수수료%) 환불액에서 공제</li>
* <li>bookingFeeRefund = (D- 예매수수료 환불 최소일) ? bookingFee : 0 (예매수수료 별도 규정)</li>
* <li>refund = ticketRefund adminFee + bookingFeeRefund</li>
* <li>forfeit = (ticketAmount + bookingFee) refund</li>
* </ul>
* D-일이 음수(개막 당일·이후) 매칭 구간 0% 티켓 환불 0.
*/
public final class TicketCancelCalc {
private TicketCancelCalc() {
}
/** 다구간 — minDaysBefore 이상 남았을 때 refundRatePercent 적용. */
public record Bracket(int minDaysBefore, double refundRatePercent, String label) {
}
/** 산정 결과(스냅샷). */
public record Result(
int daysBefore,
long ticketAmount,
long bookingFee,
double refundRatePercent,
String bracketLabel,
long ticketRefund,
long adminFee,
long bookingFeeRefund,
long refundAmount,
long forfeitAmount) {
}
/** D-일에 해당하는 구간(조건 만족 중 minDaysBefore 최대). 없으면 null. */
public static Bracket bracketFor(int daysBefore, List<Bracket> brackets) {
if (brackets == null || brackets.isEmpty()) {
return null;
}
Bracket best = null;
for (Bracket b : brackets) {
if (daysBefore >= b.minDaysBefore() && (best == null || b.minDaysBefore() > best.minDaysBefore())) {
best = b;
}
}
return best;
}
/**
* 환불 산정.
*
* @param daysBefore 개막까지 남은 일수(음수=개막 당일·이후, null 미허용 미확정은 호출부가 0 처리)
* @param ticketAmount 환불 대상 티켓액(예매수수료 제외)
* @param bookingFee 예매수수료 총액
* @param brackets 구간 목록
* @param bookingFeeRefundMinDays 예매수수료 환불 최소 D-
* @param cancelAdminFeePercent 취소 관리수수료(%)
*/
public static Result compute(int daysBefore, long ticketAmount, long bookingFee,
List<Bracket> brackets, int bookingFeeRefundMinDays,
double cancelAdminFeePercent) {
long tAmt = Math.max(0, ticketAmount);
long bFee = Math.max(0, bookingFee);
Bracket br = bracketFor(daysBefore, brackets);
double rate = br == null ? 0.0 : clampPct(br.refundRatePercent());
String label = br == null ? "환불 불가 구간" : br.label();
long ticketRefund = Math.round(tAmt * rate / 100.0);
long adminFee = Math.round(ticketRefund * clampPct(cancelAdminFeePercent) / 100.0);
long bookingFeeRefund = daysBefore >= bookingFeeRefundMinDays ? bFee : 0L;
long refund = Math.max(0L, ticketRefund - adminFee + bookingFeeRefund);
long forfeit = Math.max(0L, (tAmt + bFee) - refund);
return new Result(daysBefore, tAmt, bFee, rate, label,
ticketRefund, adminFee, bookingFeeRefund, refund, forfeit);
}
private static double clampPct(double p) {
if (p < 0) {
return 0;
}
return Math.min(p, 100.0);
}
}

View File

@ -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.
* <p>본인확인(예매번호+연락처). 예상 환불액은 서버 권위 산출.
*/
@RestController
@RequestMapping("/api/public/tickets")
public class TicketCancelController {
private final TicketCancelService service;
public TicketCancelController(TicketCancelService service) {
this.service = service;
}
/** POST /refund-quote — 취소 전 예상 환불 산정(조회만). */
@PostMapping("/refund-quote")
public ApiResponse<RefundQuoteDto> quote(@Valid @RequestBody CancelRequest req) {
return ApiResponse.ok(service.quote(req.orderNo(), req.contact()));
}
/** POST /cancel — 실제 취소 처리(상태 전이·재고 원복·환불 스냅샷). */
@PostMapping("/cancel")
public ApiResponse<RefundQuoteDto> cancel(@Valid @RequestBody CancelRequest req) {
return ApiResponse.ok(service.cancel(req.orderNo(), req.contact()));
}
}

View File

@ -0,0 +1,48 @@
package com.zioinfo.kintex.ticket.cancel;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
/**
* F-B2 티켓 취소·환불 DTO. 경로 공개(/api/public/**) 티켓은 비로그인.
* <p>보안(§0-3): 구매자 PII 원문 미노출. 금액은 전부 서버 산출값.
*/
public final class TicketCancelDtos {
private TicketCancelDtos() {
}
/** 예상 환불 조회/취소 요청 — 예매번호 + 연락처(본인확인). */
public record CancelRequest(
@NotBlank String orderNo,
@NotBlank String contact) {
}
/** 규정 구간(표시용). */
public record BracketDto(int minDaysBefore, double refundRatePercent, String label) {
}
/**
* 예상 환불 산정 결과(서버 권위). preview=true 미확정(조회만), false 실제 취소 반영.
*/
public record RefundQuoteDto(
String orderNo,
String status, // PAID/CANCELLED
String productName,
int qty,
long ticketAmount, // 환불 대상 티켓액
long bookingFee, // 예매수수료 총액
int daysBefore,
double refundRatePercent,
String bracketLabel,
long ticketRefund,
long adminFee,
long bookingFeeRefund,
long refundAmount, // 최종 환불 예상액
long forfeitAmount, // 공제(위약)
String policyVersion,
boolean cancellable, // 취소 가능 여부(PAID·미취소)
List<BracketDto> brackets) {
}
}

View File

@ -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' 고정.
* <p> camelCase 별칭 쌍따옴표({@code AS "x"}). 날짜 비교는 파라미터 캐스팅.
* <p>보안(§0-3): 조회에서 구매자 원문 컬럼은 SELECT 하지 않는다(마스킹/해시만).
*/
@Mapper
public interface TicketCancelMapper {
/** 주문번호+연락처 해시 대조 → 취소 산정에 필요한 주문 원시값. 없으면 null. */
@Select("""
SELECT id AS "id",
order_no AS "orderNo",
event_id AS "eventId",
product_id AS "productId",
product_name AS "productName",
qty,
unit_price AS "unitPrice",
total_amount AS "totalAmount",
status
FROM ticket_order
WHERE order_no = #{orderNo} AND buyer_contact_hash = #{contactHash}
LIMIT 1
""")
Map<String, Object> findOrderForCancel(@Param("orderNo") String orderNo,
@Param("contactHash") String contactHash);
/** 행사 개막일(YYYY-MM-DD). 없으면 null. */
@Select("SELECT to_char(start_date, 'YYYY-MM-DD') FROM event WHERE id = #{eventId}")
String findEventStartDate(@Param("eventId") String eventId);
/** 행사 특화 취소 구간(없으면 빈 목록 → 기본 구간 사용). */
@Select("""
SELECT min_days_before AS "minDaysBefore",
refund_rate_percent AS "refundRatePercent",
label
FROM ticket_cancel_bracket
WHERE tenant_id = 'KINTEX' AND event_id = #{eventId}
ORDER BY min_days_before DESC
""")
List<Map<String, Object>> findEventBrackets(@Param("eventId") String eventId);
/** 기본 취소 구간(event_id IS NULL). */
@Select("""
SELECT min_days_before AS "minDaysBefore",
refund_rate_percent AS "refundRatePercent",
label
FROM ticket_cancel_bracket
WHERE tenant_id = 'KINTEX' AND event_id IS NULL
ORDER BY min_days_before DESC
""")
List<Map<String, Object>> findDefaultBrackets();
/** 수수료 정책(행사 우선, 없으면 __default__). 없으면 null. */
@Select("""
SELECT booking_fee_per_ticket AS "bookingFeePerTicket",
booking_fee_refund_min_days AS "bookingFeeRefundMinDays",
cancel_admin_fee_percent AS "cancelAdminFeePercent",
version
FROM ticket_fee_policy
WHERE tenant_id = 'KINTEX' AND event_id = #{eventId}
""")
Map<String, Object> findFeePolicy(@Param("eventId") String eventId);
/** 이미 취소된 주문 여부. */
@Select("SELECT count(*) FROM ticket_cancellation WHERE tenant_id='KINTEX' AND order_id = #{orderId}")
int countCancellation(@Param("orderId") String orderId);
/** 주문 상태 → CANCELLED(조건부: PAID 만). 갱신행수 1이면 성공. */
@Update("""
UPDATE ticket_order
SET status = 'CANCELLED', cancelled_at = now()
WHERE tenant_id = 'KINTEX' AND id = #{orderId} AND status = 'PAID'
""")
int cancelOrder(@Param("orderId") String orderId);
/** 재고 원복(취소). */
@Update("""
UPDATE ticket_product
SET sold_qty = GREATEST(0, sold_qty - #{qty})
WHERE tenant_id = 'KINTEX' AND id = #{productId}
""")
void restock(@Param("productId") String productId, @Param("qty") int qty);
/** 취소 산정 스냅샷 저장(감사). */
@Insert("""
INSERT INTO ticket_cancellation
(tenant_id, id, order_id, order_no, days_before, ticket_amount, booking_fee,
refund_rate, admin_fee, refund_amount, forfeit_amount, policy_version)
VALUES
('KINTEX', #{id}, #{orderId}, #{orderNo}, #{daysBefore}, #{ticketAmount}, #{bookingFee},
#{refundRate}, #{adminFee}, #{refundAmount}, #{forfeitAmount}, #{policyVersion})
""")
void insertCancellation(Map<String, Object> row);
}

View File

@ -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 티켓 취소·환불 서비스 다구간 취소수수료 + 예매수수료 별도 규정, 서버 권위 산출.
*
* <p>예상 환불({@link #quote}) 조회만(멱등), 실제 취소({@link #cancel}) 상태 전이·재고 원복·스냅샷 저장.
* 금액 산식은 {@link TicketCancelCalc}(순수 함수) 위임 클라이언트 금액을 신뢰하지 않는다.
*/
@Service
public class TicketCancelService {
private static final String DEFAULT_POLICY = "__default__";
private final TicketCancelMapper mapper;
public TicketCancelService(TicketCancelMapper mapper) {
this.mapper = mapper;
}
/** 예상 환불 조회(취소 전) — 본인확인(예매번호+연락처). */
@Transactional(readOnly = true)
public RefundQuoteDto quote(String orderNo, String contact) {
return build(loadOrder(orderNo, contact), false);
}
/** 실제 취소 처리 — 상태 CANCELLED 전이·재고 원복·환불 스냅샷 저장. */
@Transactional
public RefundQuoteDto cancel(String orderNo, String contact) {
Map<String, Object> order = loadOrder(orderNo, contact);
String orderId = str(order.get("id"));
if (!"PAID".equals(str(order.get("status")))) {
throw new ApiException(ErrorCode.CONFLICT, "취소할 수 없는 예매입니다(이미 취소되었거나 사용됨).");
}
if (mapper.countCancellation(orderId) > 0) {
throw new ApiException(ErrorCode.CONFLICT, "이미 취소 처리된 예매입니다.");
}
RefundQuoteDto q = build(order, true);
int updated = mapper.cancelOrder(orderId);
if (updated != 1) {
throw new ApiException(ErrorCode.CONFLICT, "취소 처리에 실패했습니다. 다시 시도해 주세요.");
}
mapper.restock(str(order.get("productId")), toInt(order.get("qty"), 0));
Map<String, Object> row = new java.util.HashMap<>();
row.put("id", "tc-" + UUID.randomUUID());
row.put("orderId", orderId);
row.put("orderNo", q.orderNo());
row.put("daysBefore", q.daysBefore());
row.put("ticketAmount", q.ticketAmount());
row.put("bookingFee", q.bookingFee());
row.put("refundRate", q.refundRatePercent());
row.put("adminFee", q.adminFee());
row.put("refundAmount", q.refundAmount());
row.put("forfeitAmount", q.forfeitAmount());
row.put("policyVersion", q.policyVersion());
mapper.insertCancellation(row);
// 상태를 CANCELLED 갱신해 응답.
return new RefundQuoteDto(q.orderNo(), "CANCELLED", q.productName(), q.qty(),
q.ticketAmount(), q.bookingFee(), q.daysBefore(), q.refundRatePercent(), q.bracketLabel(),
q.ticketRefund(), q.adminFee(), q.bookingFeeRefund(), q.refundAmount(), q.forfeitAmount(),
q.policyVersion(), false, q.brackets());
}
// 내부 산정
private RefundQuoteDto build(Map<String, Object> order, boolean applying) {
String eventId = str(order.get("eventId"));
int qty = toInt(order.get("qty"), 0);
long ticketAmount = toLong(order.get("totalAmount"), 0);
// 정책 로드(행사 우선 기본).
Map<String, Object> policy = mapper.findFeePolicy(eventId);
if (policy == null) {
policy = mapper.findFeePolicy(DEFAULT_POLICY);
}
long bookingFeePerTicket = policy == null ? 0 : toLong(policy.get("bookingFeePerTicket"), 0);
int bookingFeeRefundMinDays = policy == null ? 7 : toInt(policy.get("bookingFeeRefundMinDays"), 7);
double adminPct = policy == null ? 0 : toDouble(policy.get("cancelAdminFeePercent"), 0);
String version = policy == null ? "cancel-v1.0" : str(policy.getOrDefault("version", "cancel-v1.0"));
long bookingFee = bookingFeePerTicket * qty;
// 구간 로드(행사 특화 기본).
List<Map<String, Object>> raw = mapper.findEventBrackets(eventId);
if (raw == null || raw.isEmpty()) {
raw = mapper.findDefaultBrackets();
}
List<TicketCancelCalc.Bracket> brackets = new ArrayList<>();
List<BracketDto> bracketDtos = new ArrayList<>();
for (Map<String, Object> b : raw) {
int min = toInt(b.get("minDaysBefore"), 0);
double rate = toDouble(b.get("refundRatePercent"), 0);
String label = str(b.get("label"));
brackets.add(new TicketCancelCalc.Bracket(min, rate, label));
bracketDtos.add(new BracketDto(min, rate, label));
}
int daysBefore = daysBefore(eventId);
TicketCancelCalc.Result r = TicketCancelCalc.compute(
daysBefore, ticketAmount, bookingFee, brackets, bookingFeeRefundMinDays, adminPct);
boolean cancellable = "PAID".equals(str(order.get("status")));
return new RefundQuoteDto(
str(order.get("orderNo")), str(order.get("status")), str(order.get("productName")), qty,
r.ticketAmount(), r.bookingFee(), r.daysBefore(), r.refundRatePercent(), r.bracketLabel(),
r.ticketRefund(), r.adminFee(), r.bookingFeeRefund(), r.refundAmount(), r.forfeitAmount(),
version, cancellable, bracketDtos);
}
private Map<String, Object> loadOrder(String orderNo, String contact) {
if (blank(orderNo) || blank(contact)) {
throw new ApiException(ErrorCode.VALIDATION, "예매번호와 연락처를 입력해 주세요.");
}
Map<String, Object> order = mapper.findOrderForCancel(orderNo.trim(), contactHash(contact));
if (order == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "일치하는 예매 내역이 없습니다.");
}
return order;
}
/** 개막까지 남은 일수 — 미확정이면 0(환불 불가 안전값). */
private int daysBefore(String eventId) {
String start = mapper.findEventStartDate(eventId);
if (start == null || start.isBlank()) {
return 0;
}
try {
LocalDate s = LocalDate.parse(start, DateTimeFormatter.ISO_LOCAL_DATE);
return (int) (s.toEpochDay() - LocalDate.now().toEpochDay());
} catch (Exception e) {
return 0;
}
}
// helpers
private static boolean blank(String s) {
return s == null || s.isBlank();
}
private static String contactHash(String contact) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(contact.replaceAll("[^0-9]", "").getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new ApiException(ErrorCode.INTERNAL);
}
}
private static String str(Object o) {
return o == null ? null : o.toString();
}
private static int toInt(Object o, int def) {
return o instanceof Number n ? n.intValue() : def;
}
private static long toLong(Object o, long def) {
return o instanceof Number n ? n.longValue() : def;
}
private static double toDouble(Object o, double def) {
return o instanceof Number n ? n.doubleValue() : def;
}
}

View File

@ -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.
* <p>보안: 응답에 secret·deviceId 원문 미포함. 검증은 실패도 200(valid=false) 정보 노출 최소화.
*/
@RestController
@RequestMapping("/api/public/tickets/smart")
public class SmartTicketController {
private final SmartTicketService service;
public SmartTicketController(SmartTicketService service) {
this.service = service;
}
/** POST /activate — 예매번호+연락처 본인확인 후 티켓을 기기에 바인딩. */
@PostMapping("/activate")
public ApiResponse<ActivateResultDto> activate(@Valid @RequestBody ActivateRequest req) {
return ApiResponse.ok(service.activate(req.orderNo(), req.contact(), req.ticketCode(), req.deviceId()));
}
/** GET /token?ticketCode=&deviceId= — 바인딩 기기의 현재 회전 토큰(QR 페이로드). */
@GetMapping("/token")
public ApiResponse<RotatingTokenDto> token(@RequestParam String ticketCode,
@RequestParam String deviceId) {
return ApiResponse.ok(service.currentToken(ticketCode, deviceId));
}
/** POST /verify — 게이트 스캐너 QR 검증(회전 토큰 유효성). */
@PostMapping("/verify")
public ApiResponse<VerifyResultDto> verify(@Valid @RequestBody VerifyRequest req) {
return ApiResponse.ok(service.verify(req.qrPayload()));
}
}

View File

@ -0,0 +1,42 @@
package com.zioinfo.kintex.ticket.smart;
import jakarta.validation.constraints.NotBlank;
/**
* F-B1 스마트티켓(회전 QR) DTO. 경로 공개(/api/public/**) 티켓은 비로그인 예매.
* <p>보안(§0-3): deviceId 원문 미보관(sha256 해시만) · secret 미노출. 응답 티켓코드 PII 없음.
*/
public final class SmartTicketDtos {
private SmartTicketDtos() {
}
/** 스마트티켓 활성화(디바이스 바인딩) — 예매번호+연락처 본인확인 후 특정 기기에 바인딩. */
public record ActivateRequest(
@NotBlank String orderNo,
@NotBlank String contact,
@NotBlank String ticketCode,
@NotBlank String deviceId) {
}
/** 활성화 결과. */
public record ActivateResultDto(String ticketCode, boolean activated, int windowSeconds) {
}
/** 회전 토큰 발급 결과 — QR 페이로드 = {@code ticketCode|token}. */
public record RotatingTokenDto(
String ticketCode,
String token,
String qrPayload,
int windowSeconds,
int remainingSeconds) {
}
/** 게이트 검증 요청 — 스캐너가 읽은 QR 페이로드(ticketCode|token). */
public record VerifyRequest(@NotBlank String qrPayload) {
}
/** 검증 결과. */
public record VerifyResultDto(boolean valid, String ticketCode, String reason) {
}
}

View File

@ -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/조회.
* <p> camelCase 별칭은 반드시 쌍따옴표({@code AS "x"})(PG lower-fold 방지).
* <p>보안(§0-3): device_hash·secret 서버 전용. 조회 응답에 secret 노출하지 않는다(서비스가 통제).
*/
@Mapper
public interface SmartTicketMapper {
/** 주문번호 + 연락처 해시 대조 → 주문 id(둘 다 일치해야). 없으면 null. */
@Select("""
SELECT id AS "orderId"
FROM ticket_order
WHERE order_no = #{orderNo} AND buyer_contact_hash = #{contactHash}
LIMIT 1
""")
Map<String, Object> findOrderIdForLookup(@Param("orderNo") String orderNo,
@Param("contactHash") String contactHash);
/** 발권 티켓이 해당 주문 소속인지(대조). 1이면 소속. */
@Select("""
SELECT count(*)
FROM ticket_issue
WHERE tenant_id = 'KINTEX' AND order_id = #{orderId} AND ticket_code = #{ticketCode}
""")
int ticketBelongsToOrder(@Param("orderId") String orderId, @Param("ticketCode") String ticketCode);
/** 바인딩 upsert — 디바이스 재바인딩(기기 변경) 허용, secret 재발급으로 이전 기기 토큰 무효화. */
@Update("""
INSERT INTO ticket_smart_binding (tenant_id, ticket_code, device_hash, secret)
VALUES ('KINTEX', #{ticketCode}, #{deviceHash}, #{secret})
ON CONFLICT (tenant_id, ticket_code)
DO UPDATE SET device_hash = EXCLUDED.device_hash,
secret = EXCLUDED.secret,
bound_at = now()
""")
void upsertBinding(@Param("ticketCode") String ticketCode,
@Param("deviceHash") String deviceHash,
@Param("secret") String secret);
/** 바인딩 조회(서비스 전용 — secret 포함). 없으면 null. */
@Select("""
SELECT ticket_code AS "ticketCode",
device_hash AS "deviceHash",
secret AS "secret"
FROM ticket_smart_binding
WHERE tenant_id = 'KINTEX' AND ticket_code = #{ticketCode}
""")
Map<String, Object> findBinding(@Param("ticketCode") String ticketCode);
/** 검증 시 사용 마킹(감사). */
@Update("UPDATE ticket_smart_binding SET last_used_at = now() WHERE tenant_id='KINTEX' AND ticket_code = #{ticketCode}")
void markUsed(@Param("ticketCode") String ticketCode);
}

View File

@ -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 토큰 발급/검증.
*
* <p>흐름: activate(예매번호+연락처 본인확인 티켓을 기기에 바인딩, 서버 시크릿 발급)
* token(바인딩 기기에서만 현재 회전 토큰 발급) verify(게이트 스캐너가 QR 검증).
* 캡처 스크린샷은 토큰 회전으로 30초(+직전 1창) 무효 양도/재사용 차단.
*
* <p>보안(§0-3): deviceId 원문 미보관(sha256), secret 응답·로그에 절대 노출하지 않는다.
*/
@Service
public class SmartTicketService {
/** 검증 유예 창 수(현재+직전 N) — 전송/시계 오차 흡수. */
private static final int VERIFY_WINDOW_BACK = 1;
private final SmartTicketMapper mapper;
private final SecureRandom random = new SecureRandom();
public SmartTicketService(SmartTicketMapper mapper) {
this.mapper = mapper;
}
/** 스마트티켓 활성화 — 본인확인(예매번호+연락처) 후 발권 티켓을 기기에 바인딩. */
@Transactional
public ActivateResultDto activate(String orderNo, String contact, String ticketCode, String deviceId) {
if (blank(orderNo) || blank(contact) || blank(ticketCode) || blank(deviceId)) {
throw new ApiException(ErrorCode.VALIDATION, "활성화에 필요한 정보가 부족합니다.");
}
Map<String, Object> order = mapper.findOrderIdForLookup(orderNo.trim(), contactHash(contact));
if (order == null) {
// 존재 여부 노출 금지 불일치도 NOT_FOUND 통일.
throw new ApiException(ErrorCode.NOT_FOUND, "예매 정보를 확인할 수 없습니다. 예매번호와 연락처를 확인해 주세요.");
}
String orderId = String.valueOf(order.get("orderId"));
if (mapper.ticketBelongsToOrder(orderId, ticketCode.trim()) != 1) {
throw new ApiException(ErrorCode.NOT_FOUND, "해당 예매의 티켓이 아닙니다.");
}
String secret = newSecret();
mapper.upsertBinding(ticketCode.trim(), deviceHash(deviceId), secret);
return new ActivateResultDto(ticketCode.trim(), true, Totp.STEP_SECONDS);
}
/** 회전 토큰 발급 — 바인딩된 기기에서만. QR 페이로드 = ticketCode|token. */
@Transactional(readOnly = true)
public RotatingTokenDto currentToken(String ticketCode, String deviceId) {
if (blank(ticketCode) || blank(deviceId)) {
throw new ApiException(ErrorCode.VALIDATION, "티켓/기기 정보가 필요합니다.");
}
Map<String, Object> b = mapper.findBinding(ticketCode.trim());
if (b == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "활성화되지 않은 티켓입니다. 스마트티켓을 먼저 활성화해 주세요.");
}
if (!deviceHash(deviceId).equals(String.valueOf(b.get("deviceHash")))) {
// 다른 기기 양도/캡처 방지. 존재 노출 없이 권한 거절.
throw new ApiException(ErrorCode.FORBIDDEN, "이 기기에서 발급할 수 없는 티켓입니다.");
}
long now = nowSeconds();
String token = Totp.code(String.valueOf(b.get("secret")), now);
return new RotatingTokenDto(
ticketCode.trim(), token, ticketCode.trim() + "|" + token,
Totp.STEP_SECONDS, Totp.remainingSeconds(now));
}
/** 게이트 검증 — QR 페이로드(ticketCode|token)의 회전 토큰 유효성. */
@Transactional
public VerifyResultDto verify(String qrPayload) {
if (blank(qrPayload) || !qrPayload.contains("|")) {
return new VerifyResultDto(false, null, "형식이 올바르지 않은 코드입니다.");
}
int sep = qrPayload.lastIndexOf('|');
String ticketCode = qrPayload.substring(0, sep).trim();
String token = qrPayload.substring(sep + 1).trim();
Map<String, Object> b = mapper.findBinding(ticketCode);
if (b == null) {
return new VerifyResultDto(false, null, "미등록 또는 만료된 티켓입니다.");
}
boolean ok = Totp.verify(String.valueOf(b.get("secret")), token, nowSeconds(), VERIFY_WINDOW_BACK);
if (!ok) {
return new VerifyResultDto(false, ticketCode, "만료된 코드입니다. 앱에서 최신 QR을 다시 제시해 주세요.");
}
mapper.markUsed(ticketCode);
return new VerifyResultDto(true, ticketCode, "정상 입장");
}
// helpers
protected long nowSeconds() {
return System.currentTimeMillis() / 1000L;
}
private String newSecret() {
byte[] b = new byte[24];
random.nextBytes(b);
return HexFormat.of().formatHex(b);
}
private static String deviceHash(String deviceId) {
return sha256Hex(deviceId.trim());
}
private static String contactHash(String contact) {
return sha256Hex(contact.replaceAll("[^0-9]", ""));
}
private static boolean blank(String s) {
return s == null || s.isBlank();
}
private static String sha256Hex(String s) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(s.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new ApiException(ErrorCode.INTERNAL);
}
}
}

View File

@ -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 계열, 순수 함수 단위테스트 대상).
*
* <p>토큰 = base32(HMAC-SHA256(secret, floor(epochSeconds / STEP))[0..5]) 8자.
* 30초 스텝마다 토큰이 회전하므로 캡처(스크린샷) 토큰은 해당 (+직전 1창 유예)에서만 유효하다
* 양도/재사용 차단. 시크릿은 서버 전용(응답·로그 미노출)이며, 클라이언트에는 현재 토큰만 내려간다.
*/
public final class Totp {
/** 회전 주기(초). */
public static final int STEP_SECONDS = 30;
private static final char[] B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray();
private Totp() {
}
/** 지정 시각(epoch 초)의 회전 토큰(8자). */
public static String code(String secret, long epochSeconds) {
long counter = Math.floorDiv(epochSeconds, STEP_SECONDS);
byte[] mac = hmac(secret, counter);
StringBuilder sb = new StringBuilder(8);
for (int i = 0; i < 5; i++) {
sb.append(B32[(mac[i] & 0xFF) % 32]);
}
// 5바이트 8자 채움(추가 3자는 후속 바이트 저비트).
for (int i = 0; i < 3; i++) {
sb.append(B32[(mac[5 + i] & 0x1F)]);
}
return sb.toString();
}
/** 남은 초(현재 창 만료까지). */
public static int remainingSeconds(long epochSeconds) {
return STEP_SECONDS - (int) Math.floorMod(epochSeconds, STEP_SECONDS);
}
/**
* 토큰 유효성 현재 창과 직전 {@code windowBack} 창을 허용(시계 오차·전송 지연 유예).
* 대소문자·공백 무시.
*/
public static boolean verify(String secret, String token, long epochSeconds, int windowBack) {
if (secret == null || token == null) {
return false;
}
String t = token.trim().toUpperCase(java.util.Locale.ROOT);
for (int w = 0; w <= Math.max(0, windowBack); w++) {
if (code(secret, epochSeconds - (long) w * STEP_SECONDS).equals(t)) {
return true;
}
}
return false;
}
private static byte[] hmac(String secret, long counter) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return mac.doFinal(ByteBuffer.allocate(Long.BYTES).putLong(counter).array());
} catch (Exception e) {
throw new IllegalStateException("HMAC 산출 실패", e);
}
}
}

View File

@ -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.
* <p>관람객 개인화 기능(멤버십·컨택트·결제수단·피드·알림·RSVP) 본인(userId) 소유분만 접근한다.
*/
public final class VisitorAuth {
private VisitorAuth() {
}
public static String requireUserId(KintexPrincipal principal) {
if (principal == null || principal.userId() == null || principal.userId().isBlank()) {
throw new ApiException(ErrorCode.UNAUTHORIZED, "로그인이 필요합니다.");
}
return principal.userId();
}
}

View File

@ -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<MyCardDto> myCard(@AuthenticationPrincipal KintexPrincipal principal) {
return ApiResponse.ok(service.myCard(VisitorAuth.requireUserId(principal), principal.displayName()));
}
/** PUT /me/card — 내 명함 저장/수정. */
@PutMapping("/me/card")
public ApiResponse<MyCardDto> saveCard(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody SaveCardRequest req) {
return ApiResponse.ok(service.saveCard(VisitorAuth.requireUserId(principal), req));
}
/** POST /exchange — 상대 QR 스캔 교환(상호 저장). */
@PostMapping("/exchange")
public ApiResponse<ContactDto> exchange(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody ExchangeRequest req) {
return ApiResponse.ok(service.exchange(
VisitorAuth.requireUserId(principal), principal.displayName(), req.qrPayload()));
}
/** GET / — 내 컨택트 지갑. */
@GetMapping
public ApiResponse<List<ContactDto>> list(@AuthenticationPrincipal KintexPrincipal principal) {
return ApiResponse.ok(service.list(VisitorAuth.requireUserId(principal)));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@AuthenticationPrincipal KintexPrincipal principal, @PathVariable String id) {
service.delete(VisitorAuth.requireUserId(principal), id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,49 @@
package com.zioinfo.kintex.visitor.contact;
import jakarta.validation.constraints.NotBlank;
/**
* F-C4 관람객 명함/컨택트 지갑 DTO. 인증(본인 지갑만).
* <p>보안(§0-3): 연락처·이메일은 마스킹만 노출.
*/
public final class ContactDtos {
private ContactDtos() {
}
/** 내 명함 저장 요청 — 연락처/이메일은 서버가 마스킹 후 저장. */
public record SaveCardRequest(
@NotBlank String displayName,
String company,
String title,
String email,
String phone) {
}
/** 내 명함 카드(QR 공유 대상). qrPayload = CONTACT|shareToken. */
public record MyCardDto(
String displayName,
String company,
String title,
String emailMasked,
String phoneMasked,
String shareToken,
String qrPayload) {
}
/** QR 교환 요청 — 스캔한 상대 명함 QR 페이로드. */
public record ExchangeRequest(@NotBlank String qrPayload) {
}
/** 저장된 컨택트. */
public record ContactDto(
String id,
String displayName,
String company,
String title,
String emailMasked,
String phoneMasked,
String memo,
String savedAt) {
}
}

View File

@ -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) 지갑만.
* <p>보안(§0-3): 연락처·이메일은 마스킹 저장(원문 미보관).
*/
@Mapper
public interface ContactMapper {
/** 내 명함 카드. 없으면 null. */
@Select("""
SELECT user_id AS "userId",
display_name AS "displayName",
company,
title,
email_masked AS "emailMasked",
phone_masked AS "phoneMasked",
share_token AS "shareToken"
FROM visitor_contact_card
WHERE tenant_id = 'KINTEX' AND user_id = #{userId}
""")
Map<String, Object> findMyCard(@Param("userId") String userId);
/** 명함 카드 upsert. */
@Update("""
INSERT INTO visitor_contact_card
(tenant_id, user_id, display_name, company, title, email_masked, phone_masked, share_token, updated_at)
VALUES
('KINTEX', #{userId}, #{displayName}, #{company}, #{title}, #{emailMasked}, #{phoneMasked}, #{shareToken}, now())
ON CONFLICT (tenant_id, user_id)
DO UPDATE SET display_name = EXCLUDED.display_name,
company = EXCLUDED.company,
title = EXCLUDED.title,
email_masked = EXCLUDED.email_masked,
phone_masked = EXCLUDED.phone_masked,
updated_at = now()
""")
void upsertMyCard(Map<String, Object> row);
/** 공유 토큰으로 상대 명함 조회(교환 매개). 없으면 null. */
@Select("""
SELECT user_id AS "userId",
display_name AS "displayName",
company,
title,
email_masked AS "emailMasked",
phone_masked AS "phoneMasked"
FROM visitor_contact_card
WHERE tenant_id = 'KINTEX' AND share_token = #{shareToken}
""")
Map<String, Object> findCardByShareToken(@Param("shareToken") String shareToken);
/** 컨택트 저장(교환). 중복(owner,contact)은 무시. */
@Insert("""
INSERT INTO visitor_contact
(tenant_id, id, owner_user_id, contact_user_id, display_name, company, title, email_masked, phone_masked, memo)
VALUES
('KINTEX', #{id}, #{ownerUserId}, #{contactUserId}, #{displayName}, #{company}, #{title}, #{emailMasked}, #{phoneMasked}, #{memo})
ON CONFLICT (owner_user_id, contact_user_id) DO NOTHING
""")
void insertContact(Map<String, Object> row);
/** 내 지갑 목록(최신순). */
@Select("""
SELECT id,
contact_user_id AS "contactUserId",
display_name AS "displayName",
company,
title,
email_masked AS "emailMasked",
phone_masked AS "phoneMasked",
memo,
to_char(saved_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "savedAt"
FROM visitor_contact
WHERE tenant_id = 'KINTEX' AND owner_user_id = #{userId}
ORDER BY saved_at DESC
""")
List<Map<String, Object>> listContacts(@Param("userId") String userId);
@Delete("DELETE FROM visitor_contact WHERE tenant_id='KINTEX' AND owner_user_id = #{userId} AND id = #{id}")
int deleteContact(@Param("userId") String userId, @Param("id") String id);
}

View File

@ -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 명함 교환·컨택트 지갑 서비스.
*
* <p>흐름: 명함 등록/조회(QR 공유 토큰) 상대 QR 스캔으로 교환(상호 지갑 저장) 지갑 목록/삭제.
* 리드캡처(업체관람객) 별개인 관람객 상호 교환.
*/
@Service
public class ContactService {
private static final String QR_PREFIX = "CONTACT|";
private final ContactMapper mapper;
private final SecureRandom random = new SecureRandom();
public ContactService(ContactMapper mapper) {
this.mapper = mapper;
}
/** 내 명함 조회 — 없으면 표시이름으로 최소 카드 자동 생성(공유 토큰 발급). */
@Transactional
public MyCardDto myCard(String userId, String fallbackDisplayName) {
Map<String, Object> card = mapper.findMyCard(userId);
if (card == null) {
String token = newShareToken();
Map<String, Object> row = new java.util.HashMap<>();
row.put("userId", userId);
row.put("displayName", fallbackDisplayName == null || fallbackDisplayName.isBlank()
? "관람객" : fallbackDisplayName);
row.put("company", null);
row.put("title", null);
row.put("emailMasked", null);
row.put("phoneMasked", null);
row.put("shareToken", token);
mapper.upsertMyCard(row);
card = mapper.findMyCard(userId);
}
return toCard(card);
}
/** 내 명함 저장/수정 — 연락처·이메일 마스킹 후 저장(원문 미보관). */
@Transactional
public MyCardDto saveCard(String userId, SaveCardRequest req) {
Map<String, Object> existing = mapper.findMyCard(userId);
String token = existing != null ? str(existing.get("shareToken")) : newShareToken();
Map<String, Object> row = new java.util.HashMap<>();
row.put("userId", userId);
row.put("displayName", req.displayName().trim());
row.put("company", blankToNull(req.company()));
row.put("title", blankToNull(req.title()));
row.put("emailMasked", req.email() == null || req.email().isBlank() ? null : Masking.maskEmail(req.email()));
row.put("phoneMasked", req.phone() == null || req.phone().isBlank() ? null : Masking.maskPhone(req.phone()));
row.put("shareToken", token);
mapper.upsertMyCard(row);
return toCard(mapper.findMyCard(userId));
}
/** QR 교환 — 상대 명함을 내 지갑에 저장하고, 내 명함도 상대 지갑에 저장(상호). */
@Transactional
public ContactDto exchange(String userId, String myDisplayName, String qrPayload) {
String token = parseToken(qrPayload);
Map<String, Object> other = mapper.findCardByShareToken(token);
if (other == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "명함을 찾을 수 없습니다. QR을 다시 확인해 주세요.");
}
String otherUserId = str(other.get("userId"));
if (userId.equals(otherUserId)) {
throw new ApiException(ErrorCode.VALIDATION, "본인 명함은 교환할 수 없습니다.");
}
// 지갑에 상대 저장.
String contactId = "vc-" + UUID.randomUUID();
Map<String, Object> mineRow = contactRow(contactId, userId, otherUserId, other);
mapper.insertContact(mineRow);
// 상대 지갑에 ( 명함) 저장(상호). 명함이 없으면 자동 생성.
MyCardDto myCard = myCard(userId, myDisplayName);
Map<String, Object> myAsContact = new java.util.HashMap<>();
myAsContact.put("id", "vc-" + UUID.randomUUID());
myAsContact.put("ownerUserId", otherUserId);
myAsContact.put("contactUserId", userId);
myAsContact.put("displayName", myCard.displayName());
myAsContact.put("company", myCard.company());
myAsContact.put("title", myCard.title());
myAsContact.put("emailMasked", myCard.emailMasked());
myAsContact.put("phoneMasked", myCard.phoneMasked());
myAsContact.put("memo", null);
mapper.insertContact(myAsContact);
return new ContactDto(contactId, str(other.get("displayName")), str(other.get("company")),
str(other.get("title")), str(other.get("emailMasked")), str(other.get("phoneMasked")),
null, null);
}
@Transactional(readOnly = true)
public List<ContactDto> list(String userId) {
return mapper.listContacts(userId).stream()
.map(m -> new ContactDto(str(m.get("id")), str(m.get("displayName")), str(m.get("company")),
str(m.get("title")), str(m.get("emailMasked")), str(m.get("phoneMasked")),
str(m.get("memo")), str(m.get("savedAt"))))
.toList();
}
@Transactional
public void delete(String userId, String id) {
if (mapper.deleteContact(userId, id) != 1) {
throw new ApiException(ErrorCode.NOT_FOUND, "컨택트를 찾을 수 없습니다.");
}
}
// helpers
private static Map<String, Object> contactRow(String id, String owner, String contactUser, Map<String, Object> src) {
Map<String, Object> row = new java.util.HashMap<>();
row.put("id", id);
row.put("ownerUserId", owner);
row.put("contactUserId", contactUser);
row.put("displayName", src.get("displayName"));
row.put("company", src.get("company"));
row.put("title", src.get("title"));
row.put("emailMasked", src.get("emailMasked"));
row.put("phoneMasked", src.get("phoneMasked"));
row.put("memo", null);
return row;
}
private static MyCardDto toCard(Map<String, Object> m) {
String token = str(m.get("shareToken"));
return new MyCardDto(str(m.get("displayName")), str(m.get("company")), str(m.get("title")),
str(m.get("emailMasked")), str(m.get("phoneMasked")), token, QR_PREFIX + token);
}
private static String parseToken(String qrPayload) {
if (qrPayload == null || qrPayload.isBlank()) {
throw new ApiException(ErrorCode.VALIDATION, "QR 코드가 필요합니다.");
}
String p = qrPayload.trim();
return p.startsWith(QR_PREFIX) ? p.substring(QR_PREFIX.length()) : p;
}
private String newShareToken() {
return "SHARE-" + HexFormat.of().formatHex(randomBytes()).toUpperCase();
}
private byte[] randomBytes() {
byte[] b = new byte[8];
random.nextBytes(b);
return b;
}
private static String blankToNull(String s) {
return s == null || s.isBlank() ? null : s.trim();
}
private static String str(Object o) {
return o == null ? null : o.toString();
}
}

View File

@ -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<Void> record(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody RecordViewRequest req) {
service.record(VisitorAuth.requireUserId(principal), req);
return ApiResponse.ok(null);
}
/** GET /api/visitor/feed/recent?limit= — 최근 본 피드. */
@GetMapping("/recent")
public ApiResponse<List<FeedItemDto>> recent(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(defaultValue = "20") int limit) {
return ApiResponse.ok(service.recent(VisitorAuth.requireUserId(principal), limit));
}
}

View File

@ -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) {
}
}

View File

@ -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<String, Object> row);
/** 최근 본 목록. */
@Select("""
SELECT id,
item_type AS "itemType",
item_id AS "itemId",
item_title AS "itemTitle",
item_sub AS "itemSub",
to_char(viewed_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "viewedAt"
FROM visitor_view_history
WHERE tenant_id = 'KINTEX' AND user_id = #{userId}
ORDER BY viewed_at DESC
LIMIT #{limit}
""")
List<Map<String, Object>> listRecent(@Param("userId") String userId, @Param("limit") int limit);
/** 상한 초과분 정리(최근 200건 유지). */
@Delete("""
DELETE FROM visitor_view_history
WHERE tenant_id = 'KINTEX' AND user_id = #{userId}
AND id NOT IN (
SELECT id FROM visitor_view_history
WHERE tenant_id = 'KINTEX' AND user_id = #{userId}
ORDER BY viewed_at DESC
LIMIT 200
)
""")
void prune(@Param("userId") String userId);
}

View File

@ -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<String, Object> row = new java.util.HashMap<>();
row.put("id", "vvh-" + UUID.randomUUID());
row.put("userId", userId);
row.put("itemType", type);
row.put("itemId", req.itemId().trim());
row.put("itemTitle", req.itemTitle().trim());
row.put("itemSub", req.itemSub());
mapper.upsertView(row);
mapper.prune(userId);
}
/** 최근 본 피드. */
@Transactional(readOnly = true)
public List<FeedItemDto> recent(String userId, int limit) {
int lim = limit <= 0 || limit > 100 ? DEFAULT_LIMIT : limit;
return mapper.listRecent(userId, lim).stream()
.map(m -> new FeedItemDto(str(m.get("id")), str(m.get("itemType")), str(m.get("itemId")),
str(m.get("itemTitle")), str(m.get("itemSub")), str(m.get("viewedAt"))))
.toList();
}
private static String normalizeType(String t) {
String u = t == null ? "" : t.trim().toUpperCase(Locale.ROOT);
if (!"EVENT".equals(u) && !"BOOTH".equals(u)) {
throw new ApiException(ErrorCode.VALIDATION, "지원하지 않는 항목 유형입니다.");
}
return u;
}
private static String str(Object o) {
return o == null ? null : o.toString();
}
}

View File

@ -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<MembershipDto> me(@AuthenticationPrincipal KintexPrincipal principal) {
return ApiResponse.ok(service.myMembership(VisitorAuth.requireUserId(principal)));
}
/** POST /api/visitor/membership/buyer-verify?verified= — 바이어 인증 토글(데모). */
@PostMapping("/buyer-verify")
public ApiResponse<MembershipDto> buyerVerify(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(defaultValue = "true") boolean verified) {
return ApiResponse.ok(service.setBuyerVerified(VisitorAuth.requireUserId(principal), verified));
}
}

View File

@ -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<String> benefits) {
}
}

View File

@ -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<String, Object> 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<String> benefits(@Param("tier") String tier);
}

View File

@ -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<String, Object> 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<String, Object> 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;
}
}

View File

@ -0,0 +1,42 @@
package com.zioinfo.kintex.visitor.membership;
/**
* F-C6 관람객 멤버십 등급 산정(순수 함수 단위테스트 대상). 서버 권위.
*
* <p>규칙: 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);
}
}

View File

@ -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<Map<String, Object>>> list(@AuthenticationPrincipal KintexPrincipal principal) {
return ApiResponse.ok(service.list(VisitorAuth.requireUserId(principal)));
}
/** GET /api/visitor/notifications/unread-count — 미읽음 수. */
@GetMapping("/unread-count")
public ApiResponse<Map<String, Integer>> 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<Void> 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<Void> readAll(@AuthenticationPrincipal KintexPrincipal principal) {
service.markAllRead(VisitorAuth.requireUserId(principal));
return ApiResponse.ok(null);
}
}

View File

@ -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) 알림만.
* <p> 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<String, Object> 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<Map<String, Object>> 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);
}

View File

@ -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 관람객 인앱 알림 서비스 발행(다른 서비스가 호출) + 조회/읽음.
* <p>외부 채널 발송이 아닌 관람객 대면 인앱 알림함(대기 승격·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<String, Object> 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<Map<String, Object>> 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);
}
}

View File

@ -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<PayMethodDto>> list(@AuthenticationPrincipal KintexPrincipal principal) {
return ApiResponse.ok(service.list(VisitorAuth.requireUserId(principal)));
}
@PostMapping
public ApiResponse<PayMethodDto> register(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody RegisterRequest req) {
return ApiResponse.ok(service.register(VisitorAuth.requireUserId(principal), req));
}
@PostMapping("/{id}/default")
public ApiResponse<Void> setDefault(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
service.setDefault(VisitorAuth.requireUserId(principal), id);
return ApiResponse.ok(null);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> 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<QuickPayResultDto> quickPay(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody QuickPayRequest req) {
return ApiResponse.ok(service.quickPay(
VisitorAuth.requireUserId(principal), req.methodId(), req.amount(), req.memo()));
}
}

View File

@ -0,0 +1,52 @@
package com.zioinfo.kintex.visitor.paymethod;
import jakarta.validation.constraints.NotBlank;
/**
* F-B5 저장 결제수단 DTO. 인증(본인 소유분만).
* <p>보안(§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) {
}
}

View File

@ -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) 소유분만.
* <p>보안(§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<Map<String, Object>> 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<String, Object> 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<String, Object> 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);
}

View File

@ -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 토큰화 등록 + 저장 수단 빠른결제.
*
* <p>보안(§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<PayMethodDto> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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;
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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');

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
범용 그리드 PDF(JasperReports) 한글 폰트 확장(font extension).
jrxml/디자인에서 fontName="NanumGothic" 지정 시 아래 TTF 를 사용하고 PDF 에 임베딩(pdfEmbedded=true)한다.
Identity-H 인코딩 + 임베딩으로 서버에 시스템 폰트가 없어도 한글이 깨지지 않는다.
-->
<fontFamilies>
<fontFamily name="NanumGothic">
<normal>fonts/NanumGothic.ttf</normal>
<bold>fonts/NanumGothic-Bold.ttf</bold>
<italic>fonts/NanumGothic.ttf</italic>
<boldItalic>fonts/NanumGothic-Bold.ttf</boldItalic>
<pdfEncoding>Identity-H</pdfEncoding>
<pdfEmbedded>true</pdfEmbedded>
<exportFonts>
<export key="net.sf.jasperreports.html">'NanumGothic', sans-serif</export>
</exportFonts>
</fontFamily>
</fontFamilies>

View File

@ -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

View File

@ -14,7 +14,7 @@
--> -->
<mapper namespace="com.zioinfo.kintex.module.m2.mapper.BoothMapper"> <mapper namespace="com.zioinfo.kintex.module.m2.mapper.BoothMapper">
<!-- 배치안 헤더(version=null → 최신). --> <!-- 배치안 헤더(version=null → 최신 작업본: proposal 은 뒤로 밀어 draft/applied 우선). -->
<select id="findLayout" resultType="map"> <select id="findLayout" resultType="map">
SELECT id AS "layoutId", SELECT id AS "layoutId",
version, version,
@ -27,10 +27,31 @@
<if test="version != null"> <if test="version != null">
AND version = #{version} AND version = #{version}
</if> </if>
ORDER BY version DESC ORDER BY (status = 'proposal'), version DESC
LIMIT 1 LIMIT 1
</select> </select>
<!-- 최대 버전(proposal 포함) — 다음 버전 산정. -->
<select id="maxVersion" resultType="java.lang.Integer">
SELECT MAX(version)
FROM layout
WHERE event_id = #{eventId}
AND hall_id = #{hallId}
</select>
<!-- 배치안 버전 이력(부스 수 포함, 최신 순). -->
<select id="findLayoutVersions" resultType="map">
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>
<!-- 배치안 부스 목록(폴리곤은 GeoJSON). --> <!-- 배치안 부스 목록(폴리곤은 GeoJSON). -->
<select id="findBooths" resultType="map"> <select id="findBooths" resultType="map">
SELECT id AS "boothId", SELECT id AS "boothId",

View File

@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
범용 그리드 PDF 공용 템플릿 (전 그리드 공용 · 동적 컬럼).
- "골격(chrome)"만 정의: KINTEX 로고·타이틀·부제·출력일시·행수·페이지 번호(X / Y).
- columnHeader / detail 밴드는 비어 있으며, 요청 columns 개수에 맞춰 GridReportService 가
JasperDesign API 로 정적텍스트(머리글)·텍스트필드(셀)를 런타임에 주입한 뒤 compile 한다
(Jasper 정적 컬럼 한계 회피 — 가로 균등/가중 분할 자체 구현. DynamicJasper 미사용).
- 데이터소스: JRMapCollectionDataSource(행=Map<String,String>). 필드 key 도 런타임 주입.
- 한글: fontName="NanumGothic" (resources/fonts/fonts.xml 폰트확장 → Identity-H 임베딩).
- A4 가로(842x595) — 컬럼 수 많은 그리드 대응. 좌우 여백 30 → 컨텐츠 폭 782.
-->
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd"
name="grid_report" pageWidth="842" pageHeight="595" orientation="Landscape"
columnWidth="782" leftMargin="30" rightMargin="30" topMargin="30" bottomMargin="30"
whenNoDataType="AllSectionsNoDetail">
<parameter name="P_TITLE" class="java.lang.String"/>
<parameter name="P_SUBTITLE" class="java.lang.String"/>
<parameter name="P_CREATED_AT" class="java.lang.String"/>
<parameter name="P_TOTAL_ROWS" class="java.lang.String"/>
<!-- 로고 이미지 바이트 스트림(classpath reports/kintex_ci.jpg). null 이면 이미지 미표시. -->
<parameter name="P_LOGO" class="java.io.InputStream" isForPrompting="false"/>
<title>
<band height="86" splitType="Stretch">
<!-- KINTEX CI 로고 (311x56 비율 유지 · 좌상단) -->
<image scaleImage="RetainShape" hAlign="Left" vAlign="Top" isUsingCache="false" onErrorType="Blank">
<reportElement x="0" y="0" width="170" height="31"/>
<imageExpression><![CDATA[$P{P_LOGO}]]></imageExpression>
</image>
<!-- 타이틀 -->
<textField isBlankWhenNull="true">
<reportElement x="0" y="36" width="620" height="26"/>
<textElement verticalAlignment="Middle">
<font fontName="NanumGothic" size="17" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{P_TITLE}]]></textFieldExpression>
</textField>
<!-- 부제 -->
<textField isBlankWhenNull="true">
<reportElement x="0" y="63" width="620" height="16"/>
<textElement verticalAlignment="Middle">
<font fontName="NanumGothic" size="9.5"/>
<paragraph/>
</textElement>
<textFieldExpression><![CDATA[$P{P_SUBTITLE}]]></textFieldExpression>
</textField>
<!-- 출력일시 -->
<textField isBlankWhenNull="true">
<reportElement x="562" y="6" width="220" height="14"/>
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="NanumGothic" size="8.5"/>
</textElement>
<textFieldExpression><![CDATA["출력일시 " + $P{P_CREATED_AT}]]></textFieldExpression>
</textField>
<!-- 총 행수 -->
<textField isBlankWhenNull="true">
<reportElement x="562" y="21" width="220" height="14"/>
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="NanumGothic" size="8.5"/>
</textElement>
<textFieldExpression><![CDATA["총 " + $P{P_TOTAL_ROWS} + " 건"]]></textFieldExpression>
</textField>
<!-- 상단 구분선(브랜드 컬러) -->
<line>
<reportElement x="0" y="83" width="782" height="1"/>
<graphicElement>
<pen lineWidth="1.5" lineColor="#1F29FC"/>
</graphicElement>
</line>
</band>
</title>
<!-- columnHeader / detail 은 비워둔다(런타임 주입). height 는 서비스가 재설정. -->
<columnHeader>
<band height="22" splitType="Prevent"/>
</columnHeader>
<detail>
<band height="18" splitType="Prevent"/>
</detail>
<pageFooter>
<band height="22" splitType="Stretch">
<line>
<reportElement x="0" y="2" width="782" height="1"/>
<graphicElement>
<pen lineWidth="0.5" lineColor="#B8BFCE"/>
</graphicElement>
</line>
<textField isBlankWhenNull="true">
<reportElement x="0" y="6" width="400" height="14"/>
<textElement verticalAlignment="Middle">
<font fontName="NanumGothic" size="8"/>
</textElement>
<textFieldExpression><![CDATA["KINTEX AI 전시·행사시스템"]]></textFieldExpression>
</textField>
<textField evaluationTime="Now">
<reportElement x="602" y="6" width="150" height="14"/>
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="NanumGothic" size="8"/>
</textElement>
<textFieldExpression><![CDATA[$V{PAGE_NUMBER} + " / "]]></textFieldExpression>
</textField>
<textField evaluationTime="Report">
<reportElement x="752" y="6" width="30" height="14"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="NanumGothic" size="8"/>
</textElement>
<textFieldExpression><![CDATA[String.valueOf($V{PAGE_NUMBER})]]></textFieldExpression>
</textField>
</band>
</pageFooter>
</jasperReport>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -69,6 +69,34 @@ class AnalyticsServiceTest {
assertTrue(dto.exhibitorPerf().isEmpty()); 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<String, Object> ict = new HashMap<>();
ict.put("sector", "ICT/소프트웨어");
ict.put("boothCount", 3L);
ict.put("areaM2", 60000.0); // 75%
Map<String, Object> 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 @Test
void overviewComputesRetentionOccupancyAndForecast() { void overviewComputesRetentionOccupancyAndForecast() {
// 행사 횡단 집계는 테넌트 스코프(currentTenantId 기본 'kintex') 매처는 테넌트 무관 검증. // 행사 횡단 집계는 테넌트 스코프(currentTenantId 기본 'kintex') 매처는 테넌트 무관 검증.

View File

@ -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}) <b>봉인 안전성</b> 회귀 테스트.
*
* <p>{@link AuctionRealtimeService} 순위 변동(BID/CLOSE/AWARD) {@link AuctionService#publicRanking}
* 만든 공개 스냅샷을 모든 구독자에게 동일 전송한다. 페이로드가 어떤 뷰어별 개인화 정보( 순위 표시·타사 금액·
* 업체 실명) 담지 않음을 고정하여, 폴링푸시 전환 후에도 노출 범위가 기존 공개 폴링과 동일함을 보장한다.
*/
class AuctionRealtimeRankingTest {
private static Map<String, Object> bid(long total) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("total", total);
m.put("companyId", "c-secret-" + total); // 존재해도 스냅샷에 새어나가면
m.put("companyName", "비밀장치㈜"); // 업체 실명 절대 미노출
return m;
}
@Test
void broadcastSnapshot_neverMarksIsMe() {
List<RankRow> 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<RankRow> 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<RankRow> 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<RankRow> ranking = AuctionService.publicRanking(List.of());
assertTrue(ranking.isEmpty(), "응찰 없음 → 빈 순위(널 안전)");
}
}

View File

@ -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<String, Object> 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<LiveNoticeDto> feed = service.publicFeed("e1", 0);
assertEquals(2, feed.size());
assertTrue(feed.get(0).pinned());
}
}

Some files were not shown because too many files have changed in this diff Show More