diff --git a/docs/medications-api-client.md b/docs/medications-api-client.md new file mode 100644 index 0000000..928c0e7 --- /dev/null +++ b/docs/medications-api-client.md @@ -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 +``` + +## 数据类型定义 + +### 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 { + 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 +``` + +**响应示例**: + +```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 +``` + +**响应**: 同"获取药物列表"中的单个药物对象 + +--- + +### 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 +``` + +**响应示例**: + +```json +{ + "code": 200, + "message": "删除成功", + "data": null +} +``` + +--- + +### 6. 停用药物 + +停用指定药物(将 isActive 设为 false)。 + +**接口**: `POST /medications/{id}/deactivate` + +**路径参数**: + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ------ | ---- | ------ | +| id | string | 是 | 药物ID | + +**请求示例**: + +```http +POST /medications/med_001/deactivate +Authorization: Bearer +``` + +**响应示例**: + +```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 +``` + +**响应示例**: + +```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 +``` + +**响应**: 同"获取服药记录"响应 + +--- + +### 3. 获取服药记录详情 + +获取指定服药记录的详细信息。 + +**接口**: `GET /medication-records/{id}` + +**路径参数**: + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ------ | ---- | ------ | +| id | string | 是 | 记录ID | + +**请求示例**: + +```http +GET /medication-records/record_001 +Authorization: Bearer +``` + +**响应**: 同"获取服药记录"中的单个记录对象 + +--- + +### 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 +``` + +**响应示例**: + +```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 +``` + +**响应示例**: + +```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 +``` + +**响应示例**: + +```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) + +- 初始版本发布 +- 完整的药物管理功能 +- 服药记录追踪 +- 统计分析功能 +- 自动状态更新 +- 推送提醒支持 diff --git a/sql-scripts/medications-tables-create.sql b/sql-scripts/medications-tables-create.sql new file mode 100644 index 0000000..99383e0 --- /dev/null +++ b/sql-scripts/medications-tables-create.sql @@ -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'); \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 5f5afda..5f9ff37 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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], diff --git a/src/medications/README.md b/src/medications/README.md new file mode 100644 index 0000000..a741441 --- /dev/null +++ b/src/medications/README.md @@ -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) diff --git a/src/medications/dto/create-medication.dto.ts b/src/medications/dto/create-medication.dto.ts new file mode 100644 index 0000000..798b9b3 --- /dev/null +++ b/src/medications/dto/create-medication.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/medications/dto/medication-query.dto.ts b/src/medications/dto/medication-query.dto.ts new file mode 100644 index 0000000..4f7d716 --- /dev/null +++ b/src/medications/dto/medication-query.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/medications/dto/medication-record-query.dto.ts b/src/medications/dto/medication-record-query.dto.ts new file mode 100644 index 0000000..8bb2055 --- /dev/null +++ b/src/medications/dto/medication-record-query.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/medications/dto/medication-stats.dto.ts b/src/medications/dto/medication-stats.dto.ts new file mode 100644 index 0000000..f0d38ce --- /dev/null +++ b/src/medications/dto/medication-stats.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/medications/dto/skip-medication.dto.ts b/src/medications/dto/skip-medication.dto.ts new file mode 100644 index 0000000..8d532d4 --- /dev/null +++ b/src/medications/dto/skip-medication.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/medications/dto/take-medication.dto.ts b/src/medications/dto/take-medication.dto.ts new file mode 100644 index 0000000..2f29db0 --- /dev/null +++ b/src/medications/dto/take-medication.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/medications/dto/update-medication-record.dto.ts b/src/medications/dto/update-medication-record.dto.ts new file mode 100644 index 0000000..c252670 --- /dev/null +++ b/src/medications/dto/update-medication-record.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/medications/dto/update-medication.dto.ts b/src/medications/dto/update-medication.dto.ts new file mode 100644 index 0000000..608c9f6 --- /dev/null +++ b/src/medications/dto/update-medication.dto.ts @@ -0,0 +1,8 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateMedicationDto } from './create-medication.dto'; + +/** + * 更新药物 DTO + * 继承创建 DTO,所有字段都是可选的 + */ +export class UpdateMedicationDto extends PartialType(CreateMedicationDto) {} \ No newline at end of file diff --git a/src/medications/enums/medication-form.enum.ts b/src/medications/enums/medication-form.enum.ts new file mode 100644 index 0000000..633bb3d --- /dev/null +++ b/src/medications/enums/medication-form.enum.ts @@ -0,0 +1,19 @@ +/** + * 药物剂型枚举 + */ +export enum MedicationFormEnum { + /** 胶囊 */ + CAPSULE = 'capsule', + /** 药片 */ + PILL = 'pill', + /** 注射 */ + INJECTION = 'injection', + /** 喷雾 */ + SPRAY = 'spray', + /** 滴剂 */ + DROP = 'drop', + /** 糖浆 */ + SYRUP = 'syrup', + /** 其他 */ + OTHER = 'other', +} \ No newline at end of file diff --git a/src/medications/enums/medication-status.enum.ts b/src/medications/enums/medication-status.enum.ts new file mode 100644 index 0000000..84aa368 --- /dev/null +++ b/src/medications/enums/medication-status.enum.ts @@ -0,0 +1,13 @@ +/** + * 服药状态枚举 + */ +export enum MedicationStatusEnum { + /** 待服用 */ + UPCOMING = 'upcoming', + /** 已服用 */ + TAKEN = 'taken', + /** 已错过 */ + MISSED = 'missed', + /** 已跳过 */ + SKIPPED = 'skipped', +} \ No newline at end of file diff --git a/src/medications/enums/repeat-pattern.enum.ts b/src/medications/enums/repeat-pattern.enum.ts new file mode 100644 index 0000000..48da63a --- /dev/null +++ b/src/medications/enums/repeat-pattern.enum.ts @@ -0,0 +1,11 @@ +/** + * 重复模式枚举 + */ +export enum RepeatPatternEnum { + /** 每日 */ + DAILY = 'daily', + /** 每周 */ + WEEKLY = 'weekly', + /** 自定义 */ + CUSTOM = 'custom', +} \ No newline at end of file diff --git a/src/medications/medication-records.controller.ts b/src/medications/medication-records.controller.ts new file mode 100644 index 0000000..6799a82 --- /dev/null +++ b/src/medications/medication-records.controller.ts @@ -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, '更新成功'); + } +} \ No newline at end of file diff --git a/src/medications/medication-records.service.ts b/src/medications/medication-records.service.ts new file mode 100644 index 0000000..681a845 --- /dev/null +++ b/src/medications/medication-records.service.ts @@ -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 { + // 如果指定了日期,确保该日期的记录存在 + 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 { + 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 { + 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 { + 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 { + 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 { + 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']], + }); + } +} \ No newline at end of file diff --git a/src/medications/medication-stats.controller.ts b/src/medications/medication-stats.controller.ts new file mode 100644 index 0000000..537c915 --- /dev/null +++ b/src/medications/medication-stats.controller.ts @@ -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, '查询成功'); + } +} \ No newline at end of file diff --git a/src/medications/medication-stats.service.ts b/src/medications/medication-stats.service.ts new file mode 100644 index 0000000..5525333 --- /dev/null +++ b/src/medications/medication-stats.service.ts @@ -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 { + // 确保该日期的记录存在 + 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 { + 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 { + 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 { + 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, // 需要计算连续天数 + }; + } +} \ No newline at end of file diff --git a/src/medications/medications.controller.ts b/src/medications/medications.controller.ts new file mode 100644 index 0000000..9df9acb --- /dev/null +++ b/src/medications/medications.controller.ts @@ -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, '激活成功'); + } +} \ No newline at end of file diff --git a/src/medications/medications.module.ts b/src/medications/medications.module.ts new file mode 100644 index 0000000..447847c --- /dev/null +++ b/src/medications/medications.module.ts @@ -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 {} \ No newline at end of file diff --git a/src/medications/medications.service.ts b/src/medications/medications.service.ts new file mode 100644 index 0000000..5488ceb --- /dev/null +++ b/src/medications/medications.service.ts @@ -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 { + 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 { + 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 { + 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 { + const medication = await this.findOne(id, userId); + + medication.deleted = true; + await medication.save(); + + this.logger.log(`成功删除药物 ${id}`); + } + + /** + * 停用药物 + */ + async deactivate(id: string, userId: string): Promise { + 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 { + const medication = await this.findOne(id, userId); + + medication.isActive = true; + await medication.save(); + + this.logger.log(`成功激活药物 ${id}`); + return medication; + } +} \ No newline at end of file diff --git a/src/medications/models/medication-record.model.ts b/src/medications/models/medication-record.model.ts new file mode 100644 index 0000000..4ac7ca5 --- /dev/null +++ b/src/medications/models/medication-record.model.ts @@ -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; +} \ No newline at end of file diff --git a/src/medications/models/medication.model.ts b/src/medications/models/medication.model.ts new file mode 100644 index 0000000..65efa65 --- /dev/null +++ b/src/medications/models/medication.model.ts @@ -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[]; +} \ No newline at end of file diff --git a/src/medications/services/medication-reminder.service.ts b/src/medications/services/medication-reminder.service.ts new file mode 100644 index 0000000..c3fd1b5 --- /dev/null +++ b/src/medications/services/medication-reminder.service.ts @@ -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 { + 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(); + 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 { + 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 { + 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 { + this.logger.log(`为药物 ${medication.id} 设置提醒(由定时任务自动触发)`); + // 实际的提醒由定时任务 checkAndSendReminders 自动处理 + // 这里只需要确保药物处于激活状态 + } + + /** + * 取消药物的所有提醒(停用或删除药物时调用) + */ + async cancelRemindersForMedication(medicationId: string): Promise { + this.logger.log(`取消药物 ${medicationId} 的所有提醒`); + // 由于提醒是基于记录的状态和药物的激活状态动态生成的 + // 所以只需要确保药物被停用或删除,定时任务就不会再发送提醒 + } + + /** + * 获取用户今天的待提醒数量 + */ + async getTodayReminderCount(userId: string): Promise { + 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; + } +} \ No newline at end of file diff --git a/src/medications/services/record-generator.service.ts b/src/medications/services/record-generator.service.ts new file mode 100644 index 0000000..1a53b2a --- /dev/null +++ b/src/medications/services/record-generator.service.ts @@ -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 { + 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 { + // 检查该日期是否在药物的有效期内 + 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 { + 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 { + 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; + } +} \ No newline at end of file diff --git a/src/medications/services/status-updater.service.ts b/src/medications/services/status-updater.service.ts new file mode 100644 index 0000000..b1c27f5 --- /dev/null +++ b/src/medications/services/status-updater.service.ts @@ -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 { + 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 { + 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 { + const now = new Date(); + + const count = await this.recordModel.count({ + where: { + status: MedicationStatusEnum.UPCOMING, + scheduledTime: { + [Op.lt]: now, + }, + deleted: false, + }, + }); + + return count; + } +} \ No newline at end of file