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,229 @@
import { Injectable, Logger } from '@nestjs/common';
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 { RepeatPatternEnum } from '../enums/repeat-pattern.enum';
import { v4 as uuidv4 } from 'uuid';
import * as dayjs from 'dayjs';
import * as utc from 'dayjs/plugin/utc';
import * as timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
/**
* 服药记录生成服务
* 实现惰性生成策略:当查询时检查并生成当天记录
*/
@Injectable()
export class RecordGeneratorService {
private readonly logger = new Logger(RecordGeneratorService.name);
constructor(
@InjectModel(Medication)
private readonly medicationModel: typeof Medication,
@InjectModel(MedicationRecord)
private readonly recordModel: typeof MedicationRecord,
) {}
/**
* 为指定日期生成服药记录
* @param userId 用户ID
* @param date 日期字符串YYYY-MM-DD
*/
async generateRecordsForDate(userId: string, date: string): Promise<void> {
this.logger.log(`开始为用户 ${userId} 生成 ${date} 的服药记录`);
// 解析目标日期
const targetDate = dayjs(date).startOf('day');
// 查询用户所有激活的药物
const medications = await this.medicationModel.findAll({
where: {
userId,
isActive: true,
deleted: false,
},
});
if (medications.length === 0) {
this.logger.log(`用户 ${userId} 没有激活的药物`);
return;
}
// 为每个药物生成当天的服药记录
for (const medication of medications) {
await this.generateRecordsForMedicationOnDate(medication, targetDate);
}
this.logger.log(`成功为用户 ${userId} 生成 ${date} 的服药记录`);
}
/**
* 为单个药物在指定日期生成服药记录
*/
private async generateRecordsForMedicationOnDate(
medication: Medication,
targetDate: dayjs.Dayjs,
): Promise<void> {
// 检查该日期是否在药物的有效期内
if (!this.isDateInMedicationRange(medication, targetDate)) {
this.logger.debug(
`药物 ${medication.id}${targetDate.format('YYYY-MM-DD')} 不在有效期内`,
);
return;
}
// 检查是否已经生成过该日期的记录
const existingRecords = await this.recordModel.findAll({
where: {
medicationId: medication.id,
userId: medication.userId,
deleted: false,
},
});
// 过滤出当天的记录
const recordsOnDate = existingRecords.filter((record) => {
const recordDate = dayjs(record.scheduledTime).startOf('day');
return recordDate.isSame(targetDate, 'day');
});
if (recordsOnDate.length > 0) {
this.logger.debug(
`药物 ${medication.id}${targetDate.format('YYYY-MM-DD')} 的记录已存在`,
);
return;
}
// 根据重复模式生成记录
if (medication.repeatPattern === RepeatPatternEnum.DAILY) {
await this.generateDailyRecords(medication, targetDate);
}
// 未来可以扩展 WEEKLY 和 CUSTOM 模式
}
/**
* 生成每日重复模式的记录
*/
private async generateDailyRecords(
medication: Medication,
targetDate: dayjs.Dayjs,
): Promise<void> {
const records: any[] = [];
// 为每个服药时间生成一条记录
for (const timeStr of medication.medicationTimes) {
// 解析时间字符串HH:mm
const [hours, minutes] = timeStr.split(':').map(Number);
// 创建计划服药时间UTC
const scheduledTime = targetDate
.hour(hours)
.minute(minutes)
.second(0)
.millisecond(0)
.toDate();
// 判断初始状态
const now = new Date();
const status =
scheduledTime <= now
? MedicationStatusEnum.MISSED
: MedicationStatusEnum.UPCOMING;
records.push({
id: uuidv4(),
medicationId: medication.id,
userId: medication.userId,
scheduledTime,
actualTime: null,
status,
note: null,
deleted: false,
});
}
// 批量创建记录
if (records.length > 0) {
await this.recordModel.bulkCreate(records);
this.logger.log(
`为药物 ${medication.id}${targetDate.format('YYYY-MM-DD')} 生成了 ${records.length} 条记录`,
);
}
}
/**
* 检查日期是否在药物有效期内
*/
private isDateInMedicationRange(
medication: Medication,
targetDate: dayjs.Dayjs,
): boolean {
const startDate = dayjs(medication.startDate).startOf('day');
const endDate = medication.endDate
? dayjs(medication.endDate).startOf('day')
: null;
// 检查是否在开始日期之后
if (targetDate.isBefore(startDate, 'day')) {
return false;
}
// 检查是否在结束日期之前(如果有结束日期)
if (endDate && targetDate.isAfter(endDate, 'day')) {
return false;
}
return true;
}
/**
* 检查并生成指定日期的记录(如果不存在)
* @param userId 用户ID
* @param date 日期字符串YYYY-MM-DD
* @returns 是否生成了新记录
*/
async ensureRecordsExist(userId: string, date: string): Promise<boolean> {
const targetDate = dayjs(date).format('YYYY-MM-DD');
// 检查该日期是否已有记录
const startOfDay = dayjs(date).startOf('day').toDate();
const endOfDay = dayjs(date).endOf('day').toDate();
const existingRecords = await this.recordModel.count({
where: {
userId,
deleted: false,
},
});
// 简单判断:如果没有任何记录,则生成
const recordsCount = await this.recordModel.count({
where: {
userId,
deleted: false,
},
});
// 这里使用更精确的查询
const Op = require('sequelize').Op;
const recordsOnDate = await this.recordModel.count({
where: {
userId,
deleted: false,
scheduledTime: {
[Op.between]: [startOfDay, endOfDay],
},
},
});
if (recordsOnDate === 0) {
await this.generateRecordsForDate(userId, targetDate);
return true;
}
return false;
}
}