From 8943c806ee3c4ed885d9a7acef0ddba195d38d88 Mon Sep 17 00:00:00 2001 From: zio Date: Tue, 14 Jul 2026 00:25:24 +0900 Subject: [PATCH] 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 --- docs/GAP_AUDIT.md | 5 +- .../zioinfo/kintex/common/text/Masking.java | 16 + .../kintex/ticket/TicketController.java | 52 ++ .../zioinfo/kintex/ticket/TicketMapper.java | 44 ++ .../zioinfo/kintex/ticket/TicketService.java | 236 ++++++++ .../zioinfo/kintex/ticket/dto/TicketDtos.java | 89 +++ .../ticket/payment/MockPaymentGateway.java | 30 + .../kintex/ticket/payment/PaymentGateway.java | 21 + .../db/migration/V50__ticket_domain.sql | 154 +++++ .../resources/mybatis/mapper/TicketMapper.xml | 123 ++++ src/frontend/src/i18n/locales/en.json | 17 +- src/frontend/src/i18n/locales/ja.json | 17 +- src/frontend/src/i18n/locales/ko.json | 17 +- src/frontend/src/i18n/locales/zh.json | 17 +- .../src/screens/public/PublicTicketPage.tsx | 535 +++++++++++------- src/frontend/src/screens/public/ticketApi.ts | 99 ++++ 16 files changed, 1254 insertions(+), 218 deletions(-) create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/ticket/TicketController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/ticket/TicketMapper.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/ticket/TicketService.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/ticket/dto/TicketDtos.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/ticket/payment/MockPaymentGateway.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/ticket/payment/PaymentGateway.java create mode 100644 src/backend/src/main/resources/db/migration/V50__ticket_domain.sql create mode 100644 src/backend/src/main/resources/mybatis/mapper/TicketMapper.xml create mode 100644 src/frontend/src/screens/public/ticketApi.ts 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 @@ + + + + + + + + + + + + + + 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} + + + + + + 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}) + + + + + + + + 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) => ( +
+
+ + +
-
-
+ ) : 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} - -
-
- ))} -
+
+

+ {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) })} + +
+
+
+ + {q} + +
+
+ ); + })} +
+ )}
)} @@ -208,20 +290,40 @@ function PurchaseView() {
- + setName(e.target.value)} + />
- + setContact(e.target.value)} + />
- + setEmail(e.target.value)} + />
{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 && ( @@ -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() {

- {/* 조회 폼 */}
- + setOrderNo(e.target.value)} + />
- - + + setContact(e.target.value)} + />
-
- + {(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)}
- {/* 환불 정책 */}
-
- {t('ticket.refundEst')} - ₩ 40,000 -
-

{t('ticket.refundNote')}

- -
- -
- {t('ticket.aiHelper')} -

{t('ticket.aiHelperDesc')}

-
-
)} - - {confirmOpen && ( -
-
setConfirmOpen(false)} aria-hidden /> -
-

{t('ticket.cancelModalTitle')}

-

{t('ticket.cancelModalDesc')}

-
- - -
-
-
- )} ); } 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, + ), +};