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:
parent
bbd4a63f5a
commit
92572df511
@ -49,6 +49,13 @@ dependencies {
|
||||
// --- 서류 PDF 생성(M6 G-05) — HTML→PDF 경량 렌더러. NanumGothic 폰트 임베드(한글 보존). ---
|
||||
implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10'
|
||||
|
||||
// --- 범용 그리드 PDF 출력(전 그리드 공용, JasperReports) — WISE/UIWS 패턴 이식. ---
|
||||
// 6.21.x는 PDF(iText) 익스포터가 코어에 포함. 한글은 fonts.xml 폰트확장으로 Identity-H 임베딩.
|
||||
implementation('net.sf.jasperreports:jasperreports:6.21.3') {
|
||||
// Spring Boot 관리 버전과 충돌 방지 — commons-logging 전이 의존성 정리.
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
}
|
||||
|
||||
// --- JWT (행사 단위 RBAC 인증) ---
|
||||
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
|
||||
|
||||
@ -2,6 +2,10 @@ package com.zioinfo.kintex.admin;
|
||||
|
||||
import com.zioinfo.kintex.admin.dto.AdminDashboardDto;
|
||||
import com.zioinfo.kintex.admin.dto.AdminStatsDto;
|
||||
import com.zioinfo.kintex.common.audit.AuditLogDto;
|
||||
import com.zioinfo.kintex.common.audit.AuditLogService;
|
||||
import com.zioinfo.kintex.ops.OpsService;
|
||||
import com.zioinfo.kintex.ops.dto.OpsDto;
|
||||
import com.zioinfo.kintex.tenant.TenantContext;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@ -12,14 +16,21 @@ import java.util.Map;
|
||||
/**
|
||||
* 관리자 대시보드 서비스 — 실 테이블 집계로 KPI·라이브 행사를 조립.
|
||||
* 관람객 추이는 센서/집계 인프라 부재 → 빈배열. 테넌트는 실 데이터(KINTEX)만 반환.
|
||||
* <p>SCR-16 실전환: 라이브 행사에 M10 체크인 실인원·M14 혼잡 등급(OpsService 재사용, 수정 없이 호출만),
|
||||
* 최근 활동은 AuditLogService(audit_log 실 기록) 재사용.
|
||||
*/
|
||||
@Service
|
||||
public class AdminDashboardService {
|
||||
|
||||
private final AdminDashboardMapper mapper;
|
||||
private final OpsService opsService;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
public AdminDashboardService(AdminDashboardMapper mapper) {
|
||||
public AdminDashboardService(AdminDashboardMapper mapper, OpsService opsService,
|
||||
AuditLogService auditLogService) {
|
||||
this.mapper = mapper;
|
||||
this.opsService = opsService;
|
||||
this.auditLogService = auditLogService;
|
||||
}
|
||||
|
||||
public AdminDashboardDto getDashboard(String tenant) {
|
||||
@ -45,9 +56,22 @@ public class AdminDashboardService {
|
||||
long booths = lng(r.get("boothCount"));
|
||||
long capacity = lng(r.get("capacity"));
|
||||
int occupancy = capacity > 0 ? (int) Math.min(100, Math.round(booths * 100.0 / capacity)) : 0;
|
||||
String eventId = str(r.get("eventId"));
|
||||
|
||||
// M10 체크인 실인원 + M14 혼잡 등급 — OpsService(SCR-14) 파생 재사용. 한 행사 실패가 전체를 막지 않도록 방어.
|
||||
long onSite = 0L;
|
||||
String heat = "smooth";
|
||||
try {
|
||||
OpsDto ops = opsService.getOps(eventId);
|
||||
onSite = ops.onSiteCount();
|
||||
heat = eventHeat(ops);
|
||||
} catch (RuntimeException e) {
|
||||
// 파생 실패 시 정직한 기본값(0/smooth) 유지 — 가짜 수치 금지.
|
||||
}
|
||||
|
||||
live.add(new AdminDashboardDto.LiveEvent(
|
||||
str(r.get("eventId")), str(r.get("name")), str(r.get("hall")),
|
||||
occupancy, "ongoing"));
|
||||
eventId, str(r.get("name")), str(r.get("hall")),
|
||||
occupancy, "ongoing", onSite, heat));
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,7 +79,39 @@ public class AdminDashboardService {
|
||||
List<AdminDashboardDto.VisitorPoint> visitorTrend = new ArrayList<>();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -3,14 +3,17 @@ package com.zioinfo.kintex.admin.dto;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 관리자 백오피스 대시보드 (SCR-14 / 06_backend_api_gaps §4).
|
||||
* 집계원: event(진행/라이브)·hall_assignment·app_user·company. 관람객 추이는 센서/집계 부재 → 빈배열(정직).
|
||||
* 관리자 백오피스 대시보드 (SCR-14/16 / 06_backend_api_gaps §4).
|
||||
* 집계원: event(진행/라이브)·hall_assignment·app_user·company.
|
||||
* <p>실전환(SCR-16, 2026-07): ①라이브 행사에 M10 실 체크인 인원(onSiteCount)·M14 혼잡 등급(heat, OpsService 재사용) 부가,
|
||||
* ②최근 활동(recentActivity)은 audit_log 실 기록. 관람객 시간대 추이는 센서/집계 부재 → 빈배열(정직).
|
||||
*/
|
||||
public record AdminDashboardDto(
|
||||
List<Kpi> kpis,
|
||||
List<VisitorPoint> visitorTrend,
|
||||
List<LiveEvent> liveEvents,
|
||||
List<String> tenants
|
||||
List<String> tenants,
|
||||
List<Activity> recentActivity
|
||||
) {
|
||||
public record Kpi(String label, String value, String sub) {
|
||||
}
|
||||
@ -19,7 +22,16 @@ public record AdminDashboardDto(
|
||||
public record VisitorPoint(String hour, long count) {
|
||||
}
|
||||
|
||||
/** 라이브(진행 중) 행사 — 점유율=부스수/홀 수용. */
|
||||
public record LiveEvent(String eventId, String name, String hall, int occupancy, String status) {
|
||||
/**
|
||||
* 라이브(진행 중) 행사 — 점유율=부스수/홀 수용(occupancy),
|
||||
* onSiteCount=M10 현장 체크인 실집계, heat=M14 혼잡 등급(smooth/moderate/busy, OpsService 파생).
|
||||
*/
|
||||
public record LiveEvent(String eventId, String name, String hall, int occupancy, String status,
|
||||
long onSiteCount, String heat) {
|
||||
}
|
||||
|
||||
/** 최근 관리 활동 — audit_log append-only 실 기록(민감정보·스택트레이스 미포함). */
|
||||
public record Activity(String action, String actorName, String targetType, String summary,
|
||||
String result, String createdAt) {
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +53,27 @@ public interface AnalyticsMapper {
|
||||
""")
|
||||
Double findUtilityRevenue(@Param("eventId") String eventId);
|
||||
|
||||
/**
|
||||
* 업종별 구성(sectors) — 참가업체(부스 배정사) 업종 분포. 원천 = booth.assigned_company_name
|
||||
* ↔ company.name(대소문자·공백 무시 매칭) → company.category. 매칭 실패/미분류는 '기타' 버킷.
|
||||
* <p>버킷별 부스 수·판매면적(㎡). 매출/점유율은 서비스가 면적×요율로 환산·정규화한다.
|
||||
* <p>plain @Select(<script> 미사용) — {@code <>} 는 XML 파싱되지 않음(findRetentionSummary 동일 패턴).
|
||||
*/
|
||||
@Select("""
|
||||
SELECT COALESCE(NULLIF(btrim(c.category), ''), '기타') AS "sector",
|
||||
count(b.id) AS "boothCount",
|
||||
COALESCE(sum(ST_Area(b.geom)), 0) AS "areaM2"
|
||||
FROM booth b
|
||||
JOIN layout l ON l.id = b.layout_id
|
||||
LEFT JOIN company c ON lower(btrim(c.name)) = lower(btrim(b.assigned_company_name))
|
||||
WHERE l.event_id = #{eventId}
|
||||
AND b.assigned_company_name IS NOT NULL
|
||||
AND btrim(b.assigned_company_name) <> ''
|
||||
GROUP BY COALESCE(NULLIF(btrim(c.category), ''), '기타')
|
||||
ORDER BY sum(ST_Area(b.geom)) DESC
|
||||
""")
|
||||
List<Map<String, Object>> findSectorAggregation(@Param("eventId") String eventId);
|
||||
|
||||
// ── 테넌트 전역(행사 횡단) BI 집계 — M16 잔여 갭 ──────────────────────────
|
||||
// ★멀티테넌시 2단계: 행사 횡단 집계는 tenant_id(event/hall) 선행 필터로 격리(§1A-2 · §8-2).
|
||||
// booth/layout/utility_order는 tenant_id 컬럼이 없으므로 event 조인으로 간접 스코프(신규 컬럼 금지).
|
||||
|
||||
@ -53,20 +53,46 @@ public class AnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
// 손익(operator 관점) — 실 매출 항목만. 업종 분류 원천 부재 → sectors 미집계.
|
||||
// 손익(operator 관점) — 실 매출 항목만.
|
||||
List<AnalyticsDto.PnlRow> pnl = new ArrayList<>();
|
||||
List<AnalyticsDto.Sector> sectors = new ArrayList<>();
|
||||
if (!exhibitor) {
|
||||
pnl.add(new AnalyticsDto.PnlRow("부스 임대", rentalRevenue, grade(rentalRevenue)));
|
||||
pnl.add(new AnalyticsDto.PnlRow("유틸리티", utilityRevenue, grade(utilityRevenue)));
|
||||
}
|
||||
|
||||
// 업종별 구성(sectors) — 참가업체(부스 배정사) 업종(company.category) 실집계. 운영사/참가업체 공통.
|
||||
List<AnalyticsDto.Sector> sectors = buildSectors(eventId, ratePerM2);
|
||||
|
||||
// 참가업체 성과 — 방문자 리드(M13) 원천 부재 → 미집계(빈배열).
|
||||
List<AnalyticsDto.ExhibitorPerf> exhibitorPerf = new ArrayList<>();
|
||||
|
||||
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 잔여 갭 ───────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@ -5,7 +5,8 @@ import java.util.List;
|
||||
/**
|
||||
* 경영분석 BI 집계 (SCR-13 / 06_backend_api_gaps §3).
|
||||
* 집계원: booth 면적(ST_Area)×임대 요율(master_data)·utility_order.quote·booth 월별 추이.
|
||||
* 업종(sectors)·리드(exhibitorPerf)는 원천(M13 방문자/업종 분류) 부재 → 빈배열(정직 반환).
|
||||
* 업종(sectors)은 참가업체(booth.assigned_company_name↔company.category) 실집계.
|
||||
* 리드(exhibitorPerf)는 원천(M13 방문자 분류) 부재 → 빈배열(정직 반환).
|
||||
*/
|
||||
public record AnalyticsDto(
|
||||
List<Kpi> kpis,
|
||||
|
||||
@ -17,7 +17,7 @@ public interface CmsMapper {
|
||||
<script>
|
||||
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",
|
||||
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(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"
|
||||
@ -50,7 +50,7 @@ public interface CmsMapper {
|
||||
@Select("""
|
||||
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",
|
||||
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(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"
|
||||
@ -184,6 +184,7 @@ public interface CmsMapper {
|
||||
@Select("""
|
||||
<script>
|
||||
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(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
|
||||
FROM cms_content
|
||||
|
||||
@ -497,7 +497,7 @@ public class CmsService {
|
||||
str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")),
|
||||
str(r.get("scheduledAt")), bool(r.get("signage")), bool(r.get("mailing")),
|
||||
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("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")),
|
||||
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) {
|
||||
|
||||
@ -15,5 +15,6 @@ public record CmsContentDto(
|
||||
String authorName,
|
||||
String publishedAt,
|
||||
String createdAt,
|
||||
String updatedAt) {
|
||||
String updatedAt,
|
||||
String heroUrl) {
|
||||
}
|
||||
|
||||
@ -70,8 +70,8 @@ public class SecurityConfig {
|
||||
ApiResponse.fail(new ApiError(ErrorCode.UNAUTHORIZED.name(),
|
||||
ErrorCode.UNAUTHORIZED.defaultMessage()))));
|
||||
}))
|
||||
// 테넌트 컨텍스트 해소(§1A-3) → 이후 JWT 인증. 미해소 시 기준 테넌트 폴백(회귀 0).
|
||||
.addFilterBefore(new TenantContextFilter(tenantResolver, objectMapper),
|
||||
// 테넌트 컨텍스트 해소(§1A-3) — 인증 요청은 JWT tid 권위(§1A-3-2, 2단계). 미해소 시 기준 테넌트 폴백(회귀 0).
|
||||
.addFilterBefore(new TenantContextFilter(tenantResolver, objectMapper, jwtService),
|
||||
UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(new JwtAuthenticationFilter(jwtService),
|
||||
UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,28 @@ public final class MailTemplates {
|
||||
+ WRAP_FOOT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 티켓 오픈 알림(관심 행사 구독자 대상, F-C1) — 행사명 + 예매 안내 + 원클릭 수신거부 링크.
|
||||
* 광고성 대량 발송이 아니라 사용자가 명시 구독한 트랜잭션성 알림이지만, 수신거부 링크는 항상 포함한다.
|
||||
*/
|
||||
public static String ticketOpen(String eventName, String bookUrl, String unsubUrl) {
|
||||
String unsubLine = (unsubUrl == null || unsubUrl.isBlank())
|
||||
? ""
|
||||
: "<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)} 사용. */
|
||||
public static String edm(String campaignName, String eventName) {
|
||||
return edm(campaignName, eventName, null);
|
||||
|
||||
@ -11,6 +11,7 @@ import com.zioinfo.kintex.module.m2.dto.LayoutDto;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretRequest;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutVersionDto;
|
||||
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
|
||||
import com.zioinfo.kintex.rules.ComplianceReport;
|
||||
import jakarta.validation.Valid;
|
||||
@ -45,6 +46,15 @@ public class FloorplanController {
|
||||
return ApiResponse.ok(service.getLayout(eventId, hallId, version));
|
||||
}
|
||||
|
||||
/** GET /versions — 배치안 버전 이력 목록(자동생성 proposal·draft·applied). 행사 멤버/홀매니저 열람. */
|
||||
@GetMapping("/versions")
|
||||
public ApiResponse<List<LayoutVersionDto>> versions(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String hallId) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.listVersions(eventId, hallId));
|
||||
}
|
||||
|
||||
/** PUT — 배치 저장(주최자). */
|
||||
@PutMapping
|
||||
public ApiResponse<LayoutDto> save(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
|
||||
@ -6,6 +6,7 @@ import com.zioinfo.kintex.module.m2.dto.AutoLayoutRequest;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutDto;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutVersionDto;
|
||||
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
|
||||
import com.zioinfo.kintex.rules.ComplianceReport;
|
||||
|
||||
@ -17,6 +18,9 @@ public interface FloorplanService {
|
||||
/** 배치안 조회(version=null이면 최신). */
|
||||
LayoutDto getLayout(String eventId, String hallId, Integer version);
|
||||
|
||||
/** 배치안 버전 이력 목록(version·name·status·boothCount·updatedAt, 최신 순). */
|
||||
List<LayoutVersionDto> listVersions(String eventId, String hallId);
|
||||
|
||||
/** 배치 저장 → 저장된 배치안. 저장 시 규정 검증도 함께 산출해 summary에 반영. */
|
||||
LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request);
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ import com.zioinfo.kintex.module.m2.dto.LayoutDto;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutInterpretResult;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutSaveRequest;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutSummary;
|
||||
import com.zioinfo.kintex.module.m2.dto.LayoutVersionDto;
|
||||
import com.zioinfo.kintex.module.m2.dto.S7PreviewResult;
|
||||
import com.zioinfo.kintex.module.m2.mapper.BoothMapper;
|
||||
import com.zioinfo.kintex.module.m2.mapper.HallMapper;
|
||||
@ -85,20 +86,20 @@ public class FloorplanServiceImpl implements FloorplanService {
|
||||
@Override
|
||||
@Transactional
|
||||
public LayoutDto saveLayout(String eventId, String hallId, LayoutSaveRequest request) {
|
||||
return persistBooths(eventId, hallId, request.name(), request.version(), request.booths());
|
||||
return persistBooths(eventId, hallId, request.name(), request.version(), request.booths(), "draft");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public LayoutDto applyOption(String eventId, String hallId, ApplyOptionRequest request) {
|
||||
// 적용/병합은 항상 새 버전으로 저장(version=null → 다음 버전) 후 규정 재검증.
|
||||
// 적용/병합은 항상 새 버전으로 저장(version=null → 다음 버전) 후 규정 재검증. 적용본은 status='applied'.
|
||||
String name = request.name() == null ? "자동배치 적용" : request.name();
|
||||
return persistBooths(eventId, hallId, name, null, request.booths());
|
||||
return persistBooths(eventId, hallId, name, null, request.booths(), "applied");
|
||||
}
|
||||
|
||||
/** 배치안 upsert + 부스 폴리곤 교체 저장 → 규정 검증 반영된 LayoutDto. */
|
||||
/** 배치안 upsert + 부스 폴리곤 교체 저장 → 규정 검증 반영된 LayoutDto. status = draft|applied|proposal. */
|
||||
private LayoutDto persistBooths(String eventId, String hallId, String name,
|
||||
Integer requestedVersion, List<BoothDto> booths) {
|
||||
Integer requestedVersion, List<BoothDto> booths, String status) {
|
||||
int version = requestedVersion != null ? requestedVersion : nextVersion(eventId, hallId);
|
||||
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
@ -106,7 +107,7 @@ public class FloorplanServiceImpl implements FloorplanService {
|
||||
params.put("hallId", hallId);
|
||||
params.put("version", version);
|
||||
params.put("name", name);
|
||||
params.put("status", "draft");
|
||||
params.put("status", status);
|
||||
String layoutId = boothMapper.upsertLayout(params);
|
||||
|
||||
List<Map<String, Object>> boothRows = new ArrayList<>();
|
||||
@ -323,6 +324,9 @@ public class FloorplanServiceImpl implements FloorplanService {
|
||||
String label = "배치안 " + tag + (zoneLabel != null ? " · " + zoneLabel : "");
|
||||
options.add(new AutoLayoutOption("opt-" + tag, label, summary, booths, null));
|
||||
}
|
||||
// 생성 안 영속화 — 각 안을 status='proposal' 버전으로 저장(배치 이력 재열람·적용 근거, 소유자 지시 2026-07-23).
|
||||
options = persistProposals(eventId, hallId, options);
|
||||
|
||||
log.info("자동배치 생성: event={} hall={} zone={} options={} target={} ai={}",
|
||||
eventId, hallId, request.zoneId(), options.size(), request.targetBoothCount(), ai);
|
||||
|
||||
@ -330,6 +334,41 @@ public class FloorplanServiceImpl implements FloorplanService {
|
||||
return ai ? attachAiSummaries(options, hall) : options;
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 배치안을 status='proposal' layout 버전으로 영속화한다(응답 유실 방지, 배치 이력 조회 근거).
|
||||
* <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 호출로 optionId→요약 JSON 을 받아 매핑한다. AI 미가용/파싱 실패 시 원본 옵션(요약 없음)을 그대로 반환.
|
||||
@ -614,11 +653,9 @@ public class FloorplanServiceImpl implements FloorplanService {
|
||||
}
|
||||
|
||||
private int nextVersion(String eventId, String hallId) {
|
||||
Map<String, Object> latest = boothMapper.findLayout(eventId, hallId, null);
|
||||
if (latest == null) {
|
||||
return 1;
|
||||
}
|
||||
return intVal(latest.get("version")) + 1;
|
||||
// proposal 포함 최대 버전 + 1 — 작업본 우선 조회(findLayout)와 분리해 버전 충돌 방지.
|
||||
Integer max = boothMapper.maxVersion(eventId, hallId);
|
||||
return (max == null ? 0 : max) + 1;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- 좌표/형변환 -----
|
||||
|
||||
@ -13,6 +13,7 @@ import java.util.List;
|
||||
* @param booths 후보 부스 배열 — "이 안으로 편집 시작"/병합 소스(계약 확장, 프론트 gap #3 해소, NON_NULL)
|
||||
* @param s7RenderJobId S7 홀 전경(조감) 생성 잡 ID — 완료 시 WebSocket 푸시로 카드 이미지 교체
|
||||
* @param aiSummary 배치안 AI 장단점 요약(ai=true 시에만, 엔진 산출 metrics 근거 2~3줄) — 실패/미요청 시 null(NON_NULL 로 응답 제외)
|
||||
* @param version 생성 시 proposal 로 영속된 layout 버전(배치안 이력 조회·재열람 근거) — 미영속 시 null(NON_NULL 제외)
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record AutoLayoutOption(
|
||||
@ -21,16 +22,22 @@ public record AutoLayoutOption(
|
||||
LayoutSummary summary,
|
||||
List<BoothDto> booths,
|
||||
String s7RenderJobId,
|
||||
String aiSummary
|
||||
String aiSummary,
|
||||
Integer version
|
||||
) {
|
||||
/** 기존 4필드 + s7 생성자 호환 — aiSummary 미부여(null). */
|
||||
/** 기존 4필드 + s7 생성자 호환 — aiSummary·version 미부여(null). */
|
||||
public AutoLayoutOption(String optionId, String label, LayoutSummary summary,
|
||||
List<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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -14,11 +14,18 @@ import java.util.Map;
|
||||
@Mapper
|
||||
public interface BoothMapper {
|
||||
|
||||
/** 배치안 헤더 조회(layoutId, version, name, status, updatedAt). */
|
||||
/** 배치안 헤더 조회(layoutId, version, name, status, updatedAt). version=null → 최신(작업본 우선). */
|
||||
Map<String, Object> findLayout(@Param("eventId") String eventId,
|
||||
@Param("hallId") String hallId,
|
||||
@Param("version") Integer version);
|
||||
|
||||
/** 현재 최대 버전(proposal 포함) — 다음 버전 번호 산정용. 없으면 null. */
|
||||
Integer maxVersion(@Param("eventId") String eventId, @Param("hallId") String hallId);
|
||||
|
||||
/** 배치안 버전 이력 목록(version·name·status·boothCount·updatedAt) — 최신 순. */
|
||||
List<Map<String, Object>> findLayoutVersions(@Param("eventId") String eventId,
|
||||
@Param("hallId") String hallId);
|
||||
|
||||
/** 배치안의 부스 목록 조회(폴리곤은 ST_AsGeoJSON 또는 좌표 배열로 반환). */
|
||||
List<Map<String, Object>> findBooths(@Param("layoutId") String layoutId);
|
||||
|
||||
|
||||
@ -28,6 +28,7 @@ public interface OpsMapper {
|
||||
@Select("""
|
||||
SELECT h.id AS "hallId", h.label AS "label",
|
||||
COALESCE(ar.sales_area, 0) AS "salesArea",
|
||||
COALESCE(h.area_m2, 0) AS "physicalArea",
|
||||
pw.req_kw AS "powerKw"
|
||||
FROM hall_assignment ha
|
||||
JOIN hall h ON h.id = ha.hall_id
|
||||
|
||||
@ -35,30 +35,36 @@ public class OpsService {
|
||||
rows = new ArrayList<>();
|
||||
}
|
||||
|
||||
double totalArea = 0;
|
||||
// 분배 가중치: 홀별로 판매면적(부스 배치 실적)이 있으면 그 값을, 없으면 물리 바닥면적을 쓴다.
|
||||
// 판매면적만 쓰면 데모/초기처럼 한 홀에만 배치가 있을 때 그 홀에 현장 인원이 100% 몰려
|
||||
// "면적 비중 분배가 이상"해 보인다 → 배정된 모든 홀이 실제 규모에 비례해 분산되도록 폴백.
|
||||
double totalWeight = 0;
|
||||
for (Map<String, Object> r : rows) {
|
||||
totalArea += dbl(r.get("salesArea"));
|
||||
totalWeight += hallWeight(r);
|
||||
}
|
||||
|
||||
List<OpsDto.Hall> halls = new ArrayList<>();
|
||||
Double powerTotal = null;
|
||||
int hallCount = rows.size();
|
||||
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"));
|
||||
|
||||
long est = 0L;
|
||||
if (onSite > 0) {
|
||||
if (totalArea > 0) {
|
||||
est = Math.round(onSite * (area / totalArea));
|
||||
if (totalWeight > 0) {
|
||||
est = Math.round(onSite * (weight / totalWeight));
|
||||
} else if (hallCount > 0) {
|
||||
// 부스 미배치 → 균등 분배(면적 가중 불가).
|
||||
// 면적 정보 전무 → 균등 분배(가중 불가).
|
||||
est = Math.round((double) onSite / hallCount);
|
||||
}
|
||||
}
|
||||
|
||||
// 혼잡 밀도 기준면적: 판매면적 우선(사람은 부스 통로에 있음), 없으면 물리면적.
|
||||
double densityArea = salesArea > 0 ? salesArea : dbl(r.get("physicalArea"));
|
||||
halls.add(new OpsDto.Hall(
|
||||
str(r.get("hallId")), str(r.get("label")), est, heatFor(est, area), powerKw));
|
||||
str(r.get("hallId")), str(r.get("label")), est, heatFor(est, densityArea), powerKw));
|
||||
|
||||
if (powerKw != null) {
|
||||
powerTotal = (powerTotal == null ? 0d : powerTotal) + powerKw;
|
||||
@ -78,6 +84,12 @@ public class OpsService {
|
||||
return new OpsDto(halls, hvac, new ArrayList<>(), new ArrayList<>(), onSite, powerTotal, series);
|
||||
}
|
||||
|
||||
/** 홀 분배 가중치 — 판매면적(부스 배치 실적) 우선, 없으면 물리 바닥면적. */
|
||||
private static double hallWeight(Map<String, Object> r) {
|
||||
double salesArea = dbl(r.get("salesArea"));
|
||||
return salesArea > 0 ? salesArea : dbl(r.get("physicalArea"));
|
||||
}
|
||||
|
||||
/** 인원 밀도(명/㎡) 기준 혼잡 등급. 인원 0 또는 면적 부재 시 보수적 처리. */
|
||||
private static String heatFor(long est, double area) {
|
||||
if (est <= 0) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 상대 폭 가중치(선택, >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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,10 @@ import com.zioinfo.kintex.common.audit.Audited;
|
||||
import com.zioinfo.kintex.system.SystemAccessGuard;
|
||||
import com.zioinfo.kintex.tenant.dto.TenantAdminDto;
|
||||
import com.zioinfo.kintex.tenant.dto.TenantCreateRequest;
|
||||
import com.zioinfo.kintex.tenant.dto.TenantSwitchResponse;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@ -47,4 +49,16 @@ public class TenantAdminController {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.create(req));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /{code}/switch — 플랫폼 관리자 테넌트 스위치. 대상 테넌트 tid 가 박힌 신규 토큰을 반환한다(§1A-3-2).
|
||||
* 프론트 스위처가 응답 토큰으로 세션을 교체하면 이후 전 요청이 대상 테넌트로 스코프된다.
|
||||
*/
|
||||
@Audited(action = "TENANT_SWITCH", targetType = "tenant")
|
||||
@PostMapping("/{code}/switch")
|
||||
public ApiResponse<TenantSwitchResponse> switchTenant(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String code) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.switchTenant(principal, code));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
package com.zioinfo.kintex.tenant;
|
||||
|
||||
import com.zioinfo.kintex.auth.JwtService;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.tenant.dto.TenantAdminDto;
|
||||
import com.zioinfo.kintex.tenant.dto.TenantCreateRequest;
|
||||
import com.zioinfo.kintex.tenant.dto.TenantSwitchResponse;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@ -25,10 +28,12 @@ public class TenantAdminService {
|
||||
|
||||
private final TenantMapper mapper;
|
||||
private final TenantResolver resolver;
|
||||
private final JwtService jwtService;
|
||||
|
||||
public TenantAdminService(TenantMapper mapper, TenantResolver resolver) {
|
||||
public TenantAdminService(TenantMapper mapper, TenantResolver resolver, JwtService jwtService) {
|
||||
this.mapper = mapper;
|
||||
this.resolver = resolver;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
/** 전 테넌트 목록(행사·사용자 수 포함). */
|
||||
@ -69,6 +74,21 @@ public class TenantAdminService {
|
||||
return new TenantAdminDto(code, req.name().trim(), domain, status, 0L, 0L, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플랫폼 관리자 테넌트 스위치 — 대상 테넌트 {@code tid}가 박힌 신규 토큰 재발급(§1A-3-2).
|
||||
* 접근 게이트(requireAdmin)는 컨트롤러가 이미 수행. 대상 테넌트가 미존재면 400.
|
||||
* 사용자 신원·역할(eventRoles·hallManager·roleCode)은 그대로 유지하고 테넌트만 교체한다(선택 클레임 재발급).
|
||||
*/
|
||||
public TenantSwitchResponse switchTenant(KintexPrincipal principal, String code) {
|
||||
String target = TenantContext.normalize(code);
|
||||
if (target == null || !resolver.exists(target)) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 테넌트입니다.");
|
||||
}
|
||||
String token = jwtService.issue(principal.userId(), principal.displayName(),
|
||||
principal.eventRoles(), principal.hallManager(), principal.roleCode(), target);
|
||||
return new TenantSwitchResponse(token, jwtService.ttlSeconds(), target);
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package com.zioinfo.kintex.tenant;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zioinfo.kintex.auth.JwtService;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.common.ApiResponse.ApiError;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
@ -15,40 +17,76 @@ import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 테넌트 컨텍스트 해소 필터 (PLANNING §1A-3 · §8-2 필터 계층).
|
||||
* <p>해소 우선순위: ① {@code X-Tenant-Id} 헤더 → ② Host 도메인 매칭 → ③ 기준 테넌트('kintex').
|
||||
* 명시적 {@code X-Tenant-Id}가 존재하지 않는 테넌트를 가리키면 <b>400</b>(오접속·위조 차단).
|
||||
* 그 외(헤더 없음·도메인 미매칭)는 기준 테넌트로 폴백해 기존 동작을 완전히 보존한다(회귀 0, 1단계).
|
||||
* <p>2단계에서 fail-closed(미해소 거부)·JWT 테넌트 클레임 검증으로 강화 — 설계 노트 참조.
|
||||
*
|
||||
* <p><b>2단계(JWT tid 권위)</b> — 인증 요청은 JWT {@code tid} 클레임을 신뢰 원천으로 삼는다(§1A-3-2):
|
||||
* <ul>
|
||||
* <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 {
|
||||
|
||||
private static final String HEADER = "X-Tenant-Id";
|
||||
private static final String BEARER = "Bearer ";
|
||||
|
||||
private final TenantResolver resolver;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final JwtService jwtService;
|
||||
|
||||
public TenantContextFilter(TenantResolver resolver, ObjectMapper objectMapper) {
|
||||
public TenantContextFilter(TenantResolver resolver, ObjectMapper objectMapper, JwtService jwtService) {
|
||||
this.resolver = resolver;
|
||||
this.objectMapper = objectMapper;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
String headerTenant = trimToNull(request.getHeader(HEADER));
|
||||
KintexPrincipal principal = tryPrincipal(request);
|
||||
|
||||
String tenantId;
|
||||
String headerTenant = request.getHeader(HEADER);
|
||||
if (headerTenant != null && !headerTenant.isBlank()) {
|
||||
String candidate = headerTenant.trim();
|
||||
if (!resolver.exists(candidate)) {
|
||||
if (principal != null) {
|
||||
// 인증 요청 — JWT tid 권위(§1A-3-2).
|
||||
String jwtTenant = principal.tenantId(); // 이미 KINTEX 기본 정규화됨
|
||||
if (headerTenant != null
|
||||
&& !equalsTenant(headerTenant, jwtTenant)) {
|
||||
// 명시적 크로스-테넌트 스위치 시도.
|
||||
boolean platformAdmin = principal.isAdmin() || principal.hallManager();
|
||||
if (!platformAdmin) {
|
||||
writeForbiddenTenant(response); // 403 — 하이재킹/오접속 차단
|
||||
return;
|
||||
}
|
||||
if (!resolver.exists(headerTenant)) {
|
||||
writeBadTenant(response); // 400 — 미존재 테넌트
|
||||
return;
|
||||
}
|
||||
tenantId = headerTenant; // 관리자 명시 스위치(감사 로깅은 컨트롤러/감사 레이어)
|
||||
} else {
|
||||
tenantId = jwtTenant; // 기본: JWT tid
|
||||
}
|
||||
} else {
|
||||
// 미인증(공개 경로) — 1단계 해소 유지: 헤더 → 도메인 → 기준 테넌트.
|
||||
if (headerTenant != null) {
|
||||
if (!resolver.exists(headerTenant)) {
|
||||
writeBadTenant(response);
|
||||
return;
|
||||
}
|
||||
tenantId = candidate;
|
||||
tenantId = headerTenant;
|
||||
} else {
|
||||
// 도메인 매칭(§1A-3) → 폴백 기준 테넌트.
|
||||
String byDomain = resolver.resolveByDomain(request.getHeader("Host"));
|
||||
tenantId = (byDomain != null) ? byDomain : TenantContext.DEFAULT_TENANT;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
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 {
|
||||
response.setStatus(ErrorCode.VALIDATION.status().value());
|
||||
writeError(response, ErrorCode.VALIDATION, "알 수 없는 테넌트입니다.");
|
||||
}
|
||||
|
||||
private void writeForbiddenTenant(HttpServletResponse response) throws IOException {
|
||||
writeError(response, ErrorCode.FORBIDDEN, "요청한 테넌트에 접근할 수 없습니다.");
|
||||
}
|
||||
|
||||
private void writeError(HttpServletResponse response, ErrorCode code, String message) throws IOException {
|
||||
response.setStatus(code.status().value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.getWriter().write(objectMapper.writeValueAsString(
|
||||
ApiResponse.fail(new ApiError(ErrorCode.VALIDATION.name(),
|
||||
"알 수 없는 테넌트입니다."))));
|
||||
ApiResponse.fail(new ApiError(code.name(), message))));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -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');
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
BIN
src/backend/src/main/resources/fonts/NanumGothic-Bold.ttf
Normal file
BIN
src/backend/src/main/resources/fonts/NanumGothic-Bold.ttf
Normal file
Binary file not shown.
19
src/backend/src/main/resources/fonts/fonts.xml
Normal file
19
src/backend/src/main/resources/fonts/fonts.xml
Normal 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>
|
||||
@ -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
|
||||
@ -14,7 +14,7 @@
|
||||
-->
|
||||
<mapper namespace="com.zioinfo.kintex.module.m2.mapper.BoothMapper">
|
||||
|
||||
<!-- 배치안 헤더(version=null → 최신). -->
|
||||
<!-- 배치안 헤더(version=null → 최신 작업본: proposal 은 뒤로 밀어 draft/applied 우선). -->
|
||||
<select id="findLayout" resultType="map">
|
||||
SELECT id AS "layoutId",
|
||||
version,
|
||||
@ -27,10 +27,31 @@
|
||||
<if test="version != null">
|
||||
AND version = #{version}
|
||||
</if>
|
||||
ORDER BY version DESC
|
||||
ORDER BY (status = 'proposal'), version DESC
|
||||
LIMIT 1
|
||||
</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). -->
|
||||
<select id="findBooths" resultType="map">
|
||||
SELECT id AS "boothId",
|
||||
|
||||
116
src/backend/src/main/resources/reports/grid_report.jrxml
Normal file
116
src/backend/src/main/resources/reports/grid_report.jrxml
Normal 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>
|
||||
BIN
src/backend/src/main/resources/reports/kintex_ci.jpg
Normal file
BIN
src/backend/src/main/resources/reports/kintex_ci.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@ -69,6 +69,34 @@ class AnalyticsServiceTest {
|
||||
assertTrue(dto.exhibitorPerf().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregatesSectorsFromCompanyCategoryAreaShare() {
|
||||
when(mapper.findRentalRatePerM2()).thenReturn(2000.0);
|
||||
when(mapper.findAreaAndCount("e1")).thenReturn(new HashMap<>());
|
||||
when(mapper.findUtilityRevenue("e1")).thenReturn(0.0);
|
||||
when(mapper.findMonthlyTrend("e1", 0)).thenReturn(List.of());
|
||||
|
||||
Map<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
|
||||
void overviewComputesRetentionOccupancyAndForecast() {
|
||||
// 행사 횡단 집계는 테넌트 스코프(currentTenantId 기본 'kintex') — 매처는 테넌트 무관 검증.
|
||||
|
||||
@ -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(), "응찰 없음 → 빈 순위(널 안전)");
|
||||
}
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user