mmday-firebase/scripts/upload-reward-product-assets.ts
윤정민 6343a817af 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) 스크립트 추가
2026-07-16 14:25:58 +09:00

218 lines
6.8 KiB
TypeScript

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;
});