- 상품 카탈로그(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) 스크립트 추가
218 lines
6.8 KiB
TypeScript
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;
|
|
});
|