fix: 거래처 주문 장바구니 가시성 + 이미지 업로드 영구 저장
Deploy momo-erp / deploy (push) Successful in 49s

장바구니:
- /m/items 우측 패널 lg: → md: 브레이크포인트 (768px+ 부터 노출)
- 패널 테두리 강조 (emerald-300 2px) + 그림자 강화
- [담기] 클릭 시 우측 상단 토스트 피드백 (장바구니 추가됨)

이미지 업로드:
- docker-compose.prod.yml 에 ./public/uploads:/app/public/uploads 볼륨 마운트
  (컨테이너 재빌드 시에도 이미지 보존)
- Dockerfile: /app/public/uploads/items 디렉토리 + nextjs 소유권 미리 설정
- scripts/deploy.sh: 호스트 public/uploads/items 디렉토리 보장 + 권한 777
- /api/m/items/upload-image: 저장 경로 fallback (3개 후보) + 명확한 에러 메시지
This commit is contained in:
chpark
2026-04-26 01:18:14 +09:00
parent 0ba97294eb
commit 6408984074
5 changed files with 69 additions and 28 deletions
+2 -1
View File
@@ -36,7 +36,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# 파일 업로드 디렉토리
RUN mkdir -p /data_storage && chown nextjs:nodejs /data_storage
RUN mkdir -p /data_storage /app/public/uploads/items && \
chown -R nextjs:nodejs /data_storage /app/public/uploads
USER nextjs
EXPOSE 3000
+2 -1
View File
@@ -13,11 +13,12 @@ services:
- .env.production
volumes:
- ./data_storage:/data_storage
# 업로드 이미지 영구 저장 (컨테이너 재빌드 시에도 보존)
- ./public/uploads:/app/public/uploads
# 자가 배포: webhook 이 호스트의 deploy.sh 를 실행하기 위함
- /var/run/docker.sock:/var/run/docker.sock
- ./scripts/deploy.sh:/deploy/deploy.sh:ro
# source 디렉토리를 컨테이너 안에서 git pull 하기 위해 호스트의 소스를 마운트
# (호스트 ~/momo-erp/source 를 /deploy/source 로)
- $PWD:/deploy/source
networks:
- traefik-net
+4
View File
@@ -10,6 +10,10 @@ echo "[$(date)] git fetch + reset --hard origin/main"
git fetch origin
git reset --hard origin/main
# 업로드 디렉토리 보장 (컨테이너 마운트 경로)
mkdir -p public/uploads/items
chmod -R 777 public/uploads || true
echo "[$(date)] docker compose up --build"
docker compose -f docker-compose.prod.yml up -d --build
+8 -2
View File
@@ -58,6 +58,12 @@ export default function ItemsBrowse() {
}
return [...c, { item, qty: 1 }];
});
// 시각 피드백: 우측 상단 토스트
Swal.fire({
toast: true, position: "top-end", icon: "success",
title: `장바구니에 추가됨: ${item.ITEM_NAME}`,
showConfirmButton: false, timer: 1500, timerProgressBar: true,
});
};
const updateQty = (objid: string, delta: number) => {
@@ -123,7 +129,7 @@ export default function ItemsBrowse() {
};
return (
<div className="grid lg:grid-cols-[1fr_380px] gap-6">
<div className="grid md:grid-cols-[1fr_340px] gap-4">
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-slate-900"> </h1>
@@ -194,7 +200,7 @@ export default function ItemsBrowse() {
</div>
{/* 장바구니 */}
<aside className="lg:sticky lg:top-6 self-start bg-white border border-slate-200 rounded-xl p-5 shadow-sm h-fit">
<aside className="md:sticky md:top-4 self-start bg-white border-2 border-emerald-300 rounded-xl p-5 shadow-lg h-fit">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 font-bold text-slate-800">
<ShoppingCart size={16} />
+53 -24
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "node:fs/promises";
import { writeFile, mkdir, stat } from "node:fs/promises";
import path from "node:path";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { createObjectId } from "@/lib/utils";
@@ -11,28 +11,57 @@ export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ success: false, message: "파일이 없습니다." }, { status: 400 });
if (!ALLOWED.includes(file.type)) {
return NextResponse.json({ success: false, message: "이미지 파일만 업로드 가능합니다." }, { status: 400 });
try {
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ success: false, message: "파일이 없습니다." }, { status: 400 });
if (!ALLOWED.includes(file.type)) {
return NextResponse.json({ success: false, message: "이미지 파일만 업로드 가능합니다." }, { status: 400 });
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ success: false, message: "파일 크기는 5MB 이하여야 합니다." }, { status: 400 });
}
const today = new Date();
const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}`;
// 저장 경로 후보 (운영 도커 마운트 / 로컬 dev / 폴백)
const candidates = [
"/app/public/uploads/items", // 컨테이너 표준
path.join(process.cwd(), "public", "uploads", "items"), // 로컬 dev
"/data_storage/uploads/items", // 폴백
];
let baseDir: string | null = null;
for (const c of candidates) {
try {
await mkdir(path.join(c, ymd), { recursive: true });
await stat(path.join(c, ymd));
baseDir = c;
break;
} catch { /* 다음 후보 */ }
}
if (!baseDir) {
console.error("[upload] 저장 디렉토리 생성 실패 — 모든 후보 권한 없음");
return NextResponse.json({ success: false, message: "서버 저장소 권한 오류 — 운영자에게 문의" }, { status: 500 });
}
const ext = (file.name.match(/\.[a-zA-Z0-9]+$/)?.[0] ?? ".jpg").toLowerCase();
const safeExt = /^\.(jpg|jpeg|png|webp|gif)$/.test(ext) ? ext : ".jpg";
const fname = createObjectId() + safeExt;
const fpath = path.join(baseDir, ymd, fname);
const buf = Buffer.from(await file.arrayBuffer());
await writeFile(fpath, buf);
// URL 은 /uploads/items/.. 로 통일 (Next.js public 정적 서빙)
const url = baseDir === "/data_storage/uploads/items"
? `/api/file/items/${ymd}/${fname}` // data_storage는 별도 핸들러 필요
: `/uploads/items/${ymd}/${fname}`;
console.log("[upload] saved:", fpath, "→", url);
return NextResponse.json({ success: true, url, savedAt: fpath });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[upload] error:", msg);
return NextResponse.json({ success: false, message: `업로드 실패: ${msg}` }, { status: 500 });
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ success: false, message: "파일 크기는 5MB 이하여야 합니다." }, { status: 400 });
}
const today = new Date();
const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}`;
const dir = path.join(process.cwd(), "public", "uploads", "items", ymd);
await mkdir(dir, { recursive: true });
const ext = (file.name.match(/\.[a-zA-Z0-9]+$/)?.[0] ?? ".jpg").toLowerCase();
const safeExt = /^\.(jpg|jpeg|png|webp|gif)$/.test(ext) ? ext : ".jpg";
const fname = createObjectId() + safeExt;
const fpath = path.join(dir, fname);
const buf = Buffer.from(await file.arrayBuffer());
await writeFile(fpath, buf);
const url = `/uploads/items/${ymd}/${fname}`;
return NextResponse.json({ success: true, url });
}