新增设备别名管理功能,支持设备别名的保存与解绑

This commit is contained in:
2026-04-17 15:08:39 +08:00
parent 71760cbb1f
commit 70d42e6792
9 changed files with 570 additions and 72 deletions
+95
View File
@@ -11,6 +11,61 @@ const deviceSummarySelect = {
lastSeenAt: true,
} as const;
async function loadCaregiverDeviceNameMap(
caregiverId: string,
elderDeviceIds: string[],
) {
if (elderDeviceIds.length === 0) {
return new Map<string, string>();
}
const bindings = await prisma.deviceBinding.findMany({
where: {
caregiverId,
elderDeviceId: {
in: elderDeviceIds,
},
},
select: {
elderDeviceId: true,
alias: true,
elderDevice: {
select: {
displayName: true,
},
},
},
});
return new Map(
bindings
.map((binding) => [
binding.elderDeviceId,
binding.alias?.trim() || binding.elderDevice.displayName?.trim() || "",
] as const)
.filter((entry) => entry[1].length > 0),
);
}
function applyCaregiverDeviceNames<
T extends {
elderDeviceId: string;
elderDevice: {
displayName: string | null;
};
},
>(items: T[], deviceNameMap: Map<string, string>) {
items.forEach((item) => {
const resolvedName = deviceNameMap.get(item.elderDeviceId);
if (resolvedName) {
item.elderDevice.displayName = resolvedName;
}
});
return items;
}
export async function getCaregiverOverview(caregiverId: string) {
const dashboard = await getCaregiverDashboard(caregiverId);
@@ -36,6 +91,11 @@ export async function getCaregiverOverview(caregiverId: string) {
where: {
direction: MessageDirection.FAMILY_TO_ELDER,
caregiverId,
elderDevice: {
bindings: {
some: { caregiverId },
},
},
},
include: {
elderDevice: {
@@ -47,6 +107,15 @@ export async function getCaregiverOverview(caregiverId: string) {
}),
]);
const deviceNameMap = new Map(
dashboard.devices
.map((device) => [device.elderDeviceId, device.elderDevice.displayName?.trim() || ""] as const)
.filter((entry) => entry[1].length > 0),
);
applyCaregiverDeviceNames(recentIncomingMessages, deviceNameMap);
applyCaregiverDeviceNames(recentOutgoingMessages, deviceNameMap);
return {
dashboard,
recentIncomingMessages,
@@ -87,11 +156,23 @@ export async function getCaregiverMessages(caregiverId: string) {
where: {
direction: MessageDirection.FAMILY_TO_ELDER,
caregiverId,
elderDevice: {
bindings: {
some: { caregiverId },
},
},
readAt: null,
},
}),
]);
const deviceNameMap = await loadCaregiverDeviceNameMap(
caregiverId,
[...new Set(messages.map((message) => message.elderDeviceId))],
);
applyCaregiverDeviceNames(messages, deviceNameMap);
return {
messages,
unreadIncomingCount,
@@ -140,6 +221,9 @@ export async function getCaregiverMessageDetail(
});
}
const deviceNameMap = await loadCaregiverDeviceNameMap(caregiverId, [message.elderDeviceId]);
applyCaregiverDeviceNames([message], deviceNameMap);
return message;
}
@@ -215,6 +299,11 @@ export async function getCaregiverDeviceDetail(
return {
...binding,
elderDevice: {
...binding.elderDevice,
displayName:
binding.alias?.trim() || binding.elderDevice.displayName?.trim() || null,
},
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
familyUnreadCount,
elderUnreadCount,
@@ -278,6 +367,12 @@ export async function getCaregiverActivity(caregiverId: string) {
}),
]);
const deviceNameMap = await loadCaregiverDeviceNameMap(caregiverId, elderDeviceIds);
applyCaregiverDeviceNames(usageEvents, deviceNameMap);
applyCaregiverDeviceNames(conversationTurns, deviceNameMap);
applyCaregiverDeviceNames(toolCalls, deviceNameMap);
return {
usageEvents,
conversationTurns,
+99 -1
View File
@@ -46,6 +46,7 @@ type ToolCallInput = {
const PUBLIC_BASE_URL = (
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
).replace(/\/+$/, "");
const DEVICE_ALIAS_MAX_LENGTH = 24;
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;
@@ -90,6 +91,24 @@ export function extractDeviceUuid(rawValue: string) {
return null;
}
function normalizeDeviceAlias(rawAlias?: string | null) {
const alias = rawAlias?.trim() || null;
if (!alias) {
return null;
}
if (alias.length > DEVICE_ALIAS_MAX_LENGTH) {
throw new Error(`设备别名请控制在 ${DEVICE_ALIAS_MAX_LENGTH} 个字以内。`);
}
return alias;
}
function resolveBindingDisplayName(alias?: string | null, displayName?: string | null) {
return alias?.trim() || displayName?.trim() || null;
}
export async function ensureDeviceRegistration(input: RegisterDeviceInput) {
const deviceUuid = normalizeDeviceUuid(input.deviceUuid);
@@ -177,6 +196,7 @@ async function migrateLegacyCaregiverDataForDevice(
},
select: {
elderDeviceId: true,
alias: true,
},
});
@@ -189,10 +209,11 @@ async function migrateLegacyCaregiverDataForDevice(
elderDeviceId: binding.elderDeviceId,
},
},
update: {},
update: binding.alias ? { alias: binding.alias } : {},
create: {
caregiverId,
elderDeviceId: binding.elderDeviceId,
alias: binding.alias,
},
}),
),
@@ -393,6 +414,76 @@ export async function createFamilyMessageFromCaregiver(input: {
});
}
async function findCaregiverDeviceBinding(
caregiverId: string,
rawDeviceValue: string,
) {
const deviceUuid = extractDeviceUuid(rawDeviceValue);
if (!deviceUuid) {
throw new Error("设备码格式不正确,请重新扫码或粘贴。");
}
const binding = await prisma.deviceBinding.findFirst({
where: {
caregiverId,
elderDevice: {
deviceUuid,
},
},
include: {
elderDevice: true,
},
});
if (!binding) {
throw new Error("当前家属账号还没有绑定这台设备。");
}
return binding;
}
export async function updateCaregiverDeviceAlias(input: {
caregiverId: string;
rawDeviceValue: string;
alias?: string | null;
}) {
const binding = await findCaregiverDeviceBinding(
input.caregiverId,
input.rawDeviceValue,
);
const alias = normalizeDeviceAlias(input.alias);
return prisma.deviceBinding.update({
where: {
id: binding.id,
},
data: {
alias,
},
include: {
elderDevice: true,
},
});
}
export async function unbindDeviceFromCaregiver(
caregiverId: string,
rawDeviceValue: string,
) {
const binding = await findCaregiverDeviceBinding(caregiverId, rawDeviceValue);
await prisma.deviceBinding.delete({
where: {
id: binding.id,
},
});
return {
deviceUuid: binding.elderDevice.deviceUuid,
};
}
export async function recordUsageEvent(input: UsageEventInput) {
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
@@ -591,6 +682,13 @@ export async function getCaregiverDashboard(caregiverId: string) {
caregiver,
devices: caregiver.bindings.map((binding) => ({
...binding,
elderDevice: {
...binding.elderDevice,
displayName: resolveBindingDisplayName(
binding.alias,
binding.elderDevice.displayName,
),
},
familyUnreadCount: familyUnreadMap.get(binding.elderDeviceId) || 0,
elderUnreadCount: elderUnreadMap.get(binding.elderDeviceId) || 0,
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
+5 -2
View File
@@ -109,6 +109,9 @@ export function getMessageHref(publicId: number) {
return `/messages/${publicId}`;
}
export function getDeviceName(displayName?: string | null) {
return displayName?.trim() || "长辈的设备";
export function getDeviceName(
primaryName?: string | null,
fallbackName?: string | null,
) {
return primaryName?.trim() || fallbackName?.trim() || "长辈的设备";
}
+124 -67
View File
@@ -39,6 +39,11 @@ type PushDeliveryResult = PushDeliverySummary & {
failureDetails?: PushFailureDetail[];
};
type PushAttemptResult = {
outcome: "sent" | "removed" | "failed";
detail?: PushFailureDetail;
};
const APP_BASE_URL = (
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
).replace(/\/+$/, "");
@@ -191,6 +196,66 @@ function getPushFailureDetail(subscription: StoredPushSubscription, error: unkno
} satisfies PushFailureDetail;
}
async function sendPushNotification(input: {
subscription: StoredPushSubscription;
payload: string;
topic?: string;
collectFailureDetails?: boolean;
}) {
const requestOptions: {
TTL: number;
urgency: "high";
topic?: string;
} = {
TTL: 60 * 30,
urgency: "high",
};
if (input.topic && !shouldOmitTopic(input.subscription.endpoint)) {
requestOptions.topic = input.topic;
}
try {
await webpush.sendNotification(
{
endpoint: input.subscription.endpoint,
expirationTime: input.subscription.expirationTime?.getTime() || null,
keys: {
p256dh: input.subscription.p256dh,
auth: input.subscription.auth,
},
},
input.payload,
requestOptions,
);
return {
outcome: "sent",
} satisfies PushAttemptResult;
} catch (error) {
const detail = getPushFailureDetail(input.subscription, error);
const statusCode = detail.statusCode || 0;
console.error("Push notification delivery failed", detail);
if (statusCode === 404 || statusCode === 410) {
await prisma.pushSubscription.deleteMany({
where: { endpoint: input.subscription.endpoint },
});
return {
outcome: "removed",
detail: input.collectFailureDetails ? detail : undefined,
} satisfies PushAttemptResult;
}
return {
outcome: "failed",
detail: input.collectFailureDetails ? detail : undefined,
} satisfies PushAttemptResult;
}
}
async function deliverPushToSubscriptions(input: {
subscriptions: StoredPushSubscription[];
payload: string;
@@ -209,66 +274,26 @@ async function deliverPushToSubscriptions(input: {
ensureVapidDetails();
const failureDetails: PushFailureDetail[] = [];
const results = await Promise.all(
input.subscriptions.map(async (subscription) => {
try {
const requestOptions: {
TTL: number;
urgency: "high";
topic?: string;
} = {
TTL: 60 * 30,
urgency: "high",
};
if (input.topic && !shouldOmitTopic(subscription.endpoint)) {
requestOptions.topic = input.topic;
}
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime?.getTime() || null,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
input.payload,
requestOptions,
);
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 sendPushNotification({
subscription,
payload: input.payload,
topic: input.topic,
collectFailureDetails: input.collectFailureDetails,
});
}),
);
const failureDetails = results
.map((result) => result.detail)
.filter((detail): detail is PushFailureDetail => Boolean(detail));
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,
sentCount: results.filter((result) => result.outcome === "sent").length,
removedCount: results.filter((result) => result.outcome === "removed").length,
failedCount: results.filter((result) => result.outcome === "failed").length,
failureDetails,
} satisfies PushDeliveryResult;
}
@@ -308,6 +333,25 @@ export async function sendIncomingMessagePush(publicId: number) {
},
},
},
select: {
endpoint: true,
expirationTime: true,
p256dh: true,
auth: true,
caregiver: {
select: {
bindings: {
where: {
elderDeviceId: message.elderDeviceId,
},
select: {
alias: true,
},
take: 1,
},
},
},
},
});
if (subscriptions.length === 0) {
@@ -316,21 +360,34 @@ export async function sendIncomingMessagePush(publicId: number) {
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.all(
subscriptions.map(async (subscription) => {
const deviceName = getDeviceName(
subscription.caregiver.bindings[0]?.alias,
message.elderDevice.displayName,
);
await deliverPushToSubscriptions({
subscriptions,
payload,
topic: `msg-${message.publicId}`,
});
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 sendPushNotification({
subscription: {
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime,
p256dh: subscription.p256dh,
auth: subscription.auth,
},
payload,
topic: `msg-${message.publicId}`,
});
}),
);
}
export async function sendCaregiverTestPush(input: {