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

@@ -21,6 +21,7 @@ import { FoodLibraryModule } from './food-library/food-library.module';
import { WaterRecordsModule } from './water-records/water-records.module';
import { ChallengesModule } from './challenges/challenges.module';
import { PushNotificationsModule } from './push-notifications/push-notifications.module';
import { MedicationsModule } from './medications/medications.module';
@Module({
imports: [
@@ -47,6 +48,7 @@ import { PushNotificationsModule } from './push-notifications/push-notifications
WaterRecordsModule,
ChallengesModule,
PushNotificationsModule,
MedicationsModule,
],
controllers: [AppController],
providers: [AppService],

379
src/medications/README.md Normal file
View File

@@ -0,0 +1,379 @@
# 药物管理模块
## 概述
药物管理模块提供完整的用药提醒和服药记录管理功能,包括:
- 药物信息管理CRUD
- 服药记录追踪
- 统计分析
- 自动状态更新
- 推送提醒
## 功能特性
### 1. 药物管理
- 支持多种药物剂型(胶囊、药片、注射、喷雾、滴剂、糖浆等)
- 灵活的服药时间设置
- 支持每日重复模式
- 可设置开始和结束日期
- 支持添加药物照片和备注
### 2. 服药记录
- **惰性生成策略**:查询时才生成当天记录,避免预先生成大量数据
- 四种状态:待服用(upcoming)、已服用(taken)、已错过(missed)、已跳过(skipped)
- 自动状态更新定时任务每30分钟检查并更新过期记录
- 支持标记服用、跳过服药、更新记录
### 3. 统计功能
- 每日统计:计划服药次数、已服用、已错过、待服用、完成率
- 日期范围统计:支持查询任意时间段的统计数据
- 总体统计概览:总记录数、完成率等
### 4. 推送提醒
- 定时任务每5分钟检查即将到来的服药时间提前15-20分钟
- 集成现有推送通知系统
- 支持自定义提醒消息
## API 接口
### 药物管理接口
#### 1. 获取药物列表
```http
GET /medications?isActive=true&page=1&pageSize=20
```
#### 2. 创建药物
```http
POST /medications
Content-Type: application/json
{
"name": "Metformin",
"photoUrl": "https://cdn.example.com/med_001.jpg",
"form": "capsule",
"dosageValue": 1,
"dosageUnit": "粒",
"timesPerDay": 2,
"medicationTimes": ["08:00", "20:00"],
"repeatPattern": "daily",
"startDate": "2025-01-01T00:00:00.000Z",
"note": "饭后服用"
}
```
#### 3. 更新药物
```http
PUT /medications/{id}
Content-Type: application/json
{
"dosageValue": 2,
"timesPerDay": 3,
"medicationTimes": ["08:00", "14:00", "20:00"]
}
```
#### 4. 删除药物
```http
DELETE /medications/{id}
```
#### 5. 停用药物
```http
POST /medications/{id}/deactivate
```
### 服药记录接口
#### 1. 获取服药记录
```http
GET /medication-records?date=2025-01-15&status=upcoming
```
#### 2. 获取今日服药记录
```http
GET /medication-records/today
```
#### 3. 标记为已服用
```http
POST /medication-records/{recordId}/take
Content-Type: application/json
{
"actualTime": "2025-01-15T08:10:00.000Z"
}
```
#### 4. 跳过服药
```http
POST /medication-records/{recordId}/skip
Content-Type: application/json
{
"note": "今天状态不好,暂时跳过"
}
```
#### 5. 更新服药记录
```http
PUT /medication-records/{recordId}
Content-Type: application/json
{
"status": "taken",
"actualTime": "2025-01-15T08:15:00.000Z",
"note": "延迟服用"
}
```
### 统计接口
#### 1. 获取每日统计
```http
GET /medication-stats/daily?date=2025-01-15
```
响应示例:
```json
{
"code": 200,
"message": "查询成功",
"data": {
"date": "2025-01-15",
"totalScheduled": 6,
"taken": 4,
"missed": 1,
"upcoming": 1,
"completionRate": 66.67
}
}
```
#### 2. 获取日期范围统计
```http
GET /medication-stats/range?startDate=2025-01-01&endDate=2025-01-15
```
#### 3. 获取总体统计
```http
GET /medication-stats/overall
```
## 数据模型
### Medication药物
```typescript
{
id: string;
userId: string;
name: string; // 药物名称
photoUrl?: string; // 药物照片
form: MedicationForm; // 剂型
dosageValue: number; // 剂量数值
dosageUnit: string; // 剂量单位
timesPerDay: number; // 每日服用次数
medicationTimes: string[]; // 服药时间
repeatPattern: RepeatPattern;
startDate: Date;
endDate?: Date;
note?: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
```
### MedicationRecord服药记录
```typescript
{
id: string;
medicationId: string;
userId: string;
scheduledTime: Date; // 计划服药时间
actualTime?: Date; // 实际服药时间
status: MedicationStatus;
note?: string;
createdAt: Date;
updatedAt: Date;
}
```
## 业务逻辑说明
### 1. 惰性生成策略
服药记录采用惰性生成策略,而非预先生成大量记录:
```typescript
// 查询记录时自动生成当天记录
async findAll(userId: string, query: MedicationRecordQueryDto) {
// 1. 确保指定日期的记录存在
await this.recordGenerator.ensureRecordsExist(userId, query.date);
// 2. 查询并返回记录
return this.recordModel.findAll({...});
}
```
### 2. 状态自动更新
定时任务每30分钟检查并更新过期记录
```typescript
@Cron(CronExpression.EVERY_30_MINUTES)
async updateExpiredRecords() {
// 将已过期的 upcoming 记录更新为 missed
await this.recordModel.update(
{ status: MedicationStatusEnum.MISSED },
{
where: {
status: MedicationStatusEnum.UPCOMING,
scheduledTime: { [Op.lt]: new Date() }
}
}
);
}
```
### 3. 推送提醒
定时任务每5分钟检查即将到来的服药时间
```typescript
@Cron('*/5 * * * *')
async checkAndSendReminders() {
const now = new Date();
const reminderStart = dayjs(now).add(15, 'minute').toDate();
const reminderEnd = dayjs(now).add(20, 'minute').toDate();
// 查找15-20分钟后需要服药的记录
const upcomingRecords = await this.recordModel.findAll({
where: {
status: MedicationStatusEnum.UPCOMING,
scheduledTime: { [Op.between]: [reminderStart, reminderEnd] }
}
});
// 发送推送通知
for (const record of upcomingRecords) {
await this.sendReminder(record);
}
}
```
## 部署说明
### 1. 执行数据库迁移
```bash
# 连接到 MySQL 数据库
mysql -u your_username -p your_database
# 执行建表 SQL
source sql-scripts/medications-tables-create.sql
```
### 2. 环境变量
确保 `.env` 文件中包含以下配置:
```env
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=your_username
DB_PASSWORD=your_password
DB_DATABASE=pilates_db
# JWT 配置
JWT_SECRET=your_jwt_secret
# 推送通知配置(如需使用推送功能)
APPLE_KEY_ID=your_apple_key_id
APPLE_ISSUER_ID=your_apple_issuer_id
APPLE_PRIVATE_KEY_PATH=path/to/private/key.p8
```
### 3. 启动应用
```bash
# 开发模式
yarn start:dev
# 生产模式
yarn build
yarn start:prod
```
## 测试建议
### 1. 基础功能测试
- 创建药物
- 查询药物列表
- 更新药物信息
- 删除/停用药物
### 2. 记录管理测试
- 查询今日记录(验证惰性生成)
- 标记服用
- 跳过服药
- 更新记录
### 3. 统计功能测试
- 每日统计计算准确性
- 日期范围统计
- 完成率计算
### 4. 定时任务测试
- 状态自动更新等待30分钟后检查
- 推送提醒发送创建15分钟后的服药记录
## 注意事项
1. **时区处理**:所有时间使用 UTC 存储,前端需要转换为本地时间
2. **权限控制**:所有接口需要 JWT 认证,用户只能访问自己的数据
3. **惰性生成**:首次查询某天记录时会自动生成,可能有轻微延迟
4. **定时任务**:依赖 `@nestjs/schedule` 模块,确保已启用
5. **推送通知**:需要正确配置 APNs 证书和密钥
## 未来扩展
1. **周计划模式**:支持每周特定日期服药
2. **自定义周期**支持间隔天数服药如每3天一次
3. **剂量提醒**:提醒用户剩余药量不足
4. **服药历史**:长期服药历史分析和可视化
5. **多设备同步**:支持多设备间的数据同步
6. **家庭账户**:支持为家人管理用药
## 相关文档
- [API 规范文档](../../docs/medication-api-spec.md)
- [数据库设计](../../sql-scripts/medications-tables-create.sql)
- [推送通知文档](../push-notifications/README_PUSH_TEST.md)

View File

@@ -0,0 +1,104 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
IsNotEmpty,
IsEnum,
IsNumber,
IsInt,
IsArray,
IsDateString,
IsOptional,
Min,
ArrayMinSize,
Matches,
} from 'class-validator';
import { MedicationFormEnum } from '../enums/medication-form.enum';
import { RepeatPatternEnum } from '../enums/repeat-pattern.enum';
/**
* 创建药物 DTO
*/
export class CreateMedicationDto {
@ApiProperty({ description: '药物名称', example: 'Metformin' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({
description: '药物照片URL',
example: 'https://cdn.example.com/medications/med_001.jpg',
required: false,
})
@IsString()
@IsOptional()
photoUrl?: string;
@ApiProperty({
description: '药物剂型',
enum: MedicationFormEnum,
example: MedicationFormEnum.CAPSULE,
})
@IsEnum(MedicationFormEnum)
@IsNotEmpty()
form: MedicationFormEnum;
@ApiProperty({ description: '剂量数值', example: 1 })
@IsNumber()
@Min(0.01)
dosageValue: number;
@ApiProperty({ description: '剂量单位', example: '粒' })
@IsString()
@IsNotEmpty()
dosageUnit: string;
@ApiProperty({ description: '每日服用次数', example: 2 })
@IsInt()
@Min(1)
timesPerDay: number;
@ApiProperty({
description: '服药时间列表格式HH:mm',
example: ['08:00', '20:00'],
type: [String],
})
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, {
each: true,
message: '服药时间格式必须为 HH:mm',
})
medicationTimes: string[];
@ApiProperty({
description: '重复模式',
enum: RepeatPatternEnum,
example: RepeatPatternEnum.DAILY,
})
@IsEnum(RepeatPatternEnum)
@IsNotEmpty()
repeatPattern: RepeatPatternEnum;
@ApiProperty({
description: '开始日期ISO 8601 格式',
example: '2025-01-01T00:00:00.000Z',
})
@IsDateString()
@IsNotEmpty()
startDate: string;
@ApiProperty({
description: '结束日期ISO 8601 格式(可选)',
example: '2025-12-31T23:59:59.999Z',
required: false,
})
@IsDateString()
@IsOptional()
endDate?: string;
@ApiProperty({ description: '备注信息', example: '饭后服用', required: false })
@IsString()
@IsOptional()
note?: string;
}

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsBoolean } from 'class-validator';
import { Transform } from 'class-transformer';
/**
* 查询药物列表 DTO
*/
export class MedicationQueryDto {
@ApiProperty({
description: '是否只获取激活的药物',
example: true,
required: false,
})
@IsOptional()
@IsString()
@Transform(({ value }) => value === 'true')
isActive?: boolean;
@ApiProperty({ description: '页码', example: 1, required: false })
@IsOptional()
@IsString()
page?: string;
@ApiProperty({ description: '每页数量', example: 20, required: false })
@IsOptional()
@IsString()
pageSize?: string;
}

View File

@@ -0,0 +1,53 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsDateString, IsEnum } from 'class-validator';
import { MedicationStatusEnum } from '../enums/medication-status.enum';
/**
* 查询服药记录 DTO
*/
export class MedicationRecordQueryDto {
@ApiProperty({
description: '指定日期YYYY-MM-DD',
example: '2025-01-15',
required: false,
})
@IsOptional()
@IsString()
date?: string;
@ApiProperty({
description: '开始日期YYYY-MM-DD',
example: '2025-01-01',
required: false,
})
@IsOptional()
@IsString()
startDate?: string;
@ApiProperty({
description: '结束日期YYYY-MM-DD',
example: '2025-01-31',
required: false,
})
@IsOptional()
@IsString()
endDate?: string;
@ApiProperty({
description: '指定药物ID',
example: 'med_001',
required: false,
})
@IsOptional()
@IsString()
medicationId?: string;
@ApiProperty({
description: '状态筛选',
enum: MedicationStatusEnum,
required: false,
})
@IsOptional()
@IsEnum(MedicationStatusEnum)
status?: MedicationStatusEnum;
}

