feat(ticket): public ticket booking/lookup domain with mock PG (GAP G-02)

V50 ticket_product/ticket_order/ticket_issue (tenant-leading composite
PK, e-2026-live demo seeds). Public API: product list with server-side
remaining/sale-window, transactional order flow with conditional stock
decrement (oversell-safe) + MockPaymentGateway adapter, hash-verified
lookup returning masked PII only. PublicTicketPage wired via
react-query (degraded sample fallback retained); ticket.* i18n x4.
Mark G-02 resolved in GAP_AUDIT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 00:25:24 +09:00
parent 0f91dcf4ae
commit 8943c806ee
16 changed files with 1254 additions and 218 deletions

View File

@ -53,11 +53,12 @@
- 정본: PLANNING M18 §1A(테넌트 온보딩), design.md SCR-A9. 기획에 있음.
- **해소:** `tenantApi`(list/create) 라이브 배선 — react-query 목록(로딩/에러/빈 상태), 등록 모달(서버 권위 검증+클라 미러), KxDataTable(검색·정렬·CSV), `tenant.*` i18n 17키×4로케일. 하드코딩·disabled·샘플 배너 제거.
**G-02 [P1] 입장권 예매/조회 백엔드 전무**
**G-02 [P1] 입장권 예매/조회 백엔드 전무** — ✅해소(2026-07-13)
- 파일: `src/frontend/src/screens/public/PublicTicketPage.tsx:5`(주석 "티켓·재고·결제 API 부재 → 샘플"), `:36`(하드코딩 `TICKETS`)
- 라우트: `/tickets/:eventId/purchase`, `/tickets/lookup` (공개, 비로그인 고객 대면)
- 유형: 미구현 API(백엔드 컨트롤러 부재 — grep "ticket" in backend = 0) + PG 미연동
- 정본: PLANNING M10(관람객)·M9(정산) design.md SCR-P7. 기획에 있음.
- **해소:** 티켓 도메인 풀 슬라이스 구현. 백엔드 `com.zioinfo.kintex.ticket`(Controller·Service·Mapper+XML) + `payment`(PaymentGateway 어댑터·MockPaymentGateway 기본) + Flyway `V50__ticket_domain.sql`(ticket_product·ticket_order·ticket_issue, tenant_id 복합 PK, e-2026-live 상품 3종+데모 주문 3건·발권·타행사 3종). 공개 API 3종: `GET /api/public/tickets/{eventId}`(판매상품·잔여 서버권위), `POST .../orders`(수량·판매기간·재고 서버검증→조건부 재고차감 oversell 차단→Mock PG 승인→발권, 실패 시 트랜잭션 롤백 원복), `GET .../lookup?orderNo=&contact=`(연락처 sha256 해시 대조·PII 마스킹 응답, 원문 미저장). 프론트 `ticketApi.ts`(react-query) 실배선 — PublicTicketPage 예매(권종→예매자→결제→발권결과 티켓코드)·조회 탭, 로딩/에러/빈 3상태, NETWORK/NOT_FOUND 강등 시에만 샘플 배지 폴백. `ticket.*` i18n 15키×4로케일(leaf 2374 패리티). 하드코딩 `TICKETS` 제거.
### P2 — 부분 미개발(정상 degraded/planned)
@ -95,7 +96,7 @@
| 순위 | ID | 작업 | 담당 |
|------|-----|------|------|
| 1 | ~~G-01~~ ✅ | TenantAdminPage → `/api/admin/tenants` GET/POST 배선(하드코딩·disabled 제거) — 2026-07-13 해소 | **kintex-frontend-dev** |
| 2 | G-02 | 티켓 도메인 백엔드(티켓·재고·예매·조회) + PG 어댑터 + 프론트 배선 | **kintex-backend-dev**(+visitor-dev) → frontend-dev |
| 2 | ~~G-02~~ ✅ | 티켓 도메인 백엔드(티켓·재고·예매·조회) + PG 어댑터 + 프론트 배선 — 2026-07-13 해소 | **kintex-backend-dev**(+visitor-dev) → frontend-dev |
| 3 | G-04 | 캠페인 실발송(SMTP, 수신동의·상한) | kintex-backend-dev(marketing) |
| 4 | G-03 | CMS AI 자동번역 배선(AiTextRouter) | kintex-ai-dev |
| 5 | G-06/G-07 | 회의 STT·통합검색 자연어 AI | kintex-ai-dev |

View File

