Compare commits

58 Commits

Author SHA1 Message Date
richarjiang
8fa741cc1b refactor(expo-updates): 优化hash计算和并行处理逻辑,提升性能和调试能力 2025-12-06 10:05:12 +08:00
richarjiang
51d0dabc9a refactor(expo-updates): 重构manifest构建逻辑,支持动态获取metadata和真实hash计算 2025-12-05 22:34:33 +08:00
richarjiang
190bc5bce9 refactor(expo-updates): 优化更新ID生成逻辑,基于bundle hash确保ID唯一性 2025-12-05 20:11:29 +08:00
richarjiang
14d791f552 feat: 添加Expo Updates服务端模块和就医资料管理功能 2025-12-05 16:08:53 +08:00
richarjiang
de67132a36 refactor: 移除workouts模块依赖,清理训练计划相关注释 2025-12-05 10:28:50 +08:00
richarjiang
b46d99fe69 feat: 集成Redis模块并重构限流存储机制 2025-12-05 10:03:59 +08:00
richarjiang
8a43b1795b fix: invite 2025-12-04 18:56:01 +08:00
richarjiang
eecc14d45a feat(health-profiles): 禁用家庭成员模型的时间戳字段 2025-12-04 17:55:51 +08:00
richarjiang
2d7e067888 feat(health-profiles): 添加健康档案模块,支持健康史记录、家庭健康管理和档案概览功能 2025-12-04 17:15:11 +08:00
richarjiang
03bd0b041e feat(medications): 添加药品过期预警功能,支持多时间段提醒和标记重置 2025-12-03 19:22:46 +08:00
richarjiang
b12956d80a feat(ai): 优化健康日报生成,集成用户健康统计数据并增强视觉提示 2025-12-03 10:13:05 +08:00
richarjiang
2ff2c58b43 feat(users): 添加用户每日健康数据步数字段,支持步数记录和更新 2025-12-03 09:00:26 +08:00
richarjiang
c3b59752ee feat(users): 添加用户每日健康数据记录功能,支持多维度健康指标更新 2025-12-02 19:11:17 +08:00
richarjiang
6cdd2bc137 feat(users): 添加App版本号追踪功能,支持用户版本更新记录 2025-12-02 14:40:59 +08:00
richarjiang
562c66a930 feat(ai): 添加AI报告生成历史记录功能,支持每日生成限制和双API提供商 2025-12-02 12:07:08 +08:00
richarjiang
5b89a07751 stash 2025-12-01 18:12:09 +08:00
richarjiang
7ce51409af feat(medications): 添加用药AI总结功能,支持生成用户用药计划的重点解读 2025-12-01 11:21:57 +08:00
ae41a2b643 feat(users): add version checking endpoint
Add app version checking functionality to notify users when updates are available. The feature extracts the current version from the x-App-Version header, compares it with the latest configured version, and returns update information including download links and release notes.
2025-11-29 20:47:01 +08:00
richarjiang
ff2dfd5bb3 feat(ai): 支持多语言AI分析响应并优化药品识别流程
- 饮食分析与药品分析服务新增多语言支持(zh-CN/en-US),根据用户偏好动态调整 Prompt 和返回信息
- 重构药品识别流程,利用 GLM-4.5v 模型将多阶段分析合并为单次全量分析,提升响应速度
- 增加用户语言获取逻辑,并在异步任务状态更新中支持本地化文案
- 移除废弃的药品分析 V1 接口,升级底层模型配置
2025-11-28 16:02:16 +08:00
richarjiang
43f378d44d feat(users): 添加用户语言偏好字段
- 在 User 模型中添加 language 字段,默认值为 'zh-CN'
- 更新 UpdateUserDto 以支持语言偏好参数
- 在用户更新服务中实现语言偏好的保存逻辑
2025-11-27 11:17:27 +08:00
richarjiang
ac231a7742 feat(challenges): 支持自定义挑战类型并优化必填字段验证
- 新增 CUSTOM 挑战类型枚举值
- requirementLabel 字段改为可选,允许为空并添加默认值处理
- minimumCheckInDays 最大值从 365 提升至 1000,支持更长周期挑战
- 推送通知模板支持自定义挑战的动态文案生成
- 新增 getCustomEncouragementTemplate 和 getCustomInvitationTemplate 函数
2025-11-27 11:11:26 +08:00
richarjiang
7a05097226 feat(challenges): add vip user restrictions for challenge creation
限制非会员用户只能创建一个未归档的自定义挑战,添加用户VIP状态检查和挑战数量限制逻辑
2025-11-27 08:33:46 +08:00
richarjiang
5d64a99ce5 feat(challenges): 添加挑战创建者标识和归档状态过滤
- 为挑战详情和列表接口添加isCreator字段标识创建者
- 过滤掉已归档的挑战,避免在列表和操作中显示
- 为挑战详情接口添加JWT认证守卫
- 将自定义挑战的progressUnit字段设为必填
- 优化挑战编辑时的错误提示信息
- 移除冗余的isCreator私有方法,直接在响应中设置标识
2025-11-26 18:57:13 +08:00
richarjiang
26e88ae610 feat(challenges): 添加挑战源和分享代码字段,更新挑战详情和列表接口 2025-11-26 17:26:27 +08:00
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
161 changed files with 18471 additions and 3253 deletions

1
.kilocode/mcp.json Normal file
View File

@@ -0,0 +1 @@
{"mcpServers":{"context7":{"command":"npx","args":["-y","@upstash/context7-mcp"],"env":{"DEFAULT_MINIMUM_TOKENS":""},"alwaysAllow":["get-library-docs","resolve-library-id"]}}}

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 文档
- 代码提交 message 用中文
- 注意代码的可读性、架构实现要清晰
- 不要随意新增示例文件
- 接口规范: 接口的返回都需要遵循 base.dto.ts 文件中的规范

View File

@@ -1,3 +1,4 @@
{
"kiroAgent.configureMCP": "Enabled"
"kiroAgent.configureMCP": "Enabled",
"codingcopilot.enableCompletionLanguage": {}
}

View File

