Add reward goods catalog and point-based order flow

- 상품 카탈로그(products/) 신설: 공개 필드(name·pointPrice·이미지)와 재고(total/reserved/safety)를 한 문서로 관리, 목록·상세는 60초 인메모리 캐시로 서빙
- 주문 생성 시 포인트 예약(order_reserve)과 재고 예약을 단일 트랜잭션으로 원자 처리, 주문 ID를 uid_클라이언트멱등키로 고정해 더블탭·재시도 중복 생성 차단
- 주문 상태 머신(reserved→confirmed→preparing→shipped→delivered / cancelled / refunded) 전이 검증과 전이별 capture·release·refund 역거래 원장 기록
- reward HTTP 함수 신설: 지갑·원장 조회, 상품 목록·상세, 교환 자격, 주문 생성·목록·상세·취소 API
- 관리자 라우트 추가: 상품 등록·수정, 재고 조정(가용재고 음수 방지), 주문 상태 변경, 포인트 지급·회수
- Firestore 보안규칙(지갑 본인 read, 상품·주문은 API 전용)과 orders 복합 인덱스(uid+createdAt, status+createdAt) 추가
- 상품 4종 시드(seed:rewards)와 상세 이미지 업로드(upload:reward-assets) 스크립트 추가
This commit is contained in:
윤정민 2026-07-16 14:25:58 +09:00
parent c964572a9c
commit 6343a817af
16 changed files with 646 additions and 10 deletions

View File

@ -107,14 +107,32 @@
] ]
}, },
{ {
"collectionGroup": "pointLedger", "collectionGroup": "orders",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
"fields": [ "fields": [
{ "fieldPath": "createdAt", "order": "DESCENDING" }, { "fieldPath": "uid", "order": "ASCENDING" },
{ "fieldPath": "seq", "order": "DESCENDING" }, { "fieldPath": "createdAt", "order": "DESCENDING" }
{ "fieldPath": "__name__", "order": "DESCENDING" } ]
},
{
"collectionGroup": "orders",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
] ]
} }
], ],
"fieldOverrides": [] "fieldOverrides": [
} {
"collectionGroup": "products",
"fieldPath": "mainImages",
"indexes": []
},
{
"collectionGroup": "products",
"fieldPath": "detailImages",
"indexes": []
}
]
}

View File

