2026-04-17 14:44:27 +08:00

359 lines
8.6 KiB
TypeScript

import { MessageDirection } from "@prisma/client";
import webpush from "web-push";
import { getDeviceName, truncateText } from "@/lib/panel-format";
import { prisma } from "@/lib/prisma";
type SerializablePushSubscription = {
endpoint: string;
expirationTime?: number | null;
keys?: {
p256dh?: string;
auth?: string;
};
};
type StoredPushSubscription = {
endpoint: string;
expirationTime: Date | null;
p256dh: string;
auth: string;
};
type PushDeliverySummary = {
targetedCount: number;
sentCount: number;
removedCount: number;
failedCount: number;
};
type PushFailureDetail = {
endpointHost: string;
statusCode: number | null;
message: string;
body: string | null;
reason: string | null;
};
type PushDeliveryResult = PushDeliverySummary & {
failureDetails?: PushFailureDetail[];
};
const APP_BASE_URL = (
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
).replace(/\/+$/, "");
let vapidReady = false;
function hasPushConfiguration() {
return Boolean(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY &&
process.env.VAPID_PRIVATE_KEY &&
process.env.VAPID_SUBJECT,
);
}
function ensureVapidDetails() {
if (!hasPushConfiguration()) {
throw new Error("VAPID 配置尚未完成,暂时不能发送推送通知。");
}
if (vapidReady) {
return;
}
webpush.setVapidDetails(
process.env.VAPID_SUBJECT!,
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!,
);
vapidReady = true;
}
function normalizeSubscription(input: SerializablePushSubscription) {
const endpoint = input.endpoint?.trim();
const p256dh = input.keys?.p256dh?.trim();
const auth = input.keys?.auth?.trim();
if (!endpoint || !p256dh || !auth) {
throw new Error("推送订阅信息不完整。");
}
return {
endpoint,
p256dh,
auth,
expirationTime:
typeof input.expirationTime === "number"
? new Date(input.expirationTime)
: null,
};
}
function absoluteUrl(path: string) {
if (/^https?:\/\//i.test(path)) {
return path;
}
return `${APP_BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
export function getPushPublicKey() {
return process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
}
export function isPushAvailable() {
return hasPushConfiguration();
}
export async function savePushSubscription(input: {
caregiverId: string;
subscription: SerializablePushSubscription;
userAgent?: string | null;
}) {
const subscription = normalizeSubscription(input.subscription);
return prisma.pushSubscription.upsert({
where: { endpoint: subscription.endpoint },
update: {
caregiverId: input.caregiverId,
p256dh: subscription.p256dh,
auth: subscription.auth,
expirationTime: subscription.expirationTime,
userAgent: input.userAgent?.trim() || null,
},
create: {
caregiverId: input.caregiverId,
endpoint: subscription.endpoint,
p256dh: subscription.p256dh,
auth: subscription.auth,
expirationTime: subscription.expirationTime,
userAgent: input.userAgent?.trim() || null,
},
});
}
export async function removePushSubscription(input: {
caregiverId: string;
endpoint: string;
}) {
await prisma.pushSubscription.deleteMany({
where: {
caregiverId: input.caregiverId,
endpoint: input.endpoint.trim(),
},
});
}
function getEndpointHost(endpoint: string) {
try {
return new URL(endpoint).host;
} catch {
return "unknown";
}
}
function getPushFailureDetail(subscription: StoredPushSubscription, error: unknown) {
const statusCode =
error && typeof error === "object" && "statusCode" in error
? Number((error as { statusCode?: number }).statusCode)
: null;
const message =
error && typeof error === "object" && "message" in error
? String((error as { message?: string }).message || "推送发送失败")
: "推送发送失败";
const body =
error && typeof error === "object" && "body" in error
? String((error as { body?: string }).body || "") || null
: null;
let reason: string | null = null;
if (body) {
try {
const parsedBody = JSON.parse(body) as { reason?: unknown };
reason = typeof parsedBody.reason === "string" ? parsedBody.reason : null;
} catch {
reason = null;
}
}
return {
endpointHost: getEndpointHost(subscription.endpoint),
statusCode,
message,
body,
reason,
} satisfies PushFailureDetail;
}
async function deliverPushToSubscriptions(input: {
subscriptions: StoredPushSubscription[];
payload: string;
topic: string;
collectFailureDetails?: boolean;
}) {
if (input.subscriptions.length === 0) {
return {
targetedCount: 0,
sentCount: 0,
removedCount: 0,
failedCount: 0,
failureDetails: [],
} satisfies PushDeliveryResult;
}
ensureVapidDetails();
const failureDetails: PushFailureDetail[] = [];
const results = await Promise.all(
input.subscriptions.map(async (subscription) => {
try {
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime?.getTime() || null,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
input.payload,
{
TTL: 60 * 30,
urgency: "high",
topic: input.topic,
},
);
return "sent" as const;
} catch (error) {
const detail = getPushFailureDetail(subscription, error);
const statusCode = detail.statusCode || 0;
console.error("Push notification delivery failed", detail);
if (input.collectFailureDetails) {
failureDetails.push(detail);
}
if (statusCode === 404 || statusCode === 410) {
await prisma.pushSubscription.deleteMany({
where: { endpoint: subscription.endpoint },
});
return "removed" as const;
}
return "failed" as const;
}
}),
);
return {
targetedCount: input.subscriptions.length,
sentCount: results.filter((result) => result === "sent").length,
removedCount: results.filter((result) => result === "removed").length,
failedCount: results.filter((result) => result === "failed").length,
failureDetails,
} satisfies PushDeliveryResult;
}
export async function sendIncomingMessagePush(publicId: number) {
if (!hasPushConfiguration()) {
return;
}
const message = await prisma.familyMessage.findUnique({
where: { publicId },
select: {
publicId: true,
content: true,
direction: true,
elderDeviceId: true,
elderDevice: {
select: {
deviceUuid: true,
displayName: true,
},
},
},
});
if (!message || message.direction !== MessageDirection.ELDER_TO_FAMILY) {
return;
}
const subscriptions = await prisma.pushSubscription.findMany({
where: {
caregiver: {
bindings: {
some: {
elderDeviceId: message.elderDeviceId,
},
},
},
},
});
if (subscriptions.length === 0) {
return;
}
ensureVapidDetails();
const deviceName = getDeviceName(message.elderDevice.displayName);
const payload = JSON.stringify({
title: `${deviceName} 发来一条新留言`,
body: truncateText(message.content, 72),
url: absoluteUrl(`/messages/${message.publicId}`),
icon: absoluteUrl("/pwa/icon-192x192.png"),
badge: absoluteUrl("/pwa/badge-96x96.png"),
tag: `message-${message.publicId}`,
});
await deliverPushToSubscriptions({
subscriptions,
payload,
topic: `msg-${message.publicId}`,
});
}
export async function sendCaregiverTestPush(input: {
caregiverId: string;
caregiverLabel: string;
}) {
if (!hasPushConfiguration()) {
throw new Error("当前环境还没有配置推送服务。",
);
}
const subscriptions = await prisma.pushSubscription.findMany({
where: {
caregiverId: input.caregiverId,
},
select: {
endpoint: true,
expirationTime: true,
p256dh: true,
auth: true,
},
});
const payload = JSON.stringify({
title: "安智伴推送测试",
body: `${input.caregiverLabel} 账号下的通知链路工作正常。`,
url: absoluteUrl("/settings"),
icon: absoluteUrl("/pwa/icon-192x192.png"),
badge: absoluteUrl("/pwa/badge-96x96.png"),
tag: `push-test-${input.caregiverId}`,
});
return deliverPushToSubscriptions({
subscriptions,
payload,
topic: `test-${input.caregiverId.slice(0, 20)}`,
collectFailureDetails: true,
});
}