From c635f1695264c025459b67edc8027cfbb631cf73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Tue, 21 Jul 2026 13:57:42 +0900 Subject: [PATCH] Add admin console read APIs, claim script, and CORS hedge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 어드민 웹(fanit_admin) 지원: GET products/list(비노출 포함 전체 상품, AdminProductDto)·GET orders/list(전 유저 주문, createdAt desc 커서 페이지네이션) 추가 — Hosting rewrite·직접 호출 양쪽에서 일관되는 2세그먼트 라우트 - 지갑·원장 어드민 응답도 DTO로 감싸 Timestamp({_seconds}) 유출 제거 - admin 함수에 단일 origin CORS 헤지 추가(OPTIONS preflight를 requireAdmin보다 먼저 처리, 에러 응답에도 헤더 적용) - scripts/set-admin-claim.ts로 운영자 admin 클레임 부여(부여 후 재로그인 필요) - Storage 규칙: write만 admin 클레임으로 제한, read는 불변(앱 이미지 표시 계약 유지) - 신규 조회·DTO vitest 테스트 추가 --- scripts/set-admin-claim.ts | 27 ++++++++ src/handlers/adminHandlers.ts | 47 ++++++++++++- src/repositories/orderRepository.ts | 11 ++- src/repositories/productRepository.ts | 2 + src/types/dto/rewardDto.ts | 33 ++++++++- storage.rules | 3 +- tests/repositories/orderRepository.test.ts | 71 ++++++++++++++++++++ tests/repositories/productRepository.test.ts | 51 ++++++++++++++ tests/types/rewardDto.test.ts | 54 ++++++++++++++- 9 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 scripts/set-admin-claim.ts create mode 100644 tests/repositories/orderRepository.test.ts create mode 100644 tests/repositories/productRepository.test.ts diff --git a/scripts/set-admin-claim.ts b/scripts/set-admin-claim.ts new file mode 100644 index 0000000..d4a1f0b --- /dev/null +++ b/scripts/set-admin-claim.ts @@ -0,0 +1,27 @@ +/** + * 이메일로 유저를 찾아 admin custom claim(`admin: true`)을 부여한다. + * + * 사용법: npx tsx scripts/set-admin-claim.ts + */ +import "./_bootstrap"; +import { auth } from "../src/firebase"; + +async function main() { + const email = process.argv[2]; + if (!email) { + console.error("사용법: npx tsx scripts/set-admin-claim.ts "); + process.exitCode = 1; + return; + } + + const user = await auth.getUserByEmail(email); + await auth.setCustomUserClaims(user.uid, { admin: true }); + + console.log(`admin claim 부여 완료: ${email} (uid: ${user.uid})`); + console.log("반영을 위해 재로그인(또는 강제 토큰 갱신)이 필요합니다."); +} + +main().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/src/handlers/adminHandlers.ts b/src/handlers/adminHandlers.ts index ca0186b..ef57814 100644 --- a/src/handlers/adminHandlers.ts +++ b/src/handlers/adminHandlers.ts @@ -5,13 +5,35 @@ import { markGameEnded } from "../services/gameResultService"; import { adminPointChange } from "../services/adminPointService"; import { getWallet } from "../repositories/walletRepository"; import { listLedger } from "../repositories/pointLedgerRepository"; +import { listAllProducts } from "../repositories/productRepository"; +import { listAllOrders } from "../repositories/orderRepository"; import { firestore } from "../firebase"; import { Timestamp } from "firebase-admin/firestore"; import type { OrderStatus, ProductDoc } from "../types/reward"; import { transitionOrder } from "../services/orderService"; import { invalidateRewardCatalog } from "../services/rewardCatalogService"; +import { + toAdminProductDto, toLedgerEntryDto, toOrderDto, toWalletDto, + type AdminProductDto, type LedgerPageDto, type OrderPageDto, +} from "../types/dto/rewardDto"; + +// TODO: 어드민 웹 Hosting 사이트 도메인이 확정되면 실제 값으로 교체. +const ADMIN_ORIGIN = "https://fanit-admin.web.app"; + +/** admin 함수 전용 단일 origin CORS 헤지. rewrite 경로 장애 시 직접 호출 대체 경로를 위해 둔다. */ +function applyCors(req: { get(name: string): string | undefined }, res: { set(name: string, value: string): void }) { + if (req.get("origin") === ADMIN_ORIGIN) { + res.set("Access-Control-Allow-Origin", ADMIN_ORIGIN); + res.set("Access-Control-Allow-Headers", "Authorization, Content-Type"); + res.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + } +} export const admin = onRequest(async (req, res) => { + applyCors(req, res); + // preflight에는 Authorization 헤더가 없어 requireAdmin보다 먼저 처리해야 한다. + if (req.method === "OPTIONS") { res.status(204).send(""); return; } + const segs = req.path.replace(/^\/+|\/+$/g, "").split("/"); const tail = segs.slice(-2).join("/"); @@ -19,8 +41,9 @@ export const admin = onRequest(async (req, res) => { 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; } + // 지갑/원장도 DTO 를 거쳐 Timestamp 가 {_seconds,_nanoseconds} 로 새지 않게 한다 (어드민 웹이 브라우저에서 소비). + if (tail === "points/wallet" && req.method === "GET") { const wallet = await getWallet(String(req.query.uid)); res.json(wallet ? toWalletDto(wallet) : null); return; } + if (tail === "points/ledger" && req.method === "GET") { const uid = String(req.query.uid); const page = await listLedger(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined); const dto: LedgerPageDto = { items: page.items.map((entry) => toLedgerEntryDto(entry.id, uid, entry)), cursor: page.cursor }; res.json(dto); return; } if (tail === "product/upsert" && req.method === "POST") { const { id, name, pointPrice, active, redeemable, displayOrder, @@ -75,6 +98,26 @@ export const admin = onRequest(async (req, res) => { } if (tail === "order/status" && req.method === "POST") { const { orderId, status } = req.body ?? {}; res.json(await transitionOrder(orderId, status as OrderStatus, adminUid, { admin: true })); return; } + if (tail === "products/list" && req.method === "GET") { + const products = await listAllProducts(); + const dto: AdminProductDto[] = products.map((p) => toAdminProductDto(p.id, p)); + res.json(dto); + return; + } + + if (tail === "orders/list" && req.method === "GET") { + const limit = Number(req.query.limit) || 20; + const cursor = typeof req.query.cursor === "string" ? req.query.cursor : undefined; + const status = typeof req.query.status === "string" ? (req.query.status as OrderStatus) : undefined; + const page = await listAllOrders(limit, cursor, status); + const dto: OrderPageDto = { + items: page.items.map((o) => toOrderDto(o.id, o)), + cursor: page.cursor, + }; + res.json(dto); + return; + } + if (tail === "game/end" && req.method === "POST") { const { gameId, winningTeamCode } = req.body ?? {}; const result = await markGameEnded(gameId, winningTeamCode); diff --git a/src/repositories/orderRepository.ts b/src/repositories/orderRepository.ts index 3b5a016..8c5cc0a 100644 --- a/src/repositories/orderRepository.ts +++ b/src/repositories/orderRepository.ts @@ -1,5 +1,14 @@ import { firestore } from "../firebase"; -import type { OrderDoc } from "../types/reward"; +import type { OrderDoc, OrderStatus } 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 }; } +/** 어드민 전용 — uid 필터 없이 전 유저 주문을 조회한다. status 필터는 기존 status+createdAt 복합 인덱스를 재사용한다. */ +export async function listAllOrders(limit = 20, cursor?: string, status?: OrderStatus) { + let q = (status ? firestore.collection("orders").where("status", "==", status) : firestore.collection("orders")) + .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 index a5d7be2..60ab0d4 100644 --- a/src/repositories/productRepository.ts +++ b/src/repositories/productRepository.ts @@ -2,4 +2,6 @@ import { firestore } from "../firebase"; import type { ProductDoc } from "../types/reward"; export function productRef(id: string) { return firestore.doc(`products/${id}`); } 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) })); } +/** 어드민 전용 — active/redeemable 필터 없이 전체 상품을 반환한다. */ +export async function listAllProducts(): Promise> { const s = await firestore.collection("products").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/types/dto/rewardDto.ts b/src/types/dto/rewardDto.ts index 23d025e..9dc8f69 100644 --- a/src/types/dto/rewardDto.ts +++ b/src/types/dto/rewardDto.ts @@ -1,7 +1,7 @@ import { toIso, toIsoOrUndefined } from "./iso"; import { OP_BY_TYPE } from "../points"; import type { PointLedgerEntry, PointLedgerType, PointOperation, WalletDoc } from "../points"; -import type { OrderDoc, OrderStatus } from "../reward"; +import type { OrderDoc, OrderStatus, ProductDoc } from "../reward"; /** * reward 응답 DTO. @@ -129,6 +129,12 @@ export interface ProductDetailDto extends ProductSummaryDto { options?: ProductOptionDto[]; } +/** 어드민 상품 목록 DTO — active/redeemable 필터 없이 전체 상품을 노출한다. */ +export interface AdminProductDto extends ProductDetailDto { + createdAt: string; + updatedAt: string; +} + export interface EligibilityDto { eligible: boolean; reasons: string[]; @@ -216,6 +222,31 @@ export function toLedgerEntryDto( }; } +/** + * 어드민 상품 목록 DTO. active/redeemable 무관하게 전체 상품을 필드 나열로 조립한다 + * (스프레드 금지 — 문서에 새 필드가 생겨도 그대로 새지 않도록). + */ +export function toAdminProductDto(id: string, doc: ProductDoc): AdminProductDto { + return { + id, + name: doc.name, + pointPrice: doc.pointPrice, + active: doc.active, + redeemable: doc.redeemable, + displayOrder: doc.displayOrder, + mainImages: doc.mainImages, + detailImages: doc.detailImages, + ...(doc.options?.length + ? { + optionLabel: doc.optionLabel ?? "옵션", + options: doc.options.map((o) => ({ id: o.id, name: o.name })), + } + : {}), + createdAt: toIso(doc.createdAt), + updatedAt: toIso(doc.updatedAt), + }; +} + /** * 주문 DTO. POST /orders, GET /orders, GET /orders/:id 세 경로가 공유하고 * statusHistory 중첩까지 같은 규칙을 타야 해서 이것만 함수로 뺐다. diff --git a/storage.rules b/storage.rules index fbb489a..a76b3b2 100644 --- a/storage.rules +++ b/storage.rules @@ -3,7 +3,8 @@ rules_version = '2'; service firebase.storage { match /b/{bucket}/o { match /{allPaths=**} { - allow read, write: if request.auth != null; + allow read: if request.auth != null; + allow write: if request.auth != null && request.auth.token.admin == true; } } } \ No newline at end of file diff --git a/tests/repositories/orderRepository.test.ts b/tests/repositories/orderRepository.test.ts new file mode 100644 index 0000000..11352c9 --- /dev/null +++ b/tests/repositories/orderRepository.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { firestore } from "../../src/firebase"; +import { listAllOrders } from "../../src/repositories/orderRepository"; +import type { OrderDoc } from "../../src/types/reward"; + +function makeOrder(uid: string, createdAt: Timestamp, overrides: Partial = {}): OrderDoc { + return { + uid, + items: [{ productId: "p1", qty: 1, pointPrice: 500, name: "굿즈" }], + totalPoints: 500, + recipient: { name: "홍길동", phone: "01000000000", address1: "서울", postalCode: "00000" }, + status: "reserved", + clientIdempotencyKey: `key-${uid}-${createdAt.toMillis()}`, + reserveLedgerTxIds: [], + orderedAt: createdAt, + statusHistory: [{ status: "reserved", at: createdAt, actor: uid }], + createdAt, + updatedAt: createdAt, + ...overrides, + }; +} + +describe("orderRepository.listAllOrders", () => { + beforeEach(async () => { + await firestore.recursiveDelete(firestore.collection("orders")); + }); + + it("uid 필터 없이 서로 다른 유저의 주문을 모두 createdAt desc로 반환한다", async () => { + const t1 = Timestamp.fromDate(new Date("2026-07-01T00:00:00.000Z")); + const t2 = Timestamp.fromDate(new Date("2026-07-02T00:00:00.000Z")); + const t3 = Timestamp.fromDate(new Date("2026-07-03T00:00:00.000Z")); + + await firestore.doc("orders/o1").set(makeOrder("uid-a", t1)); + await firestore.doc("orders/o2").set(makeOrder("uid-b", t2)); + await firestore.doc("orders/o3").set(makeOrder("uid-a", t3)); + + const page = await listAllOrders(20); + expect(page.items.map((o) => o.id)).toEqual(["o3", "o2", "o1"]); + expect(page.items.map((o) => o.uid).sort()).toEqual(["uid-a", "uid-a", "uid-b"].sort()); + }); + + it("cursor 기반 페이지네이션이 동작한다", async () => { + const t1 = Timestamp.fromDate(new Date("2026-07-01T00:00:00.000Z")); + const t2 = Timestamp.fromDate(new Date("2026-07-02T00:00:00.000Z")); + const t3 = Timestamp.fromDate(new Date("2026-07-03T00:00:00.000Z")); + + await firestore.doc("orders/o1").set(makeOrder("uid-a", t1)); + await firestore.doc("orders/o2").set(makeOrder("uid-b", t2)); + await firestore.doc("orders/o3").set(makeOrder("uid-a", t3)); + + const firstPage = await listAllOrders(2); + expect(firstPage.items.map((o) => o.id)).toEqual(["o3", "o2"]); + expect(firstPage.cursor).toBe("o2"); + + const secondPage = await listAllOrders(2, firstPage.cursor ?? undefined); + expect(secondPage.items.map((o) => o.id)).toEqual(["o1"]); + expect(secondPage.cursor).toBe("o1"); + }); + + it("status 필터를 걸면 해당 상태 주문만 반환한다 (기존 status+createdAt 인덱스 재사용)", async () => { + const t1 = Timestamp.fromDate(new Date("2026-07-01T00:00:00.000Z")); + const t2 = Timestamp.fromDate(new Date("2026-07-02T00:00:00.000Z")); + + await firestore.doc("orders/o1").set(makeOrder("uid-a", t1, { status: "reserved" })); + await firestore.doc("orders/o2").set(makeOrder("uid-b", t2, { status: "confirmed" })); + + const page = await listAllOrders(20, undefined, "confirmed"); + expect(page.items.map((o) => o.id)).toEqual(["o2"]); + }); +}); diff --git a/tests/repositories/productRepository.test.ts b/tests/repositories/productRepository.test.ts new file mode 100644 index 0000000..51f3e28 --- /dev/null +++ b/tests/repositories/productRepository.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { firestore } from "../../src/firebase"; +import { listAllProducts, listProducts } from "../../src/repositories/productRepository"; +import type { ProductDoc } from "../../src/types/reward"; + +const now = Timestamp.now(); + +function makeProduct(overrides: Partial = {}): ProductDoc { + return { + name: "상품", + pointPrice: 1000, + active: true, + redeemable: true, + displayOrder: 0, + mainImages: [], + detailImages: [], + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +describe("productRepository", () => { + beforeEach(async () => { + await firestore.recursiveDelete(firestore.collection("products")); + }); + + describe("listAllProducts", () => { + it("active/redeemable 필터 없이 비노출 상품도 포함해 전체를 반환한다", async () => { + await firestore.doc("products/exposed").set(makeProduct({ active: true, redeemable: true })); + await firestore.doc("products/hidden-inactive").set(makeProduct({ active: false, redeemable: true })); + await firestore.doc("products/hidden-non-redeemable").set(makeProduct({ active: true, redeemable: false })); + + const all = await listAllProducts(); + expect(all.map((p) => p.id).sort()).toEqual([ + "exposed", + "hidden-inactive", + "hidden-non-redeemable", + ]); + }); + + it("listProducts(소비자용)는 여전히 active+redeemable 필터가 걸려 있다", async () => { + await firestore.doc("products/exposed").set(makeProduct({ active: true, redeemable: true })); + await firestore.doc("products/hidden").set(makeProduct({ active: false, redeemable: true })); + + const consumerList = await listProducts(); + expect(consumerList.map((p) => p.id)).toEqual(["exposed"]); + }); + }); +}); diff --git a/tests/types/rewardDto.test.ts b/tests/types/rewardDto.test.ts index 721e193..b5742a1 100644 --- a/tests/types/rewardDto.test.ts +++ b/tests/types/rewardDto.test.ts @@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest"; import { Timestamp } from "firebase-admin/firestore"; import { EMPTY_WALLET_DTO, + toAdminProductDto, toLedgerEntryDto, toOrderDto, toWalletDto, } from "../../src/types/dto/rewardDto"; import { PointLedgerType, type PointLedgerEntry, type WalletDoc } from "../../src/types/points"; -import type { OrderDoc } from "../../src/types/reward"; +import type { OrderDoc, ProductDoc } from "../../src/types/reward"; const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; @@ -214,3 +215,54 @@ describe("toOrderDto", () => { expect(json.items[0]).not.toHaveProperty("optionName"); }); }); + +describe("toAdminProductDto", () => { + const baseProduct: ProductDoc = { + name: "아크릴 키링", + pointPrice: 3000, + active: false, + redeemable: false, + displayOrder: 1, + mainImages: ["https://example.com/main.png"], + detailImages: ["https://example.com/detail.png"], + createdAt: ts("2026-01-01T00:00:00.000Z"), + updatedAt: ts("2026-07-20T05:30:00.000Z"), + }; + + it("active/redeemable 이 false 인 비노출 상품도 그대로 응답에 포함한다", () => { + const dto = toAdminProductDto("acrylic-keyring", baseProduct); + expect(dto.active).toBe(false); + expect(dto.redeemable).toBe(false); + }); + + it("Timestamp 를 유출하지 않고 UTC ISO(Z) 문자열로 내보낸다", () => { + const dto = toAdminProductDto("acrylic-keyring", baseProduct); + expectNoTimestampLeak(dto); + expect(dto.createdAt).toMatch(UTC_ISO); + expect(dto.updatedAt).toMatch(UTC_ISO); + expect(dto.createdAt).toBe("2026-01-01T00:00:00.000Z"); + expect(dto.updatedAt).toBe("2026-07-20T05:30:00.000Z"); + }); + + it("옵션이 있으면 optionLabel/options 를 포함한다", () => { + const withOptions: ProductDoc = { + ...baseProduct, + optionLabel: "색상", + options: [{ id: "blue", name: "블루" }], + }; + const json = wire(toAdminProductDto("p1", withOptions)); + expect(json.optionLabel).toBe("색상"); + expect(json.options).toEqual([{ id: "blue", name: "블루" }]); + }); + + it("옵션이 없으면 optionLabel/options 키가 응답에서 사라진다", () => { + const json = wire(toAdminProductDto("p1", baseProduct)); + expect(json).not.toHaveProperty("optionLabel"); + expect(json).not.toHaveProperty("options"); + }); + + it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => { + const polluted = { ...baseProduct, internalOnly: "secret" } as ProductDoc; + expect(wire(toAdminProductDto("p1", polluted))).not.toHaveProperty("internalOnly"); + }); +});