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 { this.logger.log('开始检查服药提醒'); try { // 检查是否为主进程(NODE_APP_INSTANCE 为 0) const nodeAppInstance = this.configService.get('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(); 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 { this.logger.log('开始检查超时服药提醒'); try { // 检查是否为主进程(NODE_APP_INSTANCE 为 0) const nodeAppInstance = this.configService.get('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(); 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 { 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 { 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 { 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 { 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 { 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 { this.logger.log(`为药物 ${medication.id} 设置提醒(由定时任务自动触发)`); // 实际的提醒由定时任务 checkAndSendReminders 自动处理 // 这里只需要确保药物处于激活状态 } /** * 取消药物的所有提醒(停用或删除药物时调用) */ async cancelRemindersForMedication(medicationId: string): Promise { this.logger.log(`取消药物 ${medicationId} 的所有提醒`); // 由于提醒是基于记录的状态和药物的激活状态动态生成的 // 所以只需要确保药物被停用或删除,定时任务就不会再发送提醒 } /** * 获取用户今天的待提醒数量 */ async getTodayReminderCount(userId: string): Promise { 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; } }