View File

@@ -0,0 +1,59 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty } from 'class-validator';
/**
* 每日统计查询 DTO
*/
export class DailyStatsQueryDto {
@ApiProperty({
description: '日期YYYY-MM-DD',
example: '2025-01-15',
})
@IsString()
@IsNotEmpty()
date: string;
}
/**
* 日期范围统计查询 DTO
*/
export class RangeStatsQueryDto {
@ApiProperty({
description: '开始日期YYYY-MM-DD',
example: '2025-01-01',
})
@IsString()
@IsNotEmpty()
startDate: string;
@ApiProperty({
description: '结束日期YYYY-MM-DD',
example: '2025-01-31',
})
@IsString()
@IsNotEmpty()
endDate: string;
}
/**
* 每日统计响应 DTO
*/
export class DailyMedicationStatsDto {
@ApiProperty({ description: '日期', example: '2025-01-15' })
date: string;
@ApiProperty({ description: '计划服药总次数', example: 6 })
totalScheduled: number;
@ApiProperty({ description: '已服用次数', example: 4 })
taken: number;
@ApiProperty({ description: '已错过次数', example: 1 })
missed: number;
@ApiProperty({ description: '待服用次数', example: 1 })
upcoming: number;
@ApiProperty({ description: '完成率(百分比)', example: 66.67 })
completionRate: number;
}

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
/**
* 跳过服药 DTO
*/
export class SkipMedicationDto {
@ApiProperty({
description: '跳过原因或备注',
example: '今天状态不好,暂时跳过',
required: false,
})
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsDateString } from 'class-validator';
/**
* 标记服药 DTO
*/
export class TakeMedicationDto {
@ApiProperty({
description: '实际服药时间ISO 8601 格式(可选,默认为当前时间)',
example: '2025-01-15T08:10:00.000Z',
required: false,
})
@IsOptional()
@IsDateString()
actualTime?: string;
}

