feat(ticket): public ticket booking/lookup domain with mock PG (GAP G-02)
V50 ticket_product/ticket_order/ticket_issue (tenant-leading composite PK, e-2026-live demo seeds). Public API: product list with server-side remaining/sale-window, transactional order flow with conditional stock decrement (oversell-safe) + MockPaymentGateway adapter, hash-verified lookup returning masked PII only. PublicTicketPage wired via react-query (degraded sample fallback retained); ticket.* i18n x4. Mark G-02 resolved in GAP_AUDIT. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0f91dcf4ae
commit
8943c806ee
@ -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 |
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
package com.zioinfo.kintex.ticket;
|
||||
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.ticket.dto.TicketDtos.LookupResultDto;
|
||||
import com.zioinfo.kintex.ticket.dto.TicketDtos.OrderRequest;
|
||||
import com.zioinfo.kintex.ticket.dto.TicketDtos.OrderResultDto;
|
||||
import com.zioinfo.kintex.ticket.dto.TicketDtos.TicketProductDto;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 공개 입장권 API (M10·M9) — 전 경로 비인증 공개({@code /api/public/**} permitAll).
|
||||
* <p>보안: 응답에 구매자 PII 원문 미노출(마스킹만) · 스택트레이스 미노출(GlobalExceptionHandler).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/public/tickets")
|
||||
public class TicketController {
|
||||
|
||||
private final TicketService service;
|
||||
|
||||
public TicketController(TicketService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
/** GET /api/public/tickets/{eventId} — 판매중 상품 목록(잔여수량·판매기간 서버 권위). */
|
||||
@GetMapping("/{eventId}")
|
||||
public ApiResponse<List<TicketProductDto>> products(@PathVariable String eventId) {
|
||||
return ApiResponse.ok(service.listProducts(eventId));
|
||||
}
|
||||
|
||||
/** POST /api/public/tickets/{eventId}/orders — 예매(재고차감→PG승인→발권). */
|
||||
@PostMapping("/{eventId}/orders")
|
||||
public ApiResponse<OrderResultDto> createOrder(@PathVariable String eventId,
|
||||
@Valid @RequestBody OrderRequest req) {
|
||||
return ApiResponse.ok(service.createOrder(eventId, req));
|
||||
}
|
||||
|
||||
/** GET /api/public/tickets/lookup?orderNo=&contact= — 주문번호+연락처 조회(PII 마스킹). */
|
||||
@GetMapping("/lookup")
|
||||
public ApiResponse<LookupResultDto> lookup(@RequestParam String orderNo,
|
||||
@RequestParam String contact) {
|
||||
return ApiResponse.ok(service.lookup(orderNo, contact));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package com.zioinfo.kintex.ticket;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 입장권 도메인 매퍼 — tenant_id='KINTEX' 고정. XML: mybatis/mapper/TicketMapper.xml.
|
||||
* <p>★ 재고 차감은 {@link #decrementStock}(조건부 UPDATE)로 oversell 를 원자적으로 방지한다.
|
||||
* <p>★ camelCase 별칭은 XML 에서 {@code AS "x"} 쌍따옴표(PG lower-fold 방지).
|
||||
*/
|
||||
@Mapper
|
||||
public interface TicketMapper {
|
||||
|
||||
/** 행사별 판매 상품 목록(정렬순). remaining 은 total-sold 로 계산해 서빙. */
|
||||
List<Map<String, Object>> findProducts(@Param("eventId") String eventId);
|
||||
|
||||
/** 단일 상품(주문 검증용). 없으면 null. */
|
||||
Map<String, Object> findProduct(@Param("productId") String productId);
|
||||
|
||||
/**
|
||||
* 조건부 재고 차감 — {@code sold_qty += qty WHERE 판매중 AND (total-sold) >= qty}.
|
||||
* 반환 갱신행수 1 이면 확보 성공, 0 이면 매진/판매종료(oversell 차단).
|
||||
*/
|
||||
int decrementStock(@Param("productId") String productId, @Param("qty") int qty);
|
||||
|
||||
/** 주문번호 존재 여부(생성 충돌 회피). */
|
||||
int countOrderNo(@Param("orderNo") String orderNo);
|
||||
|
||||
/** 주문 삽입. */
|
||||
int insertOrder(Map<String, Object> order);
|
||||
|
||||
/** 발권 1건 삽입. */
|
||||
int insertIssue(Map<String, Object> issue);
|
||||
|
||||
/** 조회 — 주문번호 + 연락처 해시 대조(둘 다 일치해야 반환). 없으면 null. */
|
||||
Map<String, Object> findOrderForLookup(@Param("orderNo") String orderNo,
|
||||
@Param("contactHash") String contactHash);
|
||||
|
||||
/** 주문의 발권 티켓 목록(seq 순). */
|
||||
List<Map<String, Object>> findIssuesByOrder(@Param("orderId") String orderId);
|
||||
}
|
||||
@ -0,0 +1,236 @@
|
||||
package com.zioinfo.kintex.ticket;
|
||||
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.common.text.Masking;
|
||||
import com.zioinfo.kintex.ticket.dto.TicketDtos.IssuedTicketDto;
|
||||
import com.zioinfo.kintex.ticket.dto.TicketDtos.LookupResultDto;
|
||||
import com.zioinfo.kintex.ticket.dto.TicketDtos.OrderRequest;
|
||||
import com.zioinfo.kintex.ticket.dto.TicketDtos.OrderResultDto;
|
||||
import com.zioinfo.kintex.ticket.dto.TicketDtos.TicketProductDto;
|
||||
import com.zioinfo.kintex.ticket.payment.PaymentGateway;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 입장권 서비스 — 판매 상품 조회 · 예매(재고차감→PG승인→발권) · 조회.
|
||||
* <p>서버 권위: 수량 상한·판매기간·재고를 서버에서 재검증한다(클라이언트 신뢰 금지).
|
||||
* <p>PII 최소화(§0-3): 구매자 원문은 저장·응답 어디에도 남기지 않고 마스킹/해시만 보관한다.
|
||||
*/
|
||||
@Service
|
||||
public class TicketService {
|
||||
|
||||
private static final int ABS_MAX_QTY = 100;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
private final TicketMapper mapper;
|
||||
private final PaymentGateway paymentGateway;
|
||||
|
||||
public TicketService(TicketMapper mapper, PaymentGateway paymentGateway) {
|
||||
this.mapper = mapper;
|
||||
this.paymentGateway = paymentGateway;
|
||||
}
|
||||
|
||||
// ── 판매 상품 목록 ──────────────────────────────────────────────────────────
|
||||
@Transactional(readOnly = true)
|
||||
public List<TicketProductDto> listProducts(String eventId) {
|
||||
if (eventId == null || eventId.isBlank()) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "행사 식별자가 필요합니다.");
|
||||
}
|
||||
return mapper.findProducts(eventId).stream().map(TicketService::toProduct).toList();
|
||||
}
|
||||
|
||||
// ── 예매(주문) ──────────────────────────────────────────────────────────────
|
||||
@Transactional
|
||||
public OrderResultDto createOrder(String eventId, OrderRequest req) {
|
||||
if (eventId == null || eventId.isBlank()) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "행사 식별자가 필요합니다.");
|
||||
}
|
||||
if (req.agreePrivacy() == null || !req.agreePrivacy()) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "개인정보 수집·이용 동의가 필요합니다.");
|
||||
}
|
||||
int qty = req.qty() == null ? 0 : req.qty();
|
||||
if (qty < 1 || qty > ABS_MAX_QTY) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "예매 수량이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
Map<String, Object> product = mapper.findProduct(req.productId());
|
||||
if (product == null || !eventId.equals(str(product.get("eventId")))) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "선택한 입장권을 찾을 수 없습니다.");
|
||||
}
|
||||
if (!bool(product.get("onSale"))) {
|
||||
throw new ApiException(ErrorCode.CONFLICT, "판매 기간이 아닌 입장권입니다.");
|
||||
}
|
||||
int maxPerOrder = toInt(product.get("maxPerOrder"), ABS_MAX_QTY);
|
||||
if (qty > maxPerOrder) {
|
||||
throw new ApiException(ErrorCode.VALIDATION,
|
||||
"1회 최대 " + maxPerOrder + "매까지 예매할 수 있습니다.");
|
||||
}
|
||||
|
||||
// 재고 차감(조건부 UPDATE) — 실패 시 매진. 이후 어떤 예외든 트랜잭션 롤백으로 재고 원복.
|
||||
int updated = mapper.decrementStock(req.productId(), qty);
|
||||
if (updated != 1) {
|
||||
throw new ApiException(ErrorCode.CONFLICT, "잔여 수량이 부족합니다.");
|
||||
}
|
||||
|
||||
long unitPrice = toLong(product.get("price"), 0);
|
||||
long total = unitPrice * qty;
|
||||
String productName = str(product.get("name"));
|
||||
String orderNo = generateOrderNo();
|
||||
String payMethod = normalizeMethod(req.payMethod(), total);
|
||||
|
||||
// Mock PG 승인 — 실패 시 예외 → 트랜잭션 롤백(재고 원복).
|
||||
PaymentGateway.PaymentResult pay = paymentGateway.authorize(payMethod, total, orderNo);
|
||||
if (!pay.approved()) {
|
||||
throw new ApiException(ErrorCode.CONFLICT, "결제 승인에 실패했습니다. 다시 시도해 주세요.");
|
||||
}
|
||||
|
||||
String orderId = "to-" + UUID.randomUUID();
|
||||
String nameMasked = Masking.maskName(req.buyerName());
|
||||
String contactMasked = Masking.maskPhone(req.buyerContact());
|
||||
String emailMasked = Masking.maskEmail(req.buyerEmail());
|
||||
String contactHash = sha256Hex(digitsOnly(req.buyerContact()));
|
||||
|
||||
Map<String, Object> order = new java.util.HashMap<>();
|
||||
order.put("id", orderId);
|
||||
order.put("orderNo", orderNo);
|
||||
order.put("eventId", eventId);
|
||||
order.put("productId", req.productId());
|
||||
order.put("productName", productName);
|
||||
order.put("qty", qty);
|
||||
order.put("unitPrice", unitPrice);
|
||||
order.put("totalAmount", total);
|
||||
order.put("status", "PAID");
|
||||
order.put("payMethod", payMethod);
|
||||
order.put("payApprovalNo", pay.approvalNo());
|
||||
order.put("buyerNameMasked", nameMasked);
|
||||
order.put("buyerContactMasked", contactMasked);
|
||||
order.put("buyerEmailMasked", emailMasked);
|
||||
order.put("buyerContactHash", contactHash);
|
||||
mapper.insertOrder(order);
|
||||
|
||||
List<IssuedTicketDto> tickets = new ArrayList<>(qty);
|
||||
for (int i = 1; i <= qty; i++) {
|
||||
String code = generateTicketCode(orderNo, i);
|
||||
Map<String, Object> issue = new java.util.HashMap<>();
|
||||
issue.put("id", "ti-" + UUID.randomUUID());
|
||||
issue.put("orderId", orderId);
|
||||
issue.put("ticketCode", code);
|
||||
issue.put("seq", i);
|
||||
mapper.insertIssue(issue);
|
||||
tickets.add(new IssuedTicketDto(code, i, false));
|
||||
}
|
||||
|
||||
return new OrderResultDto(orderNo, "PAID", eventId, productName, qty, unitPrice, total,
|
||||
payMethod, pay.approvalNo(), nameMasked, contactMasked, emailMasked, null, tickets);
|
||||
}
|
||||
|
||||
// ── 조회(주문번호 + 연락처) ─────────────────────────────────────────────────
|
||||
@Transactional(readOnly = true)
|
||||
public LookupResultDto lookup(String orderNo, String contact) {
|
||||
if (orderNo == null || orderNo.isBlank() || contact == null || contact.isBlank()) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "예매번호와 연락처를 입력해 주세요.");
|
||||
}
|
||||
String hash = sha256Hex(digitsOnly(contact));
|
||||
Map<String, Object> o = mapper.findOrderForLookup(orderNo.trim(), hash);
|
||||
if (o == null) {
|
||||
// 존재 여부 노출 금지 — 불일치도 NOT_FOUND 로 통일.
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "일치하는 예매 내역이 없습니다. 예매번호와 연락처를 확인해 주세요.");
|
||||
}
|
||||
List<IssuedTicketDto> tickets = mapper.findIssuesByOrder(str(o.get("id"))).stream()
|
||||
.map(m -> new IssuedTicketDto(str(m.get("ticketCode")), toInt(m.get("seq"), 0), bool(m.get("used"))))
|
||||
.toList();
|
||||
return new LookupResultDto(
|
||||
str(o.get("orderNo")), str(o.get("status")), str(o.get("eventId")), str(o.get("productName")),
|
||||
toInt(o.get("qty"), 0), toLong(o.get("unitPrice"), 0), toLong(o.get("totalAmount"), 0),
|
||||
str(o.get("payMethod")), str(o.get("buyerNameMasked")), str(o.get("buyerContactMasked")),
|
||||
str(o.get("buyerEmailMasked")), str(o.get("orderedAt")), str(o.get("paidAt")), tickets);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
private String generateOrderNo() {
|
||||
int year = java.time.Year.now().getValue();
|
||||
for (int attempt = 0; attempt < 6; attempt++) {
|
||||
String no = "KTX-" + year + "-" + String.format("%06d", random.nextInt(1_000_000));
|
||||
if (mapper.countOrderNo(no) == 0) {
|
||||
return no;
|
||||
}
|
||||
}
|
||||
// 극히 드문 충돌 — UUID 조각으로 확정 유니크.
|
||||
return "KTX-" + year + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
private String generateTicketCode(String orderNo, int seq) {
|
||||
String tail = orderNo.length() >= 6 ? orderNo.substring(orderNo.length() - 6) : orderNo;
|
||||
String rand = HexFormat.of().formatHex(randomBytes()).substring(0, 4).toUpperCase();
|
||||
return "TK-" + tail + "-" + String.format("%02d", seq) + "-" + rand;
|
||||
}
|
||||
|
||||
private byte[] randomBytes() {
|
||||
byte[] b = new byte[3];
|
||||
random.nextBytes(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
private static String normalizeMethod(String method, long total) {
|
||||
if (total == 0) {
|
||||
return "free";
|
||||
}
|
||||
if (method == null || method.isBlank()) {
|
||||
return "card";
|
||||
}
|
||||
String m = method.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
return switch (m) {
|
||||
case "card", "easy", "bank" -> m;
|
||||
default -> "card";
|
||||
};
|
||||
}
|
||||
|
||||
private static String digitsOnly(String s) {
|
||||
return s == null ? "" : s.replaceAll("[^0-9]", "");
|
||||
}
|
||||
|
||||
private static String sha256Hex(String s) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(md.digest(s.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new ApiException(ErrorCode.INTERNAL);
|
||||
}
|
||||
}
|
||||
|
||||
private static TicketProductDto toProduct(Map<String, Object> m) {
|
||||
return new TicketProductDto(
|
||||
str(m.get("id")), str(m.get("eventId")), str(m.get("code")), str(m.get("kind")),
|
||||
str(m.get("name")), str(m.get("description")), toLong(m.get("price"), 0), str(m.get("currency")),
|
||||
str(m.get("saleStart")), str(m.get("saleEnd")), toInt(m.get("totalQty"), 0),
|
||||
Math.max(0, toInt(m.get("remaining"), 0)), toInt(m.get("maxPerOrder"), 0),
|
||||
bool(m.get("onSale")), bool(m.get("soldOut")), toInt(m.get("sortOrder"), 0));
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : o.toString();
|
||||
}
|
||||
|
||||
private static boolean bool(Object o) {
|
||||
return o instanceof Boolean b && b;
|
||||
}
|
||||
|
||||
private static int toInt(Object o, int def) {
|
||||
return (o instanceof Number n) ? n.intValue() : def;
|
||||
}
|
||||
|
||||
private static long toLong(Object o, long def) {
|
||||
return (o instanceof Number n) ? n.longValue() : def;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package com.zioinfo.kintex.ticket.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 입장권(티켓) 도메인 DTO 모음 (M10 관람객 · M9 정산). 전 공개 경로.
|
||||
* <p>보안(§0-3): 응답 어디에도 구매자 PII 원문을 담지 않는다 — 마스킹 필드만 노출.
|
||||
*/
|
||||
public final class TicketDtos {
|
||||
|
||||
private TicketDtos() {
|
||||
}
|
||||
|
||||
/** 판매 상품 카드 — 잔여수량·판매기간은 서버 권위. */
|
||||
public record TicketProductDto(
|
||||
String id,
|
||||
String eventId,
|
||||
String code,
|
||||
String kind,
|
||||
String name,
|
||||
String description,
|
||||
long price,
|
||||
String currency,
|
||||
String saleStart,
|
||||
String saleEnd,
|
||||
int totalQty,
|
||||
int remaining,
|
||||
int maxPerOrder,
|
||||
boolean onSale,
|
||||
boolean soldOut,
|
||||
int sortOrder) {
|
||||
}
|
||||
|
||||
/** 예매 요청 — 단일 상품 + 수량. */
|
||||
public record OrderRequest(
|
||||
@NotBlank String productId,
|
||||
@NotNull @Min(1) @Max(100) Integer qty,
|
||||
@NotBlank String buyerName,
|
||||
@NotBlank String buyerContact,
|
||||
String buyerEmail,
|
||||
@NotNull Boolean agreePrivacy,
|
||||
String payMethod) {
|
||||
}
|
||||
|
||||
/** 발권된 개별 티켓. */
|
||||
public record IssuedTicketDto(String ticketCode, int seq, boolean used) {
|
||||
}
|
||||
|
||||
/** 예매 결과 — 주문번호 + 발권 티켓. 구매자는 마스킹만. */
|
||||
public record OrderResultDto(
|
||||
String orderNo,
|
||||
String status,
|
||||
String eventId,
|
||||
String productName,
|
||||
int qty,
|
||||
long unitPrice,
|
||||
long totalAmount,
|
||||
String payMethod,
|
||||
String approvalNo,
|
||||
String buyerNameMasked,
|
||||
String buyerContactMasked,
|
||||
String buyerEmailMasked,
|
||||
String orderedAt,
|
||||
List<IssuedTicketDto> tickets) {
|
||||
}
|
||||
|
||||
/** 예매 조회 결과 — 주문번호 + 연락처 대조 성공 시. PII 마스킹만. */
|
||||
public record LookupResultDto(
|
||||
String orderNo,
|
||||
String status,
|
||||
String eventId,
|
||||
String productName,
|
||||
int qty,
|
||||
long unitPrice,
|
||||
long totalAmount,
|
||||
String payMethod,
|
||||
String buyerNameMasked,
|
||||
String buyerContactMasked,
|
||||
String buyerEmailMasked,
|
||||
String orderedAt,
|
||||
String paidAt,
|
||||
List<IssuedTicketDto> tickets) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.zioinfo.kintex.ticket.payment;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* Mock 결제 게이트웨이(기본 구현) — 외부 PG 미연동 시뮬레이션.
|
||||
* <p>정상 승인 + 승인번호 생성. 음수 금액 등 비정상은 거절로 시뮬레이션한다.
|
||||
* <p>운영 전환 시 실 PG 어댑터 {@code @Component} 로 대체하고 이 빈은 제거/비활성화한다.
|
||||
*/
|
||||
@Component
|
||||
public class MockPaymentGateway implements PaymentGateway {
|
||||
|
||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
@Override
|
||||
public PaymentResult authorize(String method, long amount, String orderNo) {
|
||||
if (amount < 0) {
|
||||
return new PaymentResult(false, null, "결제 금액이 올바르지 않습니다.");
|
||||
}
|
||||
String approvalNo = "MOCK-" + LocalDateTime.now().format(TS) + "-"
|
||||
+ String.format("%06d", random.nextInt(1_000_000));
|
||||
String label = amount == 0 ? "무료 예매 확정" : "결제 승인 완료(모의)";
|
||||
return new PaymentResult(true, approvalNo, label);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
123
src/backend/src/main/resources/mybatis/mapper/TicketMapper.xml
Normal file
123
src/backend/src/main/resources/mybatis/mapper/TicketMapper.xml
Normal file
@ -0,0 +1,123 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<!--
|
||||
TicketMapper — 입장권 상품·주문·발권 영속 (V50). tenant_id='KINTEX' 고정.
|
||||
보안(§0-3): 조회에서 구매자 원문(이름·연락처·이메일) 은 SELECT 하지 않는다 — 마스킹 컬럼/해시만 다룬다.
|
||||
동시성: decrementStock 는 조건부 UPDATE 로 oversell 를 원자적으로 차단한다.
|
||||
-->
|
||||
<mapper namespace="com.zioinfo.kintex.ticket.TicketMapper">
|
||||
|
||||
<!-- 판매 상품 목록 — remaining = total-sold, onSale/soldOut 은 서버 계산. -->
|
||||
<select id="findProducts" resultType="map">
|
||||
SELECT id,
|
||||
event_id AS "eventId",
|
||||
code,
|
||||
kind,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
currency,
|
||||
to_char(sale_start, 'YYYY-MM-DD"T"HH24:MI:SS') AS "saleStart",
|
||||
to_char(sale_end, 'YYYY-MM-DD"T"HH24:MI:SS') AS "saleEnd",
|
||||
total_qty AS "totalQty",
|
||||
(total_qty - sold_qty) AS "remaining",
|
||||
max_per_order AS "maxPerOrder",
|
||||
CASE WHEN status = 'active'
|
||||
AND (sale_start IS NULL OR sale_start <= now())
|
||||
AND (sale_end IS NULL OR sale_end >= now())
|
||||
THEN true ELSE false END AS "onSale",
|
||||
CASE WHEN (total_qty - sold_qty) <= 0 THEN true ELSE false END AS "soldOut",
|
||||
sort_order AS "sortOrder"
|
||||
FROM ticket_product
|
||||
WHERE tenant_id = 'KINTEX'
|
||||
AND event_id = #{eventId}
|
||||
AND status = 'active'
|
||||
ORDER BY sort_order, id
|
||||
</select>
|
||||
|
||||
<!-- 단일 상품(주문 검증) — 판매기간·상태·잔여 판정에 필요한 원시값 포함. -->
|
||||
<select id="findProduct" resultType="map">
|
||||
SELECT id,
|
||||
event_id AS "eventId",
|
||||
name,
|
||||
kind,
|
||||
price,
|
||||
currency,
|
||||
max_per_order AS "maxPerOrder",
|
||||
status,
|
||||
(total_qty - sold_qty) AS "remaining",
|
||||
CASE WHEN status = 'active'
|
||||
AND (sale_start IS NULL OR sale_start <= now())
|
||||
AND (sale_end IS NULL OR sale_end >= now())
|
||||
THEN true ELSE false END AS "onSale"
|
||||
FROM ticket_product
|
||||
WHERE tenant_id = 'KINTEX'
|
||||
AND id = #{productId}
|
||||
</select>
|
||||
|
||||
<!-- 조건부 재고 차감 — 판매중 + 잔여 충분할 때만 1행 갱신(oversell 차단). -->
|
||||
<update id="decrementStock">
|
||||
UPDATE ticket_product
|
||||
SET sold_qty = sold_qty + #{qty}
|
||||
WHERE tenant_id = 'KINTEX'
|
||||
AND id = #{productId}
|
||||
AND status = 'active'
|
||||
AND (sale_start IS NULL OR sale_start <= now())
|
||||
AND (sale_end IS NULL OR sale_end >= now())
|
||||
AND (total_qty - sold_qty) >= #{qty}
|
||||
</update>
|
||||
|
||||
<select id="countOrderNo" resultType="int">
|
||||
SELECT COUNT(*) FROM ticket_order WHERE order_no = #{orderNo}
|
||||
</select>
|
||||
|
||||
<insert id="insertOrder">
|
||||
INSERT INTO ticket_order
|
||||
(tenant_id, id, order_no, event_id, product_id, product_name, qty, unit_price, total_amount,
|
||||
status, pay_method, pay_approval_no, buyer_name_masked, buyer_contact_masked, buyer_email_masked,
|
||||
buyer_contact_hash, ordered_at, paid_at)
|
||||
VALUES
|
||||
('KINTEX', #{id}, #{orderNo}, #{eventId}, #{productId}, #{productName}, #{qty}, #{unitPrice},
|
||||
#{totalAmount}, #{status}, #{payMethod}, #{payApprovalNo}, #{buyerNameMasked},
|
||||
#{buyerContactMasked}, #{buyerEmailMasked}, #{buyerContactHash}, now(), now())
|
||||
</insert>
|
||||
|
||||
<insert id="insertIssue">
|
||||
INSERT INTO ticket_issue (tenant_id, id, order_id, ticket_code, seq)
|
||||
VALUES ('KINTEX', #{id}, #{orderId}, #{ticketCode}, #{seq})
|
||||
</insert>
|
||||
|
||||
<!-- 조회 — 주문번호 + 연락처 해시 둘 다 일치. 원문 PII 컬럼은 SELECT 하지 않음. -->
|
||||
<select id="findOrderForLookup" resultType="map">
|
||||
SELECT id,
|
||||
order_no AS "orderNo",
|
||||
event_id AS "eventId",
|
||||
product_name AS "productName",
|
||||
qty,
|
||||
unit_price AS "unitPrice",
|
||||
total_amount AS "totalAmount",
|
||||
status,
|
||||
pay_method AS "payMethod",
|
||||
buyer_name_masked AS "buyerNameMasked",
|
||||
buyer_contact_masked AS "buyerContactMasked",
|
||||
buyer_email_masked AS "buyerEmailMasked",
|
||||
to_char(ordered_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "orderedAt",
|
||||
to_char(paid_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "paidAt"
|
||||
FROM ticket_order
|
||||
WHERE order_no = #{orderNo}
|
||||
AND buyer_contact_hash = #{contactHash}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="findIssuesByOrder" resultType="map">
|
||||
SELECT ticket_code AS "ticketCode",
|
||||
seq,
|
||||
used
|
||||
FROM ticket_issue
|
||||
WHERE tenant_id = 'KINTEX'
|
||||
AND order_id = #{orderId}
|
||||
ORDER BY seq
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@ -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",
|
||||
|
||||
@ -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": "ログイン",
|
||||
|
||||
@ -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": "로그인",
|
||||
|
||||
@ -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": "登录",
|
||||
|
||||
@ -1,12 +1,19 @@
|
||||
/*
|
||||
* SCR-P7 입장권 예매 (M10·M9) — 비로그인 공개.
|
||||
* design.md §3B. 예매 플로우(권종→예매자→결제→완료) + 예매 확인·취소(manage) 뷰.
|
||||
* 결제 PG 미연동 — 데모 플로우(카드 입력 UI 없음, PG 위임 고지 상시). PII 마스킹.
|
||||
* 데이터: 티켓·재고·결제 API 부재 → 샘플 데이터.
|
||||
* design.md §3B. 예매 플로우(권종→예매자→결제→완료) + 예매 조회(주문번호+연락처).
|
||||
* 라이브: GET /api/public/tickets/{eventId}, POST .../orders, GET .../lookup (ticketApi).
|
||||
* 결제는 Mock PG(백엔드 어댑터) — 데모 승인. 응답의 구매자 정보는 서버 마스킹 필드만 소비(PII 원문 없음).
|
||||
* 3상태(로딩/빈/에러) 준수. 목록은 NETWORK/NOT_FOUND 강등 시에만 샘플 폴백(배지 표기).
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { PublicShell } from './PublicShell';
|
||||
import { ticketApi, type TicketProduct } from './ticketApi';
|
||||
import { errorMessage } from './publicFormat';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import {
|
||||
IconTicket,
|
||||
IconCalendar,
|
||||
@ -18,31 +25,27 @@ import {
|
||||
IconCheckCircle,
|
||||
IconQr,
|
||||
IconArrowRight,
|
||||
IconSparkles,
|
||||
IconPerson,
|
||||
} from './publicIcons';
|
||||
|
||||
interface TicketType {
|
||||
key: string;
|
||||
nameKey: string;
|
||||
descKey: string;
|
||||
price: number;
|
||||
priceKey: string;
|
||||
badge?: { labelKey: string; kind: 'ai' | 'off' };
|
||||
soldout?: boolean;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const TICKETS: TicketType[] = [
|
||||
{ key: 'general', nameKey: 'ticket.type.general', descKey: 'ticket.type.generalDesc', price: 0, priceKey: 'ticket.type.generalPrice', accent: '#0066b3' },
|
||||
{ key: 'buyer', nameKey: 'ticket.type.buyer', descKey: 'ticket.type.buyerDesc', price: 0, priceKey: 'ticket.type.buyerPrice', badge: { labelKey: 'ticket.aiOptimized', kind: 'ai' }, accent: '#6d4aff' },
|
||||
{ key: 'group', nameKey: 'ticket.type.group', descKey: 'ticket.type.groupDesc', price: 9000, priceKey: 'ticket.type.groupPrice', badge: { labelKey: 'ticket.off10', kind: 'off' }, accent: '#0e8a5f' },
|
||||
{ key: 'vip', nameKey: 'ticket.type.vip', descKey: 'ticket.type.vipDesc', price: 50000, priceKey: 'ticket.type.vipPrice', soldout: true, accent: '#101828' },
|
||||
];
|
||||
|
||||
const DEFAULT_EVENT_ID = 'e-2026-live';
|
||||
const STEP_KEYS = ['ticket.steps.s1', 'ticket.steps.s2', 'ticket.steps.s3', 'ticket.steps.s4'];
|
||||
const won = (n: number) => `₩${n.toLocaleString('ko-KR')}`;
|
||||
|
||||
/** NETWORK/NOT_FOUND/NOT_IMPLEMENTED 강등 시에만 샘플 폴백을 허용한다. */
|
||||
function isDegradable(e: unknown): boolean {
|
||||
return (
|
||||
e instanceof ApiRequestError &&
|
||||
(e.code === 'NETWORK' || e.code === 'NOT_FOUND' || e.code === 'NOT_IMPLEMENTED' || e.httpStatus === 404)
|
||||
);
|
||||
}
|
||||
|
||||
const SAMPLE_PRODUCTS: TicketProduct[] = [
|
||||
{ id: 'general', eventId: '', code: 'GEN', kind: 'GENERAL', name: '일반권', description: '일반 관람객 및 개인 참가자', price: 15000, currency: 'KRW', saleStart: null, saleEnd: null, totalQty: 5000, remaining: 3758, maxPerOrder: 10, onSale: true, soldOut: false, sortOrder: 0 },
|
||||
{ id: 'student', eventId: '', code: 'STU', kind: 'STUDENT', name: '학생/군인 할인권', description: '학생·군인 대상 할인(현장 신분 확인)', price: 8000, currency: 'KRW', saleStart: null, saleEnd: null, totalQty: 2000, remaining: 1679, maxPerOrder: 6, onSale: true, soldOut: false, sortOrder: 1 },
|
||||
{ id: 'group', eventId: '', code: 'GRP', kind: 'GROUP', name: '단체권 (10매 이상)', description: '기업·기관 단체 관람 전용', price: 9000, currency: 'KRW', saleStart: null, saleEnd: null, totalQty: 3000, remaining: 2840, maxPerOrder: 100, onSale: true, soldOut: false, sortOrder: 2 },
|
||||
];
|
||||
|
||||
function EventBanner() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@ -68,59 +71,113 @@ function EventBanner() {
|
||||
);
|
||||
}
|
||||
|
||||
function PurchaseView() {
|
||||
interface Selection {
|
||||
id: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
function PurchaseView({ eventId }: { eventId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(0);
|
||||
const [qty, setQty] = useState<Record<string, number>>({});
|
||||
const [sel, setSel] = useState<Selection | null>(null);
|
||||
const [pay, setPay] = useState('card');
|
||||
const [name, setName] = useState('');
|
||||
const [contact, setContact] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [agreePrivacy, setAgreePrivacy] = useState(false);
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
const setCount = (key: string, delta: number) =>
|
||||
setQty((prev) => {
|
||||
const v = Math.max(0, (prev[key] ?? 0) + delta);
|
||||
return { ...prev, [key]: v };
|
||||
});
|
||||
const productsQ = useQuery({
|
||||
queryKey: ['ticketProducts', eventId],
|
||||
queryFn: () => ticketApi.listProducts(eventId),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { subtotal, discount, total, count } = useMemo(() => {
|
||||
let sub = 0;
|
||||
let disc = 0;
|
||||
let cnt = 0;
|
||||
for (const t of TICKETS) {
|
||||
const q = qty[t.key] ?? 0;
|
||||
cnt += q;
|
||||
if (t.key === 'group') {
|
||||
const base = 10000 * q; // 정가 기준 예시
|
||||
sub += base;
|
||||
disc += base - t.price * q;
|
||||
} else {
|
||||
sub += t.price * q;
|
||||
const degraded = productsQ.isError && isDegradable(productsQ.error);
|
||||
const products: TicketProduct[] = degraded ? SAMPLE_PRODUCTS : (productsQ.data ?? []);
|
||||
|
||||
const orderMut = useMutation({
|
||||
mutationFn: () =>
|
||||
ticketApi.createOrder(eventId, {
|
||||
productId: sel!.id,
|
||||
qty: sel!.qty,
|
||||
buyerName: name.trim(),
|
||||
buyerContact: contact.trim(),
|
||||
buyerEmail: email.trim() || undefined,
|
||||
agreePrivacy,
|
||||
payMethod: pay,
|
||||
}),
|
||||
onSuccess: () => setStep(3),
|
||||
});
|
||||
|
||||
const selProduct = sel ? products.find((p) => p.id === sel.id) ?? null : null;
|
||||
const total = sel && selProduct ? selProduct.price * sel.qty : 0;
|
||||
const count = sel ? sel.qty : 0;
|
||||
const isFree = total === 0 && count > 0;
|
||||
|
||||
const setCount = (p: TicketProduct, delta: number) => {
|
||||
if (p.soldOut || !p.onSale) return;
|
||||
setSel((prev) => {
|
||||
if (prev && prev.id === p.id) {
|
||||
const q = prev.qty + delta;
|
||||
if (q <= 0) return null;
|
||||
return { id: p.id, qty: Math.min(q, p.maxPerOrder) };
|
||||
}
|
||||
}
|
||||
return { subtotal: sub, discount: disc, total: sub - disc, count: cnt };
|
||||
}, [qty]);
|
||||
if (delta > 0) return { id: p.id, qty: 1 };
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const isFree = total === 0;
|
||||
|
||||
const next = () => {
|
||||
// 무료 권종만이면 결제 단계 자동 스킵
|
||||
if (step === 1 && isFree) {
|
||||
setStep(3);
|
||||
const proceed = () => {
|
||||
setFormError('');
|
||||
if (step === 0) {
|
||||
if (!sel || count === 0) {
|
||||
setFormError(t('ticket.selectPrompt'));
|
||||
return;
|
||||
}
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
setStep((s) => Math.min(3, s + 1));
|
||||
if (step === 1) {
|
||||
if (!name.trim()) {
|
||||
setFormError(t('ticket.errName'));
|
||||
return;
|
||||
}
|
||||
if (!contact.trim()) {
|
||||
setFormError(t('ticket.errContact'));
|
||||
return;
|
||||
}
|
||||
if (!agreePrivacy) {
|
||||
setFormError(t('ticket.errPrivacy'));
|
||||
return;
|
||||
}
|
||||
if (isFree) {
|
||||
orderMut.mutate();
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
if (step === 2) {
|
||||
orderMut.mutate();
|
||||
}
|
||||
};
|
||||
const prev = () => {
|
||||
if (step === 3 && isFree) {
|
||||
|
||||
const back = () => {
|
||||
setFormError('');
|
||||
if (step === 3) {
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
setStep((s) => Math.max(0, s - 1));
|
||||
};
|
||||
|
||||
const result = orderMut.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EventBanner />
|
||||
|
||||
{/* 스텝 인디케이터 */}
|
||||
<div className="kxp-ticket__steps" role="list" aria-label={t('ticket.stepsAria')}>
|
||||
{STEP_KEYS.map((l, i) => (
|
||||
<span
|
||||
@ -135,7 +192,6 @@ function PurchaseView() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 로그인 배너 */}
|
||||
<div className="kxp-loginbanner">
|
||||
<IconInfo width={18} height={18} /> {t('ticket.loginBanner')}
|
||||
<a href="#top">{t('ticket.loginLink')}</a>
|
||||
@ -148,53 +204,79 @@ function PurchaseView() {
|
||||
<section>
|
||||
<h2 className="kxp-ticket__h2">
|
||||
<IconTicket width={20} height={20} /> {t('ticket.h2Type')}
|
||||
<span className="kxp-sample" style={{ marginLeft: 'auto' }}>
|
||||
{t('ticket.sample')}
|
||||
</span>
|
||||
{degraded && (
|
||||
<span className="kxp-sample" style={{ marginLeft: 'auto' }}>
|
||||
{t('ticket.sample')}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="kxp-tickrows">
|
||||
{TICKETS.map((tk) => (
|
||||
<div
|
||||
key={tk.key}
|
||||
className={`kxp-tickrow${tk.soldout ? ' kxp-tickrow--soldout' : ''}`}
|
||||
style={{ borderLeftColor: tk.soldout ? undefined : tk.accent }}
|
||||
>
|
||||
<div className="kxp-tickrow__info">
|
||||
<h3>
|
||||
{t(tk.nameKey)}
|
||||
{tk.badge && (
|
||||
<span className={tk.badge.kind === 'ai' ? 'kxp-chip kxp-chip--ai' : 'kxp-chip'}>
|
||||
{tk.badge.kind === 'ai' && <IconSparkles width={12} height={12} />}
|
||||
{t(tk.badge.labelKey)}
|
||||
</span>
|
||||
)}
|
||||
{tk.soldout && <span className="kxp-status kxp-status--soldout">{t('ticket.soldout')}</span>}
|
||||
</h3>
|
||||
<p>{t(tk.descKey)}</p>
|
||||
<div className="kxp-tickrow__price">{t(tk.priceKey)}</div>
|
||||
|
||||
{productsQ.isLoading ? (
|
||||
<div className="kxp-tickrows">
|
||||
{[0, 1, 2].map((k) => (
|
||||
<div key={k} className="kxp-tickrow">
|
||||
<div className="kxp-tickrow__info">
|
||||
<Skeleton height={18} width="40%" />
|
||||
<Skeleton height={12} width="70%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="kxp-stepper" aria-label={t('ticket.qtyAria', { name: t(tk.nameKey) })}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('ticket.decrease')}
|
||||
disabled={tk.soldout}
|
||||
onClick={() => setCount(tk.key, -1)}
|
||||
))}
|
||||
</div>
|
||||
) : productsQ.isError && !degraded ? (
|
||||
<ErrorState message={errorMessage(productsQ.error)} onRetry={() => void productsQ.refetch()} />
|
||||
) : products.length === 0 ? (
|
||||
<EmptyState title={t('ticket.emptyProducts')} icon={<IconTicket width={28} height={28} />} />
|
||||
) : (
|
||||
<div className="kxp-tickrows">
|
||||
{products.map((p) => {
|
||||
const disabled = p.soldOut || !p.onSale;
|
||||
const q = sel && sel.id === p.id ? sel.qty : 0;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`kxp-tickrow${disabled ? ' kxp-tickrow--soldout' : ''}`}
|
||||
style={{ borderLeftColor: disabled ? undefined : '#0066b3' }}
|
||||
>
|
||||
<IconMinus width={16} height={16} />
|
||||
</button>
|
||||
<span>{qty[tk.key] ?? 0}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('ticket.increase')}
|
||||
disabled={tk.soldout}
|
||||
onClick={() => setCount(tk.key, 1)}
|
||||
>
|
||||
<IconPlus width={16} height={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="kxp-tickrow__info">
|
||||
<h3>
|
||||
{p.name}
|
||||
{p.soldOut && <span className="kxp-status kxp-status--soldout">{t('ticket.soldout')}</span>}
|
||||
{!p.soldOut && !p.onSale && (
|
||||
<span className="kxp-status kxp-status--soldout">{t('ticket.saleClosed')}</span>
|
||||
)}
|
||||
</h3>
|
||||
{p.description && <p>{p.description}</p>}
|
||||
<div className="kxp-tickrow__price">
|
||||
{p.price === 0 ? t('ticket.free') : won(p.price)}
|
||||
<span style={{ marginLeft: 10, fontSize: 11, color: 'var(--color-neutral-500)' }}>
|
||||
{t('ticket.remaining', { n: Math.max(0, p.remaining) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kxp-stepper" aria-label={t('ticket.qtyAria', { name: p.name })}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('ticket.decrease')}
|
||||
disabled={disabled}
|
||||
onClick={() => setCount(p, -1)}
|
||||
>
|
||||
<IconMinus width={16} height={16} />
|
||||
</button>
|
||||
<span>{q}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('ticket.increase')}
|
||||
disabled={disabled}
|
||||
onClick={() => setCount(p, 1)}
|
||||
>
|
||||
<IconPlus width={16} height={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@ -208,20 +290,40 @@ function PurchaseView() {
|
||||
<div className="kxp-formgrid kxp-formgrid--2">
|
||||
<div className="kxp-field">
|
||||
<label htmlFor="tk-name">{t('ticket.bookerName')}</label>
|
||||
<input id="tk-name" className="kxp-input" placeholder={t('ticket.bookerNamePh')} />
|
||||
<input
|
||||
id="tk-name"
|
||||
className="kxp-input"
|
||||
placeholder={t('ticket.bookerNamePh')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="kxp-field">
|
||||
<label htmlFor="tk-tel">{t('ticket.bookerTel')}</label>
|
||||
<input id="tk-tel" className="kxp-input" type="tel" placeholder="010-0000-0000" />
|
||||
<input
|
||||
id="tk-tel"
|
||||
className="kxp-input"
|
||||
type="tel"
|
||||
placeholder="010-0000-0000"
|
||||
value={contact}
|
||||
onChange={(e) => setContact(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="kxp-field kxp-formgrid__full">
|
||||
<label htmlFor="tk-email">{t('ticket.bookerEmail')}</label>
|
||||
<input id="tk-email" className="kxp-input" type="email" placeholder="example@domain.com" />
|
||||
<input
|
||||
id="tk-email"
|
||||
className="kxp-input"
|
||||
type="email"
|
||||
placeholder="example@domain.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kxp-consent">
|
||||
<label className="kxp-check">
|
||||
<input type="checkbox" />
|
||||
<input type="checkbox" checked={agreePrivacy} onChange={(e) => setAgreePrivacy(e.target.checked)} />
|
||||
<span>
|
||||
{t('ticket.consentPrivacy')} <span className="kxp-check__req">{t('ticket.required')}</span>
|
||||
</span>
|
||||
@ -266,25 +368,46 @@ function PurchaseView() {
|
||||
)}
|
||||
|
||||
{/* Step 4: 완료 */}
|
||||
{step === 3 && (
|
||||
{step === 3 && result && (
|
||||
<section className="kxp-tikdone">
|
||||
<div className="kxp-done__ic" style={{ margin: '0 auto 16px' }}>
|
||||
<IconCheckCircle width={40} height={40} />
|
||||
</div>
|
||||
<h2 className="kxp-regcard__title">{t('ticket.doneTitle')}</h2>
|
||||
<div className="kxp-tikdone__no">{t('ticket.doneNo')}</div>
|
||||
<div className="kxp-tikdone__no">{result.orderNo}</div>
|
||||
<p className="kxp-regcard__sub">
|
||||
{t('ticket.doneSub', { count, total: won(total) })}
|
||||
{t('ticket.doneSub', { count: result.qty, total: won(result.totalAmount) })}
|
||||
</p>
|
||||
<div className="kxp-tikdone__qr">
|
||||
<IconQr width={110} height={110} />
|
||||
</div>
|
||||
<span className="kxp-sample">{t('ticket.sampleReady')}</span>
|
||||
<div className="kxp-formcard" style={{ textAlign: 'left', maxWidth: 420, margin: '4px auto 0' }}>
|
||||
<div className="kxp-sumrow">
|
||||
<span>{t('ticket.colType')}</span>
|
||||
<span>{result.productName}</span>
|
||||
</div>
|
||||
{result.approvalNo && (
|
||||
<div className="kxp-sumrow">
|
||||
<span>{t('ticket.approvalNo')}</span>
|
||||
<span>{result.approvalNo}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="kxp-sumrow">
|
||||
<span>{t('ticket.ticketCodes')}</span>
|
||||
<span style={{ textAlign: 'right' }}>
|
||||
{result.tickets.map((tk) => (
|
||||
<span key={tk.ticketCode} style={{ display: 'block', fontFamily: 'monospace' }}>
|
||||
{tk.ticketCode}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kxp-tikdone__actions">
|
||||
<a className="kxp-btn kxp-btn--outline" href="#top">
|
||||
{t('ticket.walletBtn')}
|
||||
</a>
|
||||
<a className="kxp-btn kxp-btn--primary" href="#top">
|
||||
<a className="kxp-btn kxp-btn--primary" href="/tickets/lookup">
|
||||
{t('ticket.manageBtn')} <IconArrowRight width={18} height={18} />
|
||||
</a>
|
||||
</div>
|
||||
@ -302,36 +425,42 @@ function PurchaseView() {
|
||||
</div>
|
||||
<div className="kxp-sumrow">
|
||||
<span>{t('ticket.subtotal')}</span>
|
||||
<span>{won(subtotal)}</span>
|
||||
</div>
|
||||
<div className="kxp-sumrow kxp-sumrow--discount">
|
||||
<span>{t('ticket.discount')}</span>
|
||||
<span>-{won(discount)}</span>
|
||||
<span>{won(total)}</span>
|
||||
</div>
|
||||
<div className="kxp-sumtotal">
|
||||
<small>{t('ticket.finalTotal')}</small>
|
||||
<strong>{won(total)}</strong>
|
||||
<strong>{isFree ? t('ticket.free') : won(total)}</strong>
|
||||
</div>
|
||||
|
||||
{(formError || orderMut.isError) && (
|
||||
<p className="kxp-formerror" role="alert" style={{ color: 'var(--color-danger-600, #d92d20)', fontSize: 12, margin: '10px 0 0' }}>
|
||||
{formError || errorMessage(orderMut.error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="kxp-btn kxp-btn--primary kxp-btn--block kxp-btn--lg"
|
||||
disabled={count === 0}
|
||||
onClick={next}
|
||||
disabled={count === 0 || orderMut.isPending}
|
||||
onClick={proceed}
|
||||
>
|
||||
{step === 0
|
||||
? t('ticket.nextStep')
|
||||
: step === 1
|
||||
? isFree
|
||||
? t('ticket.freeDone')
|
||||
: t('ticket.pay')
|
||||
: t('ticket.payProceed')}
|
||||
{orderMut.isPending
|
||||
? t('ticket.processing')
|
||||
: step === 0
|
||||
? t('ticket.nextStep')
|
||||
: step === 1
|
||||
? isFree
|
||||
? t('ticket.freeDone')
|
||||
: t('ticket.pay')
|
||||
: t('ticket.payProceed')}
|
||||
</button>
|
||||
{step > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="kxp-btn kxp-btn--outline kxp-btn--block"
|
||||
style={{ marginTop: 10 }}
|
||||
onClick={prev}
|
||||
onClick={back}
|
||||
disabled={orderMut.isPending}
|
||||
>
|
||||
{t('common.actions.prev')}
|
||||
</button>
|
||||
@ -348,8 +477,24 @@ function PurchaseView() {
|
||||
|
||||
function ManageView() {
|
||||
const { t } = useTranslation();
|
||||
const [looked, setLooked] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [orderNo, setOrderNo] = useState('');
|
||||
const [contact, setContact] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
const lookupMut = useMutation({
|
||||
mutationFn: () => ticketApi.lookup(orderNo.trim(), contact.trim()),
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
setFormError('');
|
||||
if (!orderNo.trim() || !contact.trim()) {
|
||||
setFormError(t('ticket.lookupHint'));
|
||||
return;
|
||||
}
|
||||
lookupMut.mutate();
|
||||
};
|
||||
|
||||
const r = lookupMut.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -360,47 +505,58 @@ function ManageView() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* 조회 폼 */}
|
||||
<div className="kxp-manage__lookup">
|
||||
<div className="kxp-manage__lookrow">
|
||||
<div className="kxp-field">
|
||||
<label htmlFor="mg-no">{t('ticket.lookNo')}</label>
|
||||
<input id="mg-no" className="kxp-input" placeholder={t('ticket.lookNoPh')} />
|
||||
<input
|
||||
id="mg-no"
|
||||
className="kxp-input"
|
||||
placeholder={t('ticket.lookNoPh')}
|
||||
value={orderNo}
|
||||
onChange={(e) => setOrderNo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="kxp-field">
|
||||
<label htmlFor="mg-email">{t('ticket.lookEmail')}</label>
|
||||
<input id="mg-email" className="kxp-input" type="email" placeholder={t('ticket.lookEmailPh')} />
|
||||
<label htmlFor="mg-contact">{t('ticket.lookContact')}</label>
|
||||
<input
|
||||
id="mg-contact"
|
||||
className="kxp-input"
|
||||
type="tel"
|
||||
placeholder={t('ticket.lookContactPh')}
|
||||
value={contact}
|
||||
onChange={(e) => setContact(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="kxp-btn kxp-btn--primary" onClick={() => setLooked(true)}>
|
||||
{t('ticket.lookBtn')}
|
||||
<button type="button" className="kxp-btn kxp-btn--primary" onClick={submit} disabled={lookupMut.isPending}>
|
||||
{lookupMut.isPending ? t('ticket.processing') : t('ticket.lookBtn')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', marginTop: 14 }}>
|
||||
<a href="#top" style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)', textDecoration: 'underline' }}>
|
||||
{t('ticket.loginToView')}
|
||||
</a>
|
||||
</div>
|
||||
{(formError || lookupMut.isError) && (
|
||||
<p role="alert" style={{ textAlign: 'center', marginTop: 12, color: 'var(--color-danger-600, #d92d20)', fontSize: 13 }}>
|
||||
{formError || (lookupMut.isError ? t('ticket.notFound') : '')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!looked ? (
|
||||
<p className="kxp-empty">{t('ticket.notLooked')}</p>
|
||||
{!r ? (
|
||||
<p className="kxp-empty">{t('ticket.lookupHint')}</p>
|
||||
) : (
|
||||
<div className="kxp-manage__grid">
|
||||
{/* 예매 상세 */}
|
||||
<div>
|
||||
<article className="kxp-bookcard">
|
||||
<div className="kxp-bookcard__head">
|
||||
<div>
|
||||
<span className="kxp-status kxp-status--done">{t('ticket.booked')}</span>
|
||||
<h2>{t('ticket.bannerTitle')}</h2>
|
||||
<h2>{r.productName}</h2>
|
||||
<span className="kxp-bookcard__no">
|
||||
{t('ticket.bookNoLabel')} <b>KTX-2026-018245</b>
|
||||
{t('ticket.bookNoLabel')} <b>{r.orderNo}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="kxp-bookcard__paid">
|
||||
<small>{t('ticket.paidAt')}</small>
|
||||
<br />
|
||||
<b>2025-10-24 14:32</b>
|
||||
<b>{(r.paidAt ?? r.orderedAt ?? '').replace('T', ' ').slice(0, 16) || '-'}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -413,30 +569,20 @@ function ManageView() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<div className="kxp-ticktable__cell">
|
||||
<span className="kxp-ticktable__qr">
|
||||
<IconQr width={22} height={22} />
|
||||
</span>
|
||||
일반권 (Early Bird)
|
||||
</div>
|
||||
</td>
|
||||
<td>2매</td>
|
||||
<td>₩ 30,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div className="kxp-ticktable__cell">
|
||||
<span className="kxp-ticktable__qr">
|
||||
<IconQr width={22} height={22} />
|
||||
</span>
|
||||
학생/군인 할인
|
||||
</div>
|
||||
</td>
|
||||
<td>1매</td>
|
||||
<td>₩ 10,000</td>
|
||||
</tr>
|
||||
{r.tickets.map((tk) => (
|
||||
<tr key={tk.ticketCode}>
|
||||
<td>
|
||||
<div className="kxp-ticktable__cell">
|
||||
<span className="kxp-ticktable__qr">
|
||||
<IconQr width={22} height={22} />
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>{tk.ticketCode}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{t('ticket.count', { n: 1 })}</td>
|
||||
<td>{won(r.unitPrice)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -446,20 +592,22 @@ function ManageView() {
|
||||
<div>
|
||||
<small>{t('ticket.bookerInfo')}</small>
|
||||
<br />
|
||||
<b>jo***@ex***.com · 010-****-1234</b>
|
||||
<b>
|
||||
{r.buyerNameMasked} · {r.buyerContactMasked}
|
||||
{r.buyerEmailMasked ? ` · ${r.buyerEmailMasked}` : ''}
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<small style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)' }}>{t('ticket.totalPaid')}</small>
|
||||
<div style={{ fontSize: 'var(--fs-h2)', fontWeight: 800, color: 'var(--color-primary-700)' }}>
|
||||
₩ 40,000
|
||||
{won(r.totalAmount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{/* 환불 정책 */}
|
||||
<aside>
|
||||
<div className="kxp-refund">
|
||||
<h3>
|
||||
@ -479,60 +627,23 @@ function ManageView() {
|
||||
<b>{t('ticket.refundDayVal')}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kxp-refund__est">
|
||||
<span>{t('ticket.refundEst')}</span>
|
||||
<strong>₩ 40,000</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="kxp-btn kxp-btn--danger kxp-btn--block"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
{t('ticket.cancelBooking')}
|
||||
</button>
|
||||
<p className="kxp-refund__note">{t('ticket.refundNote')}</p>
|
||||
</div>
|
||||
|
||||
<div className="kxp-ai-helper">
|
||||
<IconSparkles width={18} height={18} />
|
||||
<div>
|
||||
<strong>{t('ticket.aiHelper')}</strong>
|
||||
<p>{t('ticket.aiHelperDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmOpen && (
|
||||
<div className="kxp-modal" role="dialog" aria-modal="true" aria-label={t('ticket.cancelModalAria')}>
|
||||
<div className="kxp-modal__scrim" onClick={() => setConfirmOpen(false)} aria-hidden />
|
||||
<div className="kxp-modal__box">
|
||||
<h3>{t('ticket.cancelModalTitle')}</h3>
|
||||
<p>{t('ticket.cancelModalDesc')}</p>
|
||||
<div className="kxp-modal__actions">
|
||||
<button type="button" className="kxp-btn kxp-btn--outline" onClick={() => setConfirmOpen(false)}>
|
||||
{t('common.actions.close')}
|
||||
</button>
|
||||
<button type="button" className="kxp-btn kxp-btn--danger" onClick={() => setConfirmOpen(false)}>
|
||||
{t('ticket.cancelConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PublicTicketPage() {
|
||||
const { t } = useTranslation();
|
||||
const [view, setView] = useState<'purchase' | 'manage'>('purchase');
|
||||
const { eventId } = useParams<{ eventId: string }>();
|
||||
const [view, setView] = useState<'purchase' | 'manage'>(eventId ? 'purchase' : 'manage');
|
||||
|
||||
return (
|
||||
<PublicShell active="visit" cta={t('ticket.viewPurchase')} search={false}>
|
||||
<div className={view === 'purchase' ? 'kxp-ticket' : 'kxp-manage'}>
|
||||
{/* 뷰 전환 */}
|
||||
<div className="kxp-daytabs" style={{ justifyContent: 'center', marginBottom: 24 }} role="tablist" aria-label={t('ticket.viewSwitchAria')}>
|
||||
<button
|
||||
type="button"
|
||||
@ -554,7 +665,7 @@ export function PublicTicketPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{view === 'purchase' ? <PurchaseView /> : <ManageView />}
|
||||
{view === 'purchase' ? <PurchaseView eventId={eventId ?? DEFAULT_EVENT_ID} /> : <ManageView />}
|
||||
</div>
|
||||
</PublicShell>
|
||||
);
|
||||
|
||||
99
src/frontend/src/screens/public/ticketApi.ts
Normal file
99
src/frontend/src/screens/public/ticketApi.ts
Normal file
@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 공개 입장권(티켓) API 클라이언트 — 전 경로 비인증 공개(/api/public/tickets/**).
|
||||
* 백엔드: com.zioinfo.kintex.ticket (V50). 응답은 ApiResponse.data 로 언랩됨.
|
||||
* 보안: 응답의 구매자 정보는 서버 마스킹 필드만 소비(원문 없음).
|
||||
*/
|
||||
import { api } from '../../api/client';
|
||||
|
||||
/** 판매 상품 — remaining/onSale/soldOut 은 서버 권위. */
|
||||
export interface TicketProduct {
|
||||
id: string;
|
||||
eventId: string;
|
||||
code: string;
|
||||
kind: string; // GENERAL/STUDENT/GROUP/VIP/BUYER
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
currency: string;
|
||||
saleStart: string | null;
|
||||
saleEnd: string | null;
|
||||
totalQty: number;
|
||||
remaining: number;
|
||||
maxPerOrder: number;
|
||||
onSale: boolean;
|
||||
soldOut: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface IssuedTicket {
|
||||
ticketCode: string;
|
||||
seq: number;
|
||||
used: boolean;
|
||||
}
|
||||
|
||||
export interface TicketOrderRequest {
|
||||
productId: string;
|
||||
qty: number;
|
||||
buyerName: string;
|
||||
buyerContact: string;
|
||||
buyerEmail?: string;
|
||||
agreePrivacy: boolean;
|
||||
payMethod?: string; // card/easy/bank (무료권은 서버가 free 처리)
|
||||
}
|
||||
|
||||
export interface TicketOrderResult {
|
||||
orderNo: string;
|
||||
status: string; // PAID
|
||||
eventId: string;
|
||||
productName: string;
|
||||
qty: number;
|
||||
unitPrice: number;
|
||||
totalAmount: number;
|
||||
payMethod: string | null;
|
||||
approvalNo: string | null;
|
||||
buyerNameMasked: string | null;
|
||||
buyerContactMasked: string | null;
|
||||
buyerEmailMasked: string | null;
|
||||
orderedAt: string | null;
|
||||
tickets: IssuedTicket[];
|
||||
}
|
||||
|
||||
export interface TicketLookupResult {
|
||||
orderNo: string;
|
||||
status: string;
|
||||
eventId: string;
|
||||
productName: string;
|
||||
qty: number;
|
||||
unitPrice: number;
|
||||
totalAmount: number;
|
||||
payMethod: string | null;
|
||||
buyerNameMasked: string | null;
|
||||
buyerContactMasked: string | null;
|
||||
buyerEmailMasked: string | null;
|
||||
orderedAt: string | null;
|
||||
paidAt: string | null;
|
||||
tickets: IssuedTicket[];
|
||||
}
|
||||
|
||||
const opt = { anonymous: true } as const;
|
||||
|
||||
export const ticketApi = {
|
||||
/** 행사 판매중 상품 목록(잔여수량·판매기간 서버 권위). */
|
||||
listProducts: (eventId: string) =>
|
||||
api.get<TicketProduct[]>(`/api/public/tickets/${encodeURIComponent(eventId)}`, opt),
|
||||
|
||||
/** 예매(재고차감→PG승인→발권). 실패 시 봉투 error(code)로 강등. */
|
||||
createOrder: (eventId: string, payload: TicketOrderRequest) =>
|
||||
api.post<TicketOrderResult>(
|
||||
`/api/public/tickets/${encodeURIComponent(eventId)}/orders`,
|
||||
payload,
|
||||
opt,
|
||||
),
|
||||
|
||||
/** 예매 조회 — 주문번호 + 연락처(하이픈 무관). 불일치·부재는 NOT_FOUND. */
|
||||
lookup: (orderNo: string, contact: string) =>
|
||||
api.get<TicketLookupResult>(
|
||||
`/api/public/tickets/lookup?orderNo=${encodeURIComponent(orderNo)}&contact=${encodeURIComponent(contact)}`,
|
||||
opt,
|
||||
),
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user