- 상품에 재고 없는 옵션(optionLabel/options — 예: 키링 색상)을 도입하고 주문 항목에 optionId/optionName 스냅샷 저장 - 주문 생성 시 옵션 검증: 옵션 상품에 미선택이면 OPTION_REQUIRED, 무옵션 상품에 optionId가 오면 INVALID_INPUT - 같은 상품의 색상별 항목을 지원하도록 상품 문서를 productId당 한 번만 읽게 정리 - 재고는 별도 플랫폼에서 관리하므로 서버 재고 개념 전면 제거(ProductDoc 재고 필드, availableStock, 주문 예약/차감, stock/adjust 엔드포인트, order/status restock) - 상세 카탈로그·주문 DTO에 옵션 노출, product/upsert가 optionLabel/options 수용 - 시드/업로드 스크립트의 재고 필드 기록 제거, vitest 계약 테스트 추가
215 lines
6.6 KiB
TypeScript
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;
|
|
});
|