View File

@@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsDateString, IsEnum, IsString } from 'class-validator';
import { MedicationStatusEnum } from '../enums/medication-status.enum';
/**
* 更新服药记录 DTO
*/
export class UpdateMedicationRecordDto {
@ApiProperty({
description: '服药状态',
enum: MedicationStatusEnum,
required: false,
})
@IsOptional()
@IsEnum(MedicationStatusEnum)
status?: MedicationStatusEnum;
@ApiProperty({
description: '实际服药时间ISO 8601 格式',
example: '2025-01-15T08:15:00.000Z',
required: false,
})
@IsOptional()
@IsDateString()
actualTime?: string;
@ApiProperty({
description: '备注',
example: '延迟服用',
required: false,
})
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -0,0 +1,8 @@
import { PartialType } from '@nestjs/swagger';
import { CreateMedicationDto } from './create-medication.dto';
/**
* 更新药物 DTO
* 继承创建 DTO所有字段都是可选的
*/
export class UpdateMedicationDto extends PartialType(CreateMedicationDto) {}

View File

@@ -0,0 +1,19 @@
/**
* 药物剂型枚举
*/
export enum MedicationFormEnum {
/** 胶囊 */
CAPSULE = 'capsule',
/** 药片 */
PILL = 'pill',
/** 注射 */
INJECTION = 'injection',
/** 喷雾 */
SPRAY = 'spray',
/** 滴剂 */
DROP = 'drop',
/** 糖浆 */
SYRUP = 'syrup',
/** 其他 */
OTHER = 'other',
}

View File

@@ -0,0 +1,13 @@
/**
* 服药状态枚举
*/
export enum MedicationStatusEnum {
/** 待服用 */
UPCOMING = 'upcoming',
/** 已服用 */
TAKEN = 'taken',
/** 已错过 */
MISSED = 'missed',
/** 已跳过 */
SKIPPED = 'skipped',
}

View File

@@ -0,0 +1,11 @@
/**
* 重复模式枚举
*/
export enum RepeatPatternEnum {
/** 每日 */
DAILY = 'daily',
/** 每周 */
WEEKLY = 'weekly',
/** 自定义 */
CUSTOM = 'custom',
}

View File

@@ -0,0 +1,102 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { MedicationRecordsService } from './medication-records.service';
import { TakeMedicationDto } from './dto/take-medication.dto';
import { SkipMedicationDto } from './dto/skip-medication.dto';
import { UpdateMedicationRecordDto } from './dto/update-medication-record.dto';
import { MedicationRecordQueryDto } from './dto/medication-record-query.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { ApiResponseDto } from '../base.dto';
/**
* 服药记录控制器
*/
@ApiTags('medication-records')
@Controller('medication-records')
@UseGuards(JwtAuthGuard)
export class MedicationRecordsController {
constructor(
private readonly recordsService: MedicationRecordsService,
) {}
@Get()
@ApiOperation({ summary: '获取服药记录' })
@ApiResponse({ status: 200, description: '查询成功' })
async findAll(
@CurrentUser() user: any,
@Query() query: MedicationRecordQueryDto,
) {
const records = await this.recordsService.findAll(user.sub, query);
return ApiResponseDto.success(records, '查询成功');
}
@Get('today')
@ApiOperation({ summary: '获取今日服药记录' })
@ApiResponse({ status: 200, description: '查询成功' })
async getTodayRecords(@CurrentUser() user: any) {
const records = await this.recordsService.getTodayRecords(user.sub);
return ApiResponseDto.success(records, '查询成功');
}
@Get(':id')
@ApiOperation({ summary: '获取服药记录详情' })
@ApiResponse({ status: 200, description: '查询成功' })
async findOne(@CurrentUser() user: any, @Param('id') id: string) {
const record = await this.recordsService.findOne(id, user.sub);
return ApiResponseDto.success(record, '查询成功');
}
@Post(':id/take')
@ApiOperation({ summary: '标记为已服用' })
@ApiResponse({ status: 200, description: '操作成功' })
async takeMedication(
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: TakeMedicationDto,
) {
const record = await this.recordsService.takeMedication(
id,
user.sub,
dto,
);
return ApiResponseDto.success(record, '已记录服药');
}
@Post(':id/skip')
@ApiOperation({ summary: '跳过服药' })
@ApiResponse({ status: 200, description: '操作成功' })
async skipMedication(
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: SkipMedicationDto,
) {
const record = await this.recordsService.skipMedication(
id,
user.sub,
dto,
);
return ApiResponseDto.success(record, '已跳过服药');
}
@Put(':id')
@ApiOperation({ summary: '更新服药记录' })
@ApiResponse({ status: 200, description: '更新成功' })
async update(
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: UpdateMedicationRecordDto,
) {
const record = await this.recordsService.update(id, user.sub, dto);
return ApiResponseDto.success(record, '更新成功');
}
}

View File

@@ -0,0 +1,229 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { MedicationRecord } from './models/medication-record.model';
import { Medication } from './models/medication.model';
import { MedicationStatusEnum } from './enums/medication-status.enum';
import { RecordGeneratorService } from './services/record-generator.service';
import { TakeMedicationDto } from './dto/take-medication.dto';
import { SkipMedicationDto } from './dto/skip-medication.dto';
import { UpdateMedicationRecordDto } from './dto/update-medication-record.dto';
import { MedicationRecordQueryDto } from './dto/medication-record-query.dto';
import { Op } from 'sequelize';
import * as dayjs from 'dayjs';
/**
* 服药记录管理服务
*/
@Injectable()
export class MedicationRecordsService {
private readonly logger = new Logger(MedicationRecordsService.name);
constructor(
@InjectModel(MedicationRecord)
private readonly recordModel: typeof MedicationRecord,
@InjectModel(Medication)
private readonly medicationModel: typeof Medication,
private readonly recordGenerator: RecordGeneratorService,
) {}
/**
* 获取服药记录(集成惰性生成)
*/
async findAll(
userId: string,
query: MedicationRecordQueryDto,
): Promise<MedicationRecord[]> {
// 如果指定了日期,确保该日期的记录存在
if (query.date) {
await this.recordGenerator.ensureRecordsExist(userId, query.date);
}
// 如果指定了日期范围,为每一天生成记录
if (query.startDate && query.endDate) {
const start = dayjs(query.startDate);
const end = dayjs(query.endDate);
let current = start;
while (current.isBefore(end) || current.isSame(end, 'day')) {
await this.recordGenerator.ensureRecordsExist(
userId,
current.format('YYYY-MM-DD'),
);
current = current.add(1, 'day');
}
}
// 构建查询条件
const where: any = {
userId,
deleted: false,
};
// 日期筛选
if (query.date) {
const startOfDay = dayjs(query.date).startOf('day').toDate();
const endOfDay = dayjs(query.date).endOf('day').toDate();
where.scheduledTime = {
[Op.between]: [startOfDay, endOfDay],
};
} else if (query.startDate && query.endDate) {
const startOfDay = dayjs(query.startDate).startOf('day').toDate();
const endOfDay = dayjs(query.endDate).endOf('day').toDate();
where.scheduledTime = {
[Op.between]: [startOfDay, endOfDay],
};
}
// 药物筛选
if (query.medicationId) {
where.medicationId = query.medicationId;
}
// 状态筛选
if (query.status) {
where.status = query.status;
}
// 查询记录,包含药物信息
const records = await this.recordModel.findAll({
where,
include: [
{
model: Medication,
as: 'medication',
attributes: ['id', 'name', 'form', 'dosageValue', 'dosageUnit'],
},
],
order: [['scheduledTime', 'ASC']],
});
return records;
}
/**
* 根据ID获取记录详情
*/
async findOne(id: string, userId: string): Promise<MedicationRecord> {
const record = await this.recordModel.findOne({
where: {
id,
deleted: false,
},
include: [
{
model: Medication,
as: 'medication',
},
],
});
if (!record) {
throw new NotFoundException('服药记录不存在');
}
// 验证所有权
if (record.userId !== userId) {
throw new ForbiddenException('无权访问此记录');
}
return record;
}
/**
* 标记为已服用
*/
async takeMedication(
id: string,
userId: string,
dto: TakeMedicationDto,
): Promise<MedicationRecord> {
const record = await this.findOne(id, userId);
record.status = MedicationStatusEnum.TAKEN;
record.actualTime = dto.actualTime ? new Date(dto.actualTime) : new Date();
await record.save();
this.logger.log(`用户 ${userId} 标记服药记录 ${id} 为已服用`);
return record;
}
/**
* 跳过服药
*/
async skipMedication(
id: string,
userId: string,
dto: SkipMedicationDto,
): Promise<MedicationRecord> {
const record = await this.findOne(id, userId);
record.status = MedicationStatusEnum.SKIPPED;
if (dto.note) {
record.note = dto.note;
}
await record.save();
this.logger.log(`用户 ${userId} 跳过服药记录 ${id}`);
return record;
}
/**
* 更新服药记录
*/
async update(
id: string,
userId: string,
dto: UpdateMedicationRecordDto,
): Promise<MedicationRecord> {
const record = await this.findOne(id, userId);
if (dto.status !== undefined) {
record.status = dto.status;
}
if (dto.actualTime !== undefined) {
record.actualTime = new Date(dto.actualTime);
}
if (dto.note !== undefined) {
record.note = dto.note;
}
await record.save();
this.logger.log(`用户 ${userId} 更新服药记录 ${id}`);
return record;
}
/**
* 获取今日的服药记录
*/
async getTodayRecords(userId: string): Promise<MedicationRecord[]> {
const today = dayjs().format('YYYY-MM-DD');
await this.recordGenerator.ensureRecordsExist(userId, today);
const startOfDay = dayjs().startOf('day').toDate();
const endOfDay = dayjs().endOf('day').toDate();
return this.recordModel.findAll({
where: {
userId,
deleted: false,
scheduledTime: {
[Op.between]: [startOfDay, endOfDay],
},
},
include: [
{
model: Medication,
as: 'medication',
attributes: ['id', 'name', 'form', 'dosageValue', 'dosageUnit'],
},
],
order: [['scheduledTime', 'ASC']],
});
}
}

View File

@@ -0,0 +1,53 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { MedicationStatsService } from './medication-stats.service';
import { DailyStatsQueryDto, RangeStatsQueryDto } from './dto/medication-stats.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { ApiResponseDto } from '../base.dto';
/**
* 服药统计控制器
*/
@ApiTags('medication-stats')
@Controller('medication-stats')
@UseGuards(JwtAuthGuard)
export class MedicationStatsController {
constructor(
private readonly statsService: MedicationStatsService,
) {}
@Get('daily')
@ApiOperation({ summary: '获取每日统计' })
@ApiResponse({ status: 200, description: '查询成功' })
async getDailyStats(
@CurrentUser() user: any,
@Query() query: DailyStatsQueryDto,
) {
const stats = await this.statsService.getDailyStats(user.sub, query.date);
return ApiResponseDto.success(stats, '查询成功');
}
@Get('range')
@ApiOperation({ summary: '获取日期范围统计' })
@ApiResponse({ status: 200, description: '查询成功' })
async getRangeStats(
@CurrentUser() user: any,
@Query() query: RangeStatsQueryDto,
) {
const stats = await this.statsService.getRangeStats(
user.sub,
query.startDate,
query.endDate,
);
return ApiResponseDto.success(stats, '查询成功');
}
@Get('overall')
@ApiOperation({ summary: '获取总体统计概览' })
@ApiResponse({ status: 200, description: '查询成功' })
async getOverallStats(@CurrentUser() user: any) {
const stats = await this.statsService.getOverallStats(user.sub);
return ApiResponseDto.success(stats, '查询成功');
}
}

View File

@@ -0,0 +1,145 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { MedicationRecord } from './models/medication-record.model';
import { MedicationStatusEnum } from './enums/medication-status.enum';
import { DailyMedicationStatsDto } from './dto/medication-stats.dto';
import { RecordGeneratorService } from './services/record-generator.service';
import { Op } from 'sequelize';
import * as dayjs from 'dayjs';
/**
* 服药统计服务
*/
@Injectable()
export class MedicationStatsService {
private readonly logger = new Logger(MedicationStatsService.name);
constructor(
@InjectModel(MedicationRecord)
private readonly recordModel: typeof MedicationRecord,
private readonly recordGenerator: RecordGeneratorService,
) {}
/**
* 获取每日统计
*/
async getDailyStats(
userId: string,
date: string,
): Promise<DailyMedicationStatsDto> {
// 确保该日期的记录存在
await this.recordGenerator.ensureRecordsExist(userId, date);
const startOfDay = dayjs(date).startOf('day').toDate();
const endOfDay = dayjs(date).endOf('day').toDate();
// 查询该日期的所有记录
const records = await this.recordModel.findAll({
where: {
userId,
deleted: false,
scheduledTime: {
[Op.between]: [startOfDay, endOfDay],
},
},
});
// 统计各状态数量
const totalScheduled = records.length;
const taken = records.filter(
(r) => r.status === MedicationStatusEnum.TAKEN,
).length;
const missed = records.filter(
(r) => r.status === MedicationStatusEnum.MISSED,
).length;
const upcoming = records.filter(
(r) => r.status === MedicationStatusEnum.UPCOMING,
).length;
// 计算完成率
const completionRate =
totalScheduled > 0 ? (taken / totalScheduled) * 100 : 0;
return {
date,
totalScheduled,
taken,
missed,
upcoming,
completionRate: Math.round(completionRate * 100) / 100, // 保留两位小数
};
}
/**
* 获取日期范围统计
*/
async getRangeStats(
userId: string,
startDate: string,
endDate: string,
): Promise<DailyMedicationStatsDto[]> {
const start = dayjs(startDate);
const end = dayjs(endDate);
const stats: DailyMedicationStatsDto[] = [];
let current = start;
while (current.isBefore(end) || current.isSame(end, 'day')) {
const dateStr = current.format('YYYY-MM-DD');
const dailyStats = await this.getDailyStats(userId, dateStr);
stats.push(dailyStats);
current = current.add(1, 'day');
}
return stats;
}
/**
* 获取本周统计
*/
async getWeeklyStats(userId: string): Promise<DailyMedicationStatsDto[]> {
const startOfWeek = dayjs().startOf('week').format('YYYY-MM-DD');
const endOfWeek = dayjs().endOf('week').format('YYYY-MM-DD');
return this.getRangeStats(userId, startOfWeek, endOfWeek);
}
/**
* 获取本月统计
*/
async getMonthlyStats(userId: string): Promise<DailyMedicationStatsDto[]> {
const startOfMonth = dayjs().startOf('month').format('YYYY-MM-DD');
const endOfMonth = dayjs().endOf('month').format('YYYY-MM-DD');
return this.getRangeStats(userId, startOfMonth, endOfMonth);
}
/**
* 获取总体统计概览
*/
async getOverallStats(userId: string): Promise<{
totalMedications: number;
totalRecords: number;
completionRate: number;
streak: number; // 连续完成天数
}> {
// 这里可以扩展更多统计维度
const records = await this.recordModel.findAll({
where: {
userId,
deleted: false,
},
});
const totalRecords = records.length;
const completedRecords = records.filter(
(r) => r.status === MedicationStatusEnum.TAKEN,
).length;
const completionRate =
totalRecords > 0 ? (completedRecords / totalRecords) * 100 : 0;
return {
totalMedications: 0, // 需要查询药物表
totalRecords,
completionRate: Math.round(completionRate * 100) / 100,
streak: 0, // 需要计算连续天数
};
}
}

View File

@@ -0,0 +1,134 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { MedicationsService } from './medications.service';
import { CreateMedicationDto } from './dto/create-medication.dto';
import { UpdateMedicationDto } from './dto/update-medication.dto';
import { MedicationQueryDto } from './dto/medication-query.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { ApiResponseDto } from '../base.dto';
import { MedicationReminderService } from './services/medication-reminder.service';
/**
* 药物管理控制器
*/
@ApiTags('medications')
@Controller('medications')
@UseGuards(JwtAuthGuard)
export class MedicationsController {
constructor(
private readonly medicationsService: MedicationsService,
private readonly reminderService: MedicationReminderService,
) {}
@Post()
@ApiOperation({ summary: '创建药物' })
@ApiResponse({ status: 201, description: '创建成功' })
async create(
@CurrentUser() user: any,
@Body() createDto: CreateMedicationDto,
) {
const medication = await this.medicationsService.create(
user.sub,
createDto,
);
// 设置提醒(实际由定时任务触发)
await this.reminderService.setupRemindersForMedication(medication);
return ApiResponseDto.success(medication, '创建成功');
}
@Get()
@ApiOperation({ summary: '获取药物列表' })
@ApiResponse({ status: 200, description: '查询成功' })
async findAll(@CurrentUser() user: any, @Query() query: MedicationQueryDto) {
const page = query.page ? parseInt(query.page, 10) : 1;
const pageSize = query.pageSize ? parseInt(query.pageSize, 10) : 20;
const result = await this.medicationsService.findAll(
user.sub,
query.isActive,
page,
pageSize,
);
return ApiResponseDto.success(result, '查询成功');
}
@Get(':id')
@ApiOperation({ summary: '获取药物详情' })
@ApiResponse({ status: 200, description: '查询成功' })
async findOne(@CurrentUser() user: any, @Param('id') id: string) {
const medication = await this.medicationsService.findOne(id, user.sub);
return ApiResponseDto.success(medication, '查询成功');
}
@Put(':id')
@ApiOperation({ summary: '更新药物信息' })
@ApiResponse({ status: 200, description: '更新成功' })
async update(
@CurrentUser() user: any,
@Param('id') id: string,
@Body() updateDto: UpdateMedicationDto,
) {
const medication = await this.medicationsService.update(
id,
user.sub,
updateDto,
);
// 如果更新了服药时间,重新设置提醒
if (updateDto.medicationTimes) {
await this.reminderService.setupRemindersForMedication(medication);
}
return ApiResponseDto.success(medication, '更新成功');
}
@Delete(':id')
@ApiOperation({ summary: '删除药物' })
@ApiResponse({ status: 200, description: '删除成功' })
async remove(@CurrentUser() user: any, @Param('id') id: string) {
await this.medicationsService.remove(id, user.sub);
// 取消提醒
await this.reminderService.cancelRemindersForMedication(id);
return ApiResponseDto.success(null, '删除成功');
}
@Post(':id/deactivate')
@ApiOperation({ summary: '停用药物' })
@ApiResponse({ status: 200, description: '停用成功' })
async deactivate(@CurrentUser() user: any, @Param('id') id: string) {
const medication = await this.medicationsService.deactivate(id, user.sub);
// 取消提醒
await this.reminderService.cancelRemindersForMedication(id);
return ApiResponseDto.success(medication, '停用成功');
}
@Post(':id/activate')
@ApiOperation({ summary: '激活药物' })
@ApiResponse({ status: 200, description: '激活成功' })
async activate(@CurrentUser() user: any, @Param('id') id: string) {
const medication = await this.medicationsService.activate(id, user.sub);
// 重新设置提醒
await this.reminderService.setupRemindersForMedication(medication);
return ApiResponseDto.success(medication, '激活成功');
}
}

View File

@@ -0,0 +1,56 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { ScheduleModule } from '@nestjs/schedule';
// Models
import { Medication } from './models/medication.model';
import { MedicationRecord } from './models/medication-record.model';
// Controllers
import { MedicationsController } from './medications.controller';
import { MedicationRecordsController } from './medication-records.controller';
import { MedicationStatsController } from './medication-stats.controller';
// Services
import { MedicationsService } from './medications.service';
import { MedicationRecordsService } from './medication-records.service';
import { MedicationStatsService } from './medication-stats.service';
import { RecordGeneratorService } from './services/record-generator.service';
import { StatusUpdaterService } from './services/status-updater.service';
import { MedicationReminderService } from './services/medication-reminder.service';
// Import PushNotificationsModule for reminders
import { PushNotificationsModule } from '../push-notifications/push-notifications.module';
// Import UsersModule for authentication
import { UsersModule } from '../users/users.module';
/**
* 药物管理模块
*/
@Module({
imports: [
SequelizeModule.forFeature([Medication, MedicationRecord]),
ScheduleModule.forRoot(), // 启用定时任务
PushNotificationsModule, // 推送通知功能
UsersModule, // 用户认证服务
],
controllers: [
MedicationsController,
MedicationRecordsController,
MedicationStatsController,
],
providers: [
MedicationsService,
MedicationRecordsService,
MedicationStatsService,
RecordGeneratorService,
StatusUpdaterService,
MedicationReminderService,
],
exports: [
MedicationsService,
MedicationRecordsService,
MedicationStatsService,
],
})
export class MedicationsModule {}

View File

@@ -0,0 +1,195 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Medication } from './models/medication.model';
import { CreateMedicationDto } from './dto/create-medication.dto';
import { UpdateMedicationDto } from './dto/update-medication.dto';
import { v4 as uuidv4 } from 'uuid';
/**
* 药物管理服务
*/
@Injectable()
export class MedicationsService {
private readonly logger = new Logger(MedicationsService.name);
constructor(
@InjectModel(Medication)
private readonly medicationModel: typeof Medication,
) {}
/**
* 创建药物
*/
async create(
userId: string,
createDto: CreateMedicationDto,
): Promise<Medication> {
this.logger.log(`用户 ${userId} 创建药物:${createDto.name}`);
const medication = await this.medicationModel.create({
id: uuidv4(),
userId,
name: createDto.name,
photoUrl: createDto.photoUrl,
form: createDto.form,
dosageValue: createDto.dosageValue,
dosageUnit: createDto.dosageUnit,
timesPerDay: createDto.timesPerDay,
medicationTimes: createDto.medicationTimes,
repeatPattern: createDto.repeatPattern,
startDate: new Date(createDto.startDate),
endDate: createDto.endDate ? new Date(createDto.endDate) : null,
note: createDto.note,
isActive: true,
deleted: false,
});
this.logger.log(`成功创建药物 ${medication.id}`);
return medication;
}
/**
* 获取用户的药物列表
*/
async findAll(
userId: string,
isActive?: boolean,
page: number = 1,
pageSize: number = 20,
): Promise<{ rows: Medication[]; total: number }> {
const where: any = {
userId,
deleted: false,
};
if (isActive !== undefined) {
where.isActive = isActive;
}
const { rows, count } = await this.medicationModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [['createdAt', 'DESC']],
});
return { rows, total: count };
}
/**
* 根据ID获取药物详情
*/
async findOne(id: string, userId: string): Promise<Medication> {
const medication = await this.medicationModel.findOne({
where: {
id,
deleted: false,
},
});
if (!medication) {
throw new NotFoundException('药物不存在');
}
// 验证所有权
if (medication.userId !== userId) {
throw new ForbiddenException('无权访问此药物');
}
return medication;
}
/**
* 更新药物信息
*/
async update(
id: string,
userId: string,
updateDto: UpdateMedicationDto,
): Promise<Medication> {
const medication = await this.findOne(id, userId);
// 更新字段
if (updateDto.name !== undefined) {
medication.name = updateDto.name;
}
if (updateDto.photoUrl !== undefined) {
medication.photoUrl = updateDto.photoUrl;
}
if (updateDto.form !== undefined) {
medication.form = updateDto.form;
}
if (updateDto.dosageValue !== undefined) {
medication.dosageValue = updateDto.dosageValue;
}
if (updateDto.dosageUnit !== undefined) {
medication.dosageUnit = updateDto.dosageUnit;
}
if (updateDto.timesPerDay !== undefined) {
medication.timesPerDay = updateDto.timesPerDay;
}
if (updateDto.medicationTimes !== undefined) {
medication.medicationTimes = updateDto.medicationTimes;
}
if (updateDto.repeatPattern !== undefined) {
medication.repeatPattern = updateDto.repeatPattern;
}
if (updateDto.startDate !== undefined) {
medication.startDate = new Date(updateDto.startDate);
}
if (updateDto.endDate !== undefined) {
medication.endDate = new Date(updateDto.endDate);
}
if (updateDto.note !== undefined) {
medication.note = updateDto.note;
}
await medication.save();
this.logger.log(`成功更新药物 ${id}`);
return medication;
}
/**
* 删除药物(软删除)
*/
async remove(id: string, userId: string): Promise<void> {
const medication = await this.findOne(id, userId);
medication.deleted = true;
await medication.save();
this.logger.log(`成功删除药物 ${id}`);
}
/**
* 停用药物
*/
async deactivate(id: string, userId: string): Promise<Medication> {
const medication = await this.findOne(id, userId);
medication.isActive = false;
await medication.save();
this.logger.log(`成功停用药物 ${id}`);
return medication;
}
/**
* 激活药物
*/
async activate(id: string, userId: string): Promise<Medication> {
const medication = await this.findOne(id, userId);
medication.isActive = true;
await medication.save();
this.logger.log(`成功激活药物 ${id}`);
return medication;
}
}