@ -38,6 +38,22 @@ public final class Masking {
return head + "***" + domain;
}
/** 홍길동 → 홍*동, 김철 → 김*, 이 → *. 가운데를 마스킹한다. null 안전. */
public static String maskName(String name) {
if (name == null || name.isBlank()) {
return name;
}
String n = name.trim();
int len = n.length();
if (len == 1) {
return "*";
}
if (len == 2) {
return n.charAt(0) + "*";
}
return n.charAt(0) + "*".repeat(len - 2) + n.charAt(len - 1);
}
/** 203.0.113.24 → 203.0.113.* (IPv6 등은 앞 절반만). null 안전. */
public static String maskIp(String ip) {
if (ip == null || ip.isBlank()) {

View File

@ -0,0 +1,52 @@
package com.zioinfo.kintex.ticket;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.ticket.dto.TicketDtos.LookupResultDto;
import com.zioinfo.kintex.ticket.dto.TicketDtos.OrderRequest;
import com.zioinfo.kintex.ticket.dto.TicketDtos.OrderResultDto;
import com.zioinfo.kintex.ticket.dto.TicketDtos.TicketProductDto;
import jakarta.validation.Valid;
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;
import java.util.List;
/**
* 공개 입장권 API (M10·M9) 경로 비인증 공개({@code /api/public/**} permitAll).
* <p>보안: 응답에 구매자 PII 원문 미노출(마스킹만) · 스택트레이스 미노출(GlobalExceptionHandler).
*/
@RestController
@RequestMapping("/api/public/tickets")
public class TicketController {
private final TicketService service;
public TicketController(TicketService service) {
this.service = service;
}
/** GET /api/public/tickets/{eventId} — 판매중 상품 목록(잔여수량·판매기간 서버 권위). */
@GetMapping("/{eventId}")
public ApiResponse<List<TicketProductDto>> products(@PathVariable String eventId) {
return ApiResponse.ok(service.listProducts(eventId));
}
/** POST /api/public/tickets/{eventId}/orders — 예매(재고차감→PG승인→발권). */
@PostMapping("/{eventId}/orders")
public ApiResponse<OrderResultDto> createOrder(@PathVariable String eventId,
@Valid @RequestBody OrderRequest req) {
return ApiResponse.ok(service.createOrder(eventId, req));
}
/** GET /api/public/tickets/lookup?orderNo=&contact= — 주문번호+연락처 조회(PII 마스킹). */
@GetMapping("/lookup")
public ApiResponse<LookupResultDto> lookup(@RequestParam String orderNo,
@RequestParam String contact) {
return ApiResponse.ok(service.lookup(orderNo, contact));
}
}

View File

@ -0,0 +1,44 @@
package com.zioinfo.kintex.ticket;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 입장권 도메인 매퍼 tenant_id='KINTEX' 고정. XML: mybatis/mapper/TicketMapper.xml.
* <p> 재고 차감은 {@link #decrementStock}(조건부 UPDATE) oversell 원자적으로 방지한다.
* <p> camelCase 별칭은 XML 에서 {@code AS "x"} 쌍따옴표(PG lower-fold 방지).
*/
@Mapper
public interface TicketMapper {
/** 행사별 판매 상품 목록(정렬순). remaining 은 total-sold 로 계산해 서빙. */
List<Map<String, Object>> findProducts(@Param("eventId") String eventId);
/** 단일 상품(주문 검증용). 없으면 null. */
Map<String, Object> findProduct(@Param("productId") String productId);
/**
* 조건부 재고 차감 {@code sold_qty += qty WHERE 판매중 AND (total-sold) >= qty}.
* 반환 갱신행수 1 이면 확보 성공, 0 이면 매진/판매종료(oversell 차단).
*/
int decrementStock(@Param("productId") String productId, @Param("qty") int qty);
/** 주문번호 존재 여부(생성 충돌 회피). */
int countOrderNo(@Param("orderNo") String orderNo);
/** 주문 삽입. */
int insertOrder(Map<String, Object> order);
/** 발권 1건 삽입. */
int insertIssue(Map<String, Object> issue);
/** 조회 — 주문번호 + 연락처 해시 대조(둘 다 일치해야 반환). 없으면 null. */
Map<String, Object> findOrderForLookup(@Param("orderNo") String orderNo,
@Param("contactHash") String contactHash);
/** 주문의 발권 티켓 목록(seq 순). */
List<Map<String, Object>> findIssuesByOrder(@Param("orderId") String orderId);
}

View File

@ -0,0 +1,236 @@
package com.zioinfo.kintex.ticket;
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.ticket.dto.TicketDtos.IssuedTicketDto;
import com.zioinfo.kintex.ticket.dto.TicketDtos.LookupResultDto;
import com.zioinfo.kintex.ticket.dto.TicketDtos.OrderRequest;
import com.zioinfo.kintex.ticket.dto.TicketDtos.OrderResultDto;
import com.zioinfo.kintex.ticket.dto.TicketDtos.TicketProductDto;
import com.zioinfo.kintex.ticket.payment.PaymentGateway;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 입장권 서비스 판매 상품 조회 · 예매(재고차감PG승인발권) · 조회.
* <p>서버 권위: 수량 상한·판매기간·재고를 서버에서 재검증한다(클라이언트 신뢰 금지).
* <p>PII 최소화(§0-3): 구매자 원문은 저장·응답 어디에도 남기지 않고 마스킹/해시만 보관한다.
*/
@Service
public class TicketService {
private static final int ABS_MAX_QTY = 100;
private final SecureRandom random = new SecureRandom();
private final TicketMapper mapper;
private final PaymentGateway paymentGateway;
public TicketService(TicketMapper mapper, PaymentGateway paymentGateway) {
this.mapper = mapper;
this.paymentGateway = paymentGateway;
}
// 판매 상품 목록
@Transactional(readOnly = true)
public List<TicketProductDto> listProducts(String eventId) {
if (eventId == null || eventId.isBlank()) {
throw new ApiException(ErrorCode.VALIDATION, "행사 식별자가 필요합니다.");
}
return mapper.findProducts(eventId).stream().map(TicketService::toProduct).toList();
}
// 예매(주문)
@Transactional
public OrderResultDto createOrder(String eventId, OrderRequest req) {
if (eventId == null || eventId.isBlank()) {
throw new ApiException(ErrorCode.VALIDATION, "행사 식별자가 필요합니다.");
}
if (req.agreePrivacy() == null || !req.agreePrivacy()) {
throw new ApiException(ErrorCode.VALIDATION, "개인정보 수집·이용 동의가 필요합니다.");
}
int qty = req.qty() == null ? 0 : req.qty();
if (qty < 1 || qty > ABS_MAX_QTY) {
throw new ApiException(ErrorCode.VALIDATION, "예매 수량이 올바르지 않습니다.");
}
Map<String, Object> product = mapper.findProduct(req.productId());
if (product == null || !eventId.equals(str(product.get("eventId")))) {
throw new ApiException(ErrorCode.NOT_FOUND, "선택한 입장권을 찾을 수 없습니다.");
}
if (!bool(product.get("onSale"))) {
throw new ApiException(ErrorCode.CONFLICT, "판매 기간이 아닌 입장권입니다.");
}
int maxPerOrder = toInt(product.get("maxPerOrder"), ABS_MAX_QTY);
if (qty > maxPerOrder) {
throw new ApiException(ErrorCode.VALIDATION,
"1회 최대 " + maxPerOrder + "매까지 예매할 수 있습니다.");
}
// 재고 차감(조건부 UPDATE) 실패 매진. 이후 어떤 예외든 트랜잭션 롤백으로 재고 원복.
int updated = mapper.decrementStock(req.productId(), qty);
if (updated != 1) {
throw new ApiException(ErrorCode.CONFLICT, "잔여 수량이 부족합니다.");
}
long unitPrice = toLong(product.get("price"), 0);
long total = unitPrice * qty;
String productName = str(product.get("name"));
String orderNo = generateOrderNo();
String payMethod = normalizeMethod(req.payMethod(), total);
// Mock PG 승인 실패 예외 트랜잭션 롤백(재고 원복).
PaymentGateway.PaymentResult pay = paymentGateway.authorize(payMethod, total, orderNo);
if (!pay.approved()) {
throw new ApiException(ErrorCode.CONFLICT, "결제 승인에 실패했습니다. 다시 시도해 주세요.");
}
String orderId = "to-" + UUID.randomUUID();
String nameMasked = Masking.maskName(req.buyerName());
String contactMasked = Masking.maskPhone(req.buyerContact());
String emailMasked = Masking.maskEmail(req.buyerEmail());
String contactHash = sha256Hex(digitsOnly(req.buyerContact()));
Map<String, Object> order = new java.util.HashMap<>();
order.put("id", orderId);
order.put("orderNo", orderNo);
order.put("eventId", eventId);
order.put("productId", req.productId());
order.put("productName", productName);
order.put("qty", qty);
order.put("unitPrice", unitPrice);
order.put("totalAmount", total);
order.put("status", "PAID");
order.put("payMethod", payMethod);
order.put("payApprovalNo", pay.approvalNo());
order.put("buyerNameMasked", nameMasked);
order.put("buyerContactMasked", contactMasked);
order.put("buyerEmailMasked", emailMasked);
order.put("buyerContactHash", contactHash);
mapper.insertOrder(order);
List<IssuedTicketDto> tickets = new ArrayList<>(qty);
for (int i = 1; i <= qty; i++) {
String code = generateTicketCode(orderNo, i);
Map<String, Object> issue = new java.util.HashMap<>();
issue.put("id", "ti-" + UUID.randomUUID());
issue.put("orderId", orderId);
issue.put("ticketCode", code);
issue.put("seq", i);
mapper.insertIssue(issue);
tickets.add(new IssuedTicketDto(code, i, false));
}
return new OrderResultDto(orderNo, "PAID", eventId, productName, qty, unitPrice, total,
payMethod, pay.approvalNo(), nameMasked, contactMasked, emailMasked, null, tickets);
}
// 조회(주문번호 + 연락처)
@Transactional(readOnly = true)
public LookupResultDto lookup(String orderNo, String contact) {
if (orderNo == null || orderNo.isBlank() || contact == null || contact.isBlank()) {
throw new ApiException(ErrorCode.VALIDATION, "예매번호와 연락처를 입력해 주세요.");
}
String hash = sha256Hex(digitsOnly(contact));
Map<String, Object> o = mapper.findOrderForLookup(orderNo.trim(), hash);
if (o == null) {
// 존재 여부 노출 금지 불일치도 NOT_FOUND 통일.
throw new ApiException(ErrorCode.NOT_FOUND, "일치하는 예매 내역이 없습니다. 예매번호와 연락처를 확인해 주세요.");
}
List<IssuedTicketDto> tickets = mapper.findIssuesByOrder(str(o.get("id"))).stream()
.map(m -> new IssuedTicketDto(str(m.get("ticketCode")), toInt(m.get("seq"), 0), bool(m.get("used"))))
.toList();
return new LookupResultDto(
str(o.get("orderNo")), str(o.get("status")), str(o.get("eventId")), str(o.get("productName")),
toInt(o.get("qty"), 0), toLong(o.get("unitPrice"), 0), toLong(o.get("totalAmount"), 0),
str(o.get("payMethod")), str(o.get("buyerNameMasked")), str(o.get("buyerContactMasked")),
str(o.get("buyerEmailMasked")), str(o.get("orderedAt")), str(o.get("paidAt")), tickets);
}
// helpers
private String generateOrderNo() {
int year = java.time.Year.now().getValue();
for (int attempt = 0; attempt < 6; attempt++) {
String no = "KTX-" + year + "-" + String.format("%06d", random.nextInt(1_000_000));
if (mapper.countOrderNo(no) == 0) {
return no;
}
}
// 극히 드문 충돌 UUID 조각으로 확정 유니크.
return "KTX-" + year + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
private String generateTicketCode(String orderNo, int seq) {
String tail = orderNo.length() >= 6 ? orderNo.substring(orderNo.length() - 6) : orderNo;
String rand = HexFormat.of().formatHex(randomBytes()).substring(0, 4).toUpperCase();
return "TK-" + tail + "-" + String.format("%02d", seq) + "-" + rand;
}
private byte[] randomBytes() {
byte[] b = new byte[3];
random.nextBytes(b);
return b;
}
private static String normalizeMethod(String method, long total) {
if (total == 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";
};
}
private static String digitsOnly(String s) {
return s == null ? "" : s.replaceAll("[^0-9]", "");
}
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 (NoSuchAlgorithmException e) {
throw new ApiException(ErrorCode.INTERNAL);
}
}
private static TicketProductDto toProduct(Map<String, Object> m) {
return new TicketProductDto(
str(m.get("id")), str(m.get("eventId")), str(m.get("code")), str(m.get("kind")),
str(m.get("name")), str(m.get("description")), toLong(m.get("price"), 0), str(m.get("currency")),
str(m.get("saleStart")), str(m.get("saleEnd")), toInt(m.get("totalQty"), 0),
Math.max(0, toInt(m.get("remaining"), 0)), toInt(m.get("maxPerOrder"), 0),
bool(m.get("onSale")), bool(m.get("soldOut")), toInt(m.get("sortOrder"), 0));
}
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 long toLong(Object o, long def) {
return (o instanceof Number n) ? n.longValue() : def;
}
}

View File

@ -0,0 +1,89 @@
package com.zioinfo.kintex.ticket.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
/**
* 입장권(티켓) 도메인 DTO 모음 (M10 관람객 · M9 정산). 공개 경로.
* <p>보안(§0-3): 응답 어디에도 구매자 PII 원문을 담지 않는다 마스킹 필드만 노출.
*/
public final class TicketDtos {
private TicketDtos() {
}
/** 판매 상품 카드 — 잔여수량·판매기간은 서버 권위. */
public record TicketProductDto(
String id,
String eventId,
String code,
String kind,
String name,
String description,
long price,
String currency,
String saleStart,
String saleEnd,
int totalQty,
int remaining,
int maxPerOrder,
boolean onSale,
boolean soldOut,
int sortOrder) {
}
/** 예매 요청 — 단일 상품 + 수량. */
public record OrderRequest(
@NotBlank String productId,
@NotNull @Min(1) @Max(100) Integer qty,
@NotBlank String buyerName,
@NotBlank String buyerContact,
String buyerEmail,
@NotNull Boolean agreePrivacy,
String payMethod) {
}
/** 발권된 개별 티켓. */
public record IssuedTicketDto(String ticketCode, int seq, boolean used) {
}
/** 예매 결과 — 주문번호 + 발권 티켓. 구매자는 마스킹만. */
public record OrderResultDto(
String orderNo,
String status,
String eventId,
String productName,
int qty,
long unitPrice,
long totalAmount,
String payMethod,
String approvalNo,
String buyerNameMasked,
String buyerContactMasked,
String buyerEmailMasked,
String orderedAt,
List<IssuedTicketDto> tickets) {
}
/** 예매 조회 결과 — 주문번호 + 연락처 대조 성공 시. PII 마스킹만. */
public record LookupResultDto(
String orderNo,
String status,
String eventId,
String productName,
int qty,
long unitPrice,
long totalAmount,
String payMethod,
String buyerNameMasked,
String buyerContactMasked,
String buyerEmailMasked,
String orderedAt,
String paidAt,
List<IssuedTicketDto> tickets) {
}
}

View File

@ -0,0 +1,30 @@
package com.zioinfo.kintex.ticket.payment;
import org.springframework.stereotype.Component;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* Mock 결제 게이트웨이(기본 구현) 외부 PG 미연동 시뮬레이션.
* <p>정상 승인 + 승인번호 생성. 음수 금액 비정상은 거절로 시뮬레이션한다.
* <p>운영 전환 PG 어댑터 {@code @Component} 대체하고 빈은 제거/비활성화한다.
*/
@Component
public class MockPaymentGateway implements PaymentGateway {
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private final SecureRandom random = new SecureRandom();
@Override
public PaymentResult authorize(String method, long amount, String orderNo) {
if (amount < 0) {
return new PaymentResult(false, null, "결제 금액이 올바르지 않습니다.");
}
String approvalNo = "MOCK-" + LocalDateTime.now().format(TS) + "-"
+ String.format("%06d", random.nextInt(1_000_000));
String label = amount == 0 ? "무료 예매 확정" : "결제 승인 완료(모의)";
return new PaymentResult(true, approvalNo, label);
}
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.kintex.ticket.payment;
/**
* 결제 게이트웨이 어댑터(GUARDiA 표준 · Mall 패턴) 외부 PG 실호출 금지.
* 운영 전환 인터페이스의 구현체를 주입한다(현재 기본은 {@link MockPaymentGateway}).
*/
public interface PaymentGateway {
/** 승인 결과 — 승인번호는 성공 시에만 채워진다(민감 카드정보 미포함). */
record PaymentResult(boolean approved, String approvalNo, String message) {
}
/**
* 결제 승인 요청(시뮬레이션). 금액 0(무료권) 승인 처리한다.
*
* @param method 결제수단(card/easy/bank/free)
* @param amount 승인 금액()
* @param orderNo 주문번호(승인번호 상관용)
*/
PaymentResult authorize(String method, long amount, String orderNo);
}

View File

@ -0,0 +1,154 @@
-- ============================================================================
-- V50 — 입장권(티켓) 도메인: 상품 · 주문 · 발권 (M10 관람객 · M9 정산)
-- ============================================================================
-- 공개(비로그인) 입장권 예매/조회 백엔드. GAP G-02 해소.
-- 표준: tenant_id = 'KINTEX'(대문자), 복합 PK 선두. FK 최소화(컬럼만, 제약 없음).
-- 재고 차감은 주문 시점 트랜잭션 — 조건부 UPDATE(sold_qty += qty WHERE total-sold >= qty)로 oversell 방지.
-- PII 최소화(§0-3): 주문에는 구매자 원문을 저장하지 않는다.
-- buyer_name_masked(홍*동)·buyer_contact_masked(010-****-1234)·buyer_email_masked(jo***@ex***.com) 만 저장,
-- 조회 대조는 buyer_contact_hash = sha256(digits(contact)) 만으로 수행(원문 미보관).
-- ============================================================================
-- ── 1) 티켓 상품 ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS ticket_product (
tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX',
id varchar(64) NOT NULL,
event_id varchar(64) NOT NULL,
code varchar(48) NOT NULL,
kind varchar(24) NOT NULL DEFAULT 'GENERAL', -- GENERAL/STUDENT/GROUP/VIP/BUYER
name varchar(160) NOT NULL,
description varchar(400),
price numeric(12,0) NOT NULL DEFAULT 0,
currency varchar(8) NOT NULL DEFAULT 'KRW',
sale_start timestamptz,
sale_end timestamptz,
total_qty integer NOT NULL DEFAULT 0,
sold_qty integer NOT NULL DEFAULT 0,
max_per_order integer NOT NULL DEFAULT 10,
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_ticket_product PRIMARY KEY (tenant_id, id),
CONSTRAINT ck_ticket_product_sold CHECK (sold_qty >= 0 AND sold_qty <= total_qty)
);
CREATE INDEX IF NOT EXISTS ix_ticket_product_event ON ticket_product (tenant_id, event_id, status, sort_order);
-- ── 2) 티켓 주문(예매) ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS ticket_order (
tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX',
id varchar(64) NOT NULL,
order_no varchar(40) NOT NULL,
event_id varchar(64) NOT NULL,
product_id varchar(64) NOT NULL,
product_name varchar(160) NOT NULL, -- 주문 시점 스냅샷
qty integer NOT NULL,
unit_price numeric(12,0) NOT NULL,
total_amount numeric(12,0) NOT NULL,
status varchar(16) NOT NULL DEFAULT 'PENDING', -- PENDING/PAID/CANCELLED
pay_method varchar(24), -- card/easy/bank/free
pay_approval_no varchar(64),
buyer_name_masked varchar(80),
buyer_contact_masked varchar(40),
buyer_email_masked varchar(160),
buyer_contact_hash varchar(64), -- sha256(digits(contact)) hex
ordered_at timestamptz NOT NULL DEFAULT now(),
paid_at timestamptz,
cancelled_at timestamptz,
CONSTRAINT pk_ticket_order PRIMARY KEY (tenant_id, id),
CONSTRAINT uq_ticket_order_no UNIQUE (order_no),
CONSTRAINT ck_ticket_order_qty CHECK (qty > 0)
);
CREATE INDEX IF NOT EXISTS ix_ticket_order_lookup ON ticket_order (order_no, buyer_contact_hash);
CREATE INDEX IF NOT EXISTS ix_ticket_order_event ON ticket_order (tenant_id, event_id, status);
-- ── 3) 발권(주문 하위 개별 티켓) ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS ticket_issue (
tenant_id varchar(32) NOT NULL DEFAULT 'KINTEX',
id varchar(64) NOT NULL,
order_id varchar(64) NOT NULL,
ticket_code varchar(48) NOT NULL,
seq integer NOT NULL,
used boolean NOT NULL DEFAULT false,
used_at timestamptz,
issued_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_ticket_issue PRIMARY KEY (tenant_id, id),
CONSTRAINT uq_ticket_issue_code UNIQUE (ticket_code)
);
CREATE INDEX IF NOT EXISTS ix_ticket_issue_order ON ticket_issue (tenant_id, order_id, seq);
-- ============================================================================
-- 시드 — 데모 해소 행사 e-2026-live 를 축으로 폐루프(빈 화면 방지). 멱등(ON CONFLICT DO NOTHING).
-- ============================================================================
-- 상품: e-2026-live 3종(일반/학생/단체) + e-2026-smf 2종 + bd-e-001 1종
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-live-gen','e-2026-live','LIVE-GEN','GENERAL','일반권',
'일반 관람객 및 개인 참가자 · 전 전시장 입장', 15000, now()-interval '30 day', now()+interval '40 day',
5000, 1242, 10, 'active', 0),
('KINTEX','tp-live-stu','e-2026-live','LIVE-STU','STUDENT','학생/군인 할인권',
'학생·군인 대상 할인(현장 신분 확인)', 8000, now()-interval '30 day', now()+interval '40 day',
2000, 321, 6, 'active', 1),
('KINTEX','tp-live-grp','e-2026-live','LIVE-GRP','GROUP','단체권 (10매 이상)',
'기업·기관 단체 관람 전용', 9000, now()-interval '30 day', now()+interval '40 day',
3000, 160, 100, 'active', 2),
('KINTEX','tp-smf-gen','e-2026-smf','SMF-GEN','GENERAL','일반권',
'스마트팩토리 코리아 일반 입장권', 12000, now()-interval '10 day', now()+interval '60 day',
4000, 512, 10, 'active', 0),
('KINTEX','tp-smf-vip','e-2026-smf','SMF-VIP','VIP','VIP 올패스',
'전 세션 + VIP 라운지 이용', 50000, now()-interval '10 day', now()+interval '60 day',
200, 47, 4, 'active', 1),
('KINTEX','tp-bd001-gen','bd-e-001','BD001-GEN','GENERAL','일반권',
'기본 입장권', 10000, now()-interval '5 day', now()+interval '90 day',
1500, 88, 10, 'active', 0)
ON CONFLICT (tenant_id, id) DO NOTHING;
-- 데모 주문(PAID) — PII 원문 미보관. 조회 대조 hash = sha256(연락처 숫자만).
-- 조회 테스트: 예매번호 + 연락처(하이픈 무관)로 lookup.
-- KTX-2026-500001 / 010-1234-5678, KTX-2026-500002 / 010-2222-3333, KTX-2026-500003 / 010-9999-0000
INSERT INTO ticket_order
(tenant_id, id, order_no, event_id, product_id, product_name, qty, unit_price, total_amount,
status, pay_method, pay_approval_no, buyer_name_masked, buyer_contact_masked, buyer_email_masked,
buyer_contact_hash, ordered_at, paid_at) VALUES
('KINTEX','to-live-500001','KTX-2026-500001','e-2026-live','tp-live-gen','일반권',
2, 15000, 30000, 'PAID', 'card', 'MOCK-DEMO-500001',
'김*준','010-****-5678','mi***@example.com',
encode(sha256(convert_to('01012345678','UTF8')),'hex'), now()-interval '3 day', now()-interval '3 day'),
('KINTEX','to-live-500002','KTX-2026-500002','e-2026-live','tp-live-stu','학생/군인 할인권',
1, 8000, 8000, 'PAID', 'easy', 'MOCK-DEMO-500002',
'이*연','010-****-3333','se***@example.com',
encode(sha256(convert_to('01022223333','UTF8')),'hex'), now()-interval '2 day', now()-interval '2 day'),
('KINTEX','to-live-500003','KTX-2026-500003','e-2026-live','tp-live-grp','단체권 (10매 이상)',
10, 9000, 90000, 'PAID', 'bank', 'MOCK-DEMO-500003',
'박*윤','010-****-0000','do***@example.com',
encode(sha256(convert_to('01099990000','UTF8')),'hex'), now()-interval '1 day', now()-interval '1 day')
ON CONFLICT (tenant_id, id) DO NOTHING;
-- 발권 — 주문 수량만큼 생성(generate_series, 멱등). ticket_code 유니크.
INSERT INTO ticket_issue (tenant_id, id, order_id, ticket_code, seq)
SELECT 'KINTEX',
'ti-500001-' || lpad(g::text, 2, '0'),
'to-live-500001',
'TK-500001-' || lpad(g::text, 2, '0'),
g
FROM generate_series(1, 2) g
ON CONFLICT (tenant_id, id) DO NOTHING;
INSERT INTO ticket_issue (tenant_id, id, order_id, ticket_code, seq)
SELECT 'KINTEX',
'ti-500002-' || lpad(g::text, 2, '0'),
'to-live-500002',
'TK-500002-' || lpad(g::text, 2, '0'),
g
FROM generate_series(1, 1) g
ON CONFLICT (tenant_id, id) DO NOTHING;
INSERT INTO ticket_issue (tenant_id, id, order_id, ticket_code, seq)
SELECT 'KINTEX',
'ti-500003-' || lpad(g::text, 2, '0'),
'to-live-500003',
'TK-500003-' || lpad(g::text, 2, '0'),
g
FROM generate_series(1, 10) g
ON CONFLICT (tenant_id, id) DO NOTHING;

