Files
plates-server/src/push-notifications/push-notifications.service.ts
richarjiang a17fe0b965 feat(medications): 增加基于视觉AI的药品智能录入系统
构建了从照片到药品档案的自动化处理流程,通过GLM多模态大模型实现药品信息的智能采集:

核心能力:
- 创建任务追踪表 t_medication_recognition_tasks 存储识别任务状态
- 四阶段渐进式分析:基础识别→人群适配→成分解析→风险评估
- 提供三个REST端点支持任务创建、进度查询和结果确认
- 前端可通过轮询方式获取0-100%的实时进度反馈
- VIP用户免费使用,普通用户按次扣费

技术实现:
- 利用GLM-4V-Plus模型处理多角度药品图像(正面+侧面+说明书)
- 采用GLM-4-Flash模型进行文本深度分析
- 异步任务执行机制避免接口阻塞
- 完整的异常处理和任务失败恢复策略
- 新增AI_RECOGNITION.md文档详细说明集成方式

同步修复:
- 修正会员用户AI配额扣减逻辑,避免不必要的次数消耗
- 优化APNs推送中无效设备令牌的检测和清理流程
- 将服药提醒的提前通知时间从15分钟缩短为5分钟
2025-11-21 10:27:59 +08:00

862 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApnsProvider } from './apns.provider';
import { PushTokenService } from './push-token.service';
import { PushTemplateService } from './push-template.service';
import { PushMessageService, CreatePushMessageDto } from './push-message.service';
import { SendPushNotificationDto } from './dto/send-push-notification.dto';
import { SendPushByTemplateDto } from './dto/send-push-by-template.dto';
import { SendPushToDevicesDto } from './dto/send-push-to-devices.dto';
import { PushResult, BatchPushResult } from './interfaces/push-notification.interface';
import { PushResponseDto, BatchPushResponseDto } from './dto/push-response.dto';
import { DevicePushResponseDto, BatchDevicePushResponseDto, DevicePushResult } from './dto/device-push-response.dto';
import { ResponseCode } from '../base.dto';
import { PushMessageStatus } from './enums/push-message-status.enum';
import { PushType } from 'apns2';
@Injectable()
export class PushNotificationsService {
private readonly logger = new Logger(PushNotificationsService.name);
private readonly bundleId: string;
constructor(
private readonly apnsProvider: ApnsProvider,
private readonly pushTokenService: PushTokenService,
private readonly pushTemplateService: PushTemplateService,
private readonly pushMessageService: PushMessageService,
private readonly configService: ConfigService,
) {
this.bundleId = this.configService.get<string>('APNS_BUNDLE_ID') || '';
}
/**
* 发送单个推送通知
*/
async sendNotification(notificationData: SendPushNotificationDto): Promise<PushResponseDto> {
try {
this.logger.log(`Sending push notification to ${notificationData.userIds.length} users`);
const results: PushResult[] = [];
let sentCount = 0;
let failedCount = 0;
// 获取所有用户的设备令牌
const userTokensMap = await this.pushTokenService.getDeviceTokensByUserIds(notificationData.userIds);
// 为每个用户创建消息记录并发送推送
for (const userId of notificationData.userIds) {
const deviceTokens = userTokensMap.get(userId) || [];
if (deviceTokens.length === 0) {
this.logger.warn(`No active device tokens found for user ${userId}`);
results.push({
userId,
deviceToken: '',
success: false,
error: 'No active device tokens found',
});
failedCount++;
continue;
}
// 为每个设备令牌创建消息记录
for (const deviceToken of deviceTokens) {
try {
// 创建消息记录
const messageData: CreatePushMessageDto = {
userId,
deviceToken,
messageType: 'manual',
title: notificationData.title,
body: notificationData.body,
payload: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry ? new Date(Date.now() + notificationData.expiry * 1000) : undefined,
collapseId: notificationData.collapseId,
};
const message = await this.pushMessageService.createMessage(messageData);
// 创建APNs通知
const apnsNotification = this.apnsProvider.createNotification({
title: notificationData.title,
body: notificationData.body,
data: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry,
collapseId: notificationData.collapseId,
topic: this.bundleId,
sound: notificationData.sound,
badge: notificationData.badge,
mutableContent: notificationData.mutableContent,
contentAvailable: notificationData.contentAvailable,
});
// 发送推送
const apnsResults = await this.apnsProvider.send(apnsNotification, [deviceToken]);
// 处理结果
if (apnsResults.sent.length > 0) {
await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResults);
await this.pushTokenService.updateLastUsedTime(deviceToken);
results.push({
userId,
deviceToken,
success: true,
apnsResponse: apnsResults,
});
sentCount++;
} else {
const failure = apnsResults.failed[0];
const error = failure.error as any;
const errorMessage = error?.message || `APNs Error: ${failure.status || 'Unknown'}`;
await this.pushMessageService.updateMessageStatus(
message.id,
PushMessageStatus.FAILED,
failure.response,
errorMessage
);
// 检查是否是无效令牌错误 - 需要停用 token
const shouldDeactivateToken = this.shouldDeactivateToken(error, failure.response);
if (shouldDeactivateToken) {
this.logger.warn(`Deactivating invalid token for user ${userId}: ${errorMessage}`);
await this.pushTokenService.deactivateToken(deviceToken);
}
results.push({
userId,
deviceToken,
success: false,
error: errorMessage,
apnsResponse: failure.response,
});
failedCount++;
}
} catch (error) {
this.logger.error(`Failed to send push to user ${userId}, device ${deviceToken}: ${error.message}`, error);
results.push({
userId,
deviceToken,
success: false,
error: error.message,
});
failedCount++;
}
}
}
const success = failedCount === 0;
return {
code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR,
message: success ? '推送发送成功' : '部分推送发送失败',
data: {
success,
sentCount,
failedCount,
results,
},
};
} catch (error) {
this.logger.error(`Failed to send push notification: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `推送发送失败: ${error.message}`,
data: {
success: false,
sentCount: 0,
failedCount: notificationData.userIds.length,
results: [],
},
};
}
}
/**
* 判断是否应该停用 token
* 根据 APNs 错误类型判断 token 是否已失效
*/
private shouldDeactivateToken(error: any, response: any): boolean {
if (!error && !response) {
return false;
}
// 检查错误对象的 reason 字段apns2 库的错误格式)
const reason = error?.reason || response?.reason || '';
// APNs 返回的需要停用 token 的错误原因
const invalidTokenReasons = [
'BadDeviceToken', // 无效的设备令牌格式
'Unregistered', // 设备已注销(用户卸载了应用)
'DeviceTokenNotForTopic', // token 与 bundle ID 不匹配
'ExpiredToken', // token 已过期
];
// 检查是否包含这些错误原因
if (invalidTokenReasons.some(r => reason.includes(r))) {
return true;
}
// 检查 HTTP 状态码 410 (Gone) - 表示设备令牌永久失效
const statusCode = error?.statusCode || response?.statusCode;
if (statusCode === 410 || statusCode === '410') {
return true;
}
return false;
}
/**
* 使用模板发送推送通知
*/
async sendNotificationByTemplate(templateData: SendPushByTemplateDto): Promise<PushResponseDto> {
try {
this.logger.log(`Sending push notification using template: ${templateData.templateKey}`);
// 渲染模板
const renderedTemplate = await this.pushTemplateService.renderTemplate(
templateData.templateKey,
templateData.data
);
// 构建推送数据
const notificationData: SendPushNotificationDto = {
userIds: templateData.userIds,
title: renderedTemplate.title,
body: renderedTemplate.body,
payload: { ...renderedTemplate.payload, ...templateData.payload },
pushType: renderedTemplate.pushType,
priority: renderedTemplate.priority,
collapseId: templateData.collapseId,
sound: templateData.sound,
badge: templateData.badge,
};
// 发送推送
return this.sendNotification(notificationData);
} catch (error) {
this.logger.error(`Failed to send push notification by template: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `模板推送发送失败: ${error.message}`,
data: {
success: false,
sentCount: 0,
failedCount: templateData.userIds.length,
results: [],
},
};
}
}
/**
* 批量发送推送通知
*/
async sendBatchNotifications(notificationData: SendPushNotificationDto): Promise<BatchPushResponseDto> {
try {
this.logger.log(`Sending batch push notification to ${notificationData.userIds.length} users`);
const results: PushResult[] = [];
let totalUsers = notificationData.userIds.length;
let totalTokens = 0;
let successCount = 0;
let failedCount = 0;
// 获取所有用户的设备令牌
const userTokensMap = await this.pushTokenService.getDeviceTokensByUserIds(notificationData.userIds);
// 统计总令牌数
for (const tokens of userTokensMap.values()) {
totalTokens += tokens.length;
}
// 创建APNs通知
const apnsNotification = this.apnsProvider.createNotification({
title: notificationData.title,
body: notificationData.body,
data: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry,
collapseId: notificationData.collapseId,
topic: this.bundleId,
sound: notificationData.sound,
badge: notificationData.badge,
mutableContent: notificationData.mutableContent,
contentAvailable: notificationData.contentAvailable,
});
// 批量发送推送
const allDeviceTokens = Array.from(userTokensMap.values()).flat();
if (allDeviceTokens.length === 0) {
return {
code: ResponseCode.ERROR,
message: '没有找到有效的设备令牌',
data: {
totalUsers,
totalTokens: 0,
successCount: 0,
failedCount: totalUsers,
results: [],
},
};
}
const apnsResults = await this.apnsProvider.send(apnsNotification, allDeviceTokens);
// 处理结果并创建消息记录
for (const [userId, deviceTokens] of userTokensMap.entries()) {
for (const deviceToken of deviceTokens) {
try {
// 创建消息记录
const messageData: CreatePushMessageDto = {
userId,
deviceToken,
messageType: 'batch',
title: notificationData.title,
body: notificationData.body,
payload: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry ? new Date(Date.now() + notificationData.expiry * 1000) : undefined,
collapseId: notificationData.collapseId,
};
const message = await this.pushMessageService.createMessage(messageData);
// 查找对应的APNs结果
const apnsResult = apnsResults.sent.includes(deviceToken) ?
{ device: deviceToken, success: true } :
apnsResults.failed.find(f => f.device === deviceToken);
if (apnsResult) {
if (apnsResult.device === deviceToken && 'success' in apnsResult && apnsResult.success) {
// 成功发送
await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResult);
await this.pushTokenService.updateLastUsedTime(deviceToken);
results.push({
userId,
deviceToken,
success: true,
apnsResponse: apnsResult,
});
successCount++;
} else {
// 发送失败
const failure = apnsResult as any;
const error = failure.error as any;
const errorMessage = error?.message || `APNs Error: ${failure.status || 'Unknown'}`;
await this.pushMessageService.updateMessageStatus(
message.id,
PushMessageStatus.FAILED,
failure.response,
errorMessage
);
// 检查是否是无效令牌错误 - 需要停用 token
const shouldDeactivateToken = this.shouldDeactivateToken(error, failure.response);
if (shouldDeactivateToken) {
this.logger.warn(`Deactivating invalid token for user ${userId}: ${errorMessage}`);
await this.pushTokenService.deactivateToken(deviceToken);
}
results.push({
userId,
deviceToken,
success: false,
error: errorMessage,
apnsResponse: failure.response,
});
failedCount++;
}
} else {
// 未找到结果,标记为失败
await this.pushMessageService.updateMessageStatus(
message.id,
PushMessageStatus.FAILED,
null,
'No APNs result found'
);
results.push({
userId,
deviceToken,
success: false,
error: 'No APNs result found',
});
failedCount++;
}
} catch (error) {
this.logger.error(`Failed to process batch push result for user ${userId}, device ${deviceToken}: ${error.message}`, error);
results.push({
userId,
deviceToken,
success: false,
error: error.message,
});
failedCount++;
}
}
}
const success = failedCount === 0;
return {
code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR,
message: success ? '批量推送发送成功' : '部分批量推送发送失败',
data: {
totalUsers,
totalTokens,
successCount,
failedCount,
results,
},
};
} catch (error) {
this.logger.error(`Failed to send batch push notification: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `批量推送发送失败: ${error.message}`,
data: {
totalUsers: notificationData.userIds.length,
totalTokens: 0,
successCount: 0,
failedCount: notificationData.userIds.length,
results: [],
},
};
}
}
/**
* 发送静默推送
*/
async sendSilentNotification(userId: string, payload: any): Promise<PushResponseDto> {
try {
this.logger.log(`Sending silent push notification to user ${userId}`);
const notificationData: SendPushNotificationDto = {
userIds: [userId],
title: '',
body: '',
payload,
pushType: PushType.background,
contentAvailable: true,
};
return this.sendNotification(notificationData);
} catch (error) {
this.logger.error(`Failed to send silent push notification: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `静默推送发送失败: ${error.message}`,
data: {
success: false,
sentCount: 0,
failedCount: 1,
results: [],
},
};
}
}
/**
* 注册设备令牌
*/
async registerToken(tokenData: any, userId?: string,): Promise<any> {
try {
const token = await this.pushTokenService.registerToken(tokenData, userId);
this.logger.log(`Registered device token for user ${userId}: ${token.id}`);
return {
code: ResponseCode.SUCCESS,
message: '设备令牌注册成功',
data: {
success: true,
tokenId: token.id,
},
};
} catch (error) {
this.logger.error(`Failed to register device token: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `设备令牌注册失败: ${error.message}`,
data: {
success: false,
tokenId: '',
},
};
}
}
/**
* 更新设备令牌
*/
async updateToken(userId: string, tokenData: any): Promise<any> {
try {
const token = await this.pushTokenService.updateToken(userId, tokenData);
return {
code: ResponseCode.SUCCESS,
message: '设备令牌更新成功',
data: {
success: true,
tokenId: token.id,
},
};
} catch (error) {
this.logger.error(`Failed to update device token: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `设备令牌更新失败: ${error.message}`,
data: {
success: false,
tokenId: '',
},
};
}
}
/**
* 注销设备令牌
*/
async unregisterToken(userId: string, deviceToken: string): Promise<any> {
try {
await this.pushTokenService.unregisterToken(userId, deviceToken);
return {
code: ResponseCode.SUCCESS,
message: '设备令牌注销成功',
data: {
success: true,
},
};
} catch (error) {
this.logger.error(`Failed to unregister device token: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `设备令牌注销失败: ${error.message}`,
data: {
success: false,
},
};
}
}
/**
* 更新令牌绑定的用户ID
*/
async updateTokenUserId(userId: string, deviceToken: string): Promise<any> {
try {
const token = await this.pushTokenService.updateTokenUserId(deviceToken, userId);
this.logger.log(`Updated user ID for device token: ${deviceToken}`);
return {
code: ResponseCode.SUCCESS,
message: '令牌用户ID更新成功',
data: {
success: true,
tokenId: token.id,
},
};
} catch (error) {
this.logger.error(`Failed to update user ID for device token: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `令牌用户ID更新失败: ${error.message}`,
data: {
success: false,
tokenId: '',
},
};
}
}
/**
* 基于设备令牌发送推送通知
*/
async sendNotificationToDevices(notificationData: SendPushToDevicesDto): Promise<DevicePushResponseDto> {
try {
this.logger.log(`Sending push notification to ${notificationData.deviceTokens.length} devices`);
const results: DevicePushResult[] = [];
let sentCount = 0;
let failedCount = 0;
// 为每个设备令牌创建消息记录并发送推送
for (const deviceToken of notificationData.deviceTokens) {
try {
// 尝试获取设备令牌对应的用户ID
const userId = await this.pushTokenService.getUserIdByDeviceToken(deviceToken);
// 创建消息记录
const messageData: CreatePushMessageDto = {
userId: userId || '',
deviceToken,
messageType: 'manual',
title: notificationData.title,
body: notificationData.body,
payload: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry ? new Date(Date.now() + notificationData.expiry * 1000) : undefined,
collapseId: notificationData.collapseId,
};
const message = await this.pushMessageService.createMessage(messageData);
// 创建APNs通知
const apnsNotification = this.apnsProvider.createNotification({
title: notificationData.title,
body: notificationData.body,
data: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry,
collapseId: notificationData.collapseId,
topic: this.bundleId,
sound: notificationData.sound,
badge: notificationData.badge,
mutableContent: notificationData.mutableContent,
contentAvailable: notificationData.contentAvailable,
});
// 发送推送
const apnsResults = await this.apnsProvider.send(apnsNotification, [deviceToken]);
// 处理结果
if (apnsResults.sent.length > 0) {
await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResults);
await this.pushTokenService.updateLastUsedTime(deviceToken);
results.push({
deviceToken,
userId: userId || undefined,
success: true,
apnsResponse: apnsResults,
});
sentCount++;
} else {
const failure = apnsResults.failed[0];
const error = failure.error as any;
const errorMessage = error?.message || `APNs Error: ${failure.status || 'Unknown'}`;
await this.pushMessageService.updateMessageStatus(
message.id,
PushMessageStatus.FAILED,
failure.response,
errorMessage
);
// 检查是否是无效令牌错误 - 需要停用 token
const shouldDeactivateToken = this.shouldDeactivateToken(error, failure.response);
if (shouldDeactivateToken) {
this.logger.warn(`Deactivating invalid token for device ${deviceToken}: ${errorMessage}`);
await this.pushTokenService.deactivateToken(deviceToken);
}
results.push({
deviceToken,
userId: userId || undefined,
success: false,
error: errorMessage,
apnsResponse: failure.response,
});
failedCount++;
}
} catch (error) {
this.logger.error(`Failed to send push to device ${deviceToken}: ${error.message}`, error);
results.push({
deviceToken,
success: false,
error: error.message,
});
failedCount++;
}
}
const success = failedCount === 0;
return {
code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR,
message: success ? '推送发送成功' : '部分推送发送失败',
data: {
success,
sentCount,
failedCount,
results,
},
};
} catch (error) {
this.logger.error(`Failed to send push notification to devices: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `推送发送失败: ${error.message}`,
data: {
success: false,
sentCount: 0,
failedCount: notificationData.deviceTokens.length,
results: [],
},
};
}
}
/**
* 批量基于设备令牌发送推送通知
*/
async sendBatchNotificationToDevices(notificationData: SendPushToDevicesDto): Promise<BatchDevicePushResponseDto> {
try {
this.logger.log(`Sending batch push notification to ${notificationData.deviceTokens.length} devices`);
const results: DevicePushResult[] = [];
let totalTokens = notificationData.deviceTokens.length;
let successCount = 0;
let failedCount = 0;
// 创建APNs通知
const apnsNotification = this.apnsProvider.createNotification({
title: notificationData.title,
body: notificationData.body,
data: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry,
collapseId: notificationData.collapseId,
topic: this.bundleId,
sound: notificationData.sound,
badge: notificationData.badge,
mutableContent: notificationData.mutableContent,
contentAvailable: notificationData.contentAvailable,
});
this.logger.log(`apnsNotification: ${JSON.stringify(apnsNotification, null, 2)}`);
// 批量发送推送
const apnsResults = await this.apnsProvider.send(apnsNotification, notificationData.deviceTokens);
// 处理结果并创建消息记录
for (const deviceToken of notificationData.deviceTokens) {
try {
// 尝试获取设备令牌对应的用户ID
const userId = await this.pushTokenService.getUserIdByDeviceToken(deviceToken);
// 创建消息记录
const messageData: CreatePushMessageDto = {
userId: userId || '',
deviceToken,
messageType: 'batch',
title: notificationData.title,
body: notificationData.body,
payload: notificationData.payload,
pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry ? new Date(Date.now() + notificationData.expiry * 1000) : undefined,
collapseId: notificationData.collapseId,
};
const message = await this.pushMessageService.createMessage(messageData);
// 查找对应的APNs结果
const apnsResult = apnsResults.sent.includes(deviceToken) ?
{ device: deviceToken, success: true } :
apnsResults.failed.find(f => f.device === deviceToken);
if (apnsResult) {
if (apnsResult.device === deviceToken && 'success' in apnsResult && apnsResult.success) {
// 成功发送
await this.pushMessageService.updateMessageStatus(message.id, PushMessageStatus.SENT, apnsResult);
await this.pushTokenService.updateLastUsedTime(deviceToken);
results.push({
deviceToken,
userId: userId || undefined,
success: true,
apnsResponse: apnsResult,
});
successCount++;
} else {
// 发送失败
const failure = apnsResult as any;
const error = failure.error as any;
const errorMessage = error?.message || `APNs Error: ${failure.status || 'Unknown'}`;
await this.pushMessageService.updateMessageStatus(
message.id,
PushMessageStatus.FAILED,
failure.response,
errorMessage
);
// 检查是否是无效令牌错误 - 需要停用 token
const shouldDeactivateToken = this.shouldDeactivateToken(error, failure.response);
if (shouldDeactivateToken) {
this.logger.warn(`Deactivating invalid token for device ${deviceToken}: ${errorMessage}`);
await this.pushTokenService.deactivateToken(deviceToken);
}
results.push({
deviceToken,
userId: userId || undefined,
success: false,
error: errorMessage,
apnsResponse: failure.response,
});
failedCount++;
}
} else {
// 未找到结果,标记为失败
await this.pushMessageService.updateMessageStatus(
message.id,
PushMessageStatus.FAILED,
null,
'No APNs result found'
);
results.push({
deviceToken,
userId: userId || undefined,
success: false,
error: 'No APNs result found',
});
failedCount++;
}
} catch (error) {
this.logger.error(`Failed to process batch push result for device ${deviceToken}: ${error.message}`, error);
results.push({
deviceToken,
success: false,
error: error.message,
});
failedCount++;
}
}
const success = failedCount === 0;
return {
code: success ? ResponseCode.SUCCESS : ResponseCode.ERROR,
message: success ? '批量推送发送成功' : '部分批量推送发送失败',
data: {
totalTokens,
successCount,
failedCount,
results,
},
};
} catch (error) {
this.logger.error(`Failed to send batch push notification to devices: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `批量推送发送失败: ${error.message}`,
data: {
totalTokens: notificationData.deviceTokens.length,
successCount: 0,
failedCount: notificationData.deviceTokens.length,
results: [],
},
};
}
}
}