构建了从照片到药品档案的自动化处理流程,通过GLM多模态大模型实现药品信息的智能采集: 核心能力: - 创建任务追踪表 t_medication_recognition_tasks 存储识别任务状态 - 四阶段渐进式分析:基础识别→人群适配→成分解析→风险评估 - 提供三个REST端点支持任务创建、进度查询和结果确认 - 前端可通过轮询方式获取0-100%的实时进度反馈 - VIP用户免费使用,普通用户按次扣费 技术实现: - 利用GLM-4V-Plus模型处理多角度药品图像(正面+侧面+说明书) - 采用GLM-4-Flash模型进行文本深度分析 - 异步任务执行机制避免接口阻塞 - 完整的异常处理和任务失败恢复策略 - 新增AI_RECOGNITION.md文档详细说明集成方式 同步修复: - 修正会员用户AI配额扣减逻辑,避免不必要的次数消耗 - 优化APNs推送中无效设备令牌的检测和清理流程 - 将服药提醒的提前通知时间从15分钟缩短为5分钟
411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { Cron } from '@nestjs/schedule';
|
||
import { InjectModel } from '@nestjs/sequelize';
|
||
import { Medication } from '../models/medication.model';
|
||
import { MedicationRecord } from '../models/medication-record.model';
|
||
import { MedicationStatusEnum } from '../enums/medication-status.enum';
|
||
import { PushNotificationsService } from '../../push-notifications/push-notifications.service';
|
||
import { Op } from 'sequelize';
|
||
import * as dayjs from 'dayjs';
|
||
|
||
/**
|
||
* 药物提醒推送服务
|
||
* 在服药时间前15分钟发送推送提醒,并在超过服药时间1小时后发送鼓励提醒
|
||
*/
|
||
@Injectable()
|
||
export class MedicationReminderService {
|
||
private readonly logger = new Logger(MedicationReminderService.name);
|
||
private readonly REMINDER_MINUTES_BEFORE = 5; // 提前5分钟提醒
|
||
private readonly OVERDUE_HOURS_THRESHOLD = 1; // 超过1小时后发送超时提醒
|
||
|
||
constructor(
|
||
@InjectModel(Medication)
|
||
private readonly medicationModel: typeof Medication,
|
||
@InjectModel(MedicationRecord)
|
||
private readonly recordModel: typeof MedicationRecord,
|
||
private readonly pushService: PushNotificationsService,
|
||
private readonly configService: ConfigService,
|
||
) {}
|
||
|
||
/**
|
||
* 每5分钟检查一次需要发送的提前提醒
|
||
* 只有主进程(NODE_APP_INSTANCE=0)执行,避免多进程重复发送
|
||
*/
|
||
@Cron('*/5 * * * *')
|
||
async checkAndSendReminders(): Promise<void> {
|
||
this.logger.log('开始检查服药提醒');
|
||
|
||
try {
|
||
// 检查是否为主进程(NODE_APP_INSTANCE 为 0)
|
||
const nodeAppInstance = this.configService.get<number>('NODE_APP_INSTANCE', 0);
|
||
if (Number(nodeAppInstance) !== 0) {
|
||
this.logger.debug(`不是主进程 (instance: ${nodeAppInstance}),跳过服药提醒检查`);
|
||
return;
|
||
}
|
||
|
||
this.logger.log('主进程检测到,执行服药提醒检查...');
|
||
|
||
// 计算时间范围:当前时间 + 15分钟
|
||
const now = new Date();
|
||
|
||
|
||
// 查找在接下来1分钟内需要提醒的记录
|
||
const startRange = now;
|
||
const endRange = dayjs(now).add(1, 'minute').toDate(); // 1分钟窗口期
|
||
|
||
const upcomingRecords = await this.recordModel.findAll({
|
||
where: {
|
||
status: MedicationStatusEnum.UPCOMING,
|
||
deleted: false,
|
||
reminderSent: false, // 只查询未发送提醒的记录
|
||
scheduledTime: {
|
||
[Op.between]: [
|
||
dayjs(startRange).add(this.REMINDER_MINUTES_BEFORE, 'minute').toDate(),
|
||
dayjs(endRange).add(this.REMINDER_MINUTES_BEFORE, 'minute').toDate(),
|
||
],
|
||
},
|
||
},
|
||
include: [
|
||
{
|
||
model: Medication,
|
||
as: 'medication',
|
||
where: {
|
||
isActive: true,
|
||
deleted: false,
|
||
},
|
||
},
|
||
],
|
||
});
|
||
|
||
if (upcomingRecords.length === 0) {
|
||
this.logger.debug('没有需要发送的服药提醒');
|
||
return;
|
||
}
|
||
|
||
this.logger.log(`找到 ${upcomingRecords.length} 条需要发送提醒的记录`);
|
||
|
||
// 按用户分组发送提醒
|
||
const userRecordsMap = new Map<string, MedicationRecord[]>();
|
||
for (const record of upcomingRecords) {
|
||
const userId = record.userId;
|
||
if (!userRecordsMap.has(userId)) {
|
||
userRecordsMap.set(userId, []);
|
||
}
|
||
userRecordsMap.get(userId)!.push(record);
|
||
}
|
||
|
||
// 为每个用户发送提醒
|
||
let successCount = 0;
|
||
let failedCount = 0;
|
||
|
||
for (const [userId, records] of userRecordsMap.entries()) {
|
||
const success = await this.sendReminderToUser(userId, records);
|
||
if (success) {
|
||
successCount += records.length;
|
||
// 标记这些记录已发送提醒
|
||
await this.markRecordsAsReminded(records.map(r => r.id));
|
||
} else {
|
||
failedCount += records.length;
|
||
}
|
||
}
|
||
|
||
this.logger.log(`服药提醒发送完成 - 成功: ${successCount}, 失败: ${failedCount}`);
|
||
} catch (error) {
|
||
this.logger.error('检查服药提醒失败', error.stack);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 每10分钟检查一次是否有超过服药时间1小时的未服用记录
|
||
* 只有主进程(NODE_APP_INSTANCE=0)执行,避免多进程重复发送
|
||
*/
|
||
@Cron('*/10 * * * *')
|
||
async checkAndSendOverdueReminders(): Promise<void> {
|
||
this.logger.log('开始检查超时服药提醒');
|
||
|
||
try {
|
||
// 检查是否为主进程(NODE_APP_INSTANCE 为 0)
|
||
const nodeAppInstance = this.configService.get<number>('NODE_APP_INSTANCE', 0);
|
||
if (Number(nodeAppInstance) !== 0) {
|
||
this.logger.debug(`不是主进程 (instance: ${nodeAppInstance}),跳过超时服药提醒检查`);
|
||
return;
|
||
}
|
||
|
||
this.logger.log('主进程检测到,执行超时服药提醒检查...');
|
||
|
||
// 计算时间范围:当前时间减去1小时
|
||
const now = new Date();
|
||
const overdueThreshold = dayjs(now)
|
||
.subtract(this.OVERDUE_HOURS_THRESHOLD, 'hour')
|
||
.toDate();
|
||
|
||
// 查找超过计划服用时间1小时但状态仍为UPCOMING的记录
|
||
const overdueRecords = await this.recordModel.findAll({
|
||
where: {
|
||
status: MedicationStatusEnum.UPCOMING,
|
||
deleted: false,
|
||
overdueReminderSent: false, // 只查询未发送超时提醒的记录
|
||
scheduledTime: {
|
||
[Op.lt]: overdueThreshold, // 计划时间早于1小时前
|
||
},
|
||
},
|
||
include: [
|
||
{
|
||
model: Medication,
|
||
as: 'medication',
|
||
where: {
|
||
isActive: true,
|
||
deleted: false,
|
||
},
|
||
},
|
||
],
|
||
});
|
||
|
||
if (overdueRecords.length === 0) {
|
||
this.logger.debug('没有需要发送的超时服药提醒');
|
||
return;
|
||
}
|
||
|
||
this.logger.log(`找到 ${overdueRecords.length} 条需要发送超时提醒的记录`);
|
||
|
||
// 按用户分组发送提醒
|
||
const userRecordsMap = new Map<string, MedicationRecord[]>();
|
||
for (const record of overdueRecords) {
|
||
const userId = record.userId;
|
||
if (!userRecordsMap.has(userId)) {
|
||
userRecordsMap.set(userId, []);
|
||
}
|
||
userRecordsMap.get(userId)!.push(record);
|
||
}
|
||
|
||
// 为每个用户发送超时提醒
|
||
let successCount = 0;
|
||
let failedCount = 0;
|
||
|
||
for (const [userId, records] of userRecordsMap.entries()) {
|
||
const success = await this.sendOverdueReminderToUser(userId, records);
|
||
if (success) {
|
||
successCount += records.length;
|
||
// 标记这些记录已发送超时提醒
|
||
await this.markRecordsAsOverdueReminded(records.map(r => r.id));
|
||
} else {
|
||
failedCount += records.length;
|
||
}
|
||
}
|
||
|
||
this.logger.log(`超时服药提醒发送完成 - 成功: ${successCount}, 失败: ${failedCount}`);
|
||
} catch (error) {
|
||
this.logger.error('检查超时服药提醒失败', error.stack);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 为单个用户发送提醒
|
||
* @returns 是否发送成功
|
||
*/
|
||
private async sendReminderToUser(
|
||
userId: string,
|
||
records: MedicationRecord[],
|
||
): Promise<boolean> {
|
||
try {
|
||
const medicationNames = records
|
||
.map((r) => r.medication?.name)
|
||
.filter(Boolean)
|
||
.join('、');
|
||
|
||
const title = '服药提醒';
|
||
const body =
|
||
records.length === 1
|
||
? `该服用 ${medicationNames} 了`
|
||
: `该服用 ${records.length} 种药物了:${medicationNames}`;
|
||
|
||
await this.pushService.sendNotification({
|
||
userIds: [userId],
|
||
title,
|
||
body,
|
||
payload: {
|
||
type: 'medication_reminder',
|
||
recordIds: records.map((r) => r.id),
|
||
medicationIds: records.map((r) => r.medicationId),
|
||
},
|
||
sound: 'default',
|
||
badge: 1,
|
||
});
|
||
|
||
this.logger.log(`成功向用户 ${userId} 发送服药提醒`);
|
||
return true;
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`向用户 ${userId} 发送服药提醒失败`,
|
||
error.stack,
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 为单个用户发送超时鼓励提醒
|
||
* @returns 是否发送成功
|
||
*/
|
||
private async sendOverdueReminderToUser(
|
||
userId: string,
|
||
records: MedicationRecord[],
|
||
): Promise<boolean> {
|
||
try {
|
||
const medicationNames = records
|
||
.map((r) => r.medication?.name)
|
||
.filter(Boolean)
|
||
.join('、');
|
||
|
||
// 计算超时时间
|
||
const overdueHours = Math.max(...records.map(r =>
|
||
Math.floor(dayjs().diff(dayjs(r.scheduledTime), 'hour'))
|
||
));
|
||
|
||
const title = '服药超时提醒';
|
||
const body =
|
||
records.length === 1
|
||
? `您已经错过了 ${medicationNames} 的服用时间超过 ${overdueHours} 小时,请尽快服用!坚持按时服药有助于您的健康恢复。`
|
||
: `您已经错过了 ${records.length} 种药物的服用时间,请尽快服用!坚持按时服药有助于您的健康恢复。`;
|
||
|
||
await this.pushService.sendNotification({
|
||
userIds: [userId],
|
||
title,
|
||
body,
|
||
payload: {
|
||
type: 'medication_overdue_reminder',
|
||
recordIds: records.map((r) => r.id),
|
||
medicationIds: records.map((r) => r.medicationId),
|
||
},
|
||
sound: 'default',
|
||
badge: 1,
|
||
});
|
||
|
||
this.logger.log(`成功向用户 ${userId} 发送超时服药提醒`);
|
||
return true;
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`向用户 ${userId} 发送超时服药提醒失败`,
|
||
error.stack,
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 标记记录为已发送提醒
|
||
*/
|
||
private async markRecordsAsReminded(recordIds: string[]): Promise<void> {
|
||
try {
|
||
await this.recordModel.update(
|
||
{ reminderSent: true },
|
||
{
|
||
where: {
|
||
id: {
|
||
[Op.in]: recordIds,
|
||
},
|
||
},
|
||
},
|
||
);
|
||
this.logger.debug(`已标记 ${recordIds.length} 条记录为已提醒`);
|
||
} catch (error) {
|
||
this.logger.error('标记记录为已提醒失败', error.stack);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 标记记录为已发送超时提醒
|
||
*/
|
||
private async markRecordsAsOverdueReminded(recordIds: string[]): Promise<void> {
|
||
try {
|
||
await this.recordModel.update(
|
||
{ overdueReminderSent: true },
|
||
{
|
||
where: {
|
||
id: {
|
||
[Op.in]: recordIds,
|
||
},
|
||
},
|
||
},
|
||
);
|
||
this.logger.debug(`已标记 ${recordIds.length} 条记录为已超时提醒`);
|
||
} catch (error) {
|
||
this.logger.error('标记记录为已超时提醒失败', error.stack);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 手动为用户发送即时提醒(用于测试或特殊情况)
|
||
*/
|
||
async sendImmediateReminder(userId: string, recordId: string): Promise<void> {
|
||
const record = await this.recordModel.findOne({
|
||
where: {
|
||
id: recordId,
|
||
userId,
|
||
deleted: false,
|
||
},
|
||
include: [
|
||
{
|
||
model: Medication,
|
||
as: 'medication',
|
||
},
|
||
],
|
||
});
|
||
|
||
if (!record || !record.medication) {
|
||
throw new Error('服药记录不存在');
|
||
}
|
||
|
||
await this.sendReminderToUser(userId, [record]);
|
||
}
|
||
|
||
/**
|
||
* 为新创建的药物设置提醒(预留方法,实际提醒由定时任务触发)
|
||
*/
|
||
async setupRemindersForMedication(medication: Medication): Promise<void> {
|
||
this.logger.log(`为药物 ${medication.id} 设置提醒(由定时任务自动触发)`);
|
||
// 实际的提醒由定时任务 checkAndSendReminders 自动处理
|
||
// 这里只需要确保药物处于激活状态
|
||
}
|
||
|
||
/**
|
||
* 取消药物的所有提醒(停用或删除药物时调用)
|
||
*/
|
||
async cancelRemindersForMedication(medicationId: string): Promise<void> {
|
||
this.logger.log(`取消药物 ${medicationId} 的所有提醒`);
|
||
// 由于提醒是基于记录的状态和药物的激活状态动态生成的
|
||
// 所以只需要确保药物被停用或删除,定时任务就不会再发送提醒
|
||
}
|
||
|
||
/**
|
||
* 获取用户今天的待提醒数量
|
||
*/
|
||
async getTodayReminderCount(userId: string): Promise<number> {
|
||
const startOfDay = dayjs().startOf('day').toDate();
|
||
const endOfDay = dayjs().endOf('day').toDate();
|
||
|
||
const count = await this.recordModel.count({
|
||
where: {
|
||
userId,
|
||
status: MedicationStatusEnum.UPCOMING,
|
||
deleted: false,
|
||
scheduledTime: {
|
||
[Op.between]: [startOfDay, endOfDay],
|
||
},
|
||
},
|
||
include: [
|
||
{
|
||
model: Medication,
|
||
as: 'medication',
|
||
where: {
|
||
isActive: true,
|
||
deleted: false,
|
||
},
|
||
},
|
||
],
|
||
});
|
||
|
||
return count;
|
||
}
|
||
} |