@@ -141,12 +141,47 @@ deploy_on_server() {
if ! command -v yarn &> /dev/null; then
echo "正在安装Yarn..."
npm install -g yarn
npm install -g yarn --unsafe-perm=true --allow-root
fi
# 安装PM2并确保可用
if ! command -v pm2 &> /dev/null; then
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
# 检查并安装Redis
@@ -171,18 +206,35 @@ deploy_on_server() {
# 停止旧的应用实例
echo "停止旧的应用实例..."
if command -v pm2 &> /dev/null; then
pm2 delete $PROJECT_NAME 2>/dev/null || true
else
\$PM2_CMD delete $PROJECT_NAME 2>/dev/null || true
fi
# 启动新的应用实例
echo "启动新的应用实例..."
if command -v pm2 &> /dev/null; then
pm2 start ecosystem.config.js --env production
else
\$PM2_CMD start ecosystem.config.js --env production
fi
# 保存PM2配置
if command -v pm2 &> /dev/null; then
pm2 save
pm2 startup
else
\$PM2_CMD save
\$PM2_CMD startup
fi
echo "部署完成!"
if command -v pm2 &> /dev/null; then
pm2 status
else
\$PM2_CMD status || echo "无法执行pm2 status命令"
fi
EOF
if [ $? -eq 0 ]; then

View File

@@ -0,0 +1,456 @@
# AI 健康报告接口文档
## 接口概述
生成用户的 AI 健康报告图片接口,基于用户的健康数据(体重、饮食、运动等)生成可视化的健康分析报告图片。
---
## 接口信息
- **接口名称**: 生成 AI 健康报告
- **接口路径**: `/users/ai-report`
- **请求方法**: `POST`
- **认证方式**: JWT Token (Bearer Token)
- **内容类型**: `application/json`
---
## 请求说明
### 请求头 (Headers)
| 参数名 | 类型 | 必填 | 说明 | 示例 |
| ------------- | ------ | ---- | ------------ | ------------------------------------------------ |
| Authorization | string | 是 | JWT 访问令牌 | `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...` |
| Content-Type | string | 是 | 请求内容类型 | `application/json` |
### 请求体 (Body)
| 参数名 | 类型 | 必填 | 说明 | 示例 |
| ------ | ------ | ---- | ------------------------------------------------------------- | -------------- |
| date | string | 否 | 指定生成报告的日期,格式 YYYY-MM-DD。不传则默认生成今天的报告 | `"2024-01-15"` |
### 请求示例
#### 示例 1: 生成今天的报告
```bash
curl -X POST 'https://api.example.com/users/ai-report' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
-H 'Content-Type: application/json' \
-d '{}'
```
#### 示例 2: 生成指定日期的报告
```bash
curl -X POST 'https://api.example.com/users/ai-report' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
-H 'Content-Type: application/json' \
-d '{
"date": "2024-01-15"
}'
```
#### JavaScript/TypeScript 示例
```typescript
// 使用 fetch
async function generateHealthReport(date?: string) {
const response = await fetch("https://api.example.com/users/ai-report", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(date ? { date } : {}),
});
const result = await response.json();
return result;
}
// 使用 axios
import axios from "axios";
async function generateHealthReport(date?: string) {
const response = await axios.post(
"https://api.example.com/users/ai-report",
date ? { date } : {},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
return response.data;
}
```
#### Swift 示例
```swift
func generateHealthReport(date: String? = nil, completion: @escaping (Result<HealthReportResponse, Error>) -> Void) {
let url = URL(string: "https://api.example.com/users/ai-report")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = [:]
if let date = date {
body["date"] = date
}
if !body.isEmpty {
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "", code: -1, userInfo: nil)))
return
}
do {
let decoder = JSONDecoder()
let result = try decoder.decode(HealthReportResponse.self, from: data)
completion(.success(result))
} catch {
completion(.failure(error))
}
}.resume()
}
// 响应模型
struct HealthReportResponse: Codable {
let code: Int
let message: String
let data: HealthReportData
}
struct HealthReportData: Codable {
let imageUrl: String
}
```
---
## 响应说明
### 响应格式
所有响应都遵循统一的响应格式:
```typescript
{
code: number; // 响应码: 0-成功, 1-失败
message: string; // 响应消息
data: {
imageUrl: string; // 生成的报告图片 URL
}
}
```
### 成功响应
**HTTP 状态码**: `200 OK`
**响应体示例**:
```json
{
"code": 0,
"message": "AI健康报告生成成功",
"data": {
"imageUrl": "https://pilates-1234567890.cos.ap-guangzhou.myqcloud.com/health-reports/user-123/2024-01-15/report-xxxxx.png"
}
}
```
**字段说明**:
| 字段 | 类型 | 说明 |
| ------------- | ------ | -------------------------------------------- |
| code | number | 响应码0 表示成功 |
| message | string | 响应消息,成功时为 "AI健康报告生成成功" |
| data.imageUrl | string | 生成的健康报告图片完整 URL可直接访问和下载 |
### 失败响应
**HTTP 状态码**: `200 OK` (业务失败也返回 200通过 code 字段判断)
**响应体示例**:
```json
{
"code": 1,
"message": "生成失败: 用户健康数据不足",
"data": {
"imageUrl": ""
}
}
```
**常见错误消息**:
| 错误消息 | 说明 | 解决方案 |
| ----------------------------- | ------------------------------ | ---------------------------------------------- |
| `生成失败: 用户健康数据不足` | 用户没有足够的健康数据生成报告 | 引导用户添加更多健康数据(体重、饮食、运动等) |
| `生成失败: 日期格式不正确` | date 参数格式错误 | 确保日期格式为 YYYY-MM-DD |
| `生成失败: 未找到用户信息` | 用户不存在或 Token 无效 | 检查认证 Token 是否有效 |
| `生成失败: AI 服务暂时不可用` | AI 模型服务异常 | 稍后重试 |
### 认证失败响应
**HTTP 状态码**: `401 Unauthorized`
**响应体示例**:
```json
{
"statusCode": 401,
"message": "Unauthorized"
}
```
---
## 业务逻辑说明
### 报告内容
AI 健康报告会基于用户的以下数据生成:
1. **体重数据**: 当天或最近的体重记录
2. **饮食数据**: 当天的饮食记录和营养摄入
3. **运动数据**: 当天的运动记录和卡路里消耗
4. **围度数据**: 身体各部位的围度测量数据
5. **目标进度**: 用户设定的健康目标完成情况
### 报告生成逻辑
- 如果不传 `date` 参数,默认生成今天的报告
- 如果指定日期没有数据,会返回数据不足的提示
- 报告图片为 PNG 格式,尺寸适配移动端展示
- 图片会存储在腾讯云 COS有效期永久或根据策略定期清理
### 缓存策略
- 同一天同一用户的报告会缓存,重复请求会返回相同的图片 URL
- 如果用户更新了当天的数据,可以重新生成覆盖旧报告
---
## 错误处理
### 客户端错误处理示例
```typescript
async function fetchHealthReport(date?: string) {
try {
const response = await fetch("https://api.example.com/users/ai-report", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(date ? { date } : {}),
});
// 检查 HTTP 状态码
if (response.status === 401) {
// Token 失效,需要重新登录
throw new Error("认证失败,请重新登录");
}
const result = await response.json();
// 检查业务状态码
if (result.code !== 0) {
// 业务失败
throw new Error(result.message);
}
// 成功,返回图片 URL
return result.data.imageUrl;
} catch (error) {
console.error("生成健康报告失败:", error);
throw error;
}
}
// 使用示例
try {
const imageUrl = await fetchHealthReport();
console.log("报告生成成功:", imageUrl);
// 在 UI 中展示图片
} catch (error) {
// 向用户展示错误提示
alert(error.message);
}
```
---
## 使用建议
### 1. 前置检查
在调用接口前,建议先检查:
- 用户是否已登录(有有效的 JWT Token
- 用户是否有足够的健康数据(可通过其他接口查询)
- 网络连接是否正常
### 2. 加载提示
由于报告生成需要调用 AI 服务,可能需要几秒钟时间,建议:
- 显示加载动画或进度提示
- 设置合理的超时时间(建议 30-60 秒)
- 提供取消操作的选项
### 3. 图片展示
获取到图片 URL 后:
- 可以直接在 Image 组件中使用该 URL
- 支持下载保存到本地相册
- 支持分享到社交媒体
### 4. 错误处理
- 网络错误:提示用户检查网络连接
- 数据不足:引导用户添加健康数据
- Token 过期:自动刷新 Token 或引导重新登录
---
## 完整示例
### React Native 完整示例
```typescript
import React, { useState } from 'react';
import { View, Image, Button, Text, ActivityIndicator } from 'react-native';
import axios from 'axios';
const HealthReportScreen = () => {
const [loading, setLoading] = useState(false);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const generateReport = async (date?: string) => {
setLoading(true);
setError(null);
try {
const response = await axios.post(
'https://api.example.com/users/ai-report',
date ? { date } : {},
{
headers: {
'Authorization': `Bearer ${accessToken}`,
},
timeout: 60000, // 60秒超时
}
);
if (response.data.code === 0) {
setImageUrl(response.data.data.imageUrl);
} else {
setError(response.data.message);
}
} catch (err) {
if (axios.isAxiosError(err)) {
if (err.response?.status === 401) {
setError('登录已过期,请重新登录');
} else if (err.code === 'ECONNABORTED') {
setError('请求超时,请检查网络连接');
} else {
setError(err.response?.data?.message || '生成报告失败');
}
} else {
setError('未知错误');
}
} finally {
setLoading(false);
}
};
return (
<View style={{ flex: 1, padding: 20 }}>
<Button
title="生成今天的健康报告"
onPressed={() => generateReport()}
disabled={loading}
/>
{loading && (
<View style={{ marginTop: 20, alignItems: 'center' }}>
<ActivityIndicator size="large" />
<Text style={{ marginTop: 10 }}>正在生成报告...</Text>
</View>
)}
{error && (
<Text style={{ color: 'red', marginTop: 20 }}>
{error}
</Text>
)}
{imageUrl && (
<Image
source={{ uri: imageUrl }}
style={{ width: '100%', height: 400, marginTop: 20 }}
resizeMode="contain"
/>
)}
</View>
);
};
export default HealthReportScreen;
```
---
## 注意事项
1. **认证要求**: 必须携带有效的 JWT Token否则返回 401
2. **请求频率**: 建议不要频繁调用,同一用户同一天建议缓存结果
3. **图片有效期**: 图片 URL 长期有效,可以缓存在客户端
4. **数据依赖**: 需要用户有足够的健康数据才能生成有意义的报告
5. **网络要求**: AI 报告生成需要调用外部服务,需要稳定的网络连接
6. **超时设置**: 建议设置 30-60 秒的请求超时时间
---
## 相关接口
- [用户信息接口](./API-USER-INFO.md)
- [体重记录接口](./API-WEIGHT-RECORDS.md)
- [饮食记录接口](./API-DIET-RECORDS.md)
- [运动记录接口](./API-WORKOUT-RECORDS.md)
---
## 更新日志
| 版本 | 日期 | 更新内容 |
| ---- | ---------- | ------------------------------ |
| v1.0 | 2024-01-15 | 初始版本,支持基础健康报告生成 |
---
## 技术支持
如有问题,请联系技术支持团队。

View File

@@ -1,140 +0,0 @@
# 喝水记录功能
## 功能概述
新增了用户喝水记录功能,支持用户记录每日的喝水情况,设置喝水目标,并查看统计信息。
## 新增文件
### 模型文件
- `src/users/models/user-water-history.model.ts` - 喝水记录模型
- 更新了 `src/users/models/user-profile.model.ts` - 添加了 `dailyWaterGoal` 字段
### DTO文件
- `src/users/dto/water-record.dto.ts` - 喝水记录相关的DTO
### 服务文件
- `src/users/services/water-record.service.ts` - 喝水记录服务
### 数据库脚本
- `sql-scripts/user-water-records-table.sql` - 数据库迁移脚本
### 测试脚本
- `test-water-records.sh` - API接口测试脚本
## API接口
### 1. 创建喝水记录
```
POST /users/water-records
```
请求体:
```json
{
"amount": 250,
"source": "manual",
"remark": "早晨第一杯水"
}
```
### 2. 获取喝水记录列表
```
GET /users/water-records?limit=10&offset=0
```
### 3. 更新喝水记录
```
PUT /users/water-records/:id
```
请求体:
```json
{
"amount": 300,
"remark": "修改后的备注"
}
```
### 4. 删除喝水记录
```
DELETE /users/water-records/:id
```
### 5. 更新喝水目标
```
PUT /users/water-goal
```
请求体:
```json
{
"dailyWaterGoal": 2000
}
```
### 6. 获取今日喝水统计
```
GET /users/water-stats/today
```
响应:
```json
{
"code": 200,
"message": "success",
"data": {
"totalAmount": 1500,
"recordCount": 6,
"dailyGoal": 2000,
"completionRate": 0.75
}
}
```
## 数据库表结构
### t_user_water_history (喝水记录表)
- `id` - 主键,自增
- `user_id` - 用户ID
- `amount` - 喝水量(毫升)
- `source` - 记录来源manual/auto/other
- `remark` - 备注
- `created_at` - 创建时间
- `updated_at` - 更新时间
### t_user_profile (用户档案表 - 新增字段)
- `daily_water_goal` - 每日喝水目标(毫升)
## 功能特点
1. **完整的CRUD操作** - 支持喝水记录的增删改查
2. **目标设置** - 用户可以设置每日喝水目标
3. **统计功能** - 提供今日喝水统计,包括总量、记录数、完成率等
4. **数据验证** - 对输入数据进行严格验证
5. **错误处理** - 完善的错误处理机制
6. **日志记录** - 详细的操作日志
7. **权限控制** - 所有接口都需要JWT认证
## 部署说明
1. 运行数据库迁移脚本:
```bash
mysql -u username -p database_name < sql-scripts/user-water-records-table.sql
```
2. 重启应用服务
3. 使用测试脚本验证功能:
```bash
./test-water-records.sh
```
## 注意事项
1. 喝水目标字段是可选的,可以为空
2. 喝水记录的来源默认为 'manual'
3. 喝水量的范围限制在 1-5000 毫升之间
4. 喝水目标的范围限制在 500-10000 毫升之间
5. 获取profile接口会返回用户的喝水目标
6. 喝水目标的更新集成在喝水接口中,避免用户服务文件过大

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. 编辑限制
-

133
docs/expo-updates.md Normal file
View File

@@ -0,0 +1,133 @@
# Expo Updates 服务端实现COS 资源版)
本服务实现了 Expo Updates 协议 v0 和 v1支持 React Native 应用的 OTA 热更新。
资源文件存储在腾讯云 COS 上,服务端只负责返回 manifest。
## API 端点
### 1. 获取 Manifest
```
GET /expo-updates/manifest
```
**请求头:**
| 头部 | 必需 | 说明 |
|------|------|------|
| `expo-platform` | 是 | 平台类型:`ios``android` |
| `expo-runtime-version` | 是 | 运行时版本号 |
| `expo-protocol-version` | 否 | 协议版本:`0``1`(默认 `0` |
| `expo-current-update-id` | 否 | 当前更新 ID |
### 2. 注册更新版本
```
POST /expo-updates/updates
```
**请求体:**
```json
{
"runtimeVersion": "1.0.0",
"createdAt": "2024-01-01T00:00:00.000Z",
"ios": {
"launchAsset": {
"url": "https://your-bucket.cos.ap-guangzhou.myqcloud.com/updates/1.0.0/ios/bundle.js",
"hash": "Base64URL编码的SHA256哈希"
},
"assets": [
{
"url": "https://your-bucket.cos.ap-guangzhou.myqcloud.com/updates/1.0.0/assets/icon.png",
"hash": "Base64URL编码的SHA256哈希",
"key": "icon",
"contentType": "image/png",
"fileExtension": ".png"
}
]
},
"android": {
"launchAsset": {
"url": "https://your-bucket.cos.ap-guangzhou.myqcloud.com/updates/1.0.0/android/bundle.js",
"hash": "Base64URL编码的SHA256哈希"
},
"assets": []
},
"expoClient": {
"name": "YourApp",
"version": "1.0.0"
}
}
```
### 3. 获取所有更新版本
```
GET /expo-updates/updates
```
### 4. 获取指定版本
```
GET /expo-updates/updates/:runtimeVersion
```
### 5. 删除更新版本
```
DELETE /expo-updates/updates/:runtimeVersion
```
## 客户端配置
在 React Native 应用的 `app.json` 中:
```json
{
"expo": {
"updates": {
"url": "https://your-server.com/expo-updates/manifest",
"enabled": true
},
"runtimeVersion": "1.0.0"
}
}
```
## 生成资源哈希
资源的 hash 需要是 Base64URL 编码的 SHA256 哈希:
```javascript
const crypto = require('crypto');
const fs = require('fs');
function getAssetHash(filePath) {
const content = fs.readFileSync(filePath);
const hash = crypto.createHash('sha256').update(content).digest('base64');
// 转换为 Base64URL
return hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
```
## 测试
```bash
# 注册更新
curl -X POST http://localhost:3000/expo-updates/updates \
-H "Content-Type: application/json" \
-d '{
"runtimeVersion": "1.0.0",
"ios": {
"launchAsset": {
"url": "https://cos.example.com/bundle.js",
"hash": "abc123"
},
"assets": []
}
}'
# 获取 manifest
curl -H "expo-platform: ios" \
-H "expo-runtime-version: 1.0.0" \
http://localhost:3000/expo-updates/manifest
```

View File

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

View File

@@ -1,230 +0,0 @@
# 话题收藏功能 API 文档
## 概述
话题收藏功能允许用户收藏喜欢的话题方便后续查看和使用。本文档描述了话题收藏相关的所有API接口。
## 功能特性
- ✅ 收藏话题
- ✅ 取消收藏话题
- ✅ 获取收藏话题列表
- ✅ 话题列表中显示收藏状态
- ✅ 防重复收藏
- ✅ 完整的错误处理
## API 接口
### 1. 收藏话题
**接口地址:** `POST /api/topic/favorite`
**请求参数:**
```json
{
"topicId": 123
}
```
**响应示例:**
```json
{
"code": 200,
"message": "收藏成功",
"data": {
"success": true,
"isFavorited": true,
"topicId": 123
}
}
```
**错误情况:**
- 话题不存在:`{ "code": 400, "message": "话题不存在" }`
- 已收藏:`{ "code": 200, "message": "已经收藏过该话题" }`
### 2. 取消收藏话题
**接口地址:** `POST /api/topic/unfavorite`
**请求参数:**
```json
{
"topicId": 123
}
```
**响应示例:**
```json
{
"code": 200,
"message": "取消收藏成功",
"data": {
"success": true,
"isFavorited": false,
"topicId": 123
}
}
```
### 3. 获取收藏话题列表
**接口地址:** `POST /api/topic/favorites`
**请求参数:**
```json
{
"page": 1,
"pageSize": 10
}
```
**响应示例:**
```json
{
"code": 200,
"message": "success",
"data": {
"list": [
{
"id": 123,
"topic": "约会话题",
"opening": {
"text": "今天天气真不错...",
"scenarios": ["咖啡厅", "公园"]
},
"scriptType": "初识破冰",
"scriptTopic": "天气",
"keywords": "天气,约会,轻松",
"isFavorited": true,
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
}
],
"total": 5,
"page": 1,
"pageSize": 10
}
}
```
### 4. 获取话题列表(已包含收藏状态)
**接口地址:** `POST /api/topic/list`
现在所有话题列表都会包含 `isFavorited` 字段,表示当前用户是否已收藏该话题。
**响应示例:**
```json
{
"code": 200,
"message": "success",
"data": {
"list": [
{
"id": 123,
"topic": "约会话题",
"opening": {
"text": "今天天气真不错...",
"scenarios": ["咖啡厅", "公园"]
},
"scriptType": "初识破冰",
"scriptTopic": "天气",
"keywords": "天气,约会,轻松",
"isFavorited": true, // ← 新增的收藏状态字段
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
},
{
"id": 124,
"topic": "工作话题",
"opening": "关于工作的开场白...",
"scriptType": "深度交流",
"scriptTopic": "职业",
"keywords": "工作,职业,发展",
"isFavorited": false, // ← 未收藏
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
}
],
"total": 20,
"page": 1,
"pageSize": 10
}
}
```
## 数据库变更
### 新增表t_topic_favorites
```sql
CREATE TABLE `t_topic_favorites` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`topic_id` int NOT NULL COMMENT '话题ID',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_topic_favorite` (`user_id`, `topic_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_topic_id` (`topic_id`),
FOREIGN KEY (`user_id`) REFERENCES `t_users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`topic_id`) REFERENCES `t_topic_library` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## 使用场景
### 1. 用户收藏话题
```javascript
// 收藏话题
const response = await fetch('/api/topic/favorite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-jwt-token'
},
body: JSON.stringify({ topicId: 123 })
});
```
### 2. 取消收藏话题
```javascript
// 取消收藏
const response = await fetch('/api/topic/unfavorite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-jwt-token'
},
body: JSON.stringify({ topicId: 123 })
});
```
### 3. 查看收藏列表
```javascript
// 获取收藏的话题
const response = await fetch('/api/topic/favorites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-jwt-token'
},
body: JSON.stringify({ page: 1, pageSize: 10 })
});
```
## 注意事项
1. **防重复收藏:** 数据库层面通过唯一索引保证同一用户不能重复收藏同一话题
2. **级联删除:** 用户删除或话题删除时,相关收藏记录会自动删除
3. **性能优化:** 获取话题列表时通过单次查询获取用户所有收藏状态避免N+1查询问题
4. **权限控制:** 所有接口都需要用户登录JWT认证
## 错误码说明
- `200`: 操作成功
- `400`: 请求参数错误(如话题不存在)
- `401`: 未授权(需要登录)
- `500`: 服务器内部错误

View File

@@ -1,473 +0,0 @@
# 喝水记录 API 客户端接入说明
## 概述
喝水记录 API 提供了完整的喝水记录管理功能,包括记录创建、查询、更新、删除,以及喝水目标设置和统计查询等功能。
## 基础信息
- **Base URL**: `https://your-api-domain.com/api`
- **认证方式**: JWT Bearer Token
- **Content-Type**: `application/json`
## 认证
所有 API 请求都需要在请求头中包含有效的 JWT Token
```http
Authorization: Bearer <your-jwt-token>
```
## API 接口列表
### 1. 创建喝水记录
**接口地址**: `POST /water-records`
**描述**: 创建一条新的喝水记录
**请求参数**:
```json
{
"amount": 250, // 必填喝水量毫升范围1-5000
"recordedAt": "2023-12-01T10:00:00.000Z", // 可选,记录时间,默认为当前时间
"source": "Manual", // 可选记录来源Manual(手动) | Auto(自动)
"note": "早晨第一杯水" // 可选备注最大100字符
}
```
**响应示例**:
```json
{
"success": true,
"message": "操作成功",
"data": {
"id": 1,
"amount": 250,
"recordedAt": "2023-12-01T10:00:00.000Z",
"note": "早晨第一杯水",
"createdAt": "2023-12-01T10:00:00.000Z",
"updatedAt": "2023-12-01T10:00:00.000Z"
}
}
```
**客户端示例代码**:
```javascript
// JavaScript/TypeScript
const createWaterRecord = async (recordData) => {
const response = await fetch('/api/water-records', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(recordData)
});
return response.json();
};
// 使用示例
const result = await createWaterRecord({
amount: 250,
note: "早晨第一杯水"
});
```
### 2. 获取喝水记录列表
**接口地址**: `GET /water-records`
**描述**: 获取用户的喝水记录列表,支持分页和日期筛选
**查询参数**:
- `startDate` (可选): 开始日期格式YYYY-MM-DD
- `endDate` (可选): 结束日期格式YYYY-MM-DD
- `page` (可选): 页码默认1
- `limit` (可选): 每页数量默认20最大100
**请求示例**:
```
GET /water-records?startDate=2023-12-01&endDate=2023-12-31&page=1&limit=20
```
**响应示例**:
```json
{
"success": true,
"message": "操作成功",
"data": {
"records": [
{
"id": 1,
"amount": 250,
"recordedAt": "2023-12-01T10:00:00.000Z",
"note": "早晨第一杯水",
"createdAt": "2023-12-01T10:00:00.000Z",
"updatedAt": "2023-12-01T10:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5
}
}
}
```
**客户端示例代码**:
```javascript
const getWaterRecords = async (params = {}) => {
const queryString = new URLSearchParams(params).toString();
const response = await fetch(`/api/water-records?${queryString}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
return response.json();
};
// 使用示例
const records = await getWaterRecords({
startDate: '2023-12-01',
endDate: '2023-12-31',
page: 1,
limit: 20
});
```
### 3. 更新喝水记录
**接口地址**: `PUT /water-records/:id`
**描述**: 更新指定的喝水记录
**路径参数**:
- `id`: 记录ID
**请求参数** (所有字段都是可选的):
```json
{
"amount": 300, // 可选,喝水量(毫升)
"recordedAt": "2023-12-01T11:00:00.000Z", // 可选,记录时间
"source": "Manual", // 可选,记录来源
"note": "修改后的备注" // 可选,备注
}
```
**响应示例**:
```json
{
"success": true,
"message": "操作成功",
"data": {
"id": 1,
"amount": 300,
"recordedAt": "2023-12-01T11:00:00.000Z",
"note": "修改后的备注",
"createdAt": "2023-12-01T10:00:00.000Z",
"updatedAt": "2023-12-01T11:30:00.000Z"
}
}
```
**客户端示例代码**:
```javascript
const updateWaterRecord = async (recordId, updateData) => {
const response = await fetch(`/api/water-records/${recordId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(updateData)
});
return response.json();
};
```
### 4. 删除喝水记录
**接口地址**: `DELETE /water-records/:id`
**描述**: 删除指定的喝水记录
**路径参数**:
- `id`: 记录ID
**响应**: HTTP 204 No Content (成功删除)
**客户端示例代码**:
```javascript
const deleteWaterRecord = async (recordId) => {
const response = await fetch(`/api/water-records/${recordId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
return response.status === 204;
};
```
### 5. 更新每日喝水目标
**接口地址**: `PUT /water-records/goal/daily`
**描述**: 设置或更新用户的每日喝水目标
**请求参数**:
```json
{
"dailyWaterGoal": 2000 // 必填每日喝水目标毫升范围500-10000
}
```
**响应示例**:
```json
{
"success": true,
"message": "操作成功",
"data": {
"dailyWaterGoal": 2000,
"updatedAt": "2023-12-01T12:00:00.000Z"
}
}
```
**客户端示例代码**:
```javascript
const updateWaterGoal = async (goalAmount) => {
const response = await fetch('/api/water-records/goal/daily', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ dailyWaterGoal: goalAmount })
});
return response.json();
};
```
### 6. 获取指定日期的喝水统计
**接口地址**: `GET /water-records/stats`
**描述**: 获取指定日期的喝水统计信息,包括总量、完成率等
**查询参数**:
- `date` (可选): 查询日期格式YYYY-MM-DD不传则默认为今天
**请求示例**:
```
GET /water-records/stats?date=2023-12-01
```
**响应示例**:
```json
{
"success": true,
"message": "操作成功",
"data": {
"date": "2023-12-01",
"totalAmount": 1500, // 当日总喝水量(毫升)
"dailyGoal": 2000, // 每日目标(毫升)
"completionRate": 75.0, // 完成率(百分比)
"recordCount": 6, // 记录次数
"records": [ // 当日所有记录
{
"id": 1,
"amount": 250,
"recordedAt": "2023-12-01T08:00:00.000Z",
"note": "早晨第一杯水"
},
{
"id": 2,
"amount": 300,
"recordedAt": "2023-12-01T10:30:00.000Z",
"note": null
}
]
}
}
```
**客户端示例代码**:
```javascript
const getWaterStats = async (date) => {
const params = date ? `?date=${date}` : '';
const response = await fetch(`/api/water-records/stats${params}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
return response.json();
};
// 使用示例
const todayStats = await getWaterStats(); // 获取今天的统计
const specificDateStats = await getWaterStats('2023-12-01'); // 获取指定日期的统计
```
## 错误处理
### 常见错误码
- `400 Bad Request`: 请求参数错误
- `401 Unauthorized`: 未授权Token无效或过期
- `404 Not Found`: 资源不存在
- `422 Unprocessable Entity`: 数据验证失败
- `500 Internal Server Error`: 服务器内部错误
### 错误响应格式
```json
{
"success": false,
"message": "错误描述",
"error": {
"code": "ERROR_CODE",
"details": "详细错误信息"
}
}
```
### 客户端错误处理示例
```javascript
const handleApiCall = async (apiFunction) => {
try {
const result = await apiFunction();
if (result.success) {
return result.data;
} else {
throw new Error(result.message);
}
} catch (error) {
console.error('API调用失败:', error.message);
// 根据错误类型进行相应处理
if (error.status === 401) {
// Token过期重新登录
redirectToLogin();
}
throw error;
}
};
```
## 数据类型说明
### WaterRecordSource 枚举
```typescript
enum WaterRecordSource {
Manual = 'Manual', // 手动记录
Auto = 'Auto' // 自动记录
}
```
### 日期格式
- 所有日期时间字段使用 ISO 8601 格式:`YYYY-MM-DDTHH:mm:ss.sssZ`
- 查询参数中的日期使用简化格式:`YYYY-MM-DD`
## 最佳实践
1. **错误处理**: 始终检查响应的 `success` 字段,并妥善处理错误情况
2. **Token管理**: 实现Token自动刷新机制避免因Token过期导致的请求失败
3. **数据验证**: 在发送请求前进行客户端数据验证,提升用户体验
4. **缓存策略**: 对于统计数据等相对稳定的信息,可以实现适当的缓存策略
5. **分页处理**: 处理列表数据时,注意分页信息,避免一次性加载过多数据
## 完整的客户端封装示例
```javascript
class WaterRecordsAPI {
constructor(baseURL, token) {
this.baseURL = baseURL;
this.token = token;
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const config = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`,
...options.headers
},
...options
};
const response = await fetch(url, config);
const data = await response.json();
if (!data.success) {
throw new Error(data.message);
}
return data.data;
}
// 创建喝水记录
async createRecord(recordData) {
return this.request('/water-records', {
method: 'POST',
body: JSON.stringify(recordData)
});
}
// 获取记录列表
async getRecords(params = {}) {
const queryString = new URLSearchParams(params).toString();
return this.request(`/water-records?${queryString}`);
}
// 更新记录
async updateRecord(recordId, updateData) {
return this.request(`/water-records/${recordId}`, {
method: 'PUT',
body: JSON.stringify(updateData)
});
}
// 删除记录
async deleteRecord(recordId) {
await this.request(`/water-records/${recordId}`, {
method: 'DELETE'
});
return true;
}
// 更新喝水目标
async updateGoal(goalAmount) {
return this.request('/water-records/goal/daily', {
method: 'PUT',
body: JSON.stringify({ dailyWaterGoal: goalAmount })
});
}
// 获取统计数据
async getStats(date) {
const params = date ? `?date=${date}` : '';
return this.request(`/water-records/stats${params}`);
}
}
// 使用示例
const api = new WaterRecordsAPI('https://your-api-domain.com/api', 'your-jwt-token');
// 创建记录
const newRecord = await api.createRecord({
amount: 250,
note: '早晨第一杯水'
});
// 获取今日统计
const todayStats = await api.getStats();
```
这个API封装提供了完整的喝水记录管理功能可以直接在客户端项目中使用。

View File

@@ -1,233 +0,0 @@
# Winston Logger 配置指南
本项目已配置了基于 Winston 的日志系统,支持日志文件输出、按日期滚动和自动清理。
## 功能特性
-**日志文件输出**: 自动将日志写入文件
-**按日期滚动**: 每天生成新的日志文件
-**自动清理**: 保留最近7天的日志文件
-**分级日志**: 支持不同级别的日志分离
-**结构化日志**: 支持JSON格式的结构化日志
-**异常处理**: 自动记录未捕获的异常和Promise拒绝
## 日志文件结构
```
logs/
├── app-2025-07-21.log # 应用日志 (info级别及以上)
├── error-2025-07-21.log # 错误日志 (error级别)
├── debug-2025-07-21.log # 调试日志 (仅开发环境)
├── exceptions-2025-07-21.log # 未捕获异常
├── rejections-2025-07-21.log # 未处理的Promise拒绝
└── .audit-*.json # 日志轮转审计文件
```
## 配置说明
### 日志级别
- **生产环境**: `info` 及以上级别
- **开发环境**: `debug` 及以上级别
### 文件轮转配置
- **日期模式**: `YYYY-MM-DD`
- **保留天数**: 7天
- **单文件大小**: 最大20MB
- **自动压缩**: 支持
## 使用方法
### 1. 在服务中使用 NestJS Logger
```typescript
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class YourService {
private readonly logger = new Logger(YourService.name);
someMethod() {
this.logger.log('这是一条信息日志');
this.logger.warn('这是一条警告日志');
this.logger.error('这是一条错误日志');
this.logger.debug('这是一条调试日志');
}
}
```
### 2. 直接使用 Winston Logger (推荐用于结构化日志)
```typescript
import { Injectable, Inject } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger as WinstonLogger } from 'winston';
@Injectable()
export class YourService {
constructor(
@Inject(WINSTON_MODULE_PROVIDER)
private readonly winstonLogger: WinstonLogger,
) {}
someMethod() {
// 结构化日志
this.winstonLogger.info('用户登录', {
context: 'AuthService',
userId: 'user123',
email: 'user@example.com',
timestamp: new Date().toISOString()
});
// 错误日志
this.winstonLogger.error('数据库连接失败', {
context: 'DatabaseService',
error: 'Connection timeout',
retryCount: 3
});
}
}
```
### 3. 在控制器中使用
```typescript
import { Controller, Logger } from '@nestjs/common';
@Controller('users')
export class UsersController {
private readonly logger = new Logger(UsersController.name);
@Get()
findAll() {
this.logger.log('获取用户列表请求');
// 业务逻辑
}
}
```
## 日志格式
### 控制台输出格式
```
2025-07-21 10:08:38 info [ServiceName] 日志消息
```
### 文件输出格式
```
2025-07-21 10:08:38 [INFO] [ServiceName] 日志消息
```
### 结构化日志格式
```json
{
"timestamp": "2025-07-21 10:08:38",
"level": "info",
"message": "用户登录",
"context": "AuthService",
"userId": "user123",
"email": "user@example.com"
}
```
## 环境变量配置
可以通过环境变量调整日志行为:
```bash
# 日志级别 (development: debug, production: info)
NODE_ENV=production
# 自定义日志目录 (可选)
LOG_DIR=/var/log/pilates-server
```
## 最佳实践
### 1. 使用合适的日志级别
- `error`: 错误和异常
- `warn`: 警告信息
- `info`: 重要的业务信息
- `debug`: 调试信息 (仅开发环境)
### 2. 结构化日志
对于重要的业务事件,使用结构化日志:
```typescript
this.winstonLogger.info('订单创建', {
context: 'OrderService',
orderId: order.id,
userId: user.id,
amount: order.amount,
currency: 'CNY'
});
```
### 3. 错误日志包含上下文
```typescript
try {
// 业务逻辑
} catch (error) {
this.winstonLogger.error('处理订单失败', {
context: 'OrderService',
orderId: order.id,
error: error.message,
stack: error.stack
});
}
```
### 4. 避免敏感信息
不要在日志中记录密码、令牌等敏感信息:
```typescript
// ❌ 错误
this.logger.log(`用户登录: ${JSON.stringify(loginData)}`);
// ✅ 正确
this.logger.log(`用户登录: ${loginData.email}`);
```
## 监控和维护
### 查看实时日志
```bash
# 查看应用日志
tail -f logs/app-$(date +%Y-%m-%d).log
# 查看错误日志
tail -f logs/error-$(date +%Y-%m-%d).log
```
### 日志分析
```bash
# 统计错误数量
grep -c "ERROR" logs/app-*.log
# 查找特定用户的日志
grep "userId.*user123" logs/app-*.log
```
### 清理旧日志
日志系统会自动清理7天前的日志文件无需手动维护。
## 故障排除
### 1. 日志文件未生成
- 检查 `logs` 目录权限
- 确认应用有写入权限
- 查看控制台是否有错误信息
### 2. 日志级别不正确
- 检查 `NODE_ENV` 环境变量
- 确认 winston 配置中的日志级别设置
### 3. 日志文件过大
- 检查日志轮转配置
- 确认 `maxSize``maxFiles` 设置
## 相关文件
- [`src/common/logger/winston.config.ts`](../src/common/logger/winston.config.ts) - Winston 配置
- [`src/common/logger/logger.module.ts`](../src/common/logger/logger.module.ts) - Logger 模块
- [`src/main.ts`](../src/main.ts) - 应用启动配置
- [`src/app.module.ts`](../src/app.module.ts) - 应用模块配置

View File

@@ -1,245 +0,0 @@
# 训练会话 API 使用指南
## 架构说明
新的训练系统采用了分离的架构设计,符合健身应用的最佳实践:
### 1. 训练计划模板 (Training Plans)
- **用途**: 用户创建和管理的训练计划模板
- **表**: `t_training_plans` + `t_schedule_exercises`
- **特点**: 静态配置,不包含完成状态
- **API**: `/training-plans`
### 2. 训练会话实例 (Workout Sessions)
- **用途**: 每日实际训练,从训练计划模板复制而来
- **表**: `t_workout_sessions` + `t_workout_exercises`
- **特点**: 动态数据,包含完成状态、进度追踪
- **API**: `/workouts`
## API 使用流程
### 第一步:创建训练计划模板
```bash
# 1. 创建训练计划
POST /training-plans
{
"name": "全身力量训练",
"startDate": "2024-01-15T00:00:00.000Z",
"mode": "daysOfWeek",
"daysOfWeek": [1, 3, 5],
"goal": "core_strength"
}
# 2. 添加训练动作到计划
POST /training-plans/{planId}/exercises
{
"exerciseKey": "squat",
"name": "深蹲训练",
"sets": 3,
"reps": 15,
"itemType": "exercise"
}
# 3. 激活训练计划
POST /training-plans/{planId}/activate
```
### 第二步:开始训练(三种方式)
#### 方式一:自动获取今日训练(推荐)
```bash
# 获取今日训练会话(如不存在则自动创建)
GET /workouts/today
# 系统会自动基于激活的训练计划创建今日训练会话
```
#### 方式二:基于训练计划手动创建
```bash
POST /workouts/sessions
{
"trainingPlanId": "{planId}",
"name": "晚间训练",
"scheduledDate": "2024-01-15T19:00:00.000Z"
}
```
#### 方式三:创建完全自定义训练
```bash
POST /workouts/sessions
{
"name": "自定义核心训练",
"scheduledDate": "2024-01-15T15:00:00.000Z",
"customExercises": [
{
"exerciseKey": "plank",
"name": "平板支撑",
"plannedDurationSec": 60,
"itemType": "exercise",
"sortOrder": 1
},
{
"name": "休息",
"plannedDurationSec": 30,
"itemType": "rest",
"sortOrder": 2
},
{
"name": "自定义深蹲变式",
"plannedSets": 3,
"plannedReps": 20,
"note": "脚距离肩膀更宽",
"itemType": "exercise",
"sortOrder": 3
}
]
}
```
### 第三步:执行训练
```bash
# 1. 开始训练会话(可选)
POST /workouts/sessions/{sessionId}/start
# 2. 动态添加动作(如果需要)
POST /workouts/sessions/{sessionId}/exercises
{
"name": "额外的拉伸",
"plannedDurationSec": 120,
"itemType": "exercise"
}
# 3. 开始特定动作
POST /workouts/sessions/{sessionId}/exercises/{exerciseId}/start
# 4. 完成动作
POST /workouts/sessions/{sessionId}/exercises/{exerciseId}/complete
{
"completedSets": 3,
"completedReps": 15,
"actualDurationSec": 180,
"performanceData": {
"sets": [
{ "reps": 15, "difficulty": 7 },
{ "reps": 12, "difficulty": 8 },
{ "reps": 10, "difficulty": 9 }
],
"perceivedExertion": 8
}
}
# 注意:当所有动作完成后,训练会话会自动标记为完成
```
## 主要优势
### 1. 数据分离
- 训练计划是可重用的模板
- 每日训练是独立的实例
- 修改计划不影响历史训练记录
### 2. 灵活的创建方式
- 自动创建:基于激活计划的今日训练
- 计划创建:基于指定训练计划创建
- 自定义创建:完全自定义的训练动作
### 3. 进度追踪
- 每个训练会话都有完整的状态跟踪
- 支持详细的性能数据记录
- 可以分析训练趋势和进步情况
### 4. 动态调整能力
- 支持训练中动态添加动作
- 支持跳过或修改特定动作
- 自动完成会话管理
- 自动计算训练统计数据
## API 端点总览
### 训练会话管理
- `GET /workouts/today` - 获取/自动创建今日训练会话 ⭐
- `POST /workouts/sessions` - 手动创建训练会话(支持基于计划或自定义动作)
- `GET /workouts/sessions` - 获取训练会话列表
- `GET /workouts/sessions/{id}` - 获取训练会话详情
- `POST /workouts/sessions/{id}/start` - 开始训练(可选)
- `DELETE /workouts/sessions/{id}` - 删除训练会话
- 注意:训练会话在所有动作完成后自动完成
### 训练动作管理
- `POST /workouts/sessions/{id}/exercises` - 向训练会话添加自定义动作 ⭐
- `GET /workouts/sessions/{id}/exercises` - 获取训练动作列表
- `GET /workouts/sessions/{id}/exercises/{exerciseId}` - 获取动作详情
- `POST /workouts/sessions/{id}/exercises/{exerciseId}/start` - 开始动作
- `POST /workouts/sessions/{id}/exercises/{exerciseId}/complete` - 完成动作
- `POST /workouts/sessions/{id}/exercises/{exerciseId}/skip` - 跳过动作
- `PUT /workouts/sessions/{id}/exercises/{exerciseId}` - 更新动作信息
### 统计和快捷功能
- `GET /workouts/sessions/{id}/stats` - 获取训练统计
- `GET /workouts/recent` - 获取最近训练
## 数据模型
### WorkoutSession (训练会话)
```typescript
{
id: string;
userId: string;
trainingPlanId: string; // 关联的训练计划模板
name: string;
scheduledDate: Date;
startedAt?: Date;
completedAt?: Date;
status: 'planned' | 'in_progress' | 'completed' | 'skipped';
totalDurationSec?: number;
summary?: string;
caloriesBurned?: number;
stats?: {
totalExercises: number;
completedExercises: number;
totalSets: number;
completedSets: number;
// ...
};
}
```
### WorkoutExercise (训练动作实例)
```typescript
{
id: string;
workoutSessionId: string;
exerciseKey?: string; // 关联动作库
name: string;
plannedSets?: number; // 计划数值
completedSets?: number; // 实际完成数值
plannedReps?: number;
completedReps?: number;
plannedDurationSec?: number;
actualDurationSec?: number;
status: 'pending' | 'in_progress' | 'completed' | 'skipped';
startedAt?: Date;
completedAt?: Date;
performanceData?: { // 详细性能数据
sets: Array<{
reps?: number;
weight?: number;
difficulty?: number;
notes?: string;
}>;
heartRate?: { avg: number; max: number };
perceivedExertion?: number; // RPE 1-10
};
}
```
## 迁移说明
如果您之前使用了 `training-plans` 的完成状态功能,现在需要:
1. 使用 `/workouts/sessions` 来创建每日训练
2. 使用新的完成状态 API 来跟踪进度
3. 原有的训练计划数据保持不变,作为模板使用
这样的架构分离使得系统更加清晰、可维护,也更符合健身应用的实际使用场景。

169
package-lock.json generated
View File

@@ -14,9 +14,13 @@
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.0.1",
"@nestjs/sequelize": "^11.0.0",
"@nestjs/swagger": "^11.1.0",
"@nestjs/throttler": "^6.4.0",
"@openrouter/sdk": "^0.1.27",
"@parse/node-apn": "^5.0.0",
"@types/ioredis": "^4.28.10",
"@types/jsonwebtoken": "^9.0.9",
"@types/uuid": "^10.0.0",
"apns2": "^12.2.0",
@@ -27,7 +31,9 @@
"cos-nodejs-sdk-v5": "^2.14.7",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.18",
"form-data": "^4.0.5",
"fs": "^0.0.1-security",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"mysql2": "^3.14.0",
@@ -1359,6 +1365,12 @@
}
}
},
"node_modules/@ioredis/commands": {
"version": "1.4.0",
"resolved": "https://mirrors.tencent.com/npm/@ioredis/commands/-/commands-1.4.0.tgz",
"integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2382,6 +2394,19 @@
"@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": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.2.tgz",
@@ -2544,6 +2569,17 @@
}
}
},
"node_modules/@nestjs/throttler": {
"version": "6.4.0",
"resolved": "https://mirrors.tencent.com/npm/@nestjs/throttler/-/throttler-6.4.0.tgz",
"integrity": "sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2598,6 +2634,15 @@
"npm": ">=5.10.0"
}
},
"node_modules/@openrouter/sdk": {
"version": "0.1.27",
"resolved": "https://mirrors.tencent.com/npm/@openrouter/sdk/-/sdk-0.1.27.tgz",
"integrity": "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ==",
"license": "Apache-2.0",
"dependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@parse/node-apn": {
"version": "5.2.3",
"resolved": "https://mirrors.tencent.com/npm/@parse/node-apn/-/node-apn-5.2.3.tgz",
@@ -3391,6 +3436,15 @@
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
"license": "MIT"
},
"node_modules/@types/ioredis": {
"version": "4.28.10",
"resolved": "https://mirrors.tencent.com/npm/@types/ioredis/-/ioredis-4.28.10.tgz",
"integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -3452,6 +3506,12 @@
"dev": true,
"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": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -5380,6 +5440,14 @@
"node": ">=0.8"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -5736,6 +5804,18 @@
"dev": true,
"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": {
"version": "4.1.97",
"resolved": "https://mirrors.tencent.com/npm/croner/-/croner-4.1.97.tgz",
@@ -5795,7 +5875,7 @@
},
"node_modules/dayjs": {
"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==",
"license": "MIT"
},
@@ -7297,14 +7377,14 @@
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT",
"version": "4.0.5",
"resolved": "https://mirrors.tencent.com/npm/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -8018,6 +8098,30 @@
"kind-of": "^6.0.2"
}
},
"node_modules/ioredis": {
"version": "5.8.2",
"resolved": "https://mirrors.tencent.com/npm/ioredis/-/ioredis-5.8.2.tgz",
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.4.0",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ip-address": {
"version": "9.0.5",
"resolved": "https://mirrors.tencent.com/npm/ip-address/-/ip-address-9.0.5.tgz",
@@ -9303,12 +9407,23 @@
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://mirrors.tencent.com/npm/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://mirrors.tencent.com/npm/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://mirrors.tencent.com/npm/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://mirrors.tencent.com/npm/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -9473,6 +9588,15 @@
"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": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@@ -11203,6 +11327,26 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://mirrors.tencent.com/npm/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://mirrors.tencent.com/npm/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -12140,6 +12284,12 @@
"node": ">=8"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://mirrors.tencent.com/npm/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -13701,6 +13851,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://mirrors.tencent.com/npm/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -32,9 +32,13 @@
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.0.1",
"@nestjs/sequelize": "^11.0.0",
"@nestjs/swagger": "^11.1.0",
"@nestjs/throttler": "^6.4.0",
"@openrouter/sdk": "^0.1.27",
"@parse/node-apn": "^5.0.0",
"@types/ioredis": "^4.28.10",
"@types/jsonwebtoken": "^9.0.9",
"@types/uuid": "^10.0.0",
"apns2": "^12.2.0",
@@ -45,7 +49,9 @@
"cos-nodejs-sdk-v5": "^2.14.7",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.18",
"form-data": "^4.0.5",
"fs": "^0.0.1-security",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"mysql2": "^3.14.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,20 @@
-- 用药 AI 总结表创建脚本
-- 创建时间: 2025-01-18
-- 说明: 按天存储用户的用药AI总结避免重复调用大模型
CREATE TABLE IF NOT EXISTS `t_medication_ai_summaries` (
`id` varchar(50) NOT NULL COMMENT '唯一标识',
`user_id` varchar(50) NOT NULL COMMENT '用户ID',
`summary_date` date NOT NULL COMMENT '统计日期YYYY-MM-DD',
`medication_analysis` json NOT NULL COMMENT '用药计划与进度统计',
`key_insights` text NOT NULL COMMENT 'AI重点解读',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_user_date` (`user_id`, `summary_date`),
KEY `idx_user_date` (`user_id`, `summary_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用药AI总结表按天缓存';
-- 使用说明:
-- 1) 每位用户每日最多一条记录用于缓存当天的用药AI总结。
-- 2) medication_analysis 字段存储 JSON 数组medicationAnalysis 列表key_insights 存储生成的重点解读文本。

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,21 @@
-- AI 健康报告生成历史记录表
-- 记录每次生成的报告信息,包括 prompt 和图片地址
CREATE TABLE IF NOT EXISTS `t_ai_report_history` (
`id` VARCHAR(36) NOT NULL COMMENT '记录ID',
`user_id` VARCHAR(36) NOT NULL COMMENT '用户ID',
`report_date` DATE NOT NULL COMMENT '报告日期',
`prompt` TEXT NOT NULL COMMENT '生成图像使用的 Prompt',
`image_url` VARCHAR(500) NOT NULL COMMENT '生成的图片地址',
`api_provider` VARCHAR(20) DEFAULT NULL COMMENT 'API 提供商: openrouter | grsai',
`model_name` VARCHAR(50) DEFAULT NULL COMMENT '使用的模型名称',
`generation_time_ms` INT DEFAULT NULL COMMENT '生成耗时(毫秒)',
`status` VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '生成状态: success | failed',
`error_message` TEXT DEFAULT NULL COMMENT '失败原因(如果失败)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_report_date` (`report_date`),
INDEX `idx_user_report_date` (`user_id`, `report_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI健康报告生成历史';

View File

@@ -0,0 +1,24 @@
-- ============================================================
-- 用户每日健康记录表
-- 每日每个用户只会生成一条数据,通过 user_id + record_date 唯一确定
-- ============================================================
CREATE TABLE IF NOT EXISTS `t_user_daily_health` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` VARCHAR(64) NOT NULL COMMENT '用户ID',
`record_date` DATE NOT NULL COMMENT '记录日期 (YYYY-MM-DD)',
`water_intake` INT NULL COMMENT '饮水量 (毫升 ml)',
`exercise_minutes` INT NULL COMMENT '锻炼分钟数',
`calories_burned` FLOAT NULL COMMENT '消耗卡路里 (千卡 kcal)',
`standing_minutes` INT NULL COMMENT '站立时间 (分钟)',
`basal_metabolism` FLOAT NULL COMMENT '基础代谢 (千卡 kcal)',
`sleep_minutes` INT NULL COMMENT '睡眠分钟数',
`blood_oxygen` FLOAT NULL COMMENT '血氧饱和度 (百分比 %)',
`stress_level` DECIMAL(5,1) NULL COMMENT '压力 (ms保留一位小数)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_record_date` (`user_id`, `record_date`),
KEY `idx_user_id` (`user_id`),
KEY `idx_record_date` (`record_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户每日健康记录表';

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

@@ -1,31 +0,0 @@
-- 创建训练会话表
CREATE TABLE t_workout_sessions (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
user_id VARCHAR(255) NOT NULL COMMENT '用户ID',
training_plan_id VARCHAR(36) NOT NULL COMMENT '关联的训练计划模板',
name VARCHAR(255) NOT NULL COMMENT '训练会话名称',
scheduled_date DATETIME NOT NULL COMMENT '计划训练日期',
started_at DATETIME NULL COMMENT '实际开始时间',
completed_at DATETIME NULL COMMENT '实际结束时间',
status ENUM('planned', 'in_progress', 'completed', 'skipped') NOT NULL DEFAULT 'planned' COMMENT '训练状态',
total_duration_sec INT NULL COMMENT '总时长(秒)',
summary TEXT NULL COMMENT '训练总结/备注',
calories_burned INT NULL COMMENT '消耗卡路里(估算)',
stats JSON NULL COMMENT '训练统计数据',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE COMMENT '是否已删除',
-- 外键约束
FOREIGN KEY (training_plan_id) REFERENCES t_training_plans(id),
-- 索引
INDEX idx_user_id (user_id),
INDEX idx_training_plan_id (training_plan_id),
INDEX idx_scheduled_date (scheduled_date),
INDEX idx_status (status),
INDEX idx_deleted (deleted)
);
-- 添加表注释
ALTER TABLE t_workout_sessions COMMENT = '训练会话表';

View File

@@ -1,85 +0,0 @@
-- 训练会话相关表创建脚本
-- 用于支持每日训练实例功能
-- 禁用外键检查
SET FOREIGN_KEY_CHECKS = 0;
-- 删除训练会话相关表(如果存在)
DROP TABLE IF EXISTS `t_workout_exercises`;
DROP TABLE IF EXISTS `t_workout_sessions`;
-- 重新启用外键检查
SET FOREIGN_KEY_CHECKS = 1;
-- 创建训练会话表
CREATE TABLE `t_workout_sessions` (
`id` char(36) NOT NULL COMMENT '训练会话唯一ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`training_plan_id` char(36) NOT NULL COMMENT '关联的训练计划模板',
`name` varchar(255) NOT NULL COMMENT '训练会话名称',
`scheduled_date` datetime NOT NULL COMMENT '计划训练日期',
`started_at` datetime DEFAULT NULL COMMENT '实际开始时间',
`completed_at` datetime DEFAULT NULL COMMENT '实际结束时间',
`status` enum('planned','in_progress','completed','skipped') NOT NULL DEFAULT 'planned' COMMENT '训练状态',
`total_duration_sec` int DEFAULT NULL COMMENT '总时长(秒)',
`summary` text COMMENT '训练总结/备注',
`calories_burned` int DEFAULT NULL COMMENT '消耗卡路里(估算)',
`stats` json DEFAULT 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` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_training_plan_id` (`training_plan_id`),
KEY `idx_scheduled_date` (`scheduled_date`),
KEY `idx_status` (`status`),
KEY `idx_deleted` (`deleted`),
KEY `idx_user_date` (`user_id`, `scheduled_date`, `deleted`),
CONSTRAINT `fk_workout_sessions_training_plan` FOREIGN KEY (`training_plan_id`) REFERENCES `t_training_plans` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='训练会话表(每日训练实例)';
-- 创建训练会话动作表
CREATE TABLE `t_workout_exercises` (
`id` char(36) NOT NULL COMMENT '训练动作唯一ID',
`workout_session_id` char(36) NOT NULL COMMENT '所属训练会话ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`exercise_key` varchar(255) DEFAULT NULL COMMENT '关联的动作key仅exercise类型',
`name` varchar(255) NOT NULL COMMENT '项目名称',
`planned_sets` int DEFAULT NULL COMMENT '计划组数',
`completed_sets` int DEFAULT NULL COMMENT '实际完成组数',
`planned_reps` int DEFAULT NULL COMMENT '计划重复次数',
`completed_reps` int DEFAULT NULL COMMENT '实际完成重复次数',
`planned_duration_sec` int DEFAULT NULL COMMENT '计划持续时长(秒)',
`actual_duration_sec` int DEFAULT NULL COMMENT '实际持续时长(秒)',
`rest_sec` int DEFAULT NULL COMMENT '休息时长(秒)',
`note` text COMMENT '备注',
`item_type` enum('exercise','rest','note') NOT NULL DEFAULT 'exercise' COMMENT '项目类型',
`status` enum('pending','in_progress','completed','skipped') NOT NULL DEFAULT 'pending' COMMENT '动作状态',
`sort_order` int NOT NULL COMMENT '排序顺序',
`started_at` datetime DEFAULT NULL COMMENT '开始时间',
`completed_at` datetime DEFAULT NULL COMMENT '完成时间',
`performance_data` json DEFAULT 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` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`),
KEY `idx_workout_session_id` (`workout_session_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_exercise_key` (`exercise_key`),
KEY `idx_sort_order` (`sort_order`),
KEY `idx_status` (`status`),
KEY `idx_deleted` (`deleted`),
KEY `idx_session_order` (`workout_session_id`, `sort_order`, `deleted`),
CONSTRAINT `fk_workout_exercises_session` FOREIGN KEY (`workout_session_id`) REFERENCES `t_workout_sessions` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_workout_exercises_exercise` FOREIGN KEY (`exercise_key`) REFERENCES `t_exercises` (`key`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='训练会话动作表(每日训练实例动作)';
-- 为 t_schedule_exercises 表添加注释,澄清其用途
ALTER TABLE `t_schedule_exercises` COMMENT = '训练计划动作表(训练计划模板的动作配置)';
-- 创建一些有用的索引
CREATE INDEX `idx_workout_sessions_user_status` ON `t_workout_sessions` (`user_id`, `status`, `deleted`);
CREATE INDEX `idx_workout_exercises_session_type` ON `t_workout_exercises` (`workout_session_id`, `item_type`, `deleted`);
-- 插入一些示例数据来测试
-- 注意实际使用时应该通过API来创建数据

View File

@@ -175,7 +175,8 @@ export class AiCoachController {
): Promise<FoodRecognitionResponseDto> {
this.logger.log(`Food recognition request from user: ${user.sub}, images: ${body.imageUrls?.length || 0}`);
const result = await this.dietAnalysisService.recognizeFoodForConfirmation(body.imageUrls);
const language = await this.usersService.getUserLanguage(user.sub);
const result = await this.dietAnalysisService.recognizeFoodForConfirmation(body.imageUrls, language);
// 转换为DTO格式
const response: FoodRecognitionResponseDto = {
@@ -220,7 +221,8 @@ export class AiCoachController {
): Promise<FoodRecognitionResponseDto> {
this.logger.log(`Text food analysis request from user: ${user.sub}, text: "${body.text}"`);
const result = await this.dietAnalysisService.analyzeTextFoodForConfirmation(body.text);
const language = await this.usersService.getUserLanguage(user.sub);
const result = await this.dietAnalysisService.analyzeTextFoodForConfirmation(body.text, language);
// 转换为DTO格式
const response: FoodRecognitionResponseDto = {

View File

@@ -4,22 +4,33 @@ import { ConfigModule } from '@nestjs/config';
import { AiCoachController } from './ai-coach.controller';
import { AiCoachService } from './ai-coach.service';
import { DietAnalysisService } from './services/diet-analysis.service';
import { AiReportService } from './services/ai-report.service';
import { AiMessage } from './models/ai-message.model';
import { AiConversation } from './models/ai-conversation.model';
import { PostureAssessment } from './models/posture-assessment.model';
import { AiReportHistory } from './models/ai-report-history.model';
import { UsersModule } from '../users/users.module';
import { DietRecordsModule } from '../diet-records/diet-records.module';
import { MedicationsModule } from '../medications/medications.module';
import { MoodCheckinsModule } from '../mood-checkins/mood-checkins.module';
import { WaterRecordsModule } from '../water-records/water-records.module';
import { ChallengesModule } from '../challenges/challenges.module';
import { CosService } from '../users/cos.service';
@Module({
imports: [
ConfigModule,
UsersModule,
forwardRef(() => UsersModule),
forwardRef(() => DietRecordsModule),
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]),
forwardRef(() => MedicationsModule),
forwardRef(() => MoodCheckinsModule),
forwardRef(() => WaterRecordsModule),
forwardRef(() => ChallengesModule),
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment, AiReportHistory]),
],
controllers: [AiCoachController],
providers: [AiCoachService, DietAnalysisService],
exports: [DietAnalysisService],
providers: [AiCoachService, DietAnalysisService, AiReportService, CosService],
exports: [DietAnalysisService, AiReportService],
})
export class AiCoachModule { }

View File

@@ -413,7 +413,8 @@ export class AiCoachService {
): Promise<Readable | { type: 'structured'; data: any }> {
if (params.imageUrls) {
// 处理图片饮食记录
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation(params.imageUrls);
const language = await this.usersService.getUserLanguage(params.userId);
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation(params.imageUrls, language);
if (recognitionResult.items.length > 0) {
const choices = recognitionResult.items.map(item => ({
@@ -467,6 +468,8 @@ export class AiCoachService {
}
} else {
// 处理文本饮食记录
// const language = await this.usersService.getUserLanguage(params.userId);
// TODO: analyzeDietFromText 也需要支持多语言
const textAnalysisResult = await this.dietAnalysisService.analyzeDietFromText(commandResult.cleanText);
if (textAnalysisResult.shouldRecord && textAnalysisResult.extractedData) {

View File

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

View File

@@ -0,0 +1,93 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ResponseCode } from '../../base.dto';
/**
* AI 健康报告历史记录查询 DTO
*/
export class GetAiReportHistoryQueryDto {
@ApiPropertyOptional({ description: '页码从1开始', default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: '每页条数默认10最大50', default: 10 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(50)
pageSize?: number = 10;
@ApiPropertyOptional({ description: '开始日期,格式 YYYY-MM-DD' })
@IsOptional()
@IsString()
startDate?: string;
@ApiPropertyOptional({ description: '结束日期,格式 YYYY-MM-DD' })
@IsOptional()
@IsString()
endDate?: string;
@ApiPropertyOptional({ description: '状态筛选: pending | processing | success | failed不传则返回所有状态' })
@IsOptional()
@IsString()
status?: string;
}
/**
* AI 健康报告历史记录项
*/
export class AiReportHistoryItemDto {
@ApiProperty({ description: '记录ID' })
id: string;
@ApiProperty({ description: '报告日期,格式 YYYY-MM-DD' })
reportDate: string;
@ApiProperty({ description: '生成的图片地址' })
imageUrl: string;
@ApiProperty({ description: '生成状态: success | failed' })
status: string;
@ApiProperty({ description: '创建时间' })
createdAt: Date;
}
/**
* AI 健康报告历史记录列表响应
*/
export class AiReportHistoryListDto {
@ApiProperty({ description: '记录列表', type: [AiReportHistoryItemDto] })
records: AiReportHistoryItemDto[];
@ApiProperty({ description: '总记录数' })
total: number;
@ApiProperty({ description: '当前页码' })
page: number;
@ApiProperty({ description: '每页条数' })
pageSize: number;
@ApiProperty({ description: '总页数' })
totalPages: number;
}
/**
* 获取 AI 健康报告历史记录响应 DTO
*/
export class GetAiReportHistoryResponseDto {
@ApiProperty({ description: '响应状态码', example: ResponseCode.SUCCESS })
declare code: ResponseCode;
@ApiProperty({ description: '响应消息', example: 'success' })
declare message: string;
@ApiProperty({ description: '报告历史数据', type: AiReportHistoryListDto })
declare data: AiReportHistoryListDto;
}

View File

@@ -0,0 +1,110 @@
import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript';
/**
* AI 报告生成状态枚举
*/
export enum AiReportStatus {
PENDING = 'pending', // 未开始
PROCESSING = 'processing', // 进行中
SUCCESS = 'success', // 已完成
FAILED = 'failed', // 已失败
}
/**
* AI 健康报告生成历史记录表
* 记录每次生成的报告信息,包括 prompt 和图片地址
*/
@Table({
tableName: 't_ai_report_history',
underscored: true,
})
export class AiReportHistory extends Model {
@PrimaryKey
@Column({
type: DataType.STRING(36),
allowNull: false,
comment: '记录ID',
})
declare id: string;
@Index
@Column({
type: DataType.STRING(36),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Index
@Column({
type: DataType.DATEONLY,
allowNull: false,
comment: '报告日期',
})
declare reportDate: string;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '生成图像使用的 Prompt',
})
declare prompt: string | null;
@Column({
type: DataType.STRING(500),
allowNull: true,
comment: '生成的图片地址',
})
declare imageUrl: string | null;
@Column({
type: DataType.STRING(20),
allowNull: true,
comment: 'API 提供商: openrouter | grsai',
})
declare apiProvider: string | null;
@Column({
type: DataType.STRING(50),
allowNull: true,
comment: '使用的模型名称',
})
declare modelName: string | null;
@Column({
type: DataType.INTEGER,
allowNull: true,
comment: '生成耗时(毫秒)',
})
declare generationTimeMs: number | null;
@Index
@Column({
type: DataType.STRING(20),
allowNull: false,
defaultValue: AiReportStatus.PENDING,
comment: '生成状态: pending | processing | success | failed',
})
declare status: string;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '失败原因(如果失败)',
})
declare errorMessage: string | null;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '创建时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '更新时间',
})
declare updatedAt: Date;
}

View File

@@ -0,0 +1,945 @@
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectModel } from '@nestjs/sequelize';
import { Op } from 'sequelize';
import * as dayjs from 'dayjs';
import { v4 as uuidv4 } from 'uuid';
import OpenAI from 'openai';
import { CosService } from '../../users/cos.service';
import { AiReportHistory, AiReportStatus } from '../models/ai-report-history.model';
// 假设各个模块的服务都已正确导出
import { UsersService } from '../../users/users.service';
import { MedicationStatsService } from '../../medications/medication-stats.service';
import { DietRecordsService } from '../../diet-records/diet-records.service';
import { WaterRecordsService } from '../../water-records/water-records.service';
import { MoodCheckinsService } from '../../mood-checkins/mood-checkins.service';
import { ChallengesService } from '../../challenges/challenges.service';
/**
* 聚合的每日健康数据接口
*/
interface DailyHealthData {
date: string;
user?: {
name: string;
avatar?: string;
gender?: string;
};
medications: {
totalScheduled: number;
taken: number;
missed: number;
completionRate: number;
};
diet: {
totalCalories: number;
totalProtein: number;
totalCarbohydrates: number;
totalFat: number;
averageCaloriesPerMeal: number;
mealTypeDistribution: Record<string, number>;
};
mood: {
primaryMood?: string;
averageIntensity?: number;
totalCheckins: number;
};
water: {
totalAmount: number;
dailyGoal: number;
completionRate: number;
};
bodyMeasurements: {
weight?: number;
latestChest?: number;
latestWaist?: number;
};
challenges: {
activeChallengeCount: number;
};
// 来自 UserDailyHealth 的健康统计数据
healthStats: {
exerciseMinutes?: number; // 锻炼分钟数
caloriesBurned?: number; // 消耗卡路里
standingMinutes?: number; // 站立时间
basalMetabolism?: number; // 基础代谢
sleepMinutes?: number; // 睡眠分钟数
bloodOxygen?: number; // 血氧饱和度
stressLevel?: number; // 压力值
steps?: number; // 步数
};
}
@Injectable()
export class AiReportService {
private readonly logger = new Logger(AiReportService.name);
constructor(
private readonly configService: ConfigService,
@InjectModel(AiReportHistory)
private readonly aiReportHistoryModel: typeof AiReportHistory,
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
@Inject(forwardRef(() => MedicationStatsService))
private readonly medicationStatsService: MedicationStatsService,
@Inject(forwardRef(() => DietRecordsService))
private readonly dietRecordsService: DietRecordsService,
@Inject(forwardRef(() => WaterRecordsService))
private readonly waterRecordsService: WaterRecordsService,
@Inject(forwardRef(() => MoodCheckinsService))
private readonly moodCheckinsService: MoodCheckinsService,
@Inject(forwardRef(() => ChallengesService))
private readonly challengesService: ChallengesService,
private readonly cosService: CosService,
) {}
/**
* 检查用户当天成功生成的报告数量
* @param userId 用户ID
* @param date 日期,格式 YYYY-MM-DD
* @returns 当天成功生成的报告数量
*/
private async getTodaySuccessfulReportCount(userId: string, date: string): Promise<number> {
const count = await this.aiReportHistoryModel.count({
where: {
userId,
reportDate: date,
status: AiReportStatus.SUCCESS,
},
});
return count;
}
/**
* 主入口生成用户的AI健康报告图片
* 流程:
* 0. 检查当天生成次数限制最多2次
* 1. 创建记录,状态为 processing进行中
* 2. 聚合数据、生成 Prompt、调用图像生成 API
* 3. 根据结果更新记录状态为 success 或 failed
*
* @param userId 用户ID
* @param date 目标日期,格式 YYYY-MM-DD默认为今天
* @returns 包含记录ID和图片URL如果成功
*/
async generateHealthReportImage(userId: string, date?: string): Promise<{ id: string; imageUrl: string }> {
const targetDate = date || dayjs().format('YYYY-MM-DD');
this.logger.log(`开始为用户 ${userId} 生成 ${targetDate} 的AI健康报告`);
// Step 0: 检查当天成功生成的报告数量最多允许2次
const maxDailyReports = 2;
const todaySuccessCount = await this.getTodaySuccessfulReportCount(userId, targetDate);
if (todaySuccessCount >= maxDailyReports) {
this.logger.warn(`用户 ${userId}${targetDate} 已成功生成 ${todaySuccessCount} 次报告,已达到每日上限`);
throw new Error(`每天最多只能生成 ${maxDailyReports} 次健康报告`);
}
const startTime = Date.now();
const apiProvider = this.configService.get<string>('IMAGE_API_PROVIDER') || 'openrouter';
const recordId = uuidv4();
// Step 1: 创建记录,状态为 processing
await this.createReportRecord({
id: recordId,
userId,
reportDate: targetDate,
apiProvider,
status: AiReportStatus.PROCESSING,
});
let imagePrompt = '';
let imageUrl = '';
try {
// Step 2: 聚合数据
const dailyData = await this.gatherDailyData(userId, targetDate);
// Step 3: 获取用户语言偏好
const userLanguage = await this.getUserLanguage(userId);
// Step 4: 生成图像生成Prompt
imagePrompt = await this.generateImagePrompt(dailyData, userLanguage);
this.logger.log(`为用户 ${userId} 生成了图像Prompt`);
// 更新 Prompt 到记录
await this.updateReportRecord(recordId, { prompt: imagePrompt });
// Step 5: 调用图像生成API
imageUrl = await this.callImageGenerationApi(imagePrompt);
this.logger.log(`为用户 ${userId} 成功生成图像: ${imageUrl}`);
// Step 6: 更新记录状态为 success
await this.updateReportRecord(recordId, {
imageUrl,
status: AiReportStatus.SUCCESS,
generationTimeMs: Date.now() - startTime,
modelName: apiProvider === 'grsai' ? 'nano-banana-pro' : 'google/gemini-3-pro-image-preview',
});
return { id: recordId, imageUrl };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`为用户 ${userId} 生成报告失败: ${errorMessage}`);
// Step 6: 更新记录状态为 failed
await this.updateReportRecord(recordId, {
prompt: imagePrompt || null,
status: AiReportStatus.FAILED,
errorMessage,
generationTimeMs: Date.now() - startTime,
modelName: apiProvider === 'grsai' ? 'nano-banana-pro' : 'google/gemini-3-pro-image-preview',
});
throw error;
}
}
/**
* 创建报告记录
*/
private async createReportRecord(data: {
id: string;
userId: string;
reportDate: string;
apiProvider: string;
status: AiReportStatus;
}): Promise<AiReportHistory> {
try {
const record = await this.aiReportHistoryModel.create({
id: data.id,
userId: data.userId,
reportDate: data.reportDate,
apiProvider: data.apiProvider,
status: data.status,
prompt: null,
imageUrl: null,
});
this.logger.log(`创建报告记录成功ID: ${data.id}, 状态: ${data.status}`);
return record;
} catch (error) {
this.logger.error(`创建报告记录失败: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
/**
* 更新报告记录
*/
private async updateReportRecord(
recordId: string,
data: Partial<{
prompt: string | null;
imageUrl: string | null;
status: AiReportStatus;
errorMessage: string | null;
generationTimeMs: number;
modelName: string;
}>,
): Promise<void> {
try {
await this.aiReportHistoryModel.update(data, {
where: { id: recordId },
});
this.logger.log(`更新报告记录成功ID: ${recordId}, 更新字段: ${Object.keys(data).join(', ')}`);
} catch (error) {
// 更新失败不影响主流程,仅记录日志
this.logger.error(`更新报告记录失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* 获取用户的报告生成历史(分页)
* @param userId 用户ID
* @param options 查询选项
*/
async getReportHistory(
userId: string,
options?: { page?: number; pageSize?: number; startDate?: string; endDate?: string; status?: string },
): Promise<{ records: AiReportHistory[]; total: number; page: number; pageSize: number; totalPages: number }> {
const { page = 1, pageSize = 10, startDate, endDate, status } = options || {};
const offset = (page - 1) * pageSize;
const where: any = { userId };
// 如果传了 status 参数则按指定状态筛选,否则返回所有状态
if (status) {
where.status = status;
}
if (startDate || endDate) {
where.reportDate = {};
if (startDate) where.reportDate[Op.gte] = startDate;
if (endDate) where.reportDate[Op.lte] = endDate;
}
const { rows, count } = await this.aiReportHistoryModel.findAndCountAll({
where,
attributes: ['id', 'reportDate', 'imageUrl', 'status', 'createdAt'],
order: [['createdAt', 'DESC']],
limit: pageSize,
offset,
});
return {
records: rows,
total: count,
page,
pageSize,
totalPages: Math.ceil(count / pageSize)
};
}
/**
* 根据记录ID获取报告详情
* @param recordId 记录ID
* @param userId 用户ID用于权限校验
*/
async getReportById(recordId: string, userId: string): Promise<AiReportHistory | null> {
return this.aiReportHistoryModel.findOne({
where: { id: recordId, userId },
});
}
/**
* 1. 聚合用户指定日期的各项健康数据
*/
private async gatherDailyData(userId: string, date: string): Promise<DailyHealthData> {
const dayStart = dayjs(date).startOf('day').toISOString();
const dayEnd = dayjs(date).endOf('day').toISOString();
const [userProfile, medicationStats, dietHistory, waterStats, moodStats, activeChallengeCount, dailyHealth] = await Promise.all([
this.usersService.getProfile({ sub: userId, email: '', isGuest: false } as any).then(p => p.data).catch(() => null),
this.medicationStatsService.getDailyStats(userId, date).catch(() => null),
// 获取当日饮食记录并聚合
this.dietRecordsService.getDietHistory(userId, { startDate: dayStart, endDate: dayEnd, limit: 100 }).catch(() => ({ records: [] })),
this.waterRecordsService.getWaterStats(userId, date).catch(() => null),
this.moodCheckinsService.getDaily(userId, date).catch(() => null),
// 获取用户当前参与的活跃挑战数量
this.challengesService.getActiveParticipatingChallengeCount(userId).catch(() => 0),
// 获取用户每日健康统计数据
this.usersService.getDailyHealth(userId, date).catch(() => null),
]);
// 处理饮食数据聚合
const dietRecords: any[] = (dietHistory as any).records || [];
const dietStats = {
totalCalories: dietRecords.reduce((sum, r) => sum + (r.estimatedCalories || 0), 0),
totalProtein: dietRecords.reduce((sum, r) => sum + (r.proteinGrams || 0), 0),
totalCarbohydrates: dietRecords.reduce((sum, r) => sum + (r.carbohydrateGrams || 0), 0),
totalFat: dietRecords.reduce((sum, r) => sum + (r.fatGrams || 0), 0),
averageCaloriesPerMeal: dietRecords.length > 0 ? (dietRecords.reduce((sum, r) => sum + (r.estimatedCalories || 0), 0) / dietRecords.length) : 0,
mealTypeDistribution: dietRecords.reduce((acc, r) => {
acc[r.mealType] = (acc[r.mealType] || 0) + 1;
return acc;
}, {} as Record<string, number>)
};
// 获取身体测量数据(最新的体重和围度)
const bodyMeasurements = await this.usersService.getBodyMeasurementHistory(userId, undefined).then(res => res.data).catch(() => []);
const latestWeight = await this.usersService.getWeightHistory(userId, { limit: 1 }).then(res => res[0]).catch(() => null);
const latestChest = bodyMeasurements.find(m => m.measurementType === 'chestCircumference');
const latestWaist = bodyMeasurements.find(m => m.measurementType === 'waistCircumference');
return {
date,
user: userProfile ? {
name: userProfile.name,
avatar: userProfile.avatar,
gender: (userProfile as any).gender,
} : undefined,
medications: medicationStats ? {
totalScheduled: medicationStats.totalScheduled,
taken: medicationStats.taken,
missed: medicationStats.missed,
completionRate: medicationStats.completionRate,
} : { totalScheduled: 0, taken: 0, missed: 0, completionRate: 0 },
diet: {
totalCalories: dietStats.totalCalories,
totalProtein: dietStats.totalProtein,
totalCarbohydrates: dietStats.totalCarbohydrates,
totalFat: dietStats.totalFat,
averageCaloriesPerMeal: dietStats.averageCaloriesPerMeal,
mealTypeDistribution: dietStats.mealTypeDistribution,
},
water: waterStats?.data ? {
totalAmount: waterStats.data.totalAmount,
dailyGoal: waterStats.data.dailyGoal,
completionRate: waterStats.data.completionRate,
} : { totalAmount: 0, dailyGoal: 0, completionRate: 0 },
mood: moodStats?.data && Array.isArray(moodStats.data) && moodStats.data.length > 0 ? {
primaryMood: moodStats.data[0].moodType,
averageIntensity: moodStats.data.reduce((sum: number, m: any) => sum + m.intensity, 0) / moodStats.data.length,
totalCheckins: moodStats.data.length,
} : { totalCheckins: 0 },
bodyMeasurements: {
weight: latestWeight?.weight,
latestChest: latestChest?.value,
latestWaist: latestWaist?.value,
},
challenges: {
activeChallengeCount: activeChallengeCount,
},
// 健康统计数据
healthStats: dailyHealth ? {
exerciseMinutes: dailyHealth.exerciseMinutes ?? undefined,
caloriesBurned: dailyHealth.caloriesBurned ?? undefined,
standingMinutes: dailyHealth.standingMinutes ?? undefined,
basalMetabolism: dailyHealth.basalMetabolism ?? undefined,
sleepMinutes: dailyHealth.sleepMinutes ?? undefined,
bloodOxygen: dailyHealth.bloodOxygen ?? undefined,
stressLevel: dailyHealth.stressLevel ?? undefined,
steps: dailyHealth.steps ?? undefined,
} : {},
};
}
/**
* 2. 生成固定格式的 prompt适用于 Nano Banana Pro 模型
* 优化:支持多语言,根据用户语言偏好生成对应的文本
* 包含健康统计数据:步数、睡眠、运动、卡路里消耗、血氧、压力等
*/
private async generateImagePrompt(data: DailyHealthData, language: string): Promise<string> {
const isEnglish = language.toLowerCase().startsWith('en');
// 格式化日期
const dateStr = isEnglish
? dayjs(data.date).format('MMM DD') // "Dec 01"
: dayjs(data.date).format('MM月DD日'); // "12月01日"
// 准备数据文本
const moodText = this.translateMood(data.mood.primaryMood, language);
const medRate = Math.round(data.medications.completionRate);
const calories = Math.round(data.diet.totalCalories);
const water = Math.round(data.water.totalAmount);
const challengeCount = data.challenges.activeChallengeCount;
// 健康统计数据
const { healthStats } = data;
const steps = healthStats.steps;
const sleepHours = healthStats.sleepMinutes ? Math.round(healthStats.sleepMinutes / 60 * 10) / 10 : undefined;
const exerciseMinutes = healthStats.exerciseMinutes;
const caloriesBurned = healthStats.caloriesBurned ? Math.round(healthStats.caloriesBurned) : undefined;
const bloodOxygen = healthStats.bloodOxygen ? Math.round(healthStats.bloodOxygen) : undefined;
const stressLevel = healthStats.stressLevel;
// 根据性别调整角色描述 - 优化版本,更具体的形象描述
let characterDesc: string;
let characterPose: string;
if (data.user?.gender === 'male') {
characterDesc = 'A cheerful young man with short hair, wearing a comfortable athletic t-shirt and shorts, fit and healthy looking';
characterPose = 'standing confidently with a thumbs up or stretching pose';
} else if (data.user?.gender === 'female') {
characterDesc = 'A cheerful young woman with ponytail hair, wearing a stylish yoga top and leggings, fit and energetic looking';
characterPose = 'doing a gentle yoga pose or stretching gracefully';
} else {
characterDesc = 'A cute friendly mascot character (like a happy cat or bunny) wearing a small fitness headband';
characterPose = 'jumping happily or giving a cheerful wave';
}
// 构建多语言文本内容
const textContent = isEnglish ? {
languageInstruction: 'Please render the following specific text in English correctly:',
title: `${dateStr} Health Report`,
medication: medRate > 0 ? `Medication: ${medRate}%` : null,
diet: calories > 0 ? `Calories In: ${calories} kcal` : null,
water: water > 0 ? `Water: ${water}ml` : null,
mood: `Mood: ${moodText}`,
challenges: challengeCount > 0 ? `Challenges: ${challengeCount}` : null,
// 健康统计
steps: steps !== undefined ? `Steps: ${steps.toLocaleString()}` : null,
sleep: sleepHours !== undefined ? `Sleep: ${sleepHours}h` : null,
exercise: exerciseMinutes !== undefined ? `Exercise: ${exerciseMinutes}min` : null,
caloriesBurned: caloriesBurned !== undefined ? `Burned: ${caloriesBurned} kcal` : null,
bloodOxygen: bloodOxygen !== undefined ? `SpO2: ${bloodOxygen}%` : null,
stress: stressLevel !== undefined ? `Stress: ${stressLevel}` : null,
} : {
languageInstruction: 'Please render the following specific text in Chinese correctly:',
title: `${dateStr} 健康日报`,
medication: medRate > 0 ? `用药: ${medRate}%` : null,
diet: calories > 0 ? `摄入: ${calories}千卡` : null,
water: water > 0 ? `饮水: ${water}ml` : null,
mood: `心情: ${moodText}`,
challenges: challengeCount > 0 ? `挑战: ${challengeCount}` : null,
// 健康统计
steps: steps !== undefined ? `步数: ${steps.toLocaleString()}` : null,
sleep: sleepHours !== undefined ? `睡眠: ${sleepHours}小时` : null,
exercise: exerciseMinutes !== undefined ? `运动: ${exerciseMinutes}分钟` : null,
caloriesBurned: caloriesBurned !== undefined ? `消耗: ${caloriesBurned}千卡` : null,
bloodOxygen: bloodOxygen !== undefined ? `血氧: ${bloodOxygen}%` : null,
stress: stressLevel !== undefined ? `压力: ${stressLevel}` : null,
};
// 构建文本部分 - 只包含有数据的项
const textSections: string[] = [];
textSections.push(`- Title text: "${textContent.title}"`);
if (textContent.medication) textSections.push(`- Medication section text: "${textContent.medication}"`);
if (textContent.diet) textSections.push(`- Diet section text: "${textContent.diet}"`);
if (textContent.water) textSections.push(`- Water section text: "${textContent.water}"`);
textSections.push(`- Mood section text: "${textContent.mood}"`);
if (textContent.challenges) textSections.push(`- Challenge section text: "${textContent.challenges}"`);
// 健康统计文本
if (textContent.steps) textSections.push(`- Steps section text: "${textContent.steps}"`);
if (textContent.sleep) textSections.push(`- Sleep section text: "${textContent.sleep}"`);
if (textContent.exercise) textSections.push(`- Exercise section text: "${textContent.exercise}"`);
if (textContent.caloriesBurned) textSections.push(`- Calories burned section text: "${textContent.caloriesBurned}"`);
if (textContent.bloodOxygen) textSections.push(`- Blood oxygen section text: "${textContent.bloodOxygen}"`);
if (textContent.stress) textSections.push(`- Stress section text: "${textContent.stress}"`);
// 构建图标部分 - 只包含有数据的项
const iconSections: string[] = [];
iconSections.push(`- ${characterDesc}, ${characterPose}, representing the user.`);
if (textContent.medication) iconSections.push('- Icon for medication (pill bottle or pills).');
if (textContent.diet) iconSections.push('- Icon for diet (healthy food bowl or apple).');
if (textContent.water) iconSections.push('- Icon for water (water glass or water drop).');
iconSections.push(`- Icon for mood (a ${moodText.toLowerCase()} face emoji).`);
if (textContent.challenges) iconSections.push(`- Icon for challenges (trophy or flag icon representing ${challengeCount} active ${challengeCount === 1 ? 'challenge' : 'challenges'}).`);
// 健康统计图标
if (textContent.steps) iconSections.push('- Icon for steps (footprints or walking figure).');
if (textContent.sleep) iconSections.push('- Icon for sleep (moon and stars or sleeping face).');
if (textContent.exercise) iconSections.push('- Icon for exercise (running figure or dumbbell).');
if (textContent.caloriesBurned) iconSections.push('- Icon for calories burned (flame or fire icon).');
if (textContent.bloodOxygen) iconSections.push('- Icon for blood oxygen (heart with pulse or O2 symbol).');
if (textContent.stress) iconSections.push('- Icon for stress level (brain or meditation icon).');
// 构建 Prompt
const prompt = `
A cute, hand-drawn style health journal page illustration, kawaii aesthetic, soft pastel colors (pink, mint, lavender, peach), warm lighting. Vertical 9:16 aspect ratio. High quality, 1k resolution.
The image features a cute organized layout with icons and text boxes arranged in a grid or card style.
${textContent.languageInstruction}
${textSections.join('\n')}
Visual elements:
${iconSections.join('\n')}
Composition: Clean, organized, magazine layout style with rounded corners on each section card, decorative stickers and washi tape effects, small sparkles and stars as decorations. The character should be prominently featured at the top or center of the design.
`.trim();
return prompt;
}
/**
* 根据语言翻译心情类型
*/
private translateMood(moodType?: string, language: string = 'zh-CN'): string {
const isEnglish = language.toLowerCase().startsWith('en');
if (isEnglish) {
const moodMapEn: Record<string, string> = {
HAPPY: 'Happy',
EXCITED: 'Excited',
THRILLED: 'Thrilled',
CALM: 'Calm',
ANXIOUS: 'Anxious',
SAD: 'Sad',
LONELY: 'Lonely',
WRONGED: 'Wronged',
ANGRY: 'Angry',
TIRED: 'Tired',
};
return moodMapEn[moodType || ''] || 'Calm';
}
const moodMapZh: Record<string, string> = {
HAPPY: '开心',
EXCITED: '兴奋',
THRILLED: '激动',
CALM: '平静',
ANXIOUS: '焦虑',
SAD: '难过',
LONELY: '孤独',
WRONGED: '委屈',
ANGRY: '生气',
TIRED: '心累',
};
return moodMapZh[moodType || ''] || '平静';
}
/**
* 获取用户语言偏好
*/
private async getUserLanguage(userId: string): Promise<string> {
try {
return await this.usersService.getUserLanguage(userId);
} catch (error) {
this.logger.error(`获取用户语言失败,使用默认中文: ${error instanceof Error ? error.message : String(error)}`);
return 'zh-CN';
}
}
/**
* 3. 调用图像生成 API支持 OpenRouter 和 GRSAI 两种提供商
* 通过环境变量 IMAGE_API_PROVIDER 配置:'openrouter' | 'grsai',默认 'openrouter'
*/
private async callImageGenerationApi(prompt: string): Promise<string> {
const apiProvider = this.configService.get<string>('IMAGE_API_PROVIDER') || 'openrouter';
this.logger.log(`准备调用 ${apiProvider} 生成图像`);
this.logger.log(`使用Prompt: ${prompt}`);
if (apiProvider === 'grsai') {
return this.callGrsaiImageApi(prompt);
} else {
return this.callOpenRouterImageApi(prompt);
}
}
/**
* 调用 OpenRouter API 生成图像
*/
private async callOpenRouterImageApi(prompt: string): Promise<string> {
const apiKey = this.configService.get<string>('OPENROUTER_API_KEY');
if (!apiKey) {
this.logger.error('OpenRouter API Key 未配置');
throw new Error('OpenRouter API Key 未配置');
}
try {
const client = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey,
});
this.logger.log(`使用 OpenRouter API, 模型: google/gemini-3-pro-image-preview`);
const apiResponse = await client.chat.completions.create({
model: 'google/gemini-3-pro-image-preview',
messages: [
{
role: 'user' as const,
content: prompt,
},
],
// @ts-ignore - 扩展参数,用于支持图像生成
modalities: ['image', 'text'],
});
const message = apiResponse.choices[0].message;
this.logger.log(`OpenRouter 收到响应: ${JSON.stringify(message, null, 2)}`);
const imageData = this.extractImageData(message);
if (imageData) {
return await this.processAndUploadImage(imageData);
}
this.logger.error('OpenRouter 响应中未包含图像数据');
this.logger.error(`实际响应内容: ${JSON.stringify(message).substring(0, 500)}`);
throw new Error('图像生成失败:响应中未包含图像数据');
} catch (error) {
this.logger.error(`调用 OpenRouter 失败: ${error.message}`);
throw error;
}
}
/**
* 调用 GRSAI API 生成图像(提交任务 + 轮询结果)
* API 文档:
* - 提交任务: POST https://grsai.dakka.com.cn/v1/draw/nano-banana
* - 查询结果: POST https://grsai.dakka.com.cn/v1/draw/result
*/
private async callGrsaiImageApi(prompt: string): Promise<string> {
const apiKey = this.configService.get<string>('GRSAI_API_KEY');
if (!apiKey) {
this.logger.error('GRSAI API Key 未配置');
throw new Error('GRSAI API Key 未配置');
}
const baseURL = 'https://grsai.dakka.com.cn';
const axios = require('axios');
// 通用请求头
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
};
try {
// Step 1: 提交图像生成任务
this.logger.log('GRSAI: 提交图像生成任务...');
const submitResponse = await axios.post(
`${baseURL}/v1/draw/nano-banana`,
{
model: 'nano-banana-pro',
prompt: prompt,
webHook: '-1', // 必填 -1表示不使用 webhook
},
{ headers, timeout: 30000 },
);
// 检查提交响应
if (submitResponse.data.code !== 0) {
this.logger.error(`GRSAI 任务提交失败: ${submitResponse.data.msg}`);
throw new Error(`GRSAI 任务提交失败: ${submitResponse.data.msg}`);
}
const taskId = submitResponse.data.data?.id;
if (!taskId) {
this.logger.error('GRSAI 任务提交成功但未返回任务ID');
throw new Error('GRSAI 任务提交失败未返回任务ID');
}
this.logger.log(`GRSAI: 任务提交成功任务ID: ${taskId}`);
// Step 2: 轮询获取结果
const imageUrl = await this.pollGrsaiResult(baseURL, headers, taskId);
// Step 3: 下载图像并上传到 COS
return await this.processAndUploadImage(imageUrl);
} catch (error) {
this.logger.error(`调用 GRSAI 失败: ${error.message}`);
throw error;
}
}
/**
* 轮询 GRSAI 任务结果
* @param baseURL API 基础地址
* @param headers 请求头
* @param taskId 任务ID
* @returns 生成的图像 URL
*/
private async pollGrsaiResult(
baseURL: string,
headers: Record<string, string>,
taskId: string,
): Promise<string> {
const axios = require('axios');
// 轮询配置
const maxAttempts = 60; // 最大尝试次数
const pollInterval = 2000; // 轮询间隔 2 秒
const maxWaitTime = 120000; // 最大等待时间 2 分钟
const startTime = Date.now();
let attempts = 0;
while (attempts < maxAttempts) {
attempts++;
const elapsedTime = Date.now() - startTime;
// 检查是否超时
if (elapsedTime > maxWaitTime) {
this.logger.error(`GRSAI: 轮询超时,已等待 ${elapsedTime / 1000}`);
throw new Error('GRSAI 图像生成超时');
}
this.logger.log(`GRSAI: 第 ${attempts} 次轮询任务ID: ${taskId}`);
try {
const resultResponse = await axios.post(
`${baseURL}/v1/draw/result`,
{ id: taskId },
{ headers, timeout: 10000 },
);
const data = resultResponse.data;
// 检查响应码
if (data.code !== 0) {
this.logger.error(`GRSAI 查询失败: ${data.msg}`);
throw new Error(`GRSAI 查询失败: ${data.msg}`);
}
const taskData = data.data;
const status = taskData?.status;
const progress = taskData?.progress || 0;
this.logger.log(`GRSAI: 任务状态: ${status}, 进度: ${progress}%`);
// 根据状态处理
switch (status) {
case 'succeeded':
// 任务成功,提取图像 URL
const results = taskData.results;
if (results && results.length > 0 && results[0].url) {
const imageUrl = results[0].url;
this.logger.log(`GRSAI: 图像生成成功: ${imageUrl}`);
return imageUrl;
}
throw new Error('GRSAI 任务成功但未返回图像URL');
case 'failed':
// 任务失败
const failureReason = taskData.failure_reason || taskData.error || '未知错误';
this.logger.error(`GRSAI: 任务失败: ${failureReason}`);
throw new Error(`GRSAI 图像生成失败: ${failureReason}`);
case 'pending':
case 'processing':
case 'running':
// 任务进行中,继续轮询
await this.sleep(pollInterval);
break;
default:
// 未知状态,继续轮询
this.logger.warn(`GRSAI: 未知任务状态: ${status}`);
await this.sleep(pollInterval);
}
} catch (error) {
// 如果是网络错误,继续重试
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
this.logger.warn(`GRSAI: 轮询请求超时,继续重试...`);
await this.sleep(pollInterval);
continue;
}
throw error;
}
}
throw new Error(`GRSAI 图像生成失败:超过最大轮询次数 ${maxAttempts}`);
}
/**
* 延时函数
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 从 API 响应中提取图像数据
*/
private extractImageData(message: any): string | undefined {
let imageData: string | undefined;
// 检查 images 数组OpenRouter/GRSAI 特有的响应格式)
if (message.images && Array.isArray(message.images) && message.images.length > 0) {
const firstImage = message.images[0];
if (firstImage.image_url?.url) {
imageData = firstImage.image_url.url;
this.logger.log(`检测到 images 数组中的图像数据`);
}
}
// 如果 images 数组中没有,检查 content 字段
if (!imageData && typeof message.content === 'string') {
// 检查是否为 base64 数据
if (message.content.startsWith('data:image/')) {
imageData = message.content;
this.logger.log('检测到 Data URL 格式的 base64 图像');
} else if (/^[A-Za-z0-9+/=]+$/.test(message.content.substring(0, 100))) {
imageData = message.content;
this.logger.log('检测到纯 base64 格式的图像数据');
} else {
// 尝试提取 HTTP URL
const urlMatch = message.content.match(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp)/i);
if (urlMatch) {
imageData = urlMatch[0];
this.logger.log(`检测到 HTTP URL: ${imageData}`);
}
}
}
return imageData;
}
/**
* 处理图像数据并上传到 COS
*/
private async processAndUploadImage(imageData: string): Promise<string> {
// 判断是 base64 还是 URL
const isBase64 = imageData.startsWith('data:image/') ||
(/^[A-Za-z0-9+/=]+$/.test(imageData.substring(0, 100)) && !imageData.startsWith('http'));
if (isBase64) {
// 处理 base64 数据并上传到 COS
this.logger.log('开始处理 base64 图像数据');
const cosImageUrl = await this.uploadBase64ToCos(imageData);
this.logger.log(`Base64 图像上传到 COS 成功: ${cosImageUrl}`);
return cosImageUrl;
} else {
// 下载 HTTP URL 图像并上传到 COS
this.logger.log(`API 返回图像 URL: ${imageData}`);
const cosImageUrl = await this.downloadAndUploadToCos(imageData);
this.logger.log(`图像上传到 COS 成功: ${cosImageUrl}`);
return cosImageUrl;
}
}
/**
* 将 base64 图像数据上传到 COS
*/
private async uploadBase64ToCos(base64Data: string): Promise<string> {
try {
this.logger.log('开始处理 base64 图像数据');
let base64String = base64Data;
let mimeType = 'image/png'; // 默认类型
// 如果是 data URL 格式,提取 MIME 类型和纯 base64 数据
if (base64Data.startsWith('data:')) {
const matches = base64Data.match(/^data:([^;]+);base64,(.+)$/);
if (matches) {
mimeType = matches[1];
base64String = matches[2];
this.logger.log(`检测到 MIME 类型: ${mimeType}`);
}
}
// 将 base64 转换为 Buffer
const imageBuffer = Buffer.from(base64String, 'base64');
this.logger.log(`Base64 数据转换成功,大小: ${imageBuffer.length} bytes`);
// 根据 MIME 类型确定文件扩展名
const ext = mimeType.split('/')[1] || 'png';
const fileName = `ai-health-report-${Date.now()}.${ext}`;
// 使用 CosService 的 uploadBuffer 方法上传
const uploadResult = await this.cosService.uploadBuffer('ai-report', imageBuffer, fileName, mimeType);
this.logger.log(`Base64 图像上传成功: ${uploadResult.fileUrl}`);
return uploadResult.fileUrl;
} catch (error) {
this.logger.error(`上传 base64 图像到 COS 失败: ${error.message}`);
throw new Error(`Base64 图像处理失败: ${error.message}`);
}
}
/**
* 下载图像并上传到 COS
*/
private async downloadAndUploadToCos(imageUrl: string): Promise<string> {
try {
this.logger.log(`开始下载图像: ${imageUrl}`);
// 下载图像
const axios = require('axios');
const response = await axios.get(imageUrl, {
responseType: 'arraybuffer',
timeout: 30000, // 30秒超时
});
// 检查响应状态
if (response.status !== 200) {
throw new Error(`图像下载失败HTTP状态码: ${response.status}`);
}
// 检查响应数据
if (!response.data) {
throw new Error('图像下载失败:响应数据为空');
}
this.logger.log(`图像下载成功,大小: ${response.data.length} bytes`);
// 创建模拟文件对象用于上传
const imageBuffer = Buffer.from(response.data);
const fileName = `ai-health-report-${Date.now()}.png`;
// 检测 MIME 类型
let mimeType = 'image/png';
if (response.headers['content-type']) {
mimeType = response.headers['content-type'];
}
// 使用 CosService 的 uploadBuffer 方法上传
const uploadResult = await this.cosService.uploadBuffer('ai-report', imageBuffer, fileName, mimeType);
return uploadResult.fileUrl;
} catch (error) {
this.logger.error(`下载或上传图像到COS失败: ${error.message}`);
throw new Error(`图像处理失败: ${error.message}`);
}
}
}

View File

@@ -1,10 +1,57 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OpenAI } from 'openai';
import { DietRecordsService } from '../../diet-records/diet-records.service';
import { CreateDietRecordDto } from '../../users/dto/diet-record.dto';
import { MealType, DietRecordSource } from '../../users/models/user-diet-history.model';
const MESSAGES = {
'zh-CN': {
recognitionFailed: '食物识别失败,请稍后重试',
serviceUnavailable: '服务暂时不可用,请稍后重试',
analysisFailed: '图片分析失败,请稍后重试',
textAnalysisFailed: '文本饮食分析失败,请稍后重试',
textFoodAnalysisFailed: '文本食物分析失败,请稍后重试',
noFoodInText: '未能从文本中识别到具体食物信息',
provideMoreDetails: '请描述更具体的食物信息,如"吃了一碗米饭"、"喝了一杯牛奶"等',
basedOnDescription: (text: string) => `基于您的描述"${text}",识别出以下食物`,
textAnalysisParseFailed: '文本分析失败:无法解析识别结果',
textAnalysisFailedRetry: '文本分析失败,请重新描述您吃的食物',
recognizedCount: (text: string, count: number) => `基于您的描述"${text}",识别出 ${count} 种食物`,
imageAnalysisFailed: '图片分析失败:无法解析识别结果',
uploadImageRetry: '图片分析失败,请重新上传图片',
noFoodDetected: '图片中未检测到食物',
imageBlurred: '图片模糊,无法准确识别食物',
foodRecognized: '已识别图片中的食物',
uploadFoodImage: '图片中未检测到食物,请上传包含食物的图片',
parseError: '图片分析失败:无法解析分析结果',
noAnalysisDesc: '未提供分析说明',
unknownFood: '未知食物'
},
'en-US': {
recognitionFailed: 'Food recognition failed, please try again later',
serviceUnavailable: 'Service temporarily unavailable, please try again later',
analysisFailed: 'Image analysis failed, please try again later',
textAnalysisFailed: 'Text diet analysis failed, please try again later',
textFoodAnalysisFailed: 'Text food analysis failed, please try again later',
noFoodInText: 'No specific food information identified from the text',
provideMoreDetails: 'Please describe more specific food information, e.g., "ate a bowl of rice", "drank a glass of milk"',
basedOnDescription: (text: string) => `Based on your description "${text}", the following foods were identified`,
textAnalysisParseFailed: 'Text analysis failed: Unable to parse recognition result',
textAnalysisFailedRetry: 'Text analysis failed, please describe your food again',
recognizedCount: (text: string, count: number) => `Based on your description "${text}", ${count} foods were identified`,
imageAnalysisFailed: 'Image analysis failed: Unable to parse recognition result',
uploadImageRetry: 'Image analysis failed, please upload image again',
noFoodDetected: 'No food detected in the image',
imageBlurred: 'Image is blurred, unable to accurately recognize food',
foodRecognized: 'Food recognized in the image',
uploadFoodImage: 'No food detected in the image, please upload an image containing food',
parseError: 'Image analysis failed: Unable to parse analysis result',
noAnalysisDesc: 'No analysis description provided',
unknownFood: 'Unknown Food'
}
};
/**
* 饮食分析结果接口
*/
@@ -72,6 +119,7 @@ export class DietAnalysisService {
constructor(
private readonly configService: ConfigService,
@Inject(forwardRef(() => DietRecordsService))
private readonly dietRecordsService: DietRecordsService,
) {
// Support both GLM-4.5V and DashScope (Qwen) models
@@ -88,7 +136,7 @@ export class DietAnalysisService {
});
this.model = this.configService.get<string>('GLM_MODEL') || 'glm-4-flash';
this.visionModel = this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4v-plus';
this.visionModel = 'glm-4v-flash'
} else {
// DashScope Configuration (default)
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
@@ -178,29 +226,31 @@ export class DietAnalysisService {
/**
* 食物识别用于用户确认 - 新的确认流程
* @param imageUrls 图片URL数组
* @param language 语言代码,默认 zh-CN
* @returns 食物识别确认结果
*/
async recognizeFoodForConfirmation(imageUrls: string[]): Promise<FoodRecognitionResult> {
async recognizeFoodForConfirmation(imageUrls: string[], language: string = 'zh-CN'): Promise<FoodRecognitionResult> {
try {
const currentHour = new Date().getHours();
const suggestedMealType = this.getSuggestedMealType(currentHour);
const prompt = this.buildFoodRecognitionPrompt(suggestedMealType);
const prompt = this.buildFoodRecognitionPrompt(suggestedMealType, language);
const completion = await this.makeVisionApiCall(prompt, imageUrls);
const rawResult = completion.choices?.[0]?.message?.content || '{}';
this.logger.log(`Food recognition result: ${rawResult}`);
return this.parseRecognitionResult(rawResult, suggestedMealType);
return this.parseRecognitionResult(rawResult, suggestedMealType, language);
} catch (error) {
this.logger.error(`食物识别失败: ${error instanceof Error ? error.message : String(error)}`);
const msgs = this.getMessages(language);
return {
items: [],
analysisText: '食物识别失败,请稍后重试',
analysisText: msgs.recognitionFailed,
confidence: 0,
isFoodDetected: false,
nonFoodMessage: '服务暂时不可用,请稍后重试'
nonFoodMessage: msgs.serviceUnavailable
};
}
}
@@ -264,9 +314,10 @@ export class DietAnalysisService {
/**
* 分析文本中的食物用于用户确认 - 与图片识别接口保持数据结构一致
* @param userText 用户输入的文本描述
* @param language 语言代码,默认 zh-CN
* @returns 食物识别确认结果
*/
async analyzeTextFoodForConfirmation(userText: string): Promise<FoodRecognitionResult> {
async analyzeTextFoodForConfirmation(userText: string, language: string = 'zh-CN'): Promise<FoodRecognitionResult> {
try {
this.logger.log(`Text food analysis request: ${userText}`);
@@ -274,22 +325,23 @@ export class DietAnalysisService {
const suggestedMealType = this.getSuggestedMealType(currentHour);
// 使用专门的多食物文本分析 prompt
const prompt = this.buildMultiFoodTextAnalysisPrompt(suggestedMealType);
const prompt = this.buildMultiFoodTextAnalysisPrompt(suggestedMealType, language);
const completion = await this.makeTextApiCall(prompt, userText);
const rawResult = completion.choices?.[0]?.message?.content || '{}';
this.logger.log(`Multi-food text analysis result: ${rawResult}`);
// 直接解析为多食物结构
return this.parseMultiFoodTextResult(rawResult, suggestedMealType, userText);
return this.parseMultiFoodTextResult(rawResult, suggestedMealType, userText, language);
} catch (error) {
this.logger.error(`文本食物分析失败: ${error instanceof Error ? error.message : String(error)}`);
const msgs = this.getMessages(language);
return {
items: [],
analysisText: '文本食物分析失败,请稍后重试',
analysisText: msgs.textFoodAnalysisFailed,
confidence: 0,
isFoodDetected: false,
nonFoodMessage: '服务暂时不可用,请稍后重试'
nonFoodMessage: msgs.serviceUnavailable
};
}
}
@@ -461,9 +513,10 @@ export class DietAnalysisService {
/**
* 构建食物识别提示(用于确认流程)
* @param suggestedMealType 建议的餐次类型
* @param language 语言代码
* @returns 提示文本
*/
private buildFoodRecognitionPrompt(suggestedMealType: MealType): string {
private buildFoodRecognitionPrompt(suggestedMealType: MealType, language: string): string {
return `作为专业营养分析师,请分析这张图片并判断是否包含食物。
当前时间建议餐次:${suggestedMealType}
@@ -475,17 +528,17 @@ export class DietAnalysisService {
返回以下格式的JSON
{
"confidence": number, // 整体识别置信度 0-100
"analysisText": string, // 简短的识别说明文字
"analysisText": string, // 简短的识别说明文字,请使用${language}语言
"isFoodDetected": boolean, // 是否检测到食物
"nonFoodMessage": string, // 当isFoodDetected为false时的提示信息
"nonFoodMessage": string, // 当isFoodDetected为false时的提示信息,请使用${language}语言
"recognizedItems": [ // 识别的食物列表(如果是食物才有内容)
{
"id": string, // 唯一标识符
"foodName": string, // 食物名称
"portion": string, // 份量描述(如"1碗"、"150g"等)
"foodName": string, // 食物名称,请使用${language}语言
"portion": string, // 份量描述,请使用${language}语言(如"1碗"、"150g"等)
"calories": number, // 估算热量
"mealType": "${suggestedMealType}", // 餐次类型
"label": string, // 显示给用户的完整选项文本(如"一条鱼 200卡"
"label": string, // 显示给用户的完整选项文本,请使用${language}语言(如"一条鱼 200卡"
"nutritionData": {
"proteinGrams": number, // 蛋白质
"carbohydrateGrams": number, // 碳水化合物
@@ -515,7 +568,11 @@ export class DietAnalysisService {
3. **模糊情况:**
- 如果图片模糊但能看出是食物相关,设置 isFoodDetected: true但返回空的recognizedItems数组
- analysisText 说明"图片模糊,无法准确识别食物"`;
- analysisText 说明"图片模糊,无法准确识别食物"
**重要提示:**
请使用 ${language} 语言返回所有文本内容包括label, analysisText, nonFoodMessage, foodName, portion等
Please respond in ${language}.`;
}
/**
@@ -605,9 +662,10 @@ export class DietAnalysisService {
/**
* 构建多食物文本分析提示 - 支持识别多种食物
* @param suggestedMealType 建议的餐次类型
* @param language 语言代码
* @returns 提示文本
*/
private buildMultiFoodTextAnalysisPrompt(suggestedMealType: MealType): string {
private buildMultiFoodTextAnalysisPrompt(suggestedMealType: MealType, language: string): string {
return `作为专业营养分析师,请分析用户描述的饮食内容,支持识别多种食物。
当前时间建议餐次:${suggestedMealType}
@@ -615,17 +673,17 @@ export class DietAnalysisService {
请返回以下格式的JSON不要包含其他文本
{
"confidence": number, // 整体识别置信度 0-100
"analysisText": string, // 简短的识别说明文字
"analysisText": string, // 简短的识别说明文字,请使用${language}语言
"isFoodDetected": boolean, // 是否检测到食物
"nonFoodMessage": string, // 当isFoodDetected为false时的提示信息
"nonFoodMessage": string, // 当isFoodDetected为false时的提示信息,请使用${language}语言
"recognizedItems": [ // 识别的食物列表
{
"id": string, // 唯一标识符(使用 food_1, food_2 等)
"foodName": string, // 食物名称(简洁)
"portion": string, // 份量描述(如"1碗"、"1份"等)
"foodName": string, // 食物名称(简洁),请使用${language}语言
"portion": string, // 份量描述(如"1碗"、"1份"等),请使用${language}语言
"calories": number, // 估算热量
"mealType": "${suggestedMealType}", // 餐次类型
"label": string, // 显示给用户的完整选项文本(如"一碗米饭 280卡"
"label": string, // 显示给用户的完整选项文本(如"一碗米饭 280卡",请使用${language}语言
"nutritionData": {
"proteinGrams": number, // 蛋白质
"carbohydrateGrams": number, // 碳水化合物
@@ -660,7 +718,11 @@ export class DietAnalysisService {
- "今天中午吃了一碗米饭,一份麻辣香锅" → 识别为2个选项
- "早餐吃了燕麦粥加香蕉和牛奶" → 可识别为1个复合选项或3个独立选项
- "晚上吃了牛肉面" → 识别为1个选项面条+牛肉的复合菜品)
- "喝了水" → isFoodDetected: false水不是营养食物`;
- "喝了水" → isFoodDetected: false水不是营养食物
**重要提示:**
请使用 ${language} 语言返回所有文本内容包括label, analysisText, nonFoodMessage, foodName, portion等
Please respond in ${language}.`;
}
/**
@@ -716,9 +778,11 @@ export class DietAnalysisService {
* @param rawResult 原始结果字符串
* @param suggestedMealType 建议的餐次类型
* @param originalText 原始用户文本
* @param language 语言代码
* @returns 解析后的识别结果
*/
private parseMultiFoodTextResult(rawResult: string, suggestedMealType: MealType, originalText: string): FoodRecognitionResult {
private parseMultiFoodTextResult(rawResult: string, suggestedMealType: MealType, originalText: string, language: string): FoodRecognitionResult {
const msgs = this.getMessages(language);
let parsedResult: any;
try {
parsedResult = JSON.parse(rawResult);
@@ -726,10 +790,10 @@ export class DietAnalysisService {
this.logger.error(`多食物文本分析JSON解析失败: ${parseError}`);
return {
items: [],
analysisText: '文本分析失败:无法解析识别结果',
analysisText: msgs.textAnalysisParseFailed,
confidence: 0,
isFoodDetected: false,
nonFoodMessage: '文本分析失败,请重新描述您吃的食物'
nonFoodMessage: msgs.textAnalysisFailedRetry
};
}
@@ -764,11 +828,11 @@ export class DietAnalysisService {
// 根据是否识别到食物设置不同的分析文本
let analysisText = parsedResult.analysisText || '';
if (!isFoodDetected) {
analysisText = analysisText || '文本中未检测到具体食物信息';
analysisText = analysisText || msgs.noFoodInText;
} else if (recognizedItems.length === 0) {
analysisText = analysisText || '无法准确解析食物信息';
analysisText = analysisText || msgs.noFoodInText;
} else {
analysisText = analysisText || `基于您的描述"${originalText}",识别出 ${recognizedItems.length} 种食物`;
analysisText = analysisText || msgs.recognizedCount(originalText, recognizedItems.length);
}
return {
@@ -776,7 +840,7 @@ export class DietAnalysisService {
analysisText,
confidence: Math.min(100, Math.max(0, parsedResult.confidence || 0)),
isFoodDetected,
nonFoodMessage: !isFoodDetected ? (nonFoodMessage || '请描述更具体的食物信息,如"吃了一碗米饭"、"喝了一杯牛奶"等') : undefined
nonFoodMessage: !isFoodDetected ? (nonFoodMessage || msgs.provideMoreDetails) : undefined
};
}
@@ -784,9 +848,11 @@ export class DietAnalysisService {
* 解析食物识别结果
* @param rawResult 原始结果字符串
* @param suggestedMealType 建议的餐次类型
* @param language 语言代码
* @returns 解析后的识别结果
*/
private parseRecognitionResult(rawResult: string, suggestedMealType: MealType): FoodRecognitionResult {
private parseRecognitionResult(rawResult: string, suggestedMealType: MealType, language: string): FoodRecognitionResult {
const msgs = this.getMessages(language);
let parsedResult: any;
try {
parsedResult = JSON.parse(rawResult);
@@ -794,10 +860,10 @@ export class DietAnalysisService {
this.logger.error(`食物识别JSON解析失败: ${parseError}`);
return {
items: [],
analysisText: '图片分析失败:无法解析识别结果',
analysisText: msgs.imageAnalysisFailed,
confidence: 0,
isFoodDetected: false,
nonFoodMessage: '图片分析失败,请重新上传图片'
nonFoodMessage: msgs.uploadImageRetry
};
}
@@ -832,11 +898,11 @@ export class DietAnalysisService {
// 根据是否识别到食物设置不同的分析文本
let analysisText = parsedResult.analysisText || '';
if (!isFoodDetected) {
analysisText = analysisText || '图片中未检测到食物';
analysisText = analysisText || msgs.noFoodDetected;
} else if (recognizedItems.length === 0) {
analysisText = analysisText || '图片模糊,无法准确识别食物';
analysisText = analysisText || msgs.imageBlurred;
} else {
analysisText = analysisText || '已识别图片中的食物';
analysisText = analysisText || msgs.foodRecognized;
}
return {
@@ -844,7 +910,7 @@ export class DietAnalysisService {
analysisText,
confidence: Math.min(100, Math.max(0, parsedResult.confidence || 0)),
isFoodDetected,
nonFoodMessage: !isFoodDetected ? (nonFoodMessage || '图片中未检测到食物,请上传包含食物的图片') : undefined
nonFoodMessage: !isFoodDetected ? (nonFoodMessage || msgs.uploadFoodImage) : undefined
};
}
@@ -1025,4 +1091,15 @@ export class DietAnalysisService {
if (isNaN(num)) return undefined;
return Math.max(min, Math.min(max, num));
}
/**
* 获取多语言消息
*/
private getMessages(language: string) {
let langCode = 'zh-CN';
if (language.toLowerCase().startsWith('en')) {
langCode = 'en-US';
}
return MESSAGES[langCode] || MESSAGES['zh-CN'];
}
}

View File

@@ -1,10 +1,14 @@
import { Module } from "@nestjs/common";
import { APP_GUARD } from '@nestjs/core';
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { DatabaseModule } from "./database/database.module";
import { UsersModule } from "./users/users.module";
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { LoggerModule } from './common/logger/logger.module';
import { RedisModule, ThrottlerStorageRedisService } from './redis';
import { CheckinsModule } from './checkins/checkins.module';
import { AiCoachModule } from './ai-coach/ai-coach.module';
import { TrainingPlansModule } from './training-plans/training-plans.module';
@@ -12,7 +16,6 @@ import { ArticlesModule } from './articles/articles.module';
import { RecommendationsModule } from './recommendations/recommendations.module';
import { ActivityLogsModule } from './activity-logs/activity-logs.module';
import { ExercisesModule } from './exercises/exercises.module';
import { WorkoutsModule } from './workouts/workouts.module';
import { MoodCheckinsModule } from './mood-checkins/mood-checkins.module';
import { GoalsModule } from './goals/goals.module';
import { DietRecordsModule } from './diet-records/diet-records.module';
@@ -20,6 +23,9 @@ import { FoodLibraryModule } from './food-library/food-library.module';
import { WaterRecordsModule } from './water-records/water-records.module';
import { ChallengesModule } from './challenges/challenges.module';
import { PushNotificationsModule } from './push-notifications/push-notifications.module';
import { MedicationsModule } from './medications/medications.module';
import { HealthProfilesModule } from './health-profiles/health-profiles.module';
import { ExpoUpdatesModule } from './expo-updates/expo-updates.module';
@Module({
imports: [
@@ -27,6 +33,19 @@ import { PushNotificationsModule } from './push-notifications/push-notifications
isGlobal: true,
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
// 限流模块必须在 RedisModule 之后导入,以确保 Redis 连接可用
RedisModule,
ThrottlerModule.forRootAsync({
useFactory: (throttlerStorage: ThrottlerStorageRedisService) => ({
throttlers: [{
ttl: 60000, // 时间窗口60秒
limit: 100, // 每个时间窗口最多100个请求
}],
storage: throttlerStorage,
}),
inject: [ThrottlerStorageRedisService],
}),
LoggerModule,
DatabaseModule,
UsersModule,
@@ -37,7 +56,6 @@ import { PushNotificationsModule } from './push-notifications/push-notifications
RecommendationsModule,
ActivityLogsModule,
ExercisesModule,
WorkoutsModule,
MoodCheckinsModule,
GoalsModule,
DietRecordsModule,
@@ -45,8 +63,17 @@ import { PushNotificationsModule } from './push-notifications/push-notifications
WaterRecordsModule,
ChallengesModule,
PushNotificationsModule,
MedicationsModule,
HealthProfilesModule,
ExpoUpdatesModule,
],
controllers: [AppController],
providers: [AppService],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule { }

View File

@@ -1,3 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
export enum ResponseCode {
SUCCESS = 0,
ERROR = 1,
@@ -8,3 +10,38 @@ export interface BaseResponseDto<T> {
message: string;
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 { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
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 { ChallengeProgressDto } from './dto/challenge-progress.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 { ChallengeState } from './models/challenge.model';
@ApiTags('挑战管理')
@Controller('challenges')
export class ChallengesController {
constructor(private readonly challengesService: ChallengesService) { }
@@ -31,6 +38,7 @@ export class ChallengesController {
@Get(':id')
@Public()
@UseGuards(JwtAuthGuard)
async getChallengeDetail(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,
@@ -104,4 +112,130 @@ export class ChallengesController {
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

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { ChallengesController } from './challenges.controller';
import { ChallengesService } from './challenges.service';
@@ -7,11 +7,12 @@ import { ChallengeParticipant } from './models/challenge-participant.model';
import { ChallengeProgressReport } from './models/challenge-progress-report.model';
import { UsersModule } from '../users/users.module';
import { User } from '../users/models/user.model';
import { BadgeConfig } from '../users/models/badge-config.model';
@Module({
imports: [
SequelizeModule.forFeature([Challenge, ChallengeParticipant, ChallengeProgressReport, User]),
UsersModule,
SequelizeModule.forFeature([Challenge, ChallengeParticipant, ChallengeProgressReport, User, BadgeConfig]),
forwardRef(() => UsersModule),
],
controllers: [ChallengesController],
providers: [ChallengesService],

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 { 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 { ChallengeProgressReport } from './models/challenge-progress-report.model';
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 { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.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 * as dayjs from 'dayjs';
import { User } from '../users/models/user.model';
import { WINSTON_MODULE_PROVIDER } from 'nest-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()
export class ChallengesService {
@@ -26,10 +32,36 @@ export class ChallengesService {
private readonly participantModel: typeof ChallengeParticipant,
@InjectModel(ChallengeProgressReport)
private readonly progressReportModel: typeof ChallengeProgressReport,
@InjectModel(BadgeConfig)
private readonly badgeConfigModel: typeof BadgeConfig,
private readonly badgeService: BadgeService,
) { }
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, challengeState: { [Op.ne]: ChallengeState.ARCHIVED } }, // 我创建的
{
id: { [Op.in]: joinedChallengeIds },
source: ChallengeSource.CUSTOM,
challengeState: ChallengeState.ACTIVE
} // 我加入的
);
}
const challenges = await this.challengeModel.findAll({
where: {
[Op.or]: whereConditions,
challengeState: { [Op.ne]: ChallengeState.ARCHIVED }, // 过滤掉已归档的挑战
},
order: [['startAt', 'ASC']],
});
@@ -50,7 +82,7 @@ export class ChallengesService {
challenge,
status: this.computeStatus(challenge.startAt, challenge.endAt),
}))
.filter(({ status }) => status !== ChallengeStatus.UPCOMING)
.filter(({ status }) => status === ChallengeStatus.ONGOING)
.sort((a, b) => {
const priorityDiff = statusPriority[a.status] - statusPriority[b.status];
if (priorityDiff !== 0) {
@@ -100,6 +132,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 }) => {
const completionTarget = challenge.minimumCheckInDays
const participation = participationMap.get(challenge.id);
@@ -113,11 +164,11 @@ export class ChallengesService {
image: challenge.image,
periodLabel: challenge.periodLabel,
durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel,
requirementLabel: challenge.requirementLabel || '',
status,
unit: challenge.progressUnit,
startAt: challenge.startAt,
endAt: challenge.endAt,
startAt: new Date(challenge.startAt).getTime(),
endAt: new Date(challenge.endAt).getTime(),
participantsCount: participantsCountMap.get(challenge.id) ?? 0,
rankingDescription: challenge.rankingDescription,
highlightTitle: challenge.highlightTitle,
@@ -127,6 +178,10 @@ export class ChallengesService {
progress,
isJoined: Boolean(participation),
type: challenge.type,
badge: challenge.type === ChallengeType.SLEEP ? sleepBadge : undefined,
source: challenge.source,
shareCode: challenge.shareCode,
isCreator: userId ? challenge.creatorId === userId : false,
};
});
}
@@ -138,6 +193,10 @@ export class ChallengesService {
throw new NotFoundException('挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('挑战不存在');
}
this.winstonLogger.info('start get detail', {
context: 'getChallengeDetail',
userId,
@@ -195,13 +254,31 @@ export class ChallengesService {
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 {
id: challenge.id,
title: challenge.title,
image: challenge.image,
periodLabel: challenge.periodLabel,
durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel,
requirementLabel: challenge.requirementLabel || '',
summary: challenge.summary,
rankingDescription: challenge.rankingDescription,
highlightTitle: challenge.highlightTitle,
@@ -214,6 +291,11 @@ export class ChallengesService {
userRank,
unit: challenge.progressUnit,
type: challenge.type,
badge,
creatorId: challenge.creatorId,
shareCode: challenge.shareCode,
source: challenge.source,
isCreator: userId ? challenge.creatorId === userId : false,
};
}
@@ -227,6 +309,10 @@ export class ChallengesService {
throw new NotFoundException('挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('挑战不存在');
}
const { userId } = params;
const page = params.page && params.page > 0 ? params.page : 1;
const requestedPageSize = params.pageSize && params.pageSize > 0 ? params.pageSize : 20;
@@ -267,6 +353,10 @@ export class ChallengesService {
throw new NotFoundException('挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('挑战不存在');
}
const status = this.computeStatus(challenge.startAt, challenge.endAt);
if (status === ChallengeStatus.EXPIRED) {
throw new BadRequestException('挑战已过期,无法加入');
@@ -317,6 +407,15 @@ export class ChallengesService {
}
async leaveChallenge(userId: string, challengeId: string): Promise<boolean> {
// 先检查挑战是否存在且未归档
const challenge = await this.challengeModel.findByPk(challengeId);
if (!challenge) {
throw new NotFoundException('挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('挑战不存在');
}
const participant = await this.participantModel.findOne({
where: {
challengeId,
@@ -346,6 +445,10 @@ export class ChallengesService {
throw new NotFoundException('挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('挑战不存在');
}
const status = this.computeStatus(challenge.startAt, challenge.endAt);
if (status === ChallengeStatus.UPCOMING) {
throw new BadRequestException('挑战尚未开始,无法上报进度');
@@ -428,6 +531,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')) {
participant.progressValue++
@@ -436,6 +580,38 @@ export class ChallengesService {
if (participant.progressValue >= (participant.challenge?.minimumCheckInDays || 0) && 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();
@@ -474,7 +650,7 @@ export class ChallengesService {
}
private computeStatus(startAt: number, endAt: number): ChallengeStatus {
private computeStatus(startAt: Date | number, endAt: Date | number): ChallengeStatus {
const now = dayjs();
const start = dayjs(startAt);
const end = dayjs(endAt);
@@ -577,4 +753,507 @@ export class ChallengesService {
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;
}
/**
* 获取用户当前参与的活跃挑战数量(正在进行中且未过期)
* @param userId 用户ID
* @returns 活跃挑战数量
*/
async getActiveParticipatingChallengeCount(userId: string): Promise<number> {
const now = new Date();
// 查询用户参与的所有活跃状态的挑战
const participants = await this.participantModel.findAll({
where: {
userId,
status: {
[Op.in]: [ChallengeParticipantStatus.ACTIVE, ChallengeParticipantStatus.COMPLETED],
},
},
include: [{
model: Challenge,
as: 'challenge',
where: {
challengeState: ChallengeState.ACTIVE,
startAt: { [Op.lte]: now },
endAt: { [Op.gte]: now },
},
required: true,
}],
});
return participants.length;
}
/**
* 获取用户加入的自定义挑战 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 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('无效的时间戳');
}
// 获取用户信息,检查会员状态
const user = await User.findByPk(userId);
if (!user) {
throw new NotFoundException('用户不存在');
}
// 检查非会员用户已创建的未归档挑战数量
if (!user.isVip) {
const existingChallengeCount = await this.challengeModel.count({
where: {
creatorId: userId,
source: ChallengeSource.CUSTOM,
challengeState: {
[Op.ne]: ChallengeState.ARCHIVED, // 不包含已归档的挑战
},
},
});
if (existingChallengeCount >= 1) {
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,
isVip: user.isVip,
existingChallengeCount: user.isVip ? null : 1,
});
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('分享码无效或挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
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('分享码无效或挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
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.challengeState === ChallengeState.ARCHIVED) {
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',
'title',
'summary',
'maxParticipants',
'highlightSubtitle',
'highlightTitle',
'image'
];
const restrictedFields = Object.keys(dto).filter(
key => !allowedFields.includes(key as keyof UpdateCustomChallengeDto)
);
if (restrictedFields.length > 0) {
const allowedFieldsDescription = '概要(summary)、公开性(isPublic)、展示文案(highlightTitle、highlightSubtitle、ctaLabel)、标题(title)、图片(image)和最大参与人数(maxParticipants)';
throw new BadRequestException(`挑战已开始,只能编辑部分字段。可编辑的字段包括:${allowedFieldsDescription}。您尝试编辑的字段:${restrictedFields.join('、')} 不在允许范围内。`);
}
}
// 更新挑战
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.challengeState === ChallengeState.ARCHIVED) {
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,5 +1,13 @@
import { ChallengeProgressDto, RankingItemDto } from './challenge-progress.dto';
import { ChallengeType } from '../models/challenge.model';
import { ChallengeSource, ChallengeType } from '../models/challenge.model';
export interface BadgeInfoDto {
code: string;
name: string;
description: string;
imageUrl: string;
category: string;
}
export interface ChallengeDetailDto {
id: string;
@@ -20,4 +28,9 @@ export interface ChallengeDetailDto {
userRank?: number;
type: ChallengeType;
unit: string;
badge?: BadgeInfoDto;
creatorId: string | null;
shareCode?: string | null;
source: ChallengeSource;
isCreator: boolean;
}

View File

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

View File

@@ -0,0 +1,89 @@
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()
startAt: number;
@ApiProperty({ description: '结束时间戳(毫秒)', example: 1705881600000 })
@IsNumber()
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: 1000 })
@IsNumber()
@Min(1)
@Max(1000)
minimumCheckInDays: number;
@ApiProperty({ description: '持续时间标签', example: '持续21天' })
@IsString()
@IsNotEmpty()
@MaxLength(128)
durationLabel: string;
@ApiProperty({ description: '挑战要求标签', example: '每日喝水8杯' })
@IsString()
@IsOptional()
@MaxLength(255)
requirementLabel: string;
@ApiProperty({ description: '挑战概要说明', required: false })
@IsString()
@IsOptional()
summary?: string;
@ApiProperty({ description: '进度单位', example: '天', required: true })
@IsString()
@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, IsEnum } 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

@@ -15,6 +15,18 @@ export enum ChallengeType {
MOOD = 'mood',
SLEEP = 'sleep',
WEIGHT = 'weight',
CUSTOM = 'custom',
}
export enum ChallengeSource {
SYSTEM = 'system',
CUSTOM = 'custom',
}
export enum ChallengeState {
DRAFT = 'draft',
ACTIVE = 'active',
ARCHIVED = 'archived',
}
@Table({
@@ -44,18 +56,18 @@ export class Challenge extends Model {
declare image: string;
@Column({
type: DataType.BIGINT,
type: DataType.DATE,
allowNull: false,
comment: '挑战开始时间(时间戳)',
comment: '挑战开始时间',
})
declare startAt: number;
declare startAt: Date;
@Column({
type: DataType.BIGINT,
type: DataType.DATE,
allowNull: false,
comment: '挑战结束时间(时间戳)',
comment: '挑战结束时间',
})
declare endAt: number;
declare endAt: Date;
@Column({
type: DataType.STRING(128),
@@ -73,10 +85,10 @@ export class Challenge extends Model {
@Column({
type: DataType.STRING(255),
allowNull: false,
allowNull: true,
comment: '挑战要求标签,例如「每日练习 1 次」',
})
declare requirementLabel: string;
declare requirementLabel?: string;
@Column({
type: DataType.TEXT,
@@ -101,7 +113,7 @@ export class Challenge extends Model {
declare progressUnit: string;
@Column({
type: DataType.INTEGER,
type: DataType.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: '最低打卡天数,用于判断挑战成功',
@@ -131,19 +143,64 @@ export class Challenge extends Model {
@Column({
type: DataType.STRING(128),
allowNull: false,
allowNull: true,
comment: 'CTA 按钮文字',
})
declare ctaLabel: string;
@Column({
type: DataType.ENUM('water', 'exercise', 'diet', 'mood', 'sleep', 'weight'),
type: DataType.ENUM('water', 'exercise', 'diet', 'mood', 'sleep', 'weight', 'custom'),
allowNull: false,
defaultValue: ChallengeType.WATER,
comment: '挑战类型',
})
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)
declare participants?: ChallengeParticipant[];
}

View File

@@ -0,0 +1,12 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* 从请求头中获取应用版本号的装饰器
* 从 x-App-Version 请求头中提取版本信息
*/
export const AppVersion = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string | undefined => {
const request = ctx.switchToHttp().getRequest();
return request.headers['x-app-version'];
},
);

View File

@@ -22,7 +22,7 @@ import { ConfigService } from '@nestjs/config';
collate: 'utf8mb4_0900_ai_ci',
},
autoLoadModels: true,
synchronize: true,
synchronize: false,
}),
}),
],

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 { NutritionAnalysisResponseDto } from './dto/nutrition-analysis.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 { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
import { UsersService } from '../users/users.service';
@ApiTags('diet-records')
@Controller('diet-records')
@@ -31,6 +33,7 @@ export class DietRecordsController {
constructor(
private readonly dietRecordsService: DietRecordsService,
private readonly nutritionAnalysisService: NutritionAnalysisService,
private readonly usersService: UsersService,
) { }
/**
@@ -141,6 +144,7 @@ export class DietRecordsController {
): Promise<FoodRecognitionToDietRecordsResponseDto> {
this.logger.log(`识别食物转饮食记录 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
return this.dietRecordsService.recognizeFoodToDietRecords(
user.sub,
requestDto.imageUrl,
requestDto.mealType
);
@@ -161,6 +165,7 @@ export class DietRecordsController {
): Promise<FoodRecognitionResponseDto> {
this.logger.log(`识别食物 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
return this.dietRecordsService.recognizeFood(
user.sub,
requestDto.imageUrl,
requestDto.mealType
);
@@ -185,37 +190,130 @@ export class DietRecordsController {
this.logger.log(`分析营养成分表 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
if (!requestDto.imageUrl) {
return {
success: false,
data: [],
message: '请提供图片URL'
};
}
// 验证URL格式
try {
new URL(requestDto.imageUrl);
} catch (error) {
return {
success: false,
data: [],
message: '图片URL格式不正确'
};
return NutritionAnalysisResponseDto.createError('请提供图片URL');
}
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}`);
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) {
this.logger.error(`营养成分表分析失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`);
return {
success: false,
data: [],
message: '营养成分表分析失败,请稍后重试'
return NutritionAnalysisResponseDto.createError('营养成分表分析失败,请稍后重试');
}
}
/**
* 获取营养成分分析记录
*/
@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,13 +5,14 @@ import { DietRecordsService } from './diet-records.service';
import { NutritionAnalysisService } from './services/nutrition-analysis.service';
import { UserDietHistory } from '../users/models/user-diet-history.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 { AiCoachModule } from '../ai-coach/ai-coach.module';
@Module({
imports: [
SequelizeModule.forFeature([UserDietHistory, ActivityLog]),
UsersModule,
SequelizeModule.forFeature([UserDietHistory, ActivityLog, NutritionAnalysisRecord]),
forwardRef(() => UsersModule),
forwardRef(() => AiCoachModule),
],
controllers: [DietRecordsController],

View File

@@ -8,6 +8,7 @@ import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietR
import { DietRecordSource, MealType } from '../users/models/user-diet-history.model';
import { ResponseCode } from '../base.dto';
import { DietAnalysisService } from '../ai-coach/services/diet-analysis.service';
import { UsersService } from '../users/users.service';
@Injectable()
export class DietRecordsService {
@@ -21,6 +22,7 @@ export class DietRecordsService {
private readonly sequelize: Sequelize,
@Inject(forwardRef(() => DietAnalysisService))
private readonly dietAnalysisService: DietAnalysisService,
private readonly usersService: UsersService,
) { }
/**
@@ -296,14 +298,17 @@ export class DietRecordsService {
* @returns 食物识别结果转换为饮食记录格式
*/
async recognizeFoodToDietRecords(
userId: string,
imageUrl: string,
suggestedMealType?: MealType
): Promise<FoodRecognitionToDietRecordsResponseDto> {
try {
this.logger.log(`recognizeFoodToDietRecords - imageUrl: ${imageUrl}, suggestedMealType: ${suggestedMealType}`);
this.logger.log(`recognizeFoodToDietRecords - userId: ${userId}, imageUrl: ${imageUrl}, suggestedMealType: ${suggestedMealType}`);
const language = await this.usersService.getUserLanguage(userId);
// 调用 DietAnalysisService 进行食物识别
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl]);
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl], language);
// 将识别结果转换为 CreateDietRecordDto 格式
const dietRecords: CreateDietRecordDto[] = recognitionResult.items.map(item => ({
@@ -344,14 +349,17 @@ export class DietRecordsService {
* @returns 食物识别结果
*/
async recognizeFood(
userId: string,
imageUrl: string,
suggestedMealType?: MealType
): Promise<FoodRecognitionResponseDto> {
try {
this.logger.log(`recognizeFood - imageUrl: ${imageUrl}, suggestedMealType: ${suggestedMealType}`);
this.logger.log(`recognizeFood - userId: ${userId}, imageUrl: ${imageUrl}, suggestedMealType: ${suggestedMealType}`);
const language = await this.usersService.getUserLanguage(userId);
// 调用 DietAnalysisService 进行食物识别
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl]);
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl], language);
// 如果指定了建议的餐次类型,更新所有识别项的餐次类型
if (suggestedMealType) {

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 { IsString, IsNotEmpty, IsUrl } from 'class-validator';
/**
* 营养成分分析请求DTO
@@ -9,5 +10,8 @@ export class NutritionAnalysisRequestDto {
example: 'https://example.com/nutrition-label.jpg',
required: true
})
@IsString()
@IsNotEmpty()
@IsUrl()
imageUrl: string;
}

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseDto } from '../../base.dto';
/**
* 营养成分分析结果项
@@ -19,14 +20,24 @@ export class NutritionAnalysisItemDto {
/**
* 营养成分分析响应DTO
* 使用通用响应结构体
*/
export class NutritionAnalysisResponseDto {
@ApiProperty({ description: '操作是否成功', example: true })
success: boolean;
@ApiProperty({ description: '营养成分分析结果数组', type: [NutritionAnalysisItemDto] })
data: NutritionAnalysisItemDto[];
@ApiProperty({ description: '响应消息', required: false })
message?: string;
export class NutritionAnalysisResponseDto extends ApiResponseDto<NutritionAnalysisItemDto[]> {
constructor(code: number, message: string, data: NutritionAnalysisItemDto[]) {
super(code, message, data);
}
/**
* 创建成功响应
*/
static createSuccess(data: NutritionAnalysisItemDto[], message: string = '营养成分分析成功'): NutritionAnalysisResponseDto {
return new NutritionAnalysisResponseDto(0, message, data);
}
/**
* 创建失败响应
*/
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 { ConfigService } from '@nestjs/config';
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 {
success: boolean;
@@ -36,7 +41,11 @@ export class NutritionAnalysisService {
private readonly visionModel: 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
this.apiProvider = this.configService.get<string>('AI_VISION_PROVIDER') || 'dashscope';
@@ -70,20 +79,37 @@ export class NutritionAnalysisService {
* @param imageUrl 图片URL
* @returns 营养成分分析结果
*/
async analyzeNutritionImage(imageUrl: string): Promise<NutritionAnalysisResponse> {
async analyzeNutritionImage(imageUrl: string, userId?: string): Promise<NutritionAnalysisResponse> {
try {
this.logger.log(`开始分析营养成分表图片: ${imageUrl}`);
this.logger.log(`开始分析营养成分表图片: ${imageUrl}, 用户ID: ${userId}`);
const prompt = this.buildNutritionAnalysisPrompt();
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}`);
return this.parseNutritionAnalysisResult(rawResult);
const result = this.parseNutritionAnalysisResult(rawResult);
// 如果提供了用户ID保存分析记录
if (userId) {
await this.saveAnalysisRecord(userId, imageUrl, result);
}
return result;
} catch (error) {
this.logger.error(`营养成分表分析失败: ${error instanceof Error ? error.message : String(error)}`);
// 如果提供了用户ID保存失败记录
if (userId) {
await this.saveAnalysisRecord(userId, imageUrl, {
success: false,
data: [],
message: '营养成分表分析失败,请稍后重试'
});
}
return {
success: false,
data: [],
@@ -152,11 +178,14 @@ export class NutritionAnalysisService {
**任务要求:**
1. 识别图片中的营养成分表,提取所有可见的营养素信息
2. 为每个营养素提供详细的健康建议和分析
3. 返回严格的JSON数组格式,包含任何额外的解释或对话文本
3. 返回严格的JSON格式包含code、msg和data字段
**输出格式要求:**
请严格按照以下JSON数组格式返回,每个对象包含四个字段
[
请严格按照以下JSON格式返回
{
"code": 0,
"msg": "分析成功",
"data": [
{
"key": "energy_kcal",
"name": "热量",
@@ -170,6 +199,15 @@ export class NutritionAnalysisService {
"analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。"
}
]
}
**失败情况格式:**
如果无法识别营养成分表或分析失败,请返回:
{
"code": 1,
"msg": "失败原因的具体描述",
"data": []
}
**营养素标识符对照表:**
- 热量/能量: energy_kcal
@@ -197,16 +235,18 @@ export class NutritionAnalysisService {
- 其他营养素: other_nutrient
**分析要求:**
1. 如果图片中没有营养成分表,返回空数组 []
2. 为每个识别到的营养素提供具体的健康建议
3. 建议应包含营养素的作用、摄入量参考和健康影响
4. 数值分析要准确,建议要专业且实用
5. 只返回JSON数组不要包含任何其他文本
1. 如果成功识别营养成分表,code设为0msg为"分析成功"
2. 如果无法识别或分析失败code设为1msg详细说明失败原因
3. 为每个识别到的营养素提供具体的健康建议
4. 建议应包含营养素的作用、摄入量参考和健康影响
5. 数值分析要准确,建议要专业且实用
6. 只返回JSON对象不要包含任何其他文本
**重要提醒:**
- 严格按照JSON数组格式返回
- 严格按照JSON对象格式返回包含code、msg和data字段
- 不要添加任何解释性文字或对话内容
- 确保JSON格式正确可以被直接解析`;
- 确保JSON格式正确可以被直接解析
- 必须返回完整的JSON结构即使分析失败也要返回code和msg字段`;
}
/**
@@ -230,9 +270,9 @@ export class NutritionAnalysisService {
};
}
// 确保结果是数组
if (!Array.isArray(parsedResult)) {
this.logger.error(`营养成分分析结果不是数组格式: ${typeof parsedResult}`);
// 检查响应格式 {code, msg, data}
if (!parsedResult || typeof parsedResult !== 'object' || !('code' in parsedResult)) {
this.logger.error(`营养成分分析结果格式不正确: ${JSON.stringify(parsedResult)}`);
return {
success: false,
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[] = [];
for (const item of parsedResult) {
for (const item of data) {
if (item && typeof item === 'object' && item.key && item.name && item.value && item.analysis) {
nutritionData.push({
key: String(item.key).trim(),
@@ -260,7 +322,7 @@ export class NutritionAnalysisService {
return {
success: false,
data: [],
message: '图片中未检测到有效的营养成分表信息'
message: msg || '图片中未检测到有效的营养成分表信息'
};
}
@@ -270,6 +332,7 @@ export class NutritionAnalysisService {
success: true,
data: nutritionData
};
} catch (error) {
this.logger.error(`营养成分分析结果处理失败: ${error instanceof Error ? error.message : String(error)}`);
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,103 @@
import {
Controller,
Get,
Query,
Headers,
Res,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiHeader, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { Response } from 'express';
import * as FormData from 'form-data';
import { ExpoUpdatesService } from './expo-updates.service';
import { logger } from 'src/common/logger/winston.config';
@ApiTags('Expo Updates')
@Controller('expo-updates')
export class ExpoUpdatesController {
constructor(private readonly expoUpdatesService: ExpoUpdatesService) {}
@Get('manifest')
@ApiOperation({ summary: '获取 Expo 更新 manifest' })
@ApiHeader({ name: 'expo-platform', description: '平台类型 (ios/android)', required: false })
@ApiHeader({ name: 'expo-runtime-version', description: '运行时版本', required: false })
@ApiHeader({ name: 'expo-protocol-version', description: '协议版本 (0/1)', required: false })
@ApiHeader({ name: 'expo-current-update-id', description: '当前更新ID', required: false })
@ApiQuery({ name: 'platform', description: '平台类型', required: false })
@ApiQuery({ name: 'runtime-version', description: '运行时版本', required: false })
@ApiResponse({ status: 200, description: '返回更新 manifest' })
async getManifest(
@Headers('expo-platform') headerPlatform: string,
@Headers('expo-runtime-version') headerRuntimeVersion: string,
@Headers('expo-protocol-version') protocolVersionHeader: string,
@Headers('expo-current-update-id') currentUpdateId: string,
@Query('platform') queryPlatform: string,
@Query('runtime-version') queryRuntimeVersion: string,
@Res() res: Response,
) {
const protocolVersion = parseInt(protocolVersionHeader || '0', 10);
if (![0, 1].includes(protocolVersion)) {
throw new BadRequestException('Unsupported protocol version. Expected either 0 or 1.');
}
const platform = headerPlatform || queryPlatform;
if (platform !== 'ios' && platform !== 'android') {
throw new BadRequestException('Unsupported platform. Expected either ios or android.');
}
const runtimeVersion = headerRuntimeVersion || queryRuntimeVersion;
if (!runtimeVersion) {
throw new BadRequestException('No runtimeVersion provided.');
}
logger.info(`Getting manifest for platform: ${platform}, runtimeVersion: ${runtimeVersion}`);
const manifest = await this.expoUpdatesService.buildManifest(platform as 'ios' | 'android', runtimeVersion);
logger.info(`Manifest: ${JSON.stringify(manifest)}`);
// 已是最新版本
if (currentUpdateId === manifest.id && protocolVersion === 1) {
return this.sendNoUpdateAvailable(res);
}
// 使用 form-data 构建正确的 multipart 响应
const form = new FormData();
form.append('manifest', JSON.stringify(manifest), {
contentType: 'application/json',
header: {
'content-type': 'application/json; charset=utf-8',
},
});
form.append('extensions', JSON.stringify({ assetRequestHeaders: {} }), {
contentType: 'application/json',
});
res.statusCode = 200;
res.setHeader('expo-protocol-version', protocolVersion);
res.setHeader('expo-sfv-version', 0);
res.setHeader('cache-control', 'private, max-age=0');
res.setHeader('content-type', `multipart/mixed; boundary=${form.getBoundary()}`);
res.send(form.getBuffer());
}
private sendNoUpdateAvailable(res: Response) {
const form = new FormData();
const directive = this.expoUpdatesService.createNoUpdateAvailableDirective();
form.append('directive', JSON.stringify(directive), {
contentType: 'application/json',
header: {
'content-type': 'application/json; charset=utf-8',
},
});
res.statusCode = 200;
res.setHeader('expo-protocol-version', 1);
res.setHeader('expo-sfv-version', 0);
res.setHeader('cache-control', 'private, max-age=0');
res.setHeader('content-type', `multipart/mixed; boundary=${form.getBoundary()}`);
res.send(form.getBuffer());
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ExpoUpdatesController } from './expo-updates.controller';
import { ExpoUpdatesService } from './expo-updates.service';
@Module({
controllers: [ExpoUpdatesController],
providers: [ExpoUpdatesService],
exports: [ExpoUpdatesService],
})
export class ExpoUpdatesModule {}

View File

@@ -0,0 +1,271 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { logger } from 'src/common/logger/winston.config';
import axios from 'axios';
import * as crypto from 'crypto';
export interface AssetMetadata {
hash: string;
key: string;
contentType: string;
fileExtension?: string;
url: string;
}
export interface UpdateManifest {
id: string;
createdAt: string;
runtimeVersion: string;
assets: AssetMetadata[];
launchAsset: AssetMetadata;
metadata: Record<string, any>;
extra: {
expoClient?: Record<string, any>;
};
}
export interface NoUpdateAvailableDirective {
type: 'noUpdateAvailable';
}
interface MetadataFileAsset {
path: string;
ext: string;
}
interface MetadataFile {
version: number;
bundler: string;
fileMetadata: {
ios?: {
bundle: string;
assets: MetadataFileAsset[];
};
android?: {
bundle: string;
assets: MetadataFileAsset[];
};
};
}
// 缓存 metadata 数据
interface MetadataCache {
data: MetadataFile;
timestamp: number;
}
// 缓存 hash 数据
interface HashCache {
hash: string;
timestamp: number;
}
@Injectable()
export class ExpoUpdatesService {
private metadataCache: Map<string, MetadataCache> = new Map();
private hashCache: Map<string, HashCache> = new Map();
private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
constructor(private configService: ConfigService) {}
/**
* 从环境变量构建 manifest
*
* 环境变量配置:
* - EXPO_UPDATE_ID: 更新ID可选
* - EXPO_RUNTIME_VERSION: 运行时版本
* - EXPO_IOS_METADATA_URL: iOS metadata.json URL
* - EXPO_ANDROID_METADATA_URL: Android metadata.json URL
*/
async buildManifest(platform: 'ios' | 'android', runtimeVersion: string): Promise<UpdateManifest> {
const configRuntimeVersion = this.configService.get<string>('EXPO_RUNTIME_VERSION');
logger.info(`buildManifest: configRuntimeVersion=${configRuntimeVersion}, runtimeVersion=${runtimeVersion}`);
// 检查运行时版本是否匹配
if (configRuntimeVersion && configRuntimeVersion !== runtimeVersion) {
throw new BadRequestException(`No update available for runtime version: ${runtimeVersion}`);
}
const metadataUrl = platform === 'ios'
? this.configService.get<string>('EXPO_IOS_METADATA_URL')
: this.configService.get<string>('EXPO_ANDROID_METADATA_URL');
if (!metadataUrl) {
throw new BadRequestException(`No metadata URL configured for platform: ${platform}`);
}
// 获取 metadata.json 内容
const metadata = await this.fetchMetadata(metadataUrl);
const platformMetadata = metadata.fileMetadata[platform];
if (!platformMetadata) {
throw new BadRequestException(`No ${platform} metadata found in metadata.json`);
}
// 计算基础 URLmetadata.json 所在目录)
const baseUrl = metadataUrl.substring(0, metadataUrl.lastIndexOf('/') + 1);
// 构建 bundle URL 并计算真实 hash
const bundleUrl = baseUrl + platformMetadata.bundle;
const bundleHash = await this.calculateFileHash(bundleUrl);
// 构建 assets需要计算每个文件的真实 hash
const assets = await this.buildAssetsWithHash(platformMetadata.assets, baseUrl);
// ID 基于 bundle hash 生成,确保内容不变时 ID 固定
const updateId = this.configService.get<string>('EXPO_UPDATE_ID')
|| this.convertSHA256HashToUUID(bundleHash);
return {
id: updateId,
createdAt: new Date().toISOString(),
runtimeVersion: configRuntimeVersion || runtimeVersion,
launchAsset: {
hash: bundleHash,
key: 'bundle',
contentType: 'application/javascript',
url: bundleUrl,
},
assets,
metadata: {},
extra: {},
};
}
/**
* 获取 metadata.json 内容(带缓存)
*/
private async fetchMetadata(url: string): Promise<MetadataFile> {
const cached = this.metadataCache.get(url);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
logger.info(`Using cached metadata for ${url}`);
return cached.data;
}
logger.info(`Fetching metadata from ${url}`);
try {
const response = await axios.get<MetadataFile>(url, { timeout: 10000 });
const data = response.data;
// 缓存数据
this.metadataCache.set(url, {
data,
timestamp: Date.now(),
});
return data;
} catch (error) {
logger.error(`Failed to fetch metadata: ${error.message}`);
throw new BadRequestException(`Failed to fetch metadata from ${url}`);
}
}
/**
* 计算文件的 SHA-256 hashBase64URL 编码)
*/
private async calculateFileHash(url: string): Promise<string> {
// 检查缓存
const cacheKey = `hash:${url}`;
const cached = this.hashCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.hash;
}
try {
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
});
const hash = crypto.createHash('sha256').update(Buffer.from(response.data)).digest('base64url');
// 缓存 hash
this.hashCache.set(cacheKey, { hash, timestamp: Date.now() });
logger.debug(`Calculated hash for ${url}: ${hash}`);
return hash;
} catch (error) {
logger.error(`Failed to calculate hash for ${url}: ${error.message}`);
throw new BadRequestException(`Failed to fetch asset: ${url}`);
}
}
/**
* 构建 assets 列表(带真实 hash 计算)
*/
private async buildAssetsWithHash(assets: MetadataFileAsset[], baseUrl: string): Promise<AssetMetadata[]> {
// 去重:相同 path 的 asset 只保留一个
const uniqueAssets = new Map<string, MetadataFileAsset>();
for (const asset of assets) {
if (!uniqueAssets.has(asset.path)) {
uniqueAssets.set(asset.path, asset);
}
}
const assetList = Array.from(uniqueAssets.values());
logger.info(`Building ${assetList.length} unique assets`);
// 分批并行计算每批10个避免并发过多
const batchSize = 10;
const results: AssetMetadata[] = [];
for (let i = 0; i < assetList.length; i += batchSize) {
const batch = assetList.slice(i, i + batchSize);
const batchResults = await Promise.all(
batch.map(async (asset) => {
const url = baseUrl + asset.path;
const key = asset.path.split('/').pop() || ''; // 使用文件名作为 key
const hash = await this.calculateFileHash(url);
return {
hash,
key,
contentType: this.getContentType(asset.ext),
fileExtension: `.${asset.ext}`,
url,
};
})
);
results.push(...batchResults);
}
return results;
}
/**
* 根据扩展名获取 content type
*/
private getContentType(ext: string): string {
const contentTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
ttf: 'font/ttf',
otf: 'font/otf',
woff: 'font/woff',
woff2: 'font/woff2',
js: 'application/javascript',
json: 'application/json',
mp3: 'audio/mpeg',
mp4: 'video/mp4',
wav: 'audio/wav',
};
return contentTypes[ext.toLowerCase()] || 'application/octet-stream';
}
createNoUpdateAvailableDirective(): NoUpdateAvailableDirective {
return { type: 'noUpdateAvailable' };
}
/**
* 将 SHA-256 hash 转换为 UUID 格式
*/
private convertSHA256HashToUUID(hash: string): string {
// 将 base64url 转为 hex然后格式化为 UUID
const hex = Buffer.from(hash, 'base64url').toString('hex').slice(0, 32);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
}

View File

@@ -0,0 +1,3 @@
export * from './expo-updates.module';
export * from './expo-updates.service';
export * from './expo-updates.controller';

View File

@@ -0,0 +1,82 @@
/**
* 健康史推荐选项常量
* 用于前端展示和数据验证
*/
export const HEALTH_HISTORY_RECOMMENDATIONS = {
allergy: [
'penicillin', // 青霉素
'sulfonamides', // 磺胺类
'peanuts', // 花生
'seafood', // 海鲜
'pollen', // 花粉
'dustMites', // 尘螨
'alcohol', // 酒精
'mango', // 芒果
],
disease: [
'hypertension', // 高血压
'diabetes', // 糖尿病
'asthma', // 哮喘
'heartDisease', // 心脏病
'gastritis', // 胃炎
'migraine', // 偏头痛
],
surgery: [
'appendectomy', // 阑尾切除术
'cesareanSection', // 剖腹产
'tonsillectomy', // 扁桃体切除术
'fractureRepair', // 骨折复位术
'none', // 无
],
familyDisease: [
'hypertension', // 高血压
'diabetes', // 糖尿病
'cancer', // 癌症
'heartDisease', // 心脏病
'stroke', // 中风
'alzheimers', // 阿尔茨海默病
],
};
/**
* 健康异常检测规则
*/
export interface HealthAbnormalityRule {
indicatorName: string;
condition: 'gt' | 'lt' | 'eq' | 'range';
threshold: number | [number, number];
severity: 'info' | 'warning' | 'critical';
message: string;
}
export const ABNORMALITY_RULES: HealthAbnormalityRule[] = [
{
indicatorName: '收缩压',
condition: 'gt',
threshold: 140,
severity: 'warning',
message: '血压偏高,建议关注',
},
{
indicatorName: '舒张压',
condition: 'gt',
threshold: 90,
severity: 'warning',
message: '舒张压偏高,建议关注',
},
{
indicatorName: '血糖',
condition: 'gt',
threshold: 7.0,
severity: 'warning',
message: '血糖偏高,建议复查',
},
{
indicatorName: '总胆固醇',
condition: 'gt',
threshold: 5.2,
severity: 'info',
message: '胆固醇偏高,建议注意饮食',
},
];

View File

@@ -0,0 +1,182 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsBoolean, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { FamilyRole } from '../enums/health-profile.enum';
/**
* 创建家庭组请求 DTO
*/
export class CreateFamilyGroupDto {
@ApiPropertyOptional({ description: '家庭组名称', default: '我的家庭' })
@IsOptional()
@IsString()
name?: string;
}
/**
* 生成邀请码请求 DTO
*/
export class GenerateInviteCodeDto {
@ApiPropertyOptional({ description: '邀请码有效期(小时)', default: 24 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(168) // 最多7天
expiresInHours?: number = 24;
}
/**
* 加入家庭组请求 DTO
*/
export class JoinFamilyGroupDto {
@ApiProperty({ description: '邀请码' })
@IsString()
inviteCode: string;
}
/**
* 更新成员权限请求 DTO
*/
export class UpdateFamilyMemberDto {
@ApiPropertyOptional({ description: '是否可查看健康数据' })
@IsOptional()
@IsBoolean()
canViewHealthData?: boolean;
@ApiPropertyOptional({ description: '是否可管理健康数据' })
@IsOptional()
@IsBoolean()
canManageHealthData?: boolean;
@ApiPropertyOptional({ description: '是否接收异常提醒' })
@IsOptional()
@IsBoolean()
receiveAlerts?: boolean;
@ApiPropertyOptional({ description: '关系(如:配偶、父母、子女)' })
@IsOptional()
@IsString()
relationship?: string;
}
/**
* 家庭成员响应 DTO
*/
export class FamilyMemberResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
userId: string;
@ApiProperty()
userName: string;
@ApiPropertyOptional()
userAvatar?: string;
@ApiProperty({ enum: FamilyRole })
role: FamilyRole;
@ApiPropertyOptional()
relationship?: string;
@ApiProperty()
canViewHealthData: boolean;
@ApiProperty()
canManageHealthData: boolean;
@ApiProperty()
receiveAlerts: boolean;
@ApiProperty()
joinedAt: string;
}
/**
* 家庭组响应 DTO
*/
export class FamilyGroupResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
ownerId: string;
@ApiProperty()
name: string;
@ApiPropertyOptional()
inviteCode?: string;
@ApiPropertyOptional()
inviteCodeExpiresAt?: string;
@ApiProperty()
maxMembers: number;
@ApiProperty({ type: [FamilyMemberResponseDto] })
members: FamilyMemberResponseDto[];
@ApiProperty()
createdAt: string;
@ApiProperty()
updatedAt: string;
}
/**
* 获取家庭组响应 DTO
*/
export class GetFamilyGroupResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({ type: FamilyGroupResponseDto, nullable: true })
data: FamilyGroupResponseDto | null;
}
/**
* 邀请码响应 DTO
*/
export class FamilyInviteResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({
example: {
familyGroupId: 'uuid',
inviteCode: 'ABC123',
expiresAt: '2024-01-02T00:00:00Z',
qrCodeUrl: 'https://...',
},
})
data: {
familyGroupId: string;
inviteCode: string;
expiresAt: string;
qrCodeUrl: string;
};
}
/**
* 获取家庭成员列表响应 DTO
*/
export class GetFamilyMembersResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({ type: [FamilyMemberResponseDto] })
data: FamilyMemberResponseDto[];
}

View File

@@ -0,0 +1,151 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsString, IsOptional, IsArray, ValidateNested, IsEnum, IsDateString } from 'class-validator';
import { Type } from 'class-transformer';
/**
* 健康史详情项 DTO
*/
export class HealthHistoryItemDto {
@ApiPropertyOptional({ description: '已有项的ID更新时传入' })
@IsOptional()
@IsString()
id?: string;
@ApiProperty({ description: '名称(如:青霉素、高血压)' })
@IsString()
name: string;
@ApiPropertyOptional({ description: '确诊/发生日期YYYY-MM-DD' })
@IsOptional()
@IsDateString()
date?: string;
@ApiPropertyOptional({ description: '是否为推荐选项' })
@IsOptional()
@IsBoolean()
isRecommendation?: boolean;
@ApiPropertyOptional({ description: '备注' })
@IsOptional()
@IsString()
note?: string;
}
/**
* 更新健康史分类请求 DTO
*/
export class UpdateHealthHistoryDto {
@ApiProperty({ description: '是否有该类健康史', nullable: true })
@IsBoolean()
hasHistory: boolean;
@ApiProperty({ description: '健康史详情列表', type: [HealthHistoryItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => HealthHistoryItemDto)
items: HealthHistoryItemDto[];
}
/**
* 健康史详情项响应
*/
export class HealthHistoryItemResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiPropertyOptional()
date?: string;
@ApiPropertyOptional()
isRecommendation?: boolean;
@ApiPropertyOptional()
note?: string;
}
/**
* 健康史分类响应
*/
export class HealthHistoryCategoryResponseDto {
@ApiProperty({ nullable: true })
hasHistory: boolean | null;
@ApiProperty({ type: [HealthHistoryItemResponseDto] })
items: HealthHistoryItemResponseDto[];
}
/**
* 获取健康史响应
*/
export class GetHealthHistoryResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({
description: '按分类组织的健康史数据',
example: {
allergy: { hasHistory: true, items: [] },
disease: { hasHistory: false, items: [] },
surgery: { hasHistory: null, items: [] },
familyDisease: { hasHistory: true, items: [] },
},
})
data: {
allergy: HealthHistoryCategoryResponseDto;
disease: HealthHistoryCategoryResponseDto;
surgery: HealthHistoryCategoryResponseDto;
familyDisease: HealthHistoryCategoryResponseDto;
};
}
/**
* 更新健康史分类响应
*/
export class UpdateHealthHistoryCategoryResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({ type: HealthHistoryCategoryResponseDto })
data: HealthHistoryCategoryResponseDto;
}
/**
* 健康史完成度响应
*/
export class HealthHistoryProgressResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({
example: {
progress: 75,
details: {
allergy: true,
disease: true,
surgery: true,
familyDisease: false,
},
},
})
data: {
progress: number;
details: {
allergy: boolean;
disease: boolean;
surgery: boolean;
familyDisease: boolean;
};
};
}

View File

@@ -0,0 +1,81 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* 基础信息概览
*/
export class BasicInfoOverviewDto {
@ApiProperty({ description: '完成度百分比' })
progress: number;
@ApiProperty({
description: '基础数据',
example: {
height: '175',
weight: '70',
bmi: '22.9',
waistCircumference: 80,
},
})
data: {
height?: string;
weight?: string;
bmi?: string;
waistCircumference?: number;
};
}
/**
* 健康史概览
*/
export class HealthHistoryOverviewDto {
@ApiProperty({ description: '完成度百分比' })
progress: number;
@ApiProperty({ description: '已回答的分类', type: [String] })
answeredCategories: string[];
@ApiProperty({ description: '待回答的分类', type: [String] })
pendingCategories: string[];
}
/**
* 药物管理概览
*/
export class MedicationsOverviewDto {
@ApiProperty({ description: '当前用药数量' })
activeCount: number;
@ApiProperty({ description: '今日服药完成率' })
todayCompletionRate: number;
}
/**
* 健康档案概览响应 DTO
*/
export class GetHealthOverviewResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({
example: {
basicInfo: {
progress: 100,
data: { height: '175', weight: '70', bmi: '22.9', waistCircumference: 80 },
},
healthHistory: {
progress: 75,
answeredCategories: ['allergy', 'disease', 'surgery'],
pendingCategories: ['familyDisease'],
},
medications: { activeCount: 3, todayCompletionRate: 66.7 },
},
})
data: {
basicInfo: BasicInfoOverviewDto;
healthHistory: HealthHistoryOverviewDto;
medications: MedicationsOverviewDto;
};
}

View File

@@ -0,0 +1,4 @@
export * from './health-history.dto';
export * from './family-health.dto';
export * from './health-overview.dto';
export * from './medical-records.dto';

View File

@@ -0,0 +1,181 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsEnum,
IsArray,
IsOptional,
IsDateString,
MaxLength,
MinLength,
ArrayMinSize,
ArrayMaxSize,
IsUrl,
} from 'class-validator';
import { MedicalRecordType, UploadFileType } from '../enums/health-profile.enum';
import { ResponseCode } from '../../base.dto';
/**
* 就医资料条目
*/
export class MedicalRecordItemDto {
@ApiProperty({ description: '唯一标识符', example: 'rec_1234567890' })
id: string;
@ApiProperty({
description: '资料类型',
enum: MedicalRecordType,
example: MedicalRecordType.MEDICAL_RECORD,
})
type: MedicalRecordType;
@ApiProperty({ description: '标题', example: '血常规检查' })
title: string;
@ApiProperty({ description: '日期 (YYYY-MM-DD)', example: '2024-12-01' })
date: string;
@ApiProperty({
description: '图片/文件 URL 数组',
type: [String],
example: ['https://cdn.example.com/images/blood_test_1.jpg'],
})
images: string[];
@ApiPropertyOptional({ description: '备注', example: '空腹检查结果' })
note?: string;
@ApiProperty({ description: '创建时间', example: '2024-12-01T08:30:00Z' })
createdAt: string;
@ApiProperty({ description: '更新时间', example: '2024-12-01T08:30:00Z' })
updatedAt: string;
}
/**
* 就医资料数据集
*/
export class MedicalRecordsDataDto {
@ApiProperty({ description: '病历资料列表', type: [MedicalRecordItemDto] })
records: MedicalRecordItemDto[];
@ApiProperty({ description: '处方单据列表', type: [MedicalRecordItemDto] })
prescriptions: MedicalRecordItemDto[];
}
/**
* 获取就医资料列表响应
*/
export class GetMedicalRecordsResponseDto {
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
code: number;
@ApiProperty({ description: '响应消息', example: 'success' })
message: string;
@ApiProperty({ description: '就医资料数据', type: MedicalRecordsDataDto })
data: MedicalRecordsDataDto;
}
/**
* 添加就医资料请求
*/
export class CreateMedicalRecordDto {
@ApiProperty({
description: '资料类型',
enum: MedicalRecordType,
example: MedicalRecordType.MEDICAL_RECORD,
})
@IsEnum(MedicalRecordType, { message: '资料类型必须是 medical_record 或 prescription' })
type: MedicalRecordType;
@ApiProperty({ description: '标题最多100字符', example: '胸部X光检查' })
@IsString({ message: '标题必须是字符串' })
@MinLength(1, { message: '标题不能为空' })
@MaxLength(100, { message: '标题最多100字符' })
title: string;
@ApiProperty({ description: '日期格式YYYY-MM-DD不能是未来日期', example: '2024-12-05' })
@IsDateString({}, { message: '日期格式必须是 YYYY-MM-DD' })
date: string;
@ApiProperty({
description: '图片URL数组至少1张最多9张',
type: [String],
example: ['https://cdn.example.com/uploads/temp/xray_001.jpg'],
})
@IsArray({ message: '图片必须是数组' })
@ArrayMinSize(1, { message: '至少需要上传一张图片' })
@ArrayMaxSize(9, { message: '最多支持9张图片' })
@IsUrl({}, { each: true, message: '图片URL格式不正确' })
images: string[];
@ApiPropertyOptional({ description: '备注最多500字符', example: '体检常规项目' })
@IsOptional()
@IsString({ message: '备注必须是字符串' })
@MaxLength(500, { message: '备注最多500字符' })
note?: string;
}
/**
* 添加就医资料响应
*/
export class CreateMedicalRecordResponseDto {
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
code: number;
@ApiProperty({ description: '响应消息', example: '添加成功' })
message: string;
@ApiProperty({ description: '新创建的就医资料', type: MedicalRecordItemDto })
data: MedicalRecordItemDto;
}
/**
* 删除就医资料响应
*/
export class DeleteMedicalRecordResponseDto {
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
code: number;
@ApiProperty({ description: '响应消息', example: '删除成功' })
message: string;
}
/**
* 上传图片请求
*/
export class UploadMedicalFilesDto {
@ApiProperty({
description: '上传类型',
enum: UploadFileType,
example: UploadFileType.IMAGE,
})
@IsEnum(UploadFileType, { message: '上传类型必须是 image 或 document' })
type: UploadFileType;
}
/**
* 上传图片响应数据
*/
export class UploadMedicalFilesDataDto {
@ApiProperty({
description: '上传成功的文件URL列表',
type: [String],
example: ['https://cdn.example.com/uploads/temp/file_001.jpg'],
})
urls: string[];
}
/**
* 上传图片响应
*/
export class UploadMedicalFilesResponseDto {
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
code: number;
@ApiProperty({ description: '响应消息', example: '上传成功' })
message: string;
@ApiProperty({ description: '上传结果', type: UploadMedicalFilesDataDto })
data: UploadMedicalFilesDataDto;
}

View File

@@ -0,0 +1,30 @@
/**
* 健康档案相关枚举定义
*/
// 健康史分类
export enum HealthHistoryCategory {
ALLERGY = 'allergy', // 过敏史
DISEASE = 'disease', // 疾病史
SURGERY = 'surgery', // 手术史
FAMILY_DISEASE = 'familyDisease', // 家族疾病史
}
// 家庭成员角色
export enum FamilyRole {
OWNER = 'owner', // 创建者
ADMIN = 'admin', // 管理员
MEMBER = 'member', // 普通成员
}
// 就医资料类型
export enum MedicalRecordType {
MEDICAL_RECORD = 'medical_record', // 病历资料(检查报告、诊断证明等)
PRESCRIPTION = 'prescription', // 处方单据(处方单、用药清单等)
}
// 上传文件类型
export enum UploadFileType {
IMAGE = 'image', // 图片
DOCUMENT = 'document', // 文档PDF
}

View File

@@ -0,0 +1,241 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
UseGuards,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiParam } from '@nestjs/swagger';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
import { ResponseCode } from '../base.dto';
// Services
import { HealthProfilesService } from './health-profiles.service';
import { HealthHistoryService } from './services/health-history.service';
import { FamilyHealthService } from './services/family-health.service';
import { MedicalRecordsService } from './services/medical-records.service';
// DTOs
import {
UpdateHealthHistoryDto,
GetHealthHistoryResponseDto,
UpdateHealthHistoryCategoryResponseDto,
HealthHistoryProgressResponseDto,
} from './dto/health-history.dto';
import {
GenerateInviteCodeDto,
JoinFamilyGroupDto,
UpdateFamilyMemberDto,
GetFamilyGroupResponseDto,
FamilyInviteResponseDto,
GetFamilyMembersResponseDto,
} from './dto/family-health.dto';
import { GetHealthOverviewResponseDto } from './dto/health-overview.dto';
import {
CreateMedicalRecordDto,
GetMedicalRecordsResponseDto,
CreateMedicalRecordResponseDto,
DeleteMedicalRecordResponseDto,
} from './dto/medical-records.dto';
import { HealthHistoryCategory } from './enums/health-profile.enum';
@ApiTags('health-profiles')
@Controller('health-profiles')
@UseGuards(JwtAuthGuard)
export class HealthProfilesController {
private readonly logger = new Logger(HealthProfilesController.name);
constructor(
private readonly healthProfilesService: HealthProfilesService,
private readonly healthHistoryService: HealthHistoryService,
private readonly familyHealthService: FamilyHealthService,
private readonly medicalRecordsService: MedicalRecordsService,
) {}
// ==================== 健康档案概览 ====================
@Get('overview')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取健康档案概览' })
@ApiResponse({ status: 200, type: GetHealthOverviewResponseDto })
async getHealthOverview(@CurrentUser() user: AccessTokenPayload): Promise<GetHealthOverviewResponseDto> {
this.logger.log(`获取健康档案概览 - 用户ID: ${user.sub}`);
return this.healthProfilesService.getHealthOverview(user.sub);
}
// ==================== 健康史 API ====================
@Get('history')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取用户健康史' })
@ApiResponse({ status: 200, type: GetHealthHistoryResponseDto })
async getHealthHistory(@CurrentUser() user: AccessTokenPayload): Promise<GetHealthHistoryResponseDto> {
this.logger.log(`获取健康史 - 用户ID: ${user.sub}`);
const data = await this.healthHistoryService.getHealthHistory(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Put('history/:category')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新健康史分类' })
@ApiParam({ name: 'category', enum: HealthHistoryCategory, description: '健康史分类' })
@ApiBody({ type: UpdateHealthHistoryDto })
@ApiResponse({ status: 200, type: UpdateHealthHistoryCategoryResponseDto })
async updateHealthHistoryCategory(
@Param('category') category: HealthHistoryCategory,
@Body() updateDto: UpdateHealthHistoryDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<UpdateHealthHistoryCategoryResponseDto> {
this.logger.log(`更新健康史分类 - 用户ID: ${user.sub}, 分类: ${category}`);
const data = await this.healthHistoryService.updateHealthHistoryCategory(user.sub, category, updateDto);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Get('history/progress')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取健康史完成度' })
@ApiResponse({ status: 200, type: HealthHistoryProgressResponseDto })
async getHealthHistoryProgress(@CurrentUser() user: AccessTokenPayload): Promise<HealthHistoryProgressResponseDto> {
this.logger.log(`获取健康史完成度 - 用户ID: ${user.sub}`);
const data = await this.healthHistoryService.getHealthHistoryProgress(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
// ==================== 家庭健康管理 API ====================
@Get('family/group')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取家庭组' })
@ApiResponse({ status: 200, type: GetFamilyGroupResponseDto })
async getFamilyGroup(@CurrentUser() user: AccessTokenPayload): Promise<GetFamilyGroupResponseDto> {
this.logger.log(`获取家庭组 - 用户ID: ${user.sub}`);
const data = await this.familyHealthService.getFamilyGroup(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Post('family/group/invite')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '生成邀请码' })
@ApiBody({ type: GenerateInviteCodeDto })
@ApiResponse({ status: 200, type: FamilyInviteResponseDto })
async generateInviteCode(
@Body() dto: GenerateInviteCodeDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<FamilyInviteResponseDto> {
this.logger.log(`生成邀请码 - 用户ID: ${user.sub}`);
const data = await this.familyHealthService.generateInviteCode(user.sub, dto);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Post('family/group/join')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '加入家庭组' })
@ApiBody({ type: JoinFamilyGroupDto })
@ApiResponse({ status: 200, type: GetFamilyGroupResponseDto })
async joinFamilyGroup(
@Body() dto: JoinFamilyGroupDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<GetFamilyGroupResponseDto> {
this.logger.log(`加入家庭组 - 用户ID: ${user.sub}`);
const data = await this.familyHealthService.joinFamilyGroup(user.sub, dto);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Get('family/members')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取家庭成员列表' })
@ApiResponse({ status: 200, type: GetFamilyMembersResponseDto })
async getFamilyMembers(@CurrentUser() user: AccessTokenPayload): Promise<GetFamilyMembersResponseDto> {
this.logger.log(`获取家庭成员列表 - 用户ID: ${user.sub}`);
const data = await this.familyHealthService.getFamilyMembers(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Put('family/members/:memberId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新成员权限' })
@ApiParam({ name: 'memberId', description: '成员ID' })
@ApiBody({ type: UpdateFamilyMemberDto })
async updateFamilyMember(
@Param('memberId') memberId: string,
@Body() updateDto: UpdateFamilyMemberDto,
@CurrentUser() user: AccessTokenPayload,
) {
this.logger.log(`更新成员权限 - 用户ID: ${user.sub}, 成员ID: ${memberId}`);
const data = await this.familyHealthService.updateFamilyMember(user.sub, memberId, updateDto);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Delete('family/members/:memberId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '移除家庭成员' })
@ApiParam({ name: 'memberId', description: '成员ID' })
async removeFamilyMember(
@Param('memberId') memberId: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<{ code: number; message: string }> {
this.logger.log(`移除家庭成员 - 用户ID: ${user.sub}, 成员ID: ${memberId}`);
await this.familyHealthService.removeFamilyMember(user.sub, memberId);
return { code: ResponseCode.SUCCESS, message: 'success' };
}
@Post('family/leave')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '退出家庭组' })
async leaveFamilyGroup(@CurrentUser() user: AccessTokenPayload): Promise<{ code: number; message: string }> {
this.logger.log(`退出家庭组 - 用户ID: ${user.sub}`);
await this.familyHealthService.leaveFamilyGroup(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success' };
}
// ==================== 就医资料管理 API ====================
@Get('medical-records')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取就医资料列表' })
@ApiResponse({ status: 200, type: GetMedicalRecordsResponseDto })
async getMedicalRecords(@CurrentUser() user: AccessTokenPayload): Promise<GetMedicalRecordsResponseDto> {
this.logger.log(`获取就医资料列表 - 用户ID: ${user.sub}`);
const data = await this.medicalRecordsService.getMedicalRecords(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Post('medical-records')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '添加就医资料' })
@ApiBody({ type: CreateMedicalRecordDto })
@ApiResponse({ status: 201, type: CreateMedicalRecordResponseDto })
@ApiResponse({ status: 400, description: '请求参数错误' })
async createMedicalRecord(
@Body() dto: CreateMedicalRecordDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<CreateMedicalRecordResponseDto> {
this.logger.log(`添加就医资料 - 用户ID: ${user.sub}, 类型: ${dto.type}`);
const data = await this.medicalRecordsService.createMedicalRecord(user.sub, dto);
return { code: ResponseCode.SUCCESS, message: '添加成功', data };
}
@Delete('medical-records/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除就医资料' })
@ApiParam({ name: 'id', description: '资料记录ID' })
@ApiResponse({ status: 200, type: DeleteMedicalRecordResponseDto })
@ApiResponse({ status: 404, description: '资料不存在或已被删除' })
@ApiResponse({ status: 403, description: '无权限删除该资料' })
async deleteMedicalRecord(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<DeleteMedicalRecordResponseDto> {
this.logger.log(`删除就医资料 - 用户ID: ${user.sub}, 资料ID: ${id}`);
await this.medicalRecordsService.deleteMedicalRecord(user.sub, id);
return { code: ResponseCode.SUCCESS, message: '删除成功' };
}
}

View File

@@ -0,0 +1,58 @@
import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
// Models
import { HealthHistory } from './models/health-history.model';
import { HealthHistoryItem } from './models/health-history-item.model';
import { FamilyGroup } from './models/family-group.model';
import { FamilyMember } from './models/family-member.model';
import { MedicalRecord } from './models/medical-record.model';
// User models (for relations)
import { User } from '../users/models/user.model';
import { UserProfile } from '../users/models/user-profile.model';
// Controller
import { HealthProfilesController } from './health-profiles.controller';
// Services
import { HealthProfilesService } from './health-profiles.service';
import { HealthHistoryService } from './services/health-history.service';
import { FamilyHealthService } from './services/family-health.service';
import { MedicalRecordsService } from './services/medical-records.service';
// Modules
import { UsersModule } from '../users/users.module';
@Module({
imports: [
SequelizeModule.forFeature([
// Health History
HealthHistory,
HealthHistoryItem,
// Family Health
FamilyGroup,
FamilyMember,
// Medical Records
MedicalRecord,
// User models for relations
User,
UserProfile,
]),
forwardRef(() => UsersModule),
],
controllers: [HealthProfilesController],
providers: [
HealthProfilesService,
HealthHistoryService,
FamilyHealthService,
MedicalRecordsService,
],
exports: [
HealthProfilesService,
HealthHistoryService,
FamilyHealthService,
MedicalRecordsService,
],
})
export class HealthProfilesModule {}

View File

@@ -0,0 +1,115 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { UserProfile } from '../users/models/user-profile.model';
import { HealthHistoryService } from './services/health-history.service';
import { GetHealthOverviewResponseDto } from './dto/health-overview.dto';
import { ResponseCode } from '../base.dto';
@Injectable()
export class HealthProfilesService {
private readonly logger = new Logger(HealthProfilesService.name);
constructor(
@InjectModel(UserProfile)
private readonly userProfileModel: typeof UserProfile,
private readonly healthHistoryService: HealthHistoryService,
) {}
/**
* 获取健康档案概览
*/
async getHealthOverview(userId: string): Promise<GetHealthOverviewResponseDto> {
try {
// 1. 基础信息概览
const profile = await this.userProfileModel.findOne({
where: { userId },
});
const basicInfo = this.calculateBasicInfoOverview(profile);
// 2. 健康史概览
const healthHistoryProgress = await this.healthHistoryService.getHealthHistoryProgress(userId);
const healthHistory = {
progress: healthHistoryProgress.progress,
answeredCategories: Object.entries(healthHistoryProgress.details)
.filter(([_, answered]) => answered)
.map(([category]) => category),
pendingCategories: Object.entries(healthHistoryProgress.details)
.filter(([_, answered]) => !answered)
.map(([category]) => category),
};
// 3. 药物管理概览(需要从药物模块获取,这里先返回默认值)
// TODO: 注入 MedicationsService 获取实际数据
const medications = {
activeCount: 0,
todayCompletionRate: 0,
};
return {
code: ResponseCode.SUCCESS,
message: 'success',
data: {
basicInfo,
healthHistory,
medications,
},
};
} catch (error) {
this.logger.error(`获取健康档案概览失败: ${error instanceof Error ? error.message : '未知错误'}`);
return {
code: ResponseCode.ERROR,
message: `获取健康档案概览失败: ${error instanceof Error ? error.message : '未知错误'}`,
data: null as any,
};
}
}
/**
* 计算基础信息概览
*/
private calculateBasicInfoOverview(profile: UserProfile | null): {
progress: number;
data: {
height?: string;
weight?: string;
bmi?: string;
waistCircumference?: number;
};
} {
if (!profile) {
return { progress: 0, data: {} };
}
let filledCount = 0;
const totalFields = 3; // height, weight, waistCircumference
const data: any = {};
if (profile.height && profile.height > 0) {
filledCount++;
data.height = profile.height.toString();
}
if (profile.weight && profile.weight > 0) {
filledCount++;
data.weight = profile.weight.toString();
// 计算 BMI
if (profile.height && profile.height > 0) {
const heightInMeters = profile.height / 100;
const bmi = profile.weight / (heightInMeters * heightInMeters);
data.bmi = bmi.toFixed(1);
}
}
if (profile.waistCircumference && profile.waistCircumference > 0) {
filledCount++;
data.waistCircumference = profile.waistCircumference;
}
const progress = Math.round((filledCount / totalFields) * 100);
return { progress, data };
}
}

View File

@@ -0,0 +1,79 @@
import { Column, Model, Table, DataType, HasMany, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { User } from '../../users/models/user.model';
import { FamilyMember } from './family-member.model';
/**
* 家庭组表
*/
@Table({
tableName: 't_family_groups',
underscored: true,
})
export class FamilyGroup extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '家庭组ID',
})
declare id: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '创建者用户ID',
})
declare ownerId: string;
@Column({
type: DataType.STRING(100),
allowNull: false,
defaultValue: '我的家庭',
comment: '家庭组名称',
})
declare name: string;
@Column({
type: DataType.STRING(20),
allowNull: true,
unique: true,
comment: '邀请码',
})
declare inviteCode: string | null;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '邀请码过期时间',
})
declare inviteCodeExpiresAt: Date | null;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 6,
comment: '最大成员数',
})
declare maxMembers: number;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '创建时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '更新时间',
})
declare updatedAt: Date;
// 关联关系
@BelongsTo(() => User, 'ownerId')
declare owner: User;
@HasMany(() => FamilyMember, 'familyGroupId')
declare members: FamilyMember[];
}

View File

@@ -0,0 +1,97 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { FamilyRole } from '../enums/health-profile.enum';
import { User } from '../../users/models/user.model';
import { FamilyGroup } from './family-group.model';
/**
* 家庭成员表
*/
@Table({
tableName: 't_family_members',
underscored: true,
indexes: [
{
unique: true,
fields: ['family_group_id', 'user_id'],
},
],
createdAt: false,
updatedAt: false,
})
export class FamilyMember extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '成员记录ID',
})
declare id: string;
@ForeignKey(() => FamilyGroup)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '家庭组ID',
})
declare familyGroupId: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING(20),
allowNull: false,
defaultValue: FamilyRole.MEMBER,
comment: '角色owner | admin | member',
})
declare role: FamilyRole;
@Column({
type: DataType.STRING(50),
allowNull: true,
comment: '关系(如:配偶、父母、子女)',
})
declare relationship: string | null;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '是否可查看健康数据',
})
declare canViewHealthData: boolean;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否可管理健康数据',
})
declare canManageHealthData: boolean;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '是否接收异常提醒',
})
declare receiveAlerts: boolean;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '加入时间',
})
declare joinedAt: Date;
// 关联关系
@BelongsTo(() => FamilyGroup, 'familyGroupId')
declare familyGroup: FamilyGroup;
@BelongsTo(() => User, 'userId')
declare user: User;
}

View File

@@ -0,0 +1,86 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { User } from '../../users/models/user.model';
import { HealthHistory } from './health-history.model';
/**
* 健康史详情表
* 记录具体的过敏源、疾病、手术等信息
*/
@Table({
tableName: 't_health_history_items',
underscored: true,
})
export class HealthHistoryItem extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '详情ID',
})
declare id: string;
@ForeignKey(() => HealthHistory)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '关联的健康史ID',
})
declare healthHistoryId: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING(255),
allowNull: false,
comment: '名称(如:青霉素、高血压)',
})
declare name: string;
@Column({
type: DataType.DATEONLY,
allowNull: true,
comment: '确诊/发生日期',
})
declare diagnosisDate: string | null;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否为推荐选项',
})
declare isRecommendation: boolean;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '备注',
})
declare note: string | null;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '创建时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '更新时间',
})
declare updatedAt: Date;
// 关联关系
@BelongsTo(() => HealthHistory, 'healthHistoryId')
declare healthHistory: HealthHistory;
@BelongsTo(() => User, 'userId')
declare user: User;
}

View File

@@ -0,0 +1,64 @@
import { Column, Model, Table, DataType, HasMany, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { HealthHistoryCategory } from '../enums/health-profile.enum';
import { User } from '../../users/models/user.model';
import { HealthHistoryItem } from './health-history-item.model';
/**
* 健康史主表
* 记录用户各分类的健康史状态
*/
@Table({
tableName: 't_health_histories',
underscored: true,
})
export class HealthHistory extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '健康史记录ID',
})
declare id: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '健康史分类allergy | disease | surgery | familyDisease',
})
declare category: HealthHistoryCategory;
@Column({
type: DataType.BOOLEAN,
allowNull: true,
comment: '是否有该类健康史null=未回答, true=有, false=无',
})
declare hasHistory: boolean | null;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '创建时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '更新时间',
})
declare updatedAt: Date;
// 关联关系
@BelongsTo(() => User, 'userId')
declare user: User;
@HasMany(() => HealthHistoryItem, 'healthHistoryId')
declare items: HealthHistoryItem[];
}

View File

@@ -0,0 +1,10 @@
// Health History
export * from './health-history.model';
export * from './health-history-item.model';
// Family Health
export * from './family-group.model';
export * from './family-member.model';
// Medical Records
export * from './medical-record.model';

View File

@@ -0,0 +1,90 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { User } from '../../users/models/user.model';
import { MedicalRecordType } from '../enums/health-profile.enum';
/**
* 就医资料表
* 存储用户的病历资料和处方单据
*/
@Table({
tableName: 't_medical_records',
underscored: true,
paranoid: true, // 软删除
})
export class MedicalRecord extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '就医资料ID',
})
declare id: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING(20),
allowNull: false,
comment: '资料类型medical_record | prescription',
})
declare type: MedicalRecordType;
@Column({
type: DataType.STRING(100),
allowNull: false,
comment: '标题',
})
declare title: string;
@Column({
type: DataType.DATEONLY,
allowNull: false,
comment: '日期 (YYYY-MM-DD)',
})
declare date: string;
@Column({
type: DataType.JSON,
allowNull: false,
defaultValue: [],
comment: '图片/文件 URL 数组',
})
declare images: string[];
@Column({
type: DataType.STRING(500),
allowNull: true,
comment: '备注',
})
declare note: string | null;
@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 deletedAt: Date | null;
// 关联关系
@BelongsTo(() => User, 'userId')
declare user: User;
}

View File

@@ -0,0 +1,404 @@
import { Injectable, Logger, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
import { InjectModel, InjectConnection } from '@nestjs/sequelize';
import { Sequelize } from 'sequelize-typescript';
import { Op } from 'sequelize';
import { v4 as uuidv4 } from 'uuid';
import * as dayjs from 'dayjs';
import { FamilyGroup } from '../models/family-group.model';
import { FamilyMember } from '../models/family-member.model';
import { User } from '../../users/models/user.model';
import { FamilyRole } from '../enums/health-profile.enum';
import {
CreateFamilyGroupDto,
GenerateInviteCodeDto,
JoinFamilyGroupDto,
UpdateFamilyMemberDto,
FamilyGroupResponseDto,
FamilyMemberResponseDto,
} from '../dto/family-health.dto';
@Injectable()
export class FamilyHealthService {
private readonly logger = new Logger(FamilyHealthService.name);
constructor(
@InjectModel(FamilyGroup)
private readonly familyGroupModel: typeof FamilyGroup,
@InjectModel(FamilyMember)
private readonly familyMemberModel: typeof FamilyMember,
@InjectModel(User)
private readonly userModel: typeof User,
@InjectConnection()
private readonly sequelize: Sequelize,
) {}
/**
* 获取用户的家庭组
*/
async getFamilyGroup(userId: string): Promise<FamilyGroupResponseDto | null> {
// 先查找用户所属的家庭成员记录
const membership = await this.familyMemberModel.findOne({
where: { userId },
});
if (!membership) {
return null;
}
const familyGroup = await this.familyGroupModel.findOne({
where: { id: membership.familyGroupId },
include: [
{
model: FamilyMember,
as: 'members',
include: [{ model: User, as: 'user' }],
},
],
});
if (!familyGroup) {
return null;
}
return this.mapGroupToResponse(familyGroup);
}
/**
* 创建家庭组
*/
async createFamilyGroup(userId: string, createDto: CreateFamilyGroupDto): Promise<FamilyGroupResponseDto> {
// 检查用户是否已经有家庭组
const existingMembership = await this.familyMemberModel.findOne({
where: { userId },
});
if (existingMembership) {
throw new BadRequestException('您已经是一个家庭组的成员,请先退出当前家庭组');
}
const transaction = await this.sequelize.transaction();
try {
// 创建家庭组
const familyGroup = await this.familyGroupModel.create(
{
id: uuidv4(),
ownerId: userId,
name: createDto.name || '我的家庭',
},
{ transaction },
);
// 将创建者添加为 owner 成员
await this.familyMemberModel.create(
{
id: uuidv4(),
familyGroupId: familyGroup.id,
userId,
role: FamilyRole.OWNER,
canViewHealthData: true,
canManageHealthData: true,
receiveAlerts: true,
},
{ transaction },
);
await transaction.commit();
this.logger.log(`用户 ${userId} 创建家庭组 ${familyGroup.id} 成功`);
// 重新查询以获取完整数据
return this.getFamilyGroup(userId) as Promise<FamilyGroupResponseDto>;
} catch (error) {
await transaction.rollback();
this.logger.error(`创建家庭组失败: ${error instanceof Error ? error.message : '未知错误'}`);
throw error;
}
}
/**
* 获取或生成邀请码
* 如果用户没有家庭组,自动创建一个
* 如果已有有效邀请码,直接返回
*/
async generateInviteCode(
userId: string,
dto: GenerateInviteCodeDto,
): Promise<{ familyGroupId: string; inviteCode: string; expiresAt: string; qrCodeUrl: string }> {
let membership = await this.familyMemberModel.findOne({
where: { userId },
});
// 如果用户没有家庭组,自动创建一个
if (!membership) {
this.logger.log(`用户 ${userId} 没有家庭组,自动创建`);
await this.createFamilyGroup(userId, { name: '我的家庭' });
membership = await this.familyMemberModel.findOne({
where: { userId },
});
}
// 只有 owner 和 admin 可以生成邀请码
if (membership!.role === FamilyRole.MEMBER) {
throw new ForbiddenException('只有管理员可以生成邀请码');
}
const familyGroup = await this.familyGroupModel.findByPk(membership!.familyGroupId);
if (!familyGroup) {
throw new NotFoundException('家庭组不存在');
}
// 如果已有有效邀请码,直接返回
if (familyGroup.inviteCode && familyGroup.inviteCodeExpiresAt && dayjs(familyGroup.inviteCodeExpiresAt).isAfter(dayjs())) {
this.logger.log(`用户 ${userId} 获取家庭组 ${familyGroup.id} 的现有邀请码 ${familyGroup.inviteCode}`);
return {
familyGroupId: familyGroup.id,
inviteCode: familyGroup.inviteCode,
expiresAt: familyGroup.inviteCodeExpiresAt.toISOString(),
qrCodeUrl: `outlive://family/join?code=${familyGroup.inviteCode}`,
};
}
// 生成新邀请码
const inviteCode = this.generateUniqueInviteCode();
const expiresAt = dayjs().add(dto.expiresInHours || 24, 'hour').toDate();
familyGroup.inviteCode = inviteCode;
familyGroup.inviteCodeExpiresAt = expiresAt;
await familyGroup.save();
this.logger.log(`用户 ${userId} 为家庭组 ${familyGroup.id} 生成邀请码 ${inviteCode}`);
return {
familyGroupId: familyGroup.id,
inviteCode,
expiresAt: expiresAt.toISOString(),
qrCodeUrl: `outlive://family/join?code=${inviteCode}`,
};
}
/**
* 加入家庭组
*/
async joinFamilyGroup(userId: string, dto: JoinFamilyGroupDto): Promise<FamilyGroupResponseDto> {
// 检查用户是否已经有家庭组
const existingMembership = await this.familyMemberModel.findOne({
where: { userId },
});
if (existingMembership) {
throw new BadRequestException('您已经是一个家庭组的成员,请先退出当前家庭组');
}
// 查找邀请码对应的家庭组
const familyGroup = await this.familyGroupModel.findOne({
where: {
inviteCode: dto.inviteCode,
inviteCodeExpiresAt: { [Op.gt]: new Date() },
},
});
if (!familyGroup) {
throw new BadRequestException('邀请码无效或已过期');
}
// 检查成员数量
const memberCount = await this.familyMemberModel.count({
where: { familyGroupId: familyGroup.id },
});
if (memberCount >= familyGroup.maxMembers) {
throw new BadRequestException('家庭组已满员');
}
// 添加成员
await this.familyMemberModel.create({
id: uuidv4(),
familyGroupId: familyGroup.id,
userId,
role: FamilyRole.MEMBER,
canViewHealthData: true,
canManageHealthData: false,
receiveAlerts: true,
});
this.logger.log(`用户 ${userId} 加入家庭组 ${familyGroup.id}`);
return this.getFamilyGroup(userId) as Promise<FamilyGroupResponseDto>;
}
/**
* 获取家庭成员列表
*/
async getFamilyMembers(userId: string): Promise<FamilyMemberResponseDto[]> {
const membership = await this.familyMemberModel.findOne({
where: { userId },
});
if (!membership) {
throw new NotFoundException('您还没有家庭组');
}
const members = await this.familyMemberModel.findAll({
where: { familyGroupId: membership.familyGroupId },
include: [{ model: User, as: 'user' }],
});
return members.map(this.mapMemberToResponse);
}
/**
* 更新成员权限
*/
async updateFamilyMember(
userId: string,
memberId: string,
updateDto: UpdateFamilyMemberDto,
): Promise<FamilyMemberResponseDto> {
const currentMembership = await this.familyMemberModel.findOne({
where: { userId },
});
if (!currentMembership) {
throw new NotFoundException('您还没有家庭组');
}
// 只有 owner 和 admin 可以修改成员权限
if (currentMembership.role === FamilyRole.MEMBER) {
throw new ForbiddenException('只有管理员可以修改成员权限');
}
const targetMember = await this.familyMemberModel.findOne({
where: { id: memberId, familyGroupId: currentMembership.familyGroupId },
include: [{ model: User, as: 'user' }],
});
if (!targetMember) {
throw new NotFoundException('成员不存在');
}
// 不能修改 owner 的权限
if (targetMember.role === FamilyRole.OWNER && currentMembership.role !== FamilyRole.OWNER) {
throw new ForbiddenException('不能修改创建者的权限');
}
// 更新权限
if (updateDto.canViewHealthData !== undefined) targetMember.canViewHealthData = updateDto.canViewHealthData;
if (updateDto.canManageHealthData !== undefined) targetMember.canManageHealthData = updateDto.canManageHealthData;
if (updateDto.receiveAlerts !== undefined) targetMember.receiveAlerts = updateDto.receiveAlerts;
if (updateDto.relationship !== undefined) targetMember.relationship = updateDto.relationship;
await targetMember.save();
this.logger.log(`用户 ${userId} 更新成员 ${memberId} 的权限`);
return this.mapMemberToResponse(targetMember);
}
/**
* 移除家庭成员
*/
async removeFamilyMember(userId: string, memberId: string): Promise<void> {
const currentMembership = await this.familyMemberModel.findOne({
where: { userId },
});
if (!currentMembership) {
throw new NotFoundException('您还没有家庭组');
}
const targetMember = await this.familyMemberModel.findOne({
where: { id: memberId, familyGroupId: currentMembership.familyGroupId },
});
if (!targetMember) {
throw new NotFoundException('成员不存在');
}
// 不能移除 owner
if (targetMember.role === FamilyRole.OWNER) {
throw new ForbiddenException('不能移除创建者');
}
// 只有 owner 和 admin 可以移除成员,或者成员自己退出
if (
currentMembership.role === FamilyRole.MEMBER &&
currentMembership.id !== memberId
) {
throw new ForbiddenException('只有管理员可以移除成员');
}
await targetMember.destroy();
this.logger.log(`成员 ${memberId} 已从家庭组移除`);
}
/**
* 退出家庭组
*/
async leaveFamilyGroup(userId: string): Promise<void> {
const membership = await this.familyMemberModel.findOne({
where: { userId },
});
if (!membership) {
throw new NotFoundException('您还没有家庭组');
}
// owner 不能直接退出,需要先转让或解散
if (membership.role === FamilyRole.OWNER) {
throw new BadRequestException('创建者不能直接退出,请先转让管理权或解散家庭组');
}
await membership.destroy();
this.logger.log(`用户 ${userId} 退出家庭组`);
}
/**
* 生成唯一邀请码
*/
private generateUniqueInviteCode(): string {
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
let code = '';
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
/**
* 映射家庭组到响应 DTO
*/
private mapGroupToResponse(group: FamilyGroup): FamilyGroupResponseDto {
return {
id: group.id,
ownerId: group.ownerId,
name: group.name,
inviteCode: group.inviteCode || undefined,
inviteCodeExpiresAt: group.inviteCodeExpiresAt?.toISOString() || undefined,
maxMembers: group.maxMembers,
members: group.members?.map(this.mapMemberToResponse) || [],
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
};
}
/**
* 映射成员到响应 DTO
*/
private mapMemberToResponse(member: FamilyMember): FamilyMemberResponseDto {
return {
id: member.id,
userId: member.userId,
userName: member.user?.name || '未知用户',
userAvatar: member.user?.avatar || undefined,
role: member.role,
relationship: member.relationship || undefined,
canViewHealthData: member.canViewHealthData,
canManageHealthData: member.canManageHealthData,
receiveAlerts: member.receiveAlerts,
joinedAt: member.joinedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,222 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectModel, InjectConnection } from '@nestjs/sequelize';
import { Sequelize } from 'sequelize-typescript';
import { v4 as uuidv4 } from 'uuid';
import { HealthHistory } from '../models/health-history.model';
import { HealthHistoryItem } from '../models/health-history-item.model';
import { HealthHistoryCategory } from '../enums/health-profile.enum';
import {
UpdateHealthHistoryDto,
HealthHistoryCategoryResponseDto,
HealthHistoryItemResponseDto,
} from '../dto/health-history.dto';
import { HEALTH_HISTORY_RECOMMENDATIONS } from '../constants/health-recommendations';
@Injectable()
export class HealthHistoryService {
private readonly logger = new Logger(HealthHistoryService.name);
constructor(
@InjectModel(HealthHistory)
private readonly healthHistoryModel: typeof HealthHistory,
@InjectModel(HealthHistoryItem)
private readonly healthHistoryItemModel: typeof HealthHistoryItem,
@InjectConnection()
private readonly sequelize: Sequelize,
) {}
/**
* 获取用户所有健康史数据
*/
async getHealthHistory(userId: string): Promise<{
allergy: HealthHistoryCategoryResponseDto;
disease: HealthHistoryCategoryResponseDto;
surgery: HealthHistoryCategoryResponseDto;
familyDisease: HealthHistoryCategoryResponseDto;
}> {
const categories = Object.values(HealthHistoryCategory);
const result: any = {};
for (const category of categories) {
const history = await this.healthHistoryModel.findOne({
where: { userId, category },
include: [{ model: HealthHistoryItem, as: 'items' }],
});
result[category] = {
hasHistory: history?.hasHistory ?? null,
items: history?.items?.map(this.mapItemToResponse) ?? [],
};
}
return result;
}
/**
* 更新指定分类的健康史
*/
async updateHealthHistoryCategory(
userId: string,
category: HealthHistoryCategory,
updateDto: UpdateHealthHistoryDto,
): Promise<HealthHistoryCategoryResponseDto> {
const transaction = await this.sequelize.transaction();
try {
// 查找或创建健康史主记录
let history = await this.healthHistoryModel.findOne({
where: { userId, category },
transaction,
});
if (!history) {
history = await this.healthHistoryModel.create(
{
id: uuidv4(),
userId,
category,
hasHistory: updateDto.hasHistory,
},
{ transaction },
);
} else {
history.hasHistory = updateDto.hasHistory;
await history.save({ transaction });
}
// 如果 hasHistory 为 false清空所有 items
if (!updateDto.hasHistory) {
await this.healthHistoryItemModel.destroy({
where: { healthHistoryId: history.id },
transaction,
});
await transaction.commit();
return { hasHistory: false, items: [] };
}
// 处理 items - 全量更新模式
// 1. 获取现有的 items
const existingItems = await this.healthHistoryItemModel.findAll({
where: { healthHistoryId: history.id },
transaction,
});
const existingItemIds = new Set(existingItems.map(item => item.id));
// 2. 处理传入的 items
const newItemIds = new Set<string>();
const updatedItems: HealthHistoryItem[] = [];
for (const itemDto of updateDto.items) {
if (itemDto.id && existingItemIds.has(itemDto.id)) {
// 更新现有项
const existingItem = existingItems.find(i => i.id === itemDto.id);
if (existingItem) {
existingItem.name = itemDto.name;
existingItem.diagnosisDate = itemDto.date || null;
existingItem.isRecommendation = itemDto.isRecommendation ?? this.isRecommendation(category, itemDto.name);
existingItem.note = itemDto.note || null;
await existingItem.save({ transaction });
updatedItems.push(existingItem);
newItemIds.add(itemDto.id);
}
} else {
// 新增项
const newItem = await this.healthHistoryItemModel.create(
{
id: uuidv4(),
healthHistoryId: history.id,
userId,
name: itemDto.name,
diagnosisDate: itemDto.date || null,
isRecommendation: itemDto.isRecommendation ?? this.isRecommendation(category, itemDto.name),
note: itemDto.note || null,
},
{ transaction },
);
updatedItems.push(newItem);
newItemIds.add(newItem.id);
}
}
// 3. 删除不在新列表中的旧项
const itemsToDelete = existingItems.filter(item => !newItemIds.has(item.id));
for (const item of itemsToDelete) {
await item.destroy({ transaction });
}
await transaction.commit();
this.logger.log(`用户 ${userId} 更新健康史分类 ${category} 成功`);
return {
hasHistory: updateDto.hasHistory,
items: updatedItems.map(this.mapItemToResponse),
};
} catch (error) {
await transaction.rollback();
this.logger.error(`更新健康史失败: ${error instanceof Error ? error.message : '未知错误'}`);
throw error;
}
}
/**
* 获取健康史完成度
*/
async getHealthHistoryProgress(userId: string): Promise<{
progress: number;
details: {
allergy: boolean;
disease: boolean;
surgery: boolean;
familyDisease: boolean;
};
}> {
const categories = Object.values(HealthHistoryCategory);
const details: any = {};
let answeredCount = 0;
for (const category of categories) {
const history = await this.healthHistoryModel.findOne({
where: { userId, category },
});
// 只要回答了是否有历史hasHistory !== null就算已完成
const isAnswered = history?.hasHistory !== null && history?.hasHistory !== undefined;
details[category] = isAnswered;
if (isAnswered) answeredCount++;
}
const progress = Math.round((answeredCount / categories.length) * 100);
return { progress, details };
}
/**
* 获取推荐选项
*/
getRecommendations(category: HealthHistoryCategory): string[] {
return HEALTH_HISTORY_RECOMMENDATIONS[category] || [];
}
/**
* 判断是否为推荐选项
*/
private isRecommendation(category: HealthHistoryCategory, name: string): boolean {
const recommendations = HEALTH_HISTORY_RECOMMENDATIONS[category] || [];
return recommendations.includes(name);
}
/**
* 映射 item 到响应 DTO
*/
private mapItemToResponse(item: HealthHistoryItem): HealthHistoryItemResponseDto {
return {
id: item.id,
name: item.name,
date: item.diagnosisDate || undefined,
isRecommendation: item.isRecommendation,
note: item.note || undefined,
};
}
}

View File

@@ -0,0 +1,128 @@
import {
Injectable,
Logger,
NotFoundException,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { v4 as uuidv4 } from 'uuid';
import { MedicalRecord } from '../models/medical-record.model';
import { MedicalRecordType } from '../enums/health-profile.enum';
import {
CreateMedicalRecordDto,
MedicalRecordItemDto,
MedicalRecordsDataDto,
} from '../dto/medical-records.dto';
@Injectable()
export class MedicalRecordsService {
private readonly logger = new Logger(MedicalRecordsService.name);
constructor(
@InjectModel(MedicalRecord)
private readonly medicalRecordModel: typeof MedicalRecord,
) {}
/**
* 获取用户的就医资料列表
*/
async getMedicalRecords(userId: string): Promise<MedicalRecordsDataDto> {
this.logger.log(`获取就医资料列表 - 用户ID: ${userId}`);
const records = await this.medicalRecordModel.findAll({
where: { userId },
order: [['date', 'DESC'], ['createdAt', 'DESC']],
});
// 分类整理
const medicalRecords: MedicalRecordItemDto[] = [];
const prescriptions: MedicalRecordItemDto[] = [];
for (const record of records) {
const item = this.toMedicalRecordItemDto(record);
if (record.type === MedicalRecordType.MEDICAL_RECORD) {
medicalRecords.push(item);
} else {
prescriptions.push(item);
}
}
return {
records: medicalRecords,
prescriptions,
};
}
/**
* 添加就医资料
*/
async createMedicalRecord(
userId: string,
dto: CreateMedicalRecordDto,
): Promise<MedicalRecordItemDto> {
this.logger.log(`添加就医资料 - 用户ID: ${userId}, 类型: ${dto.type}`);
// 验证日期不能是未来日期
const recordDate = new Date(dto.date);
const today = new Date();
today.setHours(23, 59, 59, 999);
if (recordDate > today) {
throw new BadRequestException('日期不能是未来日期');
}
// 生成ID前缀
const idPrefix = dto.type === MedicalRecordType.MEDICAL_RECORD ? 'rec_' : 'presc_';
const id = `${idPrefix}${uuidv4().replace(/-/g, '').substring(0, 16)}`;
const record = await this.medicalRecordModel.create({
id,
userId,
type: dto.type,
title: dto.title,
date: dto.date,
images: dto.images,
note: dto.note || null,
});
this.logger.log(`就医资料添加成功 - ID: ${id}`);
return this.toMedicalRecordItemDto(record);
}
/**
* 删除就医资料
*/
async deleteMedicalRecord(userId: string, recordId: string): Promise<void> {
this.logger.log(`删除就医资料 - 用户ID: ${userId}, 资料ID: ${recordId}`);
const record = await this.medicalRecordModel.findByPk(recordId);
if (!record) {
throw new NotFoundException('资料不存在或已被删除');
}
if (record.userId !== userId) {
throw new ForbiddenException('无权限删除该资料');
}
// 软删除
await record.destroy();
this.logger.log(`就医资料删除成功 - ID: ${recordId}`);
}
/**
* 转换为 DTO
*/
private toMedicalRecordItemDto(record: MedicalRecord): MedicalRecordItemDto {
return {
id: record.id,
type: record.type,
title: record.title,
date: record.date,
images: record.images || [],
note: record.note || undefined,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
}

View File

@@ -27,14 +27,35 @@ async function bootstrap() {
app.use((req, res, next) => {
const startTime = Date.now();
// 捕获响应体
const originalSend = res.send;
let responseBody: any;
res.send = function (body) {
responseBody = body;
return originalSend.call(this, body);
};
res.on('finish', () => {
const duration = Date.now() - startTime;
const logMessage = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`;
const appVersion = req.headers['x-app-version'] || 'unknown';
const logMessage = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms [v${appVersion}]`;
// 解析响应体
let responseStr = '';
try {
if (typeof responseBody === 'string') {
responseStr = responseBody;
} else if (responseBody) {
responseStr = JSON.stringify(responseBody);
}
} catch {
responseStr = '[Unable to stringify response]';
}
if (res.statusCode >= 400) {
logger.error(`${logMessage} - Body: ${JSON.stringify(req.body)}`);
logger.error(`${logMessage} - Body: ${JSON.stringify(req.body)} - Response: ${responseStr}`);
} else {
logger.log(`${logMessage} - Body: ${JSON.stringify(req.body)}`);
logger.log(`${logMessage} - Body: ${JSON.stringify(req.body)} - Response: ${responseStr}`);
}
});

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,41 @@
import { ApiProperty } from '@nestjs/swagger';
export class MedicationPlanItemDto {
@ApiProperty({ description: '药物ID' })
id: string;
@ApiProperty({ description: '药物名称' })
name: string;
@ApiProperty({ description: '开始服药日期YYYY-MM-DD' })
startDate: string;
@ApiProperty({ description: '计划统计的天数从开始日期到今天或计划结束日期未来开始则为0' })
plannedDays: number;
@ApiProperty({ description: '计划每日服药次数' })
timesPerDay: number;
@ApiProperty({ description: '计划总服药次数plannedDays * timesPerDay' })
plannedDoses: number;
@ApiProperty({ description: '已打卡完成的次数' })
takenDoses: number;
@ApiProperty({ description: '完成率0-1之间的小数保留两位', example: 0.82 })
completionRate: number;
}
export class MedicationAiSummaryDto {
@ApiProperty({
description: '当前正在服用的药物列表',
type: [MedicationPlanItemDto],
})
medicationAnalysis: MedicationPlanItemDto[];
@ApiProperty({
description: 'AI 针对当前用药搭配的重点解读200字以内',
example: '当前方案以控制炎症与镇痛为主,请留意胃肠不适并按时复诊,避免自行叠加非甾体药物,如出现头晕或皮疹需及时就医。',
})
keyInsights: 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) {}

Some files were not shown because too many files have changed in this diff Show More