添加家人绑定
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
import {
|
||||
ConversationRole,
|
||||
MessageDirection,
|
||||
MessageImportance,
|
||||
MessageStatus,
|
||||
ToolCallStatus,
|
||||
UsageEventType,
|
||||
} from "@prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type RegisterDeviceInput = {
|
||||
deviceUuid: string;
|
||||
displayName?: string;
|
||||
appVersion?: string;
|
||||
};
|
||||
|
||||
type UsageEventInput = {
|
||||
deviceUuid: string;
|
||||
eventType: UsageEventType;
|
||||
detailJson?: string;
|
||||
};
|
||||
|
||||
type ConversationTurnInput = {
|
||||
deviceUuid: string;
|
||||
sessionId?: string;
|
||||
turns: Array<{
|
||||
role: ConversationRole;
|
||||
content: string;
|
||||
metaJson?: string;
|
||||
createdAt?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type ToolCallInput = {
|
||||
deviceUuid: string;
|
||||
toolName: string;
|
||||
arguments: Record<string, unknown>;
|
||||
outputText?: string;
|
||||
status: ToolCallStatus;
|
||||
callId?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
const PUBLIC_BASE_URL = (
|
||||
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
const DEVICE_UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function normalizeDeviceUuid(rawValue: string) {
|
||||
return rawValue.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function createBindUrl(deviceUuid: string) {
|
||||
return `${PUBLIC_BASE_URL}/bind?deviceUuid=${encodeURIComponent(
|
||||
normalizeDeviceUuid(deviceUuid),
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function extractDeviceUuid(rawValue: string) {
|
||||
const trimmedValue = rawValue.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(trimmedValue);
|
||||
const queryDeviceUuid =
|
||||
parsedUrl.searchParams.get("deviceUuid") ||
|
||||
parsedUrl.searchParams.get("device");
|
||||
|
||||
if (queryDeviceUuid && DEVICE_UUID_PATTERN.test(queryDeviceUuid)) {
|
||||
return normalizeDeviceUuid(queryDeviceUuid);
|
||||
}
|
||||
|
||||
const lastSegment = parsedUrl.pathname.split("/").filter(Boolean).at(-1);
|
||||
if (lastSegment && DEVICE_UUID_PATTERN.test(lastSegment)) {
|
||||
return normalizeDeviceUuid(lastSegment);
|
||||
}
|
||||
} catch {
|
||||
if (DEVICE_UUID_PATTERN.test(trimmedValue)) {
|
||||
return normalizeDeviceUuid(trimmedValue);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function ensureDeviceRegistration(input: RegisterDeviceInput) {
|
||||
const deviceUuid = normalizeDeviceUuid(input.deviceUuid);
|
||||
|
||||
return prisma.elderDevice.upsert({
|
||||
where: { deviceUuid },
|
||||
update: {
|
||||
displayName: input.displayName?.trim() || undefined,
|
||||
appVersion: input.appVersion?.trim() || undefined,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
deviceUuid,
|
||||
displayName: input.displayName?.trim() || undefined,
|
||||
appVersion: input.appVersion?.trim() || undefined,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function bindDeviceToCaregiver(
|
||||
caregiverId: string,
|
||||
rawDeviceValue: string,
|
||||
) {
|
||||
const deviceUuid = extractDeviceUuid(rawDeviceValue);
|
||||
|
||||
if (!deviceUuid) {
|
||||
throw new Error("设备码格式不正确,请重新扫码或粘贴。");
|
||||
}
|
||||
|
||||
const device = await ensureDeviceRegistration({ deviceUuid });
|
||||
|
||||
await prisma.deviceBinding.upsert({
|
||||
where: {
|
||||
caregiverId_elderDeviceId: {
|
||||
caregiverId,
|
||||
elderDeviceId: device.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
caregiverId,
|
||||
elderDeviceId: device.id,
|
||||
},
|
||||
});
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
function parseImportance(rawImportance?: string | null) {
|
||||
const normalizedImportance = rawImportance?.trim().toUpperCase();
|
||||
|
||||
switch (normalizedImportance) {
|
||||
case MessageImportance.LOW:
|
||||
return MessageImportance.LOW;
|
||||
case MessageImportance.HIGH:
|
||||
return MessageImportance.HIGH;
|
||||
case MessageImportance.URGENT:
|
||||
return MessageImportance.URGENT;
|
||||
default:
|
||||
return MessageImportance.NORMAL;
|
||||
}
|
||||
}
|
||||
|
||||
function buildFamilyMessageSummary(
|
||||
messages: Array<{ content: string }>,
|
||||
unreadCount: number,
|
||||
) {
|
||||
if (messages.length === 0) {
|
||||
return "家里人暂时还没有新的留言。";
|
||||
}
|
||||
|
||||
const preview = messages
|
||||
.slice(0, 3)
|
||||
.map((message, index) => `${index + 1}. ${message.content}`)
|
||||
.join(" ");
|
||||
|
||||
if (unreadCount > 0) {
|
||||
return `家里人给您留了${unreadCount}条新话:${preview}`;
|
||||
}
|
||||
|
||||
return `家里人最近说:${preview}`;
|
||||
}
|
||||
|
||||
export async function listFamilyMessagesForDevice(
|
||||
rawDeviceValue: string,
|
||||
take = 12,
|
||||
) {
|
||||
const deviceUuid = extractDeviceUuid(rawDeviceValue);
|
||||
|
||||
if (!deviceUuid) {
|
||||
throw new Error("设备码格式不正确。");
|
||||
}
|
||||
|
||||
const device = await ensureDeviceRegistration({ deviceUuid });
|
||||
const messages = await prisma.familyMessage.findMany({
|
||||
where: {
|
||||
elderDeviceId: device.id,
|
||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take,
|
||||
});
|
||||
|
||||
const unreadCount = await prisma.familyMessage.count({
|
||||
where: {
|
||||
elderDeviceId: device.id,
|
||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||
readAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.familyMessage.updateMany({
|
||||
where: {
|
||||
elderDeviceId: device.id,
|
||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||
deliveredAt: null,
|
||||
},
|
||||
data: {
|
||||
deliveredAt: new Date(),
|
||||
status: MessageStatus.DELIVERED,
|
||||
},
|
||||
});
|
||||
|
||||
const summarySource =
|
||||
messages.filter((message) => message.readAt === null).length > 0
|
||||
? messages.filter((message) => message.readAt === null)
|
||||
: messages;
|
||||
|
||||
return {
|
||||
device,
|
||||
unreadCount,
|
||||
summaryText: buildFamilyMessageSummary(summarySource, unreadCount),
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
export async function markFamilyMessagesAsRead(
|
||||
rawDeviceValue: string,
|
||||
messageIds?: string[],
|
||||
) {
|
||||
const deviceUuid = extractDeviceUuid(rawDeviceValue);
|
||||
|
||||
if (!deviceUuid) {
|
||||
throw new Error("设备码格式不正确。");
|
||||
}
|
||||
|
||||
const device = await ensureDeviceRegistration({ deviceUuid });
|
||||
const now = new Date();
|
||||
|
||||
await prisma.familyMessage.updateMany({
|
||||
where: {
|
||||
elderDeviceId: device.id,
|
||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||
readAt: null,
|
||||
id: messageIds?.length ? { in: messageIds } : undefined,
|
||||
},
|
||||
data: {
|
||||
readAt: now,
|
||||
deliveredAt: now,
|
||||
status: MessageStatus.READ,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createFamilyMessageFromCaregiver(input: {
|
||||
caregiverId: string;
|
||||
rawDeviceValue: string;
|
||||
content: string;
|
||||
importance?: string;
|
||||
}) {
|
||||
const deviceUuid = extractDeviceUuid(input.rawDeviceValue);
|
||||
|
||||
if (!deviceUuid) {
|
||||
throw new Error("设备码格式不正确,请重新扫码或粘贴。");
|
||||
}
|
||||
|
||||
const binding = await prisma.deviceBinding.findFirst({
|
||||
where: {
|
||||
caregiverId: input.caregiverId,
|
||||
elderDevice: {
|
||||
deviceUuid,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
elderDevice: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!binding) {
|
||||
throw new Error("这位老人还没有绑定到当前家属账号。");
|
||||
}
|
||||
|
||||
return prisma.familyMessage.create({
|
||||
data: {
|
||||
elderDeviceId: binding.elderDeviceId,
|
||||
caregiverId: input.caregiverId,
|
||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||
content: input.content.trim(),
|
||||
importance: parseImportance(input.importance),
|
||||
status: MessageStatus.PENDING,
|
||||
source: "FAMILY_PANEL",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordUsageEvent(input: UsageEventInput) {
|
||||
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
|
||||
|
||||
return prisma.usageEvent.create({
|
||||
data: {
|
||||
elderDeviceId: device.id,
|
||||
eventType: input.eventType,
|
||||
detailJson: input.detailJson || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordConversationTurns(input: ConversationTurnInput) {
|
||||
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
|
||||
const validTurns = input.turns
|
||||
.map((turn) => ({
|
||||
role: turn.role,
|
||||
content: turn.content.trim(),
|
||||
metaJson: turn.metaJson?.trim() || null,
|
||||
createdAt: turn.createdAt ? new Date(turn.createdAt) : new Date(),
|
||||
}))
|
||||
.filter((turn) => turn.content.length > 0);
|
||||
|
||||
if (validTurns.length === 0) {
|
||||
return { count: 0 };
|
||||
}
|
||||
|
||||
const result = await prisma.conversationTurn.createMany({
|
||||
data: validTurns.map((turn) => ({
|
||||
elderDeviceId: device.id,
|
||||
sessionId: input.sessionId?.trim() || null,
|
||||
role: turn.role,
|
||||
content: turn.content,
|
||||
metaJson: turn.metaJson,
|
||||
createdAt: turn.createdAt,
|
||||
})),
|
||||
});
|
||||
|
||||
return { count: result.count };
|
||||
}
|
||||
|
||||
export async function recordToolCall(input: ToolCallInput) {
|
||||
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const log = await tx.toolCallLog.create({
|
||||
data: {
|
||||
elderDeviceId: device.id,
|
||||
sessionId: input.sessionId?.trim() || null,
|
||||
callId: input.callId?.trim() || null,
|
||||
toolName: input.toolName.trim(),
|
||||
argumentsJson: JSON.stringify(input.arguments),
|
||||
outputText: input.outputText?.trim() || null,
|
||||
status: input.status,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.usageEvent.create({
|
||||
data: {
|
||||
elderDeviceId: device.id,
|
||||
eventType: UsageEventType.TOOL_CALLED,
|
||||
detailJson: JSON.stringify({
|
||||
toolName: input.toolName,
|
||||
callId: input.callId || null,
|
||||
status: input.status,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const messageText =
|
||||
typeof input.arguments.message === "string"
|
||||
? input.arguments.message.trim()
|
||||
: "";
|
||||
|
||||
if (
|
||||
input.status === ToolCallStatus.SUCCESS &&
|
||||
input.toolName === "leave_message_for_family" &&
|
||||
messageText
|
||||
) {
|
||||
await tx.familyMessage.create({
|
||||
data: {
|
||||
elderDeviceId: device.id,
|
||||
direction: MessageDirection.ELDER_TO_FAMILY,
|
||||
content: messageText,
|
||||
recipientRelation:
|
||||
typeof input.arguments.recipientRelation === "string"
|
||||
? input.arguments.recipientRelation.trim()
|
||||
: null,
|
||||
importance: parseImportance(
|
||||
typeof input.arguments.importance === "string"
|
||||
? input.arguments.importance
|
||||
: null,
|
||||
),
|
||||
status: MessageStatus.DELIVERED,
|
||||
deliveredAt: new Date(),
|
||||
source: "MODEL_TOOL",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return log;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCaregiverDashboard(caregiverId: string) {
|
||||
const caregiver = await prisma.caregiverAccount.findUnique({
|
||||
where: { id: caregiverId },
|
||||
include: {
|
||||
bindings: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
elderDevice: {
|
||||
include: {
|
||||
messages: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
},
|
||||
usageEvents: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 6,
|
||||
},
|
||||
conversationTurns: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
},
|
||||
toolCalls: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 4,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
messages: true,
|
||||
usageEvents: true,
|
||||
conversationTurns: true,
|
||||
toolCalls: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!caregiver) {
|
||||
throw new Error("当前家属账号不存在。");
|
||||
}
|
||||
|
||||
const elderDeviceIds = caregiver.bindings.map((binding) => binding.elderDeviceId);
|
||||
const familyUnreadCounts = elderDeviceIds.length
|
||||
? await prisma.familyMessage.groupBy({
|
||||
by: ["elderDeviceId"],
|
||||
_count: { _all: true },
|
||||
where: {
|
||||
elderDeviceId: { in: elderDeviceIds },
|
||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||
readAt: null,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const elderUnreadCounts = elderDeviceIds.length
|
||||
? await prisma.familyMessage.groupBy({
|
||||
by: ["elderDeviceId"],
|
||||
_count: { _all: true },
|
||||
where: {
|
||||
elderDeviceId: { in: elderDeviceIds },
|
||||
direction: MessageDirection.ELDER_TO_FAMILY,
|
||||
readAt: null,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const familyUnreadMap = new Map(
|
||||
familyUnreadCounts.map((item) => [item.elderDeviceId, item._count._all]),
|
||||
);
|
||||
const elderUnreadMap = new Map(
|
||||
elderUnreadCounts.map((item) => [item.elderDeviceId, item._count._all]),
|
||||
);
|
||||
|
||||
return {
|
||||
caregiver,
|
||||
devices: caregiver.bindings.map((binding) => ({
|
||||
...binding,
|
||||
familyUnreadCount: familyUnreadMap.get(binding.elderDeviceId) || 0,
|
||||
elderUnreadCount: elderUnreadMap.get(binding.elderDeviceId) || 0,
|
||||
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma?: PrismaClient;
|
||||
};
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL 尚未配置,无法初始化 Prisma。");
|
||||
}
|
||||
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ?? new PrismaClient({ adapter });
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const CAREGIVER_SESSION_COOKIE = "dh-caregiver-session";
|
||||
|
||||
const CAREGIVER_SESSION_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
function createSessionToken() {
|
||||
return randomUUID().replaceAll("-", "");
|
||||
}
|
||||
|
||||
function getCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: CAREGIVER_SESSION_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRedirectPath(redirectTo?: string) {
|
||||
if (!redirectTo || !redirectTo.startsWith("/") || redirectTo.startsWith("//")) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
return redirectTo;
|
||||
}
|
||||
|
||||
async function upsertCaregiverByToken(sessionToken: string) {
|
||||
return prisma.caregiverAccount.upsert({
|
||||
where: { sessionToken },
|
||||
update: {},
|
||||
create: { sessionToken },
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCaregiverSessionBootstrapPath(redirectTo: string) {
|
||||
const normalizedRedirect = normalizeRedirectPath(redirectTo);
|
||||
return `/api/family/session?redirectTo=${encodeURIComponent(normalizedRedirect)}`;
|
||||
}
|
||||
|
||||
export async function getCaregiverSession() {
|
||||
const sessionToken = (await cookies()).get(CAREGIVER_SESSION_COOKIE)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return upsertCaregiverByToken(sessionToken);
|
||||
}
|
||||
|
||||
export async function getOrCreateCaregiverSession() {
|
||||
const cookieStore = await cookies();
|
||||
let sessionToken = cookieStore.get(CAREGIVER_SESSION_COOKIE)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
sessionToken = createSessionToken();
|
||||
cookieStore.set(CAREGIVER_SESSION_COOKIE, sessionToken, getCookieOptions());
|
||||
}
|
||||
|
||||
return upsertCaregiverByToken(sessionToken);
|
||||
}
|
||||
|
||||
export function createCaregiverSessionCookieValue() {
|
||||
return {
|
||||
sessionToken: createSessionToken(),
|
||||
options: getCookieOptions(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user