Extract ALREADY_EXISTS guard and drop duplicate wallet read
- isAlreadyExistsError를 pointService에서 middleware/errors로 이동 — 레포지토리가 서비스를 import하는 계층 역전 없이 양쪽에서 쓰기 위함. pointService는 re-export로 기존 호출부 호환 유지 - createOrder에서 getWalletTx 사전 호출과 잔액 사전 검사 제거 — applyPointChangesTx가 같은 트랜잭션에서 동일한 HttpError(409, INSUFFICIENT_BALANCE)를 던지므로 동작은 같고 wallet 문서 tx.get이 2회에서 1회로 준다
This commit is contained in:
parent
a9634b7229
commit
aa53a5b070
@ -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<string, unknown> = err.code
|
||||
|
||||
@ -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<string, DocumentSnapshot>(); 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 };
|
||||
|
||||
@ -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<WalletDoc | null> {
|
||||
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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user