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分钟
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ApnsClient, SilentNotification, Notification, Errors } from 'apns2';
|
||||
import * as fs from 'fs';
|
||||
import { ApnsConfig, ApnsNotificationOptions } from './interfaces/apns-config.interface';
|
||||
import { PushTokenService } from './push-token.service';
|
||||
|
||||
interface SendResult {
|
||||
sent: string[];
|
||||
@@ -20,7 +21,11 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
|
||||
private client: ApnsClient;
|
||||
private config: ApnsConfig;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(forwardRef(() => PushTokenService))
|
||||
private readonly pushTokenService: PushTokenService,
|
||||
) {
|
||||
this.config = this.buildConfig();
|
||||
}
|
||||
|
||||
@@ -97,27 +102,45 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* 设置错误处理器
|
||||
* 自动停用无效的设备令牌
|
||||
*/
|
||||
private setupErrorHandlers(): void {
|
||||
// 监听特定错误
|
||||
this.client.on(Errors.badDeviceToken, (err) => {
|
||||
this.logger.error(`Bad device token: ${err}`, err.reason);
|
||||
// 监听无效设备令牌错误 - 需要停用
|
||||
this.client.on(Errors.badDeviceToken, async (err) => {
|
||||
this.logger.error(`Bad device token detected: ${err.deviceToken}`, err.reason);
|
||||
await this.deactivateInvalidToken(err.deviceToken, 'BadDeviceToken');
|
||||
});
|
||||
|
||||
this.client.on(Errors.unregistered, (err) => {
|
||||
// 监听设备注销错误 - 用户已卸载应用,需要停用
|
||||
this.client.on(Errors.unregistered, async (err) => {
|
||||
this.logger.error(`Device unregistered: ${err.deviceToken}`, err.reason);
|
||||
await this.deactivateInvalidToken(err.deviceToken, 'Unregistered');
|
||||
});
|
||||
|
||||
this.client.on(Errors.topicDisallowed, (err) => {
|
||||
this.logger.error(`Topic disallowed: ${err.deviceToken}`, err.reason);
|
||||
// 监听 topic 不匹配错误 - bundle ID 配置错误,需要停用
|
||||
this.client.on(Errors.topicDisallowed, async (err) => {
|
||||
this.logger.error(`Topic disallowed for device: ${err.deviceToken}`, err.reason);
|
||||
await this.deactivateInvalidToken(err.deviceToken, 'TopicDisallowed');
|
||||
});
|
||||
|
||||
// 监听所有错误
|
||||
// 监听所有其他错误
|
||||
this.client.on(Errors.error, (err) => {
|
||||
this.logger.error(`APNs error for device ${err.deviceToken}: ${err.reason}`, err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用无效的设备令牌
|
||||
*/
|
||||
private async deactivateInvalidToken(deviceToken: string, reason: string): Promise<void> {
|
||||
try {
|
||||
this.logger.warn(`Deactivating invalid token due to ${reason}: ${deviceToken}`);
|
||||
await this.pushTokenService.deactivateToken(deviceToken);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to deactivate token ${deviceToken}: ${error.message}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送单个通知
|
||||
*/
|
||||
|
||||
@@ -110,7 +110,8 @@ export class PushNotificationsService {
|
||||
sentCount++;
|
||||
} else {
|
||||
const failure = apnsResults.failed[0];
|
||||
const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`;
|
||||
const error = failure.error as any;
|
||||
const errorMessage = error?.message || `APNs Error: ${failure.status || 'Unknown'}`;
|
||||
|
||||
await this.pushMessageService.updateMessageStatus(
|
||||
message.id,
|
||||
@@ -119,9 +120,12 @@ export class PushNotificationsService {
|
||||
errorMessage
|
||||
);
|
||||
|
||||
// 如果是无效令牌,停用该令牌
|
||||
if (failure.status === '410' || failure.response?.reason === 'Unregistered') {
|
||||
await this.pushTokenService.unregisterToken(userId, deviceToken);
|
||||
// 检查是否是无效令牌错误 - 需要停用 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({
|
||||
@@ -173,6 +177,40 @@ export class PushNotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该停用 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用模板发送推送通知
|
||||
*/
|
||||
@@ -311,7 +349,8 @@ export class PushNotificationsService {
|
||||
} else {
|
||||
// 发送失败
|
||||
const failure = apnsResult as any;
|
||||
const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`;
|
||||
const error = failure.error as any;
|
||||
const errorMessage = error?.message || `APNs Error: ${failure.status || 'Unknown'}`;
|
||||
|
||||
await this.pushMessageService.updateMessageStatus(
|
||||
message.id,
|
||||
@@ -320,9 +359,12 @@ export class PushNotificationsService {
|
||||
errorMessage
|
||||
);
|
||||
|
||||
// 如果是无效令牌,停用该令牌
|
||||
if (failure.status === '410' || failure.response?.reason === 'Unregistered') {
|
||||
await this.pushTokenService.unregisterToken(userId, deviceToken);
|
||||
// 检查是否是无效令牌错误 - 需要停用 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({
|
||||
@@ -597,7 +639,8 @@ export class PushNotificationsService {
|
||||
sentCount++;
|
||||
} else {
|
||||
const failure = apnsResults.failed[0];
|
||||
const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`;
|
||||
const error = failure.error as any;
|
||||
const errorMessage = error?.message || `APNs Error: ${failure.status || 'Unknown'}`;
|
||||
|
||||
await this.pushMessageService.updateMessageStatus(
|
||||
message.id,
|
||||
@@ -606,14 +649,12 @@ export class PushNotificationsService {
|
||||
errorMessage
|
||||
);
|
||||
|
||||
// 如果是无效令牌,停用该令牌
|
||||
if (failure.status === '410' || failure.response?.reason === 'Unregistered') {
|
||||
if (userId) {
|
||||
await this.pushTokenService.unregisterToken(userId, deviceToken);
|
||||
} else {
|
||||
// 如果没有用户ID,直接停用令牌
|
||||
await this.pushTokenService.deactivateToken(deviceToken);
|
||||
}
|
||||
// 检查是否是无效令牌错误 - 需要停用 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({
|
||||
@@ -738,7 +779,8 @@ export class PushNotificationsService {
|
||||
} else {
|
||||
// 发送失败
|
||||
const failure = apnsResult as any;
|
||||
const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`;
|
||||
const error = failure.error as any;
|
||||
const errorMessage = error?.message || `APNs Error: ${failure.status || 'Unknown'}`;
|
||||
|
||||
await this.pushMessageService.updateMessageStatus(
|
||||
message.id,
|
||||
@@ -747,14 +789,12 @@ export class PushNotificationsService {
|
||||
errorMessage
|
||||
);
|
||||
|
||||
// 如果是无效令牌,停用该令牌
|
||||
if (failure.status === '410' || failure.response?.reason === 'Unregistered') {
|
||||
if (userId) {
|
||||
await this.pushTokenService.unregisterToken(userId, deviceToken);
|
||||
} else {
|
||||
// 如果没有用户ID,直接停用令牌
|
||||
await this.pushTokenService.deactivateToken(deviceToken);
|
||||
}
|
||||
// 检查是否是无效令牌错误 - 需要停用 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({
|
||||
|
||||
Reference in New Issue
Block a user