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. 기획에 있음.
|
- 정본: PLANNING M18 §1A(테넌트 온보딩), design.md SCR-A9. 기획에 있음.
|
||||||
- **해소:** `tenantApi`(list/create) 라이브 배선 — react-query 목록(로딩/에러/빈 상태), 등록 모달(서버 권위 검증+클라 미러), KxDataTable(검색·정렬·CSV), `tenant.*` i18n 17키×4로케일. 하드코딩·disabled·샘플 배너 제거.
|
- **해소:** `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`)
|
- 파일: `src/frontend/src/screens/public/PublicTicketPage.tsx:5`(주석 "티켓·재고·결제 API 부재 → 샘플"), `:36`(하드코딩 `TICKETS`)
|
||||||
- 라우트: `/tickets/:eventId/purchase`, `/tickets/lookup` (공개, 비로그인 고객 대면)
|
- 라우트: `/tickets/:eventId/purchase`, `/tickets/lookup` (공개, 비로그인 고객 대면)
|
||||||
- 유형: 미구현 API(백엔드 컨트롤러 부재 — grep "ticket" in backend = 0) + PG 미연동
|
- 유형: 미구현 API(백엔드 컨트롤러 부재 — grep "ticket" in backend = 0) + PG 미연동
|
||||||
- 정본: PLANNING M10(관람객)·M9(정산) design.md SCR-P7. 기획에 있음.
|
- 정본: 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)
|
### P2 — 부분 미개발(정상 degraded/planned)
|
||||||
|
|
||||||
@ -95,7 +96,7 @@
|
|||||||
| 순위 | ID | 작업 | 담당 |
|
| 순위 | ID | 작업 | 담당 |
|
||||||
|------|-----|------|------|
|
|------|-----|------|------|
|
||||||
| 1 | ~~G-01~~ ✅ | TenantAdminPage → `/api/admin/tenants` GET/POST 배선(하드코딩·disabled 제거) — 2026-07-13 해소 | **kintex-frontend-dev** |
|
| 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) |
|
| 3 | G-04 | 캠페인 실발송(SMTP, 수신동의·상한) | kintex-backend-dev(marketing) |
|
||||||
| 4 | G-03 | CMS AI 자동번역 배선(AiTextRouter) | kintex-ai-dev |
|
| 4 | G-03 | CMS AI 자동번역 배선(AiTextRouter) | kintex-ai-dev |
|
||||||
| 5 | G-06/G-07 | 회의 STT·통합검색 자연어 AI | kintex-ai-dev |
|
| 5 | G-06/G-07 | 회의 STT·통합검색 자연어 AI | kintex-ai-dev |
|
||||||
|
|||||||
@ -38,6 +38,22 @@ public final class Masking {
|
|||||||
return head + "***" + domain;
|
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 안전. */
|
/** 203.0.113.24 → 203.0.113.* (IPv6 등은 앞 절반만). null 안전. */
|
||||||
public static String maskIp(String ip) {
|
public static String maskIp(String ip) {
|
||||||
if (ip == null || ip.isBlank()) {
|
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",
|
"cancelModalAria": "Confirm booking cancellation",
|
||||||
"cancelModalTitle": "Cancel this booking?",
|
"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.",
|
"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": {
|
"login": {
|
||||||
"title": "Log In",
|
"title": "Log In",
|
||||||
|
|||||||
@ -686,7 +686,22 @@
|
|||||||
"cancelModalAria": "予約キャンセルの確認",
|
"cancelModalAria": "予約キャンセルの確認",
|
||||||
"cancelModalTitle": "予約をキャンセルしますか?",
|
"cancelModalTitle": "予約をキャンセルしますか?",
|
||||||
"cancelModalDesc": "D-15 基準で100%返金(₩40,000)が適用されます。返金は元の決済手段へ PG 社を通じて処理され、本サービス連携時に実際の返金が行われます。",
|
"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": {
|
"login": {
|
||||||
"title": "ログイン",
|
"title": "ログイン",
|
||||||
|
|||||||
@ -686,7 +686,22 @@
|
|||||||
"cancelModalAria": "예매 취소 확인",
|
"cancelModalAria": "예매 취소 확인",
|
||||||
"cancelModalTitle": "예매를 취소하시겠습니까?",
|
"cancelModalTitle": "예매를 취소하시겠습니까?",
|
||||||
"cancelModalDesc": "D-15 기준 100% 환불(₩40,000)이 적용됩니다. 환불은 원결제수단으로 PG사를 통해 처리되며, 실서비스 연동 시 실제 환불이 진행됩니다.",
|
"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": {
|
"login": {
|
||||||
"title": "로그인",
|
"title": "로그인",
|
||||||
|
|||||||
@ -686,7 +686,22 @@
|
|||||||
"cancelModalAria": "确认取消预订",
|
"cancelModalAria": "确认取消预订",
|
||||||
"cancelModalTitle": "确定取消此预订吗?",
|
"cancelModalTitle": "确定取消此预订吗?",
|
||||||
"cancelModalDesc": "按 D-15 标准适用100%退款(₩40,000)。退款将通过 PG 公司按原支付方式处理,正式服务接入时进行实际退款。",
|
"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": {
|
"login": {
|
||||||
"title": "登录",
|
"title": "登录",
|
||||||
|
|||||||
@ -1,12 +1,19 @@
|
|||||||
/*
|
/*
|
||||||
* SCR-P7 입장권 예매 (M10·M9) — 비로그인 공개.
|
* SCR-P7 입장권 예매 (M10·M9) — 비로그인 공개.
|
||||||
* design.md §3B. 예매 플로우(권종→예매자→결제→완료) + 예매 확인·취소(manage) 뷰.
|
* design.md §3B. 예매 플로우(권종→예매자→결제→완료) + 예매 조회(주문번호+연락처).
|
||||||
* 결제 PG 미연동 — 데모 플로우(카드 입력 UI 없음, PG 위임 고지 상시). PII 마스킹.
|
* 라이브: GET /api/public/tickets/{eventId}, POST .../orders, GET .../lookup (ticketApi).
|
||||||
* 데이터: 티켓·재고·결제 API 부재 → 샘플 데이터.
|
* 결제는 Mock PG(백엔드 어댑터) — 데모 승인. 응답의 구매자 정보는 서버 마스킹 필드만 소비(PII 원문 없음).
|
||||||
|
* 3상태(로딩/빈/에러) 준수. 목록은 NETWORK/NOT_FOUND 강등 시에만 샘플 폴백(배지 표기).
|
||||||
*/
|
*/
|
||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { PublicShell } from './PublicShell';
|
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 {
|
import {
|
||||||
IconTicket,
|
IconTicket,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
@ -18,31 +25,27 @@ import {
|
|||||||
IconCheckCircle,
|
IconCheckCircle,
|
||||||
IconQr,
|
IconQr,
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconSparkles,
|
|
||||||
IconPerson,
|
IconPerson,
|
||||||
} from './publicIcons';
|
} from './publicIcons';
|
||||||
|
|
||||||
interface TicketType {
|
const DEFAULT_EVENT_ID = 'e-2026-live';
|
||||||
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 STEP_KEYS = ['ticket.steps.s1', 'ticket.steps.s2', 'ticket.steps.s3', 'ticket.steps.s4'];
|
const STEP_KEYS = ['ticket.steps.s1', 'ticket.steps.s2', 'ticket.steps.s3', 'ticket.steps.s4'];
|
||||||
const won = (n: number) => `₩${n.toLocaleString('ko-KR')}`;
|
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() {
|
function EventBanner() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
@ -68,59 +71,113 @@ function EventBanner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PurchaseView() {
|
interface Selection {
|
||||||
|
id: string;
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PurchaseView({ eventId }: { eventId: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [step, setStep] = useState(0);
|
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 [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) =>
|
const productsQ = useQuery({
|
||||||
setQty((prev) => {
|
queryKey: ['ticketProducts', eventId],
|
||||||
const v = Math.max(0, (prev[key] ?? 0) + delta);
|
queryFn: () => ticketApi.listProducts(eventId),
|
||||||
return { ...prev, [key]: v };
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { subtotal, discount, total, count } = useMemo(() => {
|
const degraded = productsQ.isError && isDegradable(productsQ.error);
|
||||||
let sub = 0;
|
const products: TicketProduct[] = degraded ? SAMPLE_PRODUCTS : (productsQ.data ?? []);
|
||||||
let disc = 0;
|
|
||||||
let cnt = 0;
|
const orderMut = useMutation({
|
||||||
for (const t of TICKETS) {
|
mutationFn: () =>
|
||||||
const q = qty[t.key] ?? 0;
|
ticketApi.createOrder(eventId, {
|
||||||
cnt += q;
|
productId: sel!.id,
|
||||||
if (t.key === 'group') {
|
qty: sel!.qty,
|
||||||
const base = 10000 * q; // 정가 기준 예시
|
buyerName: name.trim(),
|
||||||
sub += base;
|
buyerContact: contact.trim(),
|
||||||
disc += base - t.price * q;
|
buyerEmail: email.trim() || undefined,
|
||||||
} else {
|
agreePrivacy,
|
||||||
sub += t.price * q;
|
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) };
|
||||||
}
|
}
|
||||||
}
|
if (delta > 0) return { id: p.id, qty: 1 };
|
||||||
return { subtotal: sub, discount: disc, total: sub - disc, count: cnt };
|
return prev;
|
||||||
}, [qty]);
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const isFree = total === 0;
|
const proceed = () => {
|
||||||
|
setFormError('');
|
||||||
const next = () => {
|
if (step === 0) {
|
||||||
// 무료 권종만이면 결제 단계 자동 스킵
|
if (!sel || count === 0) {
|
||||||
if (step === 1 && isFree) {
|
setFormError(t('ticket.selectPrompt'));
|
||||||
setStep(3);
|
return;
|
||||||
|
}
|
||||||
|
setStep(1);
|
||||||
return;
|
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);
|
setStep(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStep((s) => Math.max(0, s - 1));
|
setStep((s) => Math.max(0, s - 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const result = orderMut.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EventBanner />
|
<EventBanner />
|
||||||
|
|
||||||
{/* 스텝 인디케이터 */}
|
|
||||||
<div className="kxp-ticket__steps" role="list" aria-label={t('ticket.stepsAria')}>
|
<div className="kxp-ticket__steps" role="list" aria-label={t('ticket.stepsAria')}>
|
||||||
{STEP_KEYS.map((l, i) => (
|
{STEP_KEYS.map((l, i) => (
|
||||||
<span
|
<span
|
||||||
@ -135,7 +192,6 @@ function PurchaseView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 로그인 배너 */}
|
|
||||||
<div className="kxp-loginbanner">
|
<div className="kxp-loginbanner">
|
||||||
<IconInfo width={18} height={18} /> {t('ticket.loginBanner')}
|
<IconInfo width={18} height={18} /> {t('ticket.loginBanner')}
|
||||||
<a href="#top">{t('ticket.loginLink')}</a>
|
<a href="#top">{t('ticket.loginLink')}</a>
|
||||||
@ -148,53 +204,79 @@ function PurchaseView() {
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="kxp-ticket__h2">
|
<h2 className="kxp-ticket__h2">
|
||||||
<IconTicket width={20} height={20} /> {t('ticket.h2Type')}
|
<IconTicket width={20} height={20} /> {t('ticket.h2Type')}
|
||||||
<span className="kxp-sample" style={{ marginLeft: 'auto' }}>
|
{degraded && (
|
||||||
{t('ticket.sample')}
|
<span className="kxp-sample" style={{ marginLeft: 'auto' }}>
|
||||||
</span>
|
{t('ticket.sample')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="kxp-tickrows">
|
|
||||||
{TICKETS.map((tk) => (
|
{productsQ.isLoading ? (
|
||||||
<div
|
<div className="kxp-tickrows">
|
||||||
key={tk.key}
|
{[0, 1, 2].map((k) => (
|
||||||
className={`kxp-tickrow${tk.soldout ? ' kxp-tickrow--soldout' : ''}`}
|
<div key={k} className="kxp-tickrow">
|
||||||
style={{ borderLeftColor: tk.soldout ? undefined : tk.accent }}
|
<div className="kxp-tickrow__info">
|
||||||
>
|
<Skeleton height={18} width="40%" />
|
||||||
<div className="kxp-tickrow__info">
|
<Skeleton height={12} width="70%" />
|
||||||
<h3>
|
</div>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-stepper" aria-label={t('ticket.qtyAria', { name: t(tk.nameKey) })}>
|
))}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
) : productsQ.isError && !degraded ? (
|
||||||
aria-label={t('ticket.decrease')}
|
<ErrorState message={errorMessage(productsQ.error)} onRetry={() => void productsQ.refetch()} />
|
||||||
disabled={tk.soldout}
|
) : products.length === 0 ? (
|
||||||
onClick={() => setCount(tk.key, -1)}
|
<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} />
|
<div className="kxp-tickrow__info">
|
||||||
</button>
|
<h3>
|
||||||
<span>{qty[tk.key] ?? 0}</span>
|
{p.name}
|
||||||
<button
|
{p.soldOut && <span className="kxp-status kxp-status--soldout">{t('ticket.soldout')}</span>}
|
||||||
type="button"
|
{!p.soldOut && !p.onSale && (
|
||||||
aria-label={t('ticket.increase')}
|
<span className="kxp-status kxp-status--soldout">{t('ticket.saleClosed')}</span>
|
||||||
disabled={tk.soldout}
|
)}
|
||||||
onClick={() => setCount(tk.key, 1)}
|
</h3>
|
||||||
>
|
{p.description && <p>{p.description}</p>}
|
||||||
<IconPlus width={16} height={16} />
|
<div className="kxp-tickrow__price">
|
||||||
</button>
|
{p.price === 0 ? t('ticket.free') : won(p.price)}
|
||||||
</div>
|
<span style={{ marginLeft: 10, fontSize: 11, color: 'var(--color-neutral-500)' }}>
|
||||||
</div>
|
{t('ticket.remaining', { n: Math.max(0, p.remaining) })}
|
||||||
))}
|
</span>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -208,20 +290,40 @@ function PurchaseView() {
|
|||||||
<div className="kxp-formgrid kxp-formgrid--2">
|
<div className="kxp-formgrid kxp-formgrid--2">
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="tk-name">{t('ticket.bookerName')}</label>
|
<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>
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="tk-tel">{t('ticket.bookerTel')}</label>
|
<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>
|
||||||
<div className="kxp-field kxp-formgrid__full">
|
<div className="kxp-field kxp-formgrid__full">
|
||||||
<label htmlFor="tk-email">{t('ticket.bookerEmail')}</label>
|
<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>
|
</div>
|
||||||
<div className="kxp-consent">
|
<div className="kxp-consent">
|
||||||
<label className="kxp-check">
|
<label className="kxp-check">
|
||||||
<input type="checkbox" />
|
<input type="checkbox" checked={agreePrivacy} onChange={(e) => setAgreePrivacy(e.target.checked)} />
|
||||||
<span>
|
<span>
|
||||||
{t('ticket.consentPrivacy')} <span className="kxp-check__req">{t('ticket.required')}</span>
|
{t('ticket.consentPrivacy')} <span className="kxp-check__req">{t('ticket.required')}</span>
|
||||||
</span>
|
</span>
|
||||||
@ -266,25 +368,46 @@ function PurchaseView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 4: 완료 */}
|
{/* Step 4: 완료 */}
|
||||||
{step === 3 && (
|
{step === 3 && result && (
|
||||||
<section className="kxp-tikdone">
|
<section className="kxp-tikdone">
|
||||||
<div className="kxp-done__ic" style={{ margin: '0 auto 16px' }}>
|
<div className="kxp-done__ic" style={{ margin: '0 auto 16px' }}>
|
||||||
<IconCheckCircle width={40} height={40} />
|
<IconCheckCircle width={40} height={40} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="kxp-regcard__title">{t('ticket.doneTitle')}</h2>
|
<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">
|
<p className="kxp-regcard__sub">
|
||||||
{t('ticket.doneSub', { count, total: won(total) })}
|
{t('ticket.doneSub', { count: result.qty, total: won(result.totalAmount) })}
|
||||||
</p>
|
</p>
|
||||||
<div className="kxp-tikdone__qr">
|
<div className="kxp-tikdone__qr">
|
||||||
<IconQr width={110} height={110} />
|
<IconQr width={110} height={110} />
|
||||||
</div>
|
</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">
|
<div className="kxp-tikdone__actions">
|
||||||
<a className="kxp-btn kxp-btn--outline" href="#top">
|
<a className="kxp-btn kxp-btn--outline" href="#top">
|
||||||
{t('ticket.walletBtn')}
|
{t('ticket.walletBtn')}
|
||||||
</a>
|
</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} />
|
{t('ticket.manageBtn')} <IconArrowRight width={18} height={18} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -302,36 +425,42 @@ function PurchaseView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="kxp-sumrow">
|
<div className="kxp-sumrow">
|
||||||
<span>{t('ticket.subtotal')}</span>
|
<span>{t('ticket.subtotal')}</span>
|
||||||
<span>{won(subtotal)}</span>
|
<span>{won(total)}</span>
|
||||||
</div>
|
|
||||||
<div className="kxp-sumrow kxp-sumrow--discount">
|
|
||||||
<span>{t('ticket.discount')}</span>
|
|
||||||
<span>-{won(discount)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-sumtotal">
|
<div className="kxp-sumtotal">
|
||||||
<small>{t('ticket.finalTotal')}</small>
|
<small>{t('ticket.finalTotal')}</small>
|
||||||
<strong>{won(total)}</strong>
|
<strong>{isFree ? t('ticket.free') : won(total)}</strong>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="kxp-btn kxp-btn--primary kxp-btn--block kxp-btn--lg"
|
className="kxp-btn kxp-btn--primary kxp-btn--block kxp-btn--lg"
|
||||||
disabled={count === 0}
|
disabled={count === 0 || orderMut.isPending}
|
||||||
onClick={next}
|
onClick={proceed}
|
||||||
>
|
>
|
||||||
{step === 0
|
{orderMut.isPending
|
||||||
? t('ticket.nextStep')
|
? t('ticket.processing')
|
||||||
: step === 1
|
: step === 0
|
||||||
? isFree
|
? t('ticket.nextStep')
|
||||||
? t('ticket.freeDone')
|
: step === 1
|
||||||
: t('ticket.pay')
|
? isFree
|
||||||
: t('ticket.payProceed')}
|
? t('ticket.freeDone')
|
||||||
|
: t('ticket.pay')
|
||||||
|
: t('ticket.payProceed')}
|
||||||
</button>
|
</button>
|
||||||
{step > 0 && (
|
{step > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="kxp-btn kxp-btn--outline kxp-btn--block"
|
className="kxp-btn kxp-btn--outline kxp-btn--block"
|
||||||
style={{ marginTop: 10 }}
|
style={{ marginTop: 10 }}
|
||||||
onClick={prev}
|
onClick={back}
|
||||||
|
disabled={orderMut.isPending}
|
||||||
>
|
>
|
||||||
{t('common.actions.prev')}
|
{t('common.actions.prev')}
|
||||||
</button>
|
</button>
|
||||||
@ -348,8 +477,24 @@ function PurchaseView() {
|
|||||||
|
|
||||||
function ManageView() {
|
function ManageView() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [looked, setLooked] = useState(false);
|
const [orderNo, setOrderNo] = useState('');
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -360,47 +505,58 @@ function ManageView() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 조회 폼 */}
|
|
||||||
<div className="kxp-manage__lookup">
|
<div className="kxp-manage__lookup">
|
||||||
<div className="kxp-manage__lookrow">
|
<div className="kxp-manage__lookrow">
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="mg-no">{t('ticket.lookNo')}</label>
|
<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>
|
||||||
<div className="kxp-field">
|
<div className="kxp-field">
|
||||||
<label htmlFor="mg-email">{t('ticket.lookEmail')}</label>
|
<label htmlFor="mg-contact">{t('ticket.lookContact')}</label>
|
||||||
<input id="mg-email" className="kxp-input" type="email" placeholder={t('ticket.lookEmailPh')} />
|
<input
|
||||||
|
id="mg-contact"
|
||||||
|
className="kxp-input"
|
||||||
|
type="tel"
|
||||||
|
placeholder={t('ticket.lookContactPh')}
|
||||||
|
value={contact}
|
||||||
|
onChange={(e) => setContact(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="kxp-btn kxp-btn--primary" onClick={() => setLooked(true)}>
|
<button type="button" className="kxp-btn kxp-btn--primary" onClick={submit} disabled={lookupMut.isPending}>
|
||||||
{t('ticket.lookBtn')}
|
{lookupMut.isPending ? t('ticket.processing') : t('ticket.lookBtn')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'center', marginTop: 14 }}>
|
{(formError || lookupMut.isError) && (
|
||||||
<a href="#top" style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)', textDecoration: 'underline' }}>
|
<p role="alert" style={{ textAlign: 'center', marginTop: 12, color: 'var(--color-danger-600, #d92d20)', fontSize: 13 }}>
|
||||||
{t('ticket.loginToView')}
|
{formError || (lookupMut.isError ? t('ticket.notFound') : '')}
|
||||||
</a>
|
</p>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!looked ? (
|
{!r ? (
|
||||||
<p className="kxp-empty">{t('ticket.notLooked')}</p>
|
<p className="kxp-empty">{t('ticket.lookupHint')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="kxp-manage__grid">
|
<div className="kxp-manage__grid">
|
||||||
{/* 예매 상세 */}
|
|
||||||
<div>
|
<div>
|
||||||
<article className="kxp-bookcard">
|
<article className="kxp-bookcard">
|
||||||
<div className="kxp-bookcard__head">
|
<div className="kxp-bookcard__head">
|
||||||
<div>
|
<div>
|
||||||
<span className="kxp-status kxp-status--done">{t('ticket.booked')}</span>
|
<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">
|
<span className="kxp-bookcard__no">
|
||||||
{t('ticket.bookNoLabel')} <b>KTX-2026-018245</b>
|
{t('ticket.bookNoLabel')} <b>{r.orderNo}</b>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kxp-bookcard__paid">
|
<div className="kxp-bookcard__paid">
|
||||||
<small>{t('ticket.paidAt')}</small>
|
<small>{t('ticket.paidAt')}</small>
|
||||||
<br />
|
<br />
|
||||||
<b>2025-10-24 14:32</b>
|
<b>{(r.paidAt ?? r.orderedAt ?? '').replace('T', ' ').slice(0, 16) || '-'}</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -413,30 +569,20 @@ function ManageView() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
{r.tickets.map((tk) => (
|
||||||
<td>
|
<tr key={tk.ticketCode}>
|
||||||
<div className="kxp-ticktable__cell">
|
<td>
|
||||||
<span className="kxp-ticktable__qr">
|
<div className="kxp-ticktable__cell">
|
||||||
<IconQr width={22} height={22} />
|
<span className="kxp-ticktable__qr">
|
||||||
</span>
|
<IconQr width={22} height={22} />
|
||||||
일반권 (Early Bird)
|
</span>
|
||||||
</div>
|
<span style={{ fontFamily: 'monospace' }}>{tk.ticketCode}</span>
|
||||||
</td>
|
</div>
|
||||||
<td>2매</td>
|
</td>
|
||||||
<td>₩ 30,000</td>
|
<td>{t('ticket.count', { n: 1 })}</td>
|
||||||
</tr>
|
<td>{won(r.unitPrice)}</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>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@ -446,20 +592,22 @@ function ManageView() {
|
|||||||
<div>
|
<div>
|
||||||
<small>{t('ticket.bookerInfo')}</small>
|
<small>{t('ticket.bookerInfo')}</small>
|
||||||
<br />
|
<br />
|
||||||
<b>jo***@ex***.com · 010-****-1234</b>
|
<b>
|
||||||
|
{r.buyerNameMasked} · {r.buyerContactMasked}
|
||||||
|
{r.buyerEmailMasked ? ` · ${r.buyerEmailMasked}` : ''}
|
||||||
|
</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<small style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)' }}>{t('ticket.totalPaid')}</small>
|
<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)' }}>
|
<div style={{ fontSize: 'var(--fs-h2)', fontWeight: 800, color: 'var(--color-primary-700)' }}>
|
||||||
₩ 40,000
|
{won(r.totalAmount)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 환불 정책 */}
|
|
||||||
<aside>
|
<aside>
|
||||||
<div className="kxp-refund">
|
<div className="kxp-refund">
|
||||||
<h3>
|
<h3>
|
||||||
@ -479,60 +627,23 @@ function ManageView() {
|
|||||||
<b>{t('ticket.refundDayVal')}</b>
|
<b>{t('ticket.refundDayVal')}</b>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<p className="kxp-refund__note">{t('ticket.refundNote')}</p>
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
</div>
|
</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() {
|
export function PublicTicketPage() {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<PublicShell active="visit" cta={t('ticket.viewPurchase')} search={false}>
|
<PublicShell active="visit" cta={t('ticket.viewPurchase')} search={false}>
|
||||||
<div className={view === 'purchase' ? 'kxp-ticket' : 'kxp-manage'}>
|
<div className={view === 'purchase' ? 'kxp-ticket' : 'kxp-manage'}>
|
||||||
{/* 뷰 전환 */}
|
|
||||||
<div className="kxp-daytabs" style={{ justifyContent: 'center', marginBottom: 24 }} role="tablist" aria-label={t('ticket.viewSwitchAria')}>
|
<div className="kxp-daytabs" style={{ justifyContent: 'center', marginBottom: 24 }} role="tablist" aria-label={t('ticket.viewSwitchAria')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -554,7 +665,7 @@ export function PublicTicketPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'purchase' ? <PurchaseView /> : <ManageView />}
|
{view === 'purchase' ? <PurchaseView eventId={eventId ?? DEFAULT_EVENT_ID} /> : <ManageView />}
|
||||||
</div>
|
</div>
|
||||||
</PublicShell>
|
</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