feat(medications): 新增完整的药物管理和服药提醒功能
实现了包含药物信息管理、服药记录追踪、统计分析、自动状态更新和推送提醒的完整药物管理系统。 核心功能: - 药物 CRUD 操作,支持多种剂型和自定义服药时间 - 惰性生成服药记录策略,查询时才生成当天记录 - 定时任务自动更新过期记录状态(每30分钟) - 服药前15分钟自动推送提醒(每5分钟检查) - 每日/范围/总体统计分析功能 - 完整的 API 文档和数据库建表脚本 技术实现: - 使用 Sequelize ORM 管理 MySQL 数据表 - 集成 @nestjs/schedule 实现定时任务 - 复用现有推送通知系统发送提醒 - 采用软删除和权限验证保障数据安全
This commit is contained in:
@@ -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
379
src/medications/README.md
Normal 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)
|
||||
104
src/medications/dto/create-medication.dto.ts
Normal file
104
src/medications/dto/create-medication.dto.ts
Normal 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;
|
||||
}
|
||||
28
src/medications/dto/medication-query.dto.ts
Normal file
28
src/medications/dto/medication-query.dto.ts
Normal 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;
|
||||
}
|
||||
53
src/medications/dto/medication-record-query.dto.ts
Normal file
53
src/medications/dto/medication-record-query.dto.ts
Normal 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;
|
||||
}
|
||||
59
src/medications/dto/medication-stats.dto.ts
Normal file
59
src/medications/dto/medication-stats.dto.ts
Normal 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;
|
||||
}
|
||||
16
src/medications/dto/skip-medication.dto.ts
Normal file
16
src/medications/dto/skip-medication.dto.ts
Normal 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;
|
||||
}
|
||||
16
src/medications/dto/take-medication.dto.ts
Normal file
16
src/medications/dto/take-medication.dto.ts
Normal 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;
|
||||
}
|
||||
35
src/medications/dto/update-medication-record.dto.ts
Normal file
35
src/medications/dto/update-medication-record.dto.ts
Normal 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;
|
||||
}
|
||||
8
src/medications/dto/update-medication.dto.ts
Normal file
8
src/medications/dto/update-medication.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateMedicationDto } from './create-medication.dto';
|
||||
|
||||
/**
|
||||
* 更新药物 DTO
|
||||
* 继承创建 DTO,所有字段都是可选的
|
||||
*/
|
||||
export class UpdateMedicationDto extends PartialType(CreateMedicationDto) {}
|
||||
19
src/medications/enums/medication-form.enum.ts
Normal file
19
src/medications/enums/medication-form.enum.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 药物剂型枚举
|
||||
*/
|
||||
export enum MedicationFormEnum {
|
||||
/** 胶囊 */
|
||||
CAPSULE = 'capsule',
|
||||
/** 药片 */
|
||||
PILL = 'pill',
|
||||
/** 注射 */
|
||||
INJECTION = 'injection',
|
||||
/** 喷雾 */
|
||||
SPRAY = 'spray',
|
||||
/** 滴剂 */
|
||||
DROP = 'drop',
|
||||
/** 糖浆 */
|
||||
SYRUP = 'syrup',
|
||||
/** 其他 */
|
||||
OTHER = 'other',
|
||||
}
|
||||
13
src/medications/enums/medication-status.enum.ts
Normal file
13
src/medications/enums/medication-status.enum.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 服药状态枚举
|
||||
*/
|
||||
export enum MedicationStatusEnum {
|
||||
/** 待服用 */
|
||||
UPCOMING = 'upcoming',
|
||||
/** 已服用 */
|
||||
TAKEN = 'taken',
|
||||
/** 已错过 */
|
||||
MISSED = 'missed',
|
||||
/** 已跳过 */
|
||||
SKIPPED = 'skipped',
|
||||
}
|
||||
11
src/medications/enums/repeat-pattern.enum.ts
Normal file
11
src/medications/enums/repeat-pattern.enum.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 重复模式枚举
|
||||
*/
|
||||
export enum RepeatPatternEnum {
|
||||
/** 每日 */
|
||||
DAILY = 'daily',
|
||||
/** 每周 */
|
||||
WEEKLY = 'weekly',
|
||||
/** 自定义 */
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
102
src/medications/medication-records.controller.ts
Normal file
102
src/medications/medication-records.controller.ts
Normal 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, '更新成功');
|
||||
}
|
||||
}
|
||||
229
src/medications/medication-records.service.ts
Normal file
229
src/medications/medication-records.service.ts
Normal 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']],
|
||||
});
|
||||
}
|
||||
}
|
||||
53
src/medications/medication-stats.controller.ts
Normal file
53
src/medications/medication-stats.controller.ts
Normal 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, '查询成功');
|
||||
}
|
||||
}
|
||||
145
src/medications/medication-stats.service.ts
Normal file
145
src/medications/medication-stats.service.ts
Normal 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, // 需要计算连续天数
|
||||
};
|
||||
}
|
||||
}
|
||||
134
src/medications/medications.controller.ts
Normal file
134
src/medications/medications.controller.ts
Normal 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, '激活成功');
|
||||
}
|
||||
}
|
||||
56
src/medications/medications.module.ts
Normal file
56
src/medications/medications.module.ts
Normal 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 {}
|
||||
195
src/medications/medications.service.ts
Normal file
195
src/medications/medications.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
89
src/medications/models/medication-record.model.ts
Normal file
89
src/medications/models/medication-record.model.ts
Normal 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;
|
||||
}
|
||||
140
src/medications/models/medication.model.ts
Normal file
140
src/medications/models/medication.model.ts
Normal 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[];
|
||||
}
|
||||
211
src/medications/services/medication-reminder.service.ts
Normal file
211
src/medications/services/medication-reminder.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
229
src/medications/services/record-generator.service.ts
Normal file
229
src/medications/services/record-generator.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
101
src/medications/services/status-updater.service.ts
Normal file
101
src/medications/services/status-updater.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user