新增设备别名管理功能,支持设备别名的保存与解绑
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user