feat(medications): 新增完整的药物管理和服药提醒功能
实现了包含药物信息管理、服药记录追踪、统计分析、自动状态更新和推送提醒的完整药物管理系统。 核心功能: - 药物 CRUD 操作,支持多种剂型和自定义服药时间 - 惰性生成服药记录策略,查询时才生成当天记录 - 定时任务自动更新过期记录状态(每30分钟) - 服药前15分钟自动推送提醒(每5分钟检查) - 每日/范围/总体统计分析功能 - 完整的 API 文档和数据库建表脚本 技术实现: - 使用 Sequelize ORM 管理 MySQL 数据表 - 集成 @nestjs/schedule 实现定时任务 - 复用现有推送通知系统发送提醒 - 采用软删除和权限验证保障数据安全
This commit is contained in:
946
docs/medications-api-client.md
Normal file
946
docs/medications-api-client.md
Normal file
@@ -0,0 +1,946 @@
|
||||
# 药物管理 API 文档 - 客户端版本
|
||||
|
||||
## 基础信息
|
||||
|
||||
**Base URL**: `https://your-domain.com/api`
|
||||
**认证方式**: Bearer Token (JWT)
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
## 认证说明
|
||||
|
||||
所有接口都需要在 HTTP Header 中携带 JWT Token:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <your_jwt_token>
|
||||
```
|
||||
|
||||
## 数据类型定义
|
||||
|
||||
### MedicationForm(药物剂型)
|
||||
|
||||
```typescript
|
||||
enum MedicationForm {
|
||||
CAPSULE = "capsule", // 胶囊
|
||||
PILL = "pill", // 药片
|
||||
INJECTION = "injection", // 注射
|
||||
SPRAY = "spray", // 喷雾
|
||||
DROP = "drop", // 滴剂
|
||||
SYRUP = "syrup", // 糖浆
|
||||
OTHER = "other", // 其他
|
||||
}
|
||||
```
|
||||
|
||||
### RepeatPattern(重复模式)
|
||||
|
||||
```typescript
|
||||
enum RepeatPattern {
|
||||
DAILY = "daily", // 每日(目前仅支持每日模式)
|
||||
}
|
||||
```
|
||||
|
||||
### MedicationStatus(服药状态)
|
||||
|
||||
```typescript
|
||||
enum MedicationStatus {
|
||||
UPCOMING = "upcoming", // 待服用
|
||||
TAKEN = "taken", // 已服用
|
||||
MISSED = "missed", // 已错过
|
||||
SKIPPED = "skipped", // 已跳过
|
||||
}
|
||||
```
|
||||
|
||||
### Medication(药物信息)
|
||||
|
||||
```typescript
|
||||
interface Medication {
|
||||
id: string; // 药物唯一标识
|
||||
userId: string; // 用户ID
|
||||
name: string; // 药物名称
|
||||
photoUrl?: string; // 药物照片URL(可选)
|
||||
form: MedicationForm; // 药物剂型
|
||||
dosageValue: number; // 剂量数值(如 1、0.5)
|
||||
dosageUnit: string; // 剂量单位(片、粒、毫升等)
|
||||
timesPerDay: number; // 每日服用次数
|
||||
medicationTimes: string[]; // 服药时间列表,格式:["08:00", "12:00", "18:00"]
|
||||
repeatPattern: RepeatPattern; // 重复模式
|
||||
startDate: string; // 开始日期,ISO 8601 格式
|
||||
endDate?: string; // 结束日期(可选),ISO 8601 格式
|
||||
note?: string; // 备注信息(可选)
|
||||
isActive: boolean; // 是否激活
|
||||
deleted: boolean; // 是否已删除
|
||||
createdAt: string; // 创建时间,ISO 8601 格式
|
||||
updatedAt: string; // 更新时间,ISO 8601 格式
|
||||
}
|
||||
```
|
||||
|
||||
### MedicationRecord(服药记录)
|
||||
|
||||
```typescript
|
||||
interface MedicationRecord {
|
||||
id: string; // 记录唯一标识
|
||||
medicationId: string; // 关联的药物ID
|
||||
userId: string; // 用户ID
|
||||
scheduledTime: string; // 计划服药时间,ISO 8601 格式
|
||||
actualTime?: string; // 实际服药时间(可选),ISO 8601 格式
|
||||
status: MedicationStatus; // 服药状态
|
||||
note?: string; // 备注(可选)
|
||||
deleted: boolean; // 是否已删除
|
||||
createdAt: string; // 创建时间,ISO 8601 格式
|
||||
updatedAt: string; // 更新时间,ISO 8601 格式
|
||||
medication?: Medication; // 关联的药物信息(可选,用于联表查询)
|
||||
}
|
||||
```
|
||||
|
||||
### DailyMedicationStats(每日统计)
|
||||
|
||||
```typescript
|
||||
interface DailyMedicationStats {
|
||||
date: string; // 日期,格式:YYYY-MM-DD
|
||||
totalScheduled: number; // 计划服药总次数
|
||||
taken: number; // 已服用次数
|
||||
missed: number; // 已错过次数
|
||||
upcoming: number; // 待服用次数
|
||||
completionRate: number; // 完成率(百分比,0-100,保留两位小数)
|
||||
}
|
||||
```
|
||||
|
||||
### 统一响应格式
|
||||
|
||||
```typescript
|
||||
interface ApiResponse<T> {
|
||||
code: number; // 状态码:200成功,其他为错误
|
||||
message: string; // 响应消息
|
||||
data: T | null; // 响应数据
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 药物管理接口
|
||||
|
||||
### 1. 获取药物列表
|
||||
|
||||
获取当前用户的药物列表。
|
||||
|
||||
**接口**: `GET /medications`
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| -------- | ------- | ---- | ---------------------------------- |
|
||||
| isActive | boolean | 否 | 是否只获取激活的药物,默认获取全部 |
|
||||
| page | number | 否 | 页码,默认 1 |
|
||||
| pageSize | number | 否 | 每页数量,默认 20 |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```http
|
||||
GET /medications?isActive=true&page=1&pageSize=20
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "查询成功",
|
||||
"data": [
|
||||
{
|
||||
"id": "med_001",
|
||||
"userId": "user_123",
|
||||
"name": "阿司匹林",
|
||||
"photoUrl": "https://cdn.example.com/medications/aspirin.jpg",
|
||||
"form": "pill",
|
||||
"dosageValue": 1,
|
||||
"dosageUnit": "片",
|
||||
"timesPerDay": 2,
|
||||
"medicationTimes": ["08:00", "20:00"],
|
||||
"repeatPattern": "daily",
|
||||
"startDate": "2025-01-01T00:00:00.000Z",
|
||||
"endDate": null,
|
||||
"note": "饭后服用",
|
||||
"isActive": true,
|
||||
"deleted": false,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取单个药物详情
|
||||
|
||||
获取指定药物的详细信息。
|
||||
|
||||
**接口**: `GET /medications/{id}`
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---- | ------ | ---- | ------ |
|
||||
| id | string | 是 | 药物ID |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```http
|
||||
GET /medications/med_001
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应**: 同"获取药物列表"中的单个药物对象
|
||||
|
||||
---
|
||||
|
||||
### 3. 创建药物
|
||||
|
||||
创建新的药物信息。
|
||||
|
||||
**接口**: `POST /medications`
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "阿司匹林",
|
||||
"photoUrl": "https://cdn.example.com/medications/aspirin.jpg",
|
||||
"form": "pill",
|
||||
"dosageValue": 1,
|
||||
"dosageUnit": "片",
|
||||
"timesPerDay": 2,
|
||||
"medicationTimes": ["08:00", "20:00"],
|
||||
"repeatPattern": "daily",
|
||||
"startDate": "2025-01-01T00:00:00.000Z",
|
||||
"endDate": null,
|
||||
"note": "饭后服用"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --------------- | -------------- | ---- | ---------------------------------------------------- |
|
||||
| name | string | 是 | 药物名称,最大100字符 |
|
||||
| photoUrl | string | 否 | 药物照片URL |
|
||||
| form | MedicationForm | 是 | 药物剂型 |
|
||||
| dosageValue | number | 是 | 剂量数值,必须大于0 |
|
||||
| dosageUnit | string | 是 | 剂量单位,最大20字符 |
|
||||
| timesPerDay | number | 是 | 每日服用次数,1-10次 |
|
||||
| medicationTimes | string[] | 是 | 服药时间列表,格式:"HH:mm",数量必须等于timesPerDay |
|
||||
| repeatPattern | RepeatPattern | 是 | 重复模式,目前仅支持"daily" |
|
||||
| startDate | string | 是 | 开始日期,ISO 8601格式 |
|
||||
| endDate | string | 否 | 结束日期,ISO 8601格式 |
|
||||
| note | string | 否 | 备注信息 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "创建成功",
|
||||
"data": {
|
||||
"id": "med_001",
|
||||
"userId": "user_123",
|
||||
"name": "阿司匹林",
|
||||
"photoUrl": "https://cdn.example.com/medications/aspirin.jpg",
|
||||
"form": "pill",
|
||||
"dosageValue": 1,
|
||||
"dosageUnit": "片",
|
||||
"timesPerDay": 2,
|
||||
"medicationTimes": ["08:00", "20:00"],
|
||||
"repeatPattern": "daily",
|
||||
"startDate": "2025-01-01T00:00:00.000Z",
|
||||
"endDate": null,
|
||||
"note": "饭后服用",
|
||||
"isActive": true,
|
||||
"deleted": false,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 更新药物信息
|
||||
|
||||
更新指定药物的信息。
|
||||
|
||||
**接口**: `PUT /medications/{id}`
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---- | ------ | ---- | ------ |
|
||||
| id | string | 是 | 药物ID |
|
||||
|
||||
**请求体**: 与创建药物相同,所有字段均为可选
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"dosageValue": 2,
|
||||
"timesPerDay": 3,
|
||||
"medicationTimes": ["08:00", "14:00", "20:00"],
|
||||
"note": "饭后服用,加量"
|
||||
}
|
||||
```
|
||||
|
||||
**响应**: 同"创建药物"响应
|
||||
|
||||
---
|
||||
|
||||
### 5. 删除药物
|
||||
|
||||
删除指定药物(软删除)。
|
||||
|
||||
**接口**: `DELETE /medications/{id}`
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---- | ------ | ---- | ------ |
|
||||
| id | string | 是 | 药物ID |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```http
|
||||
DELETE /medications/med_001
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "删除成功",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 停用药物
|
||||
|
||||
停用指定药物(将 isActive 设为 false)。
|
||||
|
||||
**接口**: `POST /medications/{id}/deactivate`
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---- | ------ | ---- | ------ |
|
||||
| id | string | 是 | 药物ID |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```http
|
||||
POST /medications/med_001/deactivate
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "停用成功",
|
||||
"data": {
|
||||
"id": "med_001",
|
||||
"isActive": false,
|
||||
"updatedAt": "2025-01-15T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 服药记录接口
|
||||
|
||||
### 1. 获取服药记录
|
||||
|
||||
查询服药记录,支持多种筛选条件。
|
||||
|
||||
**接口**: `GET /medication-records`
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ------------ | ---------------- | ---- | -------------------------------------- |
|
||||
| date | string | 否 | 指定日期(YYYY-MM-DD),不填则返回所有 |
|
||||
| startDate | string | 否 | 开始日期(YYYY-MM-DD) |
|
||||
| endDate | string | 否 | 结束日期(YYYY-MM-DD) |
|
||||
| medicationId | string | 否 | 指定药物ID |
|
||||
| status | MedicationStatus | 否 | 状态筛选 |
|
||||
|
||||
**重要说明**:
|
||||
|
||||
- 首次查询某天的记录时,后端会自动生成该天的服药记录(惰性生成)
|
||||
- 建议使用 `date` 参数查询特定日期,效率更高
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```http
|
||||
GET /medication-records?date=2025-01-15&status=upcoming
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "查询成功",
|
||||
"data": [
|
||||
{
|
||||
"id": "record_001",
|
||||
"medicationId": "med_001",
|
||||
"userId": "user_123",
|
||||
"scheduledTime": "2025-01-15T08:00:00.000Z",
|
||||
"actualTime": null,
|
||||
"status": "upcoming",
|
||||
"note": null,
|
||||
"deleted": false,
|
||||
"createdAt": "2025-01-15T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-15T00:00:00.000Z",
|
||||
"medication": {
|
||||
"id": "med_001",
|
||||
"name": "阿司匹林",
|
||||
"form": "pill",
|
||||
"dosageValue": 1,
|
||||
"dosageUnit": "片"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取今日服药记录
|
||||
|
||||
快捷获取今天的所有服药记录。
|
||||
|
||||
**接口**: `GET /medication-records/today`
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```http
|
||||
GET /medication-records/today
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应**: 同"获取服药记录"响应
|
||||
|
||||
---
|
||||
|
||||
### 3. 获取服药记录详情
|
||||
|
||||
获取指定服药记录的详细信息。
|
||||
|
||||
**接口**: `GET /medication-records/{id}`
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---- | ------ | ---- | ------ |
|
||||
| id | string | 是 | 记录ID |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```http
|
||||
GET /medication-records/record_001
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应**: 同"获取服药记录"中的单个记录对象
|
||||
|
||||
---
|
||||
|
||||
### 4. 标记为已服用
|
||||
|
||||
将服药记录标记为已服用。
|
||||
|
||||
**接口**: `POST /medication-records/{id}/take`
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---- | ------ | ---- | ------ |
|
||||
| id | string | 是 | 记录ID |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"actualTime": "2025-01-15T08:10:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ---------- | ------ | ---- | ---------------------------------------------- |
|
||||
| actualTime | string | 否 | 实际服药时间,ISO 8601格式,不填则使用当前时间 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "已记录服药",
|
||||
"data": {
|
||||
"id": "record_001",
|
||||
"medicationId": "med_001",
|
||||
"userId": "user_123",
|
||||
"scheduledTime": "2025-01-15T08:00:00.000Z",
|
||||
"actualTime": "2025-01-15T08:10:00.000Z",
|
||||
"status": "taken",
|
||||
"note": null,
|
||||
"deleted": false,
|
||||
"createdAt": "2025-01-15T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-15T08:10:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 跳过服药
|
||||
|
||||
跳过本次服药,不计入已错过。
|
||||
|
||||
**接口**: `POST /medication-records/{id}/skip`
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---- | ------ | ---- | ------ |
|
||||
| id | string | 是 | 记录ID |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"note": "今天身体不适,暂时跳过"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ---- | ------ | ---- | ------------ |
|
||||
| note | string | 否 | 跳过原因备注 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "已跳过服药",
|
||||
"data": {
|
||||
"id": "record_001",
|
||||
"medicationId": "med_001",
|
||||
"userId": "user_123",
|
||||
"scheduledTime": "2025-01-15T08:00:00.000Z",
|
||||
"actualTime": null,
|
||||
"status": "skipped",
|
||||
"note": "今天身体不适,暂时跳过",
|
||||
"deleted": false,
|
||||
"createdAt": "2025-01-15T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-15T08:15:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 更新服药记录
|
||||
|
||||
更新服药记录的状态和信息。
|
||||
|
||||
**接口**: `PUT /medication-records/{id}`
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---- | ------ | ---- | ------ |
|
||||
| id | string | 是 | 记录ID |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "taken",
|
||||
"actualTime": "2025-01-15T08:15:00.000Z",
|
||||
"note": "延迟服用"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**: 所有字段均为可选
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ---------- | ---------------- | ---- | -------------------------- |
|
||||
| status | MedicationStatus | 否 | 服药状态 |
|
||||
| actualTime | string | 否 | 实际服药时间,ISO 8601格式 |
|
||||
| note | string | 否 | 备注信息 |
|
||||
|
||||
**响应**: 同"标记为已服用"响应
|
||||
|
||||
---
|
||||
|
||||
## 统计接口
|
||||
|
||||
### 1. 获取每日统计
|
||||
|
||||
获取指定日期的服药统计数据。
|
||||
|
||||
**接口**: `GET /medication-stats/daily`
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---- | ------ | ---- | ---------------------- |
|
||||
| date | string | 是 | 日期,格式:YYYY-MM-DD |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```http
|
||||
GET /medication-stats/daily?date=2025-01-15
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "查询成功",
|
||||
"data": {
|
||||
"date": "2025-01-15",
|
||||
"totalScheduled": 6,
|
||||
"taken": 4,
|
||||
"missed": 1,
|
||||
"upcoming": 1,
|
||||
"completionRate": 66.67
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取日期范围统计
|
||||
|
||||
获取指定日期范围内每天的统计数据。
|
||||
|
||||
**接口**: `GET /medication-stats/range`
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ------ | ---- | -------------------------- |
|
||||
| startDate | string | 是 | 开始日期,格式:YYYY-MM-DD |
|
||||
| endDate | string | 是 | 结束日期,格式:YYYY-MM-DD |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```http
|
||||
GET /medication-stats/range?startDate=2025-01-01&endDate=2025-01-15
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "查询成功",
|
||||
"data": [
|
||||
{
|
||||
"date": "2025-01-15",
|
||||
"totalScheduled": 6,
|
||||
"taken": 4,
|
||||
"missed": 1,
|
||||
"upcoming": 1,
|
||||
"completionRate": 66.67
|
||||
},
|
||||
{
|
||||
"date": "2025-01-14",
|
||||
"totalScheduled": 6,
|
||||
"taken": 6,
|
||||
"missed": 0,
|
||||
"upcoming": 0,
|
||||
"completionRate": 100.0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 获取总体统计
|
||||
|
||||
获取用户的总体服药统计概览。
|
||||
|
||||
**接口**: `GET /medication-stats/overall`
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```http
|
||||
GET /medication-stats/overall
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "查询成功",
|
||||
"data": {
|
||||
"totalMedications": 5,
|
||||
"totalRecords": 120,
|
||||
"completionRate": 85.5,
|
||||
"streak": 7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ---------------- | ------ | ---------------------------------- |
|
||||
| totalMedications | number | 药物总数 |
|
||||
| totalRecords | number | 服药记录总数 |
|
||||
| completionRate | number | 总体完成率(百分比,保留两位小数) |
|
||||
| streak | number | 连续完成天数 |
|
||||
|
||||
---
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| 错误码 | 说明 |
|
||||
| ------ | ------------------------- |
|
||||
| 200 | 操作成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权,Token无效或已过期 |
|
||||
| 403 | 权限不足,无法访问该资源 |
|
||||
| 404 | 资源不存在 |
|
||||
| 409 | 资源冲突(如重复创建) |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
**错误响应格式**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "请求参数错误:药物名称不能为空",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 业务逻辑说明
|
||||
|
||||
### 1. 服药记录的惰性生成
|
||||
|
||||
- 服药记录不会在创建药物时预先生成
|
||||
- 当首次查询某天的记录时,后端会自动生成该天的所有服药记录
|
||||
- 生成规则:根据药物的 `timesPerDay` 和 `medicationTimes` 生成对应数量的记录
|
||||
|
||||
**示例**:
|
||||
|
||||
- 如果药物设置为每天2次,服药时间为 08:00 和 20:00
|
||||
- 首次查询 2025-01-15 的记录时,会自动生成该天 08:00 和 20:00 两条记录
|
||||
|
||||
### 2. 状态自动更新
|
||||
|
||||
- 后端每30分钟运行一次定时任务
|
||||
- 自动将已过期的 `upcoming` 状态更新为 `missed`
|
||||
- 客户端无需手动更新状态
|
||||
|
||||
**示例**:
|
||||
|
||||
- 08:00 的服药记录,到了 08:30 还未标记为已服用
|
||||
- 定时任务会自动将其状态改为 `missed`
|
||||
|
||||
### 3. 推送提醒
|
||||
|
||||
- 后端每5分钟检查一次即将到来的服药时间(15-20分钟后)
|
||||
- 自动发送推送通知提醒用户服药
|
||||
- 客户端需要正确配置推送通知权限
|
||||
|
||||
### 4. 时区处理
|
||||
|
||||
- **重要**:所有时间字段使用 UTC 时间存储和传输
|
||||
- 客户端需要:
|
||||
1. 发送请求时:将本地时间转换为 UTC
|
||||
2. 接收响应时:将 UTC 时间转换为本地时间显示
|
||||
|
||||
**示例代码(JavaScript)**:
|
||||
|
||||
```javascript
|
||||
// 本地时间转 UTC
|
||||
const localTime = new Date("2025-01-15 08:00");
|
||||
const utcTime = localTime.toISOString(); // "2025-01-15T00:00:00.000Z" (假设时区为UTC+8)
|
||||
|
||||
// UTC 转本地时间
|
||||
const utcTime = "2025-01-15T00:00:00.000Z";
|
||||
const localTime = new Date(utcTime);
|
||||
console.log(localTime.toLocaleString()); // "2025-01-15 08:00:00" (UTC+8)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践建议
|
||||
|
||||
### 1. 获取今日服药记录
|
||||
|
||||
推荐使用专用接口而非通用查询:
|
||||
|
||||
```http
|
||||
✅ 推荐
|
||||
GET /medication-records/today
|
||||
|
||||
❌ 不推荐
|
||||
GET /medication-records?date=2025-01-15
|
||||
```
|
||||
|
||||
### 2. 批量操作
|
||||
|
||||
如果需要更新多个记录,建议单独调用每个接口,后端暂不支持批量操作。
|
||||
|
||||
### 3. 错误处理
|
||||
|
||||
建议在客户端统一处理 API 错误:
|
||||
|
||||
```javascript
|
||||
async function callApi(url, options) {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 200) {
|
||||
// 显示错误提示
|
||||
showError(data.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.data;
|
||||
} catch (error) {
|
||||
// 网络错误
|
||||
showError("网络连接失败,请稍后重试");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 数据缓存
|
||||
|
||||
建议在客户端缓存以下数据以提升性能:
|
||||
|
||||
- 药物列表(有变更时刷新)
|
||||
- 今日服药记录(实时更新)
|
||||
- 统计数据(按天缓存)
|
||||
|
||||
### 5. 定时刷新
|
||||
|
||||
建议定时刷新今日服药记录以获取最新状态:
|
||||
|
||||
```javascript
|
||||
// 每5分钟刷新一次今日记录
|
||||
setInterval(
|
||||
async () => {
|
||||
const records = await getTodayRecords();
|
||||
updateUI(records);
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整使用流程示例
|
||||
|
||||
### 1. 创建药物并查看今日记录
|
||||
|
||||
```javascript
|
||||
// Step 1: 创建药物
|
||||
const medication = await createMedication({
|
||||
name: "阿司匹林",
|
||||
form: "pill",
|
||||
dosageValue: 1,
|
||||
dosageUnit: "片",
|
||||
timesPerDay: 2,
|
||||
medicationTimes: ["08:00", "20:00"],
|
||||
repeatPattern: "daily",
|
||||
startDate: new Date().toISOString(),
|
||||
note: "饭后服用",
|
||||
});
|
||||
|
||||
// Step 2: 获取今日服药记录(会自动生成)
|
||||
const todayRecords = await getTodayRecords();
|
||||
|
||||
// Step 3: 显示记录列表
|
||||
showRecordsList(todayRecords);
|
||||
```
|
||||
|
||||
### 2. 标记服用并查看统计
|
||||
|
||||
```javascript
|
||||
// Step 1: 标记为已服用
|
||||
await takeMedication(recordId, {
|
||||
actualTime: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Step 2: 刷新今日记录
|
||||
const todayRecords = await getTodayRecords();
|
||||
|
||||
// Step 3: 获取今日统计
|
||||
const todayStats = await getDailyStats(formatDate(new Date()));
|
||||
|
||||
// Step 4: 显示完成率
|
||||
showCompletionRate(todayStats.completionRate);
|
||||
```
|
||||
|
||||
### 3. 查看历史统计
|
||||
|
||||
```javascript
|
||||
// 获取最近7天的统计
|
||||
const startDate = formatDate(daysAgo(7));
|
||||
const endDate = formatDate(new Date());
|
||||
|
||||
const rangeStats = await getRangeStats(startDate, endDate);
|
||||
|
||||
// 绘制趋势图表
|
||||
drawChart(rangeStats);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有疑问或需要帮助,请联系:
|
||||
|
||||
- **技术文档**: 本文档
|
||||
- **API 基础URL**: https://your-domain.com/api
|
||||
- **更新日期**: 2025-01-15
|
||||
- **文档版本**: v1.0
|
||||
|
||||
---
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0 (2025-01-15)
|
||||
|
||||
- 初始版本发布
|
||||
- 完整的药物管理功能
|
||||
- 服药记录追踪
|
||||
- 统计分析功能
|
||||
- 自动状态更新
|
||||
- 推送提醒支持
|
||||
91
sql-scripts/medications-tables-create.sql
Normal file
91
sql-scripts/medications-tables-create.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
-- 药物管理相关表创建脚本
|
||||
-- 创建时间: 2025-01-15
|
||||
-- 说明: 包含药物信息表和服药记录表
|
||||
|
||||
-- ==========================================
|
||||
-- 1. 药物信息表 (t_medications)
|
||||
-- ==========================================
|
||||
CREATE TABLE IF NOT EXISTS `t_medications` (
|
||||
`id` varchar(50) NOT NULL COMMENT '药物唯一标识',
|
||||
`user_id` varchar(50) NOT NULL COMMENT '用户ID',
|
||||
`name` varchar(100) NOT NULL COMMENT '药物名称',
|
||||
`photo_url` varchar(255) DEFAULT NULL COMMENT '药物照片URL',
|
||||
`form` varchar(20) NOT NULL COMMENT '药物剂型:capsule/pill/injection/spray/drop/syrup/other',
|
||||
`dosage_value` decimal(10,2) NOT NULL COMMENT '剂量数值',
|
||||
`dosage_unit` varchar(20) NOT NULL COMMENT '剂量单位(片、粒、毫升等)',
|
||||
`times_per_day` int NOT NULL COMMENT '每日服用次数',
|
||||
`medication_times` json NOT NULL COMMENT '服药时间列表,格式:["08:00", "20:00"]',
|
||||
`repeat_pattern` varchar(20) NOT NULL DEFAULT 'daily' COMMENT '重复模式:daily/weekly/custom',
|
||||
`start_date` datetime NOT NULL COMMENT '开始日期(UTC时间)',
|
||||
`end_date` datetime DEFAULT NULL COMMENT '结束日期(UTC时间,可选)',
|
||||
`note` text COMMENT '备注信息',
|
||||
`is_active` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否激活:1=激活,0=停用',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '软删除标记:0=未删除,1=已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_is_active` (`is_active`),
|
||||
KEY `idx_user_active` (`user_id`, `is_active`),
|
||||
KEY `idx_deleted` (`deleted`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='药物信息表';
|
||||
|
||||
-- ==========================================
|
||||
-- 2. 服药记录表 (t_medication_records)
|
||||
-- ==========================================
|
||||
CREATE TABLE IF NOT EXISTS `t_medication_records` (
|
||||
`id` varchar(50) NOT NULL COMMENT '记录唯一标识',
|
||||
`medication_id` varchar(50) NOT NULL COMMENT '关联的药物ID',
|
||||
`user_id` varchar(50) NOT NULL COMMENT '用户ID',
|
||||
`scheduled_time` datetime NOT NULL COMMENT '计划服药时间(UTC时间)',
|
||||
`actual_time` datetime DEFAULT NULL COMMENT '实际服药时间(UTC时间)',
|
||||
`status` varchar(20) NOT NULL COMMENT '服药状态:upcoming/taken/missed/skipped',
|
||||
`note` text COMMENT '备注',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` tinyint(1) NOT NULL DEFAULT 0 COMMENT '软删除标记:0=未删除,1=已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_medication_id` (`medication_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_scheduled_time` (`scheduled_time`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_user_scheduled` (`user_id`, `scheduled_time`),
|
||||
KEY `idx_user_date_status` (`user_id`, `scheduled_time`, `status`),
|
||||
KEY `idx_deleted` (`deleted`),
|
||||
CONSTRAINT `fk_medication_records_medication`
|
||||
FOREIGN KEY (`medication_id`)
|
||||
REFERENCES `t_medications` (`id`)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='服药记录表';
|
||||
|
||||
-- ==========================================
|
||||
-- 3. 索引优化说明
|
||||
-- ==========================================
|
||||
-- idx_user_id: 用于快速查询用户的所有药物
|
||||
-- idx_is_active: 用于筛选激活状态的药物
|
||||
-- idx_user_active: 复合索引,优化查询用户的激活药物
|
||||
-- idx_user_scheduled: 复合索引,优化按日期查询用户的服药记录
|
||||
-- idx_user_date_status: 复合索引,优化统计查询
|
||||
-- idx_status: 用于定时任务批量更新状态
|
||||
|
||||
-- ==========================================
|
||||
-- 4. 数据约束说明
|
||||
-- ==========================================
|
||||
-- 1. form 字段只能是预定义的剂型枚举值
|
||||
-- 2. repeat_pattern 当前只支持 'daily'
|
||||
-- 3. status 字段只能是 upcoming/taken/missed/skipped
|
||||
-- 4. medication_times 必须是有效的 JSON 数组
|
||||
-- 5. 外键约束确保记录关联的药物存在
|
||||
|
||||
-- ==========================================
|
||||
-- 5. 使用示例
|
||||
-- ==========================================
|
||||
-- 插入药物示例:
|
||||
-- INSERT INTO t_medications (id, user_id, name, form, dosage_value, dosage_unit,
|
||||
-- times_per_day, medication_times, repeat_pattern, start_date)
|
||||
-- VALUES ('med_001', 'user_123', 'Metformin', 'capsule', 1, '粒',
|
||||
-- 2, '["08:00", "20:00"]', 'daily', '2025-01-01 00:00:00');
|
||||
|
||||
-- 插入服药记录示例:
|
||||
-- INSERT INTO t_medication_records (id, medication_id, user_id, scheduled_time, status)
|
||||
-- VALUES ('record_001', 'med_001', 'user_123', '2025-01-15 08:00:00', 'upcoming');
|
||||
@@ -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