From aa53a5b0702848d8bccb84b4f62a534985849f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 27 Jul 2026 13:24:35 +0900 Subject: [PATCH] Extract ALREADY_EXISTS guard and drop duplicate wallet read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isAlreadyExistsError를 pointService에서 middleware/errors로 이동 — 레포지토리가 서비스를 import하는 계층 역전 없이 양쪽에서 쓰기 위함. pointService는 re-export로 기존 호출부 호환 유지 - createOrder에서 getWalletTx 사전 호출과 잔액 사전 검사 제거 — applyPointChangesTx가 같은 트랜잭션에서 동일한 HttpError(409, INSUFFICIENT_BALANCE)를 던지므로 동작은 같고 wallet 문서 tx.get이 2회에서 1회로 준다 --- src/middleware/errors.ts | 10 ++++++++++ src/services/orderService.ts | 7 ++++--- src/services/pointService.ts | 4 ++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts index 0b1cb3f..ef14afb 100644 --- a/src/middleware/errors.ts +++ b/src/middleware/errors.ts @@ -11,6 +11,16 @@ export class HttpError extends Error { } } +/** + * Firestore ALREADY_EXISTS 판별 — `create()`를 멱등 쓰기로 쓰는 경로에서 + * "이미 존재"를 정상 분기로 처리하기 위한 것. 레포지토리·서비스 양쪽이 쓰므로 + * 계층 역전이 없는 여기에 둔다. + */ +export function isAlreadyExistsError(err: unknown): boolean { + const code = (err as { code?: unknown })?.code; + return code === 6 || code === "already-exists" || code === "ALREADY_EXISTS"; +} + export function sendError(res: express.Response, err: unknown): void { if (err instanceof HttpError) { const body: Record = err.code diff --git a/src/services/orderService.ts b/src/services/orderService.ts index 7aa4504..a61ea22 100644 --- a/src/services/orderService.ts +++ b/src/services/orderService.ts @@ -4,7 +4,6 @@ import { HttpError } from "../middleware/errors"; import { getOrder, listOrders, orderRef } from "../repositories/orderRepository"; import { productRef } from "../repositories/productRepository"; import { applyPointChangesTx } from "./pointService"; -import { getWalletTx } from "../repositories/walletRepository"; import { PointLedgerType } from "../types/points"; import { ORDER_TRANSITIONS, @@ -72,14 +71,16 @@ export async function createOrder(uid: string, input: CreateOrderInput) { const snapById = new Map(); for (const item of input.items) { if (!snapById.has(item.productId)) snapById.set(item.productId, await tx.get(productRef(item.productId))); } - const currentWallet = await getWalletTx(tx, uid); const orderItems: OrderItem[] = input.items.map((item) => { const p = snapById.get(item.productId)!.data() as ProductDoc | undefined; if (!p || !p.active || !p.redeemable) throw new HttpError(409, "product unavailable", "PRODUCT_UNAVAILABLE"); const option = resolveOrderOption(p, item.optionId); return { productId: item.productId, qty: item.qty, pointPrice: p.pointPrice, name: p.name, ...(option ? { optionId: option.id, optionName: option.name } : {}) }; }); - const total = orderItems.reduce((n, i) => n + i.pointPrice * i.qty, 0); if ((currentWallet?.availableBalance ?? 0) < total) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE"); + // 잔액 검사는 applyPointChangesTx가 담당한다(pointService.ts:24가 동일한 + // HttpError(409, INSUFFICIENT_BALANCE)를 던진다). 같은 트랜잭션 안이라 + // 사전 검사를 두면 동일한 wallet 문서를 두 번 tx.get 하는 것 외에 차이가 없다. + const total = orderItems.reduce((n, i) => n + i.pointPrice * i.qty, 0); // 주문은 즉시 확정 — 홀드 없이 바로 차감하고, 되돌림은 어드민 환불로만 처리한다. const debitTxId = `${uid}:order:${id}:debit`; const wallet = await applyPointChangesTx(tx, uid, [{ txId: debitTxId, type: PointLedgerType.OrderCapture, amount: total, orderId: id }]); const order: OrderDoc = { uid, items: orderItems, totalPoints: total, recipient: input.recipient, status: "confirmed", clientIdempotencyKey: input.clientIdempotencyKey, debitLedgerTxId: debitTxId, orderedAt: now, confirmedAt: now, statusHistory: [{ status: "confirmed", at: now, actor: uid }], createdAt: now, updatedAt: now }; diff --git a/src/services/pointService.ts b/src/services/pointService.ts index 1c1aaef..52bdbc5 100644 --- a/src/services/pointService.ts +++ b/src/services/pointService.ts @@ -1,12 +1,12 @@ import { Timestamp, type Transaction } from "firebase-admin/firestore"; import { firestore } from "../firebase"; -import { HttpError } from "../middleware/errors"; +import { HttpError, isAlreadyExistsError } from "../middleware/errors"; import { createLedgerEntryTx } from "../repositories/pointLedgerRepository"; import { getWalletTx, walletDocRef } from "../repositories/walletRepository"; import { OP_BY_TYPE, PointLedgerType, type PointChange, type PointLedgerEntry, type WalletDoc } from "../types/points"; const TX_ID = /^[A-Za-z0-9:_-]{1,240}$/; -export function isAlreadyExistsError(err: unknown): boolean { const code = (err as { code?: unknown })?.code; return code === 6 || code === "already-exists" || code === "ALREADY_EXISTS"; } +export { isAlreadyExistsError }; export async function applyPointChangesTx(tx: Transaction, uid: string, changes: PointChange[]): Promise { for (const c of changes) if (!TX_ID.test(c.txId) || !Number.isSafeInteger(c.amount) || c.amount <= 0) throw new HttpError(400, "invalid point change", "INVALID_POINT_CHANGE"); const existing = await getWalletTx(tx, uid);