diff --git a/firestore.indexes.json b/firestore.indexes.json index bb0d07d..bfafeea 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -107,14 +107,32 @@ ] }, { - "collectionGroup": "pointLedger", + "collectionGroup": "orders", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "createdAt", "order": "DESCENDING" }, - { "fieldPath": "seq", "order": "DESCENDING" }, - { "fieldPath": "__name__", "order": "DESCENDING" } + { "fieldPath": "uid", "order": "ASCENDING" }, + { "fieldPath": "createdAt", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "orders", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "status", "order": "ASCENDING" }, + { "fieldPath": "createdAt", "order": "DESCENDING" } ] } ], - "fieldOverrides": [] -} \ No newline at end of file + "fieldOverrides": [ + { + "collectionGroup": "products", + "fieldPath": "mainImages", + "indexes": [] + }, + { + "collectionGroup": "products", + "fieldPath": "detailImages", + "indexes": [] + } + ] +} diff --git a/firestore.rules b/firestore.rules index 8f5fdce..0f90bb4 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1,12 +1,20 @@ 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 { match /databases/{database}/documents { match /users/{uid} { allow read: if request.auth != null && request.auth.uid == uid; allow update: if request.auth != null && request.auth.uid == uid && 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; match /voteHistory/{date} { @@ -24,6 +32,11 @@ service cloud.firestore { allow write: if false; } + match /wallet/{id} { + allow read: if request.auth != null && request.auth.uid == uid; + allow write: if false; + } + // AI 채팅(짹) — 전부 Admin SDK(서버) 전용. 클라이언트가 직접 읽으면 // 프롬프트 노출, 직접 쓰면 한도 우회·이력 위조(영속 인젝션)가 가능해진다. // 이력 조회도 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 무력화 match /config/{doc} { allow read, write: if false; diff --git a/package.json b/package.json index 184c694..629c945 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "logs": "firebase functions:log", "test": "firebase emulators:exec --only firestore,database,auth \"vitest run\"", "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": { "node": "24" diff --git a/scripts/seed-reward-products.ts b/scripts/seed-reward-products.ts new file mode 100644 index 0000000..2cb2b75 --- /dev/null +++ b/scripts/seed-reward-products.ts @@ -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; }); diff --git a/scripts/upload-reward-product-assets.ts b/scripts/upload-reward-product-assets.ts new file mode 100644 index 0000000..c4882b5 --- /dev/null +++ b/scripts/upload-reward-product-assets.ts @@ -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 { + 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 { + 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 { + const output = new Array(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; +interface FirestoreDocument { fields?: Record } + +function stringArray(values: string[]): FirestoreValue { + return { arrayValue: { values: values.map((value) => ({ stringValue: value })) } }; +} + +async function getDocument(documentPath: string): Promise { + 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 +): Promise { + 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; +}); diff --git a/src/handlers/adminHandlers.ts b/src/handlers/adminHandlers.ts index cd58417..e2e4494 100644 --- a/src/handlers/adminHandlers.ts +++ b/src/handlers/adminHandlers.ts @@ -1,14 +1,66 @@ import { onRequest } from "firebase-functions/https"; import { requireAdmin } from "../middleware/admin"; -import { sendError } from "../middleware/errors"; +import { HttpError, sendError } from "../middleware/errors"; 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) => { const segs = req.path.replace(/^\/+|\/+$/g, "").split("/"); const tail = segs.slice(-2).join("/"); 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 | 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") { const { gameId, winningTeamCode } = req.body ?? {}; diff --git a/src/handlers/rewardHandlers.ts b/src/handlers/rewardHandlers.ts new file mode 100644 index 0000000..fea08a0 --- /dev/null +++ b/src/handlers/rewardHandlers.ts @@ -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); } }); diff --git a/src/index.ts b/src/index.ts index b36a3d5..295be2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export { admin } from "./handlers/adminHandlers"; export { debug } from "./handlers/debugHandlers"; export { attendance } from "./handlers/attendanceHandlers"; export { chat } from "./handlers/chatHandlers"; +export { reward } from "./handlers/rewardHandlers"; export { kboDailyRefresh } from "./scheduled/kboRefresh"; export { dailyArchive } from "./scheduled/dailyArchive"; export { accountPurge } from "./scheduled/accountPurge"; diff --git a/src/repositories/orderRepository.ts b/src/repositories/orderRepository.ts new file mode 100644 index 0000000..3b5a016 --- /dev/null +++ b/src/repositories/orderRepository.ts @@ -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; } diff --git a/src/repositories/productRepository.ts b/src/repositories/productRepository.ts new file mode 100644 index 0000000..2ea07a3 --- /dev/null +++ b/src/repositories/productRepository.ts @@ -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) { return product.totalStock - product.reservedStock - product.safetyStock; } +export async function listProducts(): Promise> { 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; } diff --git a/src/services/eligibilityService.ts b/src/services/eligibilityService.ts new file mode 100644 index 0000000..8923c2d --- /dev/null +++ b/src/services/eligibilityService.ts @@ -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 { + const availableBalance = await getAvailableBalance(uid); + const reasons = availableBalance >= requiredPoints ? [] : ["INSUFFICIENT_BALANCE"]; + return { + eligible: reasons.length === 0, + reasons, + availableBalance, + }; +} diff --git a/src/services/orderService.ts b/src/services/orderService.ts new file mode 100644 index 0000000..c63df2a --- /dev/null +++ b/src/services/orderService.ts @@ -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 = { 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 = { 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 }; diff --git a/src/services/rewardCatalogService.ts b/src/services/rewardCatalogService.ts new file mode 100644 index 0000000..aa15ded --- /dev/null +++ b/src/services/rewardCatalogService.ts @@ -0,0 +1,48 @@ +import { MemCache } from "../lib/memCache"; +import { getProduct, listProducts } from "../repositories/productRepository"; + +const cache = new MemCache(60_000, 100); + +function catalogSummary(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}`); +} diff --git a/src/types/reward.ts b/src/types/reward.ts new file mode 100644 index 0000000..8c1376e --- /dev/null +++ b/src/types/reward.ts @@ -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 = { + 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; +} diff --git a/tests/services/eligibilityService.test.ts b/tests/services/eligibilityService.test.ts new file mode 100644 index 0000000..74932de --- /dev/null +++ b/tests/services/eligibilityService.test.ts @@ -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: [], + }); + }); +}); diff --git a/tests/services/rewardCatalogService.test.ts b/tests/services/rewardCatalogService.test.ts new file mode 100644 index 0000000..50aaf32 --- /dev/null +++ b/tests/services/rewardCatalogService.test.ts @@ -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, + }); + }); +});