diff --git a/docs/GAP_AUDIT.md b/docs/GAP_AUDIT.md
index 7a9c59a..1dadaef 100644
--- a/docs/GAP_AUDIT.md
+++ b/docs/GAP_AUDIT.md
@@ -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 |
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/common/text/Masking.java b/src/backend/src/main/java/com/zioinfo/kintex/common/text/Masking.java
index 4a78bed..c38c909 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/common/text/Masking.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/common/text/Masking.java
@@ -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()) {
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/TicketController.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/TicketController.java
new file mode 100644
index 0000000..e5750fa
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/TicketController.java
@@ -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).
+ *
보안: 응답에 구매자 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> products(@PathVariable String eventId) {
+ return ApiResponse.ok(service.listProducts(eventId));
+ }
+
+ /** POST /api/public/tickets/{eventId}/orders — 예매(재고차감→PG승인→발권). */
+ @PostMapping("/{eventId}/orders")
+ public ApiResponse 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 lookup(@RequestParam String orderNo,
+ @RequestParam String contact) {
+ return ApiResponse.ok(service.lookup(orderNo, contact));
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/TicketMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/TicketMapper.java
new file mode 100644
index 0000000..194014c
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/TicketMapper.java
@@ -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.
+ * ★ 재고 차감은 {@link #decrementStock}(조건부 UPDATE)로 oversell 를 원자적으로 방지한다.
+ *
★ camelCase 별칭은 XML 에서 {@code AS "x"} 쌍따옴표(PG lower-fold 방지).
+ */
+@Mapper
+public interface TicketMapper {
+
+ /** 행사별 판매 상품 목록(정렬순). remaining 은 total-sold 로 계산해 서빙. */
+ List> findProducts(@Param("eventId") String eventId);
+
+ /** 단일 상품(주문 검증용). 없으면 null. */
+ Map 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 order);
+
+ /** 발권 1건 삽입. */
+ int insertIssue(Map issue);
+
+ /** 조회 — 주문번호 + 연락처 해시 대조(둘 다 일치해야 반환). 없으면 null. */
+ Map findOrderForLookup(@Param("orderNo") String orderNo,
+ @Param("contactHash") String contactHash);
+
+ /** 주문의 발권 티켓 목록(seq 순). */
+ List> findIssuesByOrder(@Param("orderId") String orderId);
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/TicketService.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/TicketService.java
new file mode 100644
index 0000000..58d17b2
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/TicketService.java
@@ -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승인→발권) · 조회.
+ * 서버 권위: 수량 상한·판매기간·재고를 서버에서 재검증한다(클라이언트 신뢰 금지).
+ *
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 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 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 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 tickets = new ArrayList<>(qty);
+ for (int i = 1; i <= qty; i++) {
+ String code = generateTicketCode(orderNo, i);
+ Map 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 o = mapper.findOrderForLookup(orderNo.trim(), hash);
+ if (o == null) {
+ // 존재 여부 노출 금지 — 불일치도 NOT_FOUND 로 통일.
+ throw new ApiException(ErrorCode.NOT_FOUND, "일치하는 예매 내역이 없습니다. 예매번호와 연락처를 확인해 주세요.");
+ }
+ List 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 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;
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/dto/TicketDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/dto/TicketDtos.java
new file mode 100644
index 0000000..97b2f97
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/dto/TicketDtos.java
@@ -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 정산). 전 공개 경로.
+ * 보안(§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 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 tickets) {
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/payment/MockPaymentGateway.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/payment/MockPaymentGateway.java
new file mode 100644
index 0000000..17be3a0
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/payment/MockPaymentGateway.java
@@ -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 미연동 시뮬레이션.
+ * 정상 승인 + 승인번호 생성. 음수 금액 등 비정상은 거절로 시뮬레이션한다.
+ *
운영 전환 시 실 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);
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ticket/payment/PaymentGateway.java b/src/backend/src/main/java/com/zioinfo/kintex/ticket/payment/PaymentGateway.java
new file mode 100644
index 0000000..08a9abf
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ticket/payment/PaymentGateway.java
@@ -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);
+}
diff --git a/src/backend/src/main/resources/db/migration/V50__ticket_domain.sql b/src/backend/src/main/resources/db/migration/V50__ticket_domain.sql
new file mode 100644
index 0000000..daa92bc
--- /dev/null
+++ b/src/backend/src/main/resources/db/migration/V50__ticket_domain.sql
@@ -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;
diff --git a/src/backend/src/main/resources/mybatis/mapper/TicketMapper.xml b/src/backend/src/main/resources/mybatis/mapper/TicketMapper.xml
new file mode 100644
index 0000000..628f1e7
--- /dev/null
+++ b/src/backend/src/main/resources/mybatis/mapper/TicketMapper.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+ 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 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}
+
+
+
+
+ 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 <= now())
+ AND (sale_end IS NULL OR sale_end >= now())
+ AND (total_qty - sold_qty) >= #{qty}
+
+
+
+ SELECT COUNT(*) FROM ticket_order WHERE order_no = #{orderNo}
+
+
+
+ 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 INTO ticket_issue (tenant_id, id, order_id, ticket_code, seq)
+ VALUES ('KINTEX', #{id}, #{orderId}, #{ticketCode}, #{seq})
+
+
+
+
+ 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 ticket_code AS "ticketCode",
+ seq,
+ used
+ FROM ticket_issue
+ WHERE tenant_id = 'KINTEX'
+ AND order_id = #{orderId}
+ ORDER BY seq
+
+
+
diff --git a/src/frontend/src/i18n/locales/en.json b/src/frontend/src/i18n/locales/en.json
index ea99438..c637092 100644
--- a/src/frontend/src/i18n/locales/en.json
+++ b/src/frontend/src/i18n/locales/en.json
@@ -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",
diff --git a/src/frontend/src/i18n/locales/ja.json b/src/frontend/src/i18n/locales/ja.json
index c4d559c..767afc7 100644
--- a/src/frontend/src/i18n/locales/ja.json
+++ b/src/frontend/src/i18n/locales/ja.json
@@ -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": "ログイン",
diff --git a/src/frontend/src/i18n/locales/ko.json b/src/frontend/src/i18n/locales/ko.json
index f5f10f9..63b5ec4 100644
--- a/src/frontend/src/i18n/locales/ko.json
+++ b/src/frontend/src/i18n/locales/ko.json
@@ -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": "로그인",
diff --git a/src/frontend/src/i18n/locales/zh.json b/src/frontend/src/i18n/locales/zh.json
index d2f1d09..fc12225 100644
--- a/src/frontend/src/i18n/locales/zh.json
+++ b/src/frontend/src/i18n/locales/zh.json
@@ -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": "登录",
diff --git a/src/frontend/src/screens/public/PublicTicketPage.tsx b/src/frontend/src/screens/public/PublicTicketPage.tsx
index a94caf0..5a01678 100644
--- a/src/frontend/src/screens/public/PublicTicketPage.tsx
+++ b/src/frontend/src/screens/public/PublicTicketPage.tsx
@@ -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>({});
+ const [sel, setSel] = useState(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 (
<>
- {/* 스텝 인디케이터 */}
{STEP_KEYS.map((l, i) => (
- {/* 로그인 배너 */}
{t('ticket.loginBanner')}
{t('ticket.loginLink')}
@@ -148,53 +204,79 @@ function PurchaseView() {
{t('ticket.h2Type')}
-
- {t('ticket.sample')}
-
+ {degraded && (
+
+ {t('ticket.sample')}
+
+ )}
-
- {TICKETS.map((tk) => (
-
-
-
- {t(tk.nameKey)}
- {tk.badge && (
-
- {tk.badge.kind === 'ai' && }
- {t(tk.badge.labelKey)}
-
- )}
- {tk.soldout && {t('ticket.soldout')} }
-
-
{t(tk.descKey)}
-
{t(tk.priceKey)}
+
+ {productsQ.isLoading ? (
+
+ {[0, 1, 2].map((k) => (
+
-
- setCount(tk.key, -1)}
+ ))}
+
+ ) : productsQ.isError && !degraded ? (
+
void productsQ.refetch()} />
+ ) : products.length === 0 ? (
+ } />
+ ) : (
+
+ {products.map((p) => {
+ const disabled = p.soldOut || !p.onSale;
+ const q = sel && sel.id === p.id ? sel.qty : 0;
+ return (
+
-
-
- {qty[tk.key] ?? 0}
- setCount(tk.key, 1)}
- >
-
-
-
-
- ))}
-
+
+
+ {p.name}
+ {p.soldOut && {t('ticket.soldout')} }
+ {!p.soldOut && !p.onSale && (
+ {t('ticket.saleClosed')}
+ )}
+
+ {p.description &&
{p.description}
}
+
+ {p.price === 0 ? t('ticket.free') : won(p.price)}
+
+ {t('ticket.remaining', { n: Math.max(0, p.remaining) })}
+
+
+
+
+ setCount(p, -1)}
+ >
+
+
+ {q}
+ setCount(p, 1)}
+ >
+
+
+
+
+ );
+ })}
+
+ )}
)}
@@ -208,20 +290,40 @@ function PurchaseView() {
-
+ setAgreePrivacy(e.target.checked)} />
{t('ticket.consentPrivacy')} {t('ticket.required')}
@@ -266,25 +368,46 @@ function PurchaseView() {
)}
{/* Step 4: 완료 */}
- {step === 3 && (
+ {step === 3 && result && (
{t('ticket.doneTitle')}
- {t('ticket.doneNo')}
+ {result.orderNo}
- {t('ticket.doneSub', { count, total: won(total) })}
+ {t('ticket.doneSub', { count: result.qty, total: won(result.totalAmount) })}
- {t('ticket.sampleReady')}
+
+
+ {t('ticket.colType')}
+ {result.productName}
+
+ {result.approvalNo && (
+
+ {t('ticket.approvalNo')}
+ {result.approvalNo}
+
+ )}
+
+ {t('ticket.ticketCodes')}
+
+ {result.tickets.map((tk) => (
+
+ {tk.ticketCode}
+
+ ))}
+
+
+
@@ -302,36 +425,42 @@ function PurchaseView() {
{t('ticket.subtotal')}
- {won(subtotal)}
-
-
- {t('ticket.discount')}
- -{won(discount)}
+ {won(total)}
{t('ticket.finalTotal')}
- {won(total)}
+ {isFree ? t('ticket.free') : won(total)}
+
+ {(formError || orderMut.isError) && (
+
+ {formError || errorMessage(orderMut.error)}
+
+ )}
+
- {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')}
{step > 0 && (
{t('common.actions.prev')}
@@ -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() {
- {/* 조회 폼 */}
-
+ {(formError || lookupMut.isError) && (
+
+ {formError || (lookupMut.isError ? t('ticket.notFound') : '')}
+
+ )}
- {!looked ? (
-
{t('ticket.notLooked')}
+ {!r ? (
+
{t('ticket.lookupHint')}
) : (
- {/* 예매 상세 */}
{t('ticket.booked')}
-
{t('ticket.bannerTitle')}
+ {r.productName}
- {t('ticket.bookNoLabel')} KTX-2026-018245
+ {t('ticket.bookNoLabel')} {r.orderNo}
{t('ticket.paidAt')}
- 2025-10-24 14:32
+ {(r.paidAt ?? r.orderedAt ?? '').replace('T', ' ').slice(0, 16) || '-'}
@@ -413,30 +569,20 @@ function ManageView() {
-
-
-
-
-
-
- 일반권 (Early Bird)
-
-
- 2매
- ₩ 30,000
-
-
-
-
-
-
-
- 학생/군인 할인
-
-
- 1매
- ₩ 10,000
-
+ {r.tickets.map((tk) => (
+
+
+
+
+
+
+ {tk.ticketCode}
+
+
+ {t('ticket.count', { n: 1 })}
+ {won(r.unitPrice)}
+
+ ))}
@@ -446,20 +592,22 @@ function ManageView() {
{t('ticket.bookerInfo')}
- jo***@ex***.com · 010-****-1234
+
+ {r.buyerNameMasked} · {r.buyerContactMasked}
+ {r.buyerEmailMasked ? ` · ${r.buyerEmailMasked}` : ''}
+
{t('ticket.totalPaid')}
- ₩ 40,000
+ {won(r.totalAmount)}
- {/* 환불 정책 */}
@@ -479,60 +627,23 @@ function ManageView() {
{t('ticket.refundDayVal')}
-
- {t('ticket.refundEst')}
- ₩ 40,000
-
- setConfirmOpen(true)}
- >
- {t('ticket.cancelBooking')}
-
{t('ticket.refundNote')}
-
-
-
-
-
{t('ticket.aiHelper')}
-
{t('ticket.aiHelperDesc')}
-
-
)}
-
- {confirmOpen && (
-
-
setConfirmOpen(false)} aria-hidden />
-
-
{t('ticket.cancelModalTitle')}
-
{t('ticket.cancelModalDesc')}
-
- setConfirmOpen(false)}>
- {t('common.actions.close')}
-
- setConfirmOpen(false)}>
- {t('ticket.cancelConfirm')}
-
-
-
-
- )}
>
);
}
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 (
- {/* 뷰 전환 */}
- {view === 'purchase' ?
:
}
+ {view === 'purchase' ?
:
}
);
diff --git a/src/frontend/src/screens/public/ticketApi.ts b/src/frontend/src/screens/public/ticketApi.ts
new file mode 100644
index 0000000..6e8a881
--- /dev/null
+++ b/src/frontend/src/screens/public/ticketApi.ts
@@ -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
(`/api/public/tickets/${encodeURIComponent(eventId)}`, opt),
+
+ /** 예매(재고차감→PG승인→발권). 실패 시 봉투 error(code)로 강등. */
+ createOrder: (eventId: string, payload: TicketOrderRequest) =>
+ api.post(
+ `/api/public/tickets/${encodeURIComponent(eventId)}/orders`,
+ payload,
+ opt,
+ ),
+
+ /** 예매 조회 — 주문번호 + 연락처(하이픈 무관). 불일치·부재는 NOT_FOUND. */
+ lookup: (orderNo: string, contact: string) =>
+ api.get(
+ `/api/public/tickets/lookup?orderNo=${encodeURIComponent(orderNo)}&contact=${encodeURIComponent(contact)}`,
+ opt,
+ ),
+};