Files
plates-server/src/medications/services/medication-reminder.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

411 lines
13 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 { 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;
}
}