feat(medications): 新增完整的药物管理和服药提醒功能

实现了包含药物信息管理、服药记录追踪、统计分析、自动状态更新和推送提醒的完整药物管理系统。

核心功能:
- 药物 CRUD 操作,支持多种剂型和自定义服药时间
- 惰性生成服药记录策略,查询时才生成当天记录
- 定时任务自动更新过期记录状态(每30分钟)
- 服药前15分钟自动推送提醒(每5分钟检查)
- 每日/范围/总体统计分析功能
- 完整的 API 文档和数据库建表脚本

技术实现:
- 使用 Sequelize ORM 管理 MySQL 数据表
- 集成 @nestjs/schedule 实现定时任务
- 复用现有推送通知系统发送提醒
- 采用软删除和权限验证保障数据安全
This commit is contained in:
richarjiang
2025-11-07 17:29:11 +08:00
parent 37cc2a729b
commit 188b4addca
27 changed files with 3464 additions and 0 deletions

View File

@@ -0,0 +1,211 @@
import { Injectable, Logger } from '@nestjs/common';
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分钟发送推送提醒
*/
@Injectable()
export class MedicationReminderService {
private readonly logger = new Logger(MedicationReminderService.name);
private readonly REMINDER_MINUTES_BEFORE = 15; // 提前15分钟提醒
constructor(
@InjectModel(Medication)
private readonly medicationModel: typeof Medication,
@InjectModel(MedicationRecord)
private readonly recordModel: typeof MedicationRecord,
private readonly pushService: PushNotificationsService,
) {}
/**
* 每5分钟检查一次需要发送的提醒
*/
@Cron('*/5 * * * *')
async checkAndSendReminders(): Promise<void> {
this.logger.log('开始检查服药提醒');
try {
// 计算时间范围:当前时间 + 15分钟
const now = new Date();
const reminderTime = dayjs(now)
.add(this.REMINDER_MINUTES_BEFORE, 'minute')
.toDate();
// 查找在接下来15分钟内需要提醒的记录
const startRange = now;
const endRange = dayjs(now).add(5, 'minute').toDate(); // 5分钟窗口期
const upcomingRecords = await this.recordModel.findAll({
where: {
status: MedicationStatusEnum.UPCOMING,
deleted: 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);
}
// 为每个用户发送提醒
for (const [userId, records] of userRecordsMap.entries()) {
await this.sendReminderToUser(userId, records);
}
this.logger.log(`成功发送 ${upcomingRecords.length} 条服药提醒`);
} catch (error) {
this.logger.error('检查服药提醒失败', error.stack);
}
}
/**
* 为单个用户发送提醒
*/
private async sendReminderToUser(
userId: string,
records: MedicationRecord[],
): Promise<void> {
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} 发送服药提醒`);
} catch (error) {
this.logger.error(
`向用户 ${userId} 发送服药提醒失败`,
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;
}
}