feat(medications): 新增完整的药物管理和服药提醒功能

实现了包含药物信息管理、服药记录追踪、统计分析、自动状态更新和推送提醒的完整药物管理系统。

核心功能:
- 药物 CRUD 操作,支持多种剂型和自定义服药时间
- 惰性生成服药记录策略,查询时才生成当天记录
- 定时任务自动更新过期记录状态(每30分钟)
- 服药前15分钟自动推送提醒(每5分钟检查)
- 每日/范围/总体统计分析功能
- 完整的 API 文档和数据库建表脚本

技术实现:
- 使用 Sequelize ORM 管理 MySQL 数据表
- 集成 @nestjs/schedule 实现定时任务
- 复用现有推送通知系统发送提醒
- 采用软删除和权限验证保障数据安全
This commit is contained in:
richarjiang
2025-11-07 17:29:11 +08:00
parent 37cc2a729b
commit 188b4addca
27 changed files with 3464 additions and 0 deletions

View File

@@ -0,0 +1,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)
- 初始版本发布
- 完整的药物管理功能
- 服药记录追踪
- 统计分析功能
- 自动状态更新
- 推送提醒支持

View 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');

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,211 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectModel } from '@nestjs/sequelize';
import { Medication } from '../models/medication.model';
import { MedicationRecord } from '../models/medication-record.model';
import { MedicationStatusEnum } from '../enums/medication-status.enum';
import { PushNotificationsService } from '../../push-notifications/push-notifications.service';
import { Op } from 'sequelize';
import * as dayjs from 'dayjs';
/**
* 药物提醒推送服务
* 在服药时间前15分钟发送推送提醒
*/
@Injectable()
export class MedicationReminderService {
private readonly logger = new Logger(MedicationReminderService.name);
private readonly REMINDER_MINUTES_BEFORE = 15; // 提前15分钟提醒
constructor(
@InjectModel(Medication)
private readonly medicationModel: typeof Medication,
@InjectModel(MedicationRecord)
private readonly recordModel: typeof MedicationRecord,
private readonly pushService: PushNotificationsService,
) {}
/**
* 每5分钟检查一次需要发送的提醒
*/
@Cron('*/5 * * * *')
async checkAndSendReminders(): Promise<void> {
this.logger.log('开始检查服药提醒');
try {
// 计算时间范围:当前时间 + 15分钟
const now = new Date();
const reminderTime = dayjs(now)
.add(this.REMINDER_MINUTES_BEFORE, 'minute')
.toDate();
// 查找在接下来15分钟内需要提醒的记录
const startRange = now;
const endRange = dayjs(now).add(5, 'minute').toDate(); // 5分钟窗口期
const upcomingRecords = await this.recordModel.findAll({
where: {
status: MedicationStatusEnum.UPCOMING,
deleted: false,
scheduledTime: {
[Op.between]: [
dayjs(startRange).add(this.REMINDER_MINUTES_BEFORE, 'minute').toDate(),
dayjs(endRange).add(this.REMINDER_MINUTES_BEFORE, 'minute').toDate(),
],
},
},
include: [
{
model: Medication,
as: 'medication',
where: {
isActive: true,
deleted: false,
},
},
],
});
if (upcomingRecords.length === 0) {
this.logger.debug('没有需要发送的服药提醒');
return;
}
this.logger.log(`找到 ${upcomingRecords.length} 条需要发送提醒的记录`);
// 按用户分组发送提醒
const userRecordsMap = new Map<string, MedicationRecord[]>();
for (const record of upcomingRecords) {
const userId = record.userId;
if (!userRecordsMap.has(userId)) {
userRecordsMap.set(userId, []);
}
userRecordsMap.get(userId)!.push(record);
}
// 为每个用户发送提醒
for (const [userId, records] of userRecordsMap.entries()) {
await this.sendReminderToUser(userId, records);
}
this.logger.log(`成功发送 ${upcomingRecords.length} 条服药提醒`);
} catch (error) {
this.logger.error('检查服药提醒失败', error.stack);
}
}
/**
* 为单个用户发送提醒
*/
private async sendReminderToUser(
userId: string,
records: MedicationRecord[],
): Promise<void> {
try {
const medicationNames = records
.map((r) => r.medication?.name)
.filter(Boolean)
.join('、');
const title = '服药提醒';
const body =
records.length === 1
? `该服用 ${medicationNames}`
: `该服用 ${records.length} 种药物了:${medicationNames}`;
await this.pushService.sendNotification({
userIds: [userId],
title,
body,
payload: {
type: 'medication_reminder',
recordIds: records.map((r) => r.id),
medicationIds: records.map((r) => r.medicationId),
},
sound: 'default',
badge: 1,
});
this.logger.log(`成功向用户 ${userId} 发送服药提醒`);
} catch (error) {
this.logger.error(
`向用户 ${userId} 发送服药提醒失败`,
error.stack,
);
}
}
/**
* 手动为用户发送即时提醒(用于测试或特殊情况)
*/
async sendImmediateReminder(userId: string, recordId: string): Promise<void> {
const record = await this.recordModel.findOne({
where: {
id: recordId,
userId,
deleted: false,
},
include: [
{
model: Medication,
as: 'medication',
},
],
});
if (!record || !record.medication) {
throw new Error('服药记录不存在');
}
await this.sendReminderToUser(userId, [record]);
}
/**
* 为新创建的药物设置提醒(预留方法,实际提醒由定时任务触发)
*/
async setupRemindersForMedication(medication: Medication): Promise<void> {
this.logger.log(`为药物 ${medication.id} 设置提醒(由定时任务自动触发)`);
// 实际的提醒由定时任务 checkAndSendReminders 自动处理
// 这里只需要确保药物处于激活状态
}
/**
* 取消药物的所有提醒(停用或删除药物时调用)
*/
async cancelRemindersForMedication(medicationId: string): Promise<void> {
this.logger.log(`取消药物 ${medicationId} 的所有提醒`);
// 由于提醒是基于记录的状态和药物的激活状态动态生成的
// 所以只需要确保药物被停用或删除,定时任务就不会再发送提醒
}
/**
* 获取用户今天的待提醒数量
*/
async getTodayReminderCount(userId: string): Promise<number> {
const startOfDay = dayjs().startOf('day').toDate();
const endOfDay = dayjs().endOf('day').toDate();
const count = await this.recordModel.count({
where: {
userId,
status: MedicationStatusEnum.UPCOMING,
deleted: false,
scheduledTime: {
[Op.between]: [startOfDay, endOfDay],
},
},
include: [
{
model: Medication,
as: 'medication',
where: {
isActive: true,
deleted: false,
},
},
],
});
return count;
}
}

View File

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

View File

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