mmday-firebase/scripts/upload-reward-product-assets.ts
윤정민 fde21bf8c3 Add product options and remove server-side stock management
- 상품에 재고 없는 옵션(optionLabel/options — 예: 키링 색상)을 도입하고 주문 항목에 optionId/optionName 스냅샷 저장
- 주문 생성 시 옵션 검증: 옵션 상품에 미선택이면 OPTION_REQUIRED, 무옵션 상품에 optionId가 오면 INVALID_INPUT
- 같은 상품의 색상별 항목을 지원하도록 상품 문서를 productId당 한 번만 읽게 정리
- 재고는 별도 플랫폼에서 관리하므로 서버 재고 개념 전면 제거(ProductDoc 재고 필드, availableStock, 주문 예약/차감, stock/adjust 엔드포인트, order/status restock)
- 상세 카탈로그·주문 DTO에 옵션 노출, product/upsert가 optionLabel/options 수용
- 시드/업로드 스크립트의 재고 필드 기록 제거, vitest 계약 테스트 추가
2026-07-21 13:57:07 +09:00

215 lines
6.6 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),
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;
});