fix(push): 알림에 본문/이미지 표시 — FCM image + sw.js image 옵션
증상: 폰에 도착한 푸시 알림이 제목만 보이고 본문/이미지 안 보임 원인: 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 의 첫 <img> 자동 추출 + send-push 호출 시 imageUrl 전달 - public/sw.js v4: + showNotification options.image 추가 (web-push 브라우저 큰 이미지) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+3
-2
@@ -1,5 +1,5 @@
|
|||||||
// 모모유통 ERP — Service Worker (PWA install criteria 충족용)
|
// 모모유통 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'];
|
const PRECACHE = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/badge-96.png'];
|
||||||
|
|
||||||
self.addEventListener('install', (e) => {
|
self.addEventListener('install', (e) => {
|
||||||
@@ -37,9 +37,10 @@ self.addEventListener('push', (e) => {
|
|||||||
try { data = e.data ? e.data.json() : {}; } catch (_) { data = {}; }
|
try { data = e.data ? e.data.json() : {}; } catch (_) { data = {}; }
|
||||||
const title = data.title || '모모유통';
|
const title = data.title || '모모유통';
|
||||||
const options = {
|
const options = {
|
||||||
body: data.body || '',
|
body: data.body || ' ',
|
||||||
icon: data.icon || '/icon-192.png', // 큰 아이콘 = 모모 로고(초록 M)
|
icon: data.icon || '/icon-192.png', // 큰 아이콘 = 모모 로고(초록 M)
|
||||||
badge: data.badge || '/badge-96.png', // 상태바 작은 아이콘 = 흰 M 단색(투명 배경)
|
badge: data.badge || '/badge-96.png', // 상태바 작은 아이콘 = 흰 M 단색(투명 배경)
|
||||||
|
image: data.image || undefined, // big picture (알림 확장 영역의 큰 이미지)
|
||||||
tag: data.tag || undefined,
|
tag: data.tag || undefined,
|
||||||
renotify: !!data.tag,
|
renotify: !!data.tag,
|
||||||
requireInteraction: true,
|
requireInteraction: true,
|
||||||
|
|||||||
@@ -199,6 +199,11 @@ export default function AdminNoticesPage() {
|
|||||||
const groupNames = activeGroup && !sendAll ? [activeGroup.NAME] : [];
|
const groupNames = activeGroup && !sendAll ? [activeGroup.NAME] : [];
|
||||||
// 푸시 본문은 plain text — HTML 태그 제거 + 공백 압축
|
// 푸시 본문은 plain text — HTML 태그 제거 + 공백 압축
|
||||||
const plainBody = bodyText.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim();
|
const plainBody = bodyText.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
// 첨부 이미지가 없으면 본문 HTML 의 첫 <img src=""> 추출해서 big picture 로 사용
|
||||||
|
const firstImgInBody = (() => {
|
||||||
|
const m = bodyText.match(/<img[^>]+src=["']([^"']+)["']/i);
|
||||||
|
return m ? m[1] : undefined;
|
||||||
|
})();
|
||||||
const sendRes = await fetch("/api/m/admin/notices/send-push", {
|
const sendRes = await fetch("/api/m/admin/notices/send-push", {
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -207,6 +212,7 @@ export default function AdminNoticesPage() {
|
|||||||
userIds: sendAll ? undefined : targetUserIds,
|
userIds: sendAll ? undefined : targetUserIds,
|
||||||
sendAll,
|
sendAll,
|
||||||
groupNames,
|
groupNames,
|
||||||
|
imageUrl: imageUrl || firstImgInBody,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const sj = await sendRes.json();
|
const sj = await sendRes.json();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function POST(req: NextRequest) {
|
|||||||
await ensureNoticesTable();
|
await ensureNoticesTable();
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({}));
|
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;
|
noticeObjid?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -20,6 +20,7 @@ export async function POST(req: NextRequest) {
|
|||||||
userIds?: string[];
|
userIds?: string[];
|
||||||
sendAll?: boolean;
|
sendAll?: boolean;
|
||||||
groupNames?: string[];
|
groupNames?: string[];
|
||||||
|
imageUrl?: string;
|
||||||
};
|
};
|
||||||
if (!title || !title.trim()) {
|
if (!title || !title.trim()) {
|
||||||
return NextResponse.json({ success: false, message: "제목은 필수입니다." }, { status: 400 });
|
return NextResponse.json({ success: false, message: "제목은 필수입니다." }, { status: 400 });
|
||||||
@@ -33,8 +34,25 @@ export async function POST(req: NextRequest) {
|
|||||||
: noticeObjid ? `/m/notices/${noticeObjid}`
|
: noticeObjid ? `/m/notices/${noticeObjid}`
|
||||||
: "/m/orders/new";
|
: "/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(
|
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
|
sendAll ? undefined : targets
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export interface FcmPayload {
|
|||||||
body: string;
|
body: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
image?: string; // big picture URL (절대 URL 권장)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FCM v1 send — 토큰 1개씩 호출 (v1 은 batch 미지원).
|
// FCM v1 send — 토큰 1개씩 호출 (v1 은 batch 미지원).
|
||||||
@@ -105,17 +106,19 @@ export async function sendFcm(tokens: string[], payload: FcmPayload): Promise<Fc
|
|||||||
// Native APK 가 자체 표시 옵션 결정 — data 만 보내고 notification 은 비움.
|
// Native APK 가 자체 표시 옵션 결정 — data 만 보내고 notification 은 비움.
|
||||||
// (notification 같이 보내면 백그라운드 시 OS 가 표시 + 앱 onMessage 안 부름)
|
// (notification 같이 보내면 백그라운드 시 OS 가 표시 + 앱 onMessage 안 부름)
|
||||||
await Promise.all(tokens.map(async (token) => {
|
await Promise.all(tokens.map(async (token) => {
|
||||||
const message = {
|
const message: Record<string, unknown> = {
|
||||||
token,
|
token,
|
||||||
notification: {
|
notification: {
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
body: payload.body,
|
body: payload.body || " ", // 빈 본문 대비 — 일부 OS 가 body 없으면 알림을 dismiss
|
||||||
|
...(payload.image ? { image: payload.image } : {}),
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
body: payload.body,
|
body: payload.body || "",
|
||||||
url: payload.url || "/m/orders/new",
|
url: payload.url || "/m/orders/new",
|
||||||
tag: payload.tag || "",
|
tag: payload.tag || "",
|
||||||
|
...(payload.image ? { image: payload.image } : {}),
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
priority: "HIGH" as const,
|
priority: "HIGH" as const,
|
||||||
@@ -123,8 +126,11 @@ export async function sendFcm(tokens: string[], payload: FcmPayload): Promise<Fc
|
|||||||
channel_id: "momo_default",
|
channel_id: "momo_default",
|
||||||
sound: "default",
|
sound: "default",
|
||||||
default_vibrate_timings: true,
|
default_vibrate_timings: true,
|
||||||
|
notification_priority: "PRIORITY_MAX" as const,
|
||||||
|
visibility: "PUBLIC" as const,
|
||||||
|
// big picture (안드로이드 알림 확장 영역에 큰 이미지 표시)
|
||||||
|
...(payload.image ? { image: payload.image } : {}),
|
||||||
// click_action 명시 X — OS 가 URL 로 해석해서 브라우저로 열어버림.
|
// click_action 명시 X — OS 가 URL 로 해석해서 브라우저로 열어버림.
|
||||||
// 비워두면 앱의 LAUNCHER(MainActivity) 가 열리고, Capacitor 가 data.url 로 webview 이동 처리.
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface PushPayload {
|
|||||||
body: string;
|
body: string;
|
||||||
url?: string; // 클릭 시 열 경로 (기본 /m/orders/new)
|
url?: string; // 클릭 시 열 경로 (기본 /m/orders/new)
|
||||||
tag?: string; // 같은 tag 알림은 묶임
|
tag?: string; // 같은 tag 알림은 묶임
|
||||||
|
image?: string; // big picture URL (안드로이드 알림 확장 영역)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubRow {
|
interface SubRow {
|
||||||
@@ -108,6 +109,7 @@ export async function sendPush(
|
|||||||
body: payload.body,
|
body: payload.body,
|
||||||
url: payload.url || "/m/orders/new",
|
url: payload.url || "/m/orders/new",
|
||||||
tag: payload.tag,
|
tag: payload.tag,
|
||||||
|
image: payload.image,
|
||||||
});
|
});
|
||||||
|
|
||||||
// kind 별로 분리
|
// kind 별로 분리
|
||||||
@@ -143,6 +145,7 @@ export async function sendPush(
|
|||||||
body: payload.body,
|
body: payload.body,
|
||||||
url: payload.url,
|
url: payload.url,
|
||||||
tag: payload.tag,
|
tag: payload.tag,
|
||||||
|
image: payload.image,
|
||||||
});
|
});
|
||||||
sent += fcm.sent;
|
sent += fcm.sent;
|
||||||
failed += fcm.failed;
|
failed += fcm.failed;
|
||||||
|
|||||||
Reference in New Issue
Block a user