Compare commits

...

34 Commits

Author SHA1 Message Date
richarjiang
029b8f46b9 feat(challenges): 更新自定义挑战功能,支持时间戳转换及数据模型调整 2025-11-26 10:43:42 +08:00
richarjiang
93b4fcf553 feat(challenges): 添加用户自定义挑战功能及分享机制
实现完整的自定义挑战系统,支持用户创建、分享和管理个人挑战:

- 数据库扩展:添加 source、creator_id、share_code、is_public、max_participants、challenge_state 字段
- 分享机制:自动生成6位唯一分享码,支持公开和私密模式
- API接口:创建挑战、通过分享码加入、获取创建列表、更新归档挑战、重新生成分享码
- 权限控制:创建者专属编辑权限,频率限制防滥用(每日5个)
- 业务逻辑:人数限制检查、挑战状态流转、参与者统计
- 文档完善:使用文档和部署指南,包含API示例和回滚方案

兼容现有系统挑战,使用相同的打卡、排行榜和勋章系统
2025-11-25 19:07:09 +08:00
richarjiang
2d1d43922d feat(users): 添加公共访问权限以获取可用勋章列表 2025-11-25 15:45:28 +08:00
richarjiang
f8fcc81438 feat(medications): 增强AI药品识别质量控制和多图片支持
- 新增图片可读性预检查机制,识别前先判断图片质量
- 设置置信度阈值为60%,低于阈值自动识别失败
- 支持多图片上传(正面、侧面、辅助图片)提高识别准确度
- 完善识别失败场景的错误分类和用户指导提示
- 新增药品有效期字段支持
- 优化AI提示词,强调安全优先原则
- 更新模型版本为 glm-4.5v 和 glm-4.5-air

数据库变更:
- Medication表新增 sideImageUrl, auxiliaryImageUrl, expiryDate 字段
- DTO层同步支持新增字段的传递和更新

质量控制策略:
- 图片模糊或不可读时直接返回失败
- 无法识别药品名称时主动失败
- 置信度<60%时拒绝识别,建议重新拍摄
- 宁可识别失败也不提供不准确的药品信息
2025-11-21 16:59:36 +08:00
richarjiang
a17fe0b965 feat(medications): 增加基于视觉AI的药品智能录入系统
构建了从照片到药品档案的自动化处理流程,通过GLM多模态大模型实现药品信息的智能采集:

核心能力:
- 创建任务追踪表 t_medication_recognition_tasks 存储识别任务状态
- 四阶段渐进式分析:基础识别→人群适配→成分解析→风险评估
- 提供三个REST端点支持任务创建、进度查询和结果确认
- 前端可通过轮询方式获取0-100%的实时进度反馈
- VIP用户免费使用,普通用户按次扣费

技术实现:
- 利用GLM-4V-Plus模型处理多角度药品图像(正面+侧面+说明书)
- 采用GLM-4-Flash模型进行文本深度分析
- 异步任务执行机制避免接口阻塞
- 完整的异常处理和任务失败恢复策略
- 新增AI_RECOGNITION.md文档详细说明集成方式

同步修复:
- 修正会员用户AI配额扣减逻辑,避免不必要的次数消耗
- 优化APNs推送中无效设备令牌的检测和清理流程
- 将服药提醒的提前通知时间从15分钟缩短为5分钟
2025-11-21 10:27:59 +08:00
richarjiang
75fbea2c90 feat(users): 更新用户最后登录时间记录功能 2025-11-20 19:04:38 +08:00
richarjiang
afe6ae1c6a feat(medications): 实现V2版本药品AI分析功能及结构化数据支持
- 新增 V2 版药品分析服务,通过 AI 生成包含适用人群、副作用等字段的结构化 JSON 数据
- 添加 `POST :id/ai-analysis/v2` 接口,集成用户免费次数校验与自动扣费逻辑
- 在药品创建流程中增加异步触发自动 AI 分析的机制
- fix(users): 修复 Apple 登录未获取到邮箱时的报错问题,改为自动生成随机唯一邮箱
- perf(medications): 将服药提醒定时任务的检查频率调整为每 5 分钟一次
- refactor(push-notifications): 移除不再使用的 PushTestService
2025-11-20 17:55:05 +08:00
richarjiang
07fae9bdc0 refactor(push-notifications): 移除推送测试服务模块
删除PushTestService及其相关依赖,该服务用于在应用启动时执行挑战相关的推送测试。移除内容包括:
- 删除push-test.service.ts文件(287行代码)
- 从push-notifications.module.ts中移除PushTestService的导入和注册
- 移除了挑战提醒推送测试、活跃参与者查询等测试功能

此变更简化了推送通知模块结构,移除了仅用于测试目的的代码。
2025-11-18 15:33:05 +08:00
richarjiang
bbc6924f5b feat(users): 添加会员状态自动同步验证功能
- 新增登录时异步触发会员状态验证机制,不阻塞响应
- 实现5分钟频率限制,避免过度调用RevenueCat API
- 重构API调用方法,分别获取subscriptions和purchases数据
- 支持终身会员识别和有效期自动更新
- 添加内存缓存记录最后验证时间
- 完善错误处理和日志记录,确保主流程不受影响
2025-11-18 15:31:37 +08:00
1c033cd801 feat(badges): 更新勋章系统,支持UUID作为勋章ID类型
feat(challenges): 优化进度报告,添加睡眠挑战勋章授予逻辑
fix(push-notifications): 修复推送测试服务初始化返回值问题
2025-11-15 23:25:02 +08:00
9c7bcb6083 feat(medications): 添加药品分析日志记录功能 2025-11-15 21:18:29 +08:00
richarjiang
bac75f82ba feat(challenges): 在挑战列表和详情中添加勋章信息展示
为睡眠挑战类型添加勋章信息支持,在挑战列表和详情接口中返回 sleepChallengeMonth 勋章配置数据。

- 在 ChallengesModule 中注册 BadgeConfig 模型
- 在 ChallengesService 中注入 BadgeConfig 仓库
- 查询列表时,若存在睡眠挑战则预加载勋章配置
- 查询详情时,若为睡眠挑战则附加勋章信息
- 在 DTO 中新增 BadgeInfoDto 接口定义勋章数据结构
- 仅对激活状态的 sleepChallengeMonth 勋章进行查询和展示
2025-11-14 17:26:41 +08:00
richarjiang
7b4d7c4459 feat(badges): 添加用户勋章系统,支持睡眠挑战勋章自动授予
实现完整的用户勋章功能模块:
- 新增 BadgeConfig 和 UserBadge 数据模型,支持勋章配置和用户勋章管理
- 新增 BadgeService 服务,提供勋章授予、查询、展示状态管理等核心功能
- 在挑战服务中集成勋章授予逻辑,完成首次睡眠打卡授予 goodSleep 勋章,完成睡眠挑战授予 sleepChallengeMonth 勋章
- 新增用户勋章相关接口:获取用户勋章列表、获取可用勋章列表、标记勋章已展示
- 支持勋章分类(睡眠、运动、饮食等)、排序、启用状态管理
- 支持勋章来源追踪(挑战、系统、手动授予)和元数据记录
2025-11-14 17:08:02 +08:00
richarjiang
f04c2ccd5d feat(medications): 添加药物超时提醒功能
在MedicationRecord模型中添加overdueReminderSent字段追踪超时提醒状态,在提醒服务中新增定时任务检查超过服药时间1小时的未服用记录,并向用户发送鼓励提醒。该功能每10分钟检查一次,避免重复发送提醒,帮助用户及时补服错过的药物。
2025-11-14 14:47:44 +08:00
richarjiang
5a9be42a93 feat(medications): 添加药品AI分析功能,支持智能用药指导
新增基于GLM-4.5V大模型的药品AI分析服务,为用户提供专业的用药指导和健康建议:

- 新增MedicationAnalysisService服务,集成GLM视觉和文本模型
- 实现流式SSE响应,支持实时返回AI分析结果
- 药品模型新增aiAnalysis字段,持久化存储分析结果
- 添加药品识别度判断,无法识别时引导用户补充信息
- 集成用户使用次数限制,免费用户次数用完后提示开通会员
- 支持图片识别分析,结合药品外观提供更准确的建议
- 提供全面的用药指导:适应症、用法用量、注意事项、副作用等
2025-11-12 17:07:39 +08:00
richarjiang
e6f3c79104 refactor(medications): 简化服药时间更新逻辑,采用删除重建策略
将更新服药时间的处理方式从复杂的记录更新逻辑改为删除当天记录并重新生成。
这样做的好处:
- 简化代码逻辑,移除了 updateTodayUntakenRecords 方法及其 75 行代码
- 与激活状态更新的处理方式保持一致,提高代码可维护性
- 避免了时间匹配和记录排序的复杂性,更加可靠
- 系统会在定时任务中自动根据新的服药时间重新生成正确的记录
2025-11-11 15:54:09 +08:00
richarjiang
d9c144ff87 feat(medications): 添加提醒发送状态追踪,防止重复推送
- 新增 reminder_sent 字段到服药记录表,用于标记提醒发送状态
- 添加数据库索引优化未发送提醒记录的查询性能
- 提醒检查频率从 5 分钟优化至 1 分钟,提升及时性
- 添加主进程检测机制,避免多进程环境下重复发送提醒
- 增强错误处理和发送结果统计功能
2025-11-11 11:13:44 +08:00
richarjiang
2850eba7cf feat(medications): 优化药物删除功能,添加事务处理和批量操作
- 在药物删除时使用事务确保数据一致性
- 删除药物时同时软删除所有相关用药记录
- 优化删除操作性能,使用批量更新替代循环删除
- 扩展删除范围,从删除未服用记录改为删除当天所有记录
- 添加完善的错误处理和日志记录
2025-11-10 14:46:10 +08:00
richarjiang
e25002e018 feat(medications): 新增药物激活状态管理及相关记录更新功能 2025-11-10 11:04:36 +08:00
richarjiang
188b4addca feat(medications): 新增完整的药物管理和服药提醒功能
实现了包含药物信息管理、服药记录追踪、统计分析、自动状态更新和推送提醒的完整药物管理系统。

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

技术实现:
- 使用 Sequelize ORM 管理 MySQL 数据表
- 集成 @nestjs/schedule 实现定时任务
- 复用现有推送通知系统发送提醒
- 采用软删除和权限验证保障数据安全
2025-11-07 17:29:11 +08:00
richarjiang
37cc2a729b feat(push-notifications): 新增挑战提醒定时推送功能
新增每日定时推送系统,根据用户参与状态发送不同类型的挑战提醒:
- 已参与用户:每日发送鼓励推送
- 未参与用户:隔天发送挑战邀请
- 匿名用户:隔天发送通用邀请

包含推送历史记录表、定时任务调度、多类型文案模板和防重复发送机制
2025-11-03 17:49:14 +08:00
richarjiang
3a3939e1ba 根据提供的 git diff,我来分析这次变更:
## 变更分析

这次提交主要涉及 APNs 推送通知功能的优化:

1. **apns.provider.ts** 的变更:
   - 重构了通知选项的构建逻辑,将 alert 设置为顶层属性
   - 增强了 `createDeviceNotification` 方法,完整复制所有通知选项(badge、sound、contentAvailable、mutableContent、priority、type、data)
   - 移除了未使用的 aps 对象

2. **push-notifications.service.ts** 的变更:
   - 移除了冗余的 `alert` 字段
   - 新增了更多 APNs 通知选项:priority、expiry、collapseId、mutableContent、contentAvailable

这是一次功能增强和代码重构,主要目的是完善 APNs 推送通知的配置选项支持。

## 建议的提交信息

feat(push-notifications): 完善APNs推送通知配置选项支持

- 重构通知选项构建逻辑,优化alert属性设置
- createDeviceNotification方法新增完整选项复制(badge、sound、contentAvailable、mutableContent、priority、type、data)
- 推送服务新增priority、expiry、collapseId、mutableContent、contentAvailable等配置项支持
- 移除冗余的alert字段设置
2025-11-03 17:31:31 +08:00
richarjiang
fa9b28a98f feat(push-notifications): 新增更新令牌用户ID功能
添加新的API端点用于更新设备推送令牌绑定的用户ID,包括:
- 新增UpdateTokenUserIdDto和UpdateTokenUserIdResponseDto
- 在控制器中添加updateTokenUserId端点
- 在服务层实现updateTokenUserId方法
- 在push-token服务中添加底层更新逻辑
- 优化推送测试服务,仅在主进程中执行
2025-11-03 17:08:56 +08:00
richarjiang
200484ce39 feat(push-notifications): 将推送测试改为基于挑战的个性化提醒
重构推送测试服务,从简单的测试推送改为针对正在进行中挑战的个性化提醒推送。
新增功能包括:
- 获取正在进行中的挑战和活跃参与者
- 根据挑战类型生成个性化推送内容
- 为挑战参与者发送针对性的提醒推送
- 支持多种挑战类型的推送模板(饮水、运动、饮食、心情、睡眠、体重管理)
2025-11-03 16:11:30 +08:00
richarjiang
fa8feb309d perf: 优化脚本 2025-10-29 10:15:21 +08:00
richarjiang
d89adaf19a feat(diet-records): 新增营养成分分析免费使用次数限制
在营养成分分析功能中添加免费使用次数检查和扣减机制,非VIP用户免费次数用尽时将无法使用该功能。分析成功后自动扣减用户免费次数,确保资源合理使用。
2025-10-16 17:17:38 +08:00
richarjiang
66a9e65d9b feat(diet-records): 新增营养成分分析记录删除功能
添加删除营养成分分析记录的API端点,支持软删除机制
- 新增DELETE /nutrition-analysis-records/:id接口
- 添加DeleteNutritionAnalysisRecordResponseDto响应DTO
- 在NutritionAnalysisService中实现deleteAnalysisRecord方法
- 包含完整的权限验证和错误处理逻辑
2025-10-16 16:43:42 +08:00
richarjiang
1fe969aa97 feat(diet-records): 修复营养成分分析记录查询参数验证和类型转换
修复GET请求查询参数验证装饰器缺失问题,添加正确的class-validator装饰器
在控制器中实现查询参数类型转换,确保数字参数正确处理
更新技术文档,添加DTO验证装饰器编写规范和GET请求参数处理指南
2025-10-16 16:26:58 +08:00
richarjiang
a2c719f10a perf: 初始化 2025-10-16 12:15:41 +08:00
richarjiang
4d1bc9259b feat(diet-records): 新增营养成分分析记录功能
- 添加营养成分分析记录数据模型和数据库集成
- 实现分析记录保存功能,支持成功和失败状态记录
- 新增获取用户营养成分分析记录的API接口
- 支持按日期范围、状态等条件筛选查询
- 提供分页查询功能,优化大数据量场景性能
2025-10-16 11:25:31 +08:00
richarjiang
91cac3134e refactor(api): 统一API响应格式规范
重构营养成分分析相关接口,统一使用base.dto.ts中定义的通用响应结构体ApiResponseDto,规范所有接口返回格式。更新AI模型prompt以返回标准化的code、msg、data结构,并添加相应的验证装饰器确保数据完整性。
2025-10-16 11:16:33 +08:00
richarjiang
2f2901a0bf Merge branch 'feature/push' 2025-10-16 10:04:09 +08:00
a2ac667668 perf 2025-10-01 22:55:34 +08:00
4d1b8910f8 feat(challenges): 修改挑战列表过滤条件为只显示正在进行的挑战 2025-10-01 22:54:03 +08:00
91 changed files with 12089 additions and 285 deletions

14
.kilocode/mcp.json Normal file
View File

@@ -0,0 +1,14 @@
{
"mcpServers": {
"context7": {
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp"
],
"env": {
"DEFAULT_MINIMUM_TOKENS": ""
}
}
}
}

View File

@@ -0,0 +1,167 @@
# Memory Bank
I am an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional. The memory bank files are located in `.kilocode/rules/memory-bank` folder.
When I start a task, I will include `[Memory Bank: Active]` at the beginning of my response if I successfully read the memory bank files, or `[Memory Bank: Missing]` if the folder doesn't exist or is empty. If memory bank is missing, I will warn the user about potential issues and suggest initialization.
## Memory Bank Structure
The Memory Bank consists of core files and optional context files, all in Markdown format.
### Core Files (Required)
1. `brief.md`
This file is created and maintained manually by the developer. Don't edit this file directly but suggest to user to update it if it can be improved.
- Foundation document that shapes all other files
- Created at project start if it doesn't exist
- Defines core requirements and goals
- Source of truth for project scope
2. `product.md`
- Why this project exists
- Problems it solves
- How it should work
- User experience goals
3. `context.md`
This file should be short and factual, not creative or speculative.
- Current work focus
- Recent changes
- Next steps
4. `architecture.md`
- System architecture
- Source Code paths
- Key technical decisions
- Design patterns in use
- Component relationships
- Critical implementation paths
5. `tech.md`
- Technologies used
- Development setup
- Technical constraints
- Dependencies
- Tool usage patterns
### Additional Files
Create additional files/folders within memory-bank/ when they help organize:
- `tasks.md` - Documentation of repetitive tasks and their workflows
- Complex feature documentation
- Integration specifications
- API documentation
- Testing strategies
- Deployment procedures
## Core workflows
### Memory Bank Initialization
The initialization step is CRITICALLY IMPORTANT and must be done with extreme thoroughness as it defines all future effectiveness of the Memory Bank. This is the foundation upon which all future interactions will be built.
When user requests initialization of the memory bank (command `initialize memory bank`), I'll perform an exhaustive analysis of the project, including:
- All source code files and their relationships
- Configuration files and build system setup
- Project structure and organization patterns
- Documentation and comments
- Dependencies and external integrations
- Testing frameworks and patterns
I must be extremely thorough during initialization, spending extra time and effort to build a comprehensive understanding of the project. A high-quality initialization will dramatically improve all future interactions, while a rushed or incomplete initialization will permanently limit my effectiveness.
After initialization, I will ask the user to read through the memory bank files and verify product description, used technologies and other information. I should provide a summary of what I've understood about the project to help the user verify the accuracy of the memory bank files. I should encourage the user to correct any misunderstandings or add missing information, as this will significantly improve future interactions.
### Memory Bank Update
Memory Bank updates occur when:
1. Discovering new project patterns
2. After implementing significant changes
3. When user explicitly requests with the phrase **update memory bank** (MUST review ALL files)
4. When context needs clarification
If I notice significant changes that should be preserved but the user hasn't explicitly requested an update, I should suggest: "Would you like me to update the memory bank to reflect these changes?"
To execute Memory Bank update, I will:
1. Review ALL project files
2. Document current state
3. Document Insights & Patterns
4. If requested with additional context (e.g., "update memory bank using information from @/Makefile"), focus special attention on that source
Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on context.md as it tracks current state.
### Add Task
When user completes a repetitive task (like adding support for a new model version) and wants to document it for future reference, they can request: **add task** or **store this as a task**.
This workflow is designed for repetitive tasks that follow similar patterns and require editing the same files. Examples include:
- Adding support for new AI model versions
- Implementing new API endpoints following established patterns
- Adding new features that follow existing architecture
Tasks are stored in the file `tasks.md` in the memory bank folder. The file is optional and can be empty. The file can store many tasks.
To execute Add Task workflow:
1. Create or update `tasks.md` in the memory bank folder
2. Document the task with:
- Task name and description
- Files that need to be modified
- Step-by-step workflow followed
- Important considerations or gotchas
- Example of the completed implementation
3. Include any context that was discovered during task execution but wasn't previously documented
Example task entry:
```markdown
## Add New Model Support
**Last performed:** [date]
**Files to modify:**
- `/providers/gemini.md` - Add model to documentation
- `/src/providers/gemini-config.ts` - Add model configuration
- `/src/constants/models.ts` - Add to model list
- `/tests/providers/gemini.test.ts` - Add test cases
**Steps:**
1. Add model configuration with proper token limits
2. Update documentation with model capabilities
3. Add to constants file for UI display
4. Write tests for new model configuration
**Important notes:**
- Check Google's documentation for exact token limits
- Ensure backward compatibility with existing configurations
- Test with actual API calls before committing
```
### Regular Task Execution
In the beginning of EVERY task I MUST read ALL memory bank files - this is not optional.
The memory bank files are located in `.kilocode/rules/memory-bank` folder. If the folder doesn't exist or is empty, I will warn user about potential issues with the memory bank. I will include `[Memory Bank: Active]` at the beginning of my response if I successfully read the memory bank files, or `[Memory Bank: Missing]` if the folder doesn't exist or is empty. If memory bank is missing, I will warn the user about potential issues and suggest initialization. I should briefly summarize my understanding of the project to confirm alignment with the user's expectations, like:
"[Memory Bank: Active] I understand we're building a React inventory system with barcode scanning. Currently implementing the scanner component that needs to work with the backend API."
When starting a task that matches a documented task in `tasks.md`, I should mention this and follow the documented workflow to ensure no steps are missed.
If the task was repetitive and might be needed again, I should suggest: "Would you like me to add this task to the memory bank for future reference?"
In the end of the task, when it seems to be completed, I will update `context.md` accordingly. If the change seems significant, I will suggest to the user: "Would you like me to update memory bank to reflect these changes?" I will not suggest updates for minor changes.
## Context Window Management
When the context window fills up during an extended session:
1. I should suggest updating the memory bank to preserve the current state
2. Recommend starting a fresh conversation/task
3. In the new conversation, I will automatically load the memory bank files to maintain continuity
## Technical Implementation
Memory Bank is built on Kilo Code's Custom Rules feature, with files stored as standard markdown documents that both the user and I can access.
## Important Notes
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.
If I detect inconsistencies between memory bank files, I should prioritize brief.md and note any discrepancies to the user.
IMPORTANT: I MUST read ALL memory bank files at the start of EVERY task - this is not optional. The memory bank files are located in `.kilocode/rules/memory-bank` folder.

View File

@@ -0,0 +1,273 @@
# 系统架构与技术决策
## 整体架构
### 架构模式
- **分层架构**: 采用经典的 NestJS 分层架构模式
- **模块化设计**: 按业务功能划分模块,保持高内聚低耦合
- **依赖注入**: 使用 NestJS 的 DI 容器管理依赖关系
- **中间件模式**: 使用中间件处理横切关注点(日志、认证等)
### 核心层次结构
```
┌─────────────────────────────────────────┐
│ Presentation Layer │
│ (Controllers & DTOs) │
├─────────────────────────────────────────┤
│ Business Layer │
│ (Services & Logic) │
├─────────────────────────────────────────┤
│ Data Access Layer │
│ (Models & Repositories) │
├─────────────────────────────────────────┤
│ Infrastructure Layer │
│ (Database & External Services) │
└─────────────────────────────────────────┘
```
## 模块架构
### 核心业务模块
#### 1. 用户管理模块 (Users)
- **路径**: `src/users/`
- **职责**: 用户认证、档案管理、会员体系
- **关键组件**:
- `users.controller.ts`: 用户相关 API 端点
- `users.service.ts`: 用户业务逻辑
- `models/user.model.ts`: 用户数据模型
- `services/apple-auth.service.ts`: Apple 认证服务
- `services/apple-purchase.service.ts`: Apple 购买服务
#### 2. AI 健康教练模块 (AiCoach)
- **路径**: `src/ai-coach/`
- **职责**: AI 对话、营养分析、体态评估
- **关键组件**:
- `ai-coach.service.ts`: AI 核心服务
- `services/diet-analysis.service.ts`: 饮食分析服务
- `models/ai-conversation.model.ts`: 对话记录模型
- `models/posture-assessment.model.ts`: 体态评估模型
#### 3. 饮食记录模块 (DietRecords)
- **路径**: `src/diet-records/`
- **职责**: 饮食记录、营养追踪、历史查询
- **关键组件**:
- `diet-records.service.ts`: 饮食记录业务逻辑
- `models/nutrition-analysis-record.model.ts`: 营养分析记录
#### 4. 食物库模块 (FoodLibrary)
- **路径**: `src/food-library/`
- **职责**: 食物数据库、自定义食物、收藏管理
- **关键组件**:
- `food-library.service.ts`: 食物库服务
- `models/food-library.model.ts`: 食物数据模型
- `models/user-custom-food.model.ts`: 用户自定义食物
#### 5. 运动训练模块 (Workouts)
- **路径**: `src/workouts/`
- **职责**: 训练计划、运动记录、进度追踪
- **关键组件**:
- `workouts.service.ts`: 运动训练服务
- `models/workout-session.model.ts`: 训练会话模型
### 支撑模块
#### 1. 数据库模块 (Database)
- **路径**: `src/database/`
- **职责**: 数据库连接、配置管理
- **技术栈**: Sequelize ORM + MySQL
#### 2. 通用模块 (Common)
- **路径**: `src/common/`
- **职责**: 通用工具、装饰器、守卫
- **关键组件**:
- `decorators/`: 自定义装饰器
- `guards/`: 认证守卫
- `encryption.service.ts`: 加密服务
#### 3. 活动日志模块 (ActivityLogs)
- **路径**: `src/activity-logs/`
- **职责**: 用户行为记录、审计日志
- **关键组件**:
- `activity-logs.service.ts`: 活动日志服务
- `models/activity-log.model.ts`: 活动日志模型
## 数据库设计
### 核心数据表
#### 用户相关表
- `t_users`: 用户基础信息
- `t_user_profiles`: 用户扩展档案
- `t_user_weight_history`: 体重历史记录
- `t_user_body_measurement_history`: 体围测量历史
- `t_user_diet_history`: 饮食记录历史
#### AI 相关表
- `t_ai_conversations`: AI 对话记录
- `t_ai_messages`: AI 消息详情
- `t_posture_assessments`: 体态评估记录
- `t_nutrition_analysis_records`: 营养分析记录
#### 业务功能表
- `t_food_library`: 食物库
- `t_user_custom_foods`: 用户自定义食物
- `t_workout_sessions`: 训练会话
- `t_training_plans`: 训练计划
#### 系统表
- `t_activity_logs`: 活动日志
- `t_user_purchases`: 购买记录
- `t_push_notifications`: 推送通知
### 数据库设计原则
- **命名规范**: 使用 `t_` 前缀,下划线分隔
- **字符集**: 统一使用 `utf8mb4` 支持完整 Unicode
- **索引策略**: 为常用查询字段添加复合索引
- **软删除**: 重要数据使用 `deleted` 字段实现软删除
## API 设计规范
### RESTful API 设计
- **基础路径**: `/api`
- **版本控制**: 通过路径版本控制 (`/api/v1/`)
- **响应格式**: 统一使用 `ApiResponseDto` 格式
- **状态码**: 标准 HTTP 状态码 + 业务错误码
### 响应结构
```typescript
{
code: ResponseCode.SUCCESS | ResponseCode.ERROR,
message: string,
data: T | null
}
```
### 认证授权
- **JWT Token**: 使用 JWT 进行用户认证
- **Apple Sign-In**: 集成 Apple 登录
- **游客模式**: 支持游客临时访问
- **权限控制**: 基于角色的访问控制
## 安全架构
### 数据加密
- **传输加密**: HTTPS/TLS 1.3
- **存储加密**: AES-256-GCM 敏感数据加密
- **密钥管理**: 环境变量 + 密钥轮换机制
### 访问控制
- **API 认证**: JWT Token 验证
- **权限守卫**: NestJS Guards 实现权限控制
- **速率限制**: 防止 API 滥用
- **输入验证**: class-validator 进行数据验证
### 审计日志
- **用户行为**: 完整记录用户操作
- **系统日志**: Winston 结构化日志
- **安全事件**: 记录安全相关事件
- **日志轮转**: 按时间和大小轮转日志
## 外部服务集成
### AI 服务集成
- **通义千问**: 阿里云大模型服务
- **视觉识别**: 图像分析和食物识别
- **模型配置**: 支持多模型切换和降级
### 支付服务集成
- **Apple App Store**: 应用内购买
- **RevenueCat**: 订阅管理平台
- **Webhook 处理**: 安全的支付通知处理
### 云服务集成
- **腾讯云 COS**: 文件存储服务
- **CDN 加速**: 静态资源分发
- **推送服务**: APNs (Apple Push Notification)
## 性能优化策略
### 数据库优化
- **连接池**: Sequelize 连接池管理
- **查询优化**: 避免 N+1 查询问题
- **索引策略**: 为常用查询添加合适索引
- **读写分离**: 支持主从数据库配置
### 缓存策略
- **内存缓存**: 热点数据内存缓存
- **查询缓存**: 数据库查询结果缓存
- **CDN 缓存**: 静态资源 CDN 缓存
- **API 缓存**: API 响应缓存
### 异步处理
- **队列系统**: 后台任务队列处理
- **流式响应**: AI 对话流式返回
- **批量操作**: 批量数据处理优化
## 部署架构
### 服务器配置
- **生产环境**: 阿里云 ECS
- **进程管理**: PM2 集群模式
- **负载均衡**: Nginx 反向代理
- **监控告警**: 基础监控和告警
### 部署流程
- **自动化部署**: Shell 脚本自动化部署
- **零停机部署**: 滚动更新策略
- **版本管理**: Git 版本控制
- **回滚机制**: 快速回滚到上一版本
### 环境管理
- **环境隔离**: 开发/测试/生产环境隔离
- **配置管理**: 环境变量配置管理
- **密钥管理**: 敏感信息安全管理
- **依赖管理**: npm/yarn 依赖版本锁定
## 技术决策记录
### 1. 框架选择: NestJS
- **原因**: TypeScript 原生支持、模块化架构、丰富的生态系统
- **优势**: 可维护性高、适合团队协作、内置依赖注入
- **权衡**: 学习曲线较陡、性能略低于 Express
### 2. 数据库: MySQL + Sequelize
- **原因**: 成熟稳定、事务支持好、Sequulum ORM 功能完善
- **优势**: 数据一致性强、工具链成熟、运维成本低
- **权衡**: 扩展性相对较弱、大数据量性能需优化
### 3. AI 服务: 通义千问
- **原因**: 中文支持好、成本相对较低、API 稳定
- **优势**: 本土化服务、响应速度快、技术支持好
- **权衡**: 生态相对较小、国际化程度低
### 4. 认证方式: JWT + Apple Sign-In
- **原因**: 无状态认证、Apple 生态集成、用户体验好
- **优势**: 扩展性强、安全性高、移动端友好
- **权衡**: Token 管理复杂、登出控制需要额外处理
## 未来架构演进方向
### 1. 微服务化
- **服务拆分**: 按业务域拆分微服务
- **服务发现**: 引入服务注册和发现
- **API 网关**: 统一 API 入口管理
- **分布式事务**: 处理跨服务事务
### 2. 数据架构升级
- **读写分离**: 主从数据库分离
- **分库分表**: 大数据量分片策略
- **缓存层**: Redis 分布式缓存
- **搜索引擎**: Elasticsearch 集成
### 3. 云原生架构
- **容器化**: Docker 容器部署
- **编排管理**: Kubernetes 集群管理
- **服务网格**: Istio 服务治理
- **监控体系**: Prometheus + Grafana
### 4. AI 能力增强
- **模型微调**: 领域专用模型训练
- **多模态**: 图像、语音、文本统一处理
- **边缘计算**: 本地 AI 推理优化
- **联邦学习**: 隐私保护的机器学习

View File

@@ -0,0 +1 @@
构建一个具有 AI 大模型集成的 NestJS 后端服务,专注于用户身体健康、饮食习惯等应用场景。项目集成了通义千问 AI 模型,提供智能健康教练、饮食管理、体态评估、运动训练等核心功能,支持 Apple 登录和会员订阅体系,采用 MySQL 数据库和 Sequelize ORM部署在腾讯云服务器上。

View File

@@ -0,0 +1,125 @@
# 当前开发状态与重点
## 项目概况
**项目名称**: Pilates Server
**项目类型**: NestJS 后端服务
**开发阶段**: 生产就绪版本,持续迭代中
**最后更新**: 2025年1月
## 当前开发重点
### 1. AI 健康教练功能优化
- **多模态交互增强**: 正在优化图像识别和文本分析的准确性
- **营养分析专业化**: 完善营养师级别的饮食建议系统
- **对话上下文管理**: 改进长期对话的记忆和连贯性
- **安全机制强化**: 加强 AI 回答的范围限制和内容审核
### 2. 用户体验改进
- **响应速度优化**: 针对 AI 模型调用进行性能优化
- **错误处理完善**: 改进各种异常情况下的用户提示
- **移动端适配**: 优化 API 响应格式,提升移动端体验
### 3. 数据分析与可视化
- **健康趋势分析**: 开发更智能的健康数据趋势分析功能
- **个性化报告**: 生成专业的个人健康报告
- **目标达成追踪**: 完善目标设定和进度追踪系统
## 最近完成的重要功能
### 1. 饮食记录系统
- ✅ 完成了 AI 视觉食物识别功能
- ✅ 实现了详细的营养成分分析
- ✅ 添加了自定义食物库功能
- ✅ 完善了饮食历史记录和统计
### 2. 体重管理系统
- ✅ 实现了体重记录和趋势分析
- ✅ 添加了多种数据源支持手动输入、AI识别
- ✅ 完善了体重目标设定和进度追踪
### 3. 体态评估功能
- ✅ 完成了基于图像的体态评估系统
- ✅ 实现了多角度(正面、侧面、背面)分析
- ✅ 添加了专业的改善建议和训练计划
### 4. 会员系统完善
- ✅ 集成了 Apple App Store 订阅管理
- ✅ 实现了 RevenueCat 支付处理
- ✅ 添加了购买恢复和安全验证机制
## 当前技术债务和待优化项
### 1. 性能优化
- 数据库查询优化,特别是复杂报表查询
- AI 模型调用的缓存机制
- 大文件上传和处理优化
### 2. 代码质量
- 部分模块的单元测试覆盖率需要提升
- 错误处理机制的标准化
- 代码注释和文档完善
### 3. 安全加固
- API 接口的安全审计
- 敏感数据的加密存储
- 访问日志和监控完善
## 下一步开发计划
### 短期目标1-2个月
1. **AI 功能增强**
- 优化营养分析的准确性
- 增加更多运动识别能力
- 改进对话的自然度和专业性
2. **社交功能开发**
- 健康挑战系统
- 用户社区和分享功能
- 排行榜和激励机制
3. **数据分析升级**
- 更智能的健康趋势预测
- 个性化建议算法优化
- 专业健康报告生成
### 中期目标3-6个月
1. **平台扩展**
- 支持更多语言和地区
- 集成更多第三方健康设备
- 开发 Web 端管理后台
2. **AI 能力扩展**
- 语音交互支持
- 实时运动姿态分析
- 更精准的卡路里计算
## 当前团队状态
- **后端开发**: 1名资深 Node.js 工程师
- **AI 集成**: 1名 AI 应用开发工程师
- **产品管理**: 1名产品经理
- **测试**: 外包测试团队
## 部署和运维状态
- **生产环境**: 阿里云服务器 (129.204.155.94)
- **数据库**: MySQL 8.0
- **进程管理**: PM2 集群模式
- **监控**: 基础日志监控,需要完善
- **备份**: 自动化备份脚本已配置
## 当前面临的挑战
1. **AI 成本控制**: 大模型调用成本需要优化
2. **用户增长**: 需要更有效的用户获取和留存策略
3. **数据隐私**: 平衡个性化推荐和用户隐私保护
4. **技术迭代**: 快速跟进 AI 技术发展,保持竞争优势
## 关键指标
- **日活跃用户**: 持续增长中
- **AI 调用次数**: 平均每用户每日 3-5 次
- **用户留存率**: 7日留存率约 65%
- **会员转化率**: 免费用户到付费用户转化率约 8%
- **系统稳定性**: 99.5% 可用性

View File

@@ -0,0 +1,95 @@
# 产品定位与功能说明
## 产品概述
Pilates Server 是一个基于 NestJS 框架构建的健康管理后端服务,专注于用户身体健康、饮食习惯等应用场景。该系统集成了 AI 大模型能力,为用户提供智能化的健康管理和健身指导服务。
## 核心价值主张
- **AI 驱动的健康管理**:通过大模型提供个性化的营养分析和健身建议
- **全方位健康数据追踪**:支持饮食记录、体重管理、体态评估等多维度健康数据
- **智能化用户体验**:支持视觉识别、语音交互等现代化交互方式
- **专业健身指导**:提供普拉提训练计划和专业的体态评估服务
## 目标用户群体
1. **健身爱好者**:关注身体健康、希望获得专业健身指导的用户
2. **减肥人群**:需要饮食管理和体重追踪的用户
3. **普拉提练习者**:希望获得系统化普拉提训练指导的用户
4. **健康生活追求者**:关注营养搭配和生活质量的用户
## 核心功能模块
### 1. 用户管理系统
- **认证登录**:支持 Apple Sign-In 和游客登录
- **用户档案**:完整的用户健康档案管理
- **会员体系**VIP 会员和免费用户权限管理
- **数据安全**AES-256-GCM 端到端加密保护用户隐私
### 2. AI 健康教练
- **智能对话**:基于大模型的健康咨询和问答
- **营养分析**:专业的饮食营养分析和建议
- **体态评估**AI 视觉体态评估和改善建议
- **个性化指导**:根据用户情况提供定制化健康方案
### 3. 饮食管理
- **视觉识别**AI 食物识别和营养成分分析
- **饮食记录**:详细的饮食历史和营养追踪
- **食物库**:丰富的食物营养数据库
- **自定义食物**:用户可添加个人常吃食物
### 4. 健身训练
- **训练计划**:系统化的普拉提训练计划
- **运动记录**:详细的运动数据追踪
- **进度管理**:训练进度和效果评估
- **动作指导**:专业的动作示范和说明
### 5. 健康数据追踪
- **体重管理**:体重变化趋势分析
- **体围测量**:身体围度变化追踪
- **健康指标**:多维度的健康数据可视化
- **目标设定**:个性化健康目标管理
### 6. 社交互动
- **挑战系统**:健康挑战和排行榜
- **打卡签到**:日常健康习惯养成
- **心情记录**:情绪健康追踪
- **推荐内容**:个性化的健康文章推荐
## 技术特色
### AI 能力集成
- **多模态交互**:支持文本、图像等多种输入方式
- **专业领域优化**:针对健康、营养、健身领域优化的 AI 模型
- **上下文理解**:具备对话历史和用户偏好理解能力
- **安全可控**:严格的 AI 回答范围限制和安全机制
### 数据安全与隐私
- **端到端加密**:敏感数据全程加密保护
- **权限控制**:细粒度的用户权限管理
- **数据隔离**:用户数据完全隔离存储
- **合规性**:符合数据保护法规要求
## 商业模式
### 会员订阅
- **免费版**:基础功能,有限使用次数
- **VIP 会员**:无限使用 AI 功能,高级分析报告
- **订阅周期**:支持周付、月付、季付、终身等多种方案
### 增值服务
- **专业咨询**:一对一营养师/教练咨询
- **定制方案**:个性化健康方案定制
- **数据导出**:专业健康数据报告
## 产品愿景
成为用户最信赖的 AI 健康管理伙伴,通过先进的人工智能技术,让每个人都能享受到专业、个性化的健康管理服务,帮助用户建立健康的生活方式,实现身心的全面健康发展。
## 竞争优势
1. **AI 技术领先**:深度集成大模型,提供更智能的健康分析
2. **专业领域聚焦**:专注健康领域,提供更专业的服务
3. **用户体验优秀**:简洁易用的界面设计,流畅的交互体验
4. **数据安全可靠**:企业级的数据安全和隐私保护
5. **生态完整**:从饮食到运动的全方位健康管理解决方案

View File

@@ -0,0 +1,431 @@
# 技术栈与开发环境
## 核心技术栈
### 后端框架
- **NestJS 11.x**: TypeScript 原生支持的企业级 Node.js 框架
- **TypeScript 5.7+**: 类型安全的 JavaScript 超集
- **Express.js**: 底层 HTTP 服务器框架NestJS 内置)
### 数据库与 ORM
- **MySQL 8.0**: 主数据库,支持 JSON 字段和全文索引
- **Sequelize 6.x**: ORM 框架,支持 TypeScript 和迁移
- **Sequelize-typescript**: TypeScript 类型定义和装饰器支持
### 认证与安全
- **JWT (jsonwebtoken)**: 无状态身份认证
- **Apple Sign-In**: iOS 生态登录集成
- **crypto-js**: 客户端/服务端加密工具
- **AES-256-GCM**: 敏感数据加密标准
### AI 与机器学习
- **OpenAI SDK**: AI 模型调用统一接口
- **通义千问 (阿里云)**: 主要 AI 模型服务
- **qwen-vl-max**: 视觉识别专用模型
- **qwen-flash**: 快速对话模型
### 文件存储与云服务
- **腾讯云 COS**: 对象存储服务
- **qcloud-cos-sts**: 临时访问凭证管理
- **APNs (Apple Push Notification)**: iOS 推送服务
- **@parse/node-apn**: APNs 服务端 SDK
## 开发工具与环境
### 包管理与构建
- **yarn**: 包管理器(支持工作空间)
- **npm**: 备用包管理器
- **SWC**: 快速 TypeScript 编译器
- **ts-node**: 开发时 TypeScript 执行
### 代码质量与规范
- **ESLint 9.x**: 代码质量检查
- **Prettier**: 代码格式化
- **TypeScript**: 静态类型检查
- **Husky**: Git hooks 管理(未配置但推荐)
### 测试框架
- **Jest 29.x**: 单元测试和集成测试
- **Supertest**: HTTP 接口测试
- **ts-jest**: TypeScript 测试支持
### 部署与运维
- **PM2**: Node.js 进程管理器
- **Docker**: 容器化部署(未完全实现)
- **Nginx**: 反向代理和负载均衡
- **Winston**: 结构化日志记录
## 项目配置文件
### 核心配置
- `package.json`: 项目依赖和脚本定义
- `tsconfig.json`: TypeScript 编译配置
- `nest-cli.json`: NestJS CLI 配置
- `ecosystem.config.js`: PM2 集群配置
### 环境配置
- `.env`: 开发环境变量(不提交到版本控制)
- `.env.glm.example`: 环境变量模板
- `eslint.config.mjs`: ESLint 配置
### 部署配置
- `deploy.sh`: 完整部署脚本
- `deploy-optimized.sh`: 优化部署脚本
- `start.sh`: 服务启动脚本
## 开发环境设置
### 本地开发要求
- **Node.js**: >= 18.0.0
- **MySQL**: >= 8.0
- **yarn**: 最新稳定版
- **Git**: 版本控制
### 开发命令
```bash
# 安装依赖
yarn install
# 开发模式启动
yarn start:dev
# 构建项目
yarn build
# 生产模式启动
yarn start:prod
# 运行测试
yarn test
# 代码检查
yarn lint
# 代码格式化
yarn format
```
### PM2 管理命令
```bash
# 启动开发环境
yarn pm2:start:dev
# 启动生产环境
yarn pm2:start
# 查看状态
yarn pm2:status
# 查看日志
yarn pm2:logs
# 重启服务
yarn pm2:restart
# 停止服务
yarn pm2:stop
```
## 环境变量配置
### 必需的环境变量
```bash
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=your_username
DB_PASSWORD=your_password
DB_DATABASE=pilates_db
# JWT 配置
JWT_SECRET=your_jwt_secret_key
JWT_EXPIRES_IN=7d
REFRESH_TOKEN_SECRET=your_refresh_token_secret
REFRESH_TOKEN_EXPIRES_IN=30d
# Apple 认证配置
APPLE_BUNDLE_ID=com.yourcompany.pilates
APPLE_KEY_ID=your_apple_key_id
APPLE_ISSUER_ID=your_apple_issuer_id
APPLE_PRIVATE_KEY_PATH=path/to/private/key.p8
APPLE_APP_SHARED_SECRET=your_app_shared_secret
# AI 服务配置
DASHSCOPE_API_KEY=your_dashscope_api_key
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
DASHSCOPE_MODEL=qwen-flash
DASHSCOPE_VISION_MODEL=qwen-vl-max
# 加密配置
ENCRYPTION_KEY=your-32-character-secret-key-here
# 腾讯云 COS 配置
COS_SECRET_ID=your_cos_secret_id
COS_SECRET_KEY=your_cos_secret_key
COS_REGION=your_cos_region
COS_BUCKET=your_cos_bucket
# RevenueCat 配置
REVENUECAT_PUBLIC_API_KEY=your_revenuecat_public_key
REVENUECAT_SECRET_API_KEY=your_revenuecat_secret_key
# 服务配置
PORT=3002
NODE_ENV=development
```
## 数据库架构
### 字符集和排序规则
- **字符集**: `utf8mb4` (支持完整 Unicode包括 emoji)
- **排序规则**: `utf8mb4_unicode_ci` (不区分大小写)
- **时区**: 统一使用 UTC 时间
### 连接配置
- **连接池**: 最大连接数 10最小连接数 0
- **超时设置**: 查询超时 30 秒,连接超时 10 秒
- **自动重连**: 启用连接失败自动重连
### 迁移策略
- **自动同步**: 开发环境使用 `synchronize: true`
- **生产环境**: 使用 SQL 脚本手动执行迁移
- **版本控制**: SQL 脚本存储在 `sql-scripts/` 目录
## API 设计规范
### RESTful 设计原则
- **资源导向**: 使用名词表示资源
- **HTTP 方法**: GET/POST/PUT/DELETE 对应 CRUD 操作
- **状态码**: 标准 HTTP 状态码 + 业务错误码
- **统一响应**: 使用 `ApiResponseDto` 包装所有响应
### 接口版本控制
- **路径版本**: `/api/v1/users` (推荐)
- **查询参数**: `?version=1` (备选)
- **Header 版本**: `Accept: application/vnd.api+json;version=1` (备选)
### 请求响应格式
```typescript
// 成功响应
{
"code": 0,
"message": "操作成功",
"data": { ... }
}
// 错误响应
{
"code": 1,
"message": "错误描述",
"data": null
}
```
## 日志管理
### 日志级别
- **ERROR**: 错误信息,需要立即关注
- **WARN**: 警告信息,可能的问题
- **INFO**: 一般信息,重要业务操作
- **DEBUG**: 调试信息,详细的执行流程
### 日志配置
- **Winston**: 结构化日志记录
- **日志轮转**: 按日期和大小自动轮转
- **多输出**: 同时输出到文件和控制台
- **格式化**: JSON 格式便于分析
### 日志文件
- `logs/error.log`: 错误日志
- `logs/output.log`: 一般输出日志
- `logs/combined.log`: 合并日志
## 安全最佳实践
### 数据加密
- **传输加密**: 强制 HTTPS/TLS 1.3
- **存储加密**: 敏感字段 AES-256-GCM 加密
- **密钥管理**: 环境变量 + 定期轮换
- **哈希算法**: bcrypt 处理密码(如需要)
### 输入验证
- **class-validator**: DTO 数据验证
- **class-transformer**: 数据转换和清理
- **SQL 注入防护**: Sequelize ORM 自动防护
- **XSS 防护**: 输入清理和输出编码
#### DTO 验证装饰器编写规范
**基本原则**
- 所有 DTO 类必须同时包含 `@ApiProperty``class-validator` 装饰器
- `@ApiProperty` 用于 Swagger 文档生成,`class-validator` 用于数据验证
- 缺少验证装饰器会导致参数校验失败,使 API 端点无法正常工作
**必需要导入的验证装饰器**
```typescript
import { IsOptional, IsDateString, IsNumber, IsString, IsNotEmpty, IsEnum, MaxLength, Min, Max } from 'class-validator';
```
**常用字段验证规则**
- **分页参数**:在 GET 请求中使用 `@IsOptional()` + `@IsString()`,在控制器中转换为数字;在 POST/PUT 请求中使用 `@IsOptional()` + `@IsNumber()`
- **日期参数**`startDate``endDate` 使用 `@IsOptional()` + `@IsDateString()`
- **字符串参数**:使用 `@IsOptional()` + `@IsString()`,必要时添加 `@MaxLength()`
- **枚举参数**:使用 `@IsOptional()` + `@IsEnum(EnumType)`
- **必填字段**:使用 `@IsNotEmpty()` 而不是只使用 `@IsString()`
#### GET 请求参数特殊处理
**重要说明**GET 请求的查询参数Query Parameters在 HTTP 协议中都是字符串类型,即使看起来是数字(如 `?page=1&limit=20`)。因此需要特殊处理:
**GET 请求 DTO 定义**
```typescript
export class GetRecordsQueryDto {
@ApiProperty({ description: '页码', example: 1, required: false })
@IsOptional()
@IsString() // 注意GET 请求中使用 @IsString() 而不是 @IsNumber()
page?: string;
@ApiProperty({ description: '每页数量', example: 20, required: false })
@IsOptional()
@IsString() // 注意GET 请求中使用 @IsString() 而不是 @IsNumber()
limit?: string;
}
```
**控制器中的类型转换**
```typescript
async getRecords(
@Query() query: GetRecordsQueryDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<RecordsResponseDto> {
// 转换查询参数中的字符串为数字
const convertedQuery = {
page: query.page ? parseInt(query.page, 10) : undefined,
limit: query.limit ? parseInt(query.limit, 10) : undefined,
// 其他字符串参数保持不变
startDate: query.startDate,
endDate: query.endDate,
status: query.status,
};
const result = await this.service.getRecords(user.sub, convertedQuery);
return result;
}
```
**POST/PUT 请求 DTO 定义**(对比):
```typescript
export class CreateRecordDto {
@ApiProperty({ description: '数量', example: 10 })
@IsNumber() // POST/PUT 请求中可以直接使用 @IsNumber()
quantity: number;
}
```
**正确示例GET 请求)**
```typescript
export class GetRecordsQueryDto {
@ApiProperty({ description: '页码', example: 1, required: false })
@IsOptional()
@IsString() // GET 请求中使用 @IsString()
page?: string;
@ApiProperty({ description: '每页数量', example: 20, required: false })
@IsOptional()
@IsString() // GET 请求中使用 @IsString()
limit?: string;
@ApiProperty({ description: '开始日期', example: '2023-01-01', required: false })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiProperty({ description: '状态', example: 'active', required: false })
@IsOptional()
@IsString()
status?: string;
}
```
**正确示例POST/PUT 请求)**
```typescript
export class CreateRecordDto {
@ApiProperty({ description: '数量', example: 10 })
@IsNumber() // POST/PUT 请求中可以直接使用 @IsNumber()
quantity: number;
@ApiProperty({ description: '名称', example: '测试' })
@IsString()
@IsNotEmpty()
name: string;
}
```
**常见错误**
- ❌ 只有 `@ApiProperty` 而缺少 `class-validator` 装饰器
- ❌ GET 请求中的数字参数使用 `@IsNumber()` 而不是 `@IsString()`
- ❌ 使用 `@IsString()` 但没有 `@IsOptional()` 处理可选参数
- ❌ 日期字段没有使用 `@IsDateString()` 验证
- ❌ GET 请求中忘记在控制器进行类型转换,导致服务层接收到字符串而不是数字
- ❌ 类型转换时没有进行空值检查,可能导致 `parseInt(undefined)` 返回 `NaN`
### 访问控制
- **JWT 认证**: 无状态 Token 认证
- **权限守卫**: 基于角色的访问控制
- **速率限制**: 防止 API 滥用和攻击
- **CORS 配置**: 跨域请求安全控制
## 性能优化
### 数据库优化
- **索引策略**: 为常用查询字段添加索引
- **查询优化**: 避免 N+1 查询问题
- **连接池**: 合理配置数据库连接池
- **分页查询**: 大数据集分页处理
### 缓存策略
- **内存缓存**: 热点数据内存缓存
- **查询缓存**: 数据库查询结果缓存
- **CDN 缓存**: 静态资源 CDN 分发
- **浏览器缓存**: 合理设置 Cache-Control
### 代码优化
- **异步处理**: 使用 async/await 处理异步操作
- **批量操作**: 减少数据库往返次数
- **流式处理**: 大数据量流式处理
- **懒加载**: 按需加载模块和数据
## 监控与调试
### 应用监控
- **PM2 监控**: 进程状态和资源使用
- **健康检查**: 应用健康状态接口
- **性能指标**: 响应时间和吞吐量
- **错误追踪**: 异常自动收集和报告
### 调试工具
- **Source Map**: 生产环境调试支持
- **日志分析**: 结构化日志查询和分析
- **API 文档**: Swagger 自动生成文档
- **数据库工具**: MySQL Workbench/Sequel Pro
## 部署架构
### 服务器环境
- **操作系统**: Ubuntu 20.04 LTS
- **Node.js**: 18.x LTS 版本
- **数据库**: MySQL 8.0
- **Web 服务器**: Nginx 1.18+
### 部署流程
1. **代码构建**: 本地或服务器端 TypeScript 编译
2. **依赖安装**: 生产依赖安装和锁定
3. **数据库迁移**: SQL 脚本执行和数据迁移
4. **服务启动**: PM2 集群模式启动应用
5. **健康检查**: 验证服务正常运行
### 容器化部署(未来)
- **Docker**: 应用容器化
- **Docker Compose**: 多服务编排
- **Kubernetes**: 容器编排管理
- **CI/CD**: 自动化构建和部署流水线

View File

@@ -7,3 +7,5 @@
- 不要随意新增 markdown 文档 - 不要随意新增 markdown 文档
- 代码提交 message 用中文 - 代码提交 message 用中文
- 注意代码的可读性、架构实现要清晰 - 注意代码的可读性、架构实现要清晰
- 不要随意新增示例文件
- 接口规范: 接口的返回都需要遵循 base.dto.ts 文件中的规范

View File

@@ -141,12 +141,47 @@ deploy_on_server() {
if ! command -v yarn &> /dev/null; then if ! command -v yarn &> /dev/null; then
echo "正在安装Yarn..." echo "正在安装Yarn..."
npm install -g yarn npm install -g yarn --unsafe-perm=true --allow-root
fi fi
# 安装PM2并确保可用
if ! command -v pm2 &> /dev/null; then if ! command -v pm2 &> /dev/null; then
echo "正在安装PM2..." echo "正在安装PM2..."
npm install -g pm2 npm install -g pm2 --unsafe-perm=true --allow-root
# 重新加载环境变量
source ~/.bashrc 2>/dev/null || source ~/.zshrc 2>/dev/null || true
# 创建PM2的符号链接到/usr/local/bin
if [ -f "/usr/local/bin/pm2" ]; then
echo "PM2已存在于/usr/local/bin"
elif [ -f "/usr/bin/pm2" ]; then
echo "PM2已存在于/usr/bin"
else
# 查找PM2安装位置并创建符号链接
PM2_INSTALL_PATH=\$(find /usr/local/lib/node_modules/pm2/bin/pm2 2>/dev/null)
if [ -n "\$PM2_INSTALL_PATH" ]; then
ln -sf \$PM2_INSTALL_PATH /usr/local/bin/pm2
echo "已创建PM2符号链接"
else
echo "警告: 无法找到PM2安装路径"
fi
fi
fi
# 验证PM2是否可用
if command -v pm2 &> /dev/null; then
echo "PM2已准备就绪"
else
echo "尝试使用完整路径运行PM2..."
PM2_CMD="/usr/local/bin/pm2"
if [ ! -f "\$PM2_CMD" ]; then
PM2_CMD="/usr/bin/pm2"
fi
if [ ! -f "\$PM2_CMD" ]; then
echo "错误: 无法找到PM2可执行文件"
exit 1
fi
fi fi
# 检查并安装Redis # 检查并安装Redis
@@ -171,18 +206,35 @@ deploy_on_server() {
# 停止旧的应用实例 # 停止旧的应用实例
echo "停止旧的应用实例..." echo "停止旧的应用实例..."
if command -v pm2 &> /dev/null; then
pm2 delete $PROJECT_NAME 2>/dev/null || true pm2 delete $PROJECT_NAME 2>/dev/null || true
else
\$PM2_CMD delete $PROJECT_NAME 2>/dev/null || true
fi
# 启动新的应用实例 # 启动新的应用实例
echo "启动新的应用实例..." echo "启动新的应用实例..."
if command -v pm2 &> /dev/null; then
pm2 start ecosystem.config.js --env production pm2 start ecosystem.config.js --env production
else
\$PM2_CMD start ecosystem.config.js --env production
fi
# 保存PM2配置 # 保存PM2配置
if command -v pm2 &> /dev/null; then
pm2 save pm2 save
pm2 startup pm2 startup
else
\$PM2_CMD save
\$PM2_CMD startup
fi
echo "部署完成!" echo "部署完成!"
if command -v pm2 &> /dev/null; then
pm2 status pm2 status
else
\$PM2_CMD status || echo "无法执行pm2 status命令"
fi
EOF EOF
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then

View File

@@ -0,0 +1,887 @@
# 自定义挑战 API 接口文档
**版本**: v1.0
**更新日期**: 2025-01-25
**基础URL**: `https://your-domain.com/api`
## 目录
- [概述](#概述)
- [认证](#认证)
- [数据模型](#数据模型)
- [API 接口](#api-接口)
- [创建自定义挑战](#1-创建自定义挑战)
- [通过分享码加入挑战](#2-通过分享码加入挑战)
- [获取分享码对应的挑战信息](#3-获取分享码对应的挑战信息)
- [获取我创建的挑战列表](#4-获取我创建的挑战列表)
- [更新自定义挑战](#5-更新自定义挑战)
- [归档自定义挑战](#6-归档自定义挑战)
- [重新生成分享码](#7-重新生成分享码)
- [错误码说明](#错误码说明)
- [客户端集成示例](#客户端集成示例)
---
## 概述
自定义挑战功能允许用户创建自己的挑战,并通过分享码邀请其他用户参与。该功能完全兼容现有的系统挑战。
### 核心特性
- ✅ 用户可以自由创建挑战
- ✅ 自动生成6位唯一分享码
- ✅ 支持公开和私密两种模式
- ✅ 可设置参与人数限制
- ✅ 完全兼容现有的打卡、排行榜系统
---
## 认证
大部分接口需要用户认证。在请求头中添加 JWT Token
```http
Authorization: Bearer {your_jwt_token}
```
**公开接口**(无需认证):
- 获取分享码对应的挑战信息
---
## 数据模型
### ChallengeType挑战类型
```typescript
enum ChallengeType {
WATER = "water", // 喝水
EXERCISE = "exercise", // 运动
DIET = "diet", // 饮食
MOOD = "mood", // 心情
SLEEP = "sleep", // 睡眠
WEIGHT = "weight", // 体重
}
```
### ChallengeSource挑战来源
```typescript
enum ChallengeSource {
SYSTEM = "system", // 系统预设挑战
CUSTOM = "custom", // 用户自定义挑战
}
```
### ChallengeState挑战状态
```typescript
enum ChallengeState {
DRAFT = "draft", // 草稿(预留)
ACTIVE = "active", // 活跃
ARCHIVED = "archived", // 已归档
}
```
### CustomChallengeResponse自定义挑战响应
```typescript
interface CustomChallengeResponse {
id: string; // 挑战ID
title: string; // 挑战标题
type: ChallengeType; // 挑战类型
source: ChallengeSource; // 挑战来源
creatorId: string | null; // 创建者ID
shareCode: string | null; // 分享码6位大写字母和数字
image: string | null; // 封面图URL
startAt: number; // 开始时间戳(毫秒)
endAt: number; // 结束时间戳(毫秒)
periodLabel: string | null; // 周期标签,如"21天挑战"
durationLabel: string; // 持续时间标签,如"持续21天"
requirementLabel: string; // 要求标签,如"每日喝水8杯"
summary: string | null; // 挑战说明
targetValue: number; // 每日目标值
progressUnit: string; // 进度单位,默认"天"
minimumCheckInDays: number; // 最少打卡天数
rankingDescription: string | null; // 排行榜描述
highlightTitle: string; // 高亮标题
highlightSubtitle: string; // 高亮副标题
ctaLabel: string; // CTA按钮文字
isPublic: boolean; // 是否公开
maxParticipants: number | null; // 最大参与人数null=无限制)
challengeState: ChallengeState; // 挑战状态
participantsCount: number; // 当前参与人数
progress?: {
// 用户进度(仅加入后有值)
completed: number; // 已完成天数
target: number; // 目标天数
remaining: number; // 剩余天数
checkedInToday: boolean; // 今日是否已打卡
};
isJoined: boolean; // 当前用户是否已加入
isCreator: boolean; // 当前用户是否为创建者
createdAt: Date; // 创建时间
updatedAt: Date; // 更新时间
}
```
---
## API 接口
### 1. 创建自定义挑战
创建一个新的自定义挑战,系统会自动生成唯一的分享码。
**接口地址**: `POST /challenges/custom`
**是否需要认证**: ✅ 是
**请求头**:
```http
Content-Type: application/json
Authorization: Bearer {token}
```
**请求体**:
```json
{
"title": "21天喝水挑战",
"type": "water",
"image": "https://example.com/image.jpg",
"startAt": 1704067200000,
"endAt": 1705881600000,
"targetValue": 8,
"minimumCheckInDays": 21,
"durationLabel": "持续21天",
"requirementLabel": "每日喝水8杯",
"summary": "坚持每天喝足8杯水养成健康好习惯",
"progressUnit": "天",
"periodLabel": "21天挑战",
"rankingDescription": "连续打卡榜",
"highlightTitle": "坚持21天",
"highlightSubtitle": "养成喝水好习惯",
"ctaLabel": "立即加入",
"isPublic": true,
"maxParticipants": 100
}
```
**参数说明**:
| 参数 | 类型 | 必填 | 说明 |
| ------------------ | ------------- | ---- | ------------------------------------- |
| title | string | ✅ | 挑战标题最长100字符 |
| type | ChallengeType | ✅ | 挑战类型 |
| startAt | number | ✅ | 开始时间戳(毫秒),必须是未来时间 |
| endAt | number | ✅ | 结束时间戳(毫秒),必须晚于开始时间 |
| targetValue | number | ✅ | 每日目标值1-1000 |
| minimumCheckInDays | number | ✅ | 最少打卡天数1-365 |
| durationLabel | string | ✅ | 持续时间标签最长128字符 |
| requirementLabel | string | ✅ | 要求标签最长255字符 |
| image | string | ❌ | 封面图URL最长512字符 |
| summary | string | ❌ | 挑战说明 |
| progressUnit | string | ❌ | 进度单位,默认"天"最长64字符 |
| periodLabel | string | ❌ | 周期标签最长128字符 |
| rankingDescription | string | ❌ | 排行榜描述最长255字符 |
| highlightTitle | string | ❌ | 高亮标题最长255字符 |
| highlightSubtitle | string | ❌ | 高亮副标题最长255字符 |
| ctaLabel | string | ❌ | CTA按钮文字最长128字符 |
| isPublic | boolean | ❌ | 是否公开默认true |
| maxParticipants | number | ❌ | 最大参与人数2-10000null表示无限制 |
**成功响应**: `200 OK`
```json
{
"code": 0,
"message": "创建挑战成功",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"shareCode": "A3K9P2",
"title": "21天喝水挑战",
"type": "water",
"source": "custom",
"creatorId": "user_123",
"isPublic": true,
"maxParticipants": 100,
"participantsCount": 0,
"isJoined": false,
"isCreator": true,
...
}
}
```
**错误响应**:
```json
{
"code": 1,
"message": "每天最多创建 5 个挑战,请明天再试",
"data": null
}
```
---
### 2. 通过分享码加入挑战
使用分享码加入他人创建的挑战。
**接口地址**: `POST /challenges/join-by-code`
**是否需要认证**: ✅ 是
**请求头**:
```http
Content-Type: application/json
Authorization: Bearer {token}
```
**请求体**:
```json
{
"shareCode": "A3K9P2"
}
```
**参数说明**:
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | ------------------------------------ |
| shareCode | string | ✅ | 6-12位分享码只能包含大写字母和数字 |
**成功响应**: `200 OK`
```json
{
"code": 0,
"message": "加入挑战成功",
"data": {
"completed": 0,
"target": 21,
"remaining": 21,
"checkedInToday": false
}
}
```
**错误响应**:
```json
{
"code": 1,
"message": "分享码无效或挑战不存在",
"data": null
}
```
```json
{
"code": 1,
"message": "挑战人数已满",
"data": null
}
```
---
### 3. 获取分享码对应的挑战信息
通过分享码查看挑战详情(公开接口,无需登录)。
**接口地址**: `GET /challenges/share/{shareCode}`
**是否需要认证**: ❌ 否但提供token可获取更多信息
**路径参数**:
- `shareCode`: 分享码,如 `A3K9P2`
**请求示例**:
```http
GET /challenges/share/A3K9P2
```
**成功响应**: `200 OK`
```json
{
"code": 0,
"message": "获取挑战信息成功",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "21天喝水挑战",
"image": "https://example.com/image.jpg",
"periodLabel": "21天挑战",
"durationLabel": "持续21天",
"requirementLabel": "每日喝水8杯",
"summary": "坚持每天喝足8杯水",
"rankingDescription": "连续打卡榜",
"highlightTitle": "坚持21天",
"highlightSubtitle": "养成喝水好习惯",
"ctaLabel": "立即加入",
"minimumCheckInDays": 21,
"participantsCount": 15,
"progress": null,
"rankings": [...],
"userRank": null,
"unit": "天",
"type": "water"
}
}
```
**错误响应**:
```json
{
"code": 1,
"message": "分享码无效或挑战不存在",
"data": null
}
```
---
### 4. 获取我创建的挑战列表
获取当前用户创建的所有挑战。
**接口地址**: `GET /challenges/my/created`
**是否需要认证**: ✅ 是
**请求头**:
```http
Authorization: Bearer {token}
```
**查询参数**:
| 参数 | 类型 | 必填 | 说明 |
| -------- | -------------- | ---- | ------------------------- |
| page | number | ❌ | 页码默认1 |
| pageSize | number | ❌ | 每页数量默认20最大100 |
| state | ChallengeState | ❌ | 挑战状态筛选 |
**请求示例**:
```http
GET /challenges/my/created?page=1&pageSize=20&state=active
```
**成功响应**: `200 OK`
```json
{
"code": 0,
"message": "获取我创建的挑战列表成功",
"data": {
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"shareCode": "A3K9P2",
"title": "21天喝水挑战",
"type": "water",
"source": "custom",
"participantsCount": 15,
"challengeState": "active",
"isCreator": true,
...
}
],
"total": 5,
"page": 1,
"pageSize": 20
}
}
```
---
### 5. 更新自定义挑战
更新自定义挑战信息(仅创建者可操作)。
**接口地址**: `PUT /challenges/custom/{id}`
**是否需要认证**: ✅ 是(仅创建者)
**路径参数**:
- `id`: 挑战ID
**请求头**:
```http
Content-Type: application/json
Authorization: Bearer {token}
```
**请求体**:
```json
{
"title": "新的挑战标题",
"image": "https://example.com/new-image.jpg",
"summary": "更新的挑战说明",
"isPublic": false,
"maxParticipants": 50,
"highlightTitle": "新的高亮标题",
"highlightSubtitle": "新的高亮副标题",
"ctaLabel": "快来加入"
}
```
**参数说明**:
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------- | ---- | ------------ |
| title | string | ❌ | 挑战标题 |
| image | string | ❌ | 封面图URL |
| summary | string | ❌ | 挑战说明 |
| isPublic | boolean | ❌ | 是否公开 |
| maxParticipants | number | ❌ | 最大参与人数 |
| highlightTitle | string | ❌ | 高亮标题 |
| highlightSubtitle | string | ❌ | 高亮副标题 |
| ctaLabel | string | ❌ | CTA按钮文字 |
**⚠️ 重要**: 挑战开始后,只能编辑以下字段:
- summary挑战说明
- isPublic公开性
- highlightTitle高亮标题
- highlightSubtitle高亮副标题
- ctaLabelCTA文字
**成功响应**: `200 OK`
```json
{
"code": 0,
"message": "更新挑战成功",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "新的挑战标题",
...
}
}
```
**错误响应**:
```json
{
"code": 1,
"message": "只有创建者才能编辑挑战",
"data": null
}
```
```json
{
"code": 1,
"message": "挑战已开始,只能编辑概要、公开性和展示文案",
"data": null
}
```
---
### 6. 归档自定义挑战
归档(软删除)自定义挑战(仅创建者可操作)。
**接口地址**: `DELETE /challenges/custom/{id}`
**是否需要认证**: ✅ 是(仅创建者)
**路径参数**:
- `id`: 挑战ID
**请求头**:
```http
Authorization: Bearer {token}
```
**请求示例**:
```http
DELETE /challenges/custom/550e8400-e29b-41d4-a716-446655440000
```
**成功响应**: `200 OK`
```json
{
"code": 0,
"message": "归档挑战成功",
"data": true
}
```
**错误响应**:
```json
{
"code": 1,
"message": "只有创建者才能归档挑战",
"data": null
}
```
---
### 7. 重新生成分享码
为挑战重新生成一个新的分享码(仅创建者可操作)。
**接口地址**: `POST /challenges/custom/{id}/regenerate-code`
**是否需要认证**: ✅ 是(仅创建者)
**路径参数**:
- `id`: 挑战ID
**请求头**:
```http
Authorization: Bearer {token}
```
**请求示例**:
```http
POST /challenges/custom/550e8400-e29b-41d4-a716-446655440000/regenerate-code
```
**成功响应**: `200 OK`
```json
{
"code": 0,
"message": "重新生成分享码成功",
"data": {
"shareCode": "B7M4N9"
}
}
```
**使用场景**:
- 分享码泄露,需要更换
- 想要限制旧分享码的传播
- 重新组织挑战参与者
---
## 错误码说明
### 通用错误码
| code | message | 说明 |
| ---- | ------- | ------------------------------- |
| 0 | Success | 请求成功 |
| 1 | Error | 业务错误message中有具体说明 |
### 常见业务错误
| 错误信息 | 说明 | 解决方案 |
| -------------------------------------------- | ---------------------- | -------------------- |
| "每天最多创建 5 个挑战,请明天再试" | 创建频率限制 | 提示用户明天再试 |
| "分享码无效或挑战不存在" | 分享码错误或挑战已归档 | 提示用户检查分享码 |
| "挑战人数已满" | 达到最大参与人数 | 提示用户挑战已满 |
| "挑战已过期,无法加入" | 挑战已结束 | 提示挑战已结束 |
| "只有创建者才能编辑挑战" | 权限不足 | 提示只有创建者可操作 |
| "挑战已开始,只能编辑概要、公开性和展示文案" | 限制编辑 | 提示可编辑字段 |
| "已加入该挑战" | 重复加入 | 跳转到挑战详情页 |
### HTTP 状态码
| 状态码 | 说明 |
| ------ | -------------------- |
| 200 | 请求成功 |
| 400 | 请求参数错误 |
| 401 | 未授权(需要登录) |
| 403 | 禁止访问(权限不足) |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
---
## 客户端集成示例
### Swift (iOS)
#### 1. 创建挑战
```swift
struct CreateChallengeRequest: Codable {
let title: String
let type: String
let startAt: Int64
let endAt: Int64
let targetValue: Int
let minimumCheckInDays: Int
let durationLabel: String
let requirementLabel: String
let isPublic: Bool
}
func createChallenge(request: CreateChallengeRequest) async throws -> CustomChallengeResponse {
guard let url = URL(string: "\(baseURL)/challenges/custom") else {
throw NetworkError.invalidURL
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
urlRequest.httpBody = try JSONEncoder().encode(request)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.requestFailed
}
let result = try JSONDecoder().decode(APIResponse<CustomChallengeResponse>.self, from: data)
return result.data
}
```
#### 2. 通过分享码加入
```swift
func joinByShareCode(_ shareCode: String) async throws {
guard let url = URL(string: "\(baseURL)/challenges/join-by-code") else {
throw NetworkError.invalidURL
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let body = ["shareCode": shareCode]
urlRequest.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.requestFailed
}
let result = try JSONDecoder().decode(APIResponse<ChallengeProgress>.self, from: data)
// Handle success
}
```
#### 3. 分享功能
```swift
func shareChallengeCode(_ shareCode: String, title: String) {
let message = "邀请你加入挑战:\(title)\n分享码:\(shareCode)"
let activityVC = UIActivityViewController(
activityItems: [message],
applicationActivities: nil
)
present(activityVC, animated: true)
}
```
### Kotlin (Android)
#### 1. 创建挑战
```kotlin
data class CreateChallengeRequest(
val title: String,
val type: String,
val startAt: Long,
val endAt: Long,
val targetValue: Int,
val minimumCheckInDays: Int,
val durationLabel: String,
val requirementLabel: String,
val isPublic: Boolean = true
)
suspend fun createChallenge(request: CreateChallengeRequest): CustomChallengeResponse {
return withContext(Dispatchers.IO) {
val response = apiService.createChallenge(request)
if (response.code == 0) {
response.data
} else {
throw Exception(response.message)
}
}
}
```
#### 2. 通过分享码加入
```kotlin
suspend fun joinByShareCode(shareCode: String): ChallengeProgress {
return withContext(Dispatchers.IO) {
val request = JoinByCodeRequest(shareCode)
val response = apiService.joinByShareCode(request)
if (response.code == 0) {
response.data
} else {
throw Exception(response.message)
}
}
}
```
#### 3. 分享功能
```kotlin
fun shareChallenge(context: Context, shareCode: String, title: String) {
val message = "邀请你加入挑战:$title\n分享码:$shareCode"
val shareIntent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, message)
type = "text/plain"
}
context.startActivity(Intent.createChooser(shareIntent, "分享挑战"))
}
```
### TypeScript/JavaScript
#### 1. API 客户端封装
```typescript
class ChallengesAPI {
private baseURL: string;
private token: string;
constructor(baseURL: string, token: string) {
this.baseURL = baseURL;
this.token = token;
}
async createChallenge(
data: CreateChallengeRequest
): Promise<CustomChallengeResponse> {
const response = await fetch(`${this.baseURL}/challenges/custom`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.token}`,
},
body: JSON.stringify(data),
});
const result = await response.json();
if (result.code !== 0) {
throw new Error(result.message);
}
return result.data;
}
async joinByShareCode(shareCode: string): Promise<ChallengeProgress> {
const response = await fetch(`${this.baseURL}/challenges/join-by-code`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.token}`,
},
body: JSON.stringify({ shareCode }),
});
const result = await response.json();
if (result.code !== 0) {
throw new Error(result.message);
}
return result.data;
}
async getChallengeByShareCode(shareCode: string): Promise<ChallengeDetail> {
const response = await fetch(
`${this.baseURL}/challenges/share/${shareCode}`
);
const result = await response.json();
if (result.code !== 0) {
throw new Error(result.message);
}
return result.data;
}
}
```
#### 2. 使用示例
```typescript
const api = new ChallengesAPI("https://api.example.com/api", userToken);
// 创建挑战
try {
const challenge = await api.createChallenge({
title: "21天喝水挑战",
type: "water",
startAt: Date.now(),
endAt: Date.now() + 21 * 24 * 60 * 60 * 1000,
targetValue: 8,
minimumCheckInDays: 21,
durationLabel: "持续21天",
requirementLabel: "每日喝水8杯",
isPublic: true,
});
console.log("分享码:", challenge.shareCode);
} catch (error) {
console.error("创建失败:", error.message);
}
```
---
## 注意事项
### 1. 时间戳格式
- 所有时间戳均为**毫秒级**JavaScript: `Date.now()`
- 示例: `1704067200000`2024-01-01 00:00:00
### 2. 分享码规则
- 长度: 6-12位字符
- 字符集: 大写字母和数字A-Z, 2-9
- 排除易混淆字符: 0/O, 1/I/l
- 示例: `A3K9P2`, `B7M4N9`
### 3. 创建频率限制
- 每个用户每天最多创建 **5 个挑战**
- 超出限制会返回错误,建议提示用户
### 4. 人数限制
- `maxParticipants``null` 表示无限制
- 最小值: 2 人
- 最大值: 10000 人
### 5. 编辑限制
-

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

43
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.0.1",
"@nestjs/sequelize": "^11.0.0", "@nestjs/sequelize": "^11.0.0",
"@nestjs/swagger": "^11.1.0", "@nestjs/swagger": "^11.1.0",
"@parse/node-apn": "^5.0.0", "@parse/node-apn": "^5.0.0",
@@ -2382,6 +2383,19 @@
"@nestjs/core": "^11.0.0" "@nestjs/core": "^11.0.0"
} }
}, },
"node_modules/@nestjs/schedule": {
"version": "6.0.1",
"resolved": "https://mirrors.tencent.com/npm/@nestjs/schedule/-/schedule-6.0.1.tgz",
"integrity": "sha512-v3yO6cSPAoBSSyH67HWnXHzuhPhSNZhRmLY38JvCt2sqY8sPMOODpcU1D79iUMFf7k16DaMEbL4Mgx61ZhiC8Q==",
"license": "MIT",
"dependencies": {
"cron": "4.3.3"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/schematics": { "node_modules/@nestjs/schematics": {
"version": "11.0.2", "version": "11.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.2.tgz", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.2.tgz",
@@ -3452,6 +3466,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/luxon": {
"version": "3.7.1",
"resolved": "https://mirrors.tencent.com/npm/@types/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
"license": "MIT"
},
"node_modules/@types/methods": { "node_modules/@types/methods": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -5736,6 +5756,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cron": {
"version": "4.3.3",
"resolved": "https://mirrors.tencent.com/npm/cron/-/cron-4.3.3.tgz",
"integrity": "sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==",
"dependencies": {
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
}
},
"node_modules/croner": { "node_modules/croner": {
"version": "4.1.97", "version": "4.1.97",
"resolved": "https://mirrors.tencent.com/npm/croner/-/croner-4.1.97.tgz", "resolved": "https://mirrors.tencent.com/npm/croner/-/croner-4.1.97.tgz",
@@ -5795,7 +5827,7 @@
}, },
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.18", "version": "1.11.18",
"resolved": "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.18.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
"license": "MIT" "license": "MIT"
}, },
@@ -9473,6 +9505,15 @@
"url": "https://github.com/sponsors/wellwelwel" "url": "https://github.com/sponsors/wellwelwel"
} }
}, },
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://mirrors.tencent.com/npm/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",

View File

@@ -32,6 +32,7 @@
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.0.1",
"@nestjs/sequelize": "^11.0.0", "@nestjs/sequelize": "^11.0.0",
"@nestjs/swagger": "^11.1.0", "@nestjs/swagger": "^11.1.0",
"@parse/node-apn": "^5.0.0", "@parse/node-apn": "^5.0.0",

View File

@@ -0,0 +1,11 @@
-- 为 t_medication_records 表添加 reminder_sent 字段
-- 用于标记该条服药记录是否已经发送了提醒通知
ALTER TABLE `t_medication_records`
ADD COLUMN `reminder_sent` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已发送提醒' AFTER `deleted`;
-- 为已存在的记录设置默认值
UPDATE `t_medication_records` SET `reminder_sent` = 0 WHERE `reminder_sent` IS NULL;
-- 添加索引以优化查询性能(查询未发送提醒的记录)
CREATE INDEX `idx_reminder_sent_status_scheduled` ON `t_medication_records` (`reminder_sent`, `status`, `scheduled_time`);

View File

@@ -0,0 +1,43 @@
-- =====================================================
-- 自定义挑战功能数据库迁移脚本
-- 创建时间: 2025-01-25
-- 说明: 添加用户自定义挑战功能所需的字段和索引
-- =====================================================
-- 1. 扩展 t_challenges 表,添加自定义挑战相关字段
ALTER TABLE `t_challenges`
ADD COLUMN `source` ENUM('system', 'custom') NOT NULL DEFAULT 'system' COMMENT '挑战来源system=系统预设, custom=用户创建' AFTER `type`,
ADD COLUMN `creator_id` VARCHAR(64) NULL COMMENT '创建者用户ID仅custom类型有值' AFTER `source`,
ADD COLUMN `share_code` VARCHAR(12) NULL COMMENT '分享码6-12位字符用于加入挑战' AFTER `creator_id`,
ADD COLUMN `is_public` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否公开true=任何人可通过分享码加入, false=仅邀请' AFTER `share_code`,
ADD COLUMN `max_participants` INT NULL COMMENT '最大参与人数限制null表示无限制' AFTER `is_public`,
ADD COLUMN `challenge_state` ENUM('draft', 'active', 'archived') NOT NULL DEFAULT 'active' COMMENT '挑战状态draft=草稿, active=活跃, archived=已归档' AFTER `max_participants`;
-- 2. 创建索引以提升查询性能
ALTER TABLE `t_challenges`
ADD UNIQUE INDEX `idx_share_code` (`share_code`),
ADD INDEX `idx_creator_id` (`creator_id`),
ADD INDEX `idx_source_state` (`source`, `challenge_state`);
-- 3. 更新现有数据,标记为系统挑战
UPDATE `t_challenges`
SET `source` = 'system', `challenge_state` = 'active'
WHERE `source` IS NULL OR `source` = '';
-- 4. 验证数据迁移
SELECT
COUNT(*) as total_challenges,
SUM(CASE WHEN source = 'system' THEN 1 ELSE 0 END) as system_challenges,
SUM(CASE WHEN source = 'custom' THEN 1 ELSE 0 END) as custom_challenges,
SUM(CASE WHEN challenge_state = 'active' THEN 1 ELSE 0 END) as active_challenges,
SUM(CASE WHEN challenge_state = 'draft' THEN 1 ELSE 0 END) as draft_challenges,
SUM(CASE WHEN challenge_state = 'archived' THEN 1 ELSE 0 END) as archived_challenges
FROM `t_challenges`;
-- =====================================================
-- 迁移完成说明:
-- 1. 所有现有挑战已标记为系统挑战 (source='system')
-- 2. 所有现有挑战已标记为活跃状态 (challenge_state='active')
-- 3. 已创建必要的索引以提升查询性能
-- 4. share_code 字段有唯一索引,确保分享码唯一性
-- =====================================================

View File

@@ -0,0 +1,49 @@
-- 药物AI识别任务表创建脚本
-- 用于存储用户上传的药品图片和AI识别过程的状态追踪
CREATE TABLE IF NOT EXISTS `t_medication_recognition_tasks` (
`id` VARCHAR(100) NOT NULL COMMENT '任务唯一标识,格式: task_{userId}_{timestamp}',
`user_id` VARCHAR(50) NOT NULL COMMENT '用户ID',
`front_image_url` VARCHAR(500) NOT NULL COMMENT '正面图片URL必需',
`side_image_url` VARCHAR(500) NOT NULL COMMENT '侧面图片URL必需',
`auxiliary_image_url` VARCHAR(500) DEFAULT NULL COMMENT '辅助面图片URL可选如说明书',
`status` VARCHAR(50) NOT NULL DEFAULT 'pending' COMMENT '识别状态: pending/analyzing_product/analyzing_suitability/analyzing_ingredients/analyzing_effects/completed/failed',
`current_step` VARCHAR(200) DEFAULT NULL COMMENT '当前步骤描述,用于向用户展示',
`progress` INT NOT NULL DEFAULT 0 COMMENT '进度百分比(0-100)',
`recognition_result` TEXT DEFAULT NULL COMMENT '识别结果(JSON格式),包含药品名称、剂型、剂量、适宜人群等完整信息',
`error_message` TEXT DEFAULT NULL COMMENT '错误信息仅在status为failed时有值',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`completed_at` TIMESTAMP NULL DEFAULT NULL COMMENT '完成时间(成功或失败)',
PRIMARY KEY (`id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_status` (`status`),
INDEX `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='药物AI识别任务表';
-- 添加外键约束(可选,如果需要严格的数据完整性)
-- ALTER TABLE `t_medication_recognition_tasks`
-- ADD CONSTRAINT `fk_recognition_user_id`
-- FOREIGN KEY (`user_id`) REFERENCES `t_users`(`id`) ON DELETE CASCADE;
-- 示例数据结构说明
-- recognition_result JSON 格式示例:
/*
{
"name": "阿莫西林胶囊",
"photoUrl": "https://cdn.example.com/medications/front_001.jpg",
"form": "capsule",
"dosageValue": 0.25,
"dosageUnit": "g",
"timesPerDay": 3,
"medicationTimes": ["08:00", "14:00", "20:00"],
"suitableFor": ["成年人", "细菌感染患者"],
"unsuitableFor": ["青霉素过敏者", "孕妇", "哺乳期妇女"],
"mainIngredients": ["阿莫西林"],
"mainUsage": "用于敏感菌引起的各种感染",
"sideEffects": ["恶心", "呕吐", "腹泻", "皮疹"],
"storageAdvice": ["密封保存", "室温避光", "儿童接触不到的地方"],
"healthAdvice": ["按时服药", "多喝水", "避免饮酒"],
"confidence": 0.95
}
*/

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

@@ -0,0 +1,20 @@
-- 创建营养成分分析记录表
CREATE TABLE IF NOT EXISTS `t_nutrition_analysis_records` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` VARCHAR(255) NOT NULL COMMENT '用户ID',
`image_url` VARCHAR(500) NOT NULL COMMENT '分析图片URL',
`analysis_result` JSON NOT NULL COMMENT '营养成分分析结果',
`status` VARCHAR(50) NULL COMMENT '分析状态',
`message` TEXT NULL COMMENT '分析消息',
`ai_provider` VARCHAR(50) NULL COMMENT 'AI模型提供商',
`ai_model` VARCHAR(100) NULL COMMENT '使用的AI模型',
`nutrition_count` INT NULL COMMENT '识别到的营养素数量',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已删除',
PRIMARY KEY (`id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_created_at` (`created_at`),
INDEX `idx_status` (`status`),
INDEX `idx_user_deleted` (`user_id`, `deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='营养成分分析记录表';

View File

@@ -0,0 +1,16 @@
-- 创建推送提醒历史记录表
CREATE TABLE `t_push_reminder_history` (
`id` char(36) NOT NULL DEFAULT (UUID()),
`user_id` varchar(64) DEFAULT NULL COMMENT '用户ID可能为空',
`device_token` varchar(255) NOT NULL COMMENT '设备推送令牌',
`reminder_type` enum('challenge_encouragement','challenge_invitation','general_invitation') NOT NULL COMMENT '提醒类型',
`last_sent_at` datetime NOT NULL COMMENT '最后发送时间',
`sent_count` int NOT NULL DEFAULT '0' COMMENT '发送次数',
`next_available_at` datetime DEFAULT NULL COMMENT '下次可发送时间',
`is_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否激活',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_device_type` (`user_id`,`device_token`,`reminder_type`),
KEY `idx_last_sent_at` (`last_sent_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推送提醒历史记录表';

View File

@@ -152,7 +152,7 @@ export class MealDto {
items: MealItemDto[]; items: MealItemDto[];
} }
export class NutritionAnalysisRequestDto { export class NutritionAnalysisChatRequestDto {
@ApiProperty({ description: '会话ID。未提供则创建新会话' }) @ApiProperty({ description: '会话ID。未提供则创建新会话' })
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

@@ -4,6 +4,7 @@ import { AppService } from "./app.service";
import { DatabaseModule } from "./database/database.module"; import { DatabaseModule } from "./database/database.module";
import { UsersModule } from "./users/users.module"; import { UsersModule } from "./users/users.module";
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { LoggerModule } from './common/logger/logger.module'; import { LoggerModule } from './common/logger/logger.module';
import { CheckinsModule } from './checkins/checkins.module'; import { CheckinsModule } from './checkins/checkins.module';
import { AiCoachModule } from './ai-coach/ai-coach.module'; import { AiCoachModule } from './ai-coach/ai-coach.module';
@@ -20,6 +21,7 @@ import { FoodLibraryModule } from './food-library/food-library.module';
import { WaterRecordsModule } from './water-records/water-records.module'; import { WaterRecordsModule } from './water-records/water-records.module';
import { ChallengesModule } from './challenges/challenges.module'; import { ChallengesModule } from './challenges/challenges.module';
import { PushNotificationsModule } from './push-notifications/push-notifications.module'; import { PushNotificationsModule } from './push-notifications/push-notifications.module';
import { MedicationsModule } from './medications/medications.module';
@Module({ @Module({
imports: [ imports: [
@@ -27,6 +29,7 @@ import { PushNotificationsModule } from './push-notifications/push-notifications
isGlobal: true, isGlobal: true,
envFilePath: '.env', envFilePath: '.env',
}), }),
ScheduleModule.forRoot(),
LoggerModule, LoggerModule,
DatabaseModule, DatabaseModule,
UsersModule, UsersModule,
@@ -45,6 +48,7 @@ import { PushNotificationsModule } from './push-notifications/push-notifications
WaterRecordsModule, WaterRecordsModule,
ChallengesModule, ChallengesModule,
PushNotificationsModule, PushNotificationsModule,
MedicationsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@@ -1,3 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
export enum ResponseCode { export enum ResponseCode {
SUCCESS = 0, SUCCESS = 0,
ERROR = 1, ERROR = 1,
@@ -8,3 +10,38 @@ export interface BaseResponseDto<T> {
message: string; message: string;
data: T; data: T;
} }
/**
* 通用API响应结构体
* 包含code、message和data字段各业务场景可以继承并只需定义data类型
*/
export class ApiResponseDto<T = any> {
@ApiProperty({ description: '响应状态码', example: ResponseCode.SUCCESS })
code: ResponseCode;
@ApiProperty({ description: '响应消息', example: '操作成功' })
message: string;
@ApiProperty({ description: '响应数据' })
data: T;
constructor(code: ResponseCode, message: string, data: T) {
this.code = code;
this.message = message;
this.data = data;
}
/**
* 创建成功响应
*/
static success<T>(data: T, message: string = '操作成功'): ApiResponseDto<T> {
return new ApiResponseDto(ResponseCode.SUCCESS, message, data);
}
/**
* 创建失败响应
*/
static error<T = null>(message: string = '操作失败', data: T = null as any): ApiResponseDto<T> {
return new ApiResponseDto(ResponseCode.ERROR, message, data);
}
}

View File

@@ -1,4 +1,5 @@
import { Controller, Get, Param, Post, Body, UseGuards, Query } from '@nestjs/common'; import { Controller, Get, Param, Post, Body, UseGuards, Query, Put, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { ChallengesService } from './challenges.service'; import { ChallengesService } from './challenges.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { BaseResponseDto, ResponseCode } from '../base.dto'; import { BaseResponseDto, ResponseCode } from '../base.dto';
@@ -9,8 +10,14 @@ import { ChallengeDetailDto } from './dto/challenge-detail.dto';
import { ChallengeListItemDto } from './dto/challenge-list.dto'; import { ChallengeListItemDto } from './dto/challenge-list.dto';
import { ChallengeProgressDto } from './dto/challenge-progress.dto'; import { ChallengeProgressDto } from './dto/challenge-progress.dto';
import { ChallengeRankingListDto, GetChallengeRankingQueryDto } from './dto/challenge-ranking.dto'; import { ChallengeRankingListDto, GetChallengeRankingQueryDto } from './dto/challenge-ranking.dto';
import { CreateCustomChallengeDto } from './dto/create-custom-challenge.dto';
import { UpdateCustomChallengeDto } from './dto/update-custom-challenge.dto';
import { JoinByShareCodeDto } from './dto/join-by-share-code.dto';
import { CustomChallengeResponseDto } from './dto/custom-challenge-response.dto';
import { Public } from 'src/common/decorators/public.decorator'; import { Public } from 'src/common/decorators/public.decorator';
import { ChallengeState } from './models/challenge.model';
@ApiTags('挑战管理')
@Controller('challenges') @Controller('challenges')
export class ChallengesController { export class ChallengesController {
constructor(private readonly challengesService: ChallengesService) { } constructor(private readonly challengesService: ChallengesService) { }
@@ -104,4 +111,130 @@ export class ChallengesController {
data, data,
}; };
} }
// ==================== 自定义挑战 API ====================
@Post('custom')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: '创建自定义挑战' })
@ApiResponse({ status: 201, description: '创建成功' })
async createCustomChallenge(
@Body() dto: CreateCustomChallengeDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<CustomChallengeResponseDto>> {
const data = await this.challengesService.createCustomChallenge(user.sub, dto);
return {
code: ResponseCode.SUCCESS,
message: '创建挑战成功',
data,
};
}
@Post('join-by-code')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: '通过分享码加入挑战' })
@ApiResponse({ status: 200, description: '加入成功' })
async joinByShareCode(
@Body() dto: JoinByShareCodeDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<ChallengeProgressDto>> {
const data = await this.challengesService.joinByShareCode(user.sub, dto.shareCode);
return {
code: ResponseCode.SUCCESS,
message: '加入挑战成功',
data,
};
}
@Get('share/:shareCode')
@Public()
@ApiOperation({ summary: '获取分享码对应的挑战信息(公开接口)' })
@ApiResponse({ status: 200, description: '获取成功' })
async getChallengeByShareCode(
@Param('shareCode') shareCode: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<ChallengeDetailDto>> {
const data = await this.challengesService.getChallengeByShareCode(shareCode, user?.sub);
return {
code: ResponseCode.SUCCESS,
message: '获取挑战信息成功',
data,
};
}
@Get('my/created')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: '获取我创建的挑战列表' })
@ApiResponse({ status: 200, description: '获取成功' })
async getMyCreatedChallenges(
@CurrentUser() user: AccessTokenPayload,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('state') state?: ChallengeState,
): Promise<BaseResponseDto<{
items: CustomChallengeResponseDto[];
total: number;
page: number;
pageSize: number;
}>> {
const data = await this.challengesService.getMyCreatedChallenges(user.sub, {
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
state,
});
return {
code: ResponseCode.SUCCESS,
message: '获取我创建的挑战列表成功',
data,
};
}
@Put('custom/:id')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: '更新自定义挑战' })
@ApiResponse({ status: 200, description: '更新成功' })
async updateCustomChallenge(
@Param('id') id: string,
@Body() dto: UpdateCustomChallengeDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<CustomChallengeResponseDto>> {
const data = await this.challengesService.updateCustomChallenge(user.sub, id, dto);
return {
code: ResponseCode.SUCCESS,
message: '更新挑战成功',
data,
};
}
@Delete('custom/:id')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: '归档自定义挑战' })
@ApiResponse({ status: 200, description: '归档成功' })
async archiveCustomChallenge(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<boolean>> {
const data = await this.challengesService.archiveCustomChallenge(user.sub, id);
return {
code: ResponseCode.SUCCESS,
message: '归档挑战成功',
data,
};
}
@Post('custom/:id/regenerate-code')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: '重新生成分享码' })
@ApiResponse({ status: 200, description: '生成成功' })
async regenerateShareCode(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<{ shareCode: string }>> {
const shareCode = await this.challengesService.regenerateShareCode(user.sub, id);
return {
code: ResponseCode.SUCCESS,
message: '重新生成分享码成功',
data: { shareCode },
};
}
} }

View File

@@ -7,10 +7,11 @@ import { ChallengeParticipant } from './models/challenge-participant.model';
import { ChallengeProgressReport } from './models/challenge-progress-report.model'; import { ChallengeProgressReport } from './models/challenge-progress-report.model';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { User } from '../users/models/user.model'; import { User } from '../users/models/user.model';
import { BadgeConfig } from '../users/models/badge-config.model';
@Module({ @Module({
imports: [ imports: [
SequelizeModule.forFeature([Challenge, ChallengeParticipant, ChallengeProgressReport, User]), SequelizeModule.forFeature([Challenge, ChallengeParticipant, ChallengeProgressReport, User, BadgeConfig]),
UsersModule, UsersModule,
], ],
controllers: [ChallengesController], controllers: [ChallengesController],

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException, ConflictException, Inject } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException, ConflictException, Inject, ForbiddenException } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize'; import { InjectModel } from '@nestjs/sequelize';
import { Challenge, ChallengeStatus } from './models/challenge.model'; import { Challenge, ChallengeStatus, ChallengeType, ChallengeSource, ChallengeState } from './models/challenge.model';
import { ChallengeParticipant, ChallengeParticipantStatus } from './models/challenge-participant.model'; import { ChallengeParticipant, ChallengeParticipantStatus } from './models/challenge-participant.model';
import { ChallengeProgressReport } from './models/challenge-progress-report.model'; import { ChallengeProgressReport } from './models/challenge-progress-report.model';
import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto'; import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto';
@@ -8,11 +8,17 @@ import { ChallengeDetailDto } from './dto/challenge-detail.dto';
import { ChallengeListItemDto } from './dto/challenge-list.dto'; import { ChallengeListItemDto } from './dto/challenge-list.dto';
import { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.dto'; import { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.dto';
import { ChallengeRankingListDto } from './dto/challenge-ranking.dto'; import { ChallengeRankingListDto } from './dto/challenge-ranking.dto';
import { CreateCustomChallengeDto } from './dto/create-custom-challenge.dto';
import { UpdateCustomChallengeDto } from './dto/update-custom-challenge.dto';
import { CustomChallengeResponseDto } from './dto/custom-challenge-response.dto';
import { fn, col, Op, UniqueConstraintError } from 'sequelize'; import { fn, col, Op, UniqueConstraintError } from 'sequelize';
import * as dayjs from 'dayjs'; import * as dayjs from 'dayjs';
import { User } from '../users/models/user.model'; import { User } from '../users/models/user.model';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger as WinstonLogger } from 'winston'; import { Logger as WinstonLogger } from 'winston';
import { BadgeService } from '../users/services/badge.service';
import { BadgeSource } from '../users/models/user-badge.model';
import { BadgeConfig } from '../users/models/badge-config.model';
@Injectable() @Injectable()
export class ChallengesService { export class ChallengesService {
@@ -26,10 +32,35 @@ export class ChallengesService {
private readonly participantModel: typeof ChallengeParticipant, private readonly participantModel: typeof ChallengeParticipant,
@InjectModel(ChallengeProgressReport) @InjectModel(ChallengeProgressReport)
private readonly progressReportModel: typeof ChallengeProgressReport, private readonly progressReportModel: typeof ChallengeProgressReport,
@InjectModel(BadgeConfig)
private readonly badgeConfigModel: typeof BadgeConfig,
private readonly badgeService: BadgeService,
) { } ) { }
async getChallengesForUser(userId?: string): Promise<ChallengeListItemDto[]> { async getChallengesForUser(userId?: string): Promise<ChallengeListItemDto[]> {
// 获取系统挑战 + 用户已加入的自定义挑战 + 用户创建的自定义挑战
const whereConditions: any[] = [
{ source: ChallengeSource.SYSTEM, challengeState: ChallengeState.ACTIVE },
];
if (userId) {
// 获取用户加入的自定义挑战 ID
const joinedChallengeIds = await this.getJoinedCustomChallengeIds(userId);
whereConditions.push(
{ creatorId: userId, source: ChallengeSource.CUSTOM }, // 我创建的
{
id: { [Op.in]: joinedChallengeIds },
source: ChallengeSource.CUSTOM,
challengeState: ChallengeState.ACTIVE
} // 我加入的
);
}
const challenges = await this.challengeModel.findAll({ const challenges = await this.challengeModel.findAll({
where: {
[Op.or]: whereConditions,
},
order: [['startAt', 'ASC']], order: [['startAt', 'ASC']],
}); });
@@ -50,7 +81,7 @@ export class ChallengesService {
challenge, challenge,
status: this.computeStatus(challenge.startAt, challenge.endAt), status: this.computeStatus(challenge.startAt, challenge.endAt),
})) }))
.filter(({ status }) => status !== ChallengeStatus.UPCOMING) .filter(({ status }) => status === ChallengeStatus.ONGOING)
.sort((a, b) => { .sort((a, b) => {
const priorityDiff = statusPriority[a.status] - statusPriority[b.status]; const priorityDiff = statusPriority[a.status] - statusPriority[b.status];
if (priorityDiff !== 0) { if (priorityDiff !== 0) {
@@ -100,6 +131,25 @@ export class ChallengesService {
} }
} }
// 🎖️ 查询 sleepChallengeMonth 勋章信息(仅在有睡眠挑战时查询)
const hasSleepChallenge = challengesWithStatus.some(({ challenge }) => challenge.type === ChallengeType.SLEEP);
let sleepBadge: ChallengeListItemDto['badge'] = undefined;
if (hasSleepChallenge) {
const badgeConfig = await this.badgeConfigModel.findOne({
where: { code: 'sleepChallengeMonth', isActive: true },
});
if (badgeConfig) {
sleepBadge = {
code: badgeConfig.code,
name: badgeConfig.name,
description: badgeConfig.description,
imageUrl: badgeConfig.imageUrl,
category: badgeConfig.category,
};
}
}
return challengesWithStatus.map(({ challenge, status }) => { return challengesWithStatus.map(({ challenge, status }) => {
const completionTarget = challenge.minimumCheckInDays const completionTarget = challenge.minimumCheckInDays
const participation = participationMap.get(challenge.id); const participation = participationMap.get(challenge.id);
@@ -116,8 +166,8 @@ export class ChallengesService {
requirementLabel: challenge.requirementLabel, requirementLabel: challenge.requirementLabel,
status, status,
unit: challenge.progressUnit, unit: challenge.progressUnit,
startAt: challenge.startAt, startAt: new Date(challenge.startAt).getTime(),
endAt: challenge.endAt, endAt: new Date(challenge.endAt).getTime(),
participantsCount: participantsCountMap.get(challenge.id) ?? 0, participantsCount: participantsCountMap.get(challenge.id) ?? 0,
rankingDescription: challenge.rankingDescription, rankingDescription: challenge.rankingDescription,
highlightTitle: challenge.highlightTitle, highlightTitle: challenge.highlightTitle,
@@ -127,6 +177,7 @@ export class ChallengesService {
progress, progress,
isJoined: Boolean(participation), isJoined: Boolean(participation),
type: challenge.type, type: challenge.type,
badge: challenge.type === ChallengeType.SLEEP ? sleepBadge : undefined,
}; };
}); });
} }
@@ -195,6 +246,24 @@ export class ChallengesService {
const userRank = participation ? await this.calculateUserRank(challengeId, participation) : undefined; const userRank = participation ? await this.calculateUserRank(challengeId, participation) : undefined;
// 🎖️ 如果是睡眠挑战,获取 sleepChallengeMonth 勋章信息
let badge: ChallengeDetailDto['badge'] = undefined;
if (challenge.type === ChallengeType.SLEEP) {
const badgeConfig = await this.badgeConfigModel.findOne({
where: { code: 'sleepChallengeMonth', isActive: true },
});
if (badgeConfig) {
badge = {
code: badgeConfig.code,
name: badgeConfig.name,
description: badgeConfig.description,
imageUrl: badgeConfig.imageUrl,
category: badgeConfig.category,
};
}
}
return { return {
id: challenge.id, id: challenge.id,
title: challenge.title, title: challenge.title,
@@ -214,6 +283,7 @@ export class ChallengesService {
userRank, userRank,
unit: challenge.progressUnit, unit: challenge.progressUnit,
type: challenge.type, type: challenge.type,
badge,
}; };
} }
@@ -428,6 +498,47 @@ export class ChallengesService {
} }
} }
this.winstonLogger.info('progress report updated', {
context: 'reportProgress',
userId,
challengeId,
reportDate,
reportedValue,
})
// 🎖️ 检查是否为睡眠挑战且完成了第一次打卡,授予 goodSleep 勋章
if (challenge.type === ChallengeType.SLEEP) {
this.winstonLogger.info('检查是否为睡眠挑战且完成了第一次打卡,授予 goodSleep 勋章', {
context: 'reportProgress',
userId,
challengeId,
badgeCode: 'goodSleep',
})
try {
await this.badgeService.awardBadge(userId, 'goodSleep', {
source: BadgeSource.CHALLENGE,
sourceId: challengeId,
metadata: {
challengeName: challenge.title,
challengeType: challenge.type,
},
});
this.winstonLogger.info('授予睡眠挑战勋章成功', {
context: 'reportProgress',
userId,
challengeId,
badgeCode: 'goodSleep',
});
} catch (error) {
// 勋章授予失败不应影响主流程,仅记录日志
this.winstonLogger.error('授予睡眠挑战勋章失败', {
context: 'reportProgress',
userId,
challengeId,
error: error instanceof Error ? error.message : '未知错误',
});
}
}
if (report.reportedValue >= reportCompletedValue && !dayjs(participant.lastProgressAt).isSame(dayjs(), 'd')) { if (report.reportedValue >= reportCompletedValue && !dayjs(participant.lastProgressAt).isSame(dayjs(), 'd')) {
participant.progressValue++ participant.progressValue++
@@ -436,6 +547,38 @@ export class ChallengesService {
if (participant.progressValue >= (participant.challenge?.minimumCheckInDays || 0) && participant.status !== ChallengeParticipantStatus.COMPLETED) { if (participant.progressValue >= (participant.challenge?.minimumCheckInDays || 0) && participant.status !== ChallengeParticipantStatus.COMPLETED) {
participant.status = ChallengeParticipantStatus.COMPLETED; participant.status = ChallengeParticipantStatus.COMPLETED;
// 🎖️ 完成睡眠挑战时,授予 sleepChallengeMonth 勋章
if (challenge.type === ChallengeType.SLEEP) {
try {
await this.badgeService.awardBadge(userId, 'sleepChallengeMonth', {
source: BadgeSource.CHALLENGE,
sourceId: challengeId,
metadata: {
challengeName: challenge.title,
challengeType: challenge.type,
completedDays: participant.progressValue,
completedAt: new Date(),
},
});
this.winstonLogger.info('授予睡眠挑战完成勋章成功', {
context: 'reportProgress',
userId,
challengeId,
badgeCode: 'sleepChallengeMonth',
completedDays: participant.progressValue,
});
} catch (error) {
// 勋章授予失败不应影响主流程,仅记录日志
this.winstonLogger.error('授予睡眠挑战完成勋章失败', {
context: 'reportProgress',
userId,
challengeId,
badgeCode: 'sleepChallengeMonth',
error: error instanceof Error ? error.message : '未知错误',
});
}
}
} }
await participant.save(); await participant.save();
@@ -474,7 +617,7 @@ export class ChallengesService {
} }
private computeStatus(startAt: number, endAt: number): ChallengeStatus { private computeStatus(startAt: Date | number, endAt: Date | number): ChallengeStatus {
const now = dayjs(); const now = dayjs();
const start = dayjs(startAt); const start = dayjs(startAt);
const end = dayjs(endAt); const end = dayjs(endAt);
@@ -577,4 +720,436 @@ export class ChallengesService {
total: count, total: count,
}; };
} }
// ==================== 自定义挑战功能 ====================
/**
* 生成唯一的分享码
*/
private async generateUniqueShareCode(): Promise<string> {
const chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // 避免混淆字符
let attempts = 0;
const maxAttempts = 10;
while (attempts < maxAttempts) {
let code = '';
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
const existing = await this.challengeModel.findOne({
where: { shareCode: code },
});
if (!existing) {
return code;
}
attempts++;
}
// 如果 10 次都冲突,使用更长的码
let code = '';
for (let i = 0; i < 8; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
/**
* 获取用户加入的自定义挑战 ID 列表
*/
private async getJoinedCustomChallengeIds(userId: string): Promise<string[]> {
const participants = await this.participantModel.findAll({
where: {
userId,
status: {
[Op.ne]: ChallengeParticipantStatus.LEFT,
},
},
attributes: ['challengeId'],
raw: true,
});
return participants.map(p => p.challengeId);
}
/**
* 检查用户是否为挑战创建者
*/
private async isCreator(userId: string, challengeId: string): Promise<boolean> {
const challenge = await this.challengeModel.findByPk(challengeId);
return challenge?.source === ChallengeSource.CUSTOM && challenge.creatorId === userId;
}
/**
* 检查挑战是否可以加入
*/
private async canJoinChallenge(challenge: Challenge): Promise<{ canJoin: boolean; reason?: string }> {
// 检查挑战状态
if (challenge.challengeState !== ChallengeState.ACTIVE) {
return { canJoin: false, reason: '挑战未激活' };
}
// 检查时间
const status = this.computeStatus(challenge.startAt, challenge.endAt);
if (status === ChallengeStatus.EXPIRED) {
return { canJoin: false, reason: '挑战已过期' };
}
// 检查人数限制
if (challenge.maxParticipants) {
const count = await this.participantModel.count({
where: {
challengeId: challenge.id,
status: ChallengeParticipantStatus.ACTIVE,
},
});
if (count >= challenge.maxParticipants) {
return { canJoin: false, reason: '挑战人数已满' };
}
}
return { canJoin: true };
}
/**
* 创建自定义挑战
*/
async createCustomChallenge(userId: string, dto: CreateCustomChallengeDto): Promise<CustomChallengeResponseDto> {
// 验证时间
if (dto.startAt >= dto.endAt) {
throw new BadRequestException('结束时间必须晚于开始时间');
}
// 将毫秒时间戳转换为 Date 对象,以匹配数据库 DATETIME 类型
const startAtDate = new Date(dto.startAt);
const endAtDate = new Date(dto.endAt);
// 验证日期是否有效
if (isNaN(startAtDate.getTime()) || isNaN(endAtDate.getTime())) {
throw new BadRequestException('无效的时间戳');
}
// 检查创建频率限制(每天最多创建 5 个)
const recentCount = await this.challengeModel.count({
where: {
creatorId: userId,
createdAt: {
[Op.gte]: dayjs().subtract(24, 'hour').toDate(),
},
},
});
if (recentCount >= 5) {
throw new BadRequestException('每天最多创建 5 个挑战,请明天再试');
}
// 生成分享码
const shareCode = await this.generateUniqueShareCode();
// 创建挑战
const challenge = await this.challengeModel.create({
title: dto.title,
type: dto.type,
image: dto.image || null,
startAt: startAtDate,
endAt: endAtDate,
periodLabel: dto.periodLabel || null,
durationLabel: dto.durationLabel,
requirementLabel: dto.requirementLabel,
summary: dto.summary || null,
targetValue: dto.targetValue,
progressUnit: dto.progressUnit || '天',
minimumCheckInDays: dto.minimumCheckInDays,
rankingDescription: dto.rankingDescription || '连续打卡榜',
highlightTitle: dto.title,
highlightSubtitle: dto.summary || '养成好习惯',
ctaLabel: '立即加入',
source: ChallengeSource.CUSTOM,
creatorId: userId,
shareCode,
isPublic: dto.isPublic !== undefined ? dto.isPublic : true,
maxParticipants: dto.maxParticipants || null,
challengeState: ChallengeState.ACTIVE,
});
// 创建者自动加入挑战
await this.joinChallenge(userId, challenge.id);
this.winstonLogger.info('创建自定义挑战成功,创建者已自动加入', {
context: 'createCustomChallenge',
userId,
challengeId: challenge.id,
shareCode,
});
return this.buildCustomChallengeResponse(challenge, userId);
}
/**
* 通过分享码加入挑战
*/
async joinByShareCode(userId: string, shareCode: string): Promise<ChallengeProgressDto> {
const challenge = await this.challengeModel.findOne({
where: {
shareCode: shareCode.toUpperCase(),
challengeState: ChallengeState.ACTIVE,
},
});
if (!challenge) {
throw new NotFoundException('分享码无效或挑战不存在');
}
// 检查是否可以加入
const { canJoin, reason } = await this.canJoinChallenge(challenge);
if (!canJoin) {
throw new BadRequestException(reason || '无法加入挑战');
}
// 使用现有的加入逻辑
return this.joinChallenge(userId, challenge.id);
}
/**
* 获取分享码对应的挑战信息(公开接口)
*/
async getChallengeByShareCode(shareCode: string, userId?: string): Promise<ChallengeDetailDto> {
const challenge = await this.challengeModel.findOne({
where: {
shareCode: shareCode.toUpperCase(),
challengeState: ChallengeState.ACTIVE,
},
});
if (!challenge) {
throw new NotFoundException('分享码无效或挑战不存在');
}
return this.getChallengeDetail(challenge.id, userId);
}
/**
* 更新自定义挑战
*/
async updateCustomChallenge(
userId: string,
challengeId: string,
dto: UpdateCustomChallengeDto,
): Promise<CustomChallengeResponseDto> {
const challenge = await this.challengeModel.findByPk(challengeId);
if (!challenge) {
throw new NotFoundException('挑战不存在');
}
if (challenge.source !== ChallengeSource.CUSTOM) {
throw new BadRequestException('只能编辑自定义挑战');
}
if (challenge.creatorId !== userId) {
throw new ForbiddenException('只有创建者才能编辑挑战');
}
// 如果挑战已开始,限制可编辑字段
const status = this.computeStatus(challenge.startAt, challenge.endAt);
if (status !== ChallengeStatus.UPCOMING) {
// 挑战已开始,只允许编辑部分字段
const allowedFields: (keyof UpdateCustomChallengeDto)[] = [
'summary',
'isPublic',
'highlightTitle',
'highlightSubtitle',
'ctaLabel',
];
const restrictedFields = Object.keys(dto).filter(
key => !allowedFields.includes(key as keyof UpdateCustomChallengeDto)
);
if (restrictedFields.length > 0) {
throw new BadRequestException('挑战已开始,只能编辑概要、公开性和展示文案');
}
}
// 更新挑战
await challenge.update(dto);
this.winstonLogger.info('更新自定义挑战成功', {
context: 'updateCustomChallenge',
userId,
challengeId,
updates: Object.keys(dto),
});
return this.buildCustomChallengeResponse(challenge, userId);
}
/**
* 归档自定义挑战
*/
async archiveCustomChallenge(userId: string, challengeId: string): Promise<boolean> {
const challenge = await this.challengeModel.findByPk(challengeId);
if (!challenge) {
throw new NotFoundException('挑战不存在');
}
if (challenge.source !== ChallengeSource.CUSTOM) {
throw new BadRequestException('只能归档自定义挑战');
}
if (challenge.creatorId !== userId) {
throw new ForbiddenException('只有创建者才能归档挑战');
}
await challenge.update({ challengeState: ChallengeState.ARCHIVED });
this.winstonLogger.info('归档自定义挑战成功', {
context: 'archiveCustomChallenge',
userId,
challengeId,
});
return true;
}
/**
* 重新生成分享码
*/
async regenerateShareCode(userId: string, challengeId: string): Promise<string> {
const challenge = await this.challengeModel.findByPk(challengeId);
if (!challenge) {
throw new NotFoundException('挑战不存在');
}
if (challenge.source !== ChallengeSource.CUSTOM) {
throw new BadRequestException('只能为自定义挑战重新生成分享码');
}
if (challenge.creatorId !== userId) {
throw new ForbiddenException('只有创建者才能重新生成分享码');
}
const newShareCode = await this.generateUniqueShareCode();
await challenge.update({ shareCode: newShareCode });
this.winstonLogger.info('重新生成分享码成功', {
context: 'regenerateShareCode',
userId,
challengeId,
oldShareCode: challenge.shareCode,
newShareCode,
});
return newShareCode;
}
/**
* 获取我创建的挑战列表
*/
async getMyCreatedChallenges(
userId: string,
params: { page?: number; pageSize?: number; state?: ChallengeState } = {},
): Promise<{ items: CustomChallengeResponseDto[]; total: number; page: number; pageSize: number }> {
const page = params.page && params.page > 0 ? params.page : 1;
const pageSize = params.pageSize && params.pageSize > 0 ? Math.min(params.pageSize, 100) : 20;
const offset = (page - 1) * pageSize;
const where: any = {
creatorId: userId,
source: ChallengeSource.CUSTOM,
};
if (params.state) {
where.challengeState = params.state;
}
const { rows, count } = await this.challengeModel.findAndCountAll({
where,
order: [['createdAt', 'DESC']],
limit: pageSize,
offset,
});
const items = await Promise.all(
rows.map(challenge => this.buildCustomChallengeResponse(challenge, userId))
);
return {
items,
total: count,
page,
pageSize,
};
}
/**
* 构建自定义挑战响应
*/
private async buildCustomChallengeResponse(
challenge: Challenge,
userId: string,
): Promise<CustomChallengeResponseDto> {
const [participantsCount, participation] = await Promise.all([
this.participantModel.count({
where: {
challengeId: challenge.id,
status: ChallengeParticipantStatus.ACTIVE,
},
}),
this.participantModel.findOne({
where: {
challengeId: challenge.id,
userId,
status: {
[Op.ne]: ChallengeParticipantStatus.LEFT,
},
},
}),
]);
const progress = participation
? this.buildChallengeProgress(
participation.progressValue,
challenge.minimumCheckInDays,
participation.lastProgressAt,
)
: undefined;
return {
id: challenge.id,
title: challenge.title,
type: challenge.type,
source: challenge.source as ChallengeSource,
creatorId: challenge.creatorId,
shareCode: challenge.shareCode,
image: challenge.image,
startAt: new Date(challenge.startAt).getTime(),
endAt: new Date(challenge.endAt).getTime(),
periodLabel: challenge.periodLabel,
durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel,
summary: challenge.summary,
targetValue: challenge.targetValue,
progressUnit: challenge.progressUnit,
minimumCheckInDays: challenge.minimumCheckInDays,
rankingDescription: challenge.rankingDescription,
highlightTitle: challenge.highlightTitle,
highlightSubtitle: challenge.highlightSubtitle,
ctaLabel: challenge.ctaLabel,
isPublic: challenge.isPublic,
maxParticipants: challenge.maxParticipants,
challengeState: challenge.challengeState as ChallengeState,
participantsCount,
progress,
isJoined: Boolean(participation),
isCreator: challenge.creatorId === userId,
createdAt: challenge.createdAt,
updatedAt: challenge.updatedAt,
};
}
} }

View File

@@ -1,6 +1,14 @@
import { ChallengeProgressDto, RankingItemDto } from './challenge-progress.dto'; import { ChallengeProgressDto, RankingItemDto } from './challenge-progress.dto';
import { ChallengeType } from '../models/challenge.model'; import { ChallengeType } from '../models/challenge.model';
export interface BadgeInfoDto {
code: string;
name: string;
description: string;
imageUrl: string;
category: string;
}
export interface ChallengeDetailDto { export interface ChallengeDetailDto {
id: string; id: string;
title: string; title: string;
@@ -20,4 +28,5 @@ export interface ChallengeDetailDto {
userRank?: number; userRank?: number;
type: ChallengeType; type: ChallengeType;
unit: string; unit: string;
badge?: BadgeInfoDto;
} }

View File

@@ -1,6 +1,14 @@
import { ChallengeStatus, ChallengeType } from '../models/challenge.model'; import { ChallengeStatus, ChallengeType } from '../models/challenge.model';
import { ChallengeProgressDto } from './challenge-progress.dto'; import { ChallengeProgressDto } from './challenge-progress.dto';
export interface BadgeInfoDto {
code: string;
name: string;
description: string;
imageUrl: string;
category: string;
}
export interface ChallengeListItemDto { export interface ChallengeListItemDto {
id: string; id: string;
title: string; title: string;
@@ -21,6 +29,7 @@ export interface ChallengeListItemDto {
isJoined: boolean; isJoined: boolean;
type: ChallengeType; type: ChallengeType;
unit: string; unit: string;
badge?: BadgeInfoDto;
} }
export interface ChallengeListResponseDto { export interface ChallengeListResponseDto {

View File

@@ -0,0 +1,92 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsNumber, Min, Max, IsEnum, IsOptional, IsBoolean, MaxLength, MinLength } from 'class-validator';
import { ChallengeType } from '../models/challenge.model';
export class CreateCustomChallengeDto {
@ApiProperty({ description: '挑战标题', example: '21天喝水挑战' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
title: string;
@ApiProperty({ description: '挑战类型', enum: ChallengeType, example: ChallengeType.WATER })
@IsEnum(ChallengeType)
@IsNotEmpty()
type: ChallengeType;
@ApiProperty({ description: '挑战封面图 URL', required: false })
@IsString()
@IsOptional()
@MaxLength(512)
image?: string;
@ApiProperty({ description: '开始时间戳(毫秒)', example: 1704067200000 })
@IsNumber()
@Min(Date.now())
startAt: number;
@ApiProperty({ description: '结束时间戳(毫秒)', example: 1705881600000 })
@IsNumber()
@Min(Date.now() + 86400000) // 至少未来 1 天
endAt: number;
@ApiProperty({ description: '每日目标值如喝水8杯', example: 8, minimum: 1, maximum: 1000 })
@IsNumber()
@Min(1)
@Max(1000)
targetValue: number;
@ApiProperty({ description: '最少打卡天数', example: 21, minimum: 1, maximum: 365 })
@IsNumber()
@Min(1)
@Max(365)
minimumCheckInDays: number;
@ApiProperty({ description: '持续时间标签', example: '持续21天' })
@IsString()
@IsNotEmpty()
@MaxLength(128)
durationLabel: string;
@ApiProperty({ description: '挑战要求标签', example: '每日喝水8杯' })
@IsString()
@IsNotEmpty()
@MaxLength(255)
requirementLabel: string;
@ApiProperty({ description: '挑战概要说明', required: false })
@IsString()
@IsOptional()
summary?: string;
@ApiProperty({ description: '进度单位', example: '天', required: false })
@IsString()
@IsOptional()
@MaxLength(64)
progressUnit?: string;
@ApiProperty({ description: '周期标签', example: '21天挑战', required: false })
@IsString()
@IsOptional()
@MaxLength(128)
periodLabel?: string;
@ApiProperty({ description: '排行榜描述', example: '连续打卡榜', required: false })
@IsString()
@IsOptional()
@MaxLength(255)
rankingDescription?: string;
@ApiProperty({ description: '是否公开(可通过分享码加入)', default: true })
@IsBoolean()
@IsOptional()
isPublic?: boolean;
@ApiProperty({ description: '最大参与人数限制null表示无限制', required: false, minimum: 2, maximum: 10000 })
@IsNumber()
@IsOptional()
@Min(2)
@Max(10000)
maxParticipants?: number;
}

View File

@@ -0,0 +1,40 @@
import { ChallengeType, ChallengeSource, ChallengeState } from '../models/challenge.model';
import { ChallengeProgressDto } from './challenge-progress.dto';
export interface CustomChallengeResponseDto {
id: string;
title: string;
type: ChallengeType;
source: ChallengeSource;
creatorId: string | null;
shareCode: string | null;
image: string | null;
startAt: number;
endAt: number;
periodLabel: string | null;
durationLabel: string;
requirementLabel: string;
summary: string | null;
targetValue: number;
progressUnit: string;
minimumCheckInDays: number;
rankingDescription: string | null;
highlightTitle: string;
highlightSubtitle: string;
ctaLabel: string;
isPublic: boolean;
maxParticipants: number | null;
challengeState: ChallengeState;
participantsCount: number;
progress?: ChallengeProgressDto;
isJoined: boolean;
isCreator: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface MyCreatedChallengesQueryDto {
page?: number;
pageSize?: number;
state?: ChallengeState;
}

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, Length, Matches } from 'class-validator';
export class JoinByShareCodeDto {
@ApiProperty({
description: '分享码6-12位字符',
example: 'A3K9P2',
minLength: 6,
maxLength: 12
})
@IsString()
@IsNotEmpty()
@Length(6, 12)
@Matches(/^[A-Z0-9]+$/, { message: '分享码只能包含大写字母和数字' })
shareCode: string;
}

View File

@@ -0,0 +1,51 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsOptional, IsBoolean, MaxLength, IsNumber, Min, Max } from 'class-validator';
export class UpdateCustomChallengeDto {
@ApiProperty({ description: '挑战标题', required: false })
@IsString()
@IsOptional()
@MaxLength(100)
title?: string;
@ApiProperty({ description: '挑战封面图 URL', required: false })
@IsString()
@IsOptional()
@MaxLength(512)
image?: string;
@ApiProperty({ description: '挑战概要说明', required: false })
@IsString()
@IsOptional()
summary?: string;
@ApiProperty({ description: '是否公开', required: false })
@IsBoolean()
@IsOptional()
isPublic?: boolean;
@ApiProperty({ description: '最大参与人数限制', required: false })
@IsNumber()
@IsOptional()
@Min(2)
@Max(10000)
maxParticipants?: number;
@ApiProperty({ description: '高亮标题', required: false })
@IsString()
@IsOptional()
@MaxLength(255)
highlightTitle?: string;
@ApiProperty({ description: '高亮副标题', required: false })
@IsString()
@IsOptional()
@MaxLength(255)
highlightSubtitle?: string;
@ApiProperty({ description: 'CTA 按钮文字', required: false })
@IsString()
@IsOptional()
@MaxLength(128)
ctaLabel?: string;
}

View File

@@ -17,6 +17,17 @@ export enum ChallengeType {
WEIGHT = 'weight', WEIGHT = 'weight',
} }
export enum ChallengeSource {
SYSTEM = 'system',
CUSTOM = 'custom',
}
export enum ChallengeState {
DRAFT = 'draft',
ACTIVE = 'active',
ARCHIVED = 'archived',
}
@Table({ @Table({
tableName: 't_challenges', tableName: 't_challenges',
underscored: true, underscored: true,
@@ -44,18 +55,18 @@ export class Challenge extends Model {
declare image: string; declare image: string;
@Column({ @Column({
type: DataType.BIGINT, type: DataType.DATE,
allowNull: false, allowNull: false,
comment: '挑战开始时间(时间戳)', comment: '挑战开始时间',
}) })
declare startAt: number; declare startAt: Date;
@Column({ @Column({
type: DataType.BIGINT, type: DataType.DATE,
allowNull: false, allowNull: false,
comment: '挑战结束时间(时间戳)', comment: '挑战结束时间',
}) })
declare endAt: number; declare endAt: Date;
@Column({ @Column({
type: DataType.STRING(128), type: DataType.STRING(128),
@@ -131,7 +142,7 @@ export class Challenge extends Model {
@Column({ @Column({
type: DataType.STRING(128), type: DataType.STRING(128),
allowNull: false, allowNull: true,
comment: 'CTA 按钮文字', comment: 'CTA 按钮文字',
}) })
declare ctaLabel: string; declare ctaLabel: string;
@@ -144,6 +155,51 @@ export class Challenge extends Model {
}) })
declare type: ChallengeType; declare type: ChallengeType;
@Column({
type: DataType.ENUM('system', 'custom'),
allowNull: false,
defaultValue: ChallengeSource.SYSTEM,
comment: '挑战来源system=系统预设, custom=用户创建',
})
declare source: ChallengeSource;
@Column({
type: DataType.STRING(64),
allowNull: true,
comment: '创建者用户 ID仅 custom 类型有值',
})
declare creatorId: string | null;
@Column({
type: DataType.STRING(12),
allowNull: true,
comment: '分享码6-12位字符用于加入挑战',
})
declare shareCode: string | null;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '是否公开true=任何人可通过分享码加入, false=仅邀请',
})
declare isPublic: boolean;
@Column({
type: DataType.INTEGER,
allowNull: true,
comment: '最大参与人数限制null 表示无限制',
})
declare maxParticipants: number | null;
@Column({
type: DataType.ENUM('draft', 'active', 'archived'),
allowNull: false,
defaultValue: ChallengeState.ACTIVE,
comment: '挑战状态draft=草稿, active=活跃, archived=已归档',
})
declare challengeState: ChallengeState;
@HasMany(() => ChallengeParticipant) @HasMany(() => ChallengeParticipant)
declare participants?: ChallengeParticipant[]; declare participants?: ChallengeParticipant[];
} }

View File

@@ -19,9 +19,11 @@ import { NutritionAnalysisService } from './services/nutrition-analysis.service'
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto, FoodRecognitionRequestDto, FoodRecognitionResponseDto, FoodRecognitionToDietRecordsResponseDto } from '../users/dto/diet-record.dto'; import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto, FoodRecognitionRequestDto, FoodRecognitionResponseDto, FoodRecognitionToDietRecordsResponseDto } from '../users/dto/diet-record.dto';
import { NutritionAnalysisResponseDto } from './dto/nutrition-analysis.dto'; import { NutritionAnalysisResponseDto } from './dto/nutrition-analysis.dto';
import { NutritionAnalysisRequestDto } from './dto/nutrition-analysis-request.dto'; import { NutritionAnalysisRequestDto } from './dto/nutrition-analysis-request.dto';
import { NutritionAnalysisRecordsResponseDto, GetNutritionAnalysisRecordsQueryDto, NutritionAnalysisRecordDto, DeleteNutritionAnalysisRecordResponseDto } from './dto/nutrition-analysis-record.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service'; import { AccessTokenPayload } from '../users/services/apple-auth.service';
import { UsersService } from '../users/users.service';
@ApiTags('diet-records') @ApiTags('diet-records')
@Controller('diet-records') @Controller('diet-records')
@@ -31,6 +33,7 @@ export class DietRecordsController {
constructor( constructor(
private readonly dietRecordsService: DietRecordsService, private readonly dietRecordsService: DietRecordsService,
private readonly nutritionAnalysisService: NutritionAnalysisService, private readonly nutritionAnalysisService: NutritionAnalysisService,
private readonly usersService: UsersService,
) { } ) { }
/** /**
@@ -185,37 +188,130 @@ export class DietRecordsController {
this.logger.log(`分析营养成分表 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`); this.logger.log(`分析营养成分表 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
if (!requestDto.imageUrl) { if (!requestDto.imageUrl) {
return { return NutritionAnalysisResponseDto.createError('请提供图片URL');
success: false,
data: [],
message: '请提供图片URL'
};
}
// 验证URL格式
try {
new URL(requestDto.imageUrl);
} catch (error) {
return {
success: false,
data: [],
message: '图片URL格式不正确'
};
} }
try { try {
const result = await this.nutritionAnalysisService.analyzeNutritionImage(requestDto.imageUrl); // 检查用户免费使用次数
const userUsageCount = await this.usersService.getUserUsageCount(user.sub);
// 如果用户不是VIP且免费次数不足返回错误
if (userUsageCount <= 0) {
this.logger.warn(`营养成分表分析失败 - 用户ID: ${user.sub}, 免费次数不足`);
return NutritionAnalysisResponseDto.createError('免费使用次数已用完,请开通会员获取更多使用次数');
}
// 传递用户ID以便保存分析记录
const result = await this.nutritionAnalysisService.analyzeNutritionImage(requestDto.imageUrl, user.sub);
this.logger.log(`营养成分表分析完成 - 用户ID: ${user.sub}, 成功: ${result.success}, 营养素数量: ${result.data.length}`); this.logger.log(`营养成分表分析完成 - 用户ID: ${user.sub}, 成功: ${result.success}, 营养素数量: ${result.data.length}`);
return result; // 如果分析成功,扣减用户免费使用次数
if (result.success) {
try {
await this.usersService.deductUserUsageCount(user.sub, 1);
this.logger.log(`营养成分表分析成功,已扣减用户免费次数 - 用户ID: ${user.sub}, 剩余次数: ${userUsageCount - 1}`);
} catch (deductError) {
this.logger.error(`扣减用户免费次数失败 - 用户ID: ${user.sub}, 错误: ${deductError instanceof Error ? deductError.message : String(deductError)}`);
// 不影响主流程,继续返回成功结果
}
return NutritionAnalysisResponseDto.createSuccess(result.data, result.message || '分析成功');
} else {
return NutritionAnalysisResponseDto.createError(result.message || '分析失败');
}
} catch (error) { } catch (error) {
this.logger.error(`营养成分表分析失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`); this.logger.error(`营养成分表分析失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`);
return { return NutritionAnalysisResponseDto.createError('营养成分表分析失败,请稍后重试');
success: false, }
data: [], }
message: '营养成分表分析失败,请稍后重试'
/**
* 获取营养成分分析记录
*/
@UseGuards(JwtAuthGuard)
@Get('nutrition-analysis-records')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取营养成分分析记录' })
@ApiQuery({ name: 'startDate', required: false, description: '开始日期' })
@ApiQuery({ name: 'endDate', required: false, description: '结束日期' })
@ApiQuery({ name: 'status', required: false, description: '分析状态' })
@ApiQuery({ name: 'page', required: false, description: '页码' })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
@ApiResponse({ status: 200, description: '成功获取营养成分分析记录', type: NutritionAnalysisRecordsResponseDto })
async getNutritionAnalysisRecords(
@Query() query: GetNutritionAnalysisRecordsQueryDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<NutritionAnalysisRecordsResponseDto> {
this.logger.log(`获取营养成分分析记录 - 用户ID: ${user.sub}`);
try {
// 转换查询参数中的字符串为数字
const convertedQuery = {
page: query.page ? parseInt(query.page, 10) : undefined,
limit: query.limit ? parseInt(query.limit, 10) : undefined,
startDate: query.startDate,
endDate: query.endDate,
status: query.status,
}; };
const result = await this.nutritionAnalysisService.getAnalysisRecords(user.sub, convertedQuery);
// 转换为DTO格式
const recordDtos: NutritionAnalysisRecordDto[] = result.records.map(record => ({
id: record.id,
userId: record.userId,
imageUrl: record.imageUrl,
analysisResult: record.analysisResult,
status: record.status || '',
message: record.message || '',
aiProvider: record.aiProvider || '',
aiModel: record.aiModel || '',
nutritionCount: record.nutritionCount || 0,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}));
return NutritionAnalysisRecordsResponseDto.createSuccess(
recordDtos,
result.total,
result.page,
result.limit
);
} catch (error) {
this.logger.error(`获取营养成分分析记录失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`);
return NutritionAnalysisRecordsResponseDto.createError('获取营养成分分析记录失败,请稍后重试');
}
}
/**
* 删除营养成分分析记录
*/
@UseGuards(JwtAuthGuard)
@Delete('nutrition-analysis-records/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除营养成分分析记录' })
@ApiResponse({ status: 200, description: '成功删除营养成分分析记录', type: DeleteNutritionAnalysisRecordResponseDto })
@ApiResponse({ status: 404, description: '营养分析记录不存在' })
async deleteNutritionAnalysisRecord(
@Param('id') recordId: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<DeleteNutritionAnalysisRecordResponseDto> {
this.logger.log(`删除营养成分分析记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`);
try {
const success = await this.nutritionAnalysisService.deleteAnalysisRecord(user.sub, parseInt(recordId));
if (!success) {
this.logger.warn(`删除营养成分分析记录失败 - 用户ID: ${user.sub}, 记录ID: ${recordId}, 记录不存在或无权限`);
return DeleteNutritionAnalysisRecordResponseDto.createError('营养分析记录不存在或无权限删除');
}
this.logger.log(`营养成分分析记录删除成功 - 用户ID: ${user.sub}, 记录ID: ${recordId}`);
return DeleteNutritionAnalysisRecordResponseDto.createSuccess();
} catch (error) {
this.logger.error(`删除营养成分分析记录失败 - 用户ID: ${user.sub}, 记录ID: ${recordId}, 错误: ${error instanceof Error ? error.message : String(error)}`);
return DeleteNutritionAnalysisRecordResponseDto.createError('删除营养分析记录失败,请稍后重试');
} }
} }
} }

View File

@@ -5,12 +5,13 @@ import { DietRecordsService } from './diet-records.service';
import { NutritionAnalysisService } from './services/nutrition-analysis.service'; import { NutritionAnalysisService } from './services/nutrition-analysis.service';
import { UserDietHistory } from '../users/models/user-diet-history.model'; import { UserDietHistory } from '../users/models/user-diet-history.model';
import { ActivityLog } from '../activity-logs/models/activity-log.model'; import { ActivityLog } from '../activity-logs/models/activity-log.model';
import { NutritionAnalysisRecord } from './models/nutrition-analysis-record.model';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { AiCoachModule } from '../ai-coach/ai-coach.module'; import { AiCoachModule } from '../ai-coach/ai-coach.module';
@Module({ @Module({
imports: [ imports: [
SequelizeModule.forFeature([UserDietHistory, ActivityLog]), SequelizeModule.forFeature([UserDietHistory, ActivityLog, NutritionAnalysisRecord]),
UsersModule, UsersModule,
forwardRef(() => AiCoachModule), forwardRef(() => AiCoachModule),
], ],

View File

@@ -0,0 +1,148 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsDateString, IsNumber, IsString } from 'class-validator';
import { ApiResponseDto } from '../../base.dto';
/**
* 营养成分分析记录项DTO
*/
export class NutritionAnalysisRecordDto {
@ApiProperty({ description: '记录ID', example: 1 })
id: number;
@ApiProperty({ description: '用户ID', example: 'user123' })
userId: string;
@ApiProperty({ description: '分析图片URL', example: 'https://example.com/nutrition-label.jpg' })
imageUrl: string;
@ApiProperty({ description: '营养成分分析结果' })
analysisResult: any;
@ApiProperty({ description: '分析状态', example: 'success' })
status: string;
@ApiProperty({ description: '分析消息', example: '分析成功' })
message: string;
@ApiProperty({ description: 'AI模型提供商', example: 'dashscope' })
aiProvider: string;
@ApiProperty({ description: '使用的AI模型', example: 'qwen-vl-max' })
aiModel: string;
@ApiProperty({ description: '识别到的营养素数量', example: 15 })
nutritionCount: number;
@ApiProperty({ description: '创建时间' })
createdAt: Date;
@ApiProperty({ description: '更新时间' })
updatedAt: Date;
}
/**
* 营养成分分析记录列表响应DTO
*/
export class NutritionAnalysisRecordsResponseDto extends ApiResponseDto<{
records: NutritionAnalysisRecordDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
constructor(code: number, message: string, data: {
records: NutritionAnalysisRecordDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}) {
super(code, message, data);
}
/**
* 创建成功响应
*/
static createSuccess(
records: NutritionAnalysisRecordDto[],
total: number,
page: number,
limit: number,
message: string = '获取营养分析记录成功'
): NutritionAnalysisRecordsResponseDto {
const totalPages = Math.ceil(total / limit);
return new NutritionAnalysisRecordsResponseDto(0, message, {
records,
total,
page,
limit,
totalPages,
});
}
/**
* 创建失败响应
*/
static createError(message: string = '获取营养分析记录失败'): NutritionAnalysisRecordsResponseDto {
return new NutritionAnalysisRecordsResponseDto(1, message, {
records: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
});
}
}
/**
* 查询营养分析记录请求DTO
*/
export class GetNutritionAnalysisRecordsQueryDto {
@ApiProperty({ description: '页码', example: 1, required: false })
@IsOptional()
@IsString()
page?: string;
@ApiProperty({ description: '每页数量', example: 20, required: false })
@IsOptional()
@IsString()
limit?: string;
@ApiProperty({ description: '开始日期', example: '2023-01-01', required: false })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiProperty({ description: '结束日期', example: '2023-12-31', required: false })
@IsOptional()
@IsDateString()
endDate?: string;
@ApiProperty({ description: '分析状态', example: 'success', required: false })
@IsOptional()
@IsString()
status?: string;
}
/**
* 删除营养分析记录响应DTO
*/
export class DeleteNutritionAnalysisRecordResponseDto extends ApiResponseDto<null> {
constructor(code: number, message: string) {
super(code, message, null);
}
/**
* 创建成功响应
*/
static createSuccess(message: string = '删除营养分析记录成功'): DeleteNutritionAnalysisRecordResponseDto {
return new DeleteNutritionAnalysisRecordResponseDto(0, message);
}
/**
* 创建失败响应
*/
static createError(message: string = '删除营养分析记录失败'): DeleteNutritionAnalysisRecordResponseDto {
return new DeleteNutritionAnalysisRecordResponseDto(1, message);
}
}

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsUrl } from 'class-validator';
/** /**
* 营养成分分析请求DTO * 营养成分分析请求DTO
@@ -9,5 +10,8 @@ export class NutritionAnalysisRequestDto {
example: 'https://example.com/nutrition-label.jpg', example: 'https://example.com/nutrition-label.jpg',
required: true required: true
}) })
@IsString()
@IsNotEmpty()
@IsUrl()
imageUrl: string; imageUrl: string;
} }

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseDto } from '../../base.dto';
/** /**
* 营养成分分析结果项 * 营养成分分析结果项
@@ -19,14 +20,24 @@ export class NutritionAnalysisItemDto {
/** /**
* 营养成分分析响应DTO * 营养成分分析响应DTO
* 使用通用响应结构体
*/ */
export class NutritionAnalysisResponseDto { export class NutritionAnalysisResponseDto extends ApiResponseDto<NutritionAnalysisItemDto[]> {
@ApiProperty({ description: '操作是否成功', example: true }) constructor(code: number, message: string, data: NutritionAnalysisItemDto[]) {
success: boolean; super(code, message, data);
}
@ApiProperty({ description: '营养成分分析结果数组', type: [NutritionAnalysisItemDto] }) /**
data: NutritionAnalysisItemDto[]; * 创建成功响应
*/
static createSuccess(data: NutritionAnalysisItemDto[], message: string = '营养成分分析成功'): NutritionAnalysisResponseDto {
return new NutritionAnalysisResponseDto(0, message, data);
}
@ApiProperty({ description: '响应消息', required: false }) /**
message?: string; * 创建失败响应
*/
static createError(message: string = '营养成分分析失败'): NutritionAnalysisResponseDto {
return new NutritionAnalysisResponseDto(1, message, []);
}
} }

View File

@@ -0,0 +1,90 @@
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript';
@Table({
tableName: 't_nutrition_analysis_records',
underscored: true,
})
export class NutritionAnalysisRecord extends Model {
@PrimaryKey
@Column({
type: DataType.BIGINT,
autoIncrement: true,
})
declare id: number;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '分析图片URL',
})
declare imageUrl: string;
@Column({
type: DataType.JSON,
allowNull: false,
comment: '营养成分分析结果',
})
declare analysisResult: Record<string, any>;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '分析状态',
})
declare status: string | null;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '分析消息',
})
declare message: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
comment: 'AI模型提供商',
})
declare aiProvider: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '使用的AI模型',
})
declare aiModel: string | null;
@Column({
type: DataType.INTEGER,
allowNull: true,
comment: '识别到的营养素数量',
})
declare nutritionCount: number | null;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否已删除',
})
declare deleted: boolean;
}

View File

@@ -1,6 +1,10 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { OpenAI } from 'openai'; import { OpenAI } from 'openai';
import { InjectModel } from '@nestjs/sequelize';
import { Op } from 'sequelize';
import { ResponseCode } from '../../base.dto';
import { NutritionAnalysisRecord } from '../models/nutrition-analysis-record.model';
/** /**
* 营养成分分析结果接口 * 营养成分分析结果接口
@@ -14,6 +18,7 @@ export interface NutritionAnalysisResult {
/** /**
* 营养成分分析响应接口 * 营养成分分析响应接口
* 保持向后兼容,内部使用,外部使用 NutritionAnalysisResponseDto
*/ */
export interface NutritionAnalysisResponse { export interface NutritionAnalysisResponse {
success: boolean; success: boolean;
@@ -36,7 +41,11 @@ export class NutritionAnalysisService {
private readonly visionModel: string; private readonly visionModel: string;
private readonly apiProvider: string; private readonly apiProvider: string;
constructor(private readonly configService: ConfigService) { constructor(
private readonly configService: ConfigService,
@InjectModel(NutritionAnalysisRecord)
private readonly nutritionAnalysisRecordModel: typeof NutritionAnalysisRecord,
) {
// Support both GLM-4.5V and DashScope (Qwen) models // Support both GLM-4.5V and DashScope (Qwen) models
this.apiProvider = this.configService.get<string>('AI_VISION_PROVIDER') || 'dashscope'; this.apiProvider = this.configService.get<string>('AI_VISION_PROVIDER') || 'dashscope';
@@ -70,20 +79,37 @@ export class NutritionAnalysisService {
* @param imageUrl 图片URL * @param imageUrl 图片URL
* @returns 营养成分分析结果 * @returns 营养成分分析结果
*/ */
async analyzeNutritionImage(imageUrl: string): Promise<NutritionAnalysisResponse> { async analyzeNutritionImage(imageUrl: string, userId?: string): Promise<NutritionAnalysisResponse> {
try { try {
this.logger.log(`开始分析营养成分表图片: ${imageUrl}`); this.logger.log(`开始分析营养成分表图片: ${imageUrl}, 用户ID: ${userId}`);
const prompt = this.buildNutritionAnalysisPrompt(); const prompt = this.buildNutritionAnalysisPrompt();
const completion = await this.makeVisionApiCall(prompt, [imageUrl]); const completion = await this.makeVisionApiCall(prompt, [imageUrl]);
const rawResult = completion.choices?.[0]?.message?.content || '[]'; const rawResult = completion.choices?.[0]?.message?.content || '{"code": 1, "msg": "未获取到AI模型响应", "data": []}';
this.logger.log(`营养成分分析原始结果: ${rawResult}`); this.logger.log(`营养成分分析原始结果: ${rawResult}`);
return this.parseNutritionAnalysisResult(rawResult); const result = this.parseNutritionAnalysisResult(rawResult);
// 如果提供了用户ID保存分析记录
if (userId) {
await this.saveAnalysisRecord(userId, imageUrl, result);
}
return result;
} catch (error) { } catch (error) {
this.logger.error(`营养成分表分析失败: ${error instanceof Error ? error.message : String(error)}`); this.logger.error(`营养成分表分析失败: ${error instanceof Error ? error.message : String(error)}`);
// 如果提供了用户ID保存失败记录
if (userId) {
await this.saveAnalysisRecord(userId, imageUrl, {
success: false,
data: [],
message: '营养成分表分析失败,请稍后重试'
});
}
return { return {
success: false, success: false,
data: [], data: [],
@@ -152,11 +178,14 @@ export class NutritionAnalysisService {
**任务要求:** **任务要求:**
1. 识别图片中的营养成分表,提取所有可见的营养素信息 1. 识别图片中的营养成分表,提取所有可见的营养素信息
2. 为每个营养素提供详细的健康建议和分析 2. 为每个营养素提供详细的健康建议和分析
3. 返回严格的JSON数组格式,包含任何额外的解释或对话文本 3. 返回严格的JSON格式包含code、msg和data字段
**输出格式要求:** **输出格式要求:**
请严格按照以下JSON数组格式返回,每个对象包含四个字段 请严格按照以下JSON格式返回
[ {
"code": 0,
"msg": "分析成功",
"data": [
{ {
"key": "energy_kcal", "key": "energy_kcal",
"name": "热量", "name": "热量",
@@ -169,7 +198,16 @@ export class NutritionAnalysisService {
"value": "12.5g", "value": "12.5g",
"analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。" "analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。"
} }
] ]
}
**失败情况格式:**
如果无法识别营养成分表或分析失败,请返回:
{
"code": 1,
"msg": "失败原因的具体描述",
"data": []
}
**营养素标识符对照表:** **营养素标识符对照表:**
- 热量/能量: energy_kcal - 热量/能量: energy_kcal
@@ -197,16 +235,18 @@ export class NutritionAnalysisService {
- 其他营养素: other_nutrient - 其他营养素: other_nutrient
**分析要求:** **分析要求:**
1. 如果图片中没有营养成分表,返回空数组 [] 1. 如果成功识别营养成分表,code设为0msg为"分析成功"
2. 为每个识别到的营养素提供具体的健康建议 2. 如果无法识别或分析失败code设为1msg详细说明失败原因
3. 建议应包含营养素的作用、摄入量参考和健康影响 3. 为每个识别到的营养素提供具体的健康建议
4. 数值分析要准确,建议要专业且实用 4. 建议应包含营养素的作用、摄入量参考和健康影响
5. 只返回JSON数组不要包含任何其他文本 5. 数值分析要准确,建议要专业且实用
6. 只返回JSON对象不要包含任何其他文本
**重要提醒:** **重要提醒:**
- 严格按照JSON数组格式返回 - 严格按照JSON对象格式返回包含code、msg和data字段
- 不要添加任何解释性文字或对话内容 - 不要添加任何解释性文字或对话内容
- 确保JSON格式正确可以被直接解析`; - 确保JSON格式正确可以被直接解析
- 必须返回完整的JSON结构即使分析失败也要返回code和msg字段`;
} }
/** /**
@@ -230,9 +270,9 @@ export class NutritionAnalysisService {
}; };
} }
// 确保结果是数组 // 检查响应格式 {code, msg, data}
if (!Array.isArray(parsedResult)) { if (!parsedResult || typeof parsedResult !== 'object' || !('code' in parsedResult)) {
this.logger.error(`营养成分分析结果不是数组格式: ${typeof parsedResult}`); this.logger.error(`营养成分分析结果格式不正确: ${JSON.stringify(parsedResult)}`);
return { return {
success: false, success: false,
data: [], data: [],
@@ -240,10 +280,32 @@ export class NutritionAnalysisService {
}; };
} }
const { code, msg, data } = parsedResult;
// 如果大模型返回失败状态
if (code === ResponseCode.ERROR) {
this.logger.warn(`大模型分析失败: ${msg || '未知错误'}`);
return {
success: false,
data: [],
message: msg || '营养成分表分析失败'
};
}
// 检查data是否为数组
if (!Array.isArray(data)) {
this.logger.error(`营养成分分析data字段不是数组格式: ${typeof data}`);
return {
success: false,
data: [],
message: '营养成分表格式错误data字段应为数组'
};
}
// 验证和标准化每个营养素项 // 验证和标准化每个营养素项
const nutritionData: NutritionAnalysisResult[] = []; const nutritionData: NutritionAnalysisResult[] = [];
for (const item of parsedResult) { for (const item of data) {
if (item && typeof item === 'object' && item.key && item.name && item.value && item.analysis) { if (item && typeof item === 'object' && item.key && item.name && item.value && item.analysis) {
nutritionData.push({ nutritionData.push({
key: String(item.key).trim(), key: String(item.key).trim(),
@@ -260,7 +322,7 @@ export class NutritionAnalysisService {
return { return {
success: false, success: false,
data: [], data: [],
message: '图片中未检测到有效的营养成分表信息' message: msg || '图片中未检测到有效的营养成分表信息'
}; };
} }
@@ -270,6 +332,7 @@ export class NutritionAnalysisService {
success: true, success: true,
data: nutritionData data: nutritionData
}; };
} catch (error) { } catch (error) {
this.logger.error(`营养成分分析结果处理失败: ${error instanceof Error ? error.message : String(error)}`); this.logger.error(`营养成分分析结果处理失败: ${error instanceof Error ? error.message : String(error)}`);
return { return {
@@ -279,4 +342,116 @@ export class NutritionAnalysisService {
}; };
} }
} }
/**
* 保存营养成分分析记录
* @param userId 用户ID
* @param imageUrl 图片URL
* @param result 分析结果
*/
private async saveAnalysisRecord(
userId: string,
imageUrl: string,
result: NutritionAnalysisResponse
): Promise<void> {
try {
await this.nutritionAnalysisRecordModel.create({
userId,
imageUrl,
analysisResult: result,
status: result.success ? 'success' : 'failed',
message: result.message || '',
aiProvider: this.apiProvider,
aiModel: this.visionModel,
nutritionCount: result.data.length,
});
this.logger.log(`营养成分分析记录已保存 - 用户ID: ${userId}, 成功: ${result.success}`);
} catch (error) {
this.logger.error(`保存营养成分分析记录失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* 获取用户的营养成分分析记录
* @param userId 用户ID
* @param query 查询参数
* @returns 分析记录列表
*/
async getAnalysisRecords(
userId: string,
query: {
page?: number;
limit?: number;
startDate?: string;
endDate?: string;
status?: string;
}
): Promise<{
records: NutritionAnalysisRecord[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
const where: any = { userId, deleted: false };
// 日期过滤
if (query.startDate || query.endDate) {
where.createdAt = {} as any;
if (query.startDate) where.createdAt[Op.gte] = new Date(query.startDate);
if (query.endDate) where.createdAt[Op.lte] = new Date(query.endDate);
}
// 状态过滤
if (query.status) {
where.status = query.status;
}
const limit = Math.min(100, Math.max(1, query.limit || 20));
const page = Math.max(1, query.page || 1);
const offset = (page - 1) * limit;
const { rows, count } = await this.nutritionAnalysisRecordModel.findAndCountAll({
where,
order: [['created_at', 'DESC']],
limit,
offset,
});
const totalPages = Math.ceil(count / limit);
return {
records: rows,
total: count,
page,
limit,
totalPages,
};
}
/**
* 删除营养成分分析记录(软删除)
* @param userId 用户ID
* @param recordId 记录ID
* @returns 删除结果
*/
async deleteAnalysisRecord(userId: string, recordId: number): Promise<boolean> {
try {
const record = await this.nutritionAnalysisRecordModel.findOne({
where: { id: recordId, userId, deleted: false }
});
if (!record) {
this.logger.warn(`未找到要删除的营养分析记录 - 用户ID: ${userId}, 记录ID: ${recordId}`);
return false;
}
await record.update({ deleted: true });
this.logger.log(`营养分析记录已删除 - 用户ID: ${userId}, 记录ID: ${recordId}`);
return true;
} catch (error) {
this.logger.error(`删除营养分析记录失败 - 用户ID: ${userId}, 记录ID: ${recordId}, 错误: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
} }

View File

@@ -0,0 +1,583 @@
# AI 药物识别功能说明
## 功能概述
AI 药物识别功能允许用户通过上传药品照片,自动识别药品信息并创建药物记录。系统使用 GLM-4V-Plus 视觉模型和 GLM-4-Flash 文本模型进行多阶段分析,提供完整的药品信息和健康建议。
## 核心特性
### 1. 多图片识别
- **正面图片**(必需):药品包装正面,包含药品名称
- **侧面图片**(必需):药品包装侧面,包含规格信息
- **辅助图片**(可选):药品说明书或其他辅助信息
### 2. 多阶段分析
系统分4个阶段进行识别和分析
1. **产品识别** (0-40%):识别药品基本信息(名称、剂型、剂量等)
2. **适宜人群分析** (40-60%):分析适合人群和禁忌人群
3. **成分分析** (60-80%):分析主要成分和用途
4. **副作用分析** (80-100%):分析副作用、储存建议和健康建议
### 3. 实时状态追踪
- 支持轮询查询识别进度
- 提供详细的步骤描述
- 实时更新进度百分比
### 4. 智能质量控制
系统会在识别前先判断图片质量:
- **图片可读性检查**AI 会首先判断图片是否足够清晰可读
- **置信度评估**:识别置信度低于 60% 时会自动失败
- **严格验证**:宁可识别失败,也不提供不准确的药品信息
- **友好提示**:失败时会给出明确的改进建议
### 5. 结构化输出
识别结果包含完整的药品信息:
- 质量指标:图片可读性、识别置信度
- 基本信息:名称、剂型、剂量、服用次数、服药时间
- 适宜性分析:适合人群、不适合人群
- 成分分析:主要成分、主要用途
- 安全信息:副作用、储存建议、健康建议
## API 接口
### 1. 创建识别任务
**接口**: `POST /medications/ai-recognize`
**权限要求**: 需要 VIP 会员或有 AI 使用次数
**请求参数**:
```json
{
"frontImageUrl": "https://cdn.example.com/front.jpg",
"sideImageUrl": "https://cdn.example.com/side.jpg",
"auxiliaryImageUrl": "https://cdn.example.com/auxiliary.jpg" // 可选
}
```
**响应示例**:
```json
{
"code": 0,
"message": "识别任务创建成功",
"data": {
"taskId": "task_user123_1234567890",
"status": "pending"
}
}
```
**注意事项**:
- 必须提供正面和侧面图片
- 任务创建成功后立即扣减 1 次 AI 使用次数
- 识别过程异步执行,不阻塞当前请求
### 2. 查询识别状态
**接口**: `GET /medications/ai-recognize/:taskId/status`
**轮询建议**: 每 2-3 秒查询一次
**响应示例**:
```json
{
"code": 0,
"message": "查询成功",
"data": {
"taskId": "task_user123_1234567890",
"status": "analyzing_product",
"currentStep": "正在识别药品基本信息...",
"progress": 25,
"createdAt": "2025-01-20T12:00:00.000Z"
}
}
```
**状态说明**:
- `pending`: 任务已创建,等待处理
- `analyzing_product`: 正在识别药品基本信息
- `analyzing_suitability`: 正在分析适宜人群
- `analyzing_ingredients`: 正在分析主要成分
- `analyzing_effects`: 正在分析副作用和健康建议
- `completed`: 识别完成
- `failed`: 识别失败
**完成后的响应示例**:
```json
{
"code": 0,
"message": "查询成功",
"data": {
"taskId": "task_user123_1234567890",
"status": "completed",
"currentStep": "识别完成",
"progress": 100,
"result": {
"name": "阿莫西林胶囊",
"photoUrl": "https://cdn.example.com/front.jpg",
"form": "capsule",
"dosageValue": 0.25,
"dosageUnit": "g",
"timesPerDay": 3,
"medicationTimes": ["08:00", "14:00", "20:00"],
"suitableFor": ["成年人", "细菌感染患者"],
"unsuitableFor": ["青霉素过敏者", "孕妇"],
"mainIngredients": ["阿莫西林"],
"mainUsage": "用于敏感菌引起的各种感染",
"sideEffects": ["恶心", "呕吐", "腹泻"],
"storageAdvice": ["密封保存", "室温避光"],
"healthAdvice": ["按时服药", "多喝水"],
"confidence": 0.95
},
"createdAt": "2025-01-20T12:00:00.000Z",
"completedAt": "2025-01-20T12:01:30.000Z"
}
}
```
### 3. 确认并创建药物
**接口**: `POST /medications/ai-recognize/:taskId/confirm`
**说明**: 用户确认识别结果后创建药物记录,可以对识别结果进行调整
**请求参数** (可选,用于调整识别结果):
```json
{
"name": "调整后的药品名称",
"timesPerDay": 2,
"medicationTimes": ["09:00", "21:00"],
"startDate": "2025-01-20T00:00:00.000Z",
"endDate": "2025-02-20T00:00:00.000Z",
"note": "饭后服用"
}
```
**响应示例**:
```json
{
"code": 0,
"message": "创建成功",
"data": {
"id": "med_abc123",
"name": "阿莫西林胶囊",
"form": "capsule",
"dosageValue": 0.25,
"dosageUnit": "g",
"timesPerDay": 3,
"medicationTimes": ["08:00", "14:00", "20:00"],
"isActive": true,
"aiAnalysis": "{...完整的AI分析结果...}",
"createdAt": "2025-01-20T12:02:00.000Z"
}
}
```
## 前端集成示例
### 完整流程
```typescript
// 1. 上传图片并创建识别任务
async function startRecognition(
frontImage: string,
sideImage: string,
auxiliaryImage?: string
) {
const response = await fetch("/medications/ai-recognize", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
frontImageUrl: frontImage,
sideImageUrl: sideImage,
auxiliaryImageUrl: auxiliaryImage,
}),
});
const result = await response.json();
if (result.code === 0) {
return result.data.taskId;
}
throw new Error(result.message);
}
// 2. 轮询查询识别状态
async function pollRecognitionStatus(taskId: string) {
return new Promise((resolve, reject) => {
const pollInterval = setInterval(async () => {
try {
const response = await fetch(
`/medications/ai-recognize/${taskId}/status`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const result = await response.json();
if (result.code !== 0) {
clearInterval(pollInterval);
reject(new Error(result.message));
return;
}
const {
status,
progress,
currentStep,
result: recognitionResult,
errorMessage,
} = result.data;
// 更新UI显示进度
updateProgress(progress, currentStep);
if (status === "completed") {
clearInterval(pollInterval);
resolve(recognitionResult);
} else if (status === "failed") {
clearInterval(pollInterval);
reject(new Error(errorMessage || "识别失败"));
}
} catch (error) {
clearInterval(pollInterval);
reject(error);
}
}, 2000); // 每2秒查询一次
});
}
// 3. 确认并创建药物
async function confirmAndCreate(taskId: string, adjustments?: any) {
const response = await fetch(`/medications/ai-recognize/${taskId}/confirm`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(adjustments || {}),
});
const result = await response.json();
if (result.code === 0) {
return result.data;
}
throw new Error(result.message);
}
// 完整使用示例
async function recognizeAndCreateMedication() {
try {
// 步骤1: 创建识别任务
showLoading("正在创建识别任务...");
const taskId = await startRecognition(
frontImageUrl,
sideImageUrl,
auxiliaryImageUrl
);
// 步骤2: 轮询查询状态
showProgress("识别进行中...", 0);
const recognitionResult = await pollRecognitionStatus(taskId);
// 步骤3: 展示结果给用户确认
const confirmed = await showConfirmationDialog(recognitionResult);
if (!confirmed) return;
// 步骤4: 创建药物记录
showLoading("正在创建药物记录...");
const medication = await confirmAndCreate(taskId, userAdjustments);
showSuccess("药物创建成功!");
navigateToMedicationDetail(medication.id);
} catch (error) {
showError(error.message);
}
}
```
### UI 交互建议
#### 1. 上传阶段
```typescript
// 显示图片上传引导
<div className="upload-guide">
<div className="upload-item required">
<Icon name="front" />
<span>正面照片(必需)</span>
<p>拍摄药品包装正面,确保药品名称清晰可见</p>
</div>
<div className="upload-item required">
<Icon name="side" />
<span>侧面照片(必需)</span>
<p>拍摄药品包装侧面,确保规格剂量清晰可见</p>
</div>
<div className="upload-item optional">
<Icon name="document" />
<span>说明书(可选)</span>
<p>拍摄药品说明书,可提高识别准确度</p>
</div>
</div>
```
#### 2. 识别阶段
```typescript
// 显示识别进度和当前步骤
<div className="recognition-progress">
<ProgressBar value={progress} />
<div className="current-step">
{status === 'analyzing_product' && '📦 正在识别药品信息...'}
{status === 'analyzing_suitability' && '👥 正在分析适宜人群...'}
{status === 'analyzing_ingredients' && '🧪 正在分析主要成分...'}
{status === 'analyzing_effects' && '⚠️ 正在分析副作用...'}
</div>
<div className="progress-text">{progress}%</div>
</div>
```
#### 3. 确认阶段
```typescript
// 展示识别结果供用户确认和编辑
<div className="recognition-result">
<div className="confidence-badge">
识别置信度: {(result.confidence * 100).toFixed(0)}%
</div>
<EditableField
label="药品名称"
value={result.name}
onChange={(v) => setAdjustments({...adjustments, name: v})}
/>
<EditableField
label="每日服用次数"
value={result.timesPerDay}
onChange={(v) => setAdjustments({...adjustments, timesPerDay: v})}
/>
{/* 更多可编辑字段 */}
<div className="ai-analysis">
<h3>AI 健康分析</h3>
<Section title="适合人群" items={result.suitableFor} />
<Section title="不适合人群" items={result.unsuitableFor} />
<Section title="主要成分" items={result.mainIngredients} />
<Section title="副作用" items={result.sideEffects} />
<Section title="健康建议" items={result.healthAdvice} />
</div>
<div className="action-buttons">
<Button onClick={handleCancel}>取消</Button>
<Button primary onClick={handleConfirm}>确认创建</Button>
</div>
</div>
```
## 错误处理
### 常见错误及解决方案
| 错误码 | 错误信息 | 原因 | 解决方案 |
| ------ | ---------------------- | ------------------ | ---------------------- |
| 400 | 必须提供正面和侧面图片 | 缺少必需的图片 | 确保上传正面和侧面图片 |
| 403 | 免费使用次数已用完 | AI 使用次数不足 | 引导用户开通 VIP 会员 |
| 404 | 识别任务不存在 | 任务ID错误或已过期 | 重新创建识别任务 |
| 500 | AI响应格式错误 | 模型返回异常 | 提示用户重试或联系客服 |
### 识别失败处理
当识别状态为 `failed` 时,系统会提供明确的失败原因:
#### 失败原因分类
1. **图片质量问题**
- 错误信息:`图片模糊或光线不足,无法清晰识别药品信息`
- 用户建议:
- 在光线充足的环境下重新拍摄
- 确保相机对焦清晰
- 避免手抖造成的模糊
- 避免反光和阴影
2. **药品信息不可见**
- 错误信息:`无法从图片中识别出药品名称`
- 用户建议:
- 确保药品名称完整出现在画面中
- 调整拍摄角度,避免遮挡
- 拍摄药品包装正面和侧面
- 如有说明书可一并拍摄
3. **识别置信度过低**
- 错误信息:`识别置信度过低 (XX%)。建议重新拍摄更清晰的照片`
- 用户建议:
- 拍摄更清晰的照片
- 确保药品规格、剂量等信息清晰可见
- 可选择手动输入药品信息
#### 前端处理建议
```typescript
if (status === "failed") {
// 根据错误信息提供针对性的指导
if (errorMessage.includes("图片模糊")) {
showTip("拍照小贴士", [
"在明亮的环境下拍摄",
"保持相机稳定,避免抖动",
"等待相机对焦后再拍摄",
]);
} else if (errorMessage.includes("无法识别出药品名称")) {
showTip("拍照小贴士", [
"确保药品名称完整可见",
"拍摄药品包装的正面",
"避免手指或其他物体遮挡",
]);
} else if (errorMessage.includes("置信度过低")) {
showTip("识别建议", [
"重新拍摄更清晰的照片",
"同时拍摄说明书可提高准确度",
"或选择手动输入药品信息",
]);
}
// 提供重试和手动输入选项
showActions([
{ text: "重新拍摄", action: retryPhoto },
{ text: "手动输入", action: manualInput },
]);
}
```
## 性能优化建议
### 1. 图片优化
- 上传前压缩图片(建议最大 2MB
- 使用 WebP 格式减小体积
- 限制图片尺寸(建议 1920x1080 以内)
### 2. 轮询优化
- 使用指数退避策略2s, 3s, 5s...
- 设置最大轮询次数(如 30 次,约 1 分钟)
- 超时后提示用户刷新页面
### 3. 用户体验优化
- 显示预估完成时间(约 30-60 秒)
- 支持后台识别,用户可离开页面
- 完成后发送推送通知
## 数据安全
1. **图片安全**
- 图片 URL 应使用腾讯云 COS 临时访问凭证
- 识别完成后可选择删除图片
2. **数据隐私**
- 识别任务数据仅用户本人可查看
- AI 分析结果不会共享给第三方
- 支持用户主动删除识别历史
3. **任务清理**
- 建议定期清理 30 天前的识别任务记录
- 可通过定时任务自动清理
## 最佳实践
### 拍照技巧
1. 确保光线充足,避免反光
2. 药品名称和规格清晰可见
3. 尽量拍摄完整的包装盒
4. 避免手指遮挡关键信息
5. 保持相机稳定,避免模糊
### 识别准确度
**质量控制标准**
- 图片必须清晰可读isReadable = true
- 识别置信度必须 ≥ 60% 才能通过
- 置信度 60-75%:会给出警告,建议用户核对
- 置信度 ≥ 75%:可信度较高
**推荐使用标准**
- 正面 + 侧面:准确度约 85-90%
- 正面 + 侧面 + 说明书:准确度约 90-95%
- 置信度 ≥ 0.8:可直接使用
- 置信度 0.75-0.8:建议人工核对
- 置信度 0.6-0.75:必须人工核对
- 置信度 < 0.6自动识别失败需重新拍摄
**安全优先原则**
- 宁可识别失败也不提供不准确的药品信息
- AI 无法确认信息准确性时会主动返回失败
- 用户可选择手动输入以确保用药安全
## 技术架构
### 模型选择
- **GLM-4V-Plus**视觉识别模型识别药品图片
- **GLM-4-Flash**文本分析模型深度分析和结构化输出
### 数据流
1. 客户端上传图片到 COS获取 URL
2. 调用 `/ai-recognize` 创建任务
3. 服务端异步调用 AI 模型进行多阶段分析
4. 客户端轮询查询状态和结果
5. 用户确认后创建药物记录
### 状态管理
- 使用数据库表 `t_medication_recognition_tasks` 持久化状态
- 支持断点续传和故障恢复
- 任务完成后保留 7 天供查询
## 未来规划
1. **功能增强**
- 支持批量识别多个药品
- 支持视频识别
- 支持语音输入药品名称
2. **模型优化**
- 训练专用的药品识别模型
- 提高中文药品识别准确度
- 支持更多药品类型
3. **用户体验**
- 支持离线识别边缘计算
- 实时预览识别结果
- 智能纠错和建议

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,27 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* AI 药品分析结果 DTO (V2)
*/
export class AiAnalysisResultDto {
@ApiProperty({ description: '适合人群', type: [String] })
suitableFor: string[];
@ApiProperty({ description: '不适合人群', type: [String] })
unsuitableFor: string[];
@ApiProperty({ description: '主要成分', type: [String] })
mainIngredients: string[];
@ApiProperty({ description: '主要用途' })
mainUsage: string;
@ApiProperty({ description: '可能的副作用', type: [String] })
sideEffects: string[];
@ApiProperty({ description: '储存和保管建议', type: [String] })
storageAdvice: string[];
@ApiProperty({ description: '健康关怀建议', type: [String] })
healthAdvice: string[];
}

View File

@@ -0,0 +1,142 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
IsNotEmpty,
IsEnum,
IsNumber,
IsInt,
IsArray,
IsDateString,
IsOptional,
IsBoolean,
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/front_001.jpg',
required: false,
})
@IsString()
@IsOptional()
photoUrl?: string;
@ApiProperty({
description: '药物侧面照片URL',
example: 'https://cdn.example.com/medications/side_001.jpg',
required: false,
})
@IsString()
@IsOptional()
sideImageUrl?: string;
@ApiProperty({
description: '药物辅助照片URL可选的第三张图片',
example: 'https://cdn.example.com/medications/auxiliary_001.jpg',
required: false,
})
@IsString()
@IsOptional()
auxiliaryImageUrl?: 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: '药品有效期ISO 8601 格式(可选)',
example: '2026-12-31T23:59:59.999Z',
required: false,
})
@IsDateString()
@IsOptional()
expiryDate?: string;
@ApiProperty({ description: '备注信息', example: '饭后服用', required: false })
@IsString()
@IsOptional()
note?: string;
@ApiProperty({
description: '是否激活',
example: true,
required: false,
default: true
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
/**
* 创建药物识别任务 DTO
*/
export class CreateRecognitionTaskDto {
@ApiProperty({
description: '正面图片URL必需',
example: 'https://cdn.example.com/medications/front_001.jpg',
})
@IsString()
@IsNotEmpty()
frontImageUrl: string;
@ApiProperty({
description: '侧面图片URL必需',
example: 'https://cdn.example.com/medications/side_001.jpg',
})
@IsString()
@IsNotEmpty()
sideImageUrl: string;
@ApiProperty({
description: '辅助面图片URL可选如说明书',
example: 'https://cdn.example.com/medications/auxiliary_001.jpg',
required: false,
})
@IsString()
@IsOptional()
auxiliaryImageUrl?: 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,115 @@
import { ApiProperty } from '@nestjs/swagger';
import { MedicationFormEnum } from '../enums/medication-form.enum';
/**
* 药物识别结果 DTO
* 包含创建药物所需的所有字段 + AI分析结果
*/
export class RecognitionResultDto {
@ApiProperty({
description: '图片是否清晰可读AI判断',
example: true,
required: false,
})
isReadable?: boolean;
@ApiProperty({ description: '药品名称', example: '阿莫西林胶囊' })
name: string;
@ApiProperty({
description: '药品正面照片URL',
example: 'https://cdn.example.com/medications/front_001.jpg',
})
photoUrl: string;
@ApiProperty({
description: '药品侧面照片URL',
example: 'https://cdn.example.com/medications/side_001.jpg',
required: false,
})
sideImageUrl?: string;
@ApiProperty({
description: '药品辅助照片URL',
example: 'https://cdn.example.com/medications/auxiliary_001.jpg',
required: false,
})
auxiliaryImageUrl?: string;
@ApiProperty({
description: '药物剂型',
enum: MedicationFormEnum,
example: MedicationFormEnum.CAPSULE,
})
form: MedicationFormEnum;
@ApiProperty({ description: '剂量数值', example: 1 })
dosageValue: number;
@ApiProperty({ description: '剂量单位', example: '粒' })
dosageUnit: string;
@ApiProperty({ description: '建议每日服用次数', example: 3 })
timesPerDay: number;
@ApiProperty({
description: '建议服药时间',
example: ['08:00', '14:00', '20:00'],
type: [String],
})
medicationTimes: string[];
@ApiProperty({
description: '适合人群',
example: ['成年人', '细菌感染患者'],
type: [String],
})
suitableFor: string[];
@ApiProperty({
description: '不适合人群',
example: ['青霉素过敏者', '孕妇', '哺乳期妇女'],
type: [String],
})
unsuitableFor: string[];
@ApiProperty({
description: '主要成分',
example: ['阿莫西林'],
type: [String],
})
mainIngredients: string[];
@ApiProperty({
description: '主要用途',
example: '用于敏感菌引起的各种感染',
})
mainUsage: string;
@ApiProperty({
description: '可能的副作用',
example: ['恶心', '呕吐', '腹泻', '皮疹'],
type: [String],
})
sideEffects: string[];
@ApiProperty({
description: '储存建议',
example: ['密封保存', '室温避光', '儿童接触不到的地方'],
type: [String],
})
storageAdvice: string[];
@ApiProperty({
description: '健康建议',
example: ['按时服药', '多喝水', '避免饮酒'],
type: [String],
})
healthAdvice: string[];
@ApiProperty({
description: '识别置信度(0-1)',
example: 0.95,
})
confidence: number;
}

View File

@@ -0,0 +1,60 @@
import { ApiProperty } from '@nestjs/swagger';
import { RecognitionStatusEnum } from '../enums/recognition-status.enum';
import { RecognitionResultDto } from './recognition-result.dto';
/**
* 药物识别状态响应 DTO
*/
export class RecognitionStatusDto {
@ApiProperty({
description: '任务ID',
example: 'task_user123_1234567890',
})
taskId: string;
@ApiProperty({
description: '识别状态',
enum: RecognitionStatusEnum,
example: RecognitionStatusEnum.ANALYZING_PRODUCT,
})
status: RecognitionStatusEnum;
@ApiProperty({
description: '当前步骤描述',
example: '正在识别药品基本信息...',
})
currentStep: string;
@ApiProperty({
description: '进度百分比(0-100)',
example: 40,
})
progress: number;
@ApiProperty({
description: '识别结果仅在状态为completed时返回',
type: RecognitionResultDto,
required: false,
})
result?: RecognitionResultDto;
@ApiProperty({
description: '错误信息仅在状态为failed时返回',
example: '图片无法识别,请提供更清晰的照片',
required: false,
})
errorMessage?: string;
@ApiProperty({
description: '创建时间',
example: '2025-01-20T12:00:00.000Z',
})
createdAt: Date;
@ApiProperty({
description: '完成时间仅在completed或failed时返回',
example: '2025-01-20T12:01:30.000Z',
required: false,
})
completedAt?: Date;
}

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,9 @@
import { PartialType } from '@nestjs/swagger';
import { CreateMedicationDto } from './create-medication.dto';
/**
* 更新药物 DTO
* 继承创建 DTO所有字段都是可选的
* 注意aiAnalysis 字段不包含在此 DTO 中,只能通过 AI 分析接口内部写入
*/
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,25 @@
/**
* 药物识别状态枚举
*/
export enum RecognitionStatusEnum {
PENDING = 'pending',
ANALYZING_PRODUCT = 'analyzing_product',
ANALYZING_SUITABILITY = 'analyzing_suitability',
ANALYZING_INGREDIENTS = 'analyzing_ingredients',
ANALYZING_EFFECTS = 'analyzing_effects',
COMPLETED = 'completed',
FAILED = 'failed',
}
/**
* 识别状态描述映射
*/
export const RECOGNITION_STATUS_DESCRIPTIONS: Record<RecognitionStatusEnum, string> = {
[RecognitionStatusEnum.PENDING]: '任务已创建,等待处理',
[RecognitionStatusEnum.ANALYZING_PRODUCT]: '正在识别药品基本信息...',
[RecognitionStatusEnum.ANALYZING_SUITABILITY]: '正在分析适宜人群...',
[RecognitionStatusEnum.ANALYZING_INGREDIENTS]: '正在分析主要成分...',
[RecognitionStatusEnum.ANALYZING_EFFECTS]: '正在分析副作用和健康建议...',
[RecognitionStatusEnum.COMPLETED]: '识别完成',
[RecognitionStatusEnum.FAILED]: '识别失败',
};

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,489 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Res,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Response } from 'express';
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 { AiAnalysisResultDto } from './dto/ai-analysis-result.dto';
import { CreateRecognitionTaskDto } from './dto/create-recognition-task.dto';
import { RecognitionStatusDto } from './dto/recognition-status.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';
import { MedicationAnalysisService } from './services/medication-analysis.service';
import { MedicationRecognitionService } from './services/medication-recognition.service';
import { UsersService } from '../users/users.service';
import { RepeatPatternEnum } from './enums/repeat-pattern.enum';
import { RecognitionStatusEnum } from './enums/recognition-status.enum';
/**
* 药物管理控制器
*/
@ApiTags('medications')
@Controller('medications')
@UseGuards(JwtAuthGuard)
export class MedicationsController {
private readonly logger = new Logger(MedicationsController.name);
constructor(
private readonly medicationsService: MedicationsService,
private readonly reminderService: MedicationReminderService,
private readonly analysisService: MedicationAnalysisService,
private readonly recognitionService: MedicationRecognitionService,
private readonly usersService: UsersService,
) {}
@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,
);
// 如果更新了服药时间或isActive字段重新设置提醒
if (updateDto.medicationTimes || updateDto.isActive !== undefined) {
if (medication.isActive) {
await this.reminderService.setupRemindersForMedication(medication);
} else {
// 如果停用药物,取消提醒
await this.reminderService.cancelRemindersForMedication(id);
}
}
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, '激活成功');
}
@Post(':id/ai-analysis')
@ApiOperation({
summary: '获取药品AI分析',
description: '使用大模型分析药品信息提供专业的用药指导、注意事项和健康建议。支持视觉识别药品图片。返回Server-Sent Events流式响应。'
})
@ApiResponse({
status: 200,
description: '返回流式文本分析结果',
content: {
'text/event-stream': {
schema: {
type: 'string',
example: '药品分析内容...'
}
}
}
})
@ApiResponse({
status: 403,
description: '免费使用次数已用完'
})
async getAiAnalysis(
@CurrentUser() user: any,
@Param('id') id: string,
@Res() res: Response,
) {
try {
// 检查用户免费使用次数
const userUsageCount = await this.usersService.getUserUsageCount(user.sub);
// 如果用户不是VIP且免费次数不足返回错误
if (userUsageCount <= 0) {
this.logger.warn(`药品AI分析失败 - 用户ID: ${user.sub}, 免费次数不足`);
res.status(403).json(
ApiResponseDto.error('免费使用次数已用完,请开通会员获取更多使用次数'),
);
return;
}
// 设置SSE响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // 禁用nginx缓冲
// 获取分析流
const stream = await this.analysisService.analyzeMedication(id, user.sub);
// 分析成功后扣减用户免费使用次数
try {
await this.usersService.deductUserUsageCount(user.sub, 1);
this.logger.log(`药品AI分析成功已扣减用户免费次数 - 用户ID: ${user.sub}, 剩余次数: ${userUsageCount - 1}`);
} catch (deductError) {
this.logger.error(`扣减用户免费次数失败 - 用户ID: ${user.sub}, 错误: ${deductError instanceof Error ? deductError.message : String(deductError)}`);
// 不影响主流程,继续返回分析结果
}
// 将流式数据写入响应
stream.on('data', (chunk: Buffer) => {
res.write(chunk.toString());
});
stream.on('end', () => {
res.end();
});
stream.on('error', (error) => {
res.status(500).json(
ApiResponseDto.error(
error instanceof Error ? error.message : '分析过程中发生错误',
),
);
});
} catch (error) {
res.status(500).json(
ApiResponseDto.error(
error instanceof Error ? error.message : '药品分析失败',
),
);
}
}
@Post(':id/ai-analysis/v2')
@ApiOperation({
summary: '获取药品AI分析 (V2)',
description: '使用大模型分析药品信息返回结构化的JSON数据。包含适合人群、主要成分、副作用等详细信息。'
})
@ApiResponse({
status: 200,
description: '返回结构化分析结果',
type: AiAnalysisResultDto
})
@ApiResponse({
status: 403,
description: '免费使用次数已用完'
})
async getAiAnalysisV2(
@CurrentUser() user: any,
@Param('id') id: string,
) {
// 检查用户免费使用次数
const userUsageCount = await this.usersService.getUserUsageCount(user.sub);
// 如果用户不是VIP且免费次数不足返回错误
if (userUsageCount <= 0) {
this.logger.warn(`药品AI分析(V2)失败 - 用户ID: ${user.sub}, 免费次数不足`);
return ApiResponseDto.error('免费使用次数已用完,请开通会员获取更多使用次数', 403);
}
try {
// 获取结构化分析结果
const result = await this.analysisService.analyzeMedicationV2(id, user.sub);
// 分析成功后扣减用户免费使用次数
try {
await this.usersService.deductUserUsageCount(user.sub, 1);
this.logger.log(`药品AI分析(V2)成功,已扣减用户免费次数 - 用户ID: ${user.sub}, 剩余次数: ${userUsageCount - 1}`);
} catch (deductError) {
this.logger.error(`扣减用户免费次数失败 - 用户ID: ${user.sub}, 错误: ${deductError instanceof Error ? deductError.message : String(deductError)}`);
// 不影响主流程
}
return ApiResponseDto.success(result, '分析成功');
} catch (error) {
return ApiResponseDto.error(
error instanceof Error ? error.message : '药品分析失败',
);
}
}
@Post('ai-recognize')
@ApiOperation({
summary: 'AI识别药品创建',
description: '通过上传药品图片(正面+侧面+可选辅助面AI自动识别并生成药品信息。需要VIP会员或有AI使用次数。',
})
@ApiResponse({
status: 201,
description: '识别任务创建成功',
type: RecognitionStatusDto,
})
@ApiResponse({
status: 400,
description: '参数错误(缺少必需图片)',
})
@ApiResponse({
status: 403,
description: '权限不足或次数不足',
})
async aiRecognizeMedication(
@CurrentUser() user: any,
@Body() dto: CreateRecognitionTaskDto,
) {
// 1. 验证图片必需性
if (!dto.frontImageUrl || !dto.sideImageUrl) {
return ApiResponseDto.error('必须提供正面和侧面图片');
}
// 2. 检查用户权限
const userUsageCount = await this.usersService.getUserUsageCount(user.sub);
if (userUsageCount <= 0) {
this.logger.warn(
`AI药物识别失败 - 用户ID: ${user.sub}, 免费次数不足`,
);
return ApiResponseDto.error(
'免费使用次数已用完,请开通会员获取更多使用次数',
403,
);
}
try {
// 3. 创建识别任务
const result = await this.recognitionService.createRecognitionTask(
user.sub,
dto,
);
// 4. 扣减使用次数(任务创建成功后立即扣减)
await this.usersService.deductUserUsageCount(user.sub, 1);
this.logger.log(
`AI药物识别任务创建成功 - 用户ID: ${user.sub}, taskId: ${result.taskId}, 剩余次数: ${userUsageCount - 1}`,
);
return ApiResponseDto.success(result, '识别任务创建成功');
} catch (error) {
this.logger.error(
`AI药物识别任务创建失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`,
);
return ApiResponseDto.error(
error instanceof Error ? error.message : '创建识别任务失败',
);
}
}
@Get('ai-recognize/:taskId/status')
@ApiOperation({
summary: '查询AI识别状态',
description: '通过任务ID查询药品识别的当前状态和进度支持轮询查询',
})
@ApiResponse({
status: 200,
description: '查询成功',
type: RecognitionStatusDto,
})
@ApiResponse({
status: 404,
description: '任务不存在',
})
async getRecognitionStatus(
@CurrentUser() user: any,
@Param('taskId') taskId: string,
) {
try {
const status = await this.recognitionService.getRecognitionStatus(
taskId,
user.sub,
);
return ApiResponseDto.success(status, '查询成功');
} catch (error) {
this.logger.error(
`查询识别状态失败 - 任务ID: ${taskId}, 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`,
);
return ApiResponseDto.error(
error instanceof Error ? error.message : '查询识别状态失败',
error instanceof Error && error.message.includes('不存在') ? 404 : 500,
);
}
}
@Post('ai-recognize/:taskId/confirm')
@ApiOperation({
summary: '确认并创建药物',
description: '确认AI识别结果并创建药物记录。用户可以在识别结果基础上进行调整。',
})
@ApiResponse({
status: 201,
description: '创建成功',
})
@ApiResponse({
status: 400,
description: '识别任务尚未完成或识别失败',
})
async confirmRecognitionAndCreate(
@CurrentUser() user: any,
@Param('taskId') taskId: string,
@Body() adjustments?: Partial<CreateMedicationDto>,
) {
try {
// 1. 获取识别结果
const status = await this.recognitionService.getRecognitionStatus(
taskId,
user.sub,
);
if (status.status !== RecognitionStatusEnum.COMPLETED) {
return ApiResponseDto.error(
status.status === RecognitionStatusEnum.FAILED
? `识别失败: ${status.errorMessage}`
: '识别任务尚未完成,请稍后再试',
400,
);
}
if (!status.result) {
return ApiResponseDto.error('识别结果为空', 400);
}
// 2. 合并用户调整和识别结果创建药物DTO
const createDto: CreateMedicationDto = {
name: adjustments?.name || status.result.name,
photoUrl: adjustments?.photoUrl || status.result.photoUrl,
sideImageUrl: adjustments?.sideImageUrl || status.result.sideImageUrl,
auxiliaryImageUrl: adjustments?.auxiliaryImageUrl || status.result.auxiliaryImageUrl,
form: adjustments?.form || status.result.form,
dosageValue: adjustments?.dosageValue || status.result.dosageValue,
dosageUnit: adjustments?.dosageUnit || status.result.dosageUnit,
timesPerDay: adjustments?.timesPerDay || status.result.timesPerDay,
medicationTimes:
adjustments?.medicationTimes || status.result.medicationTimes,
repeatPattern: adjustments?.repeatPattern || RepeatPatternEnum.DAILY,
startDate:
adjustments?.startDate || new Date().toISOString(),
endDate: adjustments?.endDate,
expiryDate: adjustments?.expiryDate,
note: adjustments?.note,
isActive: adjustments?.isActive !== undefined ? adjustments.isActive : true,
};
// 3. 创建药物记录
const medication = await this.medicationsService.create(
user.sub,
createDto,
);
// 4. 保存完整的AI分析结果到药物记录
// 确保保存所有AI分析字段与 getAiAnalysisV2 保持一致
const aiAnalysis = {
suitableFor: status.result.suitableFor || [],
unsuitableFor: status.result.unsuitableFor || [],
mainIngredients: status.result.mainIngredients || [],
mainUsage: status.result.mainUsage || '',
sideEffects: status.result.sideEffects || [],
storageAdvice: status.result.storageAdvice || [],
healthAdvice: status.result.healthAdvice || [],
};
this.logger.log(
`保存AI分析结果 - 药物ID: ${medication.id}, 数据: ${JSON.stringify(aiAnalysis)}`,
);
await this.medicationsService.update(medication.id, user.sub, {
aiAnalysis: JSON.stringify(aiAnalysis),
} as any);
// 5. 设置提醒
await this.reminderService.setupRemindersForMedication(medication);
this.logger.log(
`AI识别药物创建成功 - 用户ID: ${user.sub}, 药物ID: ${medication.id}, 任务ID: ${taskId}`,
);
return ApiResponseDto.success(medication, '创建成功');
} catch (error) {
this.logger.error(
`确认识别并创建药物失败 - 任务ID: ${taskId}, 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`,
);
return ApiResponseDto.error(
error instanceof Error ? error.message : '创建药物失败',
);
}
}
}

View File

@@ -0,0 +1,67 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { ScheduleModule } from '@nestjs/schedule';
import { ConfigModule } from '@nestjs/config';
// Models
import { Medication } from './models/medication.model';
import { MedicationRecord } from './models/medication-record.model';
import { MedicationRecognitionTask } from './models/medication-recognition-task.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 { MedicationAnalysisService } from './services/medication-analysis.service';
import { MedicationRecognitionService } from './services/medication-recognition.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: [
ConfigModule, // AI 配置
SequelizeModule.forFeature([
Medication,
MedicationRecord,
MedicationRecognitionTask,
]),
ScheduleModule.forRoot(), // 启用定时任务
PushNotificationsModule, // 推送通知功能
UsersModule, // 用户认证服务
],
controllers: [
MedicationsController,
MedicationRecordsController,
MedicationStatsController,
],
providers: [
MedicationsService,
MedicationRecordsService,
MedicationStatsService,
RecordGeneratorService,
StatusUpdaterService,
MedicationReminderService,
MedicationAnalysisService, // AI 分析服务
MedicationRecognitionService, // AI 识别服务
],
exports: [
MedicationsService,
MedicationRecordsService,
MedicationStatsService,
],
})
export class MedicationsModule {}

View File

@@ -0,0 +1,333 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { InjectModel, InjectConnection } from '@nestjs/sequelize';
import { Medication } from './models/medication.model';
import { MedicationRecord } from './models/medication-record.model';
import { CreateMedicationDto } from './dto/create-medication.dto';
import { UpdateMedicationDto } from './dto/update-medication.dto';
import { MedicationStatusEnum } from './enums/medication-status.enum';
import { v4 as uuidv4 } from 'uuid';
import { Op, Transaction } from 'sequelize';
import { Sequelize } from 'sequelize-typescript';
import * as dayjs from 'dayjs';
/**
* 药物管理服务
*/
@Injectable()
export class MedicationsService {
private readonly logger = new Logger(MedicationsService.name);
constructor(
@InjectModel(Medication)
private readonly medicationModel: typeof Medication,
@InjectModel(MedicationRecord)
private readonly recordModel: typeof MedicationRecord,
@InjectConnection()
private readonly sequelize: Sequelize,
) {}
/**
* 创建药物
*/
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,
sideImageUrl: createDto.sideImageUrl,
auxiliaryImageUrl: createDto.auxiliaryImageUrl,
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,
expiryDate: createDto.expiryDate ? new Date(createDto.expiryDate) : null,
note: createDto.note,
isActive: createDto.isActive !== undefined ? createDto.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);
// 保存更新前的状态
const wasActive = medication.isActive;
// 更新字段
if (updateDto.name !== undefined) {
medication.name = updateDto.name;
}
if (updateDto.photoUrl !== undefined) {
medication.photoUrl = updateDto.photoUrl;
}
if (updateDto.sideImageUrl !== undefined) {
medication.sideImageUrl = updateDto.sideImageUrl;
}
if (updateDto.auxiliaryImageUrl !== undefined) {
medication.auxiliaryImageUrl = updateDto.auxiliaryImageUrl;
}
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 = updateDto.endDate ? new Date(updateDto.endDate) : null;
}
if (updateDto.expiryDate !== undefined) {
medication.expiryDate = updateDto.expiryDate ? new Date(updateDto.expiryDate) : null;
}
if (updateDto.note !== undefined) {
medication.note = updateDto.note;
}
if (updateDto.isActive !== undefined) {
medication.isActive = updateDto.isActive;
}
// 支持更新 AI 分析结果
if ((updateDto as any).aiAnalysis !== undefined) {
medication.aiAnalysis = (updateDto as any).aiAnalysis;
}
await medication.save();
// 如果从激活状态变为停用状态,删除当天未服用的记录
if (updateDto.isActive !== undefined && wasActive && !updateDto.isActive) {
await this.deleteTodayUntakenRecords(medication);
}
// 如果更新了服药时间,删除当天的记录,让系统重新生成新的记录
// 这样更简单可靠,与激活状态更新的处理逻辑保持一致
if (updateDto.medicationTimes) {
await this.deleteTodayUntakenRecords(medication);
this.logger.log(`已删除药物 ${id} 当天的记录,系统会根据新的服药时间重新生成`);
}
this.logger.log(`成功更新药物 ${id}`);
return medication;
}
/**
* 删除药物(软删除)
*/
async remove(id: string, userId: string): Promise<void> {
const transaction = await this.sequelize.transaction();
try {
const medication = await this.findOne(id, userId);
// 软删除药物
medication.deleted = true;
await medication.save({ transaction });
// 软删除所有相关的用药记录
await this.deleteAllMedicationRecords(medication, transaction);
await transaction.commit();
this.logger.log(`成功删除药物 ${id} 及其所有记录`);
} catch (error) {
await transaction.rollback();
this.logger.error(`删除药物失败: ${error instanceof Error ? error.message : '未知错误'}`);
throw error;
}
}
/**
* 停用药物
*/
async deactivate(id: string, userId: string): Promise<Medication> {
const medication = await this.findOne(id, userId);
// 如果已经是停用状态,不需要处理
if (!medication.isActive) {
this.logger.log(`药物 ${id} 已经是停用状态`);
return medication;
}
const wasActive = medication.isActive;
medication.isActive = false;
await medication.save();
// 删除当天未服用的记录
if (wasActive && !medication.isActive) {
await this.deleteTodayUntakenRecords(medication);
}
this.logger.log(`成功停用药物 ${id}`);
return medication;
}
/**
* 激活药物
*/
async activate(id: string, userId: string): Promise<Medication> {
const medication = await this.findOne(id, userId);
// 如果已经是激活状态,不需要处理
if (medication.isActive) {
this.logger.log(`药物 ${id} 已经是激活状态`);
return medication;
}
const wasActive = medication.isActive;
medication.isActive = true;
await medication.save();
// 当药物从停用变为激活时RecordGeneratorService 会负责生成新记录
// 但这里不需要手动处理,因为采用的是惰性生成策略
this.logger.log(`成功激活药物 ${id}`);
return medication;
}
/**
* 删除当天的药物记录
* 当药物被停用时,删除当天生成的所有记录
*/
private async deleteTodayUntakenRecords(medication: Medication, transaction?: Transaction): Promise<void> {
// 获取当天的开始和结束时间
const today = dayjs().format('YYYY-MM-DD');
const todayStart = dayjs(today).startOf('day').toDate();
const todayEnd = dayjs(today).endOf('day').toDate();
this.logger.log(
`开始删除药物 ${medication.id}${today} 的所有记录`,
);
// 使用批量更新而不是循环删除,提高性能
const [affectedCount] = await this.recordModel.update(
{ deleted: true },
{
where: {
medicationId: medication.id,
userId: medication.userId,
scheduledTime: {
[Op.between]: [todayStart, todayEnd], // 当天的所有记录
},
deleted: false,
},
transaction,
}
);
this.logger.log(
`成功批量软删除了 ${affectedCount}${medication.id} 的当天记录`,
);
}
/**
* 删除药物的所有相关记录
* 当药物被删除时,软删除所有相关的服药记录
*/
private async deleteAllMedicationRecords(medication: Medication, transaction?: Transaction): Promise<void> {
this.logger.log(`开始删除药物 ${medication.id} 的所有相关记录`);
// 使用批量更新而不是循环删除,提高性能
const [affectedCount] = await this.recordModel.update(
{ deleted: true },
{
where: {
medicationId: medication.id,
userId: medication.userId,
deleted: false,
},
transaction,
}
);
this.logger.log(
`成功批量软删除了 ${affectedCount}${medication.id} 的记录`,
);
}
}

View File

@@ -0,0 +1,105 @@
import { Column, Model, Table, DataType } from 'sequelize-typescript';
import { RecognitionStatusEnum } from '../enums/recognition-status.enum';
/**
* 药物AI识别任务模型
*/
@Table({
tableName: 't_medication_recognition_tasks',
underscored: true,
timestamps: true,
})
export class MedicationRecognitionTask extends Model {
@Column({
type: DataType.STRING(100),
primaryKey: true,
comment: '任务唯一标识',
})
declare id: string;
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING(500),
allowNull: false,
comment: '正面图片URL',
})
declare frontImageUrl: string;
@Column({
type: DataType.STRING(500),
allowNull: false,
comment: '侧面图片URL',
})
declare sideImageUrl: string;
@Column({
type: DataType.STRING(500),
allowNull: true,
comment: '辅助面图片URL',
})
declare auxiliaryImageUrl: string;
@Column({
type: DataType.STRING(50),
allowNull: false,
defaultValue: RecognitionStatusEnum.PENDING,
comment: '识别状态',
})
declare status: RecognitionStatusEnum;
@Column({
type: DataType.STRING(200),
allowNull: true,
comment: '当前步骤描述',
})
declare currentStep: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '进度百分比(0-100)',
})
declare progress: number;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '识别结果(JSON格式)',
})
declare recognitionResult: string;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '错误信息',
})
declare errorMessage: 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.DATE,
allowNull: true,
comment: '完成时间',
})
declare completedAt: Date;
}

View File

@@ -0,0 +1,105 @@
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;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否已发送提醒',
})
declare reminderSent: boolean;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否已发送超时提醒',
})
declare overdueReminderSent: boolean;
// 关联关系
@BelongsTo(() => Medication, 'medicationId')
declare medication: Medication;
}

View File

@@ -0,0 +1,168 @@
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(255),
allowNull: true,
comment: '药物侧面照片URL',
})
declare sideImageUrl: string;
@Column({
type: DataType.STRING(255),
allowNull: true,
comment: '药物辅助照片URL可选的第三张图片',
})
declare auxiliaryImageUrl: 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 | null;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '药品有效期UTC时间',
})
declare expiryDate: Date | null;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '备注信息',
})
declare note: string;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: 'AI分析结果',
})
declare aiAnalysis: 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,555 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectModel } from '@nestjs/sequelize';
import { OpenAI } from 'openai';
import { Readable } from 'stream';
import { MedicationsService } from '../medications.service';
import { Medication } from '../models/medication.model';
import { AiAnalysisResultDto } from '../dto/ai-analysis-result.dto';
/**
* 药品AI分析服务
* 使用 GLM-4.5V 大模型分析药品信息,提供专业的用药指导和健康建议
*/
@Injectable()
export class MedicationAnalysisService {
private readonly logger = new Logger(MedicationAnalysisService.name);
private readonly client: OpenAI;
private readonly visionModel: string;
private readonly model: string;
constructor(
private readonly configService: ConfigService,
private readonly medicationsService: MedicationsService,
@InjectModel(Medication)
private readonly medicationModel: typeof Medication,
) {
// GLM-4.5V Configuration
const glmApiKey = this.configService.get<string>('GLM_API_KEY');
const glmBaseURL = this.configService.get<string>('GLM_BASE_URL') || 'https://open.bigmodel.cn/api/paas/v4';
this.client = new OpenAI({
apiKey: glmApiKey,
baseURL: glmBaseURL,
});
this.model = this.configService.get<string>('GLM_MODEL') || 'glm-4-flash';
this.visionModel = this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4v-plus';
}
/**
* 分析药品信息并返回流式响应
* @param medicationId 药品ID
* @param userId 用户ID
* @returns 流式文本响应
*/
async analyzeMedication(medicationId: string, userId: string): Promise<Readable> {
this.logger.log(`开始分析药品: ${medicationId}`)
try {
// 1. 获取药品信息
const medication = await this.medicationsService.findOne(medicationId, userId);
this.logger.log(`获取到药品信息: ${JSON.stringify(medication, null, 2)}`)
// 2. 构建专业医药分析提示
const prompt = this.buildMedicationAnalysisPrompt(medication);
// 3. 调用AI模型进行分析
if (medication.photoUrl) {
// 有图片:使用视觉模型
return await this.analyzeWithVision(prompt, medication.photoUrl, medicationId, userId);
} else {
// 无图片:使用文本模型
return await this.analyzeWithText(prompt, medicationId, userId);
}
} catch (error) {
this.logger.error(`药品分析失败: ${error instanceof Error ? error.message : String(error)}`);
return this.createErrorStream('药品分析失败,请稍后重试。');
}
}
/**
* 分析药品信息并返回结构化JSON (V2)
* @param medicationId 药品ID
* @param userId 用户ID
* @returns 结构化分析结果
*/
async analyzeMedicationV2(medicationId: string, userId: string): Promise<AiAnalysisResultDto> {
this.logger.log(`开始分析药品(V2): ${medicationId}`);
try {
// 1. 获取药品信息
const medication = await this.medicationsService.findOne(medicationId, userId);
this.logger.log(`获取到药品信息: ${JSON.stringify(medication, null, 2)}`);
// 2. 构建专业医药分析提示
const prompt = this.buildMedicationAnalysisPromptV2(medication);
let result: AiAnalysisResultDto;
// 3. 调用AI模型进行分析
if (medication.photoUrl) {
// 有图片:使用视觉模型
result = await this.analyzeWithVisionV2(prompt, medication.photoUrl);
} else {
// 无图片:使用文本模型
result = await this.analyzeWithTextV2(prompt);
}
// 4. 保存结果到数据库 (覆盖 aiAnalysis 字段)
await this.saveAnalysisResult(medicationId, userId, JSON.stringify(result));
return result;
} catch (error) {
this.logger.error(`药品分析(V2)失败: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
/**
* 使用视觉模型分析药品(带图片)
* @param prompt 分析提示
* @param imageUrl 药品图片URL
* @param medicationId 药品ID
* @param userId 用户ID
* @returns 流式响应
*/
private async analyzeWithVision(prompt: string, imageUrl: string, medicationId: string, userId: string): Promise<Readable> {
try {
const stream = await this.client.chat.completions.create({
model: this.visionModel,
temperature: 0.7,
stream: true,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{
type: 'image_url',
image_url: { url: imageUrl }
} as any,
] as any,
},
],
} as any);
return this.createStreamFromAI(stream, medicationId, userId);
} catch (error) {
this.logger.error(`视觉模型调用失败: ${error instanceof Error ? error.message : String(error)}`);
return this.createErrorStream('视觉分析失败,请稍后重试。');
}
}
/**
* 使用视觉模型分析药品并返回结构化数据 (V2)
* @param prompt 分析提示
* @param imageUrl 药品图片URL
* @returns 结构化数据
*/
private async analyzeWithVisionV2(prompt: string, imageUrl: string): Promise<AiAnalysisResultDto> {
try {
const response = await this.client.chat.completions.create({
model: this.visionModel,
temperature: 0.7,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{
type: 'image_url',
image_url: { url: imageUrl }
} as any,
] as any,
},
],
response_format: { type: 'json_object' },
} as any);
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('AI模型返回内容为空');
}
return this.parseAiResponse(content);
} catch (error) {
this.logger.error(`视觉模型(V2)调用失败: ${error instanceof Error ? error.message : String(error)}`);
throw new Error('视觉分析失败,请稍后重试。');
}
}
/**
* 使用文本模型分析药品(无图片)
* @param prompt 分析提示
* @param medicationId 药品ID
* @param userId 用户ID
* @returns 流式响应
*/
private async analyzeWithText(prompt: string, medicationId: string, userId: string): Promise<Readable> {
try {
const stream = await this.client.chat.completions.create({
model: this.model,
messages: [
{
role: 'user',
content: prompt
}
],
temperature: 0.7,
stream: true,
});
return this.createStreamFromAI(stream, medicationId, userId);
} catch (error) {
this.logger.error(`文本模型调用失败: ${error instanceof Error ? error.message : String(error)}`);
return this.createErrorStream('文本分析失败,请稍后重试。');
}
}
/**
* 使用文本模型分析药品并返回结构化数据 (V2)
* @param prompt 分析提示
* @returns 结构化数据
*/
private async analyzeWithTextV2(prompt: string): Promise<AiAnalysisResultDto> {
try {
const response = await this.client.chat.completions.create({
model: this.model,
messages: [
{
role: 'user',
content: prompt
}
],
temperature: 0.7,
response_format: { type: 'json_object' },
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('AI模型返回内容为空');
}
return this.parseAiResponse(content);
} catch (error) {
this.logger.error(`文本模型(V2)调用失败: ${error instanceof Error ? error.message : String(error)}`);
throw new Error('文本分析失败,请稍后重试。');
}
}
/**
* 解析AI返回的JSON内容
* @param content AI返回的文本内容
* @returns 解析后的DTO对象
*/
private parseAiResponse(content: string): AiAnalysisResultDto {
try {
// 尝试查找JSON块
let jsonString = content;
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch) {
jsonString = jsonMatch[1];
} else {
// 尝试查找第一个 { 和最后一个 }
const firstBrace = content.indexOf('{');
const lastBrace = content.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1) {
jsonString = content.substring(firstBrace, lastBrace + 1);
}
}
const parsed = JSON.parse(jsonString);
return parsed as AiAnalysisResultDto;
} catch (error) {
this.logger.error(`解析AI响应JSON失败: ${error instanceof Error ? error.message : String(error)}, Content: ${content}`);
throw new Error('AI响应格式错误无法解析');
}
}
/**
* 从AI响应创建可读流
* @param aiStream AI模型流式响应
* @param medicationId 药品ID用于保存分析结果
* @param userId 用户ID
* @returns Readable stream
*/
private createStreamFromAI(aiStream: any, medicationId?: string, userId?: string): Readable {
const readable = new Readable({ read() { } });
let fullContent = ''; // 收集完整的AI响应内容
(async () => {
try {
for await (const chunk of aiStream) {
const delta = chunk.choices?.[0]?.delta?.content || '';
if (delta) {
fullContent += delta; // 累积内容
readable.push(delta);
}
}
// 流结束后保存完整的分析结果到数据库
if (medicationId && userId && fullContent) {
await this.saveAnalysisResult(medicationId, userId, fullContent);
}
} catch (error) {
this.logger.error(`流式响应错误: ${error instanceof Error ? error.message : String(error)}`);
readable.push('\n\n[分析过程中发生错误,请稍后重试]');
} finally {
readable.push(null);
}
})();
return readable;
}
/**
* 保存AI分析结果到数据库
* @param medicationId 药品ID
* @param userId 用户ID
* @param analysisResult 分析结果
*/
private async saveAnalysisResult(medicationId: string, userId: string, analysisResult: string): Promise<void> {
try {
// 直接更新数据库,不通过 DTO
await this.medicationModel.update(
{ aiAnalysis: analysisResult },
{
where: {
id: medicationId,
userId: userId,
deleted: false
}
}
);
this.logger.log(`药品 ${medicationId} 的AI分析结果已保存到数据库`);
} catch (error) {
this.logger.error(`保存AI分析结果失败: ${error instanceof Error ? error.message : String(error)}`);
// 不抛出错误,避免影响流式响应
}
}
/**
* 创建错误流
* @param errorMessage 错误信息
* @returns Readable stream
*/
private createErrorStream(errorMessage: string): Readable {
const readable = new Readable({ read() { } });
setTimeout(() => {
readable.push(errorMessage);
readable.push(null);
}, 100);
return readable;
}
/**
* 构建专业医药分析提示
* @param medication 药品信息
* @returns 分析提示文本
*/
private buildMedicationAnalysisPrompt(medication: Medication): string {
const formName = this.getMedicationFormName(medication.form);
const dosageInfo = `${medication.dosageValue}${medication.dosageUnit}`;
return `你是一位拥有20年从业经验的资深药剂师和临床医学专家同时也是一名充满关怀的健康顾问。
你的专业背景包括:
- 药理学与临床药学
- 用药安全与药物相互作用
- 患者用药教育与健康管理
- 慢性病用药指导
- 中西医结合用药
请基于以下药品信息,为用户提供专业、详细、易懂的药品分析报告。
**药品信息**
- 药品名称:${medication.name}
- 剂型:${formName}
- 规格剂量:${dosageInfo}
- 每日服用次数:${medication.timesPerDay}
- 服药时间:${medication.medicationTimes.join('、')}
${medication.photoUrl ? '- 药品图片:已提供(请结合图片中的药品外观、包装、说明书等信息进行分析)' : ''}
${medication.note ? `- 用户备注:${medication.note}` : ''}
**关键分析原则**
⚠️ **药品识别度判断**
- 首先判断提供的药品名称是否是正规的、可识别的药品(包括通用名、商品名、中药名等)
- 如果药品名称模糊、不规范、无法识别,或者明显是随意输入的内容(如"感冒药"、"止痛药"、"消炎药"、"xx片"等过于笼统的名称)
- 如果有图片但图片信息不足以确认具体药品,结合药品名称综合判断
- 注意:没有图片不影响分析,只要药品名称明确即可
- 在无法准确识别药品的情况下,**不要进行药品分析**,而是友好地引导用户提供更多信息
**分析要求**
1. **药品识别优先**:先判断是否能准确识别药品,无法识别时不要随意推测或给出建议
2. 使用温暖、专业、通俗易懂的语言
3. 以患者的健康和安全为首要考虑
4. 提供实用的用药指导和生活建议
5. 强调重要的注意事项和禁忌
6. 给予健康关怀和鼓励
7. 如果有图片,请结合图片信息提供更准确的分析
**输出格式要求**
**情况A无法识别药品时**(药品名称不明确、过于笼统、随意输入、或缺少必要信息),请使用以下格式:
## 🤔 需要更多信息
很抱歉,根据您提供的信息,我无法准确识别这个药品。为了给您提供安全、专业的用药指导,我需要更多详细信息。
**当前信息不足的原因**
[说明为什么无法识别,例如:
- 药品名称过于笼统(如"感冒药"包含多种不同成分的药物)
- 药品名称不规范或无法在药品数据库中找到对应信息
- 药品名称疑似随意输入,无法对应到具体药品
- 提供的图片信息不足以确认具体药品(如有图片的话)]
## 💡 建议您这样做
为了给您提供安全、准确的用药指导,请选择以下任一方式:
### 方式一:补充药品完整名称 📝
在【备注】中添加药品的完整名称,例如:
- "阿莫西林胶囊"
- "布洛芬缓释胶囊"
- "999感冒灵颗粒"
💡 **小贴士**:可以从药盒或说明书上找到完整的药品名称
### 方式二:上传药品图片 📸
拍摄清晰的照片:
- 药品外包装(带有药品名称的一面)
- 或药品说明书
图片能帮助我更准确地识别和分析药品信息。
---
补充信息后重新分析,我将为您提供专业的用药指导!💚
---
**情况B能够识别药品时**请严格按照以下Markdown结构输出使用纯文本格式
## 💊 药品基本信息
[简要说明药品的通用名称、主要成分、剂型等基本信息。如果有图片,描述药品外观特征]
## 🎯 主要用途与适应症
[详细说明药品的适应症和治疗目的,让患者了解为什么要服用这个药]
## 📋 用法用量指导
[根据药品信息给出标准用法用量指导,包括:
- 推荐服用时间(饭前/饭后/空腹等)
- 服用方法(吞服/咀嚼/含服等)
- 是否需要用水送服
- 特殊注意事项]
## ⚠️ 重要注意事项
[列出关键的注意事项,包括:
- 禁忌症(哪些人不能用)
- 特殊人群用药注意(孕妇、哺乳期、儿童、老年人等)
- 可能的药物相互作用
- 用药期间需要避免的食物或行为]
## 🌡️ 可能的副作用
[说明常见和严重的副作用:
- 常见副作用及应对方法
- 需要立即就医的严重反应
- 如何减轻副作用的建议]
## 🏠 储存与保管
[正确的储存方法:
- 储存温度和环境要求
- 有效期提醒
- 儿童接触预防]
## 💚 健康关怀建议
[个性化的健康建议:
- 配合药物治疗的生活方式建议
- 饮食营养建议
- 运动和作息建议
- 心理调适建议]
## ⏰ 用药依从性提醒
[帮助患者坚持用药的实用技巧:
- 如何记住服药时间
- 漏服后的处理方法
- 定期复查的重要性
- 与医生沟通的建议]
---
**⚠️ 重要提醒**
本分析基于药品的一般信息提供参考,不能替代专业医疗建议。每个人的情况不同,请:
- 严格遵医嘱服药
- 如有疑问或不适,及时咨询医生或药师
- 定期复查,根据病情调整用药
- 不要自行增减剂量或停药
祝您早日康复,保持健康!💪`;
}
/**
* 构建专业医药分析提示 (V2 - JSON格式)
* @param medication 药品信息
* @returns 分析提示文本
*/
private buildMedicationAnalysisPromptV2(medication: Medication): string {
const formName = this.getMedicationFormName(medication.form);
const dosageInfo = `${medication.dosageValue}${medication.dosageUnit}`;
return `你是一位拥有20年从业经验的资深药剂师和临床医学专家。
请基于以下药品信息,为用户提供专业的药品分析报告。
**药品信息**
- 药品名称:${medication.name}
- 剂型:${formName}
- 规格剂量:${dosageInfo}
- 每日服用次数:${medication.timesPerDay}
- 服药时间:${medication.medicationTimes.join('、')}
${medication.photoUrl ? '- 药品图片:已提供(请结合图片中的药品外观、包装、说明书等信息进行分析)' : ''}
${medication.note ? `- 用户备注:${medication.note}` : ''}
**重要指示**
请以严格的 JSON 格式返回分析结果,不要包含任何 Markdown 标记或其他文本。JSON 结构如下:
{
"suitableFor": ["适合人群1", "适合人群2"],
"unsuitableFor": ["不适合人群1", "不适合人群2"],
"mainIngredients": ["主要成分1", "主要成分2"],
"mainUsage": "主要用途说明",
"sideEffects": ["副作用1", "副作用2"],
"storageAdvice": ["建议1", "建议2"],
"healthAdvice": ["建议1", "建议2"]
}
**字段说明**
1. suitableFor: 适合使用该药品的人群,字符串数组
2. unsuitableFor: 不适合使用该药品的人群(包括禁忌症),字符串数组
3. mainIngredients: 药品的主要成分,字符串数组
4. mainUsage: 药品的主要用途和适应症,字符串
5. sideEffects: 可能的副作用,字符串数组
6. storageAdvice: 储存和保管建议,字符串数组
7. healthAdvice: 健康关怀建议(生活方式、饮食等),字符串数组
如果无法识别药品请在所有数组字段返回空数组mainUsage 返回 "无法识别药品,请提供更准确的名称或图片"。
`;
}
/**
* 获取药品剂型的中文名称
* @param form 剂型枚举
* @returns 中文名称
*/
private getMedicationFormName(form: string): string {
const formNames: Record<string, string> = {
'tablet': '片剂',
'capsule': '胶囊',
'syrup': '糖浆',
'injection': '注射剂',
'ointment': '软膏',
'drops': '滴剂',
'powder': '散剂',
'granules': '颗粒剂',
'other': '其他'
};
return formNames[form] || form;
}
}

View File

@@ -0,0 +1,564 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectModel } from '@nestjs/sequelize';
import { OpenAI } from 'openai';
import { MedicationRecognitionTask } from '../models/medication-recognition-task.model';
import { CreateRecognitionTaskDto } from '../dto/create-recognition-task.dto';
import { RecognitionStatusDto } from '../dto/recognition-status.dto';
import { RecognitionResultDto } from '../dto/recognition-result.dto';
import {
RecognitionStatusEnum,
RECOGNITION_STATUS_DESCRIPTIONS,
} from '../enums/recognition-status.enum';
/**
* 药物AI识别服务
* 负责多图片药物识别、分析和结构化数据提取
*/
@Injectable()
export class MedicationRecognitionService {
private readonly logger = new Logger(MedicationRecognitionService.name);
private readonly client: OpenAI;
private readonly visionModel: string;
private readonly textModel: string;
constructor(
private readonly configService: ConfigService,
@InjectModel(MedicationRecognitionTask)
private readonly taskModel: typeof MedicationRecognitionTask,
) {
const glmApiKey = this.configService.get<string>('GLM_API_KEY');
const glmBaseURL =
this.configService.get<string>('GLM_BASE_URL') ||
'https://open.bigmodel.cn/api/paas/v4';
this.client = new OpenAI({
apiKey: glmApiKey,
baseURL: glmBaseURL,
});
this.visionModel =
this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4.5v';
this.textModel =
this.configService.get<string>('GLM_MODEL') || 'glm-4.5-air';
}
/**
* 创建识别任务
*/
async createRecognitionTask(
userId: string,
dto: CreateRecognitionTaskDto,
): Promise<{ taskId: string; status: RecognitionStatusEnum }> {
const taskId = `task_${userId}_${Date.now()}`;
this.logger.log(`创建药物识别任务: ${taskId}, 用户: ${userId}`);
await this.taskModel.create({
id: taskId,
userId,
frontImageUrl: dto.frontImageUrl,
sideImageUrl: dto.sideImageUrl,
auxiliaryImageUrl: dto.auxiliaryImageUrl,
status: RecognitionStatusEnum.PENDING,
currentStep: RECOGNITION_STATUS_DESCRIPTIONS[RecognitionStatusEnum.PENDING],
progress: 0,
});
// 异步开始识别过程(不阻塞当前请求)
this.startRecognitionProcess(taskId).catch((error) => {
this.logger.error(
`识别任务 ${taskId} 处理失败: ${error instanceof Error ? error.message : String(error)}`,
);
});
return { taskId, status: RecognitionStatusEnum.PENDING };
}
/**
* 查询识别状态
*/
async getRecognitionStatus(
taskId: string,
userId: string,
): Promise<RecognitionStatusDto> {
const task = await this.taskModel.findOne({
where: { id: taskId, userId },
});
if (!task) {
throw new NotFoundException('识别任务不存在');
}
return {
taskId: task.id,
status: task.status as RecognitionStatusEnum,
currentStep: task.currentStep,
progress: task.progress,
result: task.recognitionResult
? JSON.parse(task.recognitionResult)
: undefined,
errorMessage: task.errorMessage,
createdAt: task.createdAt,
completedAt: task.completedAt,
};
}
/**
* 开始识别处理流程
*/
private async startRecognitionProcess(taskId: string): Promise<void> {
try {
const task = await this.taskModel.findByPk(taskId);
if (!task) return;
// 阶段1: 产品识别分析 (0-40%)
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_PRODUCT,
'正在识别药品基本信息...',
10,
);
const productInfo = await this.recognizeProduct(task);
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_PRODUCT,
'药品基本信息识别完成',
40,
);
// 阶段2: 适宜人群分析 (40-60%)
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_SUITABILITY,
'正在分析适宜人群...',
50,
);
const suitabilityInfo = await this.analyzeSuitability(productInfo);
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_SUITABILITY,
'适宜人群分析完成',
60,
);
// 阶段3: 成分分析 (60-80%)
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_INGREDIENTS,
'正在分析主要成分...',
70,
);
const ingredientsInfo = await this.analyzeIngredients(productInfo);
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_INGREDIENTS,
'成分分析完成',
80,
);
// 阶段4: 副作用分析 (80-100%)
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_EFFECTS,
'正在分析副作用和健康建议...',
90,
);
const effectsInfo = await this.analyzeEffects(productInfo);
// 合并所有结果透传所有原始图片URL避免被AI模型修改
const finalResult = {
...productInfo,
...suitabilityInfo,
...ingredientsInfo,
...effectsInfo,
// 强制使用任务记录中存储的原始图片URL覆盖AI可能返回的不正确链接
photoUrl: task.frontImageUrl,
sideImageUrl: task.sideImageUrl,
auxiliaryImageUrl: task.auxiliaryImageUrl,
} as RecognitionResultDto;
// 完成识别
await this.completeTask(taskId, finalResult);
this.logger.log(`识别任务 ${taskId} 完成`);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
this.logger.error(`识别任务 ${taskId} 失败: ${errorMessage}`);
await this.failTask(taskId, errorMessage);
}
}
/**
* 阶段1: 识别药品基本信息
*/
private async recognizeProduct(
task: MedicationRecognitionTask,
): Promise<Partial<RecognitionResultDto>> {
const prompt = this.buildProductRecognitionPrompt();
const images = [task.frontImageUrl, task.sideImageUrl];
if (task.auxiliaryImageUrl) images.push(task.auxiliaryImageUrl);
this.logger.log(
`调用视觉模型识别药品,图片数量: ${images.length}, 任务ID: ${task.id}`,
);
const response = await this.client.chat.completions.create({
model: this.visionModel,
temperature: 0.3,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
...images.map((url) => ({
type: 'image_url',
image_url: { url },
})),
] as any,
},
],
response_format: { type: 'json_object' },
} as any);
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('AI模型返回内容为空');
}
const parsed = this.parseJsonResponse(content);
// 严格的置信度和可读性验证
const confidence = parsed.confidence || 0;
const isReadable = parsed.isReadable !== false; // 默认为 true 保持向后兼容
this.logger.log(
`药品识别结果: ${parsed.name}, 置信度: ${confidence}, 可读性: ${isReadable}`,
);
// 如果模型明确表示看不清或置信度过低,则失败
if (!isReadable) {
throw new Error('图片模糊或光线不足,无法清晰识别药品信息。请重新拍摄更清晰的照片。');
}
if (parsed.name === '无法识别' || parsed.name === '未知' || !parsed.name) {
throw new Error('无法从图片中识别出药品名称。请确保药品名称清晰可见,或选择手动输入。');
}
// 置信度阈值检查:低于 0.6 视为不可靠
if (confidence < 0.6) {
throw new Error(
`识别置信度过低 (${(confidence * 100).toFixed(0)}%)。建议重新拍摄更清晰的照片,确保药品名称、规格等信息清晰可见。`,
);
}
return parsed;
}
/**
* 阶段2: 分析适宜人群
*/
private async analyzeSuitability(
productInfo: Partial<RecognitionResultDto>,
): Promise<Partial<RecognitionResultDto>> {
const prompt = this.buildSuitabilityAnalysisPrompt(productInfo);
this.logger.log(`分析适宜人群: ${productInfo.name}`);
const response = await this.client.chat.completions.create({
model: this.textModel,
temperature: 0.7,
messages: [
{
role: 'user',
content: prompt,
},
],
response_format: { type: 'json_object' },
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('AI模型返回内容为空');
}
return this.parseJsonResponse(content);
}
/**
* 阶段3: 分析主要成分
*/
private async analyzeIngredients(
productInfo: Partial<RecognitionResultDto>,
): Promise<Partial<RecognitionResultDto>> {
const prompt = this.buildIngredientsAnalysisPrompt(productInfo);
this.logger.log(`分析主要成分: ${productInfo.name}`);
const response = await this.client.chat.completions.create({
model: this.textModel,
temperature: 0.7,
messages: [
{
role: 'user',
content: prompt,
},
],
response_format: { type: 'json_object' },
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('AI模型返回内容为空');
}
return this.parseJsonResponse(content);
}
/**
* 阶段4: 分析副作用和健康建议
*/
private async analyzeEffects(
productInfo: Partial<RecognitionResultDto>,
): Promise<Partial<RecognitionResultDto>> {
const prompt = this.buildEffectsAnalysisPrompt(productInfo);
this.logger.log(`分析副作用和健康建议: ${productInfo.name}`);
const response = await this.client.chat.completions.create({
model: this.textModel,
temperature: 0.7,
messages: [
{
role: 'user',
content: prompt,
},
],
response_format: { type: 'json_object' },
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('AI模型返回内容为空');
}
return this.parseJsonResponse(content);
}
/**
* 构建产品识别提示词
*/
private buildProductRecognitionPrompt(): string {
return `你是一位拥有20年从业经验的资深药剂师请根据提供的药品图片包括正面、侧面和可能的辅助面进行详细分析。
**重要前提条件 - 图片可读性判断**
⚠️ 在进行任何识别之前,你必须首先判断图片是否足够清晰可读:
1. 检查图片是否模糊、过曝、欠曝或有严重反光
2. 检查药品名称、规格等关键信息是否清晰可见
3. 检查文字是否完整、无遮挡
4. 如果图片质量不佳,无法清晰辨认关键信息,必须设置 isReadable 为 false
**只有在图片清晰可读的情况下才能继续分析**
1. 仔细观察药品包装、说明书上的所有信息
2. 识别药品的完整名称(通用名和商品名)
3. 确定药物剂型(片剂/胶囊/注射剂等)
4. 提取规格剂量信息
5. 推荐合理的服用次数和时间
**置信度评估标准(仅在图片可读时评估)**
- 如果图片清晰且信息完整,置信度应 >= 0.8
- 如果部分信息不清晰但大部分可推断,置信度 0.6-0.8
- 如果关键信息缺失或模糊不清,置信度 < 0.6name返回"无法识别"
- 置信度评估必须严格基于实际可见信息,不能猜测或臆断
**返回严格的JSON格式**不要包含任何markdown标记
{
"isReadable": true或false图片是否足够清晰可读,
"name": "药品完整名称",
"photoUrl": "使用正面图片URL",
"form": "剂型(tablet/capsule/injection/drops/syrup/ointment/powder/granules)",
"dosageValue": 剂量数值(数字),
"dosageUnit": "剂量单位",
"timesPerDay": 建议每日服用次数(数字),
"medicationTimes": ["建议的服药时间格式HH:mm"],
"confidence": 识别置信度(0-1的小数)
}
**关键规则(必须遵守)**
1. isReadable 是最重要的字段,如果为 false其他识别结果将被忽略
2. 当图片模糊、反光、文字不清晰时,必须设置 isReadable 为 false
3. 只有在确实能看清并理解图片内容时,才能设置 isReadable 为 true
4. confidence 必须反映真实的识别把握程度,不能虚高
5. 如果 isReadable 为 falsename 必须返回"无法识别"confidence 设为 0
6. dosageValue 和 timesPerDay 必须是数字类型,不要加引号
7. medicationTimes 必须是 HH:mm 格式的时间数组
8. form 必须是枚举值之一
**宁可识别失败,也不要提供不准确的药品信息。用药安全高于一切!**`;
}
/**
* 构建适宜人群分析提示词
*/
private buildSuitabilityAnalysisPrompt(
productInfo: Partial<RecognitionResultDto>,
): string {
return `作为资深药剂师,请分析以下药品的适宜人群和禁忌人群:
**药品信息**
- 名称:${productInfo.name}
- 剂型:${productInfo.form}
- 剂量:${productInfo.dosageValue}${productInfo.dosageUnit}
请以严格的JSON格式返回不要包含任何markdown标记
{
"suitableFor": ["适合人群1", "适合人群2", "适合人群3"],
"unsuitableFor": ["不适合人群1", "不适合人群2", "不适合人群3"],
"mainUsage": "药品的主要用途和适应症描述"
}
**要求**
- suitableFor 和 unsuitableFor 必须是字符串数组至少包含3项
- mainUsage 是字符串,描述药品的主要治疗用途
- 如果无法识别药品所有数组返回空数组mainUsage返回"无法识别药品"`;
}
/**
* 构建成分分析提示词
*/
private buildIngredientsAnalysisPrompt(
productInfo: Partial<RecognitionResultDto>,
): string {
return `作为资深药剂师,请分析以下药品的主要成分:
**药品信息**
- 名称:${productInfo.name}
- 用途:${productInfo.mainUsage}
请以严格的JSON格式返回不要包含任何markdown标记
{
"mainIngredients": ["主要成分1", "主要成分2", "主要成分3"]
}
**要求**
- mainIngredients 必须是字符串数组,列出药品的主要活性成分
- 至少包含1-3个主要成分
- 如果无法确定,返回空数组`;
}
/**
* 构建副作用分析提示词
*/
private buildEffectsAnalysisPrompt(
productInfo: Partial<RecognitionResultDto>,
): string {
return `作为资深药剂师,请分析以下药品的副作用、储存建议和健康建议:
**药品信息**
- 名称:${productInfo.name}
- 用途:${productInfo.mainUsage}
- 成分:${productInfo.mainIngredients?.join('、')}
请以严格的JSON格式返回不要包含任何markdown标记
{
"sideEffects": ["副作用1", "副作用2", "副作用3"],
"storageAdvice": ["储存建议1", "储存建议2", "储存建议3"],
"healthAdvice": ["健康建议1", "健康建议2", "健康建议3"]
}
**要求**
- 所有字段都是字符串数组
- sideEffects: 列出常见和严重的副作用至少3项
- storageAdvice: 提供正确的储存方法至少2项
- healthAdvice: 给出配合用药的生活建议至少3项
- 如果无法确定,返回空数组`;
}
/**
* 解析JSON响应
*/
private parseJsonResponse(content: string): any {
try {
// 移除可能的 markdown 代码块标记
let jsonString = content.trim();
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch) {
jsonString = jsonMatch[1];
} else {
// 尝试提取第一个 { 到最后一个 }
const firstBrace = content.indexOf('{');
const lastBrace = content.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1) {
jsonString = content.substring(firstBrace, lastBrace + 1);
}
}
return JSON.parse(jsonString);
} catch (error) {
this.logger.error(
`解析JSON响应失败: ${error instanceof Error ? error.message : String(error)}, Content: ${content}`,
);
throw new Error('AI响应格式错误无法解析');
}
}
/**
* 更新任务状态
*/
private async updateTaskStatus(
taskId: string,
status: RecognitionStatusEnum,
currentStep: string,
progress: number,
): Promise<void> {
await this.taskModel.update(
{
status,
currentStep,
progress,
},
{
where: { id: taskId },
},
);
}
/**
* 完成任务
*/
private async completeTask(
taskId: string,
result: RecognitionResultDto,
): Promise<void> {
await this.taskModel.update(
{
status: RecognitionStatusEnum.COMPLETED,
currentStep: RECOGNITION_STATUS_DESCRIPTIONS[RecognitionStatusEnum.COMPLETED],
progress: 100,
recognitionResult: JSON.stringify(result),
completedAt: new Date(),
},
{
where: { id: taskId },
},
);
}
/**
* 任务失败
*/
private async failTask(taskId: string, errorMessage: string): Promise<void> {
await this.taskModel.update(
{
status: RecognitionStatusEnum.FAILED,
currentStep: RECOGNITION_STATUS_DESCRIPTIONS[RecognitionStatusEnum.FAILED],
progress: 0,
errorMessage,
completedAt: new Date(),
},
{
where: { id: taskId },
},
);
}
}

View File

@@ -0,0 +1,411 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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分钟发送推送提醒并在超过服药时间1小时后发送鼓励提醒
*/
@Injectable()
export class MedicationReminderService {
private readonly logger = new Logger(MedicationReminderService.name);
private readonly REMINDER_MINUTES_BEFORE = 5; // 提前5分钟提醒
private readonly OVERDUE_HOURS_THRESHOLD = 1; // 超过1小时后发送超时提醒
constructor(
@InjectModel(Medication)
private readonly medicationModel: typeof Medication,
@InjectModel(MedicationRecord)
private readonly recordModel: typeof MedicationRecord,
private readonly pushService: PushNotificationsService,
private readonly configService: ConfigService,
) {}
/**
* 每5分钟检查一次需要发送的提前提醒
* 只有主进程NODE_APP_INSTANCE=0执行避免多进程重复发送
*/
@Cron('*/5 * * * *')
async checkAndSendReminders(): Promise<void> {
this.logger.log('开始检查服药提醒');
try {
// 检查是否为主进程NODE_APP_INSTANCE 为 0
const nodeAppInstance = this.configService.get<number>('NODE_APP_INSTANCE', 0);
if (Number(nodeAppInstance) !== 0) {
this.logger.debug(`不是主进程 (instance: ${nodeAppInstance}),跳过服药提醒检查`);
return;
}
this.logger.log('主进程检测到,执行服药提醒检查...');
// 计算时间范围:当前时间 + 15分钟
const now = new Date();
// 查找在接下来1分钟内需要提醒的记录
const startRange = now;
const endRange = dayjs(now).add(1, 'minute').toDate(); // 1分钟窗口期
const upcomingRecords = await this.recordModel.findAll({
where: {
status: MedicationStatusEnum.UPCOMING,
deleted: false,
reminderSent: 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);
}
// 为每个用户发送提醒
let successCount = 0;
let failedCount = 0;
for (const [userId, records] of userRecordsMap.entries()) {
const success = await this.sendReminderToUser(userId, records);
if (success) {
successCount += records.length;
// 标记这些记录已发送提醒
await this.markRecordsAsReminded(records.map(r => r.id));
} else {
failedCount += records.length;
}
}
this.logger.log(`服药提醒发送完成 - 成功: ${successCount}, 失败: ${failedCount}`);
} catch (error) {
this.logger.error('检查服药提醒失败', error.stack);
}
}
/**
* 每10分钟检查一次是否有超过服药时间1小时的未服用记录
* 只有主进程NODE_APP_INSTANCE=0执行避免多进程重复发送
*/
@Cron('*/10 * * * *')
async checkAndSendOverdueReminders(): Promise<void> {
this.logger.log('开始检查超时服药提醒');
try {
// 检查是否为主进程NODE_APP_INSTANCE 为 0
const nodeAppInstance = this.configService.get<number>('NODE_APP_INSTANCE', 0);
if (Number(nodeAppInstance) !== 0) {
this.logger.debug(`不是主进程 (instance: ${nodeAppInstance}),跳过超时服药提醒检查`);
return;
}
this.logger.log('主进程检测到,执行超时服药提醒检查...');
// 计算时间范围当前时间减去1小时
const now = new Date();
const overdueThreshold = dayjs(now)
.subtract(this.OVERDUE_HOURS_THRESHOLD, 'hour')
.toDate();
// 查找超过计划服用时间1小时但状态仍为UPCOMING的记录
const overdueRecords = await this.recordModel.findAll({
where: {
status: MedicationStatusEnum.UPCOMING,
deleted: false,
overdueReminderSent: false, // 只查询未发送超时提醒的记录
scheduledTime: {
[Op.lt]: overdueThreshold, // 计划时间早于1小时前
},
},
include: [
{
model: Medication,
as: 'medication',
where: {
isActive: true,
deleted: false,
},
},
],
});
if (overdueRecords.length === 0) {
this.logger.debug('没有需要发送的超时服药提醒');
return;
}
this.logger.log(`找到 ${overdueRecords.length} 条需要发送超时提醒的记录`);
// 按用户分组发送提醒
const userRecordsMap = new Map<string, MedicationRecord[]>();
for (const record of overdueRecords) {
const userId = record.userId;
if (!userRecordsMap.has(userId)) {
userRecordsMap.set(userId, []);
}
userRecordsMap.get(userId)!.push(record);
}
// 为每个用户发送超时提醒
let successCount = 0;
let failedCount = 0;
for (const [userId, records] of userRecordsMap.entries()) {
const success = await this.sendOverdueReminderToUser(userId, records);
if (success) {
successCount += records.length;
// 标记这些记录已发送超时提醒
await this.markRecordsAsOverdueReminded(records.map(r => r.id));
} else {
failedCount += records.length;
}
}
this.logger.log(`超时服药提醒发送完成 - 成功: ${successCount}, 失败: ${failedCount}`);
} catch (error) {
this.logger.error('检查超时服药提醒失败', error.stack);
}
}
/**
* 为单个用户发送提醒
* @returns 是否发送成功
*/
private async sendReminderToUser(
userId: string,
records: MedicationRecord[],
): Promise<boolean> {
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} 发送服药提醒`);
return true;
} catch (error) {
this.logger.error(
`向用户 ${userId} 发送服药提醒失败`,
error.stack,
);
return false;
}
}
/**
* 为单个用户发送超时鼓励提醒
* @returns 是否发送成功
*/
private async sendOverdueReminderToUser(
userId: string,
records: MedicationRecord[],
): Promise<boolean> {
try {
const medicationNames = records
.map((r) => r.medication?.name)
.filter(Boolean)
.join('、');
// 计算超时时间
const overdueHours = Math.max(...records.map(r =>
Math.floor(dayjs().diff(dayjs(r.scheduledTime), 'hour'))
));
const title = '服药超时提醒';
const body =
records.length === 1
? `您已经错过了 ${medicationNames} 的服用时间超过 ${overdueHours} 小时,请尽快服用!坚持按时服药有助于您的健康恢复。`
: `您已经错过了 ${records.length} 种药物的服用时间,请尽快服用!坚持按时服药有助于您的健康恢复。`;
await this.pushService.sendNotification({
userIds: [userId],
title,
body,
payload: {
type: 'medication_overdue_reminder',
recordIds: records.map((r) => r.id),
medicationIds: records.map((r) => r.medicationId),
},
sound: 'default',
badge: 1,
});
this.logger.log(`成功向用户 ${userId} 发送超时服药提醒`);
return true;
} catch (error) {
this.logger.error(
`向用户 ${userId} 发送超时服药提醒失败`,
error.stack,
);
return false;
}
}
/**
* 标记记录为已发送提醒
*/
private async markRecordsAsReminded(recordIds: string[]): Promise<void> {
try {
await this.recordModel.update(
{ reminderSent: true },
{
where: {
id: {
[Op.in]: recordIds,
},
},
},
);
this.logger.debug(`已标记 ${recordIds.length} 条记录为已提醒`);
} catch (error) {
this.logger.error('标记记录为已提醒失败', error.stack);
}
}
/**
* 标记记录为已发送超时提醒
*/
private async markRecordsAsOverdueReminded(recordIds: string[]): Promise<void> {
try {
await this.recordModel.update(
{ overdueReminderSent: true },
{
where: {
id: {
[Op.in]: recordIds,
},
},
},
);
this.logger.debug(`已标记 ${recordIds.length} 条记录为已超时提醒`);
} catch (error) {
this.logger.error('标记记录为已超时提醒失败', 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,259 @@
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 medicationStartDate = dayjs(medication.startDate).startOf('day');
// 如果药物开始日期是今天,无论当前时间如何,都设置为 UPCOMING
const status = targetDate.isSame(medicationStartDate, 'day')
? MedicationStatusEnum.UPCOMING
: 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 targetDateDayjs = dayjs(date).startOf('day');
// 1. 查询用户所有激活的药物
const activeMedications = await this.medicationModel.findAll({
where: {
userId,
isActive: true,
deleted: false,
},
});
if (activeMedications.length === 0) {
this.logger.debug(`用户 ${userId} 没有激活的药物`);
return false;
}
// 2. 检查该日期是否已有记录
const startOfDay = dayjs(date).startOf('day').toDate();
const endOfDay = dayjs(date).endOf('day').toDate();
const Op = require('sequelize').Op;
const existingRecords = await this.recordModel.findAll({
where: {
userId,
deleted: false,
scheduledTime: {
[Op.between]: [startOfDay, endOfDay],
},
},
attributes: ['medicationId'],
});
// 3. 获取已有记录的药物ID集合
const existingMedicationIds = new Set(
existingRecords.map((record) => record.medicationId),
);
// 4. 找出需要生成记录的药物(在有效期内且未生成记录的)
const medicationsNeedRecords = activeMedications.filter((medication) => {
// 检查是否在有效期内
if (!this.isDateInMedicationRange(medication, targetDateDayjs)) {
return false;
}
// 检查是否已生成记录
return !existingMedicationIds.has(medication.id);
});
// 5. 为需要的药物生成记录
if (medicationsNeedRecords.length > 0) {
this.logger.log(
`为用户 ${userId}${targetDate} 生成 ${medicationsNeedRecords.length} 个药物的记录`,
);
for (const medication of medicationsNeedRecords) {
await this.generateRecordsForMedicationOnDate(
medication,
targetDateDayjs,
);
}
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;
}
}

View File

@@ -1,8 +1,9 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ApnsClient, SilentNotification, Notification, Errors } from 'apns2'; import { ApnsClient, SilentNotification, Notification, Errors } from 'apns2';
import * as fs from 'fs'; import * as fs from 'fs';
import { ApnsConfig, ApnsNotificationOptions } from './interfaces/apns-config.interface'; import { ApnsConfig, ApnsNotificationOptions } from './interfaces/apns-config.interface';
import { PushTokenService } from './push-token.service';
interface SendResult { interface SendResult {
sent: string[]; sent: string[];
@@ -20,7 +21,11 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
private client: ApnsClient; private client: ApnsClient;
private config: ApnsConfig; private config: ApnsConfig;
constructor(private readonly configService: ConfigService) { constructor(
private readonly configService: ConfigService,
@Inject(forwardRef(() => PushTokenService))
private readonly pushTokenService: PushTokenService,
) {
this.config = this.buildConfig(); this.config = this.buildConfig();
} }
@@ -97,27 +102,45 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
/** /**
* 设置错误处理器 * 设置错误处理器
* 自动停用无效的设备令牌
*/ */
private setupErrorHandlers(): void { private setupErrorHandlers(): void {
// 监听特定错误 // 监听无效设备令牌错误 - 需要停用
this.client.on(Errors.badDeviceToken, (err) => { this.client.on(Errors.badDeviceToken, async (err) => {
this.logger.error(`Bad device token: ${err}`, err.reason); this.logger.error(`Bad device token detected: ${err.deviceToken}`, err.reason);
await this.deactivateInvalidToken(err.deviceToken, 'BadDeviceToken');
}); });
this.client.on(Errors.unregistered, (err) => { // 监听设备注销错误 - 用户已卸载应用,需要停用
this.client.on(Errors.unregistered, async (err) => {
this.logger.error(`Device unregistered: ${err.deviceToken}`, err.reason); this.logger.error(`Device unregistered: ${err.deviceToken}`, err.reason);
await this.deactivateInvalidToken(err.deviceToken, 'Unregistered');
}); });
this.client.on(Errors.topicDisallowed, (err) => { // 监听 topic 不匹配错误 - bundle ID 配置错误,需要停用
this.logger.error(`Topic disallowed: ${err.deviceToken}`, err.reason); this.client.on(Errors.topicDisallowed, async (err) => {
this.logger.error(`Topic disallowed for device: ${err.deviceToken}`, err.reason);
await this.deactivateInvalidToken(err.deviceToken, 'TopicDisallowed');
}); });
// 监听所有错误 // 监听所有其他错误
this.client.on(Errors.error, (err) => { this.client.on(Errors.error, (err) => {
this.logger.error(`APNs error for device ${err.deviceToken}: ${err.reason}`, err); this.logger.error(`APNs error for device ${err.deviceToken}: ${err.reason}`, err);
}); });
} }
/**
* 停用无效的设备令牌
*/
private async deactivateInvalidToken(deviceToken: string, reason: string): Promise<void> {
try {
this.logger.warn(`Deactivating invalid token due to ${reason}: ${deviceToken}`);
await this.pushTokenService.deactivateToken(deviceToken);
} catch (error) {
this.logger.error(`Failed to deactivate token ${deviceToken}: ${error.message}`, error);
}
}
/** /**
* 发送单个通知 * 发送单个通知
*/ */
@@ -241,8 +264,16 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
// 构建通知选项 // 构建通知选项
const notificationOptions: any = {}; const notificationOptions: any = {};
// 设置 APS 属性 // 设置 alert 属性 - 这是必需的
const aps: any = {}; if (options.title || options.body) {
notificationOptions.alert = {
title: options.title || '',
body: options.body || ''
};
} else if (options.alert) {
// 如果直接提供了 alert使用它
notificationOptions.alert = options.alert;
}
if (options.badge !== undefined) { if (options.badge !== undefined) {
notificationOptions.badge = options.badge; notificationOptions.badge = options.badge;
@@ -333,9 +364,43 @@ export class ApnsProvider implements OnModuleInit, OnModuleDestroy {
*/ */
private createDeviceNotification(notification: Notification, deviceToken: string): Notification { private createDeviceNotification(notification: Notification, deviceToken: string): Notification {
// 创建新的通知实例,使用相同的选项但不同的设备令牌 // 创建新的通知实例,使用相同的选项但不同的设备令牌
return new Notification(deviceToken, { // 复制所有通知选项
alert: notification.options.alert, const options: any = {};
});
if (notification.options.alert) {
options.alert = notification.options.alert;
}
if (notification.options.badge !== undefined) {
options.badge = notification.options.badge;
}
if (notification.options.sound) {
options.sound = notification.options.sound;
}
if (notification.options.contentAvailable) {
options.contentAvailable = notification.options.contentAvailable;
}
if (notification.options.mutableContent) {
options.mutableContent = notification.options.mutableContent;
}
if (notification.options.priority) {
options.priority = notification.options.priority;
}
if (notification.options.type) {
options.type = notification.options.type;
}
// 复制自定义数据
if (notification.options.data) {
options.data = notification.options.data;
}
return new Notification(deviceToken, options);
} }
/** /**

View File

@@ -0,0 +1,384 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { InjectModel } from '@nestjs/sequelize';
import { Op } from 'sequelize';
import { PushType } from 'apns2';
import { PushNotificationsService } from './push-notifications.service';
import { PushTokenService } from './push-token.service';
import { UserPushToken } from './models/user-push-token.model';
import { PushReminderHistory, ReminderType } from './models/push-reminder-history.model';
import { ChallengesService } from '../challenges/challenges.service';
import { Challenge } from '../challenges/models/challenge.model';
import { ChallengeParticipant, ChallengeParticipantStatus } from '../challenges/models/challenge-participant.model';
import {
getEncouragementTemplate,
getInvitationTemplate,
getGeneralInvitationTemplate
} from './templates/challenge-templates';
import * as dayjs from 'dayjs';
@Injectable()
export class ChallengeReminderService {
private readonly logger = new Logger(ChallengeReminderService.name);
constructor(
@InjectModel(UserPushToken)
private readonly pushTokenModel: typeof UserPushToken,
@InjectModel(PushReminderHistory)
private readonly reminderHistoryModel: typeof PushReminderHistory,
@InjectModel(ChallengeParticipant)
private readonly participantModel: typeof ChallengeParticipant,
private readonly pushNotificationsService: PushNotificationsService,
private readonly pushTokenService: PushTokenService,
private readonly challengesService: ChallengesService,
private readonly configService: ConfigService,
) {}
/**
* 每晚8点执行的挑战提醒定时任务
*/
@Cron('0 20 * * *', {
name: 'challengeReminder',
timeZone: 'Asia/Shanghai',
})
async handleChallengeReminder(): Promise<void> {
this.logger.log('Starting daily challenge reminder task...');
try {
// 检查是否为主进程NODE_APP_INSTANCE 为 0
const nodeAppInstance = this.configService.get<number>('NODE_APP_INSTANCE', 0);
if (Number(nodeAppInstance) !== 0) {
this.logger.log(`Not the primary process (instance: ${nodeAppInstance}). Skipping challenge reminder...`);
return;
}
this.logger.log('Primary process detected. Running challenge reminder...');
// 1. 获取所有活跃的推送令牌
const activeTokens = await this.getActivePushTokens();
if (activeTokens.length === 0) {
this.logger.log('No active push tokens found');
return;
}
this.logger.log(`Found ${activeTokens.length} active push tokens`);
// 2. 获取正在进行的挑战
const ongoingChallenges = await this.getOngoingChallenges();
if (ongoingChallenges.length === 0) {
this.logger.log('No ongoing challenges found');
return;
}
this.logger.log(`Found ${ongoingChallenges.length} ongoing challenges`);
// 3. 获取参与挑战的活跃用户
const activeParticipants = await this.getActiveParticipants(ongoingChallenges);
const participantUserIds = new Set(activeParticipants.map(p => p.userId));
this.logger.log(`Found ${activeParticipants.length} active participants`);
// 4. 按用户类型分组处理
await this.processTokensByUserType(activeTokens, participantUserIds, ongoingChallenges);
} catch (error) {
this.logger.error(`Error during challenge reminder task: ${error.message}`, error);
}
}
/**
* 获取所有活跃的推送令牌
*/
private async getActivePushTokens(): Promise<UserPushToken[]> {
return await this.pushTokenModel.findAll({
where: {
isActive: true,
},
});
}
/**
* 获取正在进行中的挑战
*/
private async getOngoingChallenges(): Promise<Challenge[]> {
const challenges = await this.challengesService['challengeModel'].findAll({
order: [['startAt', 'ASC']],
});
// 过滤出真正进行中的挑战
return challenges.filter(challenge => {
const start = dayjs(challenge.startAt);
const end = dayjs(challenge.endAt);
const current = dayjs();
return current.isAfter(start, 'minute') && current.isBefore(end, 'minute');
});
}
/**
* 获取参与挑战的活跃用户
*/
private async getActiveParticipants(challenges: Challenge[]): Promise<ChallengeParticipant[]> {
const challengeIds = challenges.map(challenge => challenge.id);
return await this.participantModel.findAll({
where: {
challengeId: {
[Op.in]: challengeIds,
},
status: ChallengeParticipantStatus.ACTIVE,
},
});
}
/**
* 按用户类型分组处理推送令牌
*/
private async processTokensByUserType(
tokens: UserPushToken[],
participantUserIds: Set<string>,
challenges: Challenge[]
): Promise<void> {
// 分组已参与挑战的用户、未参与挑战但有userId的用户、没有userId的用户
const participatingTokens: UserPushToken[] = [];
const nonParticipatingTokens: UserPushToken[] = [];
const anonymousTokens: UserPushToken[] = [];
for (const token of tokens) {
if (!token.userId) {
anonymousTokens.push(token);
} else if (participantUserIds.has(token.userId)) {
participatingTokens.push(token);
} else {
nonParticipatingTokens.push(token);
}
}
this.logger.log(`Token groups - Participating: ${participatingTokens.length}, Non-participating: ${nonParticipatingTokens.length}, Anonymous: ${anonymousTokens.length}`);
// 处理已参与挑战的用户 - 发送鼓励文案(每天)
await this.sendEncouragementReminders(participatingTokens);
// 处理未参与挑战但有userId的用户 - 发送邀请文案(隔天)
await this.sendInvitationReminders(nonParticipatingTokens, challenges, ReminderType.CHALLENGE_INVITATION);
// 处理没有userId的用户 - 发送通用邀请(隔天)
await this.sendInvitationReminders(anonymousTokens, challenges, ReminderType.GENERAL_INVITATION);
}
/**
* 发送鼓励提醒给已参与挑战的用户
*/
private async sendEncouragementReminders(tokens: UserPushToken[]): Promise<void> {
if (tokens.length === 0) return;
this.logger.log(`Sending encouragement reminders to ${tokens.length} participating users`);
let totalSent = 0;
let totalFailed = 0;
for (const token of tokens) {
try {
// 检查今天是否已经发送过鼓励推送
const today = dayjs().startOf('day').toDate();
const recentReminder = await this.reminderHistoryModel.findOne({
where: {
userId: token.userId,
deviceToken: token.deviceToken,
reminderType: ReminderType.CHALLENGE_ENCOURAGEMENT,
lastSentAt: {
[Op.gte]: today,
},
},
});
if (recentReminder) {
this.logger.log(`User ${token.userId} already received encouragement reminder today`);
continue;
}
// 获取用户参与的挑战信息
const participant = await this.participantModel.findOne({
where: {
userId: token.userId,
status: ChallengeParticipantStatus.ACTIVE,
},
include: [{
model: Challenge,
as: 'challenge',
}],
});
if (!participant || !participant.challenge) {
this.logger.warn(`No active challenge found for user ${token.userId}`);
continue;
}
// 获取鼓励文案
const template = getEncouragementTemplate(participant.challenge.type);
// 发送推送
const result = await this.pushNotificationsService.sendBatchNotificationToDevices({
deviceTokens: [token.deviceToken],
title: template.title,
body: template.body,
pushType: PushType.alert,
});
if (result.code === 0) {
totalSent += result.data.successCount;
totalFailed += result.data.failedCount;
// 记录推送历史
await this.updateReminderHistory(
token.userId,
token.deviceToken,
ReminderType.CHALLENGE_ENCOURAGEMENT
);
this.logger.log(`Encouragement reminder sent to user ${token.userId}`);
} else {
totalFailed++;
this.logger.warn(`Failed to send encouragement reminder to user ${token.userId}: ${result.message}`);
}
} catch (error) {
this.logger.error(`Error sending encouragement reminder to user ${token.userId}: ${error.message}`, error);
totalFailed++;
}
}
this.logger.log(`Encouragement reminders completed. Sent: ${totalSent}, Failed: ${totalFailed}`);
}
/**
* 发送邀请提醒给未参与挑战的用户
*/
private async sendInvitationReminders(
tokens: UserPushToken[],
challenges: Challenge[],
reminderType: ReminderType
): Promise<void> {
if (tokens.length === 0 || challenges.length === 0) return;
this.logger.log(`Sending invitation reminders to ${tokens.length} users`);
let totalSent = 0;
let totalFailed = 0;
// 随机选择一个挑战类型用于邀请
const randomChallenge = challenges[Math.floor(Math.random() * challenges.length)];
for (const token of tokens) {
try {
// 检查是否可以发送(隔天推送)
const canSend = await this.canSendInvitation(token, reminderType);
if (!canSend) {
continue;
}
// 获取邀请文案
const template = reminderType === ReminderType.GENERAL_INVITATION
? getGeneralInvitationTemplate()
: getInvitationTemplate(randomChallenge.type);
// 发送推送
const result = await this.pushNotificationsService.sendBatchNotificationToDevices({
deviceTokens: [token.deviceToken],
title: template.title,
body: template.body,
pushType: PushType.alert,
});
if (result.code === 0) {
totalSent += result.data.successCount;
totalFailed += result.data.failedCount;
// 记录推送历史设置下次可发送时间为2天后
await this.updateReminderHistory(
token.userId,
token.deviceToken,
reminderType,
2 // 2天后可再次发送
);
this.logger.log(`Invitation reminder sent to ${token.userId || 'anonymous user'}`);
} else {
totalFailed++;
this.logger.warn(`Failed to send invitation reminder: ${result.message}`);
}
} catch (error) {
this.logger.error(`Error sending invitation reminder: ${error.message}`, error);
totalFailed++;
}
}
this.logger.log(`Invitation reminders completed. Sent: ${totalSent}, Failed: ${totalFailed}`);
}
/**
* 检查是否可以发送邀请(隔天推送逻辑)
*/
private async canSendInvitation(token: UserPushToken, reminderType: ReminderType): Promise<boolean> {
const reminder = await this.reminderHistoryModel.findOne({
where: {
userId: token.userId,
deviceToken: token.deviceToken,
reminderType,
isActive: true,
},
});
if (!reminder) {
return true; // 没有记录,可以发送
}
// 检查是否到了下次可发送时间
if (reminder.nextAvailableAt && dayjs().isBefore(reminder.nextAvailableAt)) {
return false;
}
return true;
}
/**
* 更新推送历史记录
*/
private async updateReminderHistory(
userId: string | null,
deviceToken: string,
reminderType: ReminderType,
daysUntilNext = 1
): Promise<void> {
const now = new Date();
const nextAvailableAt = dayjs().add(daysUntilNext, 'day').toDate();
const [reminder, created] = await this.reminderHistoryModel.findOrCreate({
where: {
userId,
deviceToken,
reminderType,
},
defaults: {
userId,
deviceToken,
reminderType,
lastSentAt: now,
sentCount: 1,
nextAvailableAt,
isActive: true,
},
});
if (!created) {
await reminder.update({
lastSentAt: now,
sentCount: reminder.sentCount + 1,
nextAvailableAt,
isActive: true,
});
}
}
}

View File

@@ -91,3 +91,17 @@ export class UnregisterTokenResponseDto {
success: boolean; success: boolean;
}; };
} }
export class UpdateTokenUserIdResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '更新结果' })
data: {
success: boolean;
tokenId: string;
};
}

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty } from 'class-validator';
export class UpdateTokenUserIdDto {
@ApiProperty({ description: '设备推送令牌' })
@IsString()
@IsNotEmpty()
deviceToken: string;
}

View File

@@ -0,0 +1,83 @@
import { Table, Column, Model, DataType, Index } from 'sequelize-typescript';
export enum ReminderType {
CHALLENGE_ENCOURAGEMENT = 'challenge_encouragement', // 已参与挑战用户的鼓励推送
CHALLENGE_INVITATION = 'challenge_invitation', // 未参与挑战用户的邀请推送
GENERAL_INVITATION = 'general_invitation', // 无userId用户的通用邀请
}
@Table({
tableName: 't_push_reminder_history',
underscored: true,
})
export class PushReminderHistory extends Model {
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '用户ID可能为空',
})
declare userId: string | null;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '设备推送令牌',
})
declare deviceToken: string;
@Column({
type: DataType.ENUM(...Object.values(ReminderType)),
allowNull: false,
comment: '提醒类型',
})
declare reminderType: ReminderType;
@Column({
type: DataType.DATE,
allowNull: false,
comment: '最后发送时间',
})
declare lastSentAt: Date;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '发送次数',
})
declare sentCount: number;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '下次可发送时间',
})
declare nextAvailableAt: Date | null;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '是否激活',
})
declare isActive: boolean;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
}

View File

@@ -3,10 +3,11 @@ import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/
import { PushNotificationsService } from './push-notifications.service'; import { PushNotificationsService } from './push-notifications.service';
import { RegisterDeviceTokenDto } from './dto/register-device-token.dto'; import { RegisterDeviceTokenDto } from './dto/register-device-token.dto';
import { UpdateDeviceTokenDto } from './dto/update-device-token.dto'; import { UpdateDeviceTokenDto } from './dto/update-device-token.dto';
import { UpdateTokenUserIdDto } from './dto/update-token-user-id.dto';
import { SendPushNotificationDto } from './dto/send-push-notification.dto'; import { SendPushNotificationDto } from './dto/send-push-notification.dto';
import { SendPushByTemplateDto } from './dto/send-push-by-template.dto'; import { SendPushByTemplateDto } from './dto/send-push-by-template.dto';
import { SendPushToDevicesDto } from './dto/send-push-to-devices.dto'; import { SendPushToDevicesDto } from './dto/send-push-to-devices.dto';
import { PushResponseDto, BatchPushResponseDto, RegisterTokenResponseDto, UpdateTokenResponseDto, UnregisterTokenResponseDto } from './dto/push-response.dto'; import { PushResponseDto, BatchPushResponseDto, RegisterTokenResponseDto, UpdateTokenResponseDto, UnregisterTokenResponseDto, UpdateTokenUserIdResponseDto } from './dto/push-response.dto';
import { DevicePushResponseDto, BatchDevicePushResponseDto } from './dto/device-push-response.dto'; import { DevicePushResponseDto, BatchDevicePushResponseDto } from './dto/device-push-response.dto';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service'; import { AccessTokenPayload } from '../users/services/apple-auth.service';
@@ -15,7 +16,7 @@ import { Public } from '../common/decorators/public.decorator';
@ApiTags('推送通知') @ApiTags('推送通知')
@Controller('push-notifications') @Controller('push-notifications')
@UseGuards(JwtAuthGuard)
export class PushNotificationsController { export class PushNotificationsController {
constructor(private readonly pushNotificationsService: PushNotificationsService) { } constructor(private readonly pushNotificationsService: PushNotificationsService) { }
@@ -42,6 +43,16 @@ export class PushNotificationsController {
return this.pushNotificationsService.updateToken(user?.sub || '', updateTokenDto); return this.pushNotificationsService.updateToken(user?.sub || '', updateTokenDto);
} }
@Put('update-token-user-id')
@ApiOperation({ summary: '更新令牌绑定的用户ID' })
@ApiResponse({ status: 200, description: '更新成功', type: UpdateTokenUserIdResponseDto })
async updateTokenUserId(
@CurrentUser() user: AccessTokenPayload,
@Body() updateTokenUserIdDto: UpdateTokenUserIdDto,
): Promise<UpdateTokenUserIdResponseDto> {
return this.pushNotificationsService.updateTokenUserId(user.sub, updateTokenUserIdDto.deviceToken);
}
@Delete('unregister-token') @Delete('unregister-token')
@Public() @Public()
@ApiOperation({ summary: '注销设备推送令牌' }) @ApiOperation({ summary: '注销设备推送令牌' })
@@ -55,7 +66,6 @@ export class PushNotificationsController {
@Post('send') @Post('send')
@ApiOperation({ summary: '发送推送通知' }) @ApiOperation({ summary: '发送推送通知' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto }) @ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendNotification( async sendNotification(
@Body() sendNotificationDto: SendPushNotificationDto, @Body() sendNotificationDto: SendPushNotificationDto,
@@ -65,7 +75,6 @@ export class PushNotificationsController {
@Post('send-by-template') @Post('send-by-template')
@ApiOperation({ summary: '使用模板发送推送' }) @ApiOperation({ summary: '使用模板发送推送' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto }) @ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendNotificationByTemplate( async sendNotificationByTemplate(
@Body() sendByTemplateDto: SendPushByTemplateDto, @Body() sendByTemplateDto: SendPushByTemplateDto,
@@ -75,7 +84,6 @@ export class PushNotificationsController {
@Post('send-batch') @Post('send-batch')
@ApiOperation({ summary: '批量发送推送' }) @ApiOperation({ summary: '批量发送推送' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: BatchPushResponseDto }) @ApiResponse({ status: 200, description: '发送成功', type: BatchPushResponseDto })
async sendBatchNotifications( async sendBatchNotifications(
@Body() sendBatchDto: SendPushNotificationDto, @Body() sendBatchDto: SendPushNotificationDto,
@@ -85,7 +93,6 @@ export class PushNotificationsController {
@Post('send-silent') @Post('send-silent')
@ApiOperation({ summary: '发送静默推送' }) @ApiOperation({ summary: '发送静默推送' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto }) @ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendSilentNotification( async sendSilentNotification(
@Body() body: { userId: string; payload: any }, @Body() body: { userId: string; payload: any },
@@ -95,7 +102,6 @@ export class PushNotificationsController {
@Post('send-to-devices') @Post('send-to-devices')
@ApiOperation({ summary: '向指定设备发送推送通知' }) @ApiOperation({ summary: '向指定设备发送推送通知' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: DevicePushResponseDto }) @ApiResponse({ status: 200, description: '发送成功', type: DevicePushResponseDto })
async sendNotificationToDevices( async sendNotificationToDevices(
@Body() sendToDevicesDto: SendPushToDevicesDto, @Body() sendToDevicesDto: SendPushToDevicesDto,
@@ -105,7 +111,6 @@ export class PushNotificationsController {
@Post('send-batch-to-devices') @Post('send-batch-to-devices')
@ApiOperation({ summary: '批量向指定设备发送推送通知' }) @ApiOperation({ summary: '批量向指定设备发送推送通知' })
@UseGuards(JwtAuthGuard)
@ApiResponse({ status: 200, description: '发送成功', type: BatchDevicePushResponseDto }) @ApiResponse({ status: 200, description: '发送成功', type: BatchDevicePushResponseDto })
async sendBatchNotificationToDevices( async sendBatchNotificationToDevices(
@Body() sendBatchToDevicesDto: SendPushToDevicesDto, @Body() sendBatchToDevicesDto: SendPushToDevicesDto,

View File

@@ -7,23 +7,29 @@ import { ApnsProvider } from './apns.provider';
import { PushTokenService } from './push-token.service'; import { PushTokenService } from './push-token.service';
import { PushTemplateService } from './push-template.service'; import { PushTemplateService } from './push-template.service';
import { PushMessageService } from './push-message.service'; import { PushMessageService } from './push-message.service';
import { PushTestService } from './push-test.service'; import { ChallengeReminderService } from './challenge-reminder.service';
import { UserPushToken } from './models/user-push-token.model'; import { UserPushToken } from './models/user-push-token.model';
import { PushMessage } from './models/push-message.model'; import { PushMessage } from './models/push-message.model';
import { PushTemplate } from './models/push-template.model'; import { PushTemplate } from './models/push-template.model';
import { PushReminderHistory } from './models/push-reminder-history.model';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from '../database/database.module'; import { DatabaseModule } from '../database/database.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { ChallengesModule } from '../challenges/challenges.module';
import { ChallengeParticipant } from '../challenges/models/challenge-participant.model';
@Module({ @Module({
imports: [ imports: [
ConfigModule, ConfigModule,
DatabaseModule, DatabaseModule,
UsersModule, UsersModule,
ChallengesModule,
SequelizeModule.forFeature([ SequelizeModule.forFeature([
UserPushToken, UserPushToken,
PushMessage, PushMessage,
PushTemplate, PushTemplate,
PushReminderHistory,
ChallengeParticipant,
]), ]),
], ],
controllers: [ controllers: [
@@ -36,7 +42,7 @@ import { UsersModule } from '../users/users.module';
PushTokenService, PushTokenService,
PushTemplateService, PushTemplateService,
PushMessageService, PushMessageService,
PushTestService, ChallengeReminderService,
], ],
exports: [ exports: [
ApnsProvider, ApnsProvider,
@@ -44,7 +50,7 @@ import { UsersModule } from '../users/users.module';
PushTokenService, PushTokenService,
PushTemplateService, PushTemplateService,
PushMessageService, PushMessageService,
PushTestService, ChallengeReminderService,
], ],
}) })
export class PushNotificationsModule { } export class PushNotificationsModule { }

View File

@@ -110,7 +110,8 @@ export class PushNotificationsService {
sentCount++; sentCount++;
} else { } else {
const failure = apnsResults.failed[0]; const failure = apnsResults.failed[0];
const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`; const error = failure.error as any;
const errorMessage = error?.message || `APNs Error: ${failure.status || 'Unknown'}`;
await this.pushMessageService.updateMessageStatus( await this.pushMessageService.updateMessageStatus(
message.id, message.id,
@@ -119,9 +120,12 @@ export class PushNotificationsService {
errorMessage errorMessage
); );
// 如果是无效令牌,停用该令牌 // 检查是否是无效令牌错误 - 需要停用 token
if (failure.status === '410' || failure.response?.reason === 'Unregistered') { const shouldDeactivateToken = this.shouldDeactivateToken(error, failure.response);
await this.pushTokenService.unregisterToken(userId, deviceToken);
if (shouldDeactivateToken) {
this.logger.warn(`Deactivating invalid token for user ${userId}: ${errorMessage}`);
await this.pushTokenService.deactivateToken(deviceToken);
} }
results.push({ results.push({
@@ -173,6 +177,40 @@ export class PushNotificationsService {
} }
} }
/**
* 判断是否应该停用 token
* 根据 APNs 错误类型判断 token 是否已失效
*/
private shouldDeactivateToken(error: any, response: any): boolean {
if (!error && !response) {
return false;
}
// 检查错误对象的 reason 字段apns2 库的错误格式)
const reason = error?.reason || response?.reason || '';
// APNs 返回的需要停用 token 的错误原因
const invalidTokenReasons = [
'BadDeviceToken', // 无效的设备令牌格式
'Unregistered', // 设备已注销(用户卸载了应用)
'DeviceTokenNotForTopic', // token 与 bundle ID 不匹配
'ExpiredToken', // token 已过期
];
// 检查是否包含这些错误原因
if (invalidTokenReasons.some(r => reason.includes(r))) {
return true;
}
// 检查 HTTP 状态码 410 (Gone) - 表示设备令牌永久失效
const statusCode = error?.statusCode || response?.statusCode;
if (statusCode === 410 || statusCode === '410') {
return true;
}
return false;
}
/** /**
* 使用模板发送推送通知 * 使用模板发送推送通知
*/ */
@@ -311,7 +349,8 @@ export class PushNotificationsService {
} else { } else {
// 发送失败 // 发送失败
const failure = apnsResult as any; const failure = apnsResult as any;
const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`; const error = failure.error as any;
const errorMessage = error?.message || `APNs Error: ${failure.status || 'Unknown'}`;
await this.pushMessageService.updateMessageStatus( await this.pushMessageService.updateMessageStatus(
message.id, message.id,
@@ -320,9 +359,12 @@ export class PushNotificationsService {
errorMessage errorMessage
); );
// 如果是无效令牌,停用该令牌 // 检查是否是无效令牌错误 - 需要停用 token
if (failure.status === '410' || failure.response?.reason === 'Unregistered') { const shouldDeactivateToken = this.shouldDeactivateToken(error, failure.response);
await this.pushTokenService.unregisterToken(userId, deviceToken);
if (shouldDeactivateToken) {
this.logger.warn(`Deactivating invalid token for user ${userId}: ${errorMessage}`);
await this.pushTokenService.deactivateToken(deviceToken);
} }
results.push({ results.push({
@@ -504,6 +546,34 @@ export class PushNotificationsService {
} }
} }
/**
* 更新令牌绑定的用户ID
*/
async updateTokenUserId(userId: string, deviceToken: string): Promise<any> {
try {
const token = await this.pushTokenService.updateTokenUserId(deviceToken, userId);
this.logger.log(`Updated user ID for device token: ${deviceToken}`);
return {
code: ResponseCode.SUCCESS,
message: '令牌用户ID更新成功',
data: {
success: true,
tokenId: token.id,
},
};
} catch (error) {
this.logger.error(`Failed to update user ID for device token: ${error.message}`, error);
return {
code: ResponseCode.ERROR,
message: `令牌用户ID更新失败: ${error.message}`,
data: {
success: false,
tokenId: '',
},
};
}
}
/** /**
* 基于设备令牌发送推送通知 * 基于设备令牌发送推送通知
*/ */
@@ -569,7 +639,8 @@ export class PushNotificationsService {
sentCount++; sentCount++;
} else { } else {
const failure = apnsResults.failed[0]; const failure = apnsResults.failed[0];
const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`; const error = failure.error as any;
const errorMessage = error?.message || `APNs Error: ${failure.status || 'Unknown'}`;
await this.pushMessageService.updateMessageStatus( await this.pushMessageService.updateMessageStatus(
message.id, message.id,
@@ -578,15 +649,13 @@ export class PushNotificationsService {
errorMessage errorMessage
); );
// 如果是无效令牌,停用该令牌 // 检查是否是无效令牌错误 - 需要停用 token
if (failure.status === '410' || failure.response?.reason === 'Unregistered') { const shouldDeactivateToken = this.shouldDeactivateToken(error, failure.response);
if (userId) {
await this.pushTokenService.unregisterToken(userId, deviceToken); if (shouldDeactivateToken) {
} else { this.logger.warn(`Deactivating invalid token for device ${deviceToken}: ${errorMessage}`);
// 如果没有用户ID直接停用令牌
await this.pushTokenService.deactivateToken(deviceToken); await this.pushTokenService.deactivateToken(deviceToken);
} }
}
results.push({ results.push({
deviceToken, deviceToken,
@@ -649,14 +718,18 @@ export class PushNotificationsService {
// 创建APNs通知 // 创建APNs通知
const apnsNotification = this.apnsProvider.createNotification({ const apnsNotification = this.apnsProvider.createNotification({
alert: notificationData.title,
title: notificationData.title, title: notificationData.title,
body: notificationData.body, body: notificationData.body,
data: notificationData.payload, data: notificationData.payload,
pushType: notificationData.pushType, pushType: notificationData.pushType,
priority: notificationData.priority,
expiry: notificationData.expiry,
collapseId: notificationData.collapseId,
topic: this.bundleId, topic: this.bundleId,
sound: notificationData.sound, sound: notificationData.sound,
badge: notificationData.badge, badge: notificationData.badge,
mutableContent: notificationData.mutableContent,
contentAvailable: notificationData.contentAvailable,
}); });
this.logger.log(`apnsNotification: ${JSON.stringify(apnsNotification, null, 2)}`); this.logger.log(`apnsNotification: ${JSON.stringify(apnsNotification, null, 2)}`);
@@ -706,7 +779,8 @@ export class PushNotificationsService {
} else { } else {
// 发送失败 // 发送失败
const failure = apnsResult as any; const failure = apnsResult as any;
const errorMessage = failure.error ? failure.error.message : `APNs Error: ${failure.status}`; const error = failure.error as any;
const errorMessage = error?.message || `APNs Error: ${failure.status || 'Unknown'}`;
await this.pushMessageService.updateMessageStatus( await this.pushMessageService.updateMessageStatus(
message.id, message.id,
@@ -715,15 +789,13 @@ export class PushNotificationsService {
errorMessage errorMessage
); );
// 如果是无效令牌,停用该令牌 // 检查是否是无效令牌错误 - 需要停用 token
if (failure.status === '410' || failure.response?.reason === 'Unregistered') { const shouldDeactivateToken = this.shouldDeactivateToken(error, failure.response);
if (userId) {
await this.pushTokenService.unregisterToken(userId, deviceToken); if (shouldDeactivateToken) {
} else { this.logger.warn(`Deactivating invalid token for device ${deviceToken}: ${errorMessage}`);
// 如果没有用户ID直接停用令牌
await this.pushTokenService.deactivateToken(deviceToken); await this.pushTokenService.deactivateToken(deviceToken);
} }
}
results.push({ results.push({
deviceToken, deviceToken,

View File

@@ -1,99 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PushNotificationsService } from './push-notifications.service';
import { PushTokenService } from './push-token.service';
import { UserPushToken } from './models/user-push-token.model';
import { InjectModel } from '@nestjs/sequelize';
import { Op } from 'sequelize';
import { PushType } from 'apns2';
@Injectable()
export class PushTestService implements OnModuleInit {
private readonly logger = new Logger(PushTestService.name);
constructor(
@InjectModel(UserPushToken)
private readonly pushTokenModel: typeof UserPushToken,
private readonly pushNotificationsService: PushNotificationsService,
private readonly pushTokenService: PushTokenService,
private readonly configService: ConfigService,
) { }
/**
* 模块初始化时执行
*/
async onModuleInit() {
// 检查是否启用推送测试
const enablePushTest = this.configService.get<boolean>('ENABLE_PUSH_TEST', false);
if (!enablePushTest) {
this.logger.log('Push test is disabled. Skipping...');
return;
}
// 延迟执行,确保应用完全启动
setTimeout(async () => {
try {
await this.performPushTest();
} catch (error) {
this.logger.error(`Push test failed: ${error.message}`, error);
}
}, 5000); // 5秒后执行
}
/**
* 执行推送测试
*/
private async performPushTest(): Promise<void> {
this.logger.log('Starting push test...');
try {
// 获取所有活跃的推送令牌
const activeTokens = await this.pushTokenModel.findAll({
where: {
isActive: true,
},
limit: 10, // 限制测试数量,避免发送过多推送
});
if (activeTokens.length === 0) {
this.logger.log('No active push tokens found for testing');
return;
}
this.logger.log(`Found ${activeTokens.length} active tokens for testing`);
// 准备测试推送内容
const testTitle = this.configService.get<string>('PUSH_TEST_TITLE', '测试推送');
const testBody = this.configService.get<string>('PUSH_TEST_BODY', '这是一条测试推送消息,用于验证推送功能是否正常工作。');
// 发送测试推送
const result = await this.pushNotificationsService.sendBatchNotificationToDevices({
deviceTokens: activeTokens.map(token => token.deviceToken),
title: testTitle,
body: testBody,
pushType: PushType.alert,
});
if (result.code === 0) {
this.logger.log(`Push test completed successfully. Sent: ${result.data.successCount}, Failed: ${result.data.failedCount}`);
} else {
this.logger.warn(`Push test completed with issues. Sent: ${result.data.successCount}, Failed: ${result.data.failedCount}`);
}
// 记录详细结果
if (result.data.results && result.data.results.length > 0) {
result.data.results.forEach((resultItem, index) => {
if (resultItem.success) {
this.logger.log(`Push test success for user ${resultItem.userId}, device ${resultItem.deviceToken.substring(0, 10)}...`);
} else {
this.logger.warn(`Push test failed for user ${resultItem.userId}, device ${resultItem.deviceToken.substring(0, 10)}...: ${resultItem.error}`);
}
});
}
} catch (error) {
this.logger.error(`Error during push test: ${error.message}`, error);
}
}
}

View File

@@ -351,4 +351,43 @@ export class PushTokenService {
throw error; throw error;
} }
} }
/**
* 更新令牌绑定的用户ID
*/
async updateTokenUserId(deviceToken: string, userId: string): Promise<UserPushToken> {
try {
this.logger.log(`Updating user ID for device token: ${deviceToken}`);
// 查找设备令牌
const token = await this.pushTokenModel.findOne({
where: {
deviceToken,
isActive: true,
},
});
if (!token) {
throw new NotFoundException('Device token not found or inactive');
}
// 检查是否已经绑定了其他用户
if (token.userId && token.userId !== userId) {
this.logger.warn(`Device token ${deviceToken} is already bound to another user ${token.userId}`);
// 如果已经绑定到其他用户,我们仍然更新为当前用户,因为可能是同一用户在不同设备上登录
}
// 更新用户ID
await token.update({
userId,
lastUsedAt: new Date(),
});
this.logger.log(`Successfully updated user ID for device token: ${deviceToken}`);
return token;
} catch (error) {
this.logger.error(`Failed to update user ID for device token: ${deviceToken}: ${error.message}`, error);
throw error;
}
}
} }

View File

@@ -0,0 +1,127 @@
import { ChallengeType } from '../../challenges/models/challenge.model';
/**
* 挑战鼓励文案模板 - 针对已参与挑战的用户
*/
export const ENCOURAGEMENT_TEMPLATES = {
[ChallengeType.WATER]: [
{ title: '饮水挑战进行中', body: '今天已有多人完成饮水目标!你也要记得多喝水哦!' },
{ title: '健康饮水提醒', body: '加入饮水挑战的小伙伴们今天都很棒!你今天喝水达标了吗?' },
{ title: '水分补充时间', body: '挑战者们都在坚持每日饮水目标,你也要跟上大家的步伐!' },
{ title: '饮水习惯养成', body: '看到很多挑战伙伴都养成了良好饮水习惯,你也是其中一员!' },
{ title: '团队饮水挑战', body: '今天挑战群里又有很多人完成了目标!别掉队,一起加油!' },
],
[ChallengeType.EXERCISE]: [
{ title: '运动挑战进行时', body: '今天已有多人完成运动目标!你也来动一动吧!' },
{ title: '活力运动提醒', body: '挑战伙伴们都在坚持运动,每一滴汗水都是进步的见证!' },
{ title: '运动习惯养成', body: '看到很多挑战者都养成了运动习惯,你也是其中一员!' },
{ title: '团队运动挑战', body: '今天运动挑战群里又有很多人完成了目标!别掉队,一起加油!' },
{ title: '运动生活分享', body: '挑战者们都在分享运动心得,你的今天运动了吗?' },
],
[ChallengeType.DIET]: [
{ title: '饮食挑战进行中', body: '今天已有多人记录了健康饮食!你也要记得合理搭配哦!' },
{ title: '营养均衡提醒', body: '挑战伙伴们都在坚持健康饮食,为身体提供充足营养!' },
{ title: '健康饮食分享', body: '看到很多挑战者都在分享健康餐,你今天吃了什么?' },
{ title: '团队饮食挑战', body: '今天饮食挑战群里又有很多人完成了目标!一起健康饮食!' },
{ title: '营养生活记录', body: '挑战者们都在记录饮食,关注健康,让每一餐都有意义!' },
],
[ChallengeType.MOOD]: [
{ title: '心情记录挑战', body: '今天已有多人记录了心情!你也来分享今天的感受吧!' },
{ title: '情绪管理提醒', body: '挑战伙伴们都在关注情绪变化,让心情更加愉悦平和!' },
{ title: '心情日记分享', body: '看到很多挑战者都在记录心情故事,你今天心情如何?' },
{ title: '团队心情挑战', body: '今天心情挑战群里又有很多人分享了感受!一起关注心理健康!' },
{ title: '情绪健康关怀', body: '挑战者们都在关爱自己的情绪,从记录心情开始!' },
],
[ChallengeType.SLEEP]: [
{ title: '睡眠挑战进行中', body: '今天已有多人保持了规律作息!你也要早睡早起哦!' },
{ title: '优质睡眠提醒', body: '挑战伙伴们都在保持良好睡眠质量,享受每一个宁静的夜晚!' },
{ title: '规律作息养成', body: '看到很多挑战者都养成了规律作息,你也是其中一员!' },
{ title: '团队睡眠挑战', body: '今天睡眠挑战群里又有很多人完成了目标!一起健康作息!' },
{ title: '休息时光分享', body: '挑战者们都在分享睡眠心得,你今天休息得好吗?' },
],
[ChallengeType.WEIGHT]: [
{ title: '体重管理挑战', body: '今天已有多人记录了体重变化!你也要坚持健康生活方式!' },
{ title: '健康体重提醒', body: '挑战伙伴们都在坚持体重管理,享受轻松自在的生活!' },
{ title: '体重记录分享', body: '看到很多挑战者都在记录体重变化,见证自己的进步!' },
{ title: '团队体重挑战', body: '今天体重挑战群里又有很多人完成了目标!一起健康生活!' },
{ title: '健康生活记录', body: '挑战者们都在坚持健康饮食+适量运动,你也是其中一员!' },
],
};
/**
* 挑战邀请文案模板 - 针对未参与挑战但有userId的用户
*/
export const INVITATION_TEMPLATES = {
[ChallengeType.WATER]: [
{ title: '饮水挑战邀请', body: '加入21天饮水挑战养成健康饮水习惯' },
{ title: '健康饮水挑战', body: '每天适量饮水,让身体更健康。立即加入挑战!' },
{ title: '饮水习惯养成', body: '挑战自己,养成良好饮水习惯,收获健康!' },
],
[ChallengeType.EXERCISE]: [
{ title: '运动挑战邀请', body: '加入运动挑战,让身体充满活力!' },
{ title: '健身挑战', body: '21天运动挑战塑造更好的自己' },
{ title: '运动习惯养成', body: '坚持运动,收获健康体魄,立即加入!' },
],
[ChallengeType.DIET]: [
{ title: '饮食挑战邀请', body: '加入健康饮食挑战,培养良好饮食习惯!' },
{ title: '营养挑战', body: '21天饮食挑战让营养均衡成为习惯' },
{ title: '健康饮食计划', body: '科学饮食,健康生活,从挑战开始!' },
],
[ChallengeType.MOOD]: [
{ title: '心情记录挑战', body: '加入心情记录挑战,关注情绪健康!' },
{ title: '情绪管理挑战', body: '21天心情记录让心灵更加宁静。' },
{ title: '心理健康挑战', body: '记录心情,关爱自己,从现在开始!' },
],
[ChallengeType.SLEEP]: [
{ title: '睡眠挑战邀请', body: '加入优质睡眠挑战,养成规律作息!' },
{ title: '作息规律挑战', body: '21天睡眠挑战让身体得到充分休息。' },
{ title: '健康睡眠计划', body: '改善睡眠质量,享受每一个清晨!' },
],
[ChallengeType.WEIGHT]: [
{ title: '体重管理挑战', body: '加入体重管理挑战,收获健康理想体重!' },
{ title: '健康体重挑战', body: '21天体重管理见证自己的变化' },
{ title: '体重目标挑战', body: '科学管理体重,享受健康生活!' },
],
};
/**
* 通用邀请文案模板 - 针对没有userId的用户
*/
export const GENERAL_INVITATION_TEMPLATES = [
{ title: '健康挑战邀请', body: '加入我们的健康挑战,开启健康生活新篇章!' },
{ title: '21天挑战', body: '21天养成健康习惯你准备好了吗' },
{ title: '健康生活', body: '追求健康生活,从参加挑战开始!' },
{ title: '挑战自我', body: '挑战自己,收获健康,让生活更美好!' },
{ title: '健康之旅', body: '开启健康之旅,遇见更好的自己!' },
];
/**
* 随机选择一个模板
*/
export function getRandomTemplate<T>(templates: T[]): T {
const randomIndex = Math.floor(Math.random() * templates.length);
return templates[randomIndex];
}
/**
* 根据挑战类型获取鼓励文案
*/
export function getEncouragementTemplate(challengeType: ChallengeType) {
const templates = ENCOURAGEMENT_TEMPLATES[challengeType] || ENCOURAGEMENT_TEMPLATES[ChallengeType.EXERCISE];
return getRandomTemplate(templates);
}
/**
* 根据挑战类型获取邀请文案
*/
export function getInvitationTemplate(challengeType: ChallengeType) {
const templates = INVITATION_TEMPLATES[challengeType] || INVITATION_TEMPLATES[ChallengeType.EXERCISE];
return getRandomTemplate(templates);
}
/**
* 获取通用邀请文案
*/
export function getGeneralInvitationTemplate() {
return getRandomTemplate(GENERAL_INVITATION_TEMPLATES);
}

157
src/users/dto/badge.dto.ts Normal file
View File

@@ -0,0 +1,157 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsBoolean, IsDateString, IsNotEmpty } from 'class-validator';
import { BaseResponseDto } from '../../base.dto';
// 勋章基本信息
export class BadgeInfoDto {
@ApiProperty({ description: '勋章代码', example: 'goodSleep' })
code: string;
@ApiProperty({ description: '勋章名称', example: '好眠达人' })
name: string;
@ApiProperty({ description: '勋章描述', example: '完成首次睡眠挑战' })
description: string;
@ApiProperty({ description: '勋章图片URL' })
imageUrl: string;
@ApiProperty({ description: '勋章分类', example: 'sleep' })
category: string;
@ApiProperty({ description: '排序顺序', example: 1 })
sortOrder: number;
}
// 用户勋章信息
export class UserBadgeDto {
@ApiProperty({ description: '记录ID', example: 1 })
id: number;
@ApiProperty({ description: '勋章代码', example: 'goodSleep' })
code: string;
@ApiProperty({ description: '勋章名称', example: '好眠达人' })
name: string;
@ApiProperty({ description: '勋章描述', example: '完成首次睡眠挑战' })
description: string;
@ApiProperty({ description: '勋章图片URL' })
imageUrl: string;
@ApiProperty({ description: '勋章分类', example: 'sleep' })
category: string;
@ApiProperty({ description: '获得时间' })
awardedAt: Date;
@ApiProperty({ description: '授予来源', example: 'challenge' })
source: string;
@ApiProperty({ description: '来源ID如挑战ID', required: false })
sourceId?: string;
@ApiProperty({ description: '元数据', required: false })
metadata?: Record<string, any>;
@ApiProperty({ description: '是否已展示过', example: false })
isShow: boolean;
}
// 可用勋章信息(包含是否已获得)
export class AvailableBadgeDto extends BadgeInfoDto {
@ApiProperty({ description: '是否已获得', example: false })
isAwarded: boolean;
@ApiProperty({ description: '获得时间(如果已获得)', required: false })
awardedAt?: Date;
@ApiProperty({ description: '是否已展示过(如果已获得)', required: false })
isShow?: boolean;
}
// 获取用户勋章列表响应
export class GetUserBadgesResponseDto implements BaseResponseDto<{
badges: UserBadgeDto[];
total: number;
}> {
@ApiProperty({ description: '响应状态码', example: 0 })
code: number;
@ApiProperty({ description: '响应消息', example: 'success' })
message: string;
@ApiProperty({
description: '用户勋章数据',
example: {
badges: [
{
id: 1,
code: 'goodSleep',
name: '好眠达人',
description: '完成首次睡眠挑战',
imageUrl: 'https://example.com/badge.png',
category: 'sleep',
awardedAt: '2025-01-14T07:00:00Z',
source: 'challenge',
sourceId: 'challenge-uuid',
},
],
total: 1,
},
})
data: {
badges: UserBadgeDto[];
total: number;
};
}
// 获取所有可用勋章响应
export class GetAvailableBadgesResponseDto implements BaseResponseDto<AvailableBadgeDto[]> {
@ApiProperty({ description: '响应状态码', example: 0 })
code: number;
@ApiProperty({ description: '响应消息', example: 'success' })
message: string;
@ApiProperty({
description: '所有可用勋章列表',
example: [
{
code: 'goodSleep',
name: '好眠达人',
description: '完成首次睡眠挑战',
imageUrl: 'https://example.com/badge.png',
category: 'sleep',
sortOrder: 1,
isAwarded: true,
awardedAt: '2025-01-14T07:00:00Z',
},
],
})
data: AvailableBadgeDto[];
}
// 标记勋章已展示请求
export class MarkBadgeShownDto {
@ApiProperty({ description: '勋章代码', example: 'goodSleep' })
@IsString()
@IsNotEmpty()
badgeCode: string;
}
// 标记勋章已展示响应
export class MarkBadgeShownResponseDto implements BaseResponseDto<{ success: boolean }> {
@ApiProperty({ description: '响应状态码', example: 0 })
code: number;
@ApiProperty({ description: '响应消息', example: 'success' })
message: string;
@ApiProperty({
description: '操作结果',
example: { success: true },
})
data: { success: boolean };
}

View File

@@ -0,0 +1,90 @@
import { Column, Model, Table, DataType } from 'sequelize-typescript';
export enum BadgeCategory {
SLEEP = 'sleep',
EXERCISE = 'exercise',
DIET = 'diet',
WATER = 'water',
MOOD = 'mood',
WEIGHT = 'weight',
GENERAL = 'general',
}
@Table({
tableName: 't_badge_configs',
underscored: true,
timestamps: true,
})
export class BadgeConfig extends Model {
@Column({
type: DataType.CHAR(36),
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@Column({
type: DataType.STRING(64),
allowNull: false,
unique: true,
comment: '勋章唯一标识码',
})
declare code: string;
@Column({
type: DataType.STRING(128),
allowNull: false,
comment: '勋章名称',
})
declare name: string;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '勋章描述',
})
declare description: string;
@Column({
type: DataType.STRING(512),
allowNull: false,
comment: '勋章图片URL',
})
declare imageUrl: string;
@Column({
type: DataType.ENUM(...Object.values(BadgeCategory)),
allowNull: false,
defaultValue: BadgeCategory.GENERAL,
comment: '勋章分类',
})
declare category: BadgeCategory;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '是否启用',
})
declare isActive: boolean;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序顺序',
})
declare sortOrder: number;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
}

View File

@@ -0,0 +1,105 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { User } from './user.model';
import { BadgeConfig } from './badge-config.model';
export enum BadgeSource {
CHALLENGE = 'challenge',
MANUAL = 'manual',
SYSTEM = 'system',
}
@Table({
tableName: 't_user_badges',
underscored: true,
timestamps: true,
indexes: [
{
unique: true,
fields: ['user_id', 'badge_code'],
name: 'unique_user_badge',
},
{
fields: ['user_id'],
},
{
fields: ['badge_code'],
},
{
fields: ['awarded_at'],
},
],
})
export class UserBadge extends Model {
@Column({
type: DataType.CHAR(36),
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING,
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@ForeignKey(() => BadgeConfig)
@Column({
type: DataType.STRING(64),
allowNull: false,
comment: '勋章代码',
})
declare badgeCode: string;
@Column({
type: DataType.DATE,
allowNull: false,
defaultValue: DataType.NOW,
comment: '获得时间',
})
declare awardedAt: Date;
@Column({
type: DataType.ENUM(...Object.values(BadgeSource)),
allowNull: false,
defaultValue: BadgeSource.SYSTEM,
comment: '授予来源',
})
declare source: BadgeSource;
@Column({
type: DataType.STRING(128),
allowNull: true,
comment: '来源ID如挑战ID',
})
declare sourceId: string;
@Column({
type: DataType.JSON,
allowNull: true,
comment: '额外元数据',
})
declare metadata: Record<string, any>;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否已展示过客户端展示勋章获得动画后设置为true',
})
declare isShow: boolean;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@BelongsTo(() => User)
declare user: User;
@BelongsTo(() => BadgeConfig, 'badgeCode')
declare badge: BadgeConfig;
}

View File

@@ -0,0 +1,236 @@
import { Injectable, Logger, ConflictException } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { BadgeConfig } from '../models/badge-config.model';
import { UserBadge, BadgeSource } from '../models/user-badge.model';
import { Op } from 'sequelize';
@Injectable()
export class BadgeService {
private readonly logger = new Logger(BadgeService.name);
constructor(
@InjectModel(BadgeConfig)
private readonly badgeConfigModel: typeof BadgeConfig,
@InjectModel(UserBadge)
private readonly userBadgeModel: typeof UserBadge,
) {}
/**
* 授予用户勋章
* @param userId 用户ID
* @param badgeCode 勋章代码
* @param options 额外选项
*/
async awardBadge(
userId: string,
badgeCode: string,
options?: {
source?: BadgeSource;
sourceId?: string;
metadata?: Record<string, any>;
},
): Promise<UserBadge | null> {
try {
// 检查勋章配置是否存在且启用
const badgeConfig = await this.badgeConfigModel.findOne({
where: { code: badgeCode, isActive: true },
});
if (!badgeConfig) {
this.logger.warn(`勋章配置不存在或未启用: ${badgeCode}`);
return null;
}
// 检查用户是否已经拥有该勋章
const existingBadge = await this.userBadgeModel.findOne({
where: { userId, badgeCode },
});
if (existingBadge) {
this.logger.log(`用户 ${userId} 已拥有勋章 ${badgeCode},跳过授予`);
return existingBadge;
}
// 创建用户勋章记录
const userBadge = await this.userBadgeModel.create({
userId,
badgeCode,
awardedAt: new Date(),
source: options?.source || BadgeSource.SYSTEM,
sourceId: options?.sourceId,
metadata: options?.metadata,
});
this.logger.log(`成功授予用户 ${userId} 勋章 ${badgeCode}`);
return userBadge;
} catch (error) {
this.logger.error(`授予勋章失败: ${error instanceof Error ? error.message : '未知错误'}`);
// 如果是唯一约束冲突,说明已经拥有该勋章
if (error.name === 'SequelizeUniqueConstraintError') {
return null;
}
throw error;
}
}
/**
* 获取用户所有勋章
* @param userId 用户ID
*/
async getUserBadges(userId: string): Promise<Array<{
id: string;
code: string;
name: string;
description: string;
imageUrl: string;
category: string;
awardedAt: Date;
source: string;
sourceId: string;
metadata: Record<string, any>;
isShow: boolean;
}>> {
const userBadges = await this.userBadgeModel.findAll({
where: { userId },
include: [
{
model: BadgeConfig,
as: 'badge',
attributes: ['code', 'name', 'description', 'imageUrl', 'category'],
},
],
order: [['awardedAt', 'DESC']],
});
return userBadges.map((ub) => ({
id: ub.id,
code: ub.badgeCode,
name: ub.badge?.name || '',
description: ub.badge?.description || '',
imageUrl: ub.badge?.imageUrl || '',
category: ub.badge?.category || '',
awardedAt: ub.awardedAt,
source: ub.source,
sourceId: ub.sourceId,
metadata: ub.metadata,
isShow: ub.isShow,
}));
}
/**
* 获取所有可用勋章(包含用户是否已获得信息)
* @param userId 用户ID可选
*/
async getAvailableBadges(userId?: string): Promise<Array<{
code: string;
name: string;
description: string;
imageUrl: string;
category: string;
sortOrder: number;
isAwarded: boolean;
awardedAt?: Date;
isShow?: boolean;
}>> {
// 获取所有启用的勋章配置
const badgeConfigs = await this.badgeConfigModel.findAll({
where: { isActive: true },
order: [['sortOrder', 'ASC'], ['createdAt', 'ASC']],
});
if (!userId) {
// 如果没有提供用户ID返回所有勋章标记为未获得
return badgeConfigs.map((bc) => ({
code: bc.code,
name: bc.name,
description: bc.description,
imageUrl: bc.imageUrl,
category: bc.category,
sortOrder: bc.sortOrder,
isAwarded: false,
isShow: false
}));
}
// 获取用户已拥有的勋章
const userBadges = await this.userBadgeModel.findAll({
where: {
userId,
badgeCode: {
[Op.in]: badgeConfigs.map((bc) => bc.code),
},
},
});
const userBadgeMap = new Map(
userBadges.map((ub) => [ub.badgeCode, { awardedAt: ub.awardedAt, isShow: ub.isShow }]),
);
return badgeConfigs.map((bc) => {
const badgeInfo = userBadgeMap.get(bc.code);
return {
code: bc.code,
name: bc.name,
description: bc.description,
imageUrl: bc.imageUrl,
category: bc.category,
sortOrder: bc.sortOrder,
isAwarded: !!badgeInfo,
awardedAt: badgeInfo?.awardedAt || undefined,
isShow: badgeInfo?.isShow || false,
};
});
}
/**
* 检查用户是否拥有指定勋章
* @param userId 用户ID
* @param badgeCode 勋章代码
*/
async hasBadge(userId: string, badgeCode: string): Promise<boolean> {
const count = await this.userBadgeModel.count({
where: { userId, badgeCode },
});
return count > 0;
}
/**
* 获取用户勋章数量
* @param userId 用户ID
*/
async getUserBadgeCount(userId: string): Promise<number> {
return await this.userBadgeModel.count({
where: { userId },
});
}
/**
* 标记勋章已展示
* @param userId 用户ID
* @param badgeCode 勋章代码
*/
async markBadgeAsShown(userId: string, badgeCode: string): Promise<boolean> {
try {
const userBadge = await this.userBadgeModel.findOne({
where: { userId, badgeCode },
});
if (!userBadge) {
this.logger.warn(`用户 ${userId} 不拥有勋章 ${badgeCode},无法标记为已展示`);
return false;
}
if (userBadge.isShow) {
this.logger.log(`用户 ${userId} 的勋章 ${badgeCode} 已经标记为展示过,跳过更新`);
return true;
}
await userBadge.update({ isShow: true });
this.logger.log(`成功标记用户 ${userId} 的勋章 ${badgeCode} 为已展示`);
return true;
} catch (error) {
this.logger.error(`标记勋章已展示失败: ${error instanceof Error ? error.message : '未知错误'}`);
throw error;
}
}
}

View File

@@ -35,6 +35,7 @@ import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-pu
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto'; import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto'; import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto';
import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.dto'; import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.dto';
import { GetUserBadgesResponseDto, GetAvailableBadgesResponseDto, MarkBadgeShownDto, MarkBadgeShownResponseDto } from './dto/badge.dto';
import { Public } from '../common/decorators/public.decorator'; import { Public } from '../common/decorators/public.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
@@ -222,13 +223,6 @@ export class UsersController {
return { code: ResponseCode.ERROR, message: '请选择要上传的图片文件' }; return { code: ResponseCode.ERROR, message: '请选择要上传的图片文件' };
} }
this.winstonLogger.info(`receive file, fileSize: ${file.size}`, {
context: 'UsersController',
userId: user?.sub,
file,
})
const data = await this.cosService.uploadImage(user.sub, file); const data = await this.cosService.uploadImage(user.sub, file);
return data; return data;
} catch (error) { } catch (error) {
@@ -400,4 +394,54 @@ export class UsersController {
return this.usersService.getBodyMeasurementAnalysis(user.sub, period); return this.usersService.getBodyMeasurementAnalysis(user.sub, period);
} }
// ==================== 勋章相关接口 ====================
/**
* 获取用户勋章列表
*/
@UseGuards(JwtAuthGuard)
@Get('badges')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取用户勋章列表' })
@ApiResponse({ status: 200, description: '成功获取用户勋章列表', type: GetUserBadgesResponseDto })
async getUserBadges(
@CurrentUser() user: AccessTokenPayload,
): Promise<GetUserBadgesResponseDto> {
this.logger.log(`获取用户勋章列表 - 用户ID: ${user.sub}`);
return this.usersService.getUserBadges(user.sub);
}
/**
* 获取所有可用勋章(包含用户是否已获得)
*/
@UseGuards(JwtAuthGuard)
@Public()
@Get('badges/available')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取所有可用勋章' })
@ApiResponse({ status: 200, description: '成功获取所有可用勋章', type: GetAvailableBadgesResponseDto })
async getAvailableBadges(
@CurrentUser() user: AccessTokenPayload,
): Promise<GetAvailableBadgesResponseDto> {
this.logger.log(`获取可用勋章列表 - 用户ID: ${user?.sub}`);
return this.usersService.getAvailableBadges(user?.sub);
}
/**
* 标记勋章已展示
*/
@UseGuards(JwtAuthGuard)
@Post('badges/mark-shown')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '标记勋章已展示(客户端展示勋章动画后调用)' })
@ApiBody({ type: MarkBadgeShownDto })
@ApiResponse({ status: 200, description: '成功标记勋章已展示', type: MarkBadgeShownResponseDto })
async markBadgeAsShown(
@Body() markBadgeShownDto: MarkBadgeShownDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<MarkBadgeShownResponseDto> {
this.logger.log(`标记勋章已展示 - 用户ID: ${user.sub}, 勋章代码: ${markBadgeShownDto.badgeCode}`);
return this.usersService.markBadgeAsShown(user.sub, markBadgeShownDto.badgeCode);
}
} }

View File

@@ -6,6 +6,8 @@ import { User } from "./models/user.model";
import { UserProfile } from "./models/user-profile.model"; import { UserProfile } from "./models/user-profile.model";
import { UserWeightHistory } from "./models/user-weight-history.model"; import { UserWeightHistory } from "./models/user-weight-history.model";
import { UserBodyMeasurementHistory } from "./models/user-body-measurement-history.model"; import { UserBodyMeasurementHistory } from "./models/user-body-measurement-history.model";
import { BadgeConfig } from "./models/badge-config.model";
import { UserBadge } from "./models/user-badge.model";
import { UserDietHistory } from "./models/user-diet-history.model"; import { UserDietHistory } from "./models/user-diet-history.model";
import { ApplePurchaseService } from "./services/apple-purchase.service"; import { ApplePurchaseService } from "./services/apple-purchase.service";
@@ -20,6 +22,7 @@ import { UserPurchase } from "./models/user-purchase.model";
import { PurchaseRestoreLog } from "./models/purchase-restore-log.model"; import { PurchaseRestoreLog } from "./models/purchase-restore-log.model";
import { RevenueCatEvent } from "./models/revenue-cat-event.model"; import { RevenueCatEvent } from "./models/revenue-cat-event.model";
import { CosService } from './cos.service'; import { CosService } from './cos.service';
import { BadgeService } from './services/badge.service';
import { ActivityLogsModule } from '../activity-logs/activity-logs.module'; import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
@Module({ @Module({
@@ -33,6 +36,8 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
UserProfile, UserProfile,
UserWeightHistory, UserWeightHistory,
UserBodyMeasurementHistory, UserBodyMeasurementHistory,
BadgeConfig,
UserBadge,
UserDietHistory, UserDietHistory,
UserActivity, UserActivity,
@@ -44,7 +49,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
}), }),
], ],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService, UserActivityService], providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService, UserActivityService, BadgeService],
exports: [UsersService, AppleAuthService, UserActivityService], exports: [UsersService, AppleAuthService, UserActivityService, BadgeService],
}) })
export class UsersModule { } export class UsersModule { }

View File

@@ -41,14 +41,21 @@ import { UserActivityService } from './services/user-activity.service';
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto'; import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model'; import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
import { BadgeService } from './services/badge.service';
const DEFAULT_FREE_USAGE_COUNT = 5; const DEFAULT_FREE_USAGE_COUNT = 5;
// 会员同步验证的频率限制5分钟
const MEMBERSHIP_SYNC_INTERVAL_MS = 5 * 60 * 1000;
@Injectable() @Injectable()
export class UsersService { export class UsersService {
private readonly logger = new Logger(UsersService.name); private readonly logger = new Logger(UsersService.name);
// 用于存储最后一次验证时间的内存缓存
private lastSyncTimestamps: Map<string, number> = new Map();
constructor( constructor(
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger, @Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger,
@InjectModel(User) @InjectModel(User)
@@ -74,6 +81,7 @@ export class UsersService {
private sequelize: Sequelize, private sequelize: Sequelize,
private readonly activityLogsService: ActivityLogsService, private readonly activityLogsService: ActivityLogsService,
private readonly userActivityService: UserActivityService, private readonly userActivityService: UserActivityService,
private readonly badgeService: BadgeService,
) { } ) { }
async getProfile(user: AccessTokenPayload): Promise<UserResponseDto> { async getProfile(user: AccessTokenPayload): Promise<UserResponseDto> {
@@ -100,6 +108,10 @@ export class UsersService {
}; };
} }
// 更新用户最后登录时间
existingUser.lastLogin = new Date();
await existingUser.save();
const [profile] = await this.userProfileModel.findOrCreate({ const [profile] = await this.userProfileModel.findOrCreate({
where: { userId: existingUser.id }, where: { userId: existingUser.id },
defaults: { userId: existingUser.id }, defaults: { userId: existingUser.id },
@@ -107,6 +119,15 @@ export class UsersService {
// 检查并记录今日登录活跃 // 检查并记录今日登录活跃
await this.userActivityService.checkAndRecordTodayLogin(existingUser.id); await this.userActivityService.checkAndRecordTodayLogin(existingUser.id);
// 异步触发会员状态同步验证(不等待结果,不阻塞响应)
// 使用 Promise.resolve().then() 确保在当前事件循环后执行
Promise.resolve().then(() => {
this.syncMembershipFromRevenueCat(existingUser.id).catch(err => {
// 错误已在方法内部处理,这里只是确保不会有未捕获的 Promise rejection
this.logger.error(`异步会员验证出错: ${err instanceof Error ? err.message : '未知错误'}`);
});
});
const returnData = { const returnData = {
...existingUser.toJSON(), ...existingUser.toJSON(),
maxUsageCount: DEFAULT_FREE_USAGE_COUNT, maxUsageCount: DEFAULT_FREE_USAGE_COUNT,
@@ -184,6 +205,12 @@ export class UsersService {
if (!user) { if (!user) {
throw new NotFoundException(`ID为${userId}的用户不存在`); throw new NotFoundException(`ID为${userId}的用户不存在`);
} }
// 会员用户不扣减
if (user.isVip) {
return
}
user.freeUsageCount -= count; user.freeUsageCount -= count;
await user.save(); await user.save();
} catch (error) { } catch (error) {
@@ -521,10 +548,14 @@ export class UsersService {
if (!user) { if (!user) {
// 创建新用户 // 创建新用户
const userName = appleLoginDto.name || applePayload.email?.split('@')[0] || '用户'; const userName = appleLoginDto.name || applePayload.email?.split('@')[0] || '用户';
const userEmail = appleLoginDto.email || applePayload.email || '';
// 如果无法获取用户邮箱,生成一个随机邮箱
let userEmail = appleLoginDto.email || applePayload.email || '';
if (!userEmail) { if (!userEmail) {
throw new BadRequestException('无法获取用户邮箱信息'); // 使用用户ID生成唯一的随机邮箱
const randomString = Math.random().toString(36).substring(2, 10);
userEmail = `${userId.substring(0, 8)}_${randomString}@outlive.com`;
this.logger.log(`为用户 ${userId} 生成随机邮箱: ${userEmail}`);
} }
const memberNumber = await this.assignMemberNumber(); const memberNumber = await this.assignMemberNumber();
@@ -2198,19 +2229,18 @@ export class UsersService {
} }
/** /**
* 调用 RevenueCat API 获取用户信息 * 调用 RevenueCat API 获取用户订阅信息subscriptions endpoint
*/ */
private async getRevenueCatCustomerInfo(userId: string): Promise<any> { private async getRevenueCatSubscriptions(userId: string): Promise<any> {
try { try {
const REVENUECAT_PUBLIC_API_KEY = process.env.REVENUECAT_PUBLIC_API_KEY; const REVENUECAT_PUBLIC_API_KEY = process.env.REVENUECAT_PUBLIC_API_KEY;
const REVENUECAT_APP_USER_ID = userId; const REVENUECAT_PROJECT_ID = process.env.REVENUECAT_PROJECT_ID || 'proje92e464f';
if (!REVENUECAT_PUBLIC_API_KEY) { if (!REVENUECAT_PUBLIC_API_KEY) {
throw new Error('RevenueCat API key 未配置'); throw new Error('RevenueCat API key 未配置');
} }
// RevenueCat REST API v1 endpoint const url = `https://api.revenuecat.com/v2/projects/${REVENUECAT_PROJECT_ID}/customers/${userId}/subscriptions`;
const url = `https://api.revenuecat.com/v2/subscribers/${REVENUECAT_APP_USER_ID}`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'GET', method: 'GET',
@@ -2222,19 +2252,224 @@ export class UsersService {
if (!response.ok) { if (!response.ok) {
if (response.status === 404) { if (response.status === 404) {
this.logger.warn(`RevenueCat 中未找到用户: ${userId}`); this.logger.warn(`RevenueCat subscriptions 中未找到用户: ${userId}`);
return null; return null;
} }
throw new Error(`RevenueCat API 请求失败: ${response.status} ${response.statusText}`); throw new Error(`RevenueCat Subscriptions API 请求失败: ${response.status} ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
this.logger.log(`RevenueCat API 响应: ${JSON.stringify(data)}`); this.logger.log(`RevenueCat Subscriptions API 响应: ${JSON.stringify(data)}`);
return data; return data;
} catch (error) { } catch (error) {
this.logger.error(`调用 RevenueCat API 失败: ${error instanceof Error ? error.message : '未知错误'}`); this.logger.error(`调用 RevenueCat Subscriptions API 失败: ${error instanceof Error ? error.message : '未知错误'}`);
throw error; return null; // 返回 null 而不是抛出错误,让调用方继续处理
}
}
/**
* 调用 RevenueCat API 获取用户购买信息purchases endpoint
*/
private async getRevenueCatPurchases(userId: string): Promise<any> {
try {
const REVENUECAT_PUBLIC_API_KEY = process.env.REVENUECAT_PUBLIC_API_KEY;
const REVENUECAT_PROJECT_ID = process.env.REVENUECAT_PROJECT_ID || 'proje92e464f';
if (!REVENUECAT_PUBLIC_API_KEY) {
throw new Error('RevenueCat API key 未配置');
}
const url = `https://api.revenuecat.com/v2/projects/${REVENUECAT_PROJECT_ID}/customers/${userId}/purchases`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${REVENUECAT_PUBLIC_API_KEY}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
if (response.status === 404) {
this.logger.warn(`RevenueCat purchases 中未找到用户: ${userId}`);
return null;
}
throw new Error(`RevenueCat Purchases API 请求失败: ${response.status} ${response.statusText}`);
}
const data = await response.json();
this.logger.log(`RevenueCat Purchases API 响应: ${JSON.stringify(data)}`);
return data;
} catch (error) {
this.logger.error(`调用 RevenueCat Purchases API 失败: ${error instanceof Error ? error.message : '未知错误'}`);
return null; // 返回 null 而不是抛出错误,让调用方继续处理
}
}
/**
* 异步同步用户会员状态(从 RevenueCat 获取最新数据)
* 带频率限制,避免过度调用 API
*/
private async syncMembershipFromRevenueCat(userId: string): Promise<void> {
try {
// 检查频率限制
const lastSyncTime = this.lastSyncTimestamps.get(userId);
const now = Date.now();
if (lastSyncTime && (now - lastSyncTime) < MEMBERSHIP_SYNC_INTERVAL_MS) {
this.logger.log(`用户 ${userId} 在频率限制内,跳过会员验证(距上次验证 ${Math.floor((now - lastSyncTime) / 1000)} 秒)`);
return;
}
this.winstonLogger.info('开始同步用户会员状态', {
context: 'UsersService',
method: 'syncMembershipFromRevenueCat',
userId,
lastSyncTime: lastSyncTime ? new Date(lastSyncTime).toISOString() : 'never'
});
// 并行调用两个 RevenueCat API 接口获取用户信息
const [subscriptionsData, purchasesData] = await Promise.all([
this.getRevenueCatSubscriptions(userId),
this.getRevenueCatPurchases(userId)
]);
// 更新最后验证时间
this.lastSyncTimestamps.set(userId, now);
// 如果两个接口都没有数据,说明用户没有任何购买记录
if (!subscriptionsData && !purchasesData) {
this.logger.log(`用户 ${userId} 在 RevenueCat 中未找到任何购买信息`);
return;
}
let latestExpirationDate: Date | null = null;
let isLifetimeMember = false;
// 1. 处理订阅数据subscriptions endpoint
if (subscriptionsData && subscriptionsData.items) {
this.logger.log(`用户 ${userId} 订阅数据: ${subscriptionsData.items.length} 个订阅`);
for (const subscription of subscriptionsData.items) {
// subscription 可能包含 current_period_ends_at 字段
const currentPeriodEndsAt = subscription.current_period_ends_at;
if (currentPeriodEndsAt) {
const expiration = new Date(currentPeriodEndsAt);
// 只考虑未过期的订阅
if (expiration > new Date()) {
if (!latestExpirationDate || expiration > latestExpirationDate) {
latestExpirationDate = expiration;
this.logger.log(`找到有效订阅,过期时间: ${expiration.toISOString()}`);
}
}
}
}
}
// 2. 处理一次性购买数据purchases endpoint
if (purchasesData && purchasesData.items) {
this.logger.log(`用户 ${userId} 购买数据: ${purchasesData.items.length} 个购买`);
for (const purchase of purchasesData.items) {
// 一次性购买通常没有过期时间,或者 status 为 "owned"
// 如果有 store_transaction_id 且 status 为 active/owned认为是有效的终身购买
if (purchase.status === 'owned' || purchase.status === 'active') {
// 检查是否是终身购买(通常一次性购买没有 expires_at 或者 expires_at 是很远的未来)
const expiresAt = purchase.expires_at;
if (!expiresAt) {
// 没有过期时间,认为是终身购买
isLifetimeMember = true;
this.logger.log(`找到终身购买: ${purchase.product_id || 'unknown'}`);
break; // 找到终身购买就不需要继续了
} else {
// 有过期时间,比较是否比当前最晚的更晚
const expiration = new Date(expiresAt);
if (expiration > new Date()) {
if (!latestExpirationDate || expiration > latestExpirationDate) {
latestExpirationDate = expiration;
this.logger.log(`找到有效购买,过期时间: ${expiration.toISOString()}`);
}
}
}
}
}
}
// 获取用户当前数据
const user = await this.userModel.findByPk(userId);
if (!user) {
this.logger.warn(`用户 ${userId} 在数据库中不存在`);
return;
}
const currentExpiration = user.membershipExpiration;
// 决定最终的会员过期时间
let finalExpirationDate: Date | null = null;
if (isLifetimeMember) {
// 终身会员设置为100年后
finalExpirationDate = new Date();
finalExpirationDate.setFullYear(finalExpirationDate.getFullYear() + 100);
this.logger.log(`用户 ${userId} 是终身会员`);
} else if (latestExpirationDate) {
finalExpirationDate = latestExpirationDate;
}
// 比较并更新
if (finalExpirationDate) {
// RevenueCat 显示用户有有效的会员
const needsUpdate = !currentExpiration ||
Math.abs(finalExpirationDate.getTime() - currentExpiration.getTime()) > 60000; // 允许1分钟误差
if (needsUpdate) {
const oldExpiration = currentExpiration?.toISOString() || 'null';
user.membershipExpiration = finalExpirationDate;
await user.save();
this.winstonLogger.info('会员状态已同步更新', {
context: 'UsersService',
method: 'syncMembershipFromRevenueCat',
userId,
oldExpiration,
newExpiration: finalExpirationDate.toISOString(),
isLifetimeMember,
source: 'revenuecat_sync'
});
} else {
this.logger.log(`用户 ${userId} 会员状态一致,无需更新`);
}
} else {
// RevenueCat 显示没有有效会员
if (currentExpiration && currentExpiration > new Date()) {
// 但数据库显示会员未过期,可能需要人工确认
this.winstonLogger.warn('会员状态不一致:数据库显示有效但 RevenueCat 无有效权益', {
context: 'UsersService',
method: 'syncMembershipFromRevenueCat',
userId,
dbExpiration: currentExpiration.toISOString(),
subscriptionsCount: subscriptionsData?.items?.length || 0,
purchasesCount: purchasesData?.items?.length || 0
});
} else {
this.logger.log(`用户 ${userId} 在 RevenueCat 和数据库中均无有效会员`);
}
}
} catch (error) {
// 错误不应影响主流程,只记录日志
this.winstonLogger.error('同步会员状态失败', {
context: 'UsersService',
method: 'syncMembershipFromRevenueCat',
userId,
error: error instanceof Error ? error.message : '未知错误',
stack: error instanceof Error ? error.stack : undefined
});
} }
} }
@@ -2561,4 +2796,85 @@ export class UsersService {
return Math.floor(Date.now() / 1000) % 100000; return Math.floor(Date.now() / 1000) % 100000;
} }
} }
/**
* 获取用户勋章列表
*/
async getUserBadges(userId: string): Promise<any> {
try {
const badges = await this.badgeService.getUserBadges(userId);
const total = badges.length;
return {
code: ResponseCode.SUCCESS,
message: 'success',
data: {
badges,
total,
},
};
} catch (error) {
this.logger.error(`获取用户勋章列表失败: ${error instanceof Error ? error.message : '未知错误'}`);
return {
code: ResponseCode.ERROR,
message: `获取用户勋章列表失败: ${error instanceof Error ? error.message : '未知错误'}`,
data: {
badges: [],
total: 0,
},
};
}
}
/**
* 获取所有可用勋章(包含用户是否已获得)
*/
async getAvailableBadges(userId?: string): Promise<any> {
try {
const badges = await this.badgeService.getAvailableBadges(userId);
return {
code: ResponseCode.SUCCESS,
message: 'success',
data: badges,
};
} catch (error) {
this.logger.error(`获取可用勋章列表失败: ${error instanceof Error ? error.message : '未知错误'}`);
return {
code: ResponseCode.ERROR,
message: `获取可用勋章列表失败: ${error instanceof Error ? error.message : '未知错误'}`,
data: [],
};
}
}
/**
* 标记勋章已展示
*/
async markBadgeAsShown(userId: string, badgeCode: string): Promise<any> {
try {
const success = await this.badgeService.markBadgeAsShown(userId, badgeCode);
if (!success) {
return {
code: ResponseCode.ERROR,
message: '勋章不存在或标记失败',
data: { success: false },
};
}
return {
code: ResponseCode.SUCCESS,
message: 'success',
data: { success: true },
};
} catch (error) {
this.logger.error(`标记勋章已展示失败: ${error instanceof Error ? error.message : '未知错误'}`);
return {
code: ResponseCode.ERROR,
message: `标记勋章已展示失败: ${error instanceof Error ? error.message : '未知错误'}`,
data: { success: false },
};
}
}
} }

View File

@@ -884,12 +884,12 @@
"@napi-rs/nice-android-arm-eabi@1.0.1": "@napi-rs/nice-android-arm-eabi@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz#9a0cba12706ff56500df127d6f4caf28ddb94936" resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz#9a0cba12706ff56500df127d6f4caf28ddb94936"
integrity sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w== integrity sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==
"@napi-rs/nice-android-arm64@1.0.1": "@napi-rs/nice-android-arm64@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz#32fc32e9649bd759d2a39ad745e95766f6759d2f" resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz#32fc32e9649bd759d2a39ad745e95766f6759d2f"
integrity sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA== integrity sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA==
"@napi-rs/nice-darwin-arm64@1.0.1": "@napi-rs/nice-darwin-arm64@1.0.1":
@@ -899,67 +899,67 @@
"@napi-rs/nice-darwin-x64@1.0.1": "@napi-rs/nice-darwin-x64@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz#f1b1365a8370c6a6957e90085a9b4873d0e6a957" resolved "https://registry.yarnpkg.com/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz#f1b1365a8370c6a6957e90085a9b4873d0e6a957"
integrity sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ== integrity sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ==
"@napi-rs/nice-freebsd-x64@1.0.1": "@napi-rs/nice-freebsd-x64@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz#4280f081efbe0b46c5165fdaea8b286e55a8f89e" resolved "https://registry.yarnpkg.com/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz#4280f081efbe0b46c5165fdaea8b286e55a8f89e"
integrity sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw== integrity sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw==
"@napi-rs/nice-linux-arm-gnueabihf@1.0.1": "@napi-rs/nice-linux-arm-gnueabihf@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz#07aec23a9467ed35eb7602af5e63d42c5d7bd473" resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz#07aec23a9467ed35eb7602af5e63d42c5d7bd473"
integrity sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q== integrity sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q==
"@napi-rs/nice-linux-arm64-gnu@1.0.1": "@napi-rs/nice-linux-arm64-gnu@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz#038a77134cc6df3c48059d5a5e199d6f50fb9a90" resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz#038a77134cc6df3c48059d5a5e199d6f50fb9a90"
integrity sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA== integrity sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA==
"@napi-rs/nice-linux-arm64-musl@1.0.1": "@napi-rs/nice-linux-arm64-musl@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz#715d0906582ba0cff025109f42e5b84ea68c2bcc" resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz#715d0906582ba0cff025109f42e5b84ea68c2bcc"
integrity sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw== integrity sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw==
"@napi-rs/nice-linux-ppc64-gnu@1.0.1": "@napi-rs/nice-linux-ppc64-gnu@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz#ac1c8f781c67b0559fa7a1cd4ae3ca2299dc3d06" resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz#ac1c8f781c67b0559fa7a1cd4ae3ca2299dc3d06"
integrity sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q== integrity sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q==
"@napi-rs/nice-linux-riscv64-gnu@1.0.1": "@napi-rs/nice-linux-riscv64-gnu@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz#b0a430549acfd3920ffd28ce544e2fe17833d263" resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz#b0a430549acfd3920ffd28ce544e2fe17833d263"
integrity sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig== integrity sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig==
"@napi-rs/nice-linux-s390x-gnu@1.0.1": "@napi-rs/nice-linux-s390x-gnu@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz#5b95caf411ad72a965885217db378c4d09733e97" resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz#5b95caf411ad72a965885217db378c4d09733e97"
integrity sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg== integrity sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg==
"@napi-rs/nice-linux-x64-gnu@1.0.1": "@napi-rs/nice-linux-x64-gnu@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz#a98cdef517549f8c17a83f0236a69418a90e77b7" resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz#a98cdef517549f8c17a83f0236a69418a90e77b7"
integrity sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA== integrity sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA==
"@napi-rs/nice-linux-x64-musl@1.0.1": "@napi-rs/nice-linux-x64-musl@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz#5e26843eafa940138aed437c870cca751c8a8957" resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz#5e26843eafa940138aed437c870cca751c8a8957"
integrity sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ== integrity sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ==
"@napi-rs/nice-win32-arm64-msvc@1.0.1": "@napi-rs/nice-win32-arm64-msvc@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz#bd62617d02f04aa30ab1e9081363856715f84cd8" resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz#bd62617d02f04aa30ab1e9081363856715f84cd8"
integrity sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg== integrity sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg==
"@napi-rs/nice-win32-ia32-msvc@1.0.1": "@napi-rs/nice-win32-ia32-msvc@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz#b8b7aad552a24836027473d9b9f16edaeabecf18" resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz#b8b7aad552a24836027473d9b9f16edaeabecf18"
integrity sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw== integrity sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw==
"@napi-rs/nice-win32-x64-msvc@1.0.1": "@napi-rs/nice-win32-x64-msvc@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz#37d8718b8f722f49067713e9f1e85540c9a3dd09" resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz#37d8718b8f722f49067713e9f1e85540c9a3dd09"
integrity sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg== integrity sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg==
"@napi-rs/nice@^1.0.1": "@napi-rs/nice@^1.0.1":
@@ -1063,6 +1063,13 @@
path-to-regexp "8.2.0" path-to-regexp "8.2.0"
tslib "2.8.1" tslib "2.8.1"
"@nestjs/schedule@^6.0.1":
version "6.0.1"
resolved "https://mirrors.tencent.com/npm/@nestjs/schedule/-/schedule-6.0.1.tgz"
integrity sha512-v3yO6cSPAoBSSyH67HWnXHzuhPhSNZhRmLY38JvCt2sqY8sPMOODpcU1D79iUMFf7k16DaMEbL4Mgx61ZhiC8Q==
dependencies:
cron "4.3.3"
"@nestjs/schematics@^11.0.0", "@nestjs/schematics@^11.0.1": "@nestjs/schematics@^11.0.0", "@nestjs/schematics@^11.0.1":
version "11.0.2" version "11.0.2"
resolved "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.2.tgz" resolved "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.2.tgz"
@@ -1247,47 +1254,47 @@
"@swc/core-darwin-x64@1.11.13": "@swc/core-darwin-x64@1.11.13":
version "1.11.13" version "1.11.13"
resolved "https://mirrors.tencent.com/npm/@swc/core-darwin-x64/-/core-darwin-x64-1.11.13.tgz#9cad870d48ebff805e8946ddcbe3d8312182f70b" resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.11.13.tgz#9cad870d48ebff805e8946ddcbe3d8312182f70b"
integrity sha512-uSA4UwgsDCIysUPfPS8OrQTH2h9spO7IYFd+1NB6dJlVGUuR6jLKuMBOP1IeLeax4cGHayvkcwSJ3OvxHwgcZQ== integrity sha512-uSA4UwgsDCIysUPfPS8OrQTH2h9spO7IYFd+1NB6dJlVGUuR6jLKuMBOP1IeLeax4cGHayvkcwSJ3OvxHwgcZQ==
"@swc/core-linux-arm-gnueabihf@1.11.13": "@swc/core-linux-arm-gnueabihf@1.11.13":
version "1.11.13" version "1.11.13"
resolved "https://mirrors.tencent.com/npm/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.13.tgz#51839e5a850bfa300e2c838fee8379e4dba1de78" resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.13.tgz#51839e5a850bfa300e2c838fee8379e4dba1de78"
integrity sha512-boVtyJzS8g30iQfe8Q46W5QE/cmhKRln/7NMz/5sBP/am2Lce9NL0d05NnFwEWJp1e2AMGHFOdRr3Xg1cDiPKw== integrity sha512-boVtyJzS8g30iQfe8Q46W5QE/cmhKRln/7NMz/5sBP/am2Lce9NL0d05NnFwEWJp1e2AMGHFOdRr3Xg1cDiPKw==
"@swc/core-linux-arm64-gnu@1.11.13": "@swc/core-linux-arm64-gnu@1.11.13":
version "1.11.13" version "1.11.13"
resolved "https://mirrors.tencent.com/npm/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.13.tgz#4145f1e504bdfa92604aee883d777bc8c4fba5d7" resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.13.tgz#4145f1e504bdfa92604aee883d777bc8c4fba5d7"
integrity sha512-+IK0jZ84zHUaKtwpV+T+wT0qIUBnK9v2xXD03vARubKF+eUqCsIvcVHXmLpFuap62dClMrhCiwW10X3RbXNlHw== integrity sha512-+IK0jZ84zHUaKtwpV+T+wT0qIUBnK9v2xXD03vARubKF+eUqCsIvcVHXmLpFuap62dClMrhCiwW10X3RbXNlHw==
"@swc/core-linux-arm64-musl@1.11.13": "@swc/core-linux-arm64-musl@1.11.13":
version "1.11.13" version "1.11.13"
resolved "https://mirrors.tencent.com/npm/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.13.tgz#b1813ae2e99e386ca16fff5af6601ac45ef57c5b" resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.13.tgz#b1813ae2e99e386ca16fff5af6601ac45ef57c5b"
integrity sha512-+ukuB8RHD5BHPCUjQwuLP98z+VRfu+NkKQVBcLJGgp0/+w7y0IkaxLY/aKmrAS5ofCNEGqKL+AOVyRpX1aw+XA== integrity sha512-+ukuB8RHD5BHPCUjQwuLP98z+VRfu+NkKQVBcLJGgp0/+w7y0IkaxLY/aKmrAS5ofCNEGqKL+AOVyRpX1aw+XA==
"@swc/core-linux-x64-gnu@1.11.13": "@swc/core-linux-x64-gnu@1.11.13":
version "1.11.13" version "1.11.13"
resolved "https://mirrors.tencent.com/npm/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.13.tgz#13b89a0194c4033c01400e9c65d9c21c56a4a6cd" resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.13.tgz#13b89a0194c4033c01400e9c65d9c21c56a4a6cd"
integrity sha512-q9H3WI3U3dfJ34tdv60zc8oTuWvSd5fOxytyAO9Pc5M82Hic3jjWaf2xBekUg07ubnMZpyfnv+MlD+EbUI3Llw== integrity sha512-q9H3WI3U3dfJ34tdv60zc8oTuWvSd5fOxytyAO9Pc5M82Hic3jjWaf2xBekUg07ubnMZpyfnv+MlD+EbUI3Llw==
"@swc/core-linux-x64-musl@1.11.13": "@swc/core-linux-x64-musl@1.11.13":
version "1.11.13" version "1.11.13"
resolved "https://mirrors.tencent.com/npm/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.13.tgz#0d0e5aa889dd4da69723e2287c3c1714d9bfd8aa" resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.13.tgz#0d0e5aa889dd4da69723e2287c3c1714d9bfd8aa"
integrity sha512-9aaZnnq2pLdTbAzTSzy/q8dr7Woy3aYIcQISmw1+Q2/xHJg5y80ZzbWSWKYca/hKonDMjIbGR6dp299I5J0aeA== integrity sha512-9aaZnnq2pLdTbAzTSzy/q8dr7Woy3aYIcQISmw1+Q2/xHJg5y80ZzbWSWKYca/hKonDMjIbGR6dp299I5J0aeA==
"@swc/core-win32-arm64-msvc@1.11.13": "@swc/core-win32-arm64-msvc@1.11.13":
version "1.11.13" version "1.11.13"
resolved "https://mirrors.tencent.com/npm/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.13.tgz#ad7281f9467e3de09f52615afe2276a8ef738a9d" resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.13.tgz#ad7281f9467e3de09f52615afe2276a8ef738a9d"
integrity sha512-n3QZmDewkHANcoHvtwvA6yJbmS4XJf0MBMmwLZoKDZ2dOnC9D/jHiXw7JOohEuzYcpLoL5tgbqmjxa3XNo9Oow== integrity sha512-n3QZmDewkHANcoHvtwvA6yJbmS4XJf0MBMmwLZoKDZ2dOnC9D/jHiXw7JOohEuzYcpLoL5tgbqmjxa3XNo9Oow==
"@swc/core-win32-ia32-msvc@1.11.13": "@swc/core-win32-ia32-msvc@1.11.13":
version "1.11.13" version "1.11.13"
resolved "https://mirrors.tencent.com/npm/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.13.tgz#046f6dbddb5b69a29bbaa98de104090a46088b74" resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.13.tgz#046f6dbddb5b69a29bbaa98de104090a46088b74"
integrity sha512-wM+Nt4lc6YSJFthCx3W2dz0EwFNf++j0/2TQ0Js9QLJuIxUQAgukhNDVCDdq8TNcT0zuA399ALYbvj5lfIqG6g== integrity sha512-wM+Nt4lc6YSJFthCx3W2dz0EwFNf++j0/2TQ0Js9QLJuIxUQAgukhNDVCDdq8TNcT0zuA399ALYbvj5lfIqG6g==
"@swc/core-win32-x64-msvc@1.11.13": "@swc/core-win32-x64-msvc@1.11.13":
version "1.11.13" version "1.11.13"
resolved "https://mirrors.tencent.com/npm/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.13.tgz#0412620d8594a7d3e482d3e79d9e89d80f9a14c0" resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.13.tgz#0412620d8594a7d3e482d3e79d9e89d80f9a14c0"
integrity sha512-+X5/uW3s1L5gK7wAo0E27YaAoidJDo51dnfKSfU7gF3mlEUuWH8H1bAy5OTt2mU4eXtfsdUMEVXSwhDlLtQkuA== integrity sha512-+X5/uW3s1L5gK7wAo0E27YaAoidJDo51dnfKSfU7gF3mlEUuWH8H1bAy5OTt2mU4eXtfsdUMEVXSwhDlLtQkuA==
"@swc/core@^1.10.7": "@swc/core@^1.10.7":
@@ -1559,6 +1566,11 @@
resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz" resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz"
integrity sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g== integrity sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==
"@types/luxon@~3.7.0":
version "3.7.1"
resolved "https://mirrors.tencent.com/npm/@types/luxon/-/luxon-3.7.1.tgz"
integrity sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==
"@types/methods@^1.1.4": "@types/methods@^1.1.4":
version "1.1.4" version "1.1.4"
resolved "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz" resolved "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz"
@@ -2954,6 +2966,14 @@ create-require@^1.1.0:
resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz" resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cron@4.3.3:
version "4.3.3"
resolved "https://mirrors.tencent.com/npm/cron/-/cron-4.3.3.tgz"
integrity sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==
dependencies:
"@types/luxon" "~3.7.0"
luxon "~3.7.0"
croner@~4.1.92: croner@~4.1.92:
version "4.1.97" version "4.1.97"
resolved "https://mirrors.tencent.com/npm/croner/-/croner-4.1.97.tgz" resolved "https://mirrors.tencent.com/npm/croner/-/croner-4.1.97.tgz"
@@ -2992,7 +3012,7 @@ data-uri-to-buffer@^6.0.2:
dayjs@^1.11.18, dayjs@~1.11.13: dayjs@^1.11.18, dayjs@~1.11.13:
version "1.11.18" version "1.11.18"
resolved "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.18.tgz" resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz"
integrity sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA== integrity sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==
dayjs@~1.8.24: dayjs@~1.8.24:
@@ -5223,6 +5243,11 @@ lru.min@^1.0.0:
resolved "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz" resolved "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz"
integrity sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg== integrity sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==
luxon@~3.7.0:
version "3.7.2"
resolved "https://mirrors.tencent.com/npm/luxon/-/luxon-3.7.2.tgz"
integrity sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==
magic-string@0.30.17: magic-string@0.30.17:
version "0.30.17" version "0.30.17"
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz" resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz"