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