From 5bd07526e468fad9e855ae739dfd9966f20fb767 Mon Sep 17 00:00:00 2001 From: chpark Date: Sun, 31 May 2026 01:04:12 +0900 Subject: [PATCH] =?UTF-8?q?fix(push):=20=EC=95=8C=EB=A6=BC=EC=97=90=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8/=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=E2=80=94=20FCM=20image=20+=20sw.js=20image=20?= =?UTF-8?q?=EC=98=B5=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: 폰에 도착한 푸시 알림이 제목만 보이고 본문/이미지 안 보임 원인: 1) FCM android.notification 에 image 필드 미설정 → big picture 안 뜸 2) 본문이 비어있으면 안드로이드가 알림을 dismiss 3) 본문 HTML 안의 이미지가 푸시 발송 흐름과 분리됨 수정: - src/lib/firebase-push.ts: + FcmPayload.image 추가 + android.notification.image (big picture) + notification.image + notification_priority: PRIORITY_MAX, visibility: PUBLIC + body 빈값 대비 ' ' 폴백 - src/lib/push.ts: + PushPayload.image 추가 + web-push body JSON 에 image 포함 → sw.js 에서 그대로 사용 - src/app/api/m/admin/notices/send-push/route.ts: + imageUrl 받기 + 절대 URL 변환 (FCM/web-push 외부 접근용) + body 빈값이면 '(공지 페이지에서 확인)' 폴백 - src/app/(main)/m/admin/notices/page.tsx: + 첨부 이미지 없으면 본문 HTML 의 첫 자동 추출 + send-push 호출 시 imageUrl 전달 - public/sw.js v4: + showNotification options.image 추가 (web-push 브라우저 큰 이미지) Co-Authored-By: Claude Opus 4.7 --- public/sw.js | 5 +++-- src/app/(main)/m/admin/notices/page.tsx | 6 +++++ .../api/m/admin/notices/send-push/route.ts | 22 +++++++++++++++++-- src/lib/firebase-push.ts | 14 ++++++++---- src/lib/push.ts | 3 +++ 5 files changed, 42 insertions(+), 8 deletions(-) diff --git a/public/sw.js b/public/sw.js index 7d5af01..8901f19 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // 모모유통 ERP — Service Worker (PWA install criteria 충족용) -const CACHE = 'momo-erp-v3'; +const CACHE = 'momo-erp-v4'; const PRECACHE = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/badge-96.png']; self.addEventListener('install', (e) => { @@ -37,9 +37,10 @@ self.addEventListener('push', (e) => { try { data = e.data ? e.data.json() : {}; } catch (_) { data = {}; } const title = data.title || '모모유통'; const options = { - body: data.body || '', + body: data.body || ' ', icon: data.icon || '/icon-192.png', // 큰 아이콘 = 모모 로고(초록 M) badge: data.badge || '/badge-96.png', // 상태바 작은 아이콘 = 흰 M 단색(투명 배경) + image: data.image || undefined, // big picture (알림 확장 영역의 큰 이미지) tag: data.tag || undefined, renotify: !!data.tag, requireInteraction: true, diff --git a/src/app/(main)/m/admin/notices/page.tsx b/src/app/(main)/m/admin/notices/page.tsx index aad357d..daf9491 100644 --- a/src/app/(main)/m/admin/notices/page.tsx +++ b/src/app/(main)/m/admin/notices/page.tsx @@ -199,6 +199,11 @@ export default function AdminNoticesPage() { const groupNames = activeGroup && !sendAll ? [activeGroup.NAME] : []; // 푸시 본문은 plain text — HTML 태그 제거 + 공백 압축 const plainBody = bodyText.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim(); + // 첨부 이미지가 없으면 본문 HTML 의 첫 추출해서 big picture 로 사용 + const firstImgInBody = (() => { + const m = bodyText.match(/]+src=["']([^"']+)["']/i); + return m ? m[1] : undefined; + })(); const sendRes = await fetch("/api/m/admin/notices/send-push", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -207,6 +212,7 @@ export default function AdminNoticesPage() { userIds: sendAll ? undefined : targetUserIds, sendAll, groupNames, + imageUrl: imageUrl || firstImgInBody, }), }); const sj = await sendRes.json(); diff --git a/src/app/api/m/admin/notices/send-push/route.ts b/src/app/api/m/admin/notices/send-push/route.ts index 98796a9..5effdf1 100644 --- a/src/app/api/m/admin/notices/send-push/route.ts +++ b/src/app/api/m/admin/notices/send-push/route.ts @@ -12,7 +12,7 @@ export async function POST(req: NextRequest) { await ensureNoticesTable(); const body = await req.json().catch(() => ({})); - const { noticeObjid, title, message, url, userIds, sendAll, groupNames } = body as { + const { noticeObjid, title, message, url, userIds, sendAll, groupNames, imageUrl } = body as { noticeObjid?: string; title?: string; message?: string; @@ -20,6 +20,7 @@ export async function POST(req: NextRequest) { userIds?: string[]; sendAll?: boolean; groupNames?: string[]; + imageUrl?: string; }; if (!title || !title.trim()) { return NextResponse.json({ success: false, message: "제목은 필수입니다." }, { status: 400 }); @@ -33,8 +34,25 @@ export async function POST(req: NextRequest) { : noticeObjid ? `/m/notices/${noticeObjid}` : "/m/orders/new"; + // 이미지 URL 절대화 — FCM/web-push 가 외부에서 접근 가능한 절대 URL 필요 + const absImage = (() => { + if (!imageUrl) return undefined; + if (/^https?:\/\//i.test(imageUrl)) return imageUrl; + const base = process.env.NEXTAUTH_URL || "https://momotogether.com"; + return imageUrl.startsWith("/") ? `${base}${imageUrl}` : `${base}/${imageUrl}`; + })(); + + // 본문이 비어있으면 placeholder ("새 공지 도착") — Android 가 빈 body 알림을 무시하는 케이스 회피 + const finalBody = (message ?? "").trim().slice(0, 240) || "(공지 페이지에서 확인)"; + const res = await sendPush( - { title: title.trim(), body: (message ?? "").slice(0, 240), url: targetUrl, tag: noticeObjid ? `notice-${noticeObjid}` : undefined }, + { + title: title.trim(), + body: finalBody, + url: targetUrl, + tag: noticeObjid ? `notice-${noticeObjid}` : undefined, + image: absImage, + }, sendAll ? undefined : targets ); diff --git a/src/lib/firebase-push.ts b/src/lib/firebase-push.ts index f1ba36c..d47f4cc 100644 --- a/src/lib/firebase-push.ts +++ b/src/lib/firebase-push.ts @@ -87,6 +87,7 @@ export interface FcmPayload { body: string; url?: string; tag?: string; + image?: string; // big picture URL (절대 URL 권장) } // FCM v1 send — 토큰 1개씩 호출 (v1 은 batch 미지원). @@ -105,17 +106,19 @@ export async function sendFcm(tokens: string[], payload: FcmPayload): Promise { - const message = { + const message: Record = { token, notification: { title: payload.title, - body: payload.body, + body: payload.body || " ", // 빈 본문 대비 — 일부 OS 가 body 없으면 알림을 dismiss + ...(payload.image ? { image: payload.image } : {}), }, data: { title: payload.title, - body: payload.body, + body: payload.body || "", url: payload.url || "/m/orders/new", tag: payload.tag || "", + ...(payload.image ? { image: payload.image } : {}), }, android: { priority: "HIGH" as const, @@ -123,8 +126,11 @@ export async function sendFcm(tokens: string[], payload: FcmPayload): Promise