@ -1,12 +1,20 @@
rules_version='2' rules_version='2'
// Reward data model:
// products/{id}, orders/{id}: Admin SDK API only.
// users/{uid}/wallet/current, pointLedger/{txId}:
// owner-readable and server-write-only. Order recipient PII is never client-readable.
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
match /users/{uid} { match /users/{uid} {
allow read: if request.auth != null && request.auth.uid == uid; allow read: if request.auth != null && request.auth.uid == uid;
allow update: if request.auth != null && request.auth.uid == uid && allow update: if request.auth != null && request.auth.uid == uid &&
request.resource.data.diff(resource.data) request.resource.data.diff(resource.data)
.affectedKeys().hasOnly(['fcmToken']); .affectedKeys().hasOnly(['fcmToken']) &&
(!('fcmToken' in request.resource.data) ||
(request.resource.data.fcmToken is string &&
request.resource.data.fcmToken.size() <= 4096));
allow create, delete: if false; allow create, delete: if false;
match /voteHistory/{date} { match /voteHistory/{date} {
@ -24,6 +32,11 @@ service cloud.firestore {
allow write: if false; allow write: if false;
} }
match /wallet/{id} {
allow read: if request.auth != null && request.auth.uid == uid;
allow write: if false;
}
// AI 채팅(짹) — 전부 Admin SDK(서버) 전용. 클라이언트가 직접 읽으면 // AI 채팅(짹) — 전부 Admin SDK(서버) 전용. 클라이언트가 직접 읽으면
// 프롬프트 노출, 직접 쓰면 한도 우회·이력 위조(영속 인젝션)가 가능해진다. // 프롬프트 노출, 직접 쓰면 한도 우회·이력 위조(영속 인젝션)가 가능해진다.
// 이력 조회도 GET /chat/messages 경유. (ai-chat-tech-design.md §4.6) // 이력 조회도 GET /chat/messages 경유. (ai-chat-tech-design.md §4.6)
@ -43,6 +56,12 @@ service cloud.firestore {
} }
} }
// 상품·재고 및 주문 PII는 모두 API 전용이다.
match /products/{productId} {
allow read, write: if false;
}
match /orders/{orderId} { allow read, write: if false; }
// 시스템 프롬프트·필터 사전·한도 설정 평문 보관 — 노출 시 §7.5 무력화 // 시스템 프롬프트·필터 사전·한도 설정 평문 보관 — 노출 시 §7.5 무력화
match /config/{doc} { match /config/{doc} {
allow read, write: if false; allow read, write: if false;

View File

@ -11,7 +11,9 @@
"logs": "firebase functions:log", "logs": "firebase functions:log",
"test": "firebase emulators:exec --only firestore,database,auth \"vitest run\"", "test": "firebase emulators:exec --only firestore,database,auth \"vitest run\"",
"test:watch": "vitest", "test:watch": "vitest",
"tools:sheet": "npx tsx scripts/chat-tools-sheet.ts" "tools:sheet": "npx tsx scripts/chat-tools-sheet.ts",
"seed:rewards": "npx tsx scripts/seed-reward-products.ts",
"upload:reward-assets": "npx tsx scripts/upload-reward-product-assets.ts"
}, },
"engines": { "engines": {
"node": "24" "node": "24"

View File

@ -0,0 +1,37 @@
import "./_bootstrap";
import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../src/firebase";
const products = [
{ id: "acrylic-keyring", name: "아크릴 키링", pointPrice: 3000, displayOrder: 1 },
{ id: "magsafe-tok", name: "맥세이프 톡", pointPrice: 6500, displayOrder: 2 },
{ id: "keyring-doll", name: "키링 인형", pointPrice: 11000, displayOrder: 3 },
{ id: "crossbag", name: "크로스백", pointPrice: 15000, displayOrder: 4 },
] as const;
async function main() {
const now = Timestamp.now();
for (const p of products) {
const product = firestore.doc(`products/${p.id}`);
const old = await product.get();
const existing = old.data();
const draft = old.exists ? {} : {
active: false,
redeemable: false,
mainImages: [],
detailImages: [],
};
await product.set({
name: p.name,
pointPrice: p.pointPrice,
displayOrder: p.displayOrder,
totalStock: existing?.totalStock ?? 0,
reservedStock: existing?.reservedStock ?? 0,
safetyStock: existing?.safetyStock ?? 0,
...draft,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
}, { merge: true });
}
}
main().then(() => console.log(`seeded ${products.length} reward products`)).catch((e) => { console.error(e); process.exitCode = 1; });

View File

@ -0,0 +1,217 @@
import { execFileSync, execSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
const projectId = "mmday-panit";
const storageBucket = "mmday-panit.firebasestorage.app";
const sourceRoot = process.env.REWARD_ASSET_DIR;
if (!sourceRoot) {
throw new Error("REWARD_ASSET_DIR is required");
}
const accessToken = (process.platform === "win32" ?
execSync("gcloud auth print-access-token", {
encoding: "utf8",
windowsHide: true,
}) :
execFileSync("gcloud", ["auth", "print-access-token"], {
encoding: "utf8",
windowsHide: true,
})).trim();
const collator = new Intl.Collator("ko", { numeric: true });
const firestoreBase =
`https://firestore.googleapis.com/v1/projects/${projectId}/databases/(default)/documents`;
interface ProductAssetSpec {
id: string;
name: string;
pointPrice: number;
displayOrder: number;
folder?: string;
singleMain?: string;
}
const products: ProductAssetSpec[] = [
{
id: "acrylic-keyring",
name: "아크릴 키링",
pointPrice: 3000,
displayOrder: 1,
folder: "아크릴키링 상세페이지",
},
{
id: "magsafe-tok",
name: "맥세이프 톡",
pointPrice: 6500,
displayOrder: 2,
folder: "맥세이프톡 상세페이지",
},
{
id: "keyring-doll",
name: "키링 인형",
pointPrice: 11000,
displayOrder: 3,
},
{
id: "crossbag",
name: "크로스백",
pointPrice: 15000,
displayOrder: 4,
folder: "크로스백 상세페이지",
singleMain: "메인.png",
},
];
async function imageFiles(dir: string): Promise<string[]> {
const names = await readdir(dir);
return names
.filter((name) => /\.(png|jpe?g|webp)$/i.test(name))
.sort(collator.compare)
.map((name) => path.join(dir, name));
}
function downloadUrl(objectPath: string, token: string): string {
return `https://firebasestorage.googleapis.com/v0/b/${storageBucket}/o/${encodeURIComponent(objectPath)}?alt=media&token=${token}`;
}
async function upload(localPath: string, objectPath: string): Promise<string> {
const token = randomUUID();
const boundary = `mmday-${randomUUID()}`;
const metadata = Buffer.from(JSON.stringify({
name: objectPath,
contentType: "image/png",
cacheControl: "public,max-age=31536000,immutable",
metadata: { firebaseStorageDownloadTokens: token },
}));
const image = await readFile(localPath);
const body = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n`),
metadata,
Buffer.from(`\r\n--${boundary}\r\nContent-Type: image/png\r\n\r\n`),
image,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const response = await fetch(
`https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(storageBucket)}/o?uploadType=multipart`,
{
method: "POST",
headers: {
authorization: `Bearer ${accessToken}`,
"content-type": `multipart/related; boundary=${boundary}`,
},
body,
}
);
if (!response.ok) {
throw new Error(`Storage upload failed ${response.status}: ${await response.text()}`);
}
return downloadUrl(objectPath, token);
}
async function uploadLimited(
files: string[],
destinationPrefix: string
): Promise<string[]> {
const output = new Array<string>(files.length);
let cursor = 0;
async function worker() {
while (cursor < files.length) {
const index = cursor++;
const ext = path.extname(files[index]).toLowerCase();
const destination = `${destinationPrefix}/${String(index + 1).padStart(2, "0")}${ext}`;
output[index] = await upload(files[index], destination);
console.log(`uploaded ${destination}`);
}
}
await Promise.all(Array.from({ length: Math.min(4, files.length) }, worker));
return output;
}
async function upsertProduct(
spec: ProductAssetSpec,
mainImages: string[],
detailImages: string[]
) {
const existingProduct = await getDocument(`products/${spec.id}`);
const now = new Date().toISOString();
const hasImages = mainImages.length > 0;
await patchDocument(`products/${spec.id}`, {
name: { stringValue: spec.name },
pointPrice: { integerValue: String(spec.pointPrice) },
active: { booleanValue: hasImages },
redeemable: { booleanValue: hasImages },
displayOrder: { integerValue: String(spec.displayOrder) },
mainImages: stringArray(mainImages),
detailImages: stringArray(detailImages),
totalStock: existingProduct?.fields?.totalStock ?? { integerValue: "0" },
reservedStock: existingProduct?.fields?.reservedStock ?? { integerValue: "0" },
safetyStock: existingProduct?.fields?.safetyStock ?? { integerValue: "0" },
createdAt: existingProduct?.fields?.createdAt ?? { timestampValue: now },
updatedAt: { timestampValue: now },
});
}
type FirestoreValue = Record<string, unknown>;
interface FirestoreDocument { fields?: Record<string, FirestoreValue> }
function stringArray(values: string[]): FirestoreValue {
return { arrayValue: { values: values.map((value) => ({ stringValue: value })) } };
}
async function getDocument(documentPath: string): Promise<FirestoreDocument | null> {
const response = await fetch(`${firestoreBase}/${documentPath}`, {
headers: { authorization: `Bearer ${accessToken}` },
});
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`Firestore read failed ${response.status}: ${await response.text()}`);
}
return await response.json() as FirestoreDocument;
}
async function patchDocument(
documentPath: string,
fields: Record<string, FirestoreValue>
): Promise<void> {
const response = await fetch(`${firestoreBase}/${documentPath}`, {
method: "PATCH",
headers: {
authorization: `Bearer ${accessToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ fields }),
});
if (!response.ok) {
throw new Error(`Firestore write failed ${response.status}: ${await response.text()}`);
}
}
async function main() {
for (const spec of products) {
let mainImages: string[] = [];
let detailImages: string[] = [];
if (spec.folder) {
const productDir = path.join(sourceRoot, spec.folder);
const mainFiles = spec.singleMain ?
[path.join(productDir, spec.singleMain)] :
await imageFiles(path.join(productDir, "메인"));
const detailFiles = await imageFiles(path.join(productDir, "상세페이지"));
[mainImages, detailImages] = await Promise.all([
uploadLimited(mainFiles, `reward-products/${spec.id}/main`),
uploadLimited(detailFiles, `reward-products/${spec.id}/detail`),
]);
}
await upsertProduct(spec, mainImages, detailImages);
console.log(
`saved ${spec.id}: main=${mainImages.length}, detail=${detailImages.length}`
);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -1,14 +1,66 @@
import { onRequest } from "firebase-functions/https"; import { onRequest } from "firebase-functions/https";
import { requireAdmin } from "../middleware/admin"; import { requireAdmin } from "../middleware/admin";
import { sendError } from "../middleware/errors"; import { HttpError, sendError } from "../middleware/errors";
import { markGameEnded } from "../services/gameResultService"; import { markGameEnded } from "../services/gameResultService";
import { adminPointChange } from "../services/adminPointService";
import { getWallet } from "../repositories/walletRepository";
import { listLedger } from "../repositories/pointLedgerRepository";
import { firestore } from "../firebase";
import { Timestamp } from "firebase-admin/firestore";
import { availableStock, productRef } from "../repositories/productRepository";
import type { OrderStatus, ProductDoc } from "../types/reward";
import { transitionOrder } from "../services/orderService";
import { invalidateRewardCatalog } from "../services/rewardCatalogService";
export const admin = onRequest(async (req, res) => { export const admin = onRequest(async (req, res) => {
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/"); const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
const tail = segs.slice(-2).join("/"); const tail = segs.slice(-2).join("/");
try { try {
await requireAdmin(req); const adminUid = await requireAdmin(req);
if ((tail === "points/credit" || tail === "points/debit") && req.method === "POST") { const { uid, amount, clientIdempotencyKey, reason, reversalOf } = req.body ?? {}; res.json(await adminPointChange(uid, amount, tail.endsWith("credit") ? "credit" : "debit", clientIdempotencyKey, reason, adminUid, reversalOf)); return; }
if (tail === "points/wallet" && req.method === "GET") { res.json(await getWallet(String(req.query.uid))); return; }
if (tail === "points/ledger" && req.method === "GET") { res.json(await listLedger(String(req.query.uid), Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined)); return; }
if (tail === "product/upsert" && req.method === "POST") {
const {
id, name, pointPrice, active, redeemable, displayOrder,
mainImages, detailImages,
} = req.body ?? {};
const validUrls = (value: unknown, max: number) =>
Array.isArray(value) && value.length <= max && value.every((url) =>
typeof url === "string" && url.length <= 2048 && /^https:\/\//.test(url)
);
const valid =
/^[A-Za-z0-9_-]{1,100}$/.test(id) &&
typeof name === "string" && name.length >= 1 && name.length <= 100 &&
Number.isSafeInteger(pointPrice) && pointPrice >= 1 &&
typeof active === "boolean" && typeof redeemable === "boolean" &&
Number.isSafeInteger(displayOrder) && displayOrder >= 0 &&
validUrls(mainImages, 10) && validUrls(detailImages, 30) &&
(!active || !redeemable || mainImages.length > 0);
if (!valid) throw new HttpError(400, "invalid product", "INVALID_INPUT");
const now = Timestamp.now();
await firestore.runTransaction(async (tx) => {
const productRef = firestore.doc(`products/${id}`);
const old = await tx.get(productRef);
const previous = old.data() as Partial<ProductDoc> | undefined;
tx.set(productRef, {
name, pointPrice, active, redeemable, displayOrder,
mainImages, detailImages,
totalStock: previous?.totalStock ?? 0,
reservedStock: previous?.reservedStock ?? 0,
safetyStock: previous?.safetyStock ?? 0,
createdAt: previous?.createdAt ?? now,
updatedAt: now,
});
});
invalidateRewardCatalog(id);
res.json({ id });
return;
}
if (tail === "stock/adjust" && req.method === "POST") { const { productId, deltaTotal, safetyStock } = req.body ?? {}; if (!/^[A-Za-z0-9_-]{1,100}$/.test(productId) || !Number.isSafeInteger(deltaTotal ?? 0) || (safetyStock !== undefined && (!Number.isSafeInteger(safetyStock) || safetyStock < 0))) throw new HttpError(400, "invalid stock adjustment", "INVALID_INPUT"); const result = await firestore.runTransaction(async (tx) => { const ref = productRef(productId); const snap = await tx.get(ref); if (!snap.exists) throw new HttpError(404, "product not found", "PRODUCT_NOT_FOUND"); const product = snap.data() as ProductDoc; const next = { totalStock: product.totalStock + (deltaTotal ?? 0), reservedStock: product.reservedStock, safetyStock: safetyStock ?? product.safetyStock, updatedAt: Timestamp.now() }; if (next.totalStock < 0 || availableStock(next) < 0) throw new HttpError(409, "invalid stock adjustment", "INVALID_STOCK"); tx.update(ref, next); return { ...next, availableStock: availableStock(next) }; }); res.json(result); return; }
if (tail === "order/status" && req.method === "POST") { const { orderId, status, restock } = req.body ?? {}; res.json(await transitionOrder(orderId, status as OrderStatus, adminUid, { admin: true, restock })); return; }
if (tail === "game/end" && req.method === "POST") { if (tail === "game/end" && req.method === "POST") {
const { gameId, winningTeamCode } = req.body ?? {}; const { gameId, winningTeamCode } = req.body ?? {};

View File

@ -0,0 +1,21 @@
import { onRequest } from "firebase-functions/https";
import { requireAuth } from "../middleware/auth";
import { sendError } from "../middleware/errors";
import { getWallet } from "../repositories/walletRepository";
import { listLedger } from "../repositories/pointLedgerRepository";
import { getCatalog, getCatalogProduct } from "../services/rewardCatalogService";
import { computeEligibility } from "../services/eligibilityService";
import { cancelOrder, createOrder, getOrder, listOrders } from "../services/orderService";
export const reward = onRequest(async (req, res) => { try { const uid = await requireAuth(req); const path = req.path.replace(/^\/+|\/+$/g, "");
if (req.method === "GET" && path === "wallet") { res.json((await getWallet(uid)) ?? { availableBalance: 0, reservedBalance: 0, totalEarned: 0, totalSpent: 0, version: 0 }); return; }
if (req.method === "GET" && path === "ledger") { res.json(await listLedger(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined)); return; }
if (req.method === "GET" && path === "products") { res.json(await getCatalog()); return; }
const product = path.match(/^products\/([^/]+)$/); if (req.method === "GET" && product) { const p = await getCatalogProduct(product[1]); if (!p) { res.status(404).json({ error: "PRODUCT_NOT_FOUND" }); return; } res.json(p); return; }
if (req.method === "GET" && path === "eligibility") { res.json(await computeEligibility(uid)); return; }
if (req.method === "POST" && path === "orders") { res.status(201).json(await createOrder(uid, req.body)); return; }
if (req.method === "GET" && path === "orders") { res.json(await listOrders(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined)); return; }
const order = path.match(/^orders\/([^/]+)$/); if (req.method === "GET" && order) { const o = await getOrder(order[1]); if (!o || o.uid !== uid) { res.status(404).json({ error: "ORDER_NOT_FOUND" }); return; } res.json(o); return; }
const cancel = path.match(/^orders\/([^/]+)\/cancel$/); if (req.method === "POST" && cancel) { res.json(await cancelOrder(uid, cancel[1])); return; }
res.status(404).json({ error: "not found" });
} catch (err) { sendError(res, err); } });

View File

@ -11,6 +11,7 @@ export { admin } from "./handlers/adminHandlers";
export { debug } from "./handlers/debugHandlers"; export { debug } from "./handlers/debugHandlers";
export { attendance } from "./handlers/attendanceHandlers"; export { attendance } from "./handlers/attendanceHandlers";
export { chat } from "./handlers/chatHandlers"; export { chat } from "./handlers/chatHandlers";
export { reward } from "./handlers/rewardHandlers";
export { kboDailyRefresh } from "./scheduled/kboRefresh"; export { kboDailyRefresh } from "./scheduled/kboRefresh";
export { dailyArchive } from "./scheduled/dailyArchive"; export { dailyArchive } from "./scheduled/dailyArchive";
export { accountPurge } from "./scheduled/accountPurge"; export { accountPurge } from "./scheduled/accountPurge";

View File

@ -0,0 +1,5 @@
import { firestore } from "../firebase";
import type { OrderDoc } from "../types/reward";
export function orderRef(id: string) { return firestore.doc(`orders/${id}`); }
export async function listOrders(uid: string, limit = 20, cursor?: string) { let q = firestore.collection("orders").where("uid", "==", uid).orderBy("createdAt", "desc").limit(Math.min(Math.max(limit, 1), 100)); if (cursor) { const c = await orderRef(cursor).get(); if (c.exists) q = q.startAfter(c) as typeof q; } const s = await q.get(); return { items: s.docs.map((d) => ({ id: d.id, ...(d.data() as OrderDoc) })), cursor: s.docs.length ? s.docs[s.docs.length - 1].id : null }; }
export async function getOrder(id: string) { const s = await orderRef(id).get(); return s.exists ? { id, ...(s.data() as OrderDoc) } : null; }

View File

@ -0,0 +1,6 @@
import { firestore } from "../firebase";
import type { ProductDoc } from "../types/reward";
export function productRef(id: string) { return firestore.doc(`products/${id}`); }
export function availableStock(product: Pick<ProductDoc, "totalStock" | "reservedStock" | "safetyStock">) { return product.totalStock - product.reservedStock - product.safetyStock; }
export async function listProducts(): Promise<Array<ProductDoc & { id: string }>> { const s = await firestore.collection("products").where("active", "==", true).where("redeemable", "==", true).get(); return s.docs.map((d) => ({ id: d.id, ...(d.data() as ProductDoc) })); }
export async function getProduct(id: string): Promise<(ProductDoc & { id: string }) | null> { const s = await productRef(id).get(); return s.exists ? { id, ...(s.data() as ProductDoc) } : null; }

View File

@ -0,0 +1,19 @@
import type { EligibilityResult } from "../types/reward";
import { getAvailableBalance } from "../repositories/walletRepository";
/**
* .
* 1P , .
*/
export async function computeEligibility(
uid: string,
requiredPoints = 1
): Promise<EligibilityResult> {
const availableBalance = await getAvailableBalance(uid);
const reasons = availableBalance >= requiredPoints ? [] : ["INSUFFICIENT_BALANCE"];
return {
eligible: reasons.length === 0,
reasons,
availableBalance,
};
}

View File

@ -0,0 +1,43 @@
import { Timestamp, type DocumentSnapshot } from "firebase-admin/firestore";
import { firestore } from "../firebase";
import { HttpError } from "../middleware/errors";
import { getOrder, listOrders, orderRef } from "../repositories/orderRepository";
import { availableStock, productRef } from "../repositories/productRepository";
import { applyPointChangesTx } from "./pointService";
import { getWalletTx } from "../repositories/walletRepository";
import { PointLedgerType } from "../types/points";
import { ORDER_TRANSITIONS, type OrderDoc, type OrderStatus, type ProductDoc, type Recipient } from "../types/reward";
const KEY = /^[A-Za-z0-9_-]{1,100}$/;
export interface CreateOrderInput { clientIdempotencyKey: string; items: Array<{ productId: string; qty: number }>; recipient: Recipient }
function validateInput(i: CreateOrderInput) { if (!KEY.test(i?.clientIdempotencyKey ?? "") || !Array.isArray(i.items) || i.items.length < 1 || i.items.length > 20 || i.items.some((x) => !KEY.test(x.productId) || !Number.isSafeInteger(x.qty) || x.qty < 1 || x.qty > 100)) throw new HttpError(400, "invalid order", "INVALID_INPUT"); const r = i.recipient; if (!r || [r.name, r.phone, r.address1, r.postalCode].some((v) => typeof v !== "string" || v.length < 1 || v.length > 200)) throw new HttpError(400, "invalid recipient", "INVALID_INPUT"); }
export async function createOrder(uid: string, input: CreateOrderInput) {
validateInput(input); const id = `${uid}_${input.clientIdempotencyKey}`; const now = Timestamp.now();
return firestore.runTransaction(async (tx) => {
const existing = await tx.get(orderRef(id)); if (existing.exists) return { order: { id, ...(existing.data() as OrderDoc) }, deduplicated: true };
const products: DocumentSnapshot[] = []; for (const item of input.items) products.push(await tx.get(productRef(item.productId)));
const currentWallet = await getWalletTx(tx, uid);
const orderItems = input.items.map((item, i) => { const p = products[i].data() as ProductDoc | undefined; if (!p || !p.active || !p.redeemable) throw new HttpError(409, "product unavailable", "PRODUCT_UNAVAILABLE"); if (availableStock(p) < item.qty) throw new HttpError(409, "out of stock", "OUT_OF_STOCK"); return { productId: item.productId, qty: item.qty, pointPrice: p.pointPrice, name: p.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");
const reserveId = `${uid}:order:${id}:reserve`; const wallet = await applyPointChangesTx(tx, uid, [{ txId: reserveId, type: PointLedgerType.OrderReserve, amount: total, orderId: id }]);
for (let i = 0; i < input.items.length; i++) tx.update(productRef(input.items[i].productId), { reservedStock: (products[i].data() as ProductDoc).reservedStock + input.items[i].qty, updatedAt: now });
const order: OrderDoc = { uid, items: orderItems, totalPoints: total, recipient: input.recipient, status: "reserved", clientIdempotencyKey: input.clientIdempotencyKey, reserveLedgerTxIds: [reserveId], orderedAt: now, statusHistory: [{ status: "reserved", at: now, actor: uid }], createdAt: now, updatedAt: now };
tx.create(orderRef(id), order); return { order: { id, ...order }, deduplicated: false, availableBalance: wallet!.availableBalance };
});
}
export async function transitionOrder(orderId: string, target: OrderStatus, actor: string, opts: { admin?: boolean; restock?: boolean; system?: boolean } = {}) {
return firestore.runTransaction(async (tx) => {
const snap = await tx.get(orderRef(orderId)); if (!snap.exists) throw new HttpError(404, "order not found", "ORDER_NOT_FOUND"); const order = snap.data() as OrderDoc;
if (!ORDER_TRANSITIONS[order.status].includes(target)) throw new HttpError(409, "invalid transition", "INVALID_ORDER_TRANSITION"); if (!opts.admin && !(order.uid === actor && order.status === "reserved" && target === "cancelled")) throw new HttpError(403, "forbidden", "FORBIDDEN");
const productSnaps: DocumentSnapshot[] = []; for (const item of order.items) productSnaps.push(await tx.get(productRef(item.productId))); const now = Timestamp.now();
let type: PointLedgerType | null = null; let suffix = ""; if (target === "cancelled") { type = PointLedgerType.OrderRelease; suffix = "release"; } else if (target === "confirmed") { type = PointLedgerType.OrderCapture; suffix = "capture"; } else if (target === "refunded") { type = PointLedgerType.OrderRefund; suffix = "refund"; }
if (type) await applyPointChangesTx(tx, order.uid, [{ txId: `${order.uid}:order:${orderId}:${suffix}`, type, amount: order.totalPoints, orderId, reversalOf: order.reserveLedgerTxIds[0] }]);
order.items.forEach((item, i) => { const product = productSnaps[i].data() as ProductDoc; const patch: Partial<ProductDoc> = { updatedAt: now }; if (target === "cancelled") patch.reservedStock = product.reservedStock - item.qty; if (target === "confirmed") { patch.reservedStock = product.reservedStock - item.qty; patch.totalStock = product.totalStock - item.qty; } if (target === "refunded" && opts.restock !== false) patch.totalStock = product.totalStock + item.qty; tx.update(productRef(item.productId), patch); });
const patch: Record<string, unknown> = { status: target, updatedAt: now, statusHistory: [...order.statusHistory, { status: target, at: now, actor }] }; if (target === "cancelled") { patch.cancelledAt = now; patch.cancelledBy = actor; } if (target === "confirmed") patch.confirmedAt = now; if (target === "refunded") patch.refundedAt = now; tx.update(orderRef(orderId), patch);
return { id: orderId, status: target };
});
}
export async function cancelOrder(uid: string, id: string) { const order = await getOrder(id); if (!order || order.uid !== uid) throw new HttpError(404, "order not found", "ORDER_NOT_FOUND"); return transitionOrder(id, "cancelled", uid); }
export { getOrder, listOrders };

View File

@ -0,0 +1,48 @@
import { MemCache } from "../lib/memCache";
import { getProduct, listProducts } from "../repositories/productRepository";
const cache = new MemCache<unknown>(60_000, 100);
function catalogSummary<T extends {
id: string;
name: string;
pointPrice: number;
active: boolean;
redeemable: boolean;
displayOrder: number;
mainImages: string[];
}>(product: T) {
return {
id: product.id,
name: product.name,
pointPrice: product.pointPrice,
active: product.active,
redeemable: product.redeemable,
displayOrder: product.displayOrder,
mainImages: product.mainImages,
};
}
export async function getCatalog() {
return cache.getOrFetch("list", async () =>
(await listProducts())
.sort((a, b) => a.displayOrder - b.displayOrder)
.map(catalogSummary)
);
}
export async function getCatalogProduct(id: string) {
return cache.getOrFetch(`product:${id}`, async () => {
const product = await getProduct(id);
if (!product || !product.active || !product.redeemable) return null;
return {
...catalogSummary(product),
detailImages: product.detailImages,
};
});
}
export function invalidateRewardCatalog(productId?: string) {
cache.delete("list");
if (productId) cache.delete(`product:${productId}`);
}

41
src/types/reward.ts Normal file
View File

@ -0,0 +1,41 @@
import type { Timestamp } from "firebase-admin/firestore";
export interface ProductDoc {
name: string;
pointPrice: number;
active: boolean;
redeemable: boolean;
displayOrder: number;
/** 상품 갤러리. 첫 번째 URL은 상점 외부 카드의 대표 이미지로 사용한다. */
mainImages: string[];
/** 상세 페이지에서 배열 순서대로 위에서 아래로 노출한다. */
detailImages: string[];
/** 실재고. 예약 확정 시 차감한다. */
totalStock: number;
/** 주문에 예약되어 아직 확정되지 않은 재고. */
reservedStock: number;
/** 판매하지 않고 남겨 둘 안전재고. */
safetyStock: number;
createdAt: Timestamp;
updatedAt: Timestamp;
}
export type OrderStatus = "reserved" | "confirmed" | "preparing" | "shipped" | "delivered" | "cancelled" | "refunded";
export const ORDER_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
reserved: ["confirmed", "cancelled"], confirmed: ["preparing", "refunded"],
preparing: ["shipped", "refunded"], shipped: ["delivered", "refunded"],
delivered: ["refunded"], cancelled: [], refunded: [],
};
export interface OrderItem { productId: string; qty: number; pointPrice: number; name: string }
export interface Recipient { name: string; phone: string; address1: string; address2?: string; postalCode: string; deliveryMemo?: string }
export interface OrderDoc {
uid: string; items: OrderItem[]; totalPoints: number; recipient: Recipient; status: OrderStatus;
clientIdempotencyKey: string; reserveLedgerTxIds: string[]; orderedAt: Timestamp;
statusHistory: Array<{ status: OrderStatus; at: Timestamp; actor: string }>;
cancelledBy?: string; cancelledAt?: Timestamp; confirmedAt?: Timestamp; refundedAt?: Timestamp;
createdAt: Timestamp; updatedAt: Timestamp;
}
export interface EligibilityResult {
eligible: boolean;
reasons: string[];
availableBalance: number;
}

View File

@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it } from "vitest";
import { firestore } from "../../src/firebase";
import { computeEligibility } from "../../src/services/eligibilityService";
import { applyPointChanges } from "../../src/services/pointService";
import { PointLedgerType } from "../../src/types/points";
const uid = "eligibility-points-only";
describe("computeEligibility", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.doc(`users/${uid}`));
});
it("상점 진입 자격은 가용 포인트 보유 여부만 확인한다", async () => {
expect(await computeEligibility(uid)).toEqual({
eligible: false,
reasons: ["INSUFFICIENT_BALANCE"],
availableBalance: 0,
});
await applyPointChanges(uid, [{
txId: `${uid}:admin:seed`,
type: PointLedgerType.AdminCredit,
amount: 100,
}]);
expect(await computeEligibility(uid)).toEqual({
eligible: true,
reasons: [],
availableBalance: 100,
});
});
it("주문 자격은 주문 총액 이상의 가용 포인트만 확인한다", async () => {
await applyPointChanges(uid, [{
txId: `${uid}:admin:seed-order`,
type: PointLedgerType.AdminCredit,
amount: 100,
}]);
expect(await computeEligibility(uid, 101)).toMatchObject({
eligible: false,
reasons: ["INSUFFICIENT_BALANCE"],
});
expect(await computeEligibility(uid, 100)).toMatchObject({
eligible: true,
reasons: [],
});
});
});

View File

@ -0,0 +1,57 @@
import { Timestamp } from "firebase-admin/firestore";
import { beforeEach, describe, expect, it } from "vitest";
import { firestore } from "../../src/firebase";
import {
getCatalog,
getCatalogProduct,
invalidateRewardCatalog,
} from "../../src/services/rewardCatalogService";
const id = "catalog-images-test";
const mainImages = [
"https://cdn.example.com/product/cover.jpg",
"https://cdn.example.com/product/gallery-2.jpg",
];
const detailImages = [
"https://cdn.example.com/product/detail-1.jpg",
"https://cdn.example.com/product/detail-2.jpg",
];
describe("rewardCatalogService image contract", () => {
beforeEach(async () => {
invalidateRewardCatalog(id);
await firestore.doc(`products/${id}`).delete();
const now = Timestamp.now();
await firestore.doc(`products/${id}`).set({
name: "이미지 계약 상품",
pointPrice: 1000,
active: true,
redeemable: true,
displayOrder: 999,
mainImages,
detailImages,
totalStock: 10,
reservedStock: 2,
safetyStock: 1,
createdAt: now,
updatedAt: now,
});
});
it("목록에는 순서가 보존된 mainImages만 노출한다", async () => {
const product = (await getCatalog()).find((item) => item.id === id);
expect(product).toMatchObject({ id, mainImages });
expect(product).not.toHaveProperty("detailImages");
expect(product).not.toHaveProperty("totalStock");
expect(product).not.toHaveProperty("reservedStock");
expect(product).not.toHaveProperty("safetyStock");
});
it("상세에는 mainImages와 detailImages를 모두 순서대로 노출한다", async () => {
expect(await getCatalogProduct(id)).toMatchObject({
id,
mainImages,
detailImages,
});
});
});