View File

@@ -0,0 +1,89 @@
import { Column, Model, Table, DataType, BelongsTo, ForeignKey } from 'sequelize-typescript';
import { MedicationStatusEnum } from '../enums/medication-status.enum';
import { Medication } from './medication.model';
/**
* 服药记录模型
*/
@Table({
tableName: 't_medication_records',
underscored: true,
paranoid: false, // 使用软删除字段 deleted 而不是 deletedAt
})
export class MedicationRecord extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '记录唯一标识',
})
declare id: string;
@ForeignKey(() => Medication)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '关联的药物ID',
})
declare medicationId: string;
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.DATE,
allowNull: false,
comment: '计划服药时间UTC时间',
})
declare scheduledTime: Date;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '实际服药时间UTC时间',
})
declare actualTime: Date;
@Column({
type: DataType.STRING(20),
allowNull: false,
comment: '服药状态',
})
declare status: MedicationStatusEnum;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '备注',
})
declare note: string;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '创建时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '更新时间',
})
declare updatedAt: Date;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '软删除标记',
})
declare deleted: boolean;
// 关联关系
@BelongsTo(() => Medication, 'medicationId')
declare medication: Medication;
}

View File

@@ -0,0 +1,140 @@
import { Column, Model, Table, DataType, HasMany } from 'sequelize-typescript';
import { MedicationFormEnum } from '../enums/medication-form.enum';
import { RepeatPatternEnum } from '../enums/repeat-pattern.enum';
import { MedicationRecord } from './medication-record.model';
/**
* 药物信息模型
*/
@Table({
tableName: 't_medications',
underscored: true,
paranoid: false, // 使用软删除字段 deleted 而不是 deletedAt
})
export class Medication extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '药物唯一标识',
})
declare id: string;
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING(100),
allowNull: false,
comment: '药物名称',
})
declare name: string;
@Column({
type: DataType.STRING(255),
allowNull: true,
comment: '药物照片URL',
})
declare photoUrl: string;
@Column({
type: DataType.STRING(20),
allowNull: false,
comment: '药物剂型',
})
declare form: MedicationFormEnum;
@Column({
type: DataType.DECIMAL(10, 2),
allowNull: false,
comment: '剂量数值',
})
declare dosageValue: number;
@Column({
type: DataType.STRING(20),
allowNull: false,
comment: '剂量单位',
})
declare dosageUnit: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
comment: '每日服用次数',
})
declare timesPerDay: number;
@Column({
type: DataType.JSON,
allowNull: false,
comment: '服药时间列表,格式:["08:00", "20:00"]',
})
declare medicationTimes: string[];
@Column({
type: DataType.STRING(20),
allowNull: false,
defaultValue: RepeatPatternEnum.DAILY,
comment: '重复模式',
})
declare repeatPattern: RepeatPatternEnum;
@Column({
type: DataType.DATE,
allowNull: false,
comment: '开始日期UTC时间',
})
declare startDate: Date;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '结束日期UTC时间',
})
declare endDate: Date;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '备注信息',
})
declare note: string;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '是否激活',
})
declare isActive: boolean;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '创建时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '更新时间',
})
declare updatedAt: Date;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '软删除标记',
})
declare deleted: boolean;
// 关联关系
@HasMany(() => MedicationRecord, 'medicationId')
declare records: MedicationRecord[];
}

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;
}
}

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;
}
}

