digital-human-monitor-v2/lib/family-guard.ts
feie9456 747dcd359a 家庭守护圈:多人加入、角色权限、24 小时邀请链接、旧设备绑定自动兼容。
家庭动态:老人/家属发言、三种可见范围,家庭内部内容不会播给老人。
共同照护:家庭备忘录、任务创建、认领、完成与交接记录。
用药闭环:计划、未来 7 天提醒实例、确认服用、延后、异常反馈。
风险闭环:红橙黄蓝分级、30 分钟去重、推送、认领、处理与误报记录。
报平安:家属发起、老人语音回应、状态同步。
Web 导航升级为守护、家庭、照护、陪伴、我的。
设备接口已升级为设备令牌认证,UUID 不再单独承担认证。
2026-07-18 19:59:20 +08:00

373 lines
19 KiB
TypeScript

import {
AlertLevel,
AlertStatus,
CareTaskStatus,
CheckInStatus,
ContentVisibility,
FamilyRole,
MedicationDoseStatus,
MedicationSource,
MembershipStatus,
MemoPriority,
InvitationStatus,
} from "@prisma/client";
import { createHash, randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma";
const ACTIVE_MEMBER = { status: MembershipStatus.ACTIVE } as const;
function requiredText(value: unknown, label: string, max = 500) {
if (typeof value !== "string" || !value.trim()) throw new Error(`${label}不能为空。`);
return value.trim().slice(0, max);
}
function optionalText(value: unknown, max = 500) {
return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : null;
}
function parseDate(value: unknown) {
if (typeof value !== "string" || !value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
export async function ensureFamilyCircle(caregiverId: string, deviceUuid?: string) {
const binding = await prisma.deviceBinding.findFirst({
where: {
caregiverId,
elderDevice: deviceUuid ? { deviceUuid } : undefined,
},
orderBy: { createdAt: "asc" },
include: {
elderDevice: {
include: { elderProfile: { include: { familyCircle: true } } },
},
},
});
if (!binding) throw new Error("请先绑定一台长辈设备。 ");
let circle = binding.elderDevice.elderProfile?.familyCircle;
if (!circle) {
circle = await prisma.$transaction(async (tx) => {
const profile = await tx.elderProfile.upsert({
where: { elderDeviceId: binding.elderDeviceId },
update: {},
create: {
elderDeviceId: binding.elderDeviceId,
preferredName: binding.alias || binding.elderDevice.displayName || "长辈",
consentSetting: { create: {} },
},
});
return tx.familyCircle.upsert({
where: { elderProfileId: profile.id },
update: {},
create: {
elderProfileId: profile.id,
createdById: caregiverId,
name: `${profile.preferredName}的守护圈`,
},
});
});
}
const allBindings = await prisma.deviceBinding.findMany({
where: { elderDeviceId: binding.elderDeviceId },
orderBy: { createdAt: "asc" },
select: { caregiverId: true, createdAt: true },
});
await prisma.$transaction(
allBindings.map((item, index) =>
prisma.familyMembership.upsert({
where: {
familyCircleId_caregiverId: {
familyCircleId: circle!.id,
caregiverId: item.caregiverId,
},
},
update: { status: MembershipStatus.ACTIVE },
create: {
familyCircleId: circle!.id,
caregiverId: item.caregiverId,
role: index === 0 ? FamilyRole.ADMIN : FamilyRole.CAREGIVER,
dutyOrder: index + 1,
},
}),
),
);
return circle;
}
export async function requireCircleMember(caregiverId: string, circleId: string) {
const member = await prisma.familyMembership.findFirst({
where: { caregiverId, familyCircleId: circleId, ...ACTIVE_MEMBER },
});
if (!member) throw new Error("您不是这个家庭守护圈的成员。");
return member;
}
export async function getFamilyWorkspace(caregiverId: string, deviceUuid?: string) {
const circle = await ensureFamilyCircle(caregiverId, deviceUuid);
await requireCircleMember(caregiverId, circle.id);
return prisma.familyCircle.findUniqueOrThrow({
where: { id: circle.id },
include: {
elderProfile: { include: { elderDevice: true, consentSetting: true } },
memberships: {
where: ACTIVE_MEMBER,
orderBy: [{ dutyOrder: "asc" }, { createdAt: "asc" }],
include: { caregiver: { select: { id: true, nickname: true, username: true } } },
},
posts: { orderBy: [{ pinned: "desc" }, { createdAt: "desc" }], take: 30, include: { author: true } },
memos: { orderBy: [{ priority: "desc" }, { updatedAt: "desc" }], take: 30, include: { author: true } },
tasks: { orderBy: [{ status: "asc" }, { dueAt: "asc" }, { createdAt: "desc" }], take: 40, include: { createdBy: true, assignedTo: true } },
medicationPlans: { orderBy: { createdAt: "desc" }, include: { doses: { orderBy: { scheduledFor: "desc" }, take: 14 } } },
checkIns: { orderBy: { createdAt: "desc" }, take: 20, include: { requestedBy: true } },
alerts: { orderBy: { createdAt: "desc" }, take: 30, include: { claimedBy: true, safetySignal: true } },
},
});
}
function invitationHash(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export async function createFamilyInvitation(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
const member = await requireCircleMember(caregiverId, circle.id);
if (member.role !== FamilyRole.ADMIN) throw new Error("只有管理员可以邀请家庭成员。");
const token = randomBytes(24).toString("base64url");
const role = input.role === FamilyRole.CAREGIVER ? FamilyRole.CAREGIVER : FamilyRole.CARING;
await prisma.familyInvitation.create({
data: {
familyCircleId: circle.id,
createdById: caregiverId,
tokenHash: invitationHash(token), role,
relationLabel: optionalText(input.relationLabel, 30),
expiresAt: new Date(Date.now() + 24 * 60 * 60_000),
},
});
await audit(circle.id, caregiverId, "INVITE", "FamilyMembership", undefined, { role });
const baseUrl = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000").replace(/\/$/, "");
return { inviteUrl: `${baseUrl}/family/join?token=${encodeURIComponent(token)}`, expiresAt: new Date(Date.now() + 24 * 60 * 60_000) };
}
export async function acceptFamilyInvitation(caregiverId: string, token: string) {
const invitation = await prisma.familyInvitation.findUnique({
where: { tokenHash: invitationHash(requiredText(token, "邀请口令", 200)) },
include: { familyCircle: { include: { elderProfile: true } } },
});
if (!invitation || invitation.status !== InvitationStatus.PENDING) throw new Error("邀请链接无效或已被使用。");
if (invitation.expiresAt <= new Date()) {
await prisma.familyInvitation.update({ where: { id: invitation.id }, data: { status: InvitationStatus.EXPIRED } });
throw new Error("邀请链接已过期,请让管理员重新邀请。");
}
await prisma.$transaction([
prisma.familyMembership.upsert({
where: { familyCircleId_caregiverId: { familyCircleId: invitation.familyCircleId, caregiverId } },
update: { status: MembershipStatus.ACTIVE, role: invitation.role, relationLabel: invitation.relationLabel },
create: { familyCircleId: invitation.familyCircleId, caregiverId, role: invitation.role, relationLabel: invitation.relationLabel },
}),
prisma.deviceBinding.upsert({
where: { caregiverId_elderDeviceId: { caregiverId, elderDeviceId: invitation.familyCircle.elderProfile.elderDeviceId } },
update: {}, create: { caregiverId, elderDeviceId: invitation.familyCircle.elderProfile.elderDeviceId },
}),
prisma.familyInvitation.update({ where: { id: invitation.id }, data: { status: InvitationStatus.ACCEPTED, acceptedAt: new Date() } }),
]);
await audit(invitation.familyCircleId, caregiverId, "JOIN", "FamilyMembership");
return invitation.familyCircle;
}
export async function createFamilyPost(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
await requireCircleMember(caregiverId, circle.id);
const visibility = Object.values(ContentVisibility).includes(input.visibility as ContentVisibility)
? (input.visibility as ContentVisibility) : ContentVisibility.FAMILY_AND_ELDER;
const post = await prisma.familyPost.create({
data: { familyCircleId: circle.id, authorId: caregiverId, content: requiredText(input.content, "动态内容", 1000), visibility },
});
await audit(circle.id, caregiverId, "CREATE", "FamilyPost", post.id);
return post;
}
export async function createFamilyMemo(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
const member = await requireCircleMember(caregiverId, circle.id);
if (member.role === FamilyRole.CARING) throw new Error("关怀成员不能修改家庭备忘录。");
const priority = Object.values(MemoPriority).includes(input.priority as MemoPriority)
? (input.priority as MemoPriority) : MemoPriority.NORMAL;
const memo = await prisma.familyMemo.create({
data: {
familyCircleId: circle.id,
authorId: caregiverId,
title: requiredText(input.title, "备忘标题", 80),
content: requiredText(input.content, "备忘内容", 1000),
priority,
visibility: input.visibility === ContentVisibility.FAMILY_ONLY ? ContentVisibility.FAMILY_ONLY : ContentVisibility.FAMILY_AND_ELDER,
},
});
await audit(circle.id, caregiverId, "CREATE", "FamilyMemo", memo.id);
return memo;
}
export async function createCareTask(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
const member = await requireCircleMember(caregiverId, circle.id);
if (member.role === FamilyRole.CARING) throw new Error("关怀成员不能创建照护任务。");
const task = await prisma.careTask.create({
data: {
familyCircleId: circle.id,
createdById: caregiverId,
title: requiredText(input.title, "任务标题", 100),
description: optionalText(input.description, 1000),
dueAt: parseDate(input.dueAt),
},
});
await audit(circle.id, caregiverId, "CREATE", "CareTask", task.id);
return task;
}
export async function updateCareTask(caregiverId: string, taskId: string, action: string, note?: unknown) {
const task = await prisma.careTask.findUniqueOrThrow({ where: { id: taskId } });
await requireCircleMember(caregiverId, task.familyCircleId);
const data = action === "claim"
? { status: CareTaskStatus.CLAIMED, assignedToId: caregiverId }
: action === "complete"
? { status: CareTaskStatus.COMPLETED, assignedToId: task.assignedToId || caregiverId, completedAt: new Date(), completionNote: optionalText(note, 500) }
: action === "reopen"
? { status: CareTaskStatus.OPEN, assignedToId: null, completedAt: null, completionNote: null }
: null;
if (!data) throw new Error("不支持的任务操作。");
const updated = await prisma.careTask.update({ where: { id: taskId }, data });
await audit(task.familyCircleId, caregiverId, action.toUpperCase(), "CareTask", taskId);
return updated;
}
function parseTimes(value: unknown) {
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
const times = values.map(String).map((v) => v.trim()).filter((v) => /^([01]\d|2[0-3]):[0-5]\d$/.test(v));
if (!times.length) throw new Error("请至少填写一个 HH:mm 格式的服药时间。");
return [...new Set(times)].sort();
}
function scheduledDate(dayOffset: number, time: string) {
const now = new Date();
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset);
const [hour, minute] = time.split(":").map(Number);
date.setHours(hour, minute, 0, 0);
return date;
}
export async function createMedicationPlan(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
const member = await requireCircleMember(caregiverId, circle.id);
if (member.role === FamilyRole.CARING) throw new Error("关怀成员不能管理用药计划。");
const times = parseTimes(input.times);
const source = Object.values(MedicationSource).includes(input.source as MedicationSource)
? (input.source as MedicationSource) : MedicationSource.FAMILY_ENTRY;
const startDate = parseDate(input.startDate) || new Date();
const plan = await prisma.medicationPlan.create({
data: {
familyCircleId: circle.id,
createdById: caregiverId,
name: requiredText(input.name, "药品名称", 100),
dosage: requiredText(input.dosage, "单次用量", 100),
instructions: optionalText(input.instructions, 300),
timesJson: JSON.stringify(times), source, startDate,
endDate: parseDate(input.endDate),
stockCount: typeof input.stockCount === "number" ? Math.max(0, Math.floor(input.stockCount)) : null,
doses: { create: times.flatMap((time) => Array.from({ length: 7 }, (_, day) => ({ scheduledFor: scheduledDate(day, time) }))) },
},
include: { doses: true },
});
await audit(circle.id, caregiverId, "CREATE", "MedicationPlan", plan.id, { source });
return plan;
}
export async function createCheckIn(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
await requireCircleMember(caregiverId, circle.id);
const checkIn = await prisma.checkInRequest.create({
data: { familyCircleId: circle.id, requestedById: caregiverId, prompt: optionalText(input.prompt, 240), dueAt: parseDate(input.dueAt) },
});
await audit(circle.id, caregiverId, "CREATE", "CheckInRequest", checkIn.id);
return checkIn;
}
export async function updateAlert(caregiverId: string, alertId: string, action: string, note?: unknown) {
const alert = await prisma.safetyAlert.findUniqueOrThrow({ where: { id: alertId } });
await requireCircleMember(caregiverId, alert.familyCircleId);
const data = action === "claim"
? { status: AlertStatus.ACKNOWLEDGED, claimedById: caregiverId, claimedAt: new Date() }
: action === "resolve"
? { status: AlertStatus.RESOLVED, claimedById: alert.claimedById || caregiverId, claimedAt: alert.claimedAt || new Date(), resolvedAt: new Date(), resolutionNote: requiredText(note, "处理结果", 500) }
: action === "dismiss"
? { status: AlertStatus.DISMISSED, claimedById: alert.claimedById || caregiverId, resolvedAt: new Date(), resolutionNote: optionalText(note, 500) || "已确认是误报" }
: null;
if (!data) throw new Error("不支持的告警操作。");
const updated = await prisma.safetyAlert.update({ where: { id: alertId }, data });
await audit(alert.familyCircleId, caregiverId, action.toUpperCase(), "SafetyAlert", alertId, { note });
return updated;
}
export async function getDeviceAgenda(deviceUuid: string) {
const profile = await prisma.elderProfile.findFirst({
where: { elderDevice: { deviceUuid } },
include: { familyCircle: true },
});
if (!profile?.familyCircle) return { circle: null, posts: [], memos: [], doses: [], checkIns: [] };
const circleId = profile.familyCircle.id;
const now = new Date();
const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1);
const [posts, memos, doses, checkIns] = await Promise.all([
prisma.familyPost.findMany({ where: { familyCircleId: circleId, visibility: { in: [ContentVisibility.ELDER_ONLY, ContentVisibility.FAMILY_AND_ELDER] }, deliveredAt: null }, include: { author: true }, orderBy: { createdAt: "asc" }, take: 8 }),
prisma.familyMemo.findMany({ where: { familyCircleId: circleId, visibility: { in: [ContentVisibility.ELDER_ONLY, ContentVisibility.FAMILY_AND_ELDER] } }, orderBy: [{ priority: "desc" }, { updatedAt: "desc" }], take: 12 }),
prisma.medicationDose.findMany({ where: { medicationPlan: { familyCircleId: circleId, active: true }, scheduledFor: { gte: dayStart, lt: dayEnd }, status: { in: [MedicationDoseStatus.SCHEDULED, MedicationDoseStatus.REMINDING, MedicationDoseStatus.SNOOZED] } }, include: { medicationPlan: true }, orderBy: { scheduledFor: "asc" } }),
prisma.checkInRequest.findMany({ where: { familyCircleId: circleId, status: CheckInStatus.PENDING }, orderBy: { createdAt: "asc" }, take: 5 }),
]);
if (posts.length) {
await prisma.familyPost.updateMany({ where: { id: { in: posts.map((post) => post.id) } }, data: { deliveredAt: new Date() } });
}
return { circle: profile.familyCircle, posts, memos, doses, checkIns };
}
export async function updateMedicationDose(deviceUuid: string, doseId: string, status: MedicationDoseStatus, note?: unknown, snoozeMinutes = 10) {
const dose = await prisma.medicationDose.findFirst({ where: { id: doseId, medicationPlan: { familyCircle: { elderProfile: { elderDevice: { deviceUuid } } } } }, include: { medicationPlan: true } });
if (!dose) throw new Error("没有找到这次服药提醒。");
if (dose.status === MedicationDoseStatus.TAKEN || dose.status === MedicationDoseStatus.SKIPPED) throw new Error("这次服药已经确认,不能重复提交。");
return prisma.medicationDose.update({
where: { id: doseId },
data: {
status,
respondedAt: status === MedicationDoseStatus.SNOOZED ? null : new Date(),
snoozedUntil: status === MedicationDoseStatus.SNOOZED ? new Date(Date.now() + Math.min(Math.max(snoozeMinutes, 1), 60) * 60_000) : null,
note: optionalText(note, 500),
},
});
}
export async function respondCheckIn(deviceUuid: string, checkInId: string, status: CheckInStatus, responseText?: unknown) {
const checkIn = await prisma.checkInRequest.findFirst({ where: { id: checkInId, familyCircle: { elderProfile: { elderDevice: { deviceUuid } } }, status: CheckInStatus.PENDING } });
if (!checkIn) throw new Error("没有找到待回应的报平安请求。");
return prisma.checkInRequest.update({ where: { id: checkInId }, data: { status, responseText: optionalText(responseText, 500), respondedAt: new Date() } });
}
export async function createSafetyAlertForDevice(deviceUuid: string, input: Record<string, unknown>) {
const profile = await prisma.elderProfile.findFirst({ where: { elderDevice: { deviceUuid } }, include: { familyCircle: true } });
if (!profile?.familyCircle) throw new Error("这台设备还没有加入家庭守护圈。");
const category = requiredText(input.category, "风险类别", 80);
const summary = requiredText(input.summary, "风险说明", 500);
const since = new Date(Date.now() - 30 * 60_000);
const duplicate = await prisma.safetyAlert.findFirst({ where: { familyCircleId: profile.familyCircle.id, status: { in: [AlertStatus.OPEN, AlertStatus.ACKNOWLEDGED] }, safetySignal: { category, createdAt: { gte: since } } }, orderBy: { createdAt: "desc" } });
if (duplicate) return { alert: duplicate, deduplicated: true };
const level = Object.values(AlertLevel).includes(input.level as AlertLevel) ? (input.level as AlertLevel) : AlertLevel.ORANGE;
const signal = await prisma.safetySignal.create({ data: { familyCircleId: profile.familyCircle.id, category, summary, contextText: optionalText(input.contextText, 1000), confidence: typeof input.confidence === "number" ? Math.min(Math.max(input.confidence, 0), 1) : null } });
const alert = await prisma.safetyAlert.create({ data: { familyCircleId: profile.familyCircle.id, safetySignalId: signal.id, level, title: requiredText(input.title, "提醒标题", 100), summary } });
return { alert, deduplicated: false };
}
async function audit(circleId: string, actorId: string | null, action: string, entityType: string, entityId?: string, detail?: unknown) {
await prisma.auditLog.create({ data: { familyCircleId: circleId, actorId, action, entityType, entityId, detailJson: detail === undefined ? null : JSON.stringify(detail) } });
}