优化路径,添加消息推送

This commit is contained in:
2026-04-17 12:14:57 +08:00
parent b8a842367b
commit 0da5936ce0
34 changed files with 2472 additions and 435 deletions
+286
View File
@@ -0,0 +1,286 @@
import { MessageDirection, MessageStatus } from "@prisma/client";
import { createBindUrl, getCaregiverDashboard } from "@/lib/monitor-data";
import { prisma } from "@/lib/prisma";
const deviceSummarySelect = {
id: true,
deviceUuid: true,
displayName: true,
appVersion: true,
lastSeenAt: true,
} as const;
export async function getCaregiverOverview(caregiverId: string) {
const dashboard = await getCaregiverDashboard(caregiverId);
const [recentIncomingMessages, recentOutgoingMessages] = await prisma.$transaction([
prisma.familyMessage.findMany({
where: {
direction: MessageDirection.ELDER_TO_FAMILY,
elderDevice: {
bindings: {
some: { caregiverId },
},
},
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
orderBy: { createdAt: "desc" },
take: 4,
}),
prisma.familyMessage.findMany({
where: {
direction: MessageDirection.FAMILY_TO_ELDER,
caregiverId,
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
orderBy: { createdAt: "desc" },
take: 4,
}),
]);
return {
dashboard,
recentIncomingMessages,
recentOutgoingMessages,
};
}
export async function getCaregiverMessages(caregiverId: string) {
const [messages, unreadIncomingCount, unreadOutgoingCount] = await prisma.$transaction([
prisma.familyMessage.findMany({
where: {
elderDevice: {
bindings: {
some: { caregiverId },
},
},
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
orderBy: [{ createdAt: "desc" }, { publicId: "desc" }],
take: 120,
}),
prisma.familyMessage.count({
where: {
direction: MessageDirection.ELDER_TO_FAMILY,
elderDevice: {
bindings: {
some: { caregiverId },
},
},
readAt: null,
},
}),
prisma.familyMessage.count({
where: {
direction: MessageDirection.FAMILY_TO_ELDER,
caregiverId,
readAt: null,
},
}),
]);
return {
messages,
unreadIncomingCount,
unreadOutgoingCount,
};
}
export async function getCaregiverMessageDetail(
caregiverId: string,
publicId: number,
) {
let message = await prisma.familyMessage.findFirst({
where: {
publicId,
elderDevice: {
bindings: {
some: { caregiverId },
},
},
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
});
if (!message) {
return null;
}
if (message.direction === MessageDirection.ELDER_TO_FAMILY && !message.readAt) {
const now = new Date();
message = await prisma.familyMessage.update({
where: { id: message.id },
data: {
readAt: now,
deliveredAt: message.deliveredAt ?? now,
status: MessageStatus.READ,
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
});
}
return message;
}
export async function getCaregiverDevices(caregiverId: string) {
const dashboard = await getCaregiverDashboard(caregiverId);
return dashboard.devices;
}
export async function getCaregiverDeviceDetail(
caregiverId: string,
deviceUuid: string,
) {
const binding = await prisma.deviceBinding.findFirst({
where: {
caregiverId,
elderDevice: {
deviceUuid,
},
},
include: {
elderDevice: {
include: {
messages: {
orderBy: [{ createdAt: "desc" }, { publicId: "desc" }],
take: 14,
},
usageEvents: {
orderBy: { createdAt: "desc" },
take: 10,
},
conversationTurns: {
orderBy: { createdAt: "desc" },
take: 12,
},
toolCalls: {
orderBy: { createdAt: "desc" },
take: 8,
},
_count: {
select: {
messages: true,
usageEvents: true,
conversationTurns: true,
toolCalls: true,
},
},
},
},
},
});
if (!binding) {
return null;
}
const [familyUnreadCount, elderUnreadCount] = await prisma.$transaction([
prisma.familyMessage.count({
where: {
elderDeviceId: binding.elderDeviceId,
direction: MessageDirection.FAMILY_TO_ELDER,
readAt: null,
},
}),
prisma.familyMessage.count({
where: {
elderDeviceId: binding.elderDeviceId,
direction: MessageDirection.ELDER_TO_FAMILY,
readAt: null,
},
}),
]);
return {
...binding,
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
familyUnreadCount,
elderUnreadCount,
};
}
export async function getCaregiverActivity(caregiverId: string) {
const deviceBindings = await prisma.deviceBinding.findMany({
where: { caregiverId },
select: {
elderDeviceId: true,
},
});
const elderDeviceIds = deviceBindings.map((binding) => binding.elderDeviceId);
if (elderDeviceIds.length === 0) {
return {
usageEvents: [],
conversationTurns: [],
toolCalls: [],
};
}
const [usageEvents, conversationTurns, toolCalls] = await prisma.$transaction([
prisma.usageEvent.findMany({
where: {
elderDeviceId: { in: elderDeviceIds },
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
orderBy: { createdAt: "desc" },
take: 24,
}),
prisma.conversationTurn.findMany({
where: {
elderDeviceId: { in: elderDeviceIds },
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
orderBy: { createdAt: "desc" },
take: 24,
}),
prisma.toolCallLog.findMany({
where: {
elderDeviceId: { in: elderDeviceIds },
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
orderBy: { createdAt: "desc" },
take: 24,
}),
]);
return {
usageEvents,
conversationTurns,
toolCalls,
};
}
+18 -3
View File
@@ -7,6 +7,7 @@ import {
UsageEventType,
} from "@prisma/client";
import { sendIncomingMessagePush } from "@/lib/push";
import { prisma } from "@/lib/prisma";
type RegisterDeviceInput = {
@@ -338,7 +339,7 @@ export async function recordConversationTurns(input: ConversationTurnInput) {
export async function recordToolCall(input: ToolCallInput) {
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
return prisma.$transaction(async (tx) => {
const result = await prisma.$transaction(async (tx) => {
const log = await tx.toolCallLog.create({
data: {
elderDeviceId: device.id,
@@ -367,13 +368,14 @@ export async function recordToolCall(input: ToolCallInput) {
typeof input.arguments.message === "string"
? input.arguments.message.trim()
: "";
let createdMessagePublicId: number | null = null;
if (
input.status === ToolCallStatus.SUCCESS &&
input.toolName === "leave_message_for_family" &&
messageText
) {
await tx.familyMessage.create({
const createdMessage = await tx.familyMessage.create({
data: {
elderDeviceId: device.id,
direction: MessageDirection.ELDER_TO_FAMILY,
@@ -392,10 +394,23 @@ export async function recordToolCall(input: ToolCallInput) {
source: "MODEL_TOOL",
},
});
createdMessagePublicId = createdMessage.publicId;
}
return log;
return {
log,
createdMessagePublicId,
};
});
if (result.createdMessagePublicId) {
void sendIncomingMessagePush(result.createdMessagePublicId).catch(() => {
return undefined;
});
}
return result.log;
}
export async function getCaregiverDashboard(caregiverId: string) {
+16
View File
@@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
import {
buildCaregiverSessionBootstrapPath,
getCaregiverSession,
} from "@/lib/session";
export async function requireCaregiverSession(redirectTo: string) {
const caregiver = await getCaregiverSession();
if (!caregiver) {
redirect(buildCaregiverSessionBootstrapPath(redirectTo));
}
return caregiver;
}
+114
View File
@@ -0,0 +1,114 @@
import {
ConversationRole,
MessageDirection,
MessageImportance,
MessageStatus,
UsageEventType,
} from "@prisma/client";
export function formatDateTime(value: Date | string | null | undefined) {
if (!value) {
return "暂时还没有记录";
}
const date = value instanceof Date ? value : new Date(value);
return new Intl.DateTimeFormat("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
export function getImportanceLabel(level: MessageImportance | string) {
switch (level) {
case MessageImportance.LOW:
return "温和提醒";
case MessageImportance.HIGH:
return "希望尽快看到";
case MessageImportance.URGENT:
return "需要尽快联系";
default:
return "日常问候";
}
}
export function getUsageLabel(eventType: UsageEventType | string) {
switch (eventType) {
case UsageEventType.APP_OPEN:
return "打开了老人端应用";
case UsageEventType.SETTINGS_OPENED:
return "打开了家人连接页";
case UsageEventType.AI_SESSION_STARTED:
return "开始了一次陪伴对话";
case UsageEventType.AI_SESSION_ENDED:
return "结束了一次陪伴对话";
case UsageEventType.CAMERA_ENABLED:
return "打开了镜头模式";
case UsageEventType.CAMERA_DISABLED:
return "关闭了镜头模式";
case UsageEventType.TOOL_CALLED:
return "模型调用了一次桥接工具";
default:
return eventType;
}
}
export function getConversationRoleLabel(role: ConversationRole | string) {
switch (role) {
case ConversationRole.USER:
return "老人说";
case ConversationRole.TOOL:
return "工具结果";
default:
return "数字人回复";
}
}
export function getMessageDirectionLabel(direction: MessageDirection | string) {
return direction === MessageDirection.ELDER_TO_FAMILY
? "老人给家里"
: "家属给老人";
}
export function getMessageStatusLabel(input: {
direction: MessageDirection | string;
status: MessageStatus | string;
readAt?: Date | string | null;
deliveredAt?: Date | string | null;
}) {
if (input.direction === MessageDirection.ELDER_TO_FAMILY) {
return input.readAt ? "家属已打开" : "等待家属查看";
}
if (input.readAt) {
return "老人已看过";
}
if (input.deliveredAt) {
return "已送到老人设备";
}
if (input.status === MessageStatus.PENDING) {
return "等待老人设备拉取";
}
return "等待老人查看";
}
export function truncateText(content: string, maxLength = 88) {
if (content.length <= maxLength) {
return content;
}
return `${content.slice(0, maxLength).trimEnd()}...`;
}
export function getMessageHref(publicId: number) {
return `/messages/${publicId}`;
}
export function getDeviceName(displayName?: string | null) {
return displayName?.trim() || "未命名陪伴设备";
}
+208
View File
@@ -0,0 +1,208 @@
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;
};
};
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(),
},
});
}
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 Promise.allSettled(
subscriptions.map(async (subscription) => {
try {
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime?.getTime() || null,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
payload,
{
TTL: 60 * 30,
urgency: "high",
topic: `msg-${message.publicId}`,
},
);
} catch (error) {
const statusCode =
error && typeof error === "object" && "statusCode" in error
? Number((error as { statusCode?: number }).statusCode)
: 0;
if (statusCode === 404 || statusCode === 410) {
await prisma.pushSubscription.deleteMany({
where: { endpoint: subscription.endpoint },
});
}
}
}),
);
}