View File

@@ -0,0 +1,101 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectModel } from '@nestjs/sequelize';
import { MedicationRecord } from '../models/medication-record.model';
import { MedicationStatusEnum } from '../enums/medication-status.enum';
import { Op } from 'sequelize';
/**
* 服药记录状态自动更新服务
* 定时任务:将过期的 upcoming 记录更新为 missed
*/
@Injectable()
export class StatusUpdaterService {
private readonly logger = new Logger(StatusUpdaterService.name);
constructor(
@InjectModel(MedicationRecord)
private readonly recordModel: typeof MedicationRecord,
) {}
/**
* 每30分钟执行一次状态更新
* 将已过期但状态仍为 upcoming 的记录更新为 missed
*/
@Cron(CronExpression.EVERY_30_MINUTES)
async updateExpiredRecords(): Promise<void> {
this.logger.log('开始执行服药记录状态更新任务');
try {
const now = new Date();
// 查找所有已过期但状态仍为 upcoming 的记录
const [updatedCount] = await this.recordModel.update(
{
status: MedicationStatusEnum.MISSED,
},
{
where: {
status: MedicationStatusEnum.UPCOMING,
scheduledTime: {
[Op.lt]: now, // 小于当前时间
},
deleted: false,
},
},
);
this.logger.log(
`成功更新 ${updatedCount} 条过期的服药记录状态为 missed`,
);
} catch (error) {
this.logger.error('更新服药记录状态失败', error.stack);
}
}
/**
* 手动触发状态更新(用于测试或特殊情况)
*/
async manualUpdateExpiredRecords(): Promise<number> {
this.logger.log('手动触发服药记录状态更新');
const now = new Date();
const [updatedCount] = await this.recordModel.update(
{
status: MedicationStatusEnum.MISSED,
},
{
where: {
status: MedicationStatusEnum.UPCOMING,
scheduledTime: {
[Op.lt]: now,
},
deleted: false,
},
},
);
this.logger.log(`手动更新了 ${updatedCount} 条记录`);
return updatedCount;
}
/**
* 获取待更新的记录数量(用于监控)
*/
async getPendingUpdateCount(): Promise<number> {
const now = new Date();
const count = await this.recordModel.count({
where: {
status: MedicationStatusEnum.UPCOMING,
scheduledTime: {
[Op.lt]: now,
},
deleted: false,
},
});
return count;
}
}