View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
TicketMapper — 입장권 상품·주문·발권 영속 (V50). tenant_id='KINTEX' 고정.
보안(§0-3): 조회에서 구매자 원문(이름·연락처·이메일) 은 SELECT 하지 않는다 — 마스킹 컬럼/해시만 다룬다.
동시성: decrementStock 는 조건부 UPDATE 로 oversell 를 원자적으로 차단한다.
-->
<mapper namespace="com.zioinfo.kintex.ticket.TicketMapper">
<!-- 판매 상품 목록 — remaining = total-sold, onSale/soldOut 은 서버 계산. -->
<select id="findProducts" resultType="map">
SELECT id,
event_id AS "eventId",
code,
kind,
name,
description,
price,
currency,
to_char(sale_start, 'YYYY-MM-DD"T"HH24:MI:SS') AS "saleStart",
to_char(sale_end, 'YYYY-MM-DD"T"HH24:MI:SS') AS "saleEnd",
total_qty AS "totalQty",
(total_qty - sold_qty) AS "remaining",
max_per_order AS "maxPerOrder",
CASE WHEN status = 'active'
AND (sale_start IS NULL OR sale_start <= now())
AND (sale_end IS NULL OR sale_end >= now())
THEN true ELSE false END AS "onSale",
CASE WHEN (total_qty - sold_qty) <= 0 THEN true ELSE false END AS "soldOut",
sort_order AS "sortOrder"
FROM ticket_product
WHERE tenant_id = 'KINTEX'
AND event_id = #{eventId}
AND status = 'active'
ORDER BY sort_order, id
</select>
<!-- 단일 상품(주문 검증) — 판매기간·상태·잔여 판정에 필요한 원시값 포함. -->
<select id="findProduct" resultType="map">
SELECT id,
event_id AS "eventId",
name,
kind,
price,
currency,
max_per_order AS "maxPerOrder",
status,
(total_qty - sold_qty) AS "remaining",
CASE WHEN status = 'active'
AND (sale_start IS NULL OR sale_start <= now())
AND (sale_end IS NULL OR sale_end >= now())
THEN true ELSE false END AS "onSale"
FROM ticket_product
WHERE tenant_id = 'KINTEX'
AND id = #{productId}
</select>
<!-- 조건부 재고 차감 — 판매중 + 잔여 충분할 때만 1행 갱신(oversell 차단). -->
<update id="decrementStock">
UPDATE ticket_product
SET sold_qty = sold_qty + #{qty}
WHERE tenant_id = 'KINTEX'
AND id = #{productId}
AND status = 'active'
AND (sale_start IS NULL OR sale_start &lt;= now())
AND (sale_end IS NULL OR sale_end &gt;= now())
AND (total_qty - sold_qty) &gt;= #{qty}
</update>
<select id="countOrderNo" resultType="int">
SELECT COUNT(*) FROM ticket_order WHERE order_no = #{orderNo}
</select>
<insert id="insertOrder">
INSERT INTO ticket_order
(tenant_id, id, order_no, event_id, product_id, product_name, qty, unit_price, total_amount,
status, pay_method, pay_approval_no, buyer_name_masked, buyer_contact_masked, buyer_email_masked,
buyer_contact_hash, ordered_at, paid_at)
VALUES
('KINTEX', #{id}, #{orderNo}, #{eventId}, #{productId}, #{productName}, #{qty}, #{unitPrice},
#{totalAmount}, #{status}, #{payMethod}, #{payApprovalNo}, #{buyerNameMasked},
#{buyerContactMasked}, #{buyerEmailMasked}, #{buyerContactHash}, now(), now())
</insert>
<insert id="insertIssue">
INSERT INTO ticket_issue (tenant_id, id, order_id, ticket_code, seq)
VALUES ('KINTEX', #{id}, #{orderId}, #{ticketCode}, #{seq})
</insert>
<!-- 조회 — 주문번호 + 연락처 해시 둘 다 일치. 원문 PII 컬럼은 SELECT 하지 않음. -->
<select id="findOrderForLookup" resultType="map">
SELECT id,
order_no AS "orderNo",
event_id AS "eventId",
product_name AS "productName",
qty,
unit_price AS "unitPrice",
total_amount AS "totalAmount",
status,
pay_method AS "payMethod",
buyer_name_masked AS "buyerNameMasked",
buyer_contact_masked AS "buyerContactMasked",
buyer_email_masked AS "buyerEmailMasked",
to_char(ordered_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "orderedAt",
to_char(paid_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "paidAt"
FROM ticket_order
WHERE order_no = #{orderNo}
AND buyer_contact_hash = #{contactHash}
LIMIT 1
</select>
<select id="findIssuesByOrder" resultType="map">
SELECT ticket_code AS "ticketCode",
seq,
used
FROM ticket_issue
WHERE tenant_id = 'KINTEX'
AND order_id = #{orderId}
ORDER BY seq
</select>
</mapper>

View File

@ -686,7 +686,22 @@
"cancelModalAria": "Confirm booking cancellation",
"cancelModalTitle": "Cancel this booking?",
"cancelModalDesc": "A 100% refund (₩40,000) applies based on D-15. Refunds are processed to the original payment method via the PG provider, with actual refunds upon live integration.",
"cancelConfirm": "Cancel"
"cancelConfirm": "Cancel",
"remaining": "{{n}} left",
"saleClosed": "Not on sale",
"free": "Free",
"emptyProducts": "No tickets are currently on sale.",
"selectPrompt": "Please select a ticket type.",
"errName": "Please enter your name.",
"errContact": "Please enter your contact number.",
"errPrivacy": "Please agree to the collection and use of personal information.",
"processing": "Processing…",
"approvalNo": "Approval No.",
"ticketCodes": "Issued tickets",
"lookContact": "Contact",
"lookContactPh": "Contact used when booking",
"lookupHint": "Enter your booking number and contact to look up.",
"notFound": "No matching booking found. Please check your booking number and contact."
},
"login": {
"title": "Log In",

View File

@ -686,7 +686,22 @@
"cancelModalAria": "予約キャンセルの確認",
"cancelModalTitle": "予約をキャンセルしますか?",
"cancelModalDesc": "D-15 基準で100%返金(₩40,000)が適用されます。返金は元の決済手段へ PG 社を通じて処理され、本サービス連携時に実際の返金が行われます。",
"cancelConfirm": "キャンセルする"
"cancelConfirm": "キャンセルする",
"remaining": "残り{{n}}枚",
"saleClosed": "販売期間外",
"free": "無料",
"emptyProducts": "現在販売中のチケットはありません。",
"selectPrompt": "券種を選択してください。",
"errName": "お名前を入力してください。",
"errContact": "連絡先を入力してください。",
"errPrivacy": "個人情報の収集・利用に同意してください。",
"processing": "処理中…",
"approvalNo": "承認番号",
"ticketCodes": "発券チケット",
"lookContact": "連絡先",
"lookContactPh": "予約時に入力した連絡先",
"lookupHint": "予約番号と連絡先を入力して照会してください。",
"notFound": "一致する予約が見つかりません。予約番号と連絡先をご確認ください。"
},
"login": {
"title": "ログイン",

View File

@ -686,7 +686,22 @@
"cancelModalAria": "예매 취소 확인",
"cancelModalTitle": "예매를 취소하시겠습니까?",
"cancelModalDesc": "D-15 기준 100% 환불(₩40,000)이 적용됩니다. 환불은 원결제수단으로 PG사를 통해 처리되며, 실서비스 연동 시 실제 환불이 진행됩니다.",
"cancelConfirm": "취소하기"
"cancelConfirm": "취소하기",
"remaining": "잔여 {{n}}매",
"saleClosed": "판매기간 아님",
"free": "무료",
"emptyProducts": "현재 판매 중인 입장권이 없습니다.",
"selectPrompt": "권종을 선택해 주세요.",
"errName": "이름을 입력해 주세요.",
"errContact": "연락처를 입력해 주세요.",
"errPrivacy": "개인정보 수집·이용에 동의해 주세요.",
"processing": "처리 중…",
"approvalNo": "승인번호",
"ticketCodes": "발권 티켓",
"lookContact": "연락처",
"lookContactPh": "예매 시 입력한 연락처",
"lookupHint": "예매번호와 연락처를 입력해 조회하세요.",
"notFound": "일치하는 예매 내역이 없습니다. 예매번호와 연락처를 확인해 주세요."
},
"login": {
"title": "로그인",

View File

@ -686,7 +686,22 @@
"cancelModalAria": "确认取消预订",
"cancelModalTitle": "确定取消此预订吗?",
"cancelModalDesc": "按 D-15 标准适用100%退款(₩40,000)。退款将通过 PG 公司按原支付方式处理,正式服务接入时进行实际退款。",
"cancelConfirm": "取消"
"cancelConfirm": "取消",
"remaining": "剩余{{n}}张",
"saleClosed": "非售票期",
"free": "免费",
"emptyProducts": "当前没有在售门票。",
"selectPrompt": "请选择票种。",
"errName": "请输入姓名。",
"errContact": "请输入联系方式。",
"errPrivacy": "请同意收集和使用个人信息。",
"processing": "处理中…",
"approvalNo": "批准号",
"ticketCodes": "已出票",
"lookContact": "联系方式",
"lookContactPh": "预订时填写的联系方式",
"lookupHint": "请输入预订号和联系方式进行查询。",
"notFound": "未找到匹配的预订。请核对预订号和联系方式。"
},
"login": {
"title": "登录",

View File

@ -1,12 +1,19 @@
/*
* SCR-P7 (M10·M9) .
* design.md §3B. () + ·(manage) .
* PG ( UI , PG ). PII .
* 데이터: 티켓·· API .
* design.md §3B. () + (+).
* 라이브: GET /api/public/tickets/{eventId}, POST .../orders, GET .../lookup (ticketApi).
* Mock PG( ) . (PII ).
* 3(//) . NETWORK/NOT_FOUND ( ).
*/
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { useMutation, useQuery } from '@tanstack/react-query';
import { PublicShell } from './PublicShell';
import { ticketApi, type TicketProduct } from './ticketApi';
import { errorMessage } from './publicFormat';
import { ApiRequestError } from '../../api/client';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import {
IconTicket,
IconCalendar,
@ -18,31 +25,27 @@ import {
IconCheckCircle,
IconQr,
IconArrowRight,
IconSparkles,
IconPerson,
} from './publicIcons';
interface TicketType {
key: string;
nameKey: string;
descKey: string;
price: number;
priceKey: string;
badge?: { labelKey: string; kind: 'ai' | 'off' };
soldout?: boolean;
accent: string;
}
const TICKETS: TicketType[] = [
{ key: 'general', nameKey: 'ticket.type.general', descKey: 'ticket.type.generalDesc', price: 0, priceKey: 'ticket.type.generalPrice', accent: '#0066b3' },
{ key: 'buyer', nameKey: 'ticket.type.buyer', descKey: 'ticket.type.buyerDesc', price: 0, priceKey: 'ticket.type.buyerPrice', badge: { labelKey: 'ticket.aiOptimized', kind: 'ai' }, accent: '#6d4aff' },
{ key: 'group', nameKey: 'ticket.type.group', descKey: 'ticket.type.groupDesc', price: 9000, priceKey: 'ticket.type.groupPrice', badge: { labelKey: 'ticket.off10', kind: 'off' }, accent: '#0e8a5f' },
{ key: 'vip', nameKey: 'ticket.type.vip', descKey: 'ticket.type.vipDesc', price: 50000, priceKey: 'ticket.type.vipPrice', soldout: true, accent: '#101828' },
];
const DEFAULT_EVENT_ID = 'e-2026-live';
const STEP_KEYS = ['ticket.steps.s1', 'ticket.steps.s2', 'ticket.steps.s3', 'ticket.steps.s4'];
const won = (n: number) => `${n.toLocaleString('ko-KR')}`;
/** NETWORK/NOT_FOUND/NOT_IMPLEMENTED 강등 시에만 샘플 폴백을 허용한다. */
function isDegradable(e: unknown): boolean {
return (
e instanceof ApiRequestError &&
(e.code === 'NETWORK' || e.code === 'NOT_FOUND' || e.code === 'NOT_IMPLEMENTED' || e.httpStatus === 404)
);
}
const SAMPLE_PRODUCTS: TicketProduct[] = [
{ id: 'general', eventId: '', code: 'GEN', kind: 'GENERAL', name: '일반권', description: '일반 관람객 및 개인 참가자', price: 15000, currency: 'KRW', saleStart: null, saleEnd: null, totalQty: 5000, remaining: 3758, maxPerOrder: 10, onSale: true, soldOut: false, sortOrder: 0 },
{ id: 'student', eventId: '', code: 'STU', kind: 'STUDENT', name: '학생/군인 할인권', description: '학생·군인 대상 할인(현장 신분 확인)', price: 8000, currency: 'KRW', saleStart: null, saleEnd: null, totalQty: 2000, remaining: 1679, maxPerOrder: 6, onSale: true, soldOut: false, sortOrder: 1 },
{ id: 'group', eventId: '', code: 'GRP', kind: 'GROUP', name: '단체권 (10매 이상)', description: '기업·기관 단체 관람 전용', price: 9000, currency: 'KRW', saleStart: null, saleEnd: null, totalQty: 3000, remaining: 2840, maxPerOrder: 100, onSale: true, soldOut: false, sortOrder: 2 },
];
function EventBanner() {
const { t } = useTranslation();
return (
@ -68,59 +71,113 @@ function EventBanner() {
);
}
function PurchaseView() {
interface Selection {
id: string;
qty: number;
}
function PurchaseView({ eventId }: { eventId: string }) {
const { t } = useTranslation();
const [step, setStep] = useState(0);
const [qty, setQty] = useState<Record<string, number>>({});
const [sel, setSel] = useState<Selection | null>(null);
const [pay, setPay] = useState('card');
const [name, setName] = useState('');
const [contact, setContact] = useState('');
const [email, setEmail] = useState('');
const [agreePrivacy, setAgreePrivacy] = useState(false);
const [formError, setFormError] = useState('');
const setCount = (key: string, delta: number) =>
setQty((prev) => {
const v = Math.max(0, (prev[key] ?? 0) + delta);
return { ...prev, [key]: v };
});
const productsQ = useQuery({
queryKey: ['ticketProducts', eventId],
queryFn: () => ticketApi.listProducts(eventId),
retry: false,
});
const { subtotal, discount, total, count } = useMemo(() => {
let sub = 0;
let disc = 0;
let cnt = 0;
for (const t of TICKETS) {
const q = qty[t.key] ?? 0;
cnt += q;
if (t.key === 'group') {
const base = 10000 * q; // 정가 기준 예시
sub += base;
disc += base - t.price * q;
} else {
sub += t.price * q;
const degraded = productsQ.isError && isDegradable(productsQ.error);
const products: TicketProduct[] = degraded ? SAMPLE_PRODUCTS : (productsQ.data ?? []);
const orderMut = useMutation({
mutationFn: () =>
ticketApi.createOrder(eventId, {
productId: sel!.id,
qty: sel!.qty,
buyerName: name.trim(),
buyerContact: contact.trim(),
buyerEmail: email.trim() || undefined,
agreePrivacy,
payMethod: pay,
}),
onSuccess: () => setStep(3),
});
const selProduct = sel ? products.find((p) => p.id === sel.id) ?? null : null;
const total = sel && selProduct ? selProduct.price * sel.qty : 0;
const count = sel ? sel.qty : 0;
const isFree = total === 0 && count > 0;
const setCount = (p: TicketProduct, delta: number) => {
if (p.soldOut || !p.onSale) return;
setSel((prev) => {
if (prev && prev.id === p.id) {
const q = prev.qty + delta;
if (q <= 0) return null;
return { id: p.id, qty: Math.min(q, p.maxPerOrder) };
}
}
return { subtotal: sub, discount: disc, total: sub - disc, count: cnt };
}, [qty]);
if (delta > 0) return { id: p.id, qty: 1 };
return prev;
});
};
const isFree = total === 0;
const next = () => {
// 무료 권종만이면 결제 단계 자동 스킵
if (step === 1 && isFree) {
setStep(3);
const proceed = () => {
setFormError('');
if (step === 0) {
if (!sel || count === 0) {
setFormError(t('ticket.selectPrompt'));
return;
}
setStep(1);
return;
}
setStep((s) => Math.min(3, s + 1));
if (step === 1) {
if (!name.trim()) {
setFormError(t('ticket.errName'));
return;
}
if (!contact.trim()) {
setFormError(t('ticket.errContact'));
return;
}
if (!agreePrivacy) {
setFormError(t('ticket.errPrivacy'));
return;
}
if (isFree) {
orderMut.mutate();
return;
}
setStep(2);
return;
}
if (step === 2) {
orderMut.mutate();
}
};
const prev = () => {
if (step === 3 && isFree) {
const back = () => {
setFormError('');
if (step === 3) {
setStep(1);
return;
}
setStep((s) => Math.max(0, s - 1));
};
const result = orderMut.data;
return (
<>
<EventBanner />
{/* 스텝 인디케이터 */}
<div className="kxp-ticket__steps" role="list" aria-label={t('ticket.stepsAria')}>
{STEP_KEYS.map((l, i) => (
<span
@ -135,7 +192,6 @@ function PurchaseView() {
))}
</div>
{/* 로그인 배너 */}
<div className="kxp-loginbanner">
<IconInfo width={18} height={18} /> {t('ticket.loginBanner')}
<a href="#top">{t('ticket.loginLink')}</a>
@ -148,53 +204,79 @@ function PurchaseView() {
<section>
<h2 className="kxp-ticket__h2">
<IconTicket width={20} height={20} /> {t('ticket.h2Type')}
<span className="kxp-sample" style={{ marginLeft: 'auto' }}>
{t('ticket.sample')}
</span>
{degraded && (
<span className="kxp-sample" style={{ marginLeft: 'auto' }}>
{t('ticket.sample')}
</span>
)}
</h2>
<div className="kxp-tickrows">
{TICKETS.map((tk) => (
<div
key={tk.key}
className={`kxp-tickrow${tk.soldout ? ' kxp-tickrow--soldout' : ''}`}
style={{ borderLeftColor: tk.soldout ? undefined : tk.accent }}
>
<div className="kxp-tickrow__info">
<h3>
{t(tk.nameKey)}
{tk.badge && (
<span className={tk.badge.kind === 'ai' ? 'kxp-chip kxp-chip--ai' : 'kxp-chip'}>
{tk.badge.kind === 'ai' && <IconSparkles width={12} height={12} />}
{t(tk.badge.labelKey)}
</span>
)}
{tk.soldout && <span className="kxp-status kxp-status--soldout">{t('ticket.soldout')}</span>}
</h3>
<p>{t(tk.descKey)}</p>
<div className="kxp-tickrow__price">{t(tk.priceKey)}</div>
{productsQ.isLoading ? (
<div className="kxp-tickrows">
{[0, 1, 2].map((k) => (
<div key={k} className="kxp-tickrow">
<div className="kxp-tickrow__info">
<Skeleton height={18} width="40%" />
<Skeleton height={12} width="70%" />
</div>
</div>
<div className="kxp-stepper" aria-label={t('ticket.qtyAria', { name: t(tk.nameKey) })}>
<button
type="button"
aria-label={t('ticket.decrease')}
disabled={tk.soldout}
onClick={() => setCount(tk.key, -1)}
))}
</div>
) : productsQ.isError && !degraded ? (
<ErrorState message={errorMessage(productsQ.error)} onRetry={() => void productsQ.refetch()} />
) : products.length === 0 ? (
<EmptyState title={t('ticket.emptyProducts')} icon={<IconTicket width={28} height={28} />} />
) : (
<div className="kxp-tickrows">
{products.map((p) => {
const disabled = p.soldOut || !p.onSale;
const q = sel && sel.id === p.id ? sel.qty : 0;
return (
<div
key={p.id}
className={`kxp-tickrow${disabled ? ' kxp-tickrow--soldout' : ''}`}
style={{ borderLeftColor: disabled ? undefined : '#0066b3' }}
>
<IconMinus width={16} height={16} />
</button>
<span>{qty[tk.key] ?? 0}</span>
<button
type="button"
aria-label={t('ticket.increase')}
disabled={tk.soldout}
onClick={() => setCount(tk.key, 1)}
>
<IconPlus width={16} height={16} />
</button>
</div>
</div>
))}
</div>
<div className="kxp-tickrow__info">
<h3>
{p.name}
{p.soldOut && <span className="kxp-status kxp-status--soldout">{t('ticket.soldout')}</span>}
{!p.soldOut && !p.onSale && (
<span className="kxp-status kxp-status--soldout">{t('ticket.saleClosed')}</span>
)}
</h3>
{p.description && <p>{p.description}</p>}
<div className="kxp-tickrow__price">
{p.price === 0 ? t('ticket.free') : won(p.price)}
<span style={{ marginLeft: 10, fontSize: 11, color: 'var(--color-neutral-500)' }}>
{t('ticket.remaining', { n: Math.max(0, p.remaining) })}
</span>
</div>
</div>
<div className="kxp-stepper" aria-label={t('ticket.qtyAria', { name: p.name })}>
<button
type="button"
aria-label={t('ticket.decrease')}
disabled={disabled}
onClick={() => setCount(p, -1)}
>
<IconMinus width={16} height={16} />
</button>
<span>{q}</span>
<button
type="button"
aria-label={t('ticket.increase')}
disabled={disabled}
onClick={() => setCount(p, 1)}
>
<IconPlus width={16} height={16} />
</button>
</div>
</div>
);
})}
</div>
)}
</section>
)}
@ -208,20 +290,40 @@ function PurchaseView() {
<div className="kxp-formgrid kxp-formgrid--2">
<div className="kxp-field">
<label htmlFor="tk-name">{t('ticket.bookerName')}</label>
<input id="tk-name" className="kxp-input" placeholder={t('ticket.bookerNamePh')} />
<input
id="tk-name"
className="kxp-input"
placeholder={t('ticket.bookerNamePh')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="kxp-field">
<label htmlFor="tk-tel">{t('ticket.bookerTel')}</label>
<input id="tk-tel" className="kxp-input" type="tel" placeholder="010-0000-0000" />
<input
id="tk-tel"
className="kxp-input"
type="tel"
placeholder="010-0000-0000"
value={contact}
onChange={(e) => setContact(e.target.value)}
/>
</div>
<div className="kxp-field kxp-formgrid__full">
<label htmlFor="tk-email">{t('ticket.bookerEmail')}</label>
<input id="tk-email" className="kxp-input" type="email" placeholder="example@domain.com" />
<input
id="tk-email"
className="kxp-input"
type="email"
placeholder="example@domain.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
</div>
<div className="kxp-consent">
<label className="kxp-check">
<input type="checkbox" />
<input type="checkbox" checked={agreePrivacy} onChange={(e) => setAgreePrivacy(e.target.checked)} />
<span>
{t('ticket.consentPrivacy')} <span className="kxp-check__req">{t('ticket.required')}</span>
</span>
@ -266,25 +368,46 @@ function PurchaseView() {
)}
{/* Step 4: 완료 */}
{step === 3 && (
{step === 3 && result && (
<section className="kxp-tikdone">
<div className="kxp-done__ic" style={{ margin: '0 auto 16px' }}>
<IconCheckCircle width={40} height={40} />
</div>
<h2 className="kxp-regcard__title">{t('ticket.doneTitle')}</h2>
<div className="kxp-tikdone__no">{t('ticket.doneNo')}</div>
<div className="kxp-tikdone__no">{result.orderNo}</div>
<p className="kxp-regcard__sub">
{t('ticket.doneSub', { count, total: won(total) })}
{t('ticket.doneSub', { count: result.qty, total: won(result.totalAmount) })}
</p>
<div className="kxp-tikdone__qr">
<IconQr width={110} height={110} />
</div>
<span className="kxp-sample">{t('ticket.sampleReady')}</span>
<div className="kxp-formcard" style={{ textAlign: 'left', maxWidth: 420, margin: '4px auto 0' }}>
<div className="kxp-sumrow">
<span>{t('ticket.colType')}</span>
<span>{result.productName}</span>
</div>
{result.approvalNo && (
<div className="kxp-sumrow">
<span>{t('ticket.approvalNo')}</span>
<span>{result.approvalNo}</span>
</div>
)}
<div className="kxp-sumrow">
<span>{t('ticket.ticketCodes')}</span>
<span style={{ textAlign: 'right' }}>
{result.tickets.map((tk) => (
<span key={tk.ticketCode} style={{ display: 'block', fontFamily: 'monospace' }}>
{tk.ticketCode}
</span>
))}
</span>
</div>
</div>
<div className="kxp-tikdone__actions">
<a className="kxp-btn kxp-btn--outline" href="#top">
{t('ticket.walletBtn')}
</a>
<a className="kxp-btn kxp-btn--primary" href="#top">
<a className="kxp-btn kxp-btn--primary" href="/tickets/lookup">
{t('ticket.manageBtn')} <IconArrowRight width={18} height={18} />
</a>
</div>
@ -302,36 +425,42 @@ function PurchaseView() {
</div>
<div className="kxp-sumrow">
<span>{t('ticket.subtotal')}</span>
<span>{won(subtotal)}</span>
</div>
<div className="kxp-sumrow kxp-sumrow--discount">
<span>{t('ticket.discount')}</span>
<span>-{won(discount)}</span>
<span>{won(total)}</span>
</div>
<div className="kxp-sumtotal">
<small>{t('ticket.finalTotal')}</small>
<strong>{won(total)}</strong>
<strong>{isFree ? t('ticket.free') : won(total)}</strong>
</div>
{(formError || orderMut.isError) && (
<p className="kxp-formerror" role="alert" style={{ color: 'var(--color-danger-600, #d92d20)', fontSize: 12, margin: '10px 0 0' }}>
{formError || errorMessage(orderMut.error)}
</p>
)}
<button
type="button"
className="kxp-btn kxp-btn--primary kxp-btn--block kxp-btn--lg"
disabled={count === 0}
onClick={next}
disabled={count === 0 || orderMut.isPending}
onClick={proceed}
>
{step === 0
? t('ticket.nextStep')
: step === 1
? isFree
? t('ticket.freeDone')
: t('ticket.pay')
: t('ticket.payProceed')}
{orderMut.isPending
? t('ticket.processing')
: step === 0
? t('ticket.nextStep')
: step === 1
? isFree
? t('ticket.freeDone')
: t('ticket.pay')
: t('ticket.payProceed')}
</button>
{step > 0 && (
<button
type="button"
className="kxp-btn kxp-btn--outline kxp-btn--block"
style={{ marginTop: 10 }}
onClick={prev}
onClick={back}
disabled={orderMut.isPending}
>
{t('common.actions.prev')}
</button>
@ -348,8 +477,24 @@ function PurchaseView() {
function ManageView() {
const { t } = useTranslation();
const [looked, setLooked] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [orderNo, setOrderNo] = useState('');
const [contact, setContact] = useState('');
const [formError, setFormError] = useState('');
const lookupMut = useMutation({
mutationFn: () => ticketApi.lookup(orderNo.trim(), contact.trim()),
});
const submit = () => {
setFormError('');
if (!orderNo.trim() || !contact.trim()) {
setFormError(t('ticket.lookupHint'));
return;
}
lookupMut.mutate();
};
const r = lookupMut.data;
return (
<>
@ -360,47 +505,58 @@ function ManageView() {
</p>
</header>
{/* 조회 폼 */}
<div className="kxp-manage__lookup">
<div className="kxp-manage__lookrow">
<div className="kxp-field">
<label htmlFor="mg-no">{t('ticket.lookNo')}</label>
<input id="mg-no" className="kxp-input" placeholder={t('ticket.lookNoPh')} />
<input
id="mg-no"
className="kxp-input"
placeholder={t('ticket.lookNoPh')}
value={orderNo}
onChange={(e) => setOrderNo(e.target.value)}
/>
</div>
<div className="kxp-field">
<label htmlFor="mg-email">{t('ticket.lookEmail')}</label>
<input id="mg-email" className="kxp-input" type="email" placeholder={t('ticket.lookEmailPh')} />
<label htmlFor="mg-contact">{t('ticket.lookContact')}</label>
<input
id="mg-contact"
className="kxp-input"
type="tel"
placeholder={t('ticket.lookContactPh')}
value={contact}
onChange={(e) => setContact(e.target.value)}
/>
</div>
<button type="button" className="kxp-btn kxp-btn--primary" onClick={() => setLooked(true)}>
{t('ticket.lookBtn')}
<button type="button" className="kxp-btn kxp-btn--primary" onClick={submit} disabled={lookupMut.isPending}>
{lookupMut.isPending ? t('ticket.processing') : t('ticket.lookBtn')}
</button>
</div>
<div style={{ textAlign: 'center', marginTop: 14 }}>
<a href="#top" style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)', textDecoration: 'underline' }}>
{t('ticket.loginToView')}
</a>
</div>
{(formError || lookupMut.isError) && (
<p role="alert" style={{ textAlign: 'center', marginTop: 12, color: 'var(--color-danger-600, #d92d20)', fontSize: 13 }}>
{formError || (lookupMut.isError ? t('ticket.notFound') : '')}
</p>
)}
</div>
{!looked ? (
<p className="kxp-empty">{t('ticket.notLooked')}</p>
{!r ? (
<p className="kxp-empty">{t('ticket.lookupHint')}</p>
) : (
<div className="kxp-manage__grid">
{/* 예매 상세 */}
<div>
<article className="kxp-bookcard">
<div className="kxp-bookcard__head">
<div>
<span className="kxp-status kxp-status--done">{t('ticket.booked')}</span>
<h2>{t('ticket.bannerTitle')}</h2>
<h2>{r.productName}</h2>
<span className="kxp-bookcard__no">
{t('ticket.bookNoLabel')} <b>KTX-2026-018245</b>
{t('ticket.bookNoLabel')} <b>{r.orderNo}</b>
</span>
</div>
<div className="kxp-bookcard__paid">
<small>{t('ticket.paidAt')}</small>
<br />
<b>2025-10-24 14:32</b>
<b>{(r.paidAt ?? r.orderedAt ?? '').replace('T', ' ').slice(0, 16) || '-'}</b>
</div>
</div>
@ -413,30 +569,20 @@ function ManageView() {
</tr>
</thead>
<tbody>
<tr>
<td>
<div className="kxp-ticktable__cell">
<span className="kxp-ticktable__qr">
<IconQr width={22} height={22} />
</span>
(Early Bird)
</div>
</td>
<td>2</td>
<td> 30,000</td>
</tr>
<tr>
<td>
<div className="kxp-ticktable__cell">
<span className="kxp-ticktable__qr">
<IconQr width={22} height={22} />
</span>
/
</div>
</td>
<td>1</td>
<td> 10,000</td>
</tr>
{r.tickets.map((tk) => (
<tr key={tk.ticketCode}>
<td>
<div className="kxp-ticktable__cell">
<span className="kxp-ticktable__qr">
<IconQr width={22} height={22} />
</span>
<span style={{ fontFamily: 'monospace' }}>{tk.ticketCode}</span>
</div>
</td>
<td>{t('ticket.count', { n: 1 })}</td>
<td>{won(r.unitPrice)}</td>
</tr>
))}
</tbody>
</table>
@ -446,20 +592,22 @@ function ManageView() {
<div>
<small>{t('ticket.bookerInfo')}</small>
<br />
<b>jo***@ex***.com · 010-****-1234</b>
<b>
{r.buyerNameMasked} · {r.buyerContactMasked}
{r.buyerEmailMasked ? ` · ${r.buyerEmailMasked}` : ''}
</b>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<small style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)' }}>{t('ticket.totalPaid')}</small>
<div style={{ fontSize: 'var(--fs-h2)', fontWeight: 800, color: 'var(--color-primary-700)' }}>
40,000
{won(r.totalAmount)}
</div>
</div>
</div>
</article>
</div>
{/* 환불 정책 */}
<aside>
<div className="kxp-refund">
<h3>
@ -479,60 +627,23 @@ function ManageView() {
<b>{t('ticket.refundDayVal')}</b>
</div>
</div>
<div className="kxp-refund__est">
<span>{t('ticket.refundEst')}</span>
<strong> 40,000</strong>
</div>
<button
type="button"
className="kxp-btn kxp-btn--danger kxp-btn--block"
onClick={() => setConfirmOpen(true)}
>
{t('ticket.cancelBooking')}
</button>
<p className="kxp-refund__note">{t('ticket.refundNote')}</p>
</div>
<div className="kxp-ai-helper">
<IconSparkles width={18} height={18} />
<div>
<strong>{t('ticket.aiHelper')}</strong>
<p>{t('ticket.aiHelperDesc')}</p>
</div>
</div>
</aside>
</div>
)}
{confirmOpen && (
<div className="kxp-modal" role="dialog" aria-modal="true" aria-label={t('ticket.cancelModalAria')}>
<div className="kxp-modal__scrim" onClick={() => setConfirmOpen(false)} aria-hidden />
<div className="kxp-modal__box">
<h3>{t('ticket.cancelModalTitle')}</h3>
<p>{t('ticket.cancelModalDesc')}</p>
<div className="kxp-modal__actions">
<button type="button" className="kxp-btn kxp-btn--outline" onClick={() => setConfirmOpen(false)}>
{t('common.actions.close')}
</button>
<button type="button" className="kxp-btn kxp-btn--danger" onClick={() => setConfirmOpen(false)}>
{t('ticket.cancelConfirm')}
</button>
</div>
</div>
</div>
)}
</>
);
}
export function PublicTicketPage() {
const { t } = useTranslation();
const [view, setView] = useState<'purchase' | 'manage'>('purchase');
const { eventId } = useParams<{ eventId: string }>();
const [view, setView] = useState<'purchase' | 'manage'>(eventId ? 'purchase' : 'manage');
return (
<PublicShell active="visit" cta={t('ticket.viewPurchase')} search={false}>
<div className={view === 'purchase' ? 'kxp-ticket' : 'kxp-manage'}>
{/* 뷰 전환 */}
<div className="kxp-daytabs" style={{ justifyContent: 'center', marginBottom: 24 }} role="tablist" aria-label={t('ticket.viewSwitchAria')}>
<button
type="button"
@ -554,7 +665,7 @@ export function PublicTicketPage() {
</button>
</div>
{view === 'purchase' ? <PurchaseView /> : <ManageView />}
{view === 'purchase' ? <PurchaseView eventId={eventId ?? DEFAULT_EVENT_ID} /> : <ManageView />}
</div>
</PublicShell>
);

View File

@ -0,0 +1,99 @@
/*
* () API (/api/public/tickets/**).
* 백엔드: com.zioinfo.kintex.ticket (V50). ApiResponse.data .
* 보안: 응답의 ( ).
*/
import { api } from '../../api/client';
/** 판매 상품 — remaining/onSale/soldOut 은 서버 권위. */
export interface TicketProduct {
id: string;
eventId: string;
code: string;
kind: string; // GENERAL/STUDENT/GROUP/VIP/BUYER
name: string;
description: string | null;
price: number;
currency: string;
saleStart: string | null;
saleEnd: string | null;
totalQty: number;
remaining: number;
maxPerOrder: number;
onSale: boolean;
soldOut: boolean;
sortOrder: number;
}
export interface IssuedTicket {
ticketCode: string;
seq: number;
used: boolean;
}
export interface TicketOrderRequest {
productId: string;
qty: number;
buyerName: string;
buyerContact: string;
buyerEmail?: string;
agreePrivacy: boolean;
payMethod?: string; // card/easy/bank (무료권은 서버가 free 처리)
}
export interface TicketOrderResult {
orderNo: string;
status: string; // PAID
eventId: string;
productName: string;
qty: number;
unitPrice: number;
totalAmount: number;
payMethod: string | null;
approvalNo: string | null;
buyerNameMasked: string | null;
buyerContactMasked: string | null;
buyerEmailMasked: string | null;
orderedAt: string | null;
tickets: IssuedTicket[];
}
export interface TicketLookupResult {
orderNo: string;
status: string;
eventId: string;
productName: string;
qty: number;
unitPrice: number;
totalAmount: number;
payMethod: string | null;
buyerNameMasked: string | null;
buyerContactMasked: string | null;
buyerEmailMasked: string | null;
orderedAt: string | null;
paidAt: string | null;
tickets: IssuedTicket[];
}
const opt = { anonymous: true } as const;
export const ticketApi = {
/** 행사 판매중 상품 목록(잔여수량·판매기간 서버 권위). */
listProducts: (eventId: string) =>
api.get<TicketProduct[]>(`/api/public/tickets/${encodeURIComponent(eventId)}`, opt),
/** 예매(재고차감→PG승인→발권). 실패 시 봉투 error(code)로 강등. */
createOrder: (eventId: string, payload: TicketOrderRequest) =>
api.post<TicketOrderResult>(
`/api/public/tickets/${encodeURIComponent(eventId)}/orders`,
payload,
opt,
),
/** 예매 조회 — 주문번호 + 연락처(하이픈 무관). 불일치·부재는 NOT_FOUND. */
lookup: (orderNo: string, contact: string) =>
api.get<TicketLookupResult>(
`/api/public/tickets/lookup?orderNo=${encodeURIComponent(orderNo)}&contact=${encodeURIComponent(contact)}`,
opt,
),
};