Compare commits

...

117 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
richarjiang
5c2c9dfae8 feat(diet-records): 新增营养成分表图片分析功能
- 添加营养成分表图片识别API接口,支持通过AI模型分析食物营养成分
- 新增NutritionAnalysisService服务,集成GLM-4.5V和Qwen VL视觉模型
- 实现营养成分提取和健康建议生成功能
- 添加完整的API文档和TypeScript类型定义
- 支持多种营养素类型识别,包括热量、蛋白质、脂肪等20+种营养素
2025-10-16 10:03:22 +08:00
richarjiang
cc83b84c80 feat(push): 新增设备推送和测试功能
- 新增基于设备令牌的推送通知接口
- 添加推送测试服务,支持应用启动时自动测试
- 新增推送测试文档说明
- 更新 APNS 配置和日志记录
- 迁移至 apns2 库的 PushType 枚举
- 替换订阅密钥文件
- 添加项目规则文档
2025-10-15 19:09:51 +08:00
richarjiang
38dd740c8c feat(push): migrate APNs provider from @parse/node-apn to apns2 library
- Replace @parse/node-apn with apns2 for improved APNs integration
- Update ApnsProvider to use new ApnsClient with modern API
- Refactor notification creation and sending logic for better error handling
- Add proper error event listeners for device token issues
- Update configuration interface to match apns2 requirements
- Modify push notification endpoints to allow public access for token registration
- Update service methods to handle new response format from apns2
- Add UsersModule dependency to PushNotificationsModule
2025-10-14 19:25:30 +08:00
richarjiang
305a969912 feat: 支持 push 2025-10-11 17:38:04 +08:00
a2ac667668 perf 2025-10-01 22:55:34 +08:00
4d1b8910f8 feat(challenges): 修改挑战列表过滤条件为只显示正在进行的挑战 2025-10-01 22:54:03 +08:00
richarjiang
999fc7f793 feat(challenges): 支持公开访问挑战列表与详情接口
- 在 GET /challenges、GET /challenges/:id、GET /challenges/:id/rankings 添加 @Public() 装饰器,允许未登录用户访问
- 将 userId 改为可选参数,未登录时仍可返回基础数据
- 列表接口过滤掉 UPCOMING 状态挑战,仅展示进行中/已结束
- 返回 DTO 新增 unit 字段,用于前端展示进度单位
- 鉴权守卫优化:公开接口若携带 token 仍尝试解析并注入 user,方便后续业务逻辑
2025-09-30 16:43:46 +08:00
richarjiang
87c3cbfac9 feat(challenges): 新增分页排行榜接口并重构排行逻辑
- 新增 GET /challenges/:id/rankings 接口,支持分页查询排行榜
- 抽离 buildChallengeRankings 方法,统一排行榜数据构建逻辑
- 新增 ChallengeRankingListDto 与 GetChallengeRankingQueryDto 用于接口数据校验
- 优化挑战列表排序逻辑,按状态优先级与时间排序
- 修复排行榜索引计算错误,确保分页场景下排名正确
2025-09-30 11:17:31 +08:00
richarjiang
f13953030b feat(challenges): 新增今日上报值与目标值字段至排行榜接口 2025-09-29 17:22:10 +08:00
richarjiang
12acbbd166 feat(challenges): 新增今日打卡状态字段并更新进度构建逻辑
- 在 ChallengeProgressDto 中增加 checkedInToday 字段
- 修改 buildChallengeProgress 方法,支持传入 lastProgressAt 参数
- 所有调用处同步更新,确保返回包含今日打卡状态
- 使用 dayjs 判断最后进度时间是否为今日
2025-09-29 17:12:54 +08:00
richarjiang
64460a9d68 feat(challenges): 新增挑战类型字段并重构进度上报逻辑
- 数据库新增 type 列区分 water/exercise/diet/mood/sleep/weight 六类挑战
- 进度上报由增量模式改为绝对值模式,字段 increment_value → reportedValue
- 服务层按 challenge.targetValue 判断当日是否完成,再按 minimumCheckInDays 统计总进度
- 相关 DTO 与模型同步更新,支持新类型返回

BREAKING CHANGE: 上报接口字段由 increment 改为 value,且为当日绝对值
2025-09-29 15:14:48 +08:00
richarjiang
d87fc84575 feat(challenges): 使用 minimumCheckInDays 统一进度目标计算
将挑战完成目标从 targetValue/progressUnit 改为 minimumCheckInDays 字段驱动,确保列表、详情、加入、打卡各场景使用一致的完成天数标准,并移除前端展示字段 badge/subtitle。
2025-09-29 10:25:20 +08:00
richarjiang
22fcf694a6 fix(db): 统一字符集排序规则并修复时间戳类型
- 新增 SQL 脚本统一表与列字符集为 utf8mb4_unicode_ci
- 移除建表语句冗余 COLLATE 子句,由全局配置控制
- 将挑战起止时间字段由 Date 改为 BIGINT 时间戳,避免时区与精度问题
- 补充 Winston 日志追踪挑战详情查询性能
- 数据库模块新增 charset 与 collate 全局配置,确保后续表一致性

BREAKING CHANGE: challenge.startAt/endAt 由 Date 变更为 number(毫秒时间戳),调用方需同步调整类型
2025-09-29 09:59:06 +08:00
richarjiang
ae8039c9ed feat(challenges): 新增每日进度上报防重复机制
- 创建 t_challenge_progress_reports 表记录用户每日上报
- 通过唯一索引 (challenge_id, user_id, report_date) 确保每日仅一次有效上报
- 更新 progress 时先写入报告表,冲突则直接返回当前进度
- 模块中新增 ChallengeProgressReport 模型及相关依赖
2025-09-28 12:13:31 +08:00
richarjiang
1b7132a325 feat(challenges): 新增挑战功能模块及完整接口实现
- 新增挑战列表、详情、加入/退出、进度上报等 REST 接口
- 定义 Challenge / ChallengeParticipant 数据模型与状态枚举
- 提供排行榜查询与用户排名计算
- 包含接口文档与数据库初始化脚本
2025-09-28 12:02:39 +08:00
richarjiang
8e51994e71 feat: 支持会员编号 2025-09-26 08:48:22 +08:00
richarjiang
21b00cee0d fix: 优化围度数据计算逻辑 2025-09-24 18:03:32 +08:00
richarjiang
c9eda4577f feat(users): 新增围度分析报表接口
- 添加 dayjs 依赖用于日期处理
- 新增 GetBodyMeasurementAnalysisDto 和 GetBodyMeasurementAnalysisResponseDto
- 支持按周、月、年三种时间范围统计围度变化趋势
- 实现最近数据点匹配算法,返回各围度类型最接近时间点的测量值
2025-09-24 17:51:57 +08:00
richarjiang
e2fcb1c428 feat(users): 添加身体围度测量功能
新增用户身体围度的完整功能模块,包括数据库迁移、模型定义、API接口和历史记录追踪。
支持胸围、腰围、上臀围、臂围、大腿围、小腿围六项身体围度指标的管理。

- 添加数据库迁移脚本,扩展用户档案表字段
- 创建围度历史记录表用于数据追踪
- 实现围度数据的更新和历史查询API
- 添加数据验证和错误处理机制
2025-09-22 09:49:42 +08:00
dc06dfbebd perf:修复健康数据 2025-09-12 23:00:49 +08:00
richarjiang
cf02fda4ec chore: 调整用户控制器日志级别并新增文件信息记录 2025-09-12 15:48:39 +08:00
richarjiang
090b91e72d feat: 支持图片上传接口 2025-09-12 14:23:18 +08:00
richarjiang
97e6a0ff6d feat: 添加GLM-4.5V和DashScope模型支持,优化饮食分析服务的API调用 2025-09-04 10:16:24 +08:00
richarjiang
d34f752776 feat: 新增食物识别API,调整字段名,扩展识别功能与提示逻辑 2025-09-04 09:36:07 +08:00
richarjiang
02f21f0858 feat(water-records): 优化创建喝水记录和获取记录列表的日期处理逻辑 2025-09-02 15:27:21 +08:00
richarjiang
730b1df35e feat: 更新喝水统计功能,支持指定日期查询并返回相应数据 2025-09-02 11:32:22 +08:00
richarjiang
2c2e964199 feat(water-records): 新增喝水记录功能模块
新增完整的喝水记录管理功能,支持用户记录每日喝水情况、设置目标和查看统计信息。功能包括:

- 创建、查询、更新和删除喝水记录
- 设置和管理每日喝水目标
- 获取今日喝水统计和完成率分析
- 支持分页查询和日期范围筛选
- 完整的数据验证和错误处理机制

该模块已从用户模块中独立出来,提供REST API接口,包含数据库迁移脚本和详细文档。
2025-09-01 11:02:13 +08:00
richarjiang
0488fe62a1 feat: 添加食物识别功能,支持根据图片URL识别食物并转换为饮食记录格式 2025-08-31 14:14:33 +08:00
d0b02b6228 feat: 新增用户食物收藏功能
- 创建用户食物收藏表 `t_user_food_favorites`,用于存储用户收藏的食物信息。
- 在 `FoodLibraryController` 中添加收藏和取消收藏食物的 API 接口。
- 在 `FoodLibraryService` 中实现收藏和取消收藏的业务逻辑,并获取用户收藏的食物 ID 列表。
- 更新 DTO 以支持食物是否已收藏的状态。
2025-08-29 21:03:55 +08:00
richarjiang
6542988cb6 feat: 支持用户自定义食物 2025-08-29 15:57:28 +08:00
richarjiang
c0bdb3bf0a perf: 优化饮食记录代码 2025-08-29 10:13:26 +08:00
richarjiang
8a69f4f1af perf 2025-08-29 09:44:35 +08:00
richarjiang
74faebd73d feat: 新增食物库模块,含模型、服务、API及初始化数据 2025-08-29 09:06:18 +08:00
richarjiang
a1c21d8a23 feat: 新增饮食记录模块,含增删改查及营养汇总功能 2025-08-29 08:48:22 +08:00
richarjiang
17ee96638e feat: 新增体重记录接口及枚举,优化AI教练选择项处理 2025-08-28 09:46:03 +08:00
richarjiang
e3cd496f33 feat(users): 更新用户接口添加EFICHOPER(builderId,u-formatIdFast生成提)录制 2025-08-27 19:05:30 +08:00
richarjiang
04903426d1 refactor: 移除使用次数扣除逻辑,调整活动类型及记录逻辑 2025-08-27 19:01:21 +08:00
richarjiang
c3961150ab feat: 优化AI教练聊天逻辑,增加用户聊天次数检查和响应内容
- 在AI教练控制器中添加用户聊天次数检查,若次数用完则返回相应提示信息。
- 更新AI聊天响应DTO,新增用户剩余聊天次数和AI回复文本字段,提升用户体验。
- 修改用户服务,支持初始体重和目标体重字段的更新,增强用户资料的完整性。
2025-08-27 14:22:25 +08:00
richarjiang
79aa300aa1 feat: 在用户资料中添加活动水平字段
- 更新用户资料相关逻辑,新增活动水平字段,支持用户在更新资料时提供活动水平信息。
- 修改相关DTO和模型,确保活动水平字段的有效性和数据一致性。
- 更新用户响应数据结构,包含活动水平信息,提升用户体验和数据完整性。
2025-08-27 10:00:18 +08:00
richarjiang
a8c67ceb17 feat: 为新用户添加默认头像字段
- 在用户创建逻辑中新增avatar字段,设置默认头像链接,提升用户体验和个性化展示。
2025-08-27 09:12:15 +08:00
8aca29e2b3 feat: 更新目标修改逻辑,增加日志记录和日期处理优化
- 在updateGoal方法中添加日志记录,便于调试和监控目标更新过程。
- 优化目标更新时的日期处理逻辑,确保endDate字段在未提供时设置为undefined,提升数据的灵活性。
2025-08-26 22:12:46 +08:00
475f928990 feat: 优化目标任务生成逻辑,增加自定义重复规则支持
- 在GoalTaskService中添加对自定义重复规则的支持,允许用户指定生成任务的星期几。
- 增加日志记录,便于调试和监控任务生成过程。
- 确保生成的任务不会与现有任务冲突,提升任务管理的灵活性和准确性。
2025-08-24 09:46:24 +08:00
cba56021de feat: 删除心情打卡和目标子任务API测试文件
- 移除test-goal-tasks.http和test-mood-checkins.http文件,清理不再使用的测试文件。
- 更新GoalsService中的目标删除逻辑,增加事务处理以确保数据一致性。
- 优化GoalTaskService中的任务生成逻辑,增加日志记录以便于调试和监控。
2025-08-23 14:31:15 +08:00
richarjiang
f6b4c99e75 feat: 移除获取目标和任务详情的API及相关逻辑
- 删除GoalsController和GoalsService中获取单个目标和任务详情的API实现,简化代码结构。
- 更新GoalTaskService中的分页参数,将每页数量调整为200,提升数据处理能力。
- 优化GoalTaskQueryDto,移除页码和每页数量的验证装饰器,简化DTO结构。
2025-08-22 17:23:14 +08:00
richarjiang
3530d123fc feat: 新增目标子任务管理功能模块
- 实现目标子任务的完整功能,包括数据库表设计、API接口、业务逻辑和文档说明。
- 支持用户创建、管理和跟踪目标子任务,提供增删改查操作及任务完成记录功能。
- 引入惰性任务生成机制,优化任务管理体验,提升系统性能和用户交互。
2025-08-22 16:01:12 +08:00
richarjiang
062a78a839 feat: 优化目标创建逻辑,处理日期和时间字段的空值情况
- 更新目标创建逻辑,确保在创建目标时,startDate、startTime和endTime字段可以处理空值,提升数据的灵活性和安全性。
2025-08-22 11:22:55 +08:00
richarjiang
acf8d0c48c feat: 更新目标管理模块,优化数据库表结构和API逻辑
- 修改目标表和目标完成记录表的字段类型,增强数据一致性和查询性能。
- 移除不必要的搜索字段,简化目标查询DTO,提升查询效率。
- 引入目标状态枚举,增强代码可读性和维护性。
- 添加复合索引以优化查询性能,提升系统响应速度。
- 更新目标管理控制器和服务逻辑,确保与新数据库结构的兼容性。
2025-08-22 11:22:27 +08:00
richarjiang
ffc0cd1d13 feat: 更新心情打卡功能,优化用户信息处理
- 将用户身份信息从字符串类型改为AccessTokenPayload对象,增强代码可读性和安全性。
- 移除心情打卡DTO中的metadata字段,简化数据结构,提升性能。
- 更新心情打卡服务,去除不必要的metadata处理逻辑,优化数据存储。
2025-08-22 08:55:35 +08:00
270b59c599 feat: 新增目标管理功能模块
实现目标管理的完整功能,包括数据库表设计、API接口、业务逻辑和文档说明。支持用户创建、管理和跟踪个人目标,提供增删改查操作及目标完成记录功能。
2025-08-21 22:50:30 +08:00
richarjiang
f26d8e64c6 feat: 新增心情打卡功能模块
实现心情打卡的完整功能,包括数据库表设计、API接口、业务逻辑和文档说明。支持记录多种心情类型、强度评分和统计分析功能。
2025-08-21 15:20:05 +08:00
richarjiang
513d6e071d refactor(users): 更新用户活动表名并添加日志记录
将用户活动表名从'user_activities'改为't_user_activities',并在服务中添加详细的日志记录逻辑。
2025-08-21 14:52:09 +08:00
richarjiang
73f53ac5e4 feat: 生成活动接口 2025-08-21 14:28:15 +08:00
richarjiang
94e1b124df feat: 更新AI教练服务,增强用户体重记录和分析功能
- 新增流式聊天处理逻辑,支持用户选择和指令解析,提升交互体验。
- 实现体重记录的确认和趋势分析功能,用户可查看体重变化及健康建议。
- 扩展DTO,增加交互类型以支持新的功能,确保数据结构的完整性。
- 优化错误处理和日志记录,提升系统稳定性和可维护性。
2025-08-21 10:24:37 +08:00
richarjiang
4cd8d59f12 优化GetDietHistoryQueryDto,移除limit和page属性的验证装饰器,简化DTO结构。 2025-08-19 14:24:53 +08:00
richarjiang
8e27e3d3e3 feat: 增强饮食分析服务,支持文本饮食记录处理
- 新增分析用户文本中的饮食信息功能,自动记录饮食信息并提供营养分析。
- 优化饮食记录处理逻辑,支持无图片的文本记录,提升用户体验。
- 添加单元测试,确保文本分析功能的准确性和稳定性。
- 更新相关文档,详细说明新功能的使用方法和示例。
2025-08-19 08:58:52 +08:00
richarjiang
a56d1d5255 feat: 更新AI教练控制器,增加用户聊天次数管理功能
- 在AI教练控制器中引入用户聊天次数的检查,确保用户在进行对话前有足够的聊天次数。
- 新增用户服务方法以获取和扣减用户的聊天次数,优化用户体验。
- 调整默认免费聊天次数为5次,提升系统的使用限制管理。
2025-08-18 19:20:01 +08:00
richarjiang
ede5730647 feat: 实现饮食记录确认流程
- 新增饮食记录确认流程,将自动记录模式升级为用户确认模式,提升用户交互体验。
- 实现两阶段饮食记录流程,支持AI识别食物并生成确认选项,用户选择后记录到数据库并提供营养分析。
- 扩展DTO层,新增相关数据结构以支持确认流程。
- 更新服务层,新增处理确认逻辑的方法,优化饮食记录的创建流程。
- 增强API文档,详细说明新流程及使用建议,确保开发者理解和使用新功能。
2025-08-18 18:59:36 +08:00
richarjiang
485ba1f67c feat: 新增饮食记录和分析功能
- 创建饮食记录相关的数据库模型、DTO和API接口,支持用户手动添加和AI视觉识别记录饮食。
- 实现饮食分析服务,提供营养分析和健康建议,优化AI教练服务以集成饮食分析功能。
- 更新用户控制器,添加饮食记录的增删查改接口,增强用户饮食管理体验。
- 提供详细的API使用指南和数据库创建脚本,确保功能的完整性和可用性。
2025-08-18 16:27:01 +08:00
richarjiang
3d36ee90f0 feat: 更新AI教练服务,支持处理多个图片URL,优化饮食分析逻辑。调整相关DTO以支持数组格式的图片URL,并修改默认视觉模型为'qwen-vl-max'。 2025-08-18 15:07:47 +08:00
richarjiang
eb71f845e5 整合体重和饮食指令处理逻辑,优化AI教练服务,支持通过指令解析用户输入。更新系统提示以提供个性化健康建议,并增强饮食图片分析功能。移除冗余的体重识别逻辑,简化代码结构。 2025-08-18 10:05:11 +08:00
e358b3d2fd 增强营养分析功能,更新系统提示以支持营养分析师角色,添加相关DTO以处理饮食记录和营养目标。同时,优化AI教练服务逻辑,识别营养相关话题并调整响应内容。 2025-08-17 20:29:35 +08:00
269 changed files with 38854 additions and 2610 deletions

24
.env.glm.example Normal file
View File

@@ -0,0 +1,24 @@
# GLM-4.5V Configuration Example
# Copy this to your .env file and update with your actual API key
# AI Vision Provider - set to 'glm' to use GLM-4.5V, 'dashscope' for Qwen (default)
AI_VISION_PROVIDER=glm
# GLM-4.5V API Configuration
GLM_API_KEY=your_glm_api_key_here
GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
# GLM Model Names
GLM_MODEL=glm-4-flash
GLM_VISION_MODEL=glm-4v-plus
# Alternative: Use GLM-4.5V models (if available)
# GLM_MODEL=glm-4.5
# GLM_VISION_MODEL=glm-4.5v
# DashScope Configuration (fallback/default)
# Keep these for fallback or if you want to switch between providers
DASHSCOPE_API_KEY=your_dashscope_api_key_here
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
DASHSCOPE_MODEL=qwen-flash
DASHSCOPE_VISION_MODEL=qwen-vl-max

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**: 自动化构建和部署流水线

11
.kilocode/rules/rule.md Normal file
View File

@@ -0,0 +1,11 @@
# rule.md
你是一名拥有 20 年服务端开发经验的 javascript 工程师,这是一个 nodejs 基于 nestjs 框架的项目,与健康、健身、减肥相关
## 指导原则
- 不要随意新增 markdown 文档
- 代码提交 message 用中文
- 注意代码的可读性、架构实现要清晰
- 不要随意新增示例文件
- 接口规范: 接口的返回都需要遵循 base.dto.ts 文件中的规范

4
.vscode/settings.json vendored Normal file
View File

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

125
CLAUDE.md Normal file
View File

@@ -0,0 +1,125 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Core Development
- `yarn start:dev` - Start development server with hot reload
- `yarn start:debug` - Start development server with debugging enabled
- `yarn build` - Build production bundle
- `yarn start:prod` - Start production server from built files
### Testing
- `yarn test` - Run unit tests
- `yarn test:watch` - Run tests in watch mode
- `yarn test:cov` - Run tests with coverage report
- `yarn test:e2e` - Run end-to-end tests
- `yarn test:debug` - Run tests with debugging
### Code Quality
- `yarn lint` - Run ESLint with auto-fix
- `yarn format` - Format code with Prettier
### Production Deployment
- `yarn pm2:start` - Start with PM2 in production mode
- `yarn pm2:start:dev` - Start with PM2 in development mode
- `yarn pm2:status` - Check PM2 process status
- `yarn pm2:logs` - View PM2 logs
- `yarn pm2:restart` - Restart PM2 processes
### Deployment Scripts
- `./deploy-optimized.sh` - Recommended deployment (builds on server)
- `./deploy.sh` - Full deployment with options (`--help` for usage)
- `./deploy-simple.sh` - Basic deployment script
## Architecture Overview
### Core Framework
This is a **NestJS-based fitness and health tracking API** using TypeScript, MySQL with Sequelize ORM, and JWT authentication. The architecture follows NestJS conventions with modular design.
### Module Structure
The application is organized into domain-specific modules:
**Health & Fitness Core:**
- `users/` - User management, authentication (Apple Sign-In, guest), payments, subscriptions
- `diet-records/` - Food logging and nutrition tracking integration
- `food-library/` - Food database with categories and nutritional information
- `exercises/` - Exercise library with categories and instructions
- `training-plans/` - Workout plan management and scheduling
- `workouts/` - Workout session tracking and history
- `goals/` - Goal setting with task management system
- `mood-checkins/` - Mental health and mood tracking
**AI & Intelligence:**
- `ai-coach/` - OpenAI-powered fitness coaching with diet analysis
- `recommendations/` - Personalized content recommendation engine
**Content & Social:**
- `articles/` - Health and fitness article management
- `checkins/` - User check-in and progress tracking
- `activity-logs/` - User activity and engagement tracking
### Key Architectural Patterns
**Database Layer:**
- Sequelize ORM with MySQL
- Models use `@Table` and `@Column` decorators from `sequelize-typescript`
- Database configuration in `database.module.ts` with async factory pattern
- Auto-loading models with `autoLoadModels: true`
**Authentication & Security:**
- JWT-based authentication with refresh tokens
- Apple Sign-In integration via `apple-auth.service.ts`
- AES-256-GCM encryption service for sensitive data
- Custom `@CurrentUser()` decorator and `JwtAuthGuard`
**API Design:**
- Controllers use Swagger decorators for API documentation
- DTOs for request/response validation using `class-validator`
- Base DTO pattern in `base.dto.ts` for consistent responses
- Encryption support for sensitive endpoints
**Logging & Monitoring:**
- Winston logging with daily rotation
- Separate log files: app, error, debug, exceptions, rejections
- PM2 clustering with memory limits (1GB)
- Structured logging with context and metadata
### Configuration Management
- Environment-based configuration using `@nestjs/config`
- Global configuration module
- Environment variables for database, JWT, Apple auth, encryption keys
- Production/development environment separation
### External Integrations
- **OpenAI**: AI coaching and diet analysis
- **Apple**: Sign-in and purchase verification
- **RevenueCat**: Subscription management webhooks
- **Tencent Cloud COS**: File storage service
### Testing Strategy
- Unit tests with Jest (`*.spec.ts` files)
- E2E tests in `test/` directory
- Test configuration in `package.json` jest section
- Encryption service has comprehensive test coverage
### Development Patterns
- Each module follows NestJS structure: controller → service → model
- Services are injected using `@Injectable()` decorator
- Models are Sequelize entities with TypeScript decorators
- DTOs handle validation and transformation
- Guards handle authentication and authorization
## Database Schema Patterns
SQL scripts in `sql-scripts/` directory contain table creation scripts organized by feature:
- `*-tables-create.sql` for table definitions
- `*-sample-data.sql` for seed data
- Migration scripts for database upgrades
## Production Environment
- **Server**: 129.204.155.94
- **Deployment Path**: `/usr/local/web/pilates-server`
- **Ports**: 3002 (production), 3001 (development)
- **Process Management**: PM2 with cluster mode
- **Logging**: Daily rotated logs in `logs/` directory

View File

@@ -0,0 +1,130 @@
# 用户自定义食物功能实现总结
## 实现概述
已成功实现用户添加自定义食物的功能包括数据库表设计、后端API接口和完整的业务逻辑。用户可以创建、查看、搜索和删除自己的自定义食物这些食物会与系统食物一起显示在食物库中。
## 实现的功能
### 1. 数据库层面
- ✅ 创建了 `t_user_custom_foods`
- ✅ 包含与系统食物库相同的营养字段
- ✅ 通过 `user_id` 字段关联用户
- ✅ 通过外键约束确保分类的有效性
### 2. 模型层面
- ✅ 创建了 `UserCustomFood` Sequelize模型
- ✅ 定义了完整的字段映射和关联关系
- ✅ 更新了食物库模块以包含新模型
### 3. 服务层面
- ✅ 扩展了 `FoodLibraryService` 以支持用户自定义食物
- ✅ 实现了创建自定义食物的方法
- ✅ 实现了删除自定义食物的方法
- ✅ 更新了获取食物库列表的方法,合并系统食物和用户自定义食物
- ✅ 更新了搜索食物的方法,包含用户自定义食物
- ✅ 更新了获取食物详情的方法,支持系统食物和自定义食物
### 4. 控制器层面
- ✅ 添加了创建自定义食物的 POST 接口
- ✅ 添加了删除自定义食物的 DELETE 接口
- ✅ 更新了现有接口以支持用户认证和自定义食物
- ✅ 添加了完整的 Swagger 文档注解
### 5. DTO层面
- ✅ 创建了 `CreateCustomFoodDto` 用于创建自定义食物
- ✅ 添加了完整的验证规则
- ✅ 扩展了 `FoodItemDto` 以标识是否为自定义食物
## 核心特性
### 权限控制
- 所有接口都需要用户认证
- 用户只能看到和操作自己的自定义食物
- 系统食物对所有用户可见
### 数据隔离
- 用户自定义食物通过 `user_id` 字段实现数据隔离
- 搜索和列表查询都会自动过滤用户权限
### 智能合并
- 获取食物库列表时,自动合并系统食物和用户自定义食物
- 常见分类只显示系统食物,其他分类显示合并后的食物
- 搜索结果中用户自定义食物优先显示
### 数据验证
- 食物名称和分类键为必填项
- 营养成分有合理的数值范围限制
- 分类键必须是有效的系统分类
## API接口
### 获取食物库列表
```
GET /food-library
Authorization: Bearer <token>
```
### 搜索食物
```
GET /food-library/search?keyword=关键词
Authorization: Bearer <token>
```
### 创建自定义食物
```
POST /food-library/custom
Authorization: Bearer <token>
Content-Type: application/json
```
### 删除自定义食物
```
DELETE /food-library/custom/{id}
Authorization: Bearer <token>
```
### 获取食物详情
```
GET /food-library/{id}
Authorization: Bearer <token>
```
## 文件清单
### 新增文件
- `sql-scripts/user-custom-foods-table.sql` - 数据库表创建脚本
- `src/food-library/models/user-custom-food.model.ts` - 用户自定义食物模型
- `src/food-library/USER_CUSTOM_FOODS.md` - 功能说明文档
- `test-custom-foods.sh` - 功能测试脚本
### 修改文件
- `src/food-library/food-library.module.ts` - 添加新模型到模块
- `src/food-library/food-library.service.ts` - 扩展服务以支持自定义食物
- `src/food-library/food-library.controller.ts` - 添加新接口和更新现有接口
- `src/food-library/dto/food-library.dto.ts` - 添加新DTO和扩展现有DTO
## 使用说明
1. **运行数据库脚本**:执行 `sql-scripts/user-custom-foods-table.sql` 创建用户自定义食物表
2. **重启应用**重启NestJS应用以加载新的模型和接口
3. **测试功能**:使用 `test-custom-foods.sh` 脚本测试各个接口(需要先获取有效的访问令牌)
4. **前端集成**前端可以通过新的API接口实现用户自定义食物的增删查功能
## 注意事项
- 所有接口都需要用户认证,确保在请求头中包含有效的 Bearer token
- 创建自定义食物时,分类键必须是系统中已存在的分类
- 用户只能删除自己创建的自定义食物
- 营养成分字段都是可选的,但建议提供准确的营养信息
## 扩展建议
1. **图片上传**:可以添加图片上传功能,让用户为自定义食物添加图片
2. **营养计算**:可以添加营养成分的自动计算功能
3. **食物分享**:可以考虑添加用户间分享自定义食物的功能
4. **批量导入**:可以添加批量导入自定义食物的功能
5. **食物模板**:可以提供常见食物的营养模板,方便用户快速创建

View File

@@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgZM2yBrDe1RyBvk+V
UrMDhiiUjNhmqyYizbj++CUgleOgCgYIKoZIzj0DAQehRANCAASvI6b4Japk/hyH
GGTMQZEdo++TRs8/9dyVic271ERjQbIFCXOkKiASgyObxih2RuessC/t2+VPZx4F
Db0U/xrS
-----END PRIVATE KEY-----

View File

@@ -1,6 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgONlcciOyI4UqtLhW
4EwWvkjRybvNNg15/m6voi4vx0agCgYIKoZIzj0DAQehRANCAAQeTAmBTidpkDwT
FWUrxN+HfXhKbiDloQ68fc//+jeVQtC5iUKOZp38P/IqI+9lUIWoLKsryCxKeAkb
8U5D2WWu
-----END PRIVATE KEY-----

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 "停止旧的应用实例..."
pm2 delete $PROJECT_NAME 2>/dev/null || true
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 "启动新的应用实例..."
pm2 start ecosystem.config.js --env production
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配置
pm2 save
pm2 startup
if command -v pm2 &> /dev/null; then
pm2 save
pm2 startup
else
\$PM2_CMD save
\$PM2_CMD startup
fi
echo "部署完成!"
pm2 status
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

@@ -0,0 +1,192 @@
# 饮食分析功能重构总结
## 重构目标
原本的 `AiCoachService` 类承担了太多职责,包括:
- AI 对话管理
- 体重记录分析
- 饮食图片识别
- 营养数据分析
- 用户上下文构建
这导致代码可读性差、维护困难、职责不清。因此我们将饮食分析相关功能抽取成独立的服务。
## 重构方案
### 1. 创建独立的饮食分析服务
**新文件**: `src/ai-coach/services/diet-analysis.service.ts`
**职责分离**:
```typescript
// 原来 AiCoachService 的职责
class AiCoachService {
- AI 对话管理 (保留)
- 体重记录分析 (保留)
- 饮食图片识别 (移除)
- 营养数据分析 (移除)
- 用户上下文构建 (移除)
}
// 新的 DietAnalysisService 职责
class DietAnalysisService {
+ 饮食图片识别 (专门负责)
+ 营养数据分析 (专门负责)
+ 饮食上下文构建 (专门负责)
+ 饮食记录处理 (专门负责)
}
```
### 2. 功能模块化设计
#### DietAnalysisService 主要方法:
1. **`analyzeDietImageEnhanced()`** - 增强版图片分析
2. **`processDietRecord()`** - 处理饮食记录并保存到数据库
3. **`buildUserNutritionContext()`** - 构建用户营养信息上下文
4. **`buildEnhancedDietAnalysisPrompt()`** - 构建分析提示
#### 私有辅助方法:
- `getSuggestedMealType()` - 根据时间推断餐次
- `buildDietAnalysisPrompt()` - 构建AI分析提示
- `parseAndValidateResult()` - 解析和验证AI结果
- `buildNutritionSummaryText()` - 构建营养汇总文本
- `buildMealDistributionText()` - 构建餐次分布文本
- `buildRecentMealsText()` - 构建最近饮食详情文本
- `buildNutritionTrendText()` - 构建营养趋势文本
### 3. 接口标准化
**导出接口**:
```typescript
export interface DietAnalysisResult {
shouldRecord: boolean;
confidence: number;
extractedData?: {
foodName: string;
mealType: MealType;
portionDescription?: string;
estimatedCalories?: number;
proteinGrams?: number;
carbohydrateGrams?: number;
fatGrams?: number;
fiberGrams?: number;
nutritionDetails?: any;
};
analysisText: string;
}
```
## 重构效果
### 📈 代码质量提升
| 指标 | 重构前 | 重构后 | 改善 |
|------|--------|--------|------|
| AiCoachService 行数 | ~1057行 | ~700行 | -33% |
| 方法数量 | 15+ | 10 | 专注核心功能 |
| 单一职责 | ❌ | ✅ | 职责清晰 |
| 可测试性 | 中等 | 优秀 | 独立测试 |
| 可维护性 | 困难 | 容易 | 模块化设计 |
### 🎯 架构优势
1. **单一职责原则 (SRP)**
- `AiCoachService`: 专注 AI 对话和体重分析
- `DietAnalysisService`: 专注饮食分析和营养评估
2. **依赖注入优化**
- 清晰的服务依赖关系
- 更好的可测试性
- 松耦合设计
3. **可扩展性提升**
- 饮食分析功能可独立扩展
- 容易添加新的营养分析算法
- 支持多种AI模型集成
### 🔧 技术实现
#### 在 AiCoachService 中的使用:
```typescript
// 重构前:所有逻辑在一个方法中
const dietAnalysisResult = await this.analyzeDietImageEnhanced(params.imageUrls);
// ... 复杂的处理逻辑 ...
// 重构后:清晰的服务调用
const dietAnalysisResult = await this.dietAnalysisService.analyzeDietImageEnhanced(params.imageUrls);
const createDto = await this.dietAnalysisService.processDietRecord(params.userId, dietAnalysisResult, params.imageUrls[0]);
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
```
#### 模块依赖更新:
```typescript
// ai-coach.module.ts
providers: [AiCoachService, DietAnalysisService]
```
### 📊 性能优化
1. **内存使用优化**
- AI模型实例复用DietAnalysisService 管理)
- 减少 AiCoachService 的内存占用
2. **代码加载优化**
- 按需加载饮食分析功能
- 更好的树摇(Tree Shaking)支持
3. **缓存友好**
- 独立的饮食分析服务便于实现缓存策略
## 使用示例
### 调用饮食分析服务:
```typescript
// 在 AiCoachService 中
constructor(
private readonly dietAnalysisService: DietAnalysisService,
) {}
// 使用饮食分析功能
const analysisResult = await this.dietAnalysisService.analyzeDietImageEnhanced(imageUrls);
if (analysisResult.shouldRecord) {
const dietRecord = await this.dietAnalysisService.processDietRecord(userId, analysisResult, imageUrl);
}
```
### 单独使用营养分析:
```typescript
// 在其他服务中也可以使用
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(userId);
```
## 扩展建议
基于重构后的架构,未来可以考虑:
1. **更多分析服务**
- `ExerciseAnalysisService` - 运动分析服务
- `HealthMetricsService` - 健康指标服务
- `RecommendationService` - 推荐算法服务
2. **插件化架构**
- 支持第三方营养数据库插件
- 支持多种AI模型提供商
- 支持自定义分析算法
3. **微服务化**
- 饮食分析服务可独立部署
- 支持水平扩展
- 更好的故障隔离
## 总结
通过这次重构,我们成功地:
**提高了代码可读性** - 职责清晰,逻辑分明
**增强了可维护性** - 模块化设计,便于维护
**改善了可测试性** - 独立服务,易于单元测试
**保持了功能完整性** - 所有原有功能正常工作
**优化了架构设计** - 符合SOLID原则
重构后的代码更加专业、清晰,为后续的功能扩展和维护奠定了良好的基础。

View File

@@ -0,0 +1,148 @@
# 饮食记录确认流程实现总结
## 项目概述
将原有的饮食记录功能从"自动记录模式"升级为"用户确认模式",类似于 Cline、Kilo 等开源 AI 工具的交互体验。
## 实现的功能
### 1. 两阶段饮食记录流程
- **第一阶段**AI识别图片中的食物生成多个确认选项
- **第二阶段**:用户选择确认选项后,系统记录到数据库并提供营养分析
### 2. 新增数据结构
#### AiChoiceOptionDto
```typescript
{
id: string; // 选项唯一标识符
label: string; // 显示给用户的文本(如"一条鱼 200卡"
value: any; // 选项对应的数据
recommended?: boolean; // 是否为推荐选项
}
```
#### AiResponseDataDto
```typescript
{
content: string; // AI回复的文本内容
choices?: AiChoiceOptionDto[]; // 选择选项(可选)
interactionType?: string; // 交互类型
pendingData?: any; // 需要用户确认的数据
context?: any; // 上下文信息
}
```
#### FoodConfirmationOption
```typescript
{
id: string;
label: string;
foodName: string;
portion: string;
calories: number;
mealType: MealType;
nutritionData: { ... };
}
```
### 3. API 增强
#### 请求参数新增
- `selectedChoiceId?: string` - 用户选择的选项ID
- `confirmationData?: any` - 用户确认的数据
#### 响应结构新增
- 支持返回结构化数据(选择选项)
- 支持返回传统流式文本
## 修改的文件
### 1. DTO 层
- **src/ai-coach/dto/ai-chat.dto.ts**
- 新增 `AiChoiceOptionDto`
- 新增 `AiResponseDataDto`
- 扩展 `AiChatRequestDto``AiChatResponseDto`
### 2. 服务层
- **src/ai-coach/services/diet-analysis.service.ts**
- 新增 `FoodConfirmationOption` 接口
- 新增 `FoodRecognitionResult` 接口
- 新增 `recognizeFoodForConfirmation()` 方法
- 新增 `createDietRecordFromConfirmation()` 方法
- 新增 `buildFoodRecognitionPrompt()` 方法
- 新增 `parseRecognitionResult()` 方法
- **src/ai-coach/ai-coach.service.ts**
- 更新 `streamChat()` 方法参数和返回类型
- 重构饮食记录逻辑,支持两阶段确认流程
- 新增结构化数据返回逻辑
### 3. 控制器层
- **src/ai-coach/ai-coach.controller.ts**
- 更新 `chat()` 方法,支持结构化响应
- 新增确认数据处理逻辑
### 4. 文档
- **docs/diet-confirmation-flow-api.md** - 新增API使用文档
- **docs/DIET_CONFIRMATION_IMPLEMENTATION_SUMMARY.md** - 本总结文档
## 流程示例
### 用户上传图片
1. 用户发送 `#记饮食` 指令并上传图片
2. AI识别食物返回确认选项
```json
{
"choices": [
{"id": "food_0", "label": "一条鱼 200卡", "value": {...}},
{"id": "food_1", "label": "一根玉米 40卡", "value": {...}}
],
"interactionType": "food_confirmation"
}
```
### 用户确认选择
1. 用户选择某个选项
2. 客户端发送确认请求,包含 `selectedChoiceId` 和 `confirmationData`
3. 系统记录到数据库,返回营养分析
## 技术特点
### 1. 向后兼容
- 保留原有的自动记录逻辑(`analyzeDietImageEnhanced` 方法)
- 新流程不影响其他功能
### 2. 类型安全
- 所有新增接口都有完整的 TypeScript 类型定义
- 使用 class-validator 进行数据验证
### 3. 错误处理
- 图片识别失败时回退到普通文本响应
- 确认数据无效时提供友好错误提示
### 4. 用户体验
- 类似 Cline/Kilo 的交互体验
- 清晰的选项展示(如"一条鱼 200卡"
- 推荐选项标识
## 部署说明
1. 代码已通过编译测试,无 TypeScript 错误
2. 保持向后兼容性,可以平滑部署
3. 建议先在测试环境验证新流程
## 使用建议
1. **客户端适配**:需要客户端支持处理结构化响应和选择选项
2. **图片质量**:提醒用户上传清晰的食物图片
3. **用户引导**:在界面上提供使用说明
## 后续优化方向
1. 支持批量选择多个食物
2. 支持用户自定义修改份量和热量
3. 添加更多营养素信息展示
4. 支持语音确认
5. 添加食物历史记录快速选择

View File

@@ -0,0 +1,165 @@
# 饮食记录功能实现总结
## 功能概述
根据您的需求,我已经参照现有体重记录的实现,完整地实现了饮食记录功能。该功能包括:
1. **数据库模型** - 完整的饮食记录数据结构
2. **API接口** - RESTful API支持增删查改操作
3. **AI视觉识别** - 优化的图片分析和自动记录
4. **营养分析** - 基于最近饮食记录的健康建议
5. **AI教练集成** - 智能对话中的饮食指导
## 实现的文件清单
### 数据库模型
- `src/users/models/user-diet-history.model.ts` - 饮食记录数据模型
### DTO 结构
- `src/users/dto/diet-record.dto.ts` - 完整的请求/响应数据传输对象
### 服务层
- `src/users/users.service.ts` - 新增饮食记录相关方法:
- `addDietRecord()` - 添加饮食记录
- `addDietRecordByVision()` - 通过AI视觉识别添加记录
- `getDietHistory()` - 获取饮食历史记录
- `updateDietRecord()` - 更新饮食记录
- `deleteDietRecord()` - 删除饮食记录
- `getRecentNutritionSummary()` - 获取营养汇总
### 控制器层
- `src/users/users.controller.ts` - 新增API端点
- `POST /users/diet-records` - 添加饮食记录
- `GET /users/diet-records` - 获取饮食记录列表
- `PUT /users/diet-records/:id` - 更新饮食记录
- `DELETE /users/diet-records/:id` - 删除饮食记录
- `GET /users/nutrition-summary` - 获取营养分析
### AI教练服务增强
- `src/ai-coach/ai-coach.service.ts` - 新增功能:
- `analyzeDietImageEnhanced()` - 增强版饮食图片分析
- `buildUserNutritionContext()` - 构建用户营养上下文
- `buildEnhancedDietAnalysisPrompt()` - 增强版分析提示
- 支持 `#记饮食` 指令和自动记录
### 配置文件
- `src/users/users.module.ts` - 注册新的数据模型
### 文档
- `docs/diet-records-table-create.sql` - 数据库表创建脚本
- `docs/diet-records-api-guide.md` - API使用指南
## 核心功能特性
### 1. 智能视觉识别
- **结构化数据返回** - AI分析结果以JSON格式返回包含完整营养信息
- **自动餐次判断** - 根据当前时间智能推断餐次类型
- **置信度评估** - 只有置信度足够高才自动记录到数据库
- **营养成分估算** - 自动计算热量、蛋白质、碳水、脂肪等
### 2. 个性化营养分析
- **历史记录整合** - 结合用户最近10顿饮食记录
- **趋势分析** - 分析热量摄入、营养均衡等趋势
- **智能建议** - 基于个人饮食习惯提供针对性建议
- **营养评分** - 0-100分的综合营养评价
### 3. 完整的数据结构
参照健康管理应用的最佳实践,包含:
- 基础信息:食物名称、餐次、份量、时间
- 营养成分:热量、三大营养素、膳食纤维、钠含量等
- 扩展字段图片URL、AI分析结果、用户备注
- 数据来源手动输入、AI识别、其他
### 4. AI教练智能对话
- **指令识别** - 支持 `#记饮食``#饮食` 等指令
- **上下文感知** - 自动提供用户饮食历史上下文
- **个性化回复** - 基于用户饮食记录给出专业建议
- **健康指导** - 综合最近饮食情况提供改善建议
## 技术实现亮点
### 1. 数据安全与性能
- 使用数据库事务确保数据一致性
- 合理的索引设计优化查询性能
- 软删除机制保护用户数据
- 活动日志记录用户操作
### 2. 错误处理与验证
- 完整的数据验证规则
- 合理的错误提示信息
- 容错机制和降级处理
- 详细的日志记录
### 3. API设计规范
- RESTful API设计原则
- 完整的Swagger文档注解
- 统一的响应格式
- 分页查询支持
### 4. AI集成优化
- 结构化的AI输出格式
- 智能的数据验证和清洗
- 用户体验优化(自动记录)
- 个性化的营养分析
## 使用示例
### 1. 手动添加饮食记录
```bash
curl -X POST /users/diet-records \
-H "Authorization: Bearer <token>" \
-d '{
"mealType": "lunch",
"foodName": "鸡胸肉沙拉",
"estimatedCalories": 280,
"proteinGrams": 35.0
}'
```
### 2. AI拍照记录饮食
用户发送:`#记饮食` + 食物图片
系统自动:分析图片 → 提取数据 → 保存记录 → 提供建议
### 3. 获取营养分析
```bash
curl -X GET /users/nutrition-summary?mealCount=10 \
-H "Authorization: Bearer <token>"
```
## 部署说明
1. **数据库表创建**
```bash
mysql -u username -p database_name < docs/diet-records-table-create.sql
```
2. **环境变量配置**
确保AI模型相关的环境变量已正确配置
- `DASHSCOPE_API_KEY`
- `DASHSCOPE_BASE_URL`
- `DASHSCOPE_VISION_MODEL`
3. **应用重启**
```bash
npm run build
npm run start:prod
```
## 扩展建议
基于当前实现,未来可以考虑以下扩展:
1. **营养数据库集成** - 接入专业的食物营养数据库
2. **饮食目标设定** - 允许用户设定个性化的营养目标
3. **社交分享功能** - 用户可以分享饮食记录和成就
4. **更精确的AI识别** - 使用更专业的食物识别模型
5. **营养师咨询** - 集成专业营养师在线咨询服务
## 测试建议
1. **功能测试** - 测试所有API端点的正常功能
2. **AI识别测试** - 使用各种食物图片测试识别准确性
3. **性能测试** - 测试大量数据情况下的查询性能
4. **集成测试** - 测试与AI教练对话的完整流程
该实现完全按照您的要求,参照体重记录的实现模式,提供了完整、智能、用户友好的饮食记录功能。

View File

@@ -0,0 +1,182 @@
# 喝水记录模块 (Water Records Module)
## 概述
喝水记录模块是一个独立的NestJS模块用于管理用户的喝水记录和喝水目标。该模块已从用户模块中分离出来以提高代码的可维护性和模块化程度。
## 模块结构
```
src/water-records/
├── water-records.controller.ts # 控制器 - 处理HTTP请求
├── water-records.service.ts # 服务 - 业务逻辑处理
├── water-records.module.ts # 模块定义
├── models/
│ └── user-water-history.model.ts # 喝水记录数据模型
└── dto/
└── water-record.dto.ts # 数据传输对象
```
## API 接口
### 基础路径: `/water-records`
| 方法 | 路径 | 描述 | 权限 |
|------|------|------|------|
| POST | `/` | 创建喝水记录 | JWT |
| GET | `/` | 获取喝水记录列表 | JWT |
| PUT | `/:id` | 更新喝水记录 | JWT |
| DELETE | `/:id` | 删除喝水记录 | JWT |
| PUT | `/goal/daily` | 更新每日喝水目标 | JWT |
| GET | `/stats/today` | 获取今日喝水统计 | JWT |
## 数据模型
### UserWaterHistory (喝水记录)
```typescript
{
id: number; // 记录ID
userId: string; // 用户ID
amount: number; // 喝水量(毫升)
source: WaterRecordSource; // 记录来源
note: string | null; // 备注
recordedAt: Date; // 记录时间
createdAt: Date; // 创建时间
updatedAt: Date; // 更新时间
}
```
### WaterRecordSource (记录来源枚举)
- `manual` - 手动记录
- `auto` - 自动记录
- `other` - 其他来源
## 功能特性
### 1. 喝水记录管理
- ✅ 创建喝水记录
- ✅ 查询喝水记录(支持日期范围筛选和分页)
- ✅ 更新喝水记录
- ✅ 删除喝水记录
### 2. 喝水目标管理
- ✅ 设置每日喝水目标
- ✅ 在用户档案中返回喝水目标
### 3. 统计分析
- ✅ 今日喝水统计
- ✅ 完成率计算
- ✅ 记录数量统计
### 4. 数据验证
- ✅ 喝水量范围验证1-5000ml
- ✅ 喝水目标范围验证500-10000ml
- ✅ 输入数据格式验证
## 使用示例
### 创建喝水记录
```bash
curl -X POST "http://localhost:3000/water-records" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"amount": 250,
"note": "早晨第一杯水",
"recordedAt": "2023-12-01T08:00:00.000Z"
}'
```
### 获取喝水记录列表
```bash
curl -X GET "http://localhost:3000/water-records?startDate=2023-12-01&endDate=2023-12-31&page=1&limit=20" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
### 更新喝水目标
```bash
curl -X PUT "http://localhost:3000/water-records/goal/daily" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"dailyWaterGoal": 2500
}'
```
### 获取今日统计
```bash
curl -X GET "http://localhost:3000/water-records/stats/today" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
## 数据库表结构
### t_user_water_history
```sql
CREATE TABLE t_user_water_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id VARCHAR(255) NOT NULL COMMENT '用户ID',
amount INT NOT NULL COMMENT '喝水量(毫升)',
source ENUM('manual', 'auto', 'other') NOT NULL DEFAULT 'manual' COMMENT '记录来源',
note VARCHAR(255) NULL COMMENT '备注',
recorded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_recorded_at (recorded_at)
);
```
## 模块依赖
- `@nestjs/common` - NestJS核心功能
- `@nestjs/sequelize` - Sequelize ORM集成
- `sequelize-typescript` - TypeScript装饰器支持
- `class-validator` - 数据验证
- `class-transformer` - 数据转换
## 与其他模块的关系
- **Users Module**: 依赖用户模块的UserProfile模型来管理喝水目标
- **Activity Logs Module**: 可选的活动日志记录
- **App Module**: 在主应用模块中注册
## 测试
运行测试脚本:
```bash
chmod +x test-water-records-module.sh
./test-water-records-module.sh
```
## 注意事项
1. **权限控制**: 所有接口都需要JWT认证
2. **数据验证**: 严格的输入验证确保数据质量
3. **错误处理**: 完善的错误处理和日志记录
4. **性能优化**: 支持分页查询,避免大量数据加载
5. **数据一致性**: 使用事务确保数据操作的一致性
## 迁移说明
该模块从原来的用户模块中分离出来,主要变化:
1. **路径变更**: 从 `/users/water-*` 变更为 `/water-records/*`
2. **模块独立**: 独立的控制器、服务和模块
3. **代码分离**: 减少了用户模块的复杂度
4. **维护性提升**: 更好的代码组织和维护性
## 版本历史
- **v1.0.0** - 初始版本,从用户模块分离
- 支持完整的CRUD操作
- 支持喝水目标管理
- 支持统计分析功能

236
docs/challenges-api.md Normal file
View File

@@ -0,0 +1,236 @@
# 挑战功能接口文档
> 所有接口均需携带 `Authorization: Bearer <token>`,鉴权方式与现有用户体系一致。
> 基础路径:`/challenges`
## 数据模型概述
### Challenge
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | `string` | 挑战唯一标识 |
| `title` | `string` | 挑战名称 |
| `image` | `string` | 挑战展示图 URL |
| `periodLabel` | `string` | 可选展示周期文案如「21 天计划」) |
| `durationLabel` | `string` | 必填,持续时间描述 |
| `requirementLabel` | `string` | 必填,参与要求文案 |
| `status` | `"upcoming" \| "ongoing" \| "expired"` | 由服务端根据时间自动计算 |
| `participantsCount` | `number` | 当前参与人数(仅统计 active 状态) |
| `rankingDescription` | `string` | 可选,排行榜说明 |
| `highlightTitle` | `string` | 高亮标题 |
| `highlightSubtitle` | `string` | 高亮副标题 |
| `ctaLabel` | `string` | CTA 按钮文案 |
| `progress` | `ChallengeProgress` | 可选,仅当当前用户已加入时返回 |
| `isJoined` | `boolean` | 当前用户是否已加入 |
### ChallengeProgress
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `completed` | `number` | 已完成进度值 |
| `target` | `number` | 总目标值 |
| `remaining` | `number` | 剩余进度 |
| `badge` | `string` | 当前进度徽章文案 |
| `subtitle` | `string` | 可选,补充提示文案 |
### RankingItem
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | `string` | 用户 ID |
| `name` | `string` | 昵称 |
| `avatar` | `string` | 头像 URL |
| `metric` | `string` | 排行榜展示文案(如 `5/21天` |
| `badge` | `string` | 可选,名次勋章(`gold`/`silver`/`bronze` |
---
## 1. 获取挑战列表
- **Method / Path**`GET /challenges`
- **描述**:获取当前所有挑战(全局共享),按开始时间升序排序。
### 请求参数
无额外 query 参数。
### 响应示例
```json
{
"code": 0,
"message": "获取挑战列表成功",
"data": [
{
"id": "f27c9a5d-8e53-4ba8-b8df-3c843f0241d2",
"title": "21 天核心燃脂计划",
"image": "https://cdn.example.com/challenges/core-21.png",
"periodLabel": "21 天",
"durationLabel": "持续 21 天",
"requirementLabel": "每日完成 1 次训练",
"status": "ongoing",
"startAt": "2024-03-01T00:00:00.000Z",
"endAt": "2024-03-21T23:59:59.000Z",
"participantsCount": 1287,
"rankingDescription": "坚持天数排行榜",
"highlightTitle": "一起塑造强壮核心",
"highlightSubtitle": "与全球用户共同挑战",
"ctaLabel": "立即加入挑战",
"progress": {
"completed": 5,
"target": 21,
"remaining": 16,
"badge": "已坚持 5天",
"subtitle": "还差 16天"
},
"isJoined": true
}
]
}
```
---
## 2. 获取挑战详情
- **Method / Path**`GET /challenges/{id}`
- **描述**:获取单个挑战的详细信息及排行榜。
### 路径参数
| 名称 | 必填 | 说明 |
| --- | --- | --- |
| `id` | 是 | 挑战 ID |
### 响应示例
```json
{
"code": 0,
"message": "获取挑战详情成功",
"data": {
"id": "f27c9a5d-8e53-4ba8-b8df-3c843f0241d2",
"title": "21 天核心燃脂计划",
"image": "https://cdn.example.com/challenges/core-21.png",
"periodLabel": "21 天",
"durationLabel": "持续 21 天",
"requirementLabel": "每日完成 1 次训练",
"summary": "21 天集中强化腹部及核心肌群,帮助塑形与燃脂。",
"rankingDescription": "坚持天数排行榜",
"highlightTitle": "连赢 7 天即可获得限量徽章",
"highlightSubtitle": "邀请好友并肩作战",
"ctaLabel": "立即加入挑战",
"participantsCount": 1287,
"progress": {
"completed": 5,
"target": 21,
"remaining": 16,
"badge": "已坚持 5天",
"subtitle": "还差 16天"
},
"rankings": [
{
"id": "user-001",
"name": "Alexa",
"avatar": "https://cdn.example.com/users/user-001.png",
"metric": "15/21天",
"badge": "gold"
},
{
"id": "user-002",
"name": "Ella",
"avatar": null,
"metric": "13/21天",
"badge": "silver"
}
],
"userRank": 57
}
}
```
---
## 3. 加入挑战
- **Method / Path**`POST /challenges/{id}/join`
- **描述**:当前用户加入挑战。若已加入会返回冲突错误。
### 响应示例
```json
{
"code": 0,
"message": "加入挑战成功",
"data": {
"completed": 0,
"target": 21,
"remaining": 21,
"badge": "已坚持 0天"
}
}
```
### 可能错误
- `404`:挑战不存在
- `400`:挑战已过期
- `409`:用户已加入或已完成(需先退出再加入)
---
## 4. 退出挑战
- **Method / Path**`POST /challenges/{id}/leave`
- **描述**:用户退出挑战,之后不再计入排行榜。可重新加入恢复进度(将重置为 0
### 响应示例
```json
{
"code": 0,
"message": "退出挑战成功",
"data": true
}
```
### 可能错误
- `404`:用户尚未加入或挑战不存在
---
## 5. 上报挑战进度
- **Method / Path**`POST /challenges/{id}/progress`
- **描述**:用户完成一次进度上报。默认增量 `1`,也可传入自定义增量,服务端会控制不超过目标值。
### 请求体
| 字段 | 类型 | 必填 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `increment` | `number` | 否 | `1` | 本次增加的进度值,必须大于等于 1 |
```json
{
"increment": 2
}
```
### 响应示例
```json
{
"code": 0,
"message": "进度更新成功",
"data": {
"completed": 7,
"target": 21,
"remaining": 14,
"badge": "已坚持 7天",
"subtitle": "还差 14天"
}
}
```
### 可能错误
- `404`:挑战不存在或用户未加入
- `400`:挑战未开始 / 已过期 / 进度增量非法
---
## 错误码说明
| `code` | `message` | 场景 |
| --- | --- | --- |
| `0` | `success`/具体文案 | 请求成功 |
| `1` | 错误描述 | 业务异常,例如未加入、挑战已过期等 |
---
## 接入建议
- 列表接口可做缓存(例如 10 分钟),但需结合挑战状态实时变更。
- 排行榜为前 10 名,客户端可在详情页展示,并根据 `userRank` 显示用户当前排名。
- 进度上报建议结合业务埋点,确保重复提交时可处理幂等性(服务端会封顶到目标值)。

View File

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

View File

@@ -0,0 +1,239 @@
# 饮食记录确认流程 API 文档
## 概述
新的饮食记录流程分为两个阶段:
1. **图片识别阶段**AI识别食物并返回确认选项
2. **用户确认阶段**:用户选择确认选项后记录到数据库
## 重要说明
⚠️ **流式响应兼容性**当系统需要返回确认选项时会自动使用非流式模式返回JSON结构即使客户端请求了 `stream: true`。这确保了确认选项的正确显示。
## API 流程
### 第一阶段:图片识别(返回确认选项)
**请求示例:**
```json
POST /ai-coach/chat
{
"conversationId": "user123-1234567890",
"messages": [
{
"role": "user",
"content": "#记饮食"
}
],
"imageUrls": ["https://example.com/food-image.jpg"],
"stream": false
}
```
**响应示例:**
```json
{
"conversationId": "user123-1234567890",
"data": {
"content": "我识别到了以下食物,请选择要记录的内容:\n\n图片中识别到烤鱼和米饭看起来是一份营养均衡的晚餐。",
"choices": [
{
"id": "food_0",
"label": "一条烤鱼 220卡",
"value": {
"id": "food_0",
"foodName": "烤鱼",
"portion": "1条",
"calories": 220,
"mealType": "dinner",
"nutritionData": {
"proteinGrams": 35,
"carbohydrateGrams": 2,
"fatGrams": 8,
"fiberGrams": 0
}
},
"recommended": true
},
{
"id": "food_1",
"label": "一碗米饭 150卡",
"value": {
"id": "food_1",
"foodName": "米饭",
"portion": "1碗",
"calories": 150,
"mealType": "dinner",
"nutritionData": {
"proteinGrams": 3,
"carbohydrateGrams": 32,
"fatGrams": 0.5,
"fiberGrams": 1
}
},
"recommended": false
}
],
"interactionType": "food_confirmation",
"pendingData": {
"imageUrl": "https://example.com/food-image.jpg",
"recognitionResult": {
"recognizedItems": [...],
"analysisText": "图片中识别到烤鱼和米饭...",
"confidence": 85
}
},
"context": {
"command": "diet",
"step": "confirmation"
}
}
}
```
### 第二阶段:用户确认选择
**请求示例:**
```json
POST /ai-coach/chat
{
"conversationId": "user123-1234567890",
"messages": [
{
"role": "user",
"content": "我选择记录烤鱼"
}
],
"selectedChoiceId": "food_0",
"confirmationData": {
"selectedOption": {
"id": "food_0",
"foodName": "烤鱼",
"portion": "1条",
"calories": 220,
"mealType": "dinner",
"nutritionData": {
"proteinGrams": 35,
"carbohydrateGrams": 2,
"fatGrams": 8,
"fiberGrams": 0
}
},
"imageUrl": "https://example.com/food-image.jpg"
},
"stream": false
}
```
**响应示例:**
```json
{
"conversationId": "user123-1234567890",
"text": "很好我已经为您记录了这份烤鱼1条约220卡路里。\n\n根据您的饮食记录这是一份优质的蛋白质来源包含35克蛋白质脂肪含量适中。建议搭配一些蔬菜来增加膳食纤维的摄入。\n\n您今天的饮食营养搭配看起来不错记得保持均衡的饮食习惯"
}
```
## 数据结构说明
### AiChoiceOptionDto
```typescript
{
id: string; // 选项唯一标识符
label: string; // 显示给用户的文本(如"一条鱼 200卡"
value: any; // 选项对应的数据
recommended?: boolean; // 是否为推荐选项
}
```
### AiResponseDataDto
```typescript
{
content: string; // AI回复的文本内容
choices?: AiChoiceOptionDto[]; // 选择选项(可选)
interactionType?: string; // 交互类型:'text' | 'food_confirmation' | 'selection'
pendingData?: any; // 需要用户确认的数据(可选)
context?: any; // 上下文信息(可选)
}
```
### FoodConfirmationOption
```typescript
{
id: string; // 唯一标识符
label: string; // 显示文本
foodName: string; // 食物名称
portion: string; // 份量描述
calories: number; // 估算热量
mealType: MealType; // 餐次类型
nutritionData: { // 营养数据
proteinGrams?: number; // 蛋白质(克)
carbohydrateGrams?: number; // 碳水化合物(克)
fatGrams?: number; // 脂肪(克)
fiberGrams?: number; // 膳食纤维(克)
};
}
```
## 错误处理
### 图片识别失败
如果图片模糊或无法识别食物API会返回正常的文本响应
```json
{
"conversationId": "user123-1234567890",
"text": "抱歉,我无法清晰地识别图片中的食物。请确保图片清晰,光线充足,食物在画面中清晰可见,然后重新上传。"
}
```
### 无效的确认数据
如果第二阶段的确认数据无效,系统会返回错误提示:
```json
{
"conversationId": "user123-1234567890",
"text": "确认数据无效,请重新选择要记录的食物。"
}
```
## 使用建议
1. **图片质量**:确保上传的图片清晰,光线充足,食物在画面中清晰可见
2. **选择确认**:用户可以选择多个食物选项,每次确认记录一种食物
3. **营养分析**:系统会基于用户的历史饮食记录提供个性化的营养分析和建议
4. **流式响应处理**
- 客户端应该检查响应的 `Content-Type`
- `application/json`:结构化数据(确认选项)
- `text/plain`:流式文本
- 当返回确认选项时,系统会忽略 `stream` 参数并返回JSON
## 客户端适配指南
### 响应类型检测
```javascript
// 检查响应类型
if (response.headers['content-type'].includes('application/json')) {
// 处理结构化数据(确认选项)
const data = await response.json();
if (data.data && data.data.choices) {
// 显示选择选项
showFoodConfirmationOptions(data.data.choices);
}
} else {
// 处理流式文本
handleStreamResponse(response);
}
```
### 确认选择发送
```javascript
// 用户选择后发送确认
const confirmationRequest = {
conversationId: "user123-1234567890",
messages: [{ role: "user", content: "我选择记录烤鱼" }],
selectedChoiceId: "food_0",
confirmationData: {
selectedOption: selectedFoodOption,
imageUrl: originalImageUrl
},
stream: true // 第二阶段可以使用流式
};
```

View File

@@ -0,0 +1,159 @@
# 饮食记录功能 API 使用指南
## 功能概述
饮食记录功能允许用户通过多种方式记录和管理饮食信息,包括:
- 手动添加饮食记录
- AI视觉识别自动记录通过拍照
- 获取饮食历史记录
- 营养分析和健康建议
## 数据库模型
### 饮食记录表 (t_user_diet_history)
包含以下关键字段:
- 基础信息:食物名称、餐次类型、用餐时间
- 营养成分:热量、蛋白质、碳水化合物、脂肪、膳食纤维等
- 记录来源手动输入、AI视觉识别、其他
- AI分析结果完整的识别数据JSON格式
## API 端点
### 1. 添加饮食记录
```
POST /users/diet-records
```
**请求体示例:**
```json
{
"mealType": "lunch",
"foodName": "鸡胸肉沙拉",
"foodDescription": "烤鸡胸肉配蔬菜沙拉",
"portionDescription": "1份",
"estimatedCalories": 280,
"proteinGrams": 35.0,
"carbohydrateGrams": 15.5,
"fatGrams": 8.0,
"fiberGrams": 5.2,
"source": "manual",
"notes": "午餐很健康"
}
```
### 2. 获取饮食记录历史
```
GET /users/diet-records?startDate=2024-01-01&endDate=2024-01-31&mealType=lunch&page=1&limit=20
```
**响应示例:**
```json
{
"records": [
{
"id": 1,
"mealType": "lunch",
"foodName": "鸡胸肉沙拉",
"estimatedCalories": 280,
"proteinGrams": 35.0,
"source": "manual",
"createdAt": "2024-01-15T12:30:00.000Z"
}
],
"total": 1,
"page": 1,
"limit": 20,
"totalPages": 1
}
```
### 3. 更新饮食记录
```
PUT /users/diet-records/:id
```
### 4. 删除饮食记录
```
DELETE /users/diet-records/:id
```
### 5. 获取营养汇总分析
```
GET /users/nutrition-summary?mealCount=10
```
**响应示例:**
```json
{
"nutritionSummary": {
"totalCalories": 2150,
"totalProtein": 85.5,
"totalCarbohydrates": 180.2,
"totalFat": 65.8,
"totalFiber": 28.5,
"recordCount": 10,
"dateRange": {
"start": "2024-01-10T08:00:00.000Z",
"end": "2024-01-15T19:00:00.000Z"
}
},
"recentRecords": [...],
"healthAnalysis": "基于您最近的饮食记录,我将为您提供个性化的营养分析和健康建议。",
"nutritionScore": 78,
"recommendations": [
"建议增加膳食纤维摄入,多吃蔬菜、水果和全谷物。",
"您的饮食结构相对均衡,继续保持良好的饮食习惯!"
]
}
```
## AI教练集成
### 饮食记录指令
用户可以使用以下指令触发饮食记录功能:
- `#记饮食``#饮食``#记录饮食`
### AI视觉识别流程
1. 用户发送 `#记饮食` 指令并上传食物图片
2. AI使用视觉模型分析图片提取
- 食物名称和类型
- 营养成分估算
- 份量描述
- 餐次类型(基于时间自动判断)
3. 如果识别置信度足够,自动保存到数据库
4. 结合用户历史饮食记录,提供个性化营养分析
### 营养分析上下文
AI教练会自动获取用户最近的饮食记录提供
- 营养摄入趋势分析
- 个性化健康建议
- 基于历史记录的改善建议
## 数据库配置
1. 运行 SQL 脚本创建表:
```bash
mysql -u username -p database_name < docs/diet-records-table-create.sql
```
2. 在 UsersModule 中已自动注册 UserDietHistory 模型
## 注意事项
1. **营养数据准确性**AI估算的营养数据仅供参考实际值可能有差异
2. **图片质量**:为了更好的识别效果,建议上传清晰的食物图片
3. **隐私保护**:用户饮食数据会安全存储,仅用于个性化分析
4. **性能优化**:使用了合适的数据库索引来优化查询性能
## 扩展功能
未来可以考虑添加:
- 食物营养数据库集成
- 更精确的营养成分计算
- 饮食目标设定和追踪
- 营养师在线咨询
- 社交分享功能

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,304 @@
# 目标子任务API使用指南
## 概述
目标子任务系统是对目标管理功能的扩展,它支持用户为每个目标自动生成对应的子任务,用户可以根据目标的频率设置(每天、每周、每月)来完成这些子任务。系统采用惰性加载的方式,每次获取任务列表时才生成新的任务。
## 核心功能
### 1. 惰性任务生成
- **触发时机**: 当用户调用获取任务列表API时
- **生成策略**:
- 每日任务提前生成7天的任务
- 每周任务提前生成4周的任务
- 每月任务提前生成3个月的任务
- 自定义任务:根据自定义规则生成
### 2. 任务状态管理
- `pending`: 待开始
- `in_progress`: 进行中(部分完成)
- `completed`: 已完成
- `overdue`: 已过期
- `skipped`: 已跳过
### 3. 进度追踪
- 支持分步完成如一天要喝8杯水可以分8次上报
- 自动计算完成进度百分比
- 当完成次数达到目标次数时自动标记为完成
## API接口详解
### 1. 获取任务列表
**请求方式**: `GET /goals/tasks`
**查询参数**:
```typescript
{
goalId?: string; // 目标ID可选
status?: TaskStatus; // 任务状态(可选)
startDate?: string; // 开始日期(可选)
endDate?: string; // 结束日期(可选)
page?: number; // 页码默认1
pageSize?: number; // 每页数量默认20
}
```
**响应示例**:
```json
{
"code": 200,
"message": "获取任务列表成功",
"data": {
"page": 1,
"pageSize": 20,
"total": 5,
"list": [
{
"id": "task-uuid",
"goalId": "goal-uuid",
"userId": "user-123",
"title": "每日喝水 - 2024年01月15日",
"description": "每日目标完成8次",
"startDate": "2024-01-15",
"endDate": "2024-01-15",
"targetCount": 8,
"currentCount": 3,
"status": "in_progress",
"progressPercentage": 37,
"completedAt": null,
"notes": null,
"metadata": null,
"daysRemaining": 0,
"isToday": true,
"goal": {
"id": "goal-uuid",
"title": "每日喝水",
"repeatType": "daily",
"frequency": 8,
"category": "健康"
}
}
]
}
}
```
### 2. 完成任务
**请求方式**: `POST /goals/tasks/:taskId/complete`
**请求体**:
```typescript
{
count?: number; // 完成次数默认1
notes?: string; // 备注(可选)
completedAt?: string; // 完成时间(可选)
}
```
**使用示例**:
```bash
# 喝水1次
curl -X POST "http://localhost:3000/goals/tasks/task-uuid/complete" \
-H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
-d '{"count": 1, "notes": "午饭后喝水"}'
# 一次性完成多次
curl -X POST "http://localhost:3000/goals/tasks/task-uuid/complete" \
-H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
-d '{"count": 3, "notes": "连续喝了3杯水"}'
```
**响应示例**:
```json
{
"code": 200,
"message": "任务完成成功",
"data": {
"id": "task-uuid",
"currentCount": 4,
"progressPercentage": 50,
"status": "in_progress",
"notes": "午饭后喝水"
}
}
```
### 3. 获取特定目标的任务列表
**请求方式**: `GET /goals/:goalId/tasks`
**使用示例**:
```bash
curl -X GET "http://localhost:3000/goals/goal-uuid/tasks?page=1&pageSize=10" \
-H "Authorization: Bearer your-token"
```
### 4. 跳过任务
**请求方式**: `POST /goals/tasks/:taskId/skip`
**请求体**:
```typescript
{
reason?: string; // 跳过原因(可选)
}
```
**使用示例**:
```bash
curl -X POST "http://localhost:3000/goals/tasks/task-uuid/skip" \
-H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
-d '{"reason": "今天身体不舒服"}'
```
### 5. 获取任务统计
**请求方式**: `GET /goals/tasks/stats/overview`
**查询参数**:
```typescript
{
goalId?: string; // 目标ID可选不传则统计所有目标的任务
}
```
**响应示例**:
```json
{
"code": 200,
"message": "获取任务统计成功",
"data": {
"total": 15,
"pending": 5,
"inProgress": 3,
"completed": 6,
"overdue": 1,
"skipped": 0,
"totalProgress": 68,
"todayTasks": 3,
"weekTasks": 8,
"monthTasks": 15
}
}
```
## 典型使用场景
### 场景1每日喝水目标
1. **创建目标**:
```json
{
"title": "每日喝水",
"description": "每天喝8杯水保持健康",
"repeatType": "daily",
"frequency": 8,
"category": "健康",
"startDate": "2024-01-01"
}
```
2. **系统自动生成任务**: 当用户第一次获取任务列表时系统会自动生成当天及未来7天的每日任务
3. **用户完成任务**: 每次喝水后调用完成API直到当天任务完成
### 场景2每周运动目标
1. **创建目标**:
```json
{
"title": "每周运动",
"description": "每周运动3次",
"repeatType": "weekly",
"frequency": 3,
"category": "运动",
"startDate": "2024-01-01"
}
```
2. **系统生成周任务**: 按周生成任务,每个任务的开始时间是周一,结束时间是周日
3. **灵活完成**: 用户可以在一周内的任何时间完成3次运动每次完成后调用API更新进度
### 场景3自定义周期目标
1. **创建目标**:
```json
{
"title": "周末阅读",
"description": "每个周末阅读1小时",
"repeatType": "custom",
"frequency": 1,
"customRepeatRule": {
"weekdays": [0, 6] // 周日和周六
},
"category": "学习"
}
```
2. **系统按规则生成**: 只在周六和周日生成任务
## 最佳实践
### 1. 任务列表获取
- 建议按日期范围获取任务,避免一次性加载过多数据
- 可以按状态筛选任务,如只显示今天的待完成任务
### 2. 进度上报
- 鼓励用户及时上报完成情况,保持任务状态的实时性
- 对于可分步完成的目标,支持分次上报更有利于习惯养成
### 3. 错误处理
- 当任务不存在或已完成时API会返回相应错误信息
- 客户端应该处理网络异常情况,支持离线记录后同步
### 4. 性能优化
- 惰性生成机制确保只在需要时生成任务
- 建议客户端缓存任务列表减少不必要的API调用
## 数据库表结构
### t_goal_tasks 表字段说明
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | CHAR(36) | 任务ID主键 |
| goal_id | CHAR(36) | 关联的目标ID |
| user_id | VARCHAR(255) | 用户ID |
| title | VARCHAR(255) | 任务标题 |
| description | TEXT | 任务描述 |
| start_date | DATE | 任务开始日期 |
| end_date | DATE | 任务结束日期 |
| target_count | INT | 目标完成次数 |
| current_count | INT | 当前完成次数 |
| status | ENUM | 任务状态 |
| progress_percentage | INT | 完成进度(0-100) |
| completed_at | DATETIME | 完成时间 |
| notes | TEXT | 备注 |
| metadata | JSON | 扩展数据 |
## 注意事项
1. **时区处理**: 所有日期时间都使用服务器时区,客户端需要进行相应转换
2. **并发安全**: 多次快速调用完成API可能导致计数不准确建议客户端控制调用频率
3. **数据一致性**: 目标删除时会级联删除相关任务
4. **性能考虑**: 大量历史任务可能影响查询性能,建议定期清理过期数据
## 常见问题
**Q: 如何修改已生成的任务?**
A: 可以使用更新任务API (PUT /goals/tasks/:taskId) 修改任务的基本信息,但不建议频繁修改以保持数据一致性。
**Q: 任务过期后还能完成吗?**
A: 过期任务状态会自动更新为'overdue',但仍然可以完成,完成后状态会变为'completed'。
**Q: 如何处理用户时区问题?**
A: 客户端应该将用户本地时间转换为服务器时区后发送请求,显示时再转换回用户时区。
**Q: 能否批量完成多个任务?**
A: 目前API设计为单个任务操作如需批量操作可以在客户端并发调用多个API。

399
docs/goals-api-guide.md Normal file
View File

@@ -0,0 +1,399 @@
# 目标管理 API 文档
## 概述
目标管理功能允许用户创建、管理和跟踪个人目标。每个目标包含标题、重复周期、频率等属性,支持完整的增删改查操作。
## 数据模型
### 目标 (Goal)
| 字段 | 类型 | 必填 | 描述 |
|------|------|------|------|
| id | UUID | 是 | 目标唯一标识 |
| userId | String | 是 | 用户ID |
| title | String | 是 | 目标标题 |
| description | Text | 否 | 目标描述 |
| repeatType | Enum | 是 | 重复周期类型daily/weekly/monthly/custom |
| frequency | Integer | 是 | 频率(每天/每周/每月多少次) |
| customRepeatRule | JSON | 否 | 自定义重复规则 |
| startDate | Date | 是 | 目标开始日期 |
| endDate | Date | 否 | 目标结束日期 |
| status | Enum | 是 | 目标状态active/paused/completed/cancelled |
| completedCount | Integer | 是 | 已完成次数 |
| targetCount | Integer | 否 | 目标总次数 |
| category | String | 否 | 目标分类标签 |
| priority | Integer | 是 | 优先级0-10 |
| hasReminder | Boolean | 是 | 是否提醒 |
| reminderTime | Time | 否 | 提醒时间 |
| reminderSettings | JSON | 否 | 提醒设置 |
### 目标完成记录 (GoalCompletion)
| 字段 | 类型 | 必填 | 描述 |
|------|------|------|------|
| id | UUID | 是 | 完成记录唯一标识 |
| goalId | UUID | 是 | 目标ID |
| userId | String | 是 | 用户ID |
| completedAt | DateTime | 是 | 完成日期 |
| completionCount | Integer | 是 | 完成次数 |
| notes | Text | 否 | 完成备注 |
| metadata | JSON | 否 | 额外数据 |
## API 接口
### 1. 创建目标
**POST** `/goals`
**请求体:**
```json
{
"title": "每天跑步30分钟",
"description": "提高心肺功能,增强体质",
"repeatType": "daily",
"frequency": 1,
"startDate": "2024-01-01",
"endDate": "2024-12-31",
"targetCount": 365,
"category": "运动",
"priority": 5,
"hasReminder": true,
"reminderTime": "07:00",
"reminderSettings": {
"weekdays": [1, 2, 3, 4, 5, 6, 0],
"enabled": true
}
}
```
**响应:**
```json
{
"code": 0,
"message": "目标创建成功",
"data": {
"id": "uuid",
"title": "每天跑步30分钟",
"status": "active",
"completedCount": 0,
"progressPercentage": 0,
"daysRemaining": 365
}
}
```
### 2. 获取目标列表
**GET** `/goals?page=1&pageSize=20&status=active&category=运动&search=跑步`
**查询参数:**
- `page`: 页码默认1
- `pageSize`: 每页数量默认20最大100
- `status`: 目标状态筛选
- `repeatType`: 重复类型筛选
- `category`: 分类筛选
- `search`: 搜索标题和描述
- `startDate`: 开始日期范围
- `endDate`: 结束日期范围
- `sortBy`: 排序字段createdAt/updatedAt/priority/title/startDate
- `sortOrder`: 排序方向asc/desc
**响应:**
```json
{
"code": 0,
"message": "获取目标列表成功",
"data": {
"page": 1,
"pageSize": 20,
"total": 5,
"items": [
{
"id": "uuid",
"title": "每天跑步30分钟",
"status": "active",
"completedCount": 15,
"targetCount": 365,
"progressPercentage": 4,
"daysRemaining": 350
}
]
}
}
```
### 3. 获取目标详情
**GET** `/goals/{id}`
**响应:**
```json
{
"code": 0,
"message": "获取目标详情成功",
"data": {
"id": "uuid",
"title": "每天跑步30分钟",
"description": "提高心肺功能,增强体质",
"repeatType": "daily",
"frequency": 1,
"status": "active",
"completedCount": 15,
"targetCount": 365,
"progressPercentage": 4,
"daysRemaining": 350,
"completions": [
{
"id": "completion-uuid",
"completedAt": "2024-01-15T07:00:00Z",
"completionCount": 1,
"notes": "今天感觉很好"
}
]
}
}
```
### 4. 更新目标
**PUT** `/goals/{id}`
**请求体:**
```json
{
"title": "每天跑步45分钟",
"frequency": 1,
"priority": 7
}
```
**响应:**
```json
{
"code": 0,
"message": "目标更新成功",
"data": {
"id": "uuid",
"title": "每天跑步45分钟",
"priority": 7
}
}
```
### 5. 删除目标
**DELETE** `/goals/{id}`
**响应:**
```json
{
"code": 0,
"message": "目标删除成功",
"data": true
}
```
### 6. 记录目标完成
**POST** `/goals/{id}/complete`
**请求体:**
```json
{
"completionCount": 1,
"notes": "今天完成了跑步目标",
"completedAt": "2024-01-15T07:30:00Z"
}
```
**响应:**
```json
{
"code": 0,
"message": "目标完成记录成功",
"data": {
"id": "completion-uuid",
"goalId": "goal-uuid",
"completedAt": "2024-01-15T07:30:00Z",
"completionCount": 1,
"notes": "今天完成了跑步目标"
}
}
```
### 7. 获取目标完成记录
**GET** `/goals/{id}/completions?page=1&pageSize=20&startDate=2024-01-01&endDate=2024-01-31`
**响应:**
```json
{
"code": 0,
"message": "获取目标完成记录成功",
"data": {
"page": 1,
"pageSize": 20,
"total": 15,
"items": [
{
"id": "completion-uuid",
"completedAt": "2024-01-15T07:30:00Z",
"completionCount": 1,
"notes": "今天完成了跑步目标",
"goal": {
"id": "goal-uuid",
"title": "每天跑步30分钟"
}
}
]
}
}
```
### 8. 获取目标统计信息
**GET** `/goals/stats/overview`
**响应:**
```json
{
"code": 0,
"message": "获取目标统计成功",
"data": {
"total": 10,
"active": 7,
"completed": 2,
"paused": 1,
"cancelled": 0,
"byCategory": {
"运动": 5,
"学习": 3,
"健康": 2
},
"byRepeatType": {
"daily": 6,
"weekly": 3,
"monthly": 1
},
"totalCompletions": 150,
"thisWeekCompletions": 25,
"thisMonthCompletions": 100
}
}
```
### 9. 批量操作目标
**POST** `/goals/batch`
**请求体:**
```json
{
"goalIds": ["uuid1", "uuid2", "uuid3"],
"action": "pause"
}
```
**支持的操作:**
- `pause`: 暂停目标
- `resume`: 恢复目标
- `complete`: 完成目标
- `delete`: 删除目标
**响应:**
```json
{
"code": 0,
"message": "批量操作完成",
"data": [
{
"goalId": "uuid1",
"success": true
},
{
"goalId": "uuid2",
"success": true
},
{
"goalId": "uuid3",
"success": false,
"error": "目标不存在"
}
]
}
```
## 使用示例
### 创建每日运动目标
```bash
curl -X POST http://localhost:3000/goals \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "每日普拉提练习",
"description": "每天进行30分钟的普拉提练习提高核心力量",
"repeatType": "daily",
"frequency": 1,
"startDate": "2024-01-01",
"category": "运动",
"priority": 8,
"hasReminder": true,
"reminderTime": "18:00"
}'
```
### 创建每周学习目标
```bash
curl -X POST http://localhost:3000/goals \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "每周阅读一本书",
"description": "每周至少阅读一本专业书籍",
"repeatType": "weekly",
"frequency": 1,
"startDate": "2024-01-01",
"targetCount": 52,
"category": "学习",
"priority": 6
}'
```
### 记录目标完成
```bash
curl -X POST http://localhost:3000/goals/GOAL_ID/complete \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"notes": "今天完成了30分钟的普拉提练习感觉很好"
}'
```
## 错误处理
所有接口都遵循统一的错误响应格式:
```json
{
"code": 1,
"message": "错误描述",
"data": null
}
```
常见错误:
- `目标不存在`: 404
- `参数验证失败`: 400
- `权限不足`: 403
- `服务器内部错误`: 500
## 注意事项
1. 所有接口都需要JWT认证
2. 用户只能操作自己的目标
3. 已完成的目标不能修改状态
4. 删除操作采用软删除,不会真正删除数据
5. 目标完成记录会自动更新目标的完成次数
6. 达到目标总次数时,目标状态会自动变为已完成

View File

@@ -0,0 +1,288 @@
# iOS远程推送功能实施计划
## 项目概述
本文档详细描述了在现有NestJS项目中实现iOS远程推送功能的完整实施计划。该功能将使用Apple官方APNs服务通过@parse/node-apn库与Apple推送服务进行通信。
## 技术选型
### 核心技术栈
- **推送服务**: Apple官方APNs (Apple Push Notification service)
- **Node.js库**: @parse/node-apn (Trust Score: 9.8支持HTTP/2)
- **认证方式**: Token-based authentication (推荐)
- **数据库**: MySQL (与现有项目保持一致)
### 依赖包
```json
{
"@parse/node-apn": "^5.0.0",
"uuid": "^11.1.0" // 已存在
}
```
## 实施阶段
### 第一阶段:基础设施搭建
1. **创建推送模块结构**
- 创建`src/push-notifications/`目录
- 设置模块、控制器、服务的基础结构
2. **数据库设计与实现**
- 创建推送令牌表 (t_user_push_tokens)
- 创建推送消息表 (t_push_messages)
- 创建推送模板表 (t_push_templates)
- 编写数据库迁移脚本
3. **APNs连接配置**
- 配置APNs认证信息
- 实现APNs Provider服务
- 设置连接池和错误处理
### 第二阶段:核心功能实现
1. **推送令牌管理**
- 实现设备令牌注册/更新/注销
- 令牌有效性验证
- 无效令牌清理机制
2. **推送消息发送**
- 实现单个推送发送
- 实现批量推送发送
- 实现静默推送发送
3. **推送模板系统**
- 模板创建/更新/删除
- 模板渲染引擎
- 动态数据绑定
### 第三阶段API接口开发
1. **推送令牌管理API**
- POST /api/push-notifications/register-token
- PUT /api/push-notifications/update-token
- DELETE /api/push-notifications/unregister-token
2. **推送消息发送API**
- POST /api/push-notifications/send
- POST /api/push-notifications/send-by-template
- POST /api/push-notifications/send-batch
3. **推送模板管理API**
- GET /api/push-notifications/templates
- POST /api/push-notifications/templates
- PUT /api/push-notifications/templates/:id
- DELETE /api/push-notifications/templates/:id
### 第四阶段:优化与监控
1. **性能优化**
- 连接池管理
- 批量处理优化
- 缓存策略实现
2. **错误处理与重试**
- APNs错误分类处理
- 指数退避重试机制
- 无效令牌自动清理
3. **日志与监控**
- 推送状态日志记录
- 性能指标监控
- 错误率统计
## 文件结构
```
src/push-notifications/
├── push-notifications.module.ts
├── push-notifications.controller.ts
├── push-notifications.service.ts
├── apns.provider.ts
├── push-token.service.ts
├── push-template.service.ts
├── push-message.service.ts
├── models/
│ ├── user-push-token.model.ts
│ ├── push-message.model.ts
│ └── push-template.model.ts
├── dto/
│ ├── register-device-token.dto.ts
│ ├── update-device-token.dto.ts
│ ├── send-push-notification.dto.ts
│ ├── send-push-by-template.dto.ts
│ ├── create-push-template.dto.ts
│ ├── update-push-template.dto.ts
│ └── push-response.dto.ts
├── interfaces/
│ ├── push-notification.interface.ts
│ ├── apns-config.interface.ts
│ └── push-stats.interface.ts
└── enums/
├── device-type.enum.ts
├── push-type.enum.ts
└── push-message-status.enum.ts
```
## 环境配置
### 环境变量
```bash
# APNs配置
APNS_KEY_ID=your_key_id
APNS_TEAM_ID=your_team_id
APNS_KEY_PATH=path/to/APNsAuthKey_XXXXXXXXXX.p8
APNS_BUNDLE_ID=com.yourcompany.yourapp
APNS_ENVIRONMENT=production # or sandbox
# 推送服务配置
PUSH_RETRY_LIMIT=3
PUSH_REQUEST_TIMEOUT=5000
PUSH_HEARTBEAT=60000
PUSH_BATCH_SIZE=100
```
### APNs认证文件
- 需要从Apple开发者账号下载.p8格式的私钥文件
- 将私钥文件安全地存储在服务器上
## 数据库表结构
### 推送令牌表 (t_user_push_tokens)
```sql
CREATE TABLE t_user_push_tokens (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
user_id VARCHAR(255) NOT NULL,
device_token VARCHAR(255) NOT NULL,
device_type ENUM('IOS', 'ANDROID') NOT NULL DEFAULT 'IOS',
app_version VARCHAR(50),
os_version VARCHAR(50),
device_name VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
last_used_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_device_token (device_token),
INDEX idx_user_device (user_id, device_token),
UNIQUE KEY uk_user_device_token (user_id, device_token)
);
```
### 推送消息表 (t_push_messages)
```sql
CREATE TABLE t_push_messages (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
user_id VARCHAR(255) NOT NULL,
device_token VARCHAR(255) NOT NULL,
message_type VARCHAR(50) NOT NULL,
title VARCHAR(255),
body TEXT,
payload JSON,
push_type ENUM('ALERT', 'BACKGROUND', 'VOIP', 'LIVEACTIVITY') DEFAULT 'ALERT',
priority TINYINT DEFAULT 10,
expiry DATETIME,
collapse_id VARCHAR(64),
status ENUM('PENDING', 'SENT', 'FAILED', 'EXPIRED') DEFAULT 'PENDING',
apns_response JSON,
error_message TEXT,
sent_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
INDEX idx_message_type (message_type)
);
```
### 推送模板表 (t_push_templates)
```sql
CREATE TABLE t_push_templates (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
template_key VARCHAR(100) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
payload_template JSON,
push_type ENUM('ALERT', 'BACKGROUND', 'VOIP', 'LIVEACTIVITY') DEFAULT 'ALERT',
priority TINYINT DEFAULT 10,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_template_key (template_key),
INDEX idx_is_active (is_active)
);
```
## 使用示例
### 1. 注册设备令牌
```typescript
// iOS客户端获取设备令牌后调用此API
POST /api/push-notifications/register-token
{
"deviceToken": "a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb383697bf9f9d750a1003da19c7",
"deviceType": "IOS",
"appVersion": "1.0.0",
"osVersion": "iOS 15.0",
"deviceName": "iPhone 13"
}
```
### 2. 发送推送通知
```typescript
// 在业务服务中调用推送服务
await this.pushNotificationsService.sendNotification({
userIds: ['user_123'],
title: '训练提醒',
body: '您今天的普拉提训练还未完成,快来打卡吧!',
payload: {
type: 'training_reminder',
trainingId: 'training_123'
}
});
```
### 3. 使用模板发送推送
```typescript
// 使用预定义模板发送推送
await this.pushNotificationsService.sendNotificationByTemplate(
'user_123',
'training_reminder',
{
userName: '张三',
trainingName: '核心力量训练'
}
);
```
## 预期收益
1. **用户体验提升**: 及时推送训练提醒、饮食记录等重要信息
2. **用户粘性增强**: 通过个性化推送提高用户活跃度
3. **业务目标达成**: 支持各种业务场景的推送需求
4. **技术架构完善**: 建立可扩展的推送服务架构
## 风险评估
### 技术风险
- **APNs连接稳定性**: 通过连接池和重试机制降低风险
- **推送令牌管理**: 实现自动清理和验证机制
- **性能瓶颈**: 通过批量处理和缓存优化解决
### 业务风险
- **用户隐私**: 严格遵守数据保护法规
- **推送频率**: 实现推送频率限制避免骚扰用户
- **内容审核**: 建立推送内容审核机制
## 后续扩展
1. **多平台支持**: 扩展Android推送功能
2. **推送策略**: 实现智能推送时机和内容优化
3. **数据分析**: 推送效果分析和用户行为追踪
4. **A/B测试**: 推送内容和策略的A/B测试功能
## 总结
本实施计划提供了一个完整的iOS远程推送功能解决方案包括技术选型、架构设计、实施步骤和使用示例。该方案具有良好的可扩展性和维护性能够满足当前业务需求并为未来扩展留有空间。
实施完成后您将拥有一个功能完整、性能优良的推送服务系统可以通过简单的API调用来发送各种类型的推送通知提升用户体验和业务指标。

View File

@@ -0,0 +1,265 @@
# iOS远程推送功能设计方案
## 1. 技术方案概述
### 1.1 推送服务选择
- **服务提供商**: Apple官方APNs (Apple Push Notification service)
- **Node.js库**: @parse/node-apn (Trust Score: 9.8支持HTTP/2维护良好)
- **认证方式**: Token-based authentication (推荐) 或 Certificate-based authentication
### 1.2 技术架构
```
iOS App -> APNs -> 后端服务器 (NestJS) -> APNs Provider -> iOS设备
```
### 1.3 核心组件
1. **推送令牌管理**: 存储和管理设备推送令牌
2. **APNs服务**: 与Apple推送服务通信
3. **消息模板系统**: 管理推送消息内容
4. **推送日志**: 记录推送状态和结果
5. **API接口**: 提供推送令牌注册和推送发送功能
## 2. 数据库设计
### 2.1 推送令牌表 (t_user_push_tokens)
```sql
CREATE TABLE t_user_push_tokens (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
user_id VARCHAR(255) NOT NULL,
device_token VARCHAR(255) NOT NULL,
device_type ENUM('IOS', 'ANDROID') NOT NULL DEFAULT 'IOS',
app_version VARCHAR(50),
os_version VARCHAR(50),
device_name VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
last_used_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_device_token (device_token),
INDEX idx_user_device (user_id, device_token),
UNIQUE KEY uk_user_device_token (user_id, device_token)
);
```
### 2.2 推送消息表 (t_push_messages)
```sql
CREATE TABLE t_push_messages (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
user_id VARCHAR(255) NOT NULL,
device_token VARCHAR(255) NOT NULL,
message_type VARCHAR(50) NOT NULL,
title VARCHAR(255),
body TEXT,
payload JSON,
push_type ENUM('ALERT', 'BACKGROUND', 'VOIP', 'LIVEACTIVITY') DEFAULT 'ALERT',
priority TINYINT DEFAULT 10,
expiry DATETIME,
collapse_id VARCHAR(64),
status ENUM('PENDING', 'SENT', 'FAILED', 'EXPIRED') DEFAULT 'PENDING',
apns_response JSON,
error_message TEXT,
sent_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
INDEX idx_message_type (message_type)
);
```
### 2.3 推送模板表 (t_push_templates)
```sql
CREATE TABLE t_push_templates (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
template_key VARCHAR(100) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
payload_template JSON,
push_type ENUM('ALERT', 'BACKGROUND', 'VOIP', 'LIVEACTIVITY') DEFAULT 'ALERT',
priority TINYINT DEFAULT 10,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_template_key (template_key),
INDEX idx_is_active (is_active)
);
```
## 3. 服务架构设计
### 3.1 模块结构
```
src/push-notifications/
├── push-notifications.module.ts
├── push-notifications.controller.ts
├── push-notifications.service.ts
├── apns.provider.ts
├── models/
│ ├── user-push-token.model.ts
│ ├── push-message.model.ts
│ └── push-template.model.ts
├── dto/
│ ├── register-device-token.dto.ts
│ ├── send-push-notification.dto.ts
│ ├── push-template.dto.ts
│ └── push-response.dto.ts
└── interfaces/
├── push-notification.interface.ts
└── apns-config.interface.ts
```
### 3.2 核心服务类
1. **PushNotificationsService**: 主要业务逻辑服务
2. **ApnsProvider**: APNs连接和通信服务
3. **PushTokenService**: 推送令牌管理服务
4. **PushTemplateService**: 推送模板管理服务
## 4. API接口设计
### 4.1 推送令牌管理
```
POST /api/push-notifications/register-token
PUT /api/push-notifications/update-token
DELETE /api/push-notifications/unregister-token
```
### 4.2 推送消息发送
```
POST /api/push-notifications/send
POST /api/push-notifications/send-by-template
POST /api/push-notifications/send-batch
```
### 4.3 推送模板管理
```
GET /api/push-notifications/templates
POST /api/push-notifications/templates
PUT /api/push-notifications/templates/:id
DELETE /api/push-notifications/templates/:id
```
## 5. 配置要求
### 5.1 环境变量
```bash
# APNs配置
APNS_KEY_ID=your_key_id
APNS_TEAM_ID=your_team_id
APNS_KEY_PATH=path/to/APNsAuthKey_XXXXXXXXXX.p8
APNS_BUNDLE_ID=com.yourcompany.yourapp
APNS_ENVIRONMENT=production # or sandbox
# 推送服务配置
PUSH_RETRY_LIMIT=3
PUSH_REQUEST_TIMEOUT=5000
PUSH_HEARTBEAT=60000
```
### 5.2 APNs认证配置
- **Token-based认证** (推荐):
- Key ID: 从Apple开发者账号获取
- Team ID: 从Apple开发者账号获取
- 私钥文件: .p8格式的私钥文件
- **Certificate-based认证**:
- 证书文件: .pem格式的证书文件
- 私钥文件: .pem格式的私钥文件
## 6. 推送消息类型
### 6.1 基础推送类型
- **ALERT**: 标准推送通知,显示警告、播放声音或更新应用图标徽章
- **BACKGROUND**: 静默推送,不显示用户界面
- **VOIP**: VoIP推送用于实时通信
- **LIVEACTIVITY**: 实时活动推送
### 6.2 业务场景推送
- 训练提醒
- 饮食记录提醒
- 挑战进度通知
- 会员到期提醒
- 系统通知
## 7. 错误处理和重试机制
### 7.1 常见错误处理
- **Unregistered**: 设备令牌无效,从数据库中删除
- **BadDeviceToken**: 设备令牌格式错误,记录并标记为无效
- **DeviceTokenNotForTopic**: 设备令牌与Bundle ID不匹配
- **TooManyRequests**: 请求频率过高,实现退避重试
- **InternalServerError**: APNs服务器错误实现重试机制
### 7.2 重试策略
- 指数退避算法
- 最大重试次数限制
- 不同错误类型的差异化处理
## 8. 安全考虑
### 8.1 数据安全
- 推送令牌加密存储
- 敏感信息脱敏日志
- API访问权限控制
### 8.2 隐私保护
- 用户推送偏好设置
- 推送内容审核机制
- 推送频率限制
## 9. 监控和日志
### 9.1 推送监控
- 推送成功率统计
- 推送延迟监控
- 错误率分析
### 9.2 日志记录
- 推送请求日志
- APNs响应日志
- 错误详情日志
## 10. 性能优化
### 10.1 连接管理
- HTTP/2连接池
- 连接复用
- 心跳保活
### 10.2 批量处理
- 批量推送优化
- 异步处理机制
- 队列管理
## 11. 测试策略
### 11.1 单元测试
- 服务层逻辑测试
- 数据模型测试
- 工具函数测试
### 11.2 集成测试
- APNs连接测试
- 推送流程测试
- 错误处理测试
### 11.3 端到端测试
- 沙盒环境测试
- 真机推送测试
- 性能压力测试
## 12. 部署和运维
### 12.1 环境配置
- 开发环境: 使用APNs沙盒环境
- 测试环境: 使用APNs沙盒环境
- 生产环境: 使用APNs生产环境
### 12.2 运维监控
- 推送服务健康检查
- 性能指标监控
- 告警机制设置

View File

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

View File

@@ -0,0 +1,295 @@
# 营养成分表分析 API 文档
## 接口概述
本接口用于分析食物营养成分表图片通过AI大模型智能识别图片中的营养成分信息并为每个营养素提供详细的健康建议。
## 接口信息
- **接口地址**: `POST /diet-records/analyze-nutrition-image`
- **请求方式**: POST
- **内容类型**: `application/json`
- **认证方式**: Bearer Token (JWT)
## 请求参数
### Headers
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| Authorization | string | 是 | JWT认证令牌格式`Bearer {token}` |
| Content-Type | string | 是 | 固定值:`application/json` |
### Body 参数
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|--------|------|------|------|------|
| imageUrl | string | 是 | 营养成分表图片的URL地址 | `https://example.com/nutrition-label.jpg` |
#### 请求示例
```json
{
"imageUrl": "https://example.com/nutrition-label.jpg"
}
```
## 响应格式
### 成功响应
```json
{
"success": true,
"data": [
{
"key": "energy_kcal",
"name": "热量",
"value": "840千焦",
"analysis": "840千焦约等于201卡路里占成人每日推荐摄入总热量的10%,属于中等热量水平。"
},
{
"key": "protein",
"name": "蛋白质",
"value": "12.5g",
"analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。"
},
{
"key": "fat",
"name": "脂肪",
"value": "6.8g",
"analysis": "6.8克脂肪含量适中,主要包含不饱和脂肪酸,有助于维持正常的生理功能。"
},
{
"key": "carbohydrate",
"name": "碳水化合物",
"value": "28.5g",
"analysis": "28.5克碳水化合物提供主要能量来源,建议搭配运动以充分利用能量。"
},
{
"key": "sodium",
"name": "钠",
"value": "480mg",
"analysis": "480毫克钠含量适中约占成人每日推荐摄入量的20%,高血压患者需注意控制总钠摄入。"
}
]
}
```
### 错误响应
```json
{
"success": false,
"data": [],
"message": "错误描述信息"
}
```
## 响应字段说明
### 通用字段
| 字段名 | 类型 | 说明 |
|--------|------|------|
| success | boolean | 操作是否成功 |
| data | array | 营养成分分析结果数组 |
| message | string | 错误信息(仅在失败时返回) |
### 营养成分项字段 (data数组中的对象)
| 字段名 | 类型 | 说明 | 示例 |
|--------|------|------|------|
| key | string | 营养素的唯一标识符 | `energy_kcal` |
| name | string | 营养素的中文名称 | `热量` |
| value | string | 从图片中识别的原始值和单位 | `840千焦` |
| analysis | string | 针对该营养素的详细健康建议 | `840千焦约等于201卡路里...` |
## 支持的营养素类型
| 营养素 | key值 | 中文名称 |
|--------|-------|----------|
| 热量/能量 | energy_kcal | 热量 |
| 蛋白质 | protein | 蛋白质 |
| 脂肪 | fat | 脂肪 |
| 碳水化合物 | carbohydrate | 碳水化合物 |
| 膳食纤维 | fiber | 膳食纤维 |
| 钠 | sodium | 钠 |
| 钙 | calcium | 钙 |
| 铁 | iron | 铁 |
| 锌 | zinc | 锌 |
| 维生素C | vitamin_c | 维生素C |
| 维生素A | vitamin_a | 维生素A |
| 维生素D | vitamin_d | 维生素D |
| 维生素E | vitamin_e | 维生素E |
| 维生素B1 | vitamin_b1 | 维生素B1 |
| 维生素B2 | vitamin_b2 | 维生素B2 |
| 维生素B6 | vitamin_b6 | 维生素B6 |
| 维生素B12 | vitamin_b12 | 维生素B12 |
| 叶酸 | folic_acid | 叶酸 |
| 胆固醇 | cholesterol | 胆固醇 |
| 饱和脂肪 | saturated_fat | 饱和脂肪 |
| 反式脂肪 | trans_fat | 反式脂肪 |
| 糖 | sugar | 糖 |
## 错误码说明
| HTTP状态码 | 错误信息 | 说明 |
|------------|----------|------|
| 400 | 请提供图片URL | 请求体中缺少imageUrl参数 |
| 400 | 图片URL格式不正确 | 提供的URL格式无效 |
| 401 | 未授权访问 | 缺少或无效的JWT令牌 |
| 500 | 营养成分表分析失败,请稍后重试 | AI模型调用失败或服务器内部错误 |
| 500 | 图片中未检测到有效的营养成分表信息 | 图片中未识别到营养成分表 |
## 使用注意事项
### 图片要求
1. **图片格式**: 支持 JPG、PNG、WebP 格式
2. **图片内容**: 必须包含清晰的营养成分表
3. **图片质量**: 建议使用高清、无模糊、光线充足的图片
4. **URL要求**: 图片URL必须是公网可访问的地址
### 最佳实践
1. **URL有效性**: 确保提供的图片URL在分析期间保持可访问
2. **图片预处理**: 建议在客户端对图片进行适当的裁剪,突出营养成分表部分
3. **错误处理**: 客户端应妥善处理各种错误情况,提供友好的用户提示
4. **重试机制**: 对于网络或服务器错误,建议实现适当的重试机制
### 限制说明
1. **调用频率**: 建议客户端控制调用频率,避免过于频繁的请求
2. **图片大小**: 虽然不直接限制图片大小,但过大的图片可能影响处理速度
3. **并发限制**: 服务端可能有并发请求限制,建议客户端实现队列机制
## 客户端集成示例
### JavaScript/TypeScript 示例
```typescript
interface NutritionAnalysisRequest {
imageUrl: string;
}
interface NutritionAnalysisItem {
key: string;
name: string;
value: string;
analysis: string;
}
interface NutritionAnalysisResponse {
success: boolean;
data: NutritionAnalysisItem[];
message?: string;
}
async function analyzeNutritionImage(
imageUrl: string,
token: string
): Promise<NutritionAnalysisResponse> {
try {
const response = await fetch('/diet-records/analyze-nutrition-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ imageUrl })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '请求失败');
}
return result;
} catch (error) {
console.error('营养成分分析失败:', error);
throw error;
}
}
// 使用示例
const token = 'your-jwt-token';
const imageUrl = 'https://example.com/nutrition-label.jpg';
analyzeNutritionImage(imageUrl, token)
.then(result => {
if (result.success) {
console.log('识别到营养素数量:', result.data.length);
result.data.forEach(item => {
console.log(`${item.name}: ${item.value}`);
console.log(`建议: ${item.analysis}`);
});
} else {
console.error('分析失败:', result.message);
}
})
.catch(error => {
console.error('请求异常:', error);
});
```
### Swift 示例
```swift
struct NutritionAnalysisRequest: Codable {
let imageUrl: String
}
struct NutritionAnalysisItem: Codable {
let key: String
let name: String
let value: String
let analysis: String
}
struct NutritionAnalysisResponse: Codable {
let success: Bool
let data: [NutritionAnalysisItem]
let message: String?
}
class NutritionAnalysisService {
func analyzeNutritionImage(imageUrl: String, token: String) async throws -> NutritionAnalysisResponse {
guard let url = URL(string: "/diet-records/analyze-nutrition-image") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let requestBody = NutritionAnalysisRequest(imageUrl: imageUrl)
request.httpBody = try JSONEncoder().encode(requestBody)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard 200...299 ~= httpResponse.statusCode else {
throw NSError(domain: "APIError", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP Error"])
}
let result = try JSONDecoder().decode(NutritionAnalysisResponse.self, from: data)
return result
}
}
```
## 更新日志
| 版本 | 日期 | 更新内容 |
|------|------|----------|
| 1.0.0 | 2024-10-16 | 初始版本,支持营养成分表图片分析功能 |
## 技术支持
如有技术问题或集成困难,请联系开发团队获取支持。

View File

@@ -0,0 +1,474 @@
# iOS推送功能使用指南
## 概述
本文档详细介绍了如何使用iOS远程推送功能包括API接口使用、配置说明和代码示例。
## 环境配置
### 1. 环境变量配置
`.env`文件中添加以下配置:
```bash
# APNs配置
APNS_KEY_ID=your_key_id
APNS_TEAM_ID=your_team_id
APNS_KEY_PATH=path/to/APNsAuthKey_XXXXXXXXXX.p8
APNS_BUNDLE_ID=com.yourcompany.yourapp
APNS_ENVIRONMENT=production # or sandbox
# 推送服务配置
APNS_CLIENT_COUNT=2
APNS_CONNECTION_RETRY_LIMIT=3
APNS_HEARTBEAT=60000
APNS_REQUEST_TIMEOUT=5000
```
### 2. APNs认证文件
1. 登录 [Apple Developer Portal](https://developer.apple.com)
2. 导航到 "Certificates, Identifiers & Profiles"
3. 选择 "Keys"
4. 创建新的密钥,并启用 "Apple Push Notifications service"
5. 下载`.p8`格式的私钥文件
6. 将私钥文件安全地存储在服务器上
### 3. 数据库迁移
执行以下SQL脚本创建推送相关的数据表
```bash
mysql -u username -p database_name < sql-scripts/push-notifications-tables-create.sql
```
## API接口使用
### 1. 设备令牌管理
#### 注册设备令牌
```bash
POST /api/push-notifications/register-token
Authorization: Bearer <access_token>
Content-Type: application/json
{
"deviceToken": "a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb383697bf9f9d750a1003da19c7",
"deviceType": "IOS",
"appVersion": "1.0.0",
"osVersion": "iOS 15.0",
"deviceName": "iPhone 13"
}
```
**响应示例:**
```json
{
"code": 0,
"message": "设备令牌注册成功",
"data": {
"success": true,
"tokenId": "uuid-token-id"
}
}
```
#### 更新设备令牌
```bash
PUT /api/push-notifications/update-token
Authorization: Bearer <access_token>
Content-Type: application/json
{
"currentDeviceToken": "old-device-token",
"newDeviceToken": "new-device-token",
"appVersion": "1.0.1",
"osVersion": "iOS 15.1"
}
```
#### 注销设备令牌
```bash
DELETE /api/push-notifications/unregister-token
Authorization: Bearer <access_token>
Content-Type: application/json
{
"deviceToken": "device-token-to-unregister"
}
```
### 2. 推送消息发送
#### 发送单个推送通知
```bash
POST /api/push-notifications/send
Content-Type: application/json
{
"userIds": ["user_123", "user_456"],
"title": "训练提醒",
"body": "您今天的普拉提训练还未完成,快来打卡吧!",
"payload": {
"type": "training_reminder",
"trainingId": "training_123"
},
"pushType": "ALERT",
"priority": 10,
"sound": "default",
"badge": 1
}
```
**响应示例:**
```json
{
"code": 0,
"message": "推送发送成功",
"data": {
"success": true,
"sentCount": 2,
"failedCount": 0,
"results": [
{
"userId": "user_123",
"deviceToken": "device-token-1",
"success": true
},
{
"userId": "user_456",
"deviceToken": "device-token-2",
"success": true
}
]
}
}
```
#### 使用模板发送推送
```bash
POST /api/push-notifications/send-by-template
Content-Type: application/json
{
"userIds": ["user_123"],
"templateKey": "training_reminder",
"data": {
"userName": "张三",
"trainingName": "核心力量训练"
},
"payload": {
"type": "training_reminder",
"trainingId": "training_123"
}
}
```
#### 批量发送推送
```bash
POST /api/push-notifications/send-batch
Content-Type: application/json
{
"userIds": ["user_123", "user_456", "user_789"],
"title": "系统通知",
"body": "系统将于今晚22:00进行维护请提前保存您的工作。",
"payload": {
"type": "system_maintenance",
"maintenanceTime": "22:00"
}
}
```
#### 发送静默推送
```bash
POST /api/push-notifications/send-silent
Content-Type: application/json
{
"userId": "user_123",
"payload": {
"type": "data_sync",
"syncData": true
}
}
```
### 3. 推送模板管理
#### 获取所有模板
```bash
GET /api/push-notifications/templates
Authorization: Bearer <access_token>
```
#### 创建推送模板
```bash
POST /api/push-notifications/templates
Authorization: Bearer <access_token>
Content-Type: application/json
{
"templateKey": "custom_reminder",
"title": "自定义提醒",
"body": "您好{{userName}}{{reminderContent}}",
"payloadTemplate": {
"type": "custom_reminder",
"reminderId": "{{reminderId}}"
},
"pushType": "ALERT",
"priority": 8
}
```
#### 更新推送模板
```bash
PUT /api/push-notifications/templates/:id
Authorization: Bearer <access_token>
Content-Type: application/json
{
"title": "更新后的标题",
"body": "更新后的内容:{{userName}}{{reminderContent}}",
"isActive": true
}
```
#### 删除推送模板
```bash
DELETE /api/push-notifications/templates/:id
Authorization: Bearer <access_token>
```
## 代码示例
### 1. 在业务服务中使用推送功能
```typescript
import { Injectable } from '@nestjs/common';
import { PushNotificationsService } from '../push-notifications/push-notifications.service';
@Injectable()
export class TrainingService {
constructor(
private readonly pushNotificationsService: PushNotificationsService,
) {}
async sendTrainingReminder(userId: string, trainingName: string): Promise<void> {
// 使用模板发送推送
await this.pushNotificationsService.sendNotificationByTemplate({
userIds: [userId],
templateKey: 'training_reminder',
data: {
userName: '用户', // 可以从用户服务获取
trainingName,
},
payload: {
type: 'training_reminder',
trainingId: 'training_123',
},
});
}
async sendWorkoutCompletionNotification(userId: string, workoutName: string, calories: number): Promise<void> {
// 直接发送推送
await this.pushNotificationsService.sendNotification({
userIds: [userId],
title: '训练完成',
body: `太棒了!您已完成${workoutName}训练,消耗了${calories}卡路里。`,
payload: {
type: 'workout_completed',
workoutId: 'workout_123',
calories,
},
sound: 'celebration.caf',
badge: 1,
});
}
}
```
### 2. 在控制器中处理设备令牌注册
```typescript
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { PushNotificationsService } from '../push-notifications/push-notifications.service';
import { RegisterDeviceTokenDto } from '../push-notifications/dto/register-device-token.dto';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
@ApiTags('用户设备')
@Controller('user/device')
@UseGuards(JwtAuthGuard)
export class UserDeviceController {
constructor(
private readonly pushNotificationsService: PushNotificationsService,
) {}
@Post('register-token')
@ApiOperation({ summary: '注册设备推送令牌' })
async registerDeviceToken(
@CurrentUser() user: AccessTokenPayload,
@Body() registerTokenDto: RegisterDeviceTokenDto,
) {
return this.pushNotificationsService.registerToken(user.sub, registerTokenDto);
}
}
```
## 预定义推送模板
系统已预置以下推送模板:
### 1. 训练提醒 (training_reminder)
- **用途**: 提醒用户完成训练
- **变量**: `{{userName}}`, `{{trainingName}}`
- **示例**: "您好张三,您今天的核心力量训练还未完成,快来打卡吧!"
### 2. 饮食记录提醒 (diet_record_reminder)
- **用途**: 提醒用户记录饮食
- **变量**: `{{userName}}`
- **示例**: "您好张三,您还没有记录今天的饮食,记得及时记录哦!"
### 3. 挑战进度 (challenge_progress)
- **用途**: 通知用户挑战进度
- **变量**: `{{challengeName}}`, `{{progress}}`
- **示例**: "恭喜您您已完成30天挑战的50%,继续加油!"
### 4. 会员到期提醒 (membership_expiring)
- **用途**: 提醒用户会员即将到期
- **变量**: `{{userName}}`, `{{days}}`
- **示例**: "您好张三您的会员将在7天后到期请及时续费以免影响使用。"
### 5. 会员已到期 (membership_expired)
- **用途**: 通知用户会员已到期
- **变量**: `{{userName}}`
- **示例**: "您好张三,您的会员已到期,请续费以继续享受会员服务。"
### 6. 成就解锁 (achievement_unlocked)
- **用途**: 庆祝用户解锁成就
- **变量**: `{{achievementName}}`
- **示例**: "恭喜您解锁了"连续训练7天"成就!"
### 7. 训练完成 (workout_completed)
- **用途**: 确认用户完成训练
- **变量**: `{{workoutName}}`, `{{calories}}`
- **示例**: "太棒了您已完成核心力量训练消耗了150卡路里。"
## 错误处理
### 常见错误码
| 错误码 | 描述 | 解决方案 |
|--------|------|----------|
| 400 | 请求参数错误 | 检查请求参数格式和必填字段 |
| 401 | 未授权访问 | 确保提供了有效的访问令牌 |
| 404 | 资源不存在 | 检查用户ID或模板键是否正确 |
| 429 | 请求频率过高 | 降低请求频率,实现退避重试 |
| 500 | 服务器内部错误 | 检查服务器日志,联系技术支持 |
### APNs错误处理
系统会自动处理以下APNs错误
- **Unregistered**: 自动停用无效的设备令牌
- **BadDeviceToken**: 记录错误并停用令牌
- **DeviceTokenNotForTopic**: 记录错误日志
- **TooManyRequests**: 实现退避重试机制
- **InternalServerError**: 自动重试
## 监控和日志
### 1. 推送状态监控
可以通过以下方式监控推送状态:
```typescript
// 获取推送统计
const stats = await this.pushMessageService.getMessageStats();
console.log(`推送成功率: ${stats.successRate}%`);
console.log(`错误分布:`, stats.errorBreakdown);
```
### 2. 日志查看
推送相关日志包含以下信息:
- 推送请求和响应
- APNs连接状态
- 错误详情和堆栈跟踪
- 性能指标
## 最佳实践
### 1. 推送时机
- 避免在深夜或凌晨发送推送
- 根据用户时区调整推送时间
- 尊重用户的推送偏好设置
### 2. 推送内容
- 保持推送内容简洁明了
- 使用个性化内容提高用户参与度
- 避免发送过于频繁的推送
### 3. 性能优化
- 使用批量推送减少网络请求
- 实现推送优先级管理
- 定期清理无效的设备令牌
### 4. 安全考虑
- 保护用户隐私数据
- 实现推送内容审核机制
- 使用HTTPS进行API通信
## 故障排除
### 1. 推送不生效
- 检查APNs配置是否正确
- 验证设备令牌是否有效
- 确认Bundle ID是否匹配
### 2. 推送延迟
- 检查网络连接状态
- 验证APNs服务器状态
- 调整推送优先级设置
### 3. 设备令牌失效
- 实现令牌自动更新机制
- 定期清理无效令牌
- 监控令牌失效率
## 扩展功能
### 1. 推送统计分析
- 实现推送打开率统计
- 分析用户行为数据
- 优化推送策略
### 2. A/B测试
- 实现推送内容A/B测试
- 比较不同推送策略效果
- 优化推送转化率
### 3. 多平台支持
- 扩展Android推送功能
- 统一推送接口设计
- 实现平台特定功能
## 总结
iOS推送功能已完全集成到系统中提供了完整的推送令牌管理、消息发送和模板系统。通过遵循本指南您可以轻松地在应用中实现各种推送场景提升用户体验和参与度。
如有任何问题或需要进一步的技术支持,请参考相关文档或联系开发团队。

View File

@@ -0,0 +1,673 @@
# iOS推送服务架构和接口设计
## 1. 服务架构概览
### 1.1 整体架构图
```mermaid
graph TB
A[iOS App] --> B[APNs]
B --> C[NestJS Push Service]
C --> D[APNs Provider]
D --> B
C --> E[Push Token Service]
C --> F[Push Template Service]
C --> G[Push Message Service]
C --> H[Database]
E --> H
F --> H
G --> H
```
### 1.2 模块依赖关系
```mermaid
graph LR
A[PushNotificationsModule] --> B[PushNotificationsController]
A --> C[PushNotificationsService]
A --> D[ApnsProvider]
A --> E[PushTokenService]
A --> F[PushTemplateService]
A --> G[PushMessageService]
A --> H[DatabaseModule]
A --> I[ConfigModule]
```
## 2. 核心服务类设计
### 2.1 PushNotificationsService
```typescript
@Injectable()
export class PushNotificationsService {
constructor(
private readonly apnsProvider: ApnsProvider,
private readonly pushTokenService: PushTokenService,
private readonly pushTemplateService: PushTemplateService,
private readonly pushMessageService: PushMessageService,
private readonly logger: Logger,
) {}
// 发送单个推送
async sendNotification(userId: string, notification: PushNotificationDto): Promise<PushResponseDto>
// 批量发送推送
async sendBatchNotifications(userIds: string[], notification: PushNotificationDto): Promise<BatchPushResponseDto>
// 使用模板发送推送
async sendNotificationByTemplate(userId: string, templateKey: string, data: any): Promise<PushResponseDto>
// 发送静默推送
async sendSilentNotification(userId: string, payload: any): Promise<PushResponseDto>
}
```
### 2.2 ApnsProvider
```typescript
@Injectable()
export class ApnsProvider {
private provider: apn.Provider;
private multiProvider: apn.MultiProvider;
constructor(private readonly configService: ConfigService) {
this.initializeProvider();
}
// 初始化APNs连接
private initializeProvider(): void
// 发送单个通知
async send(notification: apn.Notification, deviceTokens: string[]): Promise<apn.Results>
// 批量发送通知
async sendBatch(notifications: apn.Notification[], deviceTokens: string[]): Promise<apn.Results>
// 管理推送通道
async manageChannels(notification: apn.Notification, bundleId: string, action: string): Promise<any>
// 广播实时活动通知
async broadcast(notification: apn.Notification, bundleId: string): Promise<any>
// 关闭连接
shutdown(): void
}
```
### 2.3 PushTokenService
```typescript
@Injectable()
export class PushTokenService {
constructor(
@InjectModel(UserPushToken) private readonly pushTokenModel: typeof UserPushToken,
) {}
// 注册设备令牌
async registerToken(userId: string, tokenData: RegisterDeviceTokenDto): Promise<UserPushToken>
// 更新设备令牌
async updateToken(userId: string, tokenData: UpdateDeviceTokenDto): Promise<UserPushToken>
// 注销设备令牌
async unregisterToken(userId: string, deviceToken: string): Promise<void>
// 获取用户的所有有效令牌
async getActiveTokens(userId: string): Promise<UserPushToken[]>
// 清理无效令牌
async cleanupInvalidTokens(): Promise<number>
// 验证令牌有效性
async validateToken(deviceToken: string): Promise<boolean>
}
```
### 2.4 PushTemplateService
```typescript
@Injectable()
export class PushTemplateService {
constructor(
@InjectModel(PushTemplate) private readonly templateModel: typeof PushTemplate,
) {}
// 创建推送模板
async createTemplate(templateData: CreatePushTemplateDto): Promise<PushTemplate>
// 更新推送模板
async updateTemplate(id: string, templateData: UpdatePushTemplateDto): Promise<PushTemplate>
// 删除推送模板
async deleteTemplate(id: string): Promise<void>
// 获取模板
async getTemplate(templateKey: string): Promise<PushTemplate>
// 获取所有模板
async getAllTemplates(): Promise<PushTemplate[]>
// 渲染模板
async renderTemplate(templateKey: string, data: any): Promise<RenderedTemplate>
}
```
### 2.5 PushMessageService
```typescript
@Injectable()
export class PushMessageService {
constructor(
@InjectModel(PushMessage) private readonly messageModel: typeof PushMessage,
) {}
// 创建推送消息记录
async createMessage(messageData: CreatePushMessageDto): Promise<PushMessage>
// 更新消息状态
async updateMessageStatus(id: string, status: PushMessageStatus, response?: any): Promise<void>
// 获取消息历史
async getMessageHistory(userId: string, options: QueryOptions): Promise<PushMessage[]>
// 获取消息统计
async getMessageStats(userId?: string, timeRange?: TimeRange): Promise<PushStats>
// 清理过期消息
async cleanupExpiredMessages(): Promise<number>
}
```
## 3. 数据传输对象(DTO)设计
### 3.1 推送令牌相关DTO
```typescript
// 注册设备令牌
export class RegisterDeviceTokenDto {
@ApiProperty({ description: '设备推送令牌' })
@IsString()
@IsNotEmpty()
deviceToken: string;
@ApiProperty({ description: '设备类型', enum: DeviceType })
@IsEnum(DeviceType)
deviceType: DeviceType;
@ApiProperty({ description: '应用版本', required: false })
@IsString()
@IsOptional()
appVersion?: string;
@ApiProperty({ description: '操作系统版本', required: false })
@IsString()
@IsOptional()
osVersion?: string;
@ApiProperty({ description: '设备名称', required: false })
@IsString()
@IsOptional()
deviceName?: string;
}
// 更新设备令牌
export class UpdateDeviceTokenDto {
@ApiProperty({ description: '新的设备推送令牌' })
@IsString()
@IsNotEmpty()
newDeviceToken: string;
@ApiProperty({ description: '应用版本', required: false })
@IsString()
@IsOptional()
appVersion?: string;
@ApiProperty({ description: '操作系统版本', required: false })
@IsString()
@IsOptional()
osVersion?: string;
@ApiProperty({ description: '设备名称', required: false })
@IsString()
@IsOptional()
deviceName?: string;
}
```
### 3.2 推送消息相关DTO
```typescript
// 发送推送通知
export class SendPushNotificationDto {
@ApiProperty({ description: '用户ID列表' })
@IsArray()
@IsString({ each: true })
userIds: string[];
@ApiProperty({ description: '推送标题' })
@IsString()
@IsNotEmpty()
title: string;
@ApiProperty({ description: '推送内容' })
@IsString()
@IsNotEmpty()
body: string;
@ApiProperty({ description: '自定义数据', required: false })
@IsObject()
@IsOptional()
payload?: any;
@ApiProperty({ description: '推送类型', enum: PushType, required: false })
@IsEnum(PushType)
@IsOptional()
pushType?: PushType;
@ApiProperty({ description: '优先级', required: false })
@IsNumber()
@IsOptional()
priority?: number;
@ApiProperty({ description: '过期时间(秒)', required: false })
@IsNumber()
@IsOptional()
expiry?: number;
@ApiProperty({ description: '折叠ID', required: false })
@IsString()
@IsOptional()
collapseId?: string;
}
// 使用模板发送推送
export class SendPushByTemplateDto {
@ApiProperty({ description: '用户ID列表' })
@IsArray()
@IsString({ each: true })
userIds: string[];
@ApiProperty({ description: '模板键' })
@IsString()
@IsNotEmpty()
templateKey: string;
@ApiProperty({ description: '模板数据' })
@IsObject()
@IsNotEmpty()
data: any;
@ApiProperty({ description: '自定义数据', required: false })
@IsObject()
@IsOptional()
payload?: any;
}
```
### 3.3 推送模板相关DTO
```typescript
// 创建推送模板
export class CreatePushTemplateDto {
@ApiProperty({ description: '模板键' })
@IsString()
@IsNotEmpty()
templateKey: string;
@ApiProperty({ description: '模板标题' })
@IsString()
@IsNotEmpty()
title: string;
@ApiProperty({ description: '模板内容' })
@IsString()
@IsNotEmpty()
body: string;
@ApiProperty({ description: '负载模板', required: false })
@IsObject()
@IsOptional()
payloadTemplate?: any;
@ApiProperty({ description: '推送类型', enum: PushType, required: false })
@IsEnum(PushType)
@IsOptional()
pushType?: PushType;
@ApiProperty({ description: '优先级', required: false })
@IsNumber()
@IsOptional()
priority?: number;
}
// 更新推送模板
export class UpdatePushTemplateDto {
@ApiProperty({ description: '模板标题', required: false })
@IsString()
@IsOptional()
title?: string;
@ApiProperty({ description: '模板内容', required: false })
@IsString()
@IsOptional()
body?: string;
@ApiProperty({ description: '负载模板', required: false })
@IsObject()
@IsOptional()
payloadTemplate?: any;
@ApiProperty({ description: '推送类型', enum: PushType, required: false })
@IsEnum(PushType)
@IsOptional()
pushType?: PushType;
@ApiProperty({ description: '优先级', required: false })
@IsNumber()
@IsOptional()
priority?: number;
@ApiProperty({ description: '是否激活', required: false })
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
```
### 3.4 响应DTO
```typescript
// 推送响应
export class PushResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '推送结果' })
data: {
success: boolean;
sentCount: number;
failedCount: number;
results: PushResult[];
};
}
// 批量推送响应
export class BatchPushResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '批量推送结果' })
data: {
totalUsers: number;
totalTokens: number;
successCount: number;
failedCount: number;
results: PushResult[];
};
}
// 推送结果
export class PushResult {
@ApiProperty({ description: '用户ID' })
userId: string;
@ApiProperty({ description: '设备令牌' })
deviceToken: string;
@ApiProperty({ description: '是否成功' })
success: boolean;
@ApiProperty({ description: '错误信息', required: false })
@IsString()
@IsOptional()
error?: string;
@ApiProperty({ description: 'APNs响应', required: false })
@IsObject()
@IsOptional()
apnsResponse?: any;
}
```
## 4. 控制器接口设计
### 4.1 PushNotificationsController
```typescript
@Controller('push-notifications')
@ApiTags('推送通知')
@UseGuards(JwtAuthGuard)
export class PushNotificationsController {
constructor(private readonly pushNotificationsService: PushNotificationsService) {}
// 注册设备令牌
@Post('register-token')
@ApiOperation({ summary: '注册设备推送令牌' })
@ApiResponse({ status: 200, description: '注册成功', type: ResponseDto })
async registerToken(
@CurrentUser() user: AccessTokenPayload,
@Body() registerTokenDto: RegisterDeviceTokenDto,
): Promise<ResponseDto> {
return this.pushNotificationsService.registerToken(user.sub, registerTokenDto);
}
// 更新设备令牌
@Put('update-token')
@ApiOperation({ summary: '更新设备推送令牌' })
@ApiResponse({ status: 200, description: '更新成功', type: ResponseDto })
async updateToken(
@CurrentUser() user: AccessTokenPayload,
@Body() updateTokenDto: UpdateDeviceTokenDto,
): Promise<ResponseDto> {
return this.pushNotificationsService.updateToken(user.sub, updateTokenDto);
}
// 注销设备令牌
@Delete('unregister-token')
@ApiOperation({ summary: '注销设备推送令牌' })
@ApiResponse({ status: 200, description: '注销成功', type: ResponseDto })
async unregisterToken(
@CurrentUser() user: AccessTokenPayload,
@Body() body: { deviceToken: string },
): Promise<ResponseDto> {
return this.pushNotificationsService.unregisterToken(user.sub, body.deviceToken);
}
// 发送推送通知
@Post('send')
@ApiOperation({ summary: '发送推送通知' })
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendNotification(
@Body() sendNotificationDto: SendPushNotificationDto,
): Promise<PushResponseDto> {
return this.pushNotificationsService.sendNotification(sendNotificationDto);
}
// 使用模板发送推送
@Post('send-by-template')
@ApiOperation({ summary: '使用模板发送推送' })
@ApiResponse({ status: 200, description: '发送成功', type: PushResponseDto })
async sendNotificationByTemplate(
@Body() sendByTemplateDto: SendPushByTemplateDto,
): Promise<PushResponseDto> {
return this.pushNotificationsService.sendNotificationByTemplate(sendByTemplateDto);
}
// 批量发送推送
@Post('send-batch')
@ApiOperation({ summary: '批量发送推送' })
@ApiResponse({ status: 200, description: '发送成功', type: BatchPushResponseDto })
async sendBatchNotifications(
@Body() sendBatchDto: SendPushNotificationDto,
): Promise<BatchPushResponseDto> {
return this.pushNotificationsService.sendBatchNotifications(sendBatchDto);
}
}
```
### 4.2 PushTemplateController
```typescript
@Controller('push-notifications/templates')
@ApiTags('推送模板')
@UseGuards(JwtAuthGuard)
export class PushTemplateController {
constructor(private readonly pushTemplateService: PushTemplateService) {}
// 获取所有模板
@Get()
@ApiOperation({ summary: '获取所有推送模板' })
@ApiResponse({ status: 200, description: '获取成功', type: [PushTemplate] })
async getAllTemplates(): Promise<PushTemplate[]> {
return this.pushTemplateService.getAllTemplates();
}
// 获取单个模板
@Get(':templateKey')
@ApiOperation({ summary: '获取推送模板' })
@ApiResponse({ status: 200, description: '获取成功', type: PushTemplate })
async getTemplate(
@Param('templateKey') templateKey: string,
): Promise<PushTemplate> {
return this.pushTemplateService.getTemplate(templateKey);
}
// 创建模板
@Post()
@ApiOperation({ summary: '创建推送模板' })
@ApiResponse({ status: 201, description: '创建成功', type: PushTemplate })
async createTemplate(
@Body() createTemplateDto: CreatePushTemplateDto,
): Promise<PushTemplate> {
return this.pushTemplateService.createTemplate(createTemplateDto);
}
// 更新模板
@Put(':id')
@ApiOperation({ summary: '更新推送模板' })
@ApiResponse({ status: 200, description: '更新成功', type: PushTemplate })
async updateTemplate(
@Param('id') id: string,
@Body() updateTemplateDto: UpdatePushTemplateDto,
): Promise<PushTemplate> {
return this.pushTemplateService.updateTemplate(id, updateTemplateDto);
}
// 删除模板
@Delete(':id')
@ApiOperation({ summary: '删除推送模板' })
@ApiResponse({ status: 200, description: '删除成功' })
async deleteTemplate(@Param('id') id: string): Promise<void> {
return this.pushTemplateService.deleteTemplate(id);
}
}
```
## 5. 接口使用示例
### 5.1 注册设备令牌
```bash
POST /api/push-notifications/register-token
Authorization: Bearer <access_token>
Content-Type: application/json
{
"deviceToken": "a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb383697bf9f9d750a1003da19c7",
"deviceType": "IOS",
"appVersion": "1.0.0",
"osVersion": "iOS 15.0",
"deviceName": "iPhone 13"
}
```
### 5.2 发送推送通知
```bash
POST /api/push-notifications/send
Content-Type: application/json
{
"userIds": ["user_123", "user_456"],
"title": "训练提醒",
"body": "您今天的普拉提训练还未完成,快来打卡吧!",
"payload": {
"type": "training_reminder",
"trainingId": "training_123"
},
"pushType": "ALERT",
"priority": 10
}
```
### 5.3 使用模板发送推送
```bash
POST /api/push-notifications/send-by-template
Content-Type: application/json
{
"userIds": ["user_123"],
"templateKey": "training_reminder",
"data": {
"userName": "张三",
"trainingName": "核心力量训练"
},
"payload": {
"type": "training_reminder",
"trainingId": "training_123"
}
}
```
## 6. 错误处理
### 6.1 错误类型定义
```typescript
export enum PushErrorCode {
INVALID_DEVICE_TOKEN = 'INVALID_DEVICE_TOKEN',
DEVICE_TOKEN_NOT_FOR_TOPIC = 'DEVICE_TOKEN_NOT_FOR_TOPIC',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
TEMPLATE_NOT_FOUND = 'TEMPLATE_NOT_FOUND',
USER_NOT_FOUND = 'USER_NOT_FOUND',
INVALID_PAYLOAD = 'INVALID_PAYLOAD',
}
export class PushException extends HttpException {
constructor(
errorCode: PushErrorCode,
message: string,
statusCode: HttpStatus = HttpStatus.BAD_REQUEST,
) {
super({ code: errorCode, message }, statusCode);
}
}
```
### 6.2 全局异常处理
```typescript
@Catch(PushException, Error)
export class PushExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
if (exception instanceof PushException) {
response.status(exception.getStatus()).json(exception.getResponse());
} else {
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
code: 'INTERNAL_SERVER_ERROR',
message: '推送服务内部错误',
});
}
}
}
```
## 7. 性能优化策略
### 7.1 连接池管理
- 使用HTTP/2连接池提高并发性能
- 实现连接复用和心跳保活
- 动态调整连接池大小
### 7.2 批量处理优化
- 实现批量推送减少网络请求
- 使用队列系统处理大量推送请求
- 实现推送优先级和限流机制
### 7.3 缓存策略
- 缓存用户设备令牌减少数据库查询
- 缓存推送模板提高渲染性能
- 实现分布式缓存支持集群部署

View File

@@ -0,0 +1,151 @@
# 流式响应与结构化数据冲突解决方案
## 问题描述
当前实现中,`#记饮食` 指令在第一阶段需要返回结构化数据(确认选项),但客户端可能设置了 `stream: true`,导致响应类型冲突。
## 解决方案对比
### 方案1强制非流式模式 ⭐ (当前实现)
**优点:**
- 实现简单,改动最小
- 完全向后兼容
- 客户端只需检查 Content-Type
**缺点:**
- 行为不够明确忽略stream参数
- 客户端需要额外处理响应类型检测
**实现:**
```typescript
// 当需要返回确认选项时自动使用JSON响应
if (typeof result === 'object' && 'type' in result) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.send({ conversationId, data: result.data });
return;
}
```
### 方案2分离API端点
**优点:**
- API语义清晰
- 响应类型明确
- 易于测试和维护
**缺点:**
- 需要新增API端点
- 客户端需要适配新API
**建议API设计**
```typescript
// 专门的食物识别API
@Post('analyze-food')
async analyzeFood(body: { imageUrls: string[] }): Promise<FoodRecognitionResponseDto>
// 确认并记录API
@Post('confirm-food-record')
async confirmFoodRecord(body: { selectedOption: any, imageUrl: string }): Promise<DietRecordResponseDto>
// 原有聊天API保持纯文本
@Post('chat')
async chat(): Promise<StreamableFile | { text: string }>
```
### 方案3统一JSON响应格式
**优点:**
- 响应格式统一
- 可以在JSON中指示是否需要流式处理
**缺点:**
- 破坏向后兼容性
- 所有客户端都需要修改
**实现示例:**
```typescript
// 统一响应格式
{
conversationId: string;
responseType: 'text' | 'choices' | 'stream';
data: {
content?: string;
choices?: any[];
streamUrl?: string; // 流式数据的WebSocket URL
}
}
```
### 方案4SSE (Server-Sent Events) 统一
**优点:**
- 可以发送不同类型的事件
- 保持连接状态
- 支持实时交互
**缺点:**
- 实现复杂度高
- 需要客户端支持SSE
**实现示例:**
```typescript
// SSE事件类型
event: text
data: {"chunk": "AI回复的文本片段"}
event: choices
data: {"choices": [...], "content": "请选择食物"}
event: complete
data: {"conversationId": "..."}
```
## 推荐方案
### 短期方案1 (当前实现) ✅
- 快速解决问题
- 最小化影响
- 保持兼容性
### 长期方案2 (分离API端点)
- 更清晰的API设计
- 更好的可维护性
- 更明确的职责分离
## 当前方案的客户端适配
```javascript
async function sendDietRequest(imageUrls, conversationId, stream = true) {
const response = await fetch('/ai-coach/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversationId,
messages: [{ role: 'user', content: '#记饮食' }],
imageUrls,
stream
})
});
// 检查响应类型
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
// 结构化数据(确认选项)
const data = await response.json();
return { type: 'choices', data };
} else {
// 流式文本
return { type: 'stream', stream: response.body };
}
}
```
## 总结
当前的方案1实现简单有效能够解决流式响应冲突问题。虽然在语义上不够完美但在实际使用中是可行的。建议
1. **立即采用方案1**,解决当前问题
2. **文档中明确说明**响应类型检测的必要性
3. **后续版本考虑方案2**提供更清晰的API设计

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,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. 原有的训练计划数据保持不变,作为模板使用
这样的架构分离使得系统更加清晰、可维护,也更符合健身应用的实际使用场景。

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来创建数据

353
package-lock.json generated
View File

@@ -14,17 +14,26 @@
"@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",
"axios": "^1.10.0",
"body-parser": "^2.2.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"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",
@@ -49,6 +58,7 @@
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7",
"@types/sequelize": "^4.28.20",
"@types/supertest": "^6.0.2",
@@ -1355,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",
@@ -1884,6 +1900,15 @@
"node": ">=8"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://mirrors.tencent.com/npm/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@microsoft/tsdoc": {
"version": "0.15.1",
"resolved": "https://mirrors.tencent.com/npm/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz",
@@ -2369,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",
@@ -2531,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",
@@ -2585,6 +2634,88 @@
"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",
"integrity": "sha512-uBUTTbzk0YyMOcE5qTcNdit5v1BdaECCRSQYbMGU/qY1eHwBaqeWOYd8rwi2Caga3K7IZyQGhpvL4/56H+uvrQ==",
"license": "MIT",
"dependencies": {
"debug": "4.3.3",
"jsonwebtoken": "9.0.0",
"node-forge": "1.3.1",
"verror": "1.10.1"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/@parse/node-apn/node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"license": "MIT"
},
"node_modules/@parse/node-apn/node_modules/debug": {
"version": "4.3.3",
"resolved": "https://mirrors.tencent.com/npm/debug/-/debug-4.3.3.tgz",
"integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
"license": "MIT",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@parse/node-apn/node_modules/jsonwebtoken": {
"version": "9.0.0",
"resolved": "https://mirrors.tencent.com/npm/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz",
"integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash": "^4.17.21",
"ms": "^2.1.1",
"semver": "^7.3.8"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/@parse/node-apn/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/@parse/node-apn/node_modules/verror": {
"version": "1.10.1",
"resolved": "https://mirrors.tencent.com/npm/verror/-/verror-1.10.1.tgz",
"integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==",
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/@pkgr/core": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz",
@@ -3305,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",
@@ -3366,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",
@@ -3385,6 +3531,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/@types/multer/-/multer-2.0.0.tgz",
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "22.13.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.13.tgz",
@@ -4319,6 +4475,19 @@
"node": ">= 8"
}
},
"node_modules/apns2": {
"version": "12.2.0",
"resolved": "https://mirrors.tencent.com/npm/apns2/-/apns2-12.2.0.tgz",
"integrity": "sha512-HySXBzPDMTX8Vxy/ilU9/XcNndJBlgCc+no2+Hj4BaY7CjkStkszufAI6CRK1yDw8K+6ALH+V+mXuQKZe2zeZA==",
"license": "MIT",
"dependencies": {
"fast-jwt": "^6.0.1",
"undici": "^7.9.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@ -4382,6 +4551,17 @@
"safer-buffer": "~2.1.0"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://mirrors.tencent.com/npm/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
@@ -4735,6 +4915,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://mirrors.tencent.com/npm/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="
},
"node_modules/bodec": {
"version": "0.1.0",
"resolved": "https://mirrors.tencent.com/npm/bodec/-/bodec-0.1.0.tgz",
@@ -5255,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",
@@ -5611,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",
@@ -5669,10 +5874,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"dev": true,
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
"license": "MIT"
},
"node_modules/debounce-fn": {
@@ -6804,6 +7008,21 @@
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT"
},
"node_modules/fast-jwt": {
"version": "6.0.2",
"resolved": "https://mirrors.tencent.com/npm/fast-jwt/-/fast-jwt-6.0.2.tgz",
"integrity": "sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA==",
"license": "Apache-2.0",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"asn1.js": "^5.4.1",
"ecdsa-sig-formatter": "^1.0.11",
"mnemonist": "^0.40.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
@@ -7158,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": {
@@ -7879,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",
@@ -9164,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",
@@ -9334,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",
@@ -9525,6 +9788,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -9578,6 +9847,15 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mnemonist": {
"version": "0.40.3",
"resolved": "https://mirrors.tencent.com/npm/mnemonist/-/mnemonist-0.40.3.tgz",
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
}
},
"node_modules/module-details-from-path": {
"version": "1.0.3",
"resolved": "https://mirrors.tencent.com/npm/module-details-from-path/-/module-details-from-path-1.0.3.tgz",
@@ -9836,6 +10114,15 @@
}
}
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://mirrors.tencent.com/npm/node-forge/-/node-forge-1.3.1.tgz",
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -9925,6 +10212,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://mirrors.tencent.com/npm/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -11034,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",
@@ -11971,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",
@@ -12890,6 +13209,15 @@
"through": "^2.3.8"
}
},
"node_modules/undici": {
"version": "7.16.0",
"resolved": "https://mirrors.tencent.com/npm/undici/-/undici-7.16.0.tgz",
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
@@ -13523,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,17 +32,26 @@
"@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",
"axios": "^1.10.0",
"body-parser": "^2.2.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"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",
@@ -67,6 +76,7 @@
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7",
"@types/sequelize": "^4.28.20",
"@types/supertest": "^6.0.2",

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,14 @@
-- Add challenge type column to t_challenges table
-- This migration adds the type column to support different challenge types
ALTER TABLE t_challenges
ADD COLUMN type ENUM('water', 'exercise', 'diet', 'mood', 'sleep', 'weight')
NOT NULL DEFAULT 'water'
COMMENT '挑战类型'
AFTER cta_label;
-- Create index on type column for better query performance
CREATE INDEX idx_challenges_type ON t_challenges (type);
-- Update existing challenges to have 'water' type if they don't have a type
UPDATE t_challenges SET type = 'water' WHERE type IS NULL;

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,54 @@
-- 会员编号字段迁移脚本
-- 执行日期: 2025-09-26
-- 描述: 为用户表添加会员编号字段按照创建时间从1开始递增编号
-- Step 1: 添加会员编号字段
ALTER TABLE `t_users`
ADD COLUMN `member_number` INT NULL COMMENT '会员编号(按注册时间递增)' AFTER `gender`;
-- Step 2: 创建索引以提高查询性能
CREATE INDEX `idx_member_number` ON `t_users` (`member_number`);
-- Step 3: 为现有用户按照创建时间分配会员编号从1开始递增
SET @row_number = 0;
UPDATE `t_users`
SET `member_number` = (@row_number := @row_number + 1)
WHERE `member_number` IS NULL
ORDER BY `created_at` ASC;
-- Step 4: 验证更新结果
SELECT
COUNT(*) as total_users,
MIN(member_number) as min_member_number,
MAX(member_number) as max_member_number,
COUNT(DISTINCT member_number) as unique_member_numbers
FROM `t_users`
WHERE `member_number` IS NOT NULL;
-- Step 5: 显示前10个用户的会员编号分配情况
SELECT
`id`,
`name`,
`member_number`,
`created_at`,
`is_guest`
FROM `t_users`
WHERE `member_number` IS NOT NULL
ORDER BY `member_number` ASC
LIMIT 10;
-- Step 6: 检查是否有重复的会员编号应该为0
SELECT
member_number,
COUNT(*) as count
FROM `t_users`
WHERE `member_number` IS NOT NULL
GROUP BY `member_number`
HAVING COUNT(*) > 1;
-- 注意事项:
-- 1. 此脚本会为所有现有用户分配会员编号,按照创建时间排序
-- 2. 会员编号从1开始递增
-- 3. 包含游客用户也会分配编号
-- 4. 如果只希望为正式用户(非游客)分配编号,请在更新语句中添加条件: WHERE `member_number` IS NULL AND `is_guest` = 0
-- 5. 执行前请备份数据库

View File

@@ -0,0 +1,63 @@
-- 身体围度功能数据库迁移脚本
-- 执行日期: 2024年
-- 功能: 为用户档案表新增围度字段,创建围度历史记录表
-- 禁用外键检查(执行时)
SET FOREIGN_KEY_CHECKS = 0;
-- 1. 为用户档案表新增围度字段
ALTER TABLE `t_user_profile`
ADD COLUMN `chest_circumference` FLOAT NULL COMMENT '胸围(厘米)' AFTER `daily_water_goal`,
ADD COLUMN `waist_circumference` FLOAT NULL COMMENT '腰围(厘米)' AFTER `chest_circumference`,
ADD COLUMN `upper_hip_circumference` FLOAT NULL COMMENT '上臀围(厘米)' AFTER `waist_circumference`,
ADD COLUMN `arm_circumference` FLOAT NULL COMMENT '臂围(厘米)' AFTER `upper_hip_circumference`,
ADD COLUMN `thigh_circumference` FLOAT NULL COMMENT '大腿围(厘米)' AFTER `arm_circumference`,
ADD COLUMN `calf_circumference` FLOAT NULL COMMENT '小腿围(厘米)' AFTER `thigh_circumference`;
-- 2. 创建用户身体围度历史记录表
CREATE TABLE `t_user_body_measurement_history` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` VARCHAR(255) NOT NULL COMMENT '用户ID',
`measurement_type` ENUM(
'chest_circumference',
'waist_circumference',
'upper_hip_circumference',
'arm_circumference',
'thigh_circumference',
'calf_circumference'
) NOT NULL COMMENT '围度类型',
`value` FLOAT NOT NULL COMMENT '围度值(厘米)',
`source` ENUM('manual', 'other') NOT NULL DEFAULT 'manual' 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`),
KEY `idx_user_id` (`user_id`),
KEY `idx_measurement_type` (`measurement_type`),
KEY `idx_created_at` (`created_at`),
KEY `idx_user_measurement_time` (`user_id`, `measurement_type`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户身体围度历史记录表';
-- 重新启用外键检查
SET FOREIGN_KEY_CHECKS = 1;
-- 验证表结构
SHOW CREATE TABLE `t_user_profile`;
SHOW CREATE TABLE `t_user_body_measurement_history`;
-- 验证新增字段
SELECT
COLUMN_NAME,
DATA_TYPE,
IS_NULLABLE,
COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 't_user_profile'
AND COLUMN_NAME IN (
'chest_circumference',
'waist_circumference',
'upper_hip_circumference',
'arm_circumference',
'thigh_circumference',
'calf_circumference'
);

View File

@@ -0,0 +1,56 @@
-- Challenges feature DDL
-- Creates core tables required by the challenge listing, participation, and progress tracking flows.
CREATE TABLE IF NOT EXISTS t_challenges (
id CHAR(36) NOT NULL PRIMARY KEY,
title VARCHAR(255) NOT NULL COMMENT '挑战标题',
image VARCHAR(512) DEFAULT NULL COMMENT '挑战封面图',
start_at DATETIME NOT NULL COMMENT '挑战开始时间',
end_at DATETIME NOT NULL COMMENT '挑战结束时间',
period_label VARCHAR(128) DEFAULT NULL COMMENT '周期标签例如「21天挑战」',
duration_label VARCHAR(128) NOT NULL COMMENT '持续时间标签例如「持续21天」',
requirement_label VARCHAR(255) NOT NULL COMMENT '挑战要求标签,例如「每日练习 1 次」',
summary TEXT DEFAULT NULL COMMENT '挑战概要说明',
target_value INT NOT NULL COMMENT '挑战目标值(例如需要完成的天数)',
progress_unit VARCHAR(64) NOT NULL DEFAULT '' COMMENT '进度单位,用于展示排行榜指标',
ranking_description VARCHAR(255) DEFAULT NULL COMMENT '排行榜描述,例如「连续打卡榜」',
highlight_title VARCHAR(255) NOT NULL COMMENT '高亮标题',
highlight_subtitle VARCHAR(255) NOT NULL COMMENT '高亮副标题',
cta_label VARCHAR(128) NOT NULL COMMENT 'CTA 按钮文字',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB
CREATE TABLE IF NOT EXISTS t_challenge_participants (
id CHAR(36) NOT NULL PRIMARY KEY,
challenge_id CHAR(36) NOT NULL COMMENT '挑战 ID',
user_id VARCHAR(64) NOT NULL COMMENT '用户 ID',
progress_value INT NOT NULL DEFAULT 0 COMMENT '当前进度值',
target_value INT NOT NULL COMMENT '目标值,通常与挑战 target_value 相同',
status ENUM('active', 'completed', 'left') NOT NULL DEFAULT 'active' COMMENT '参与状态',
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间',
left_at DATETIME DEFAULT NULL COMMENT '退出时间',
last_progress_at DATETIME DEFAULT NULL COMMENT '最近一次更新进度的时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_challenge_participant_challenge FOREIGN KEY (challenge_id) REFERENCES t_challenges (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_challenge_participant_user FOREIGN KEY (user_id) REFERENCES t_users (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT uq_challenge_participant UNIQUE KEY (challenge_id, user_id)
) ENGINE=InnoDB
CREATE INDEX idx_challenge_participants_status_progress
ON t_challenge_participants (challenge_id, status, progress_value DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS t_challenge_progress_reports (
id CHAR(36) NOT NULL PRIMARY KEY,
challenge_id CHAR(36) NOT NULL COMMENT '挑战 ID',
user_id VARCHAR(64) NOT NULL COMMENT '用户 ID',
report_date DATE NOT NULL COMMENT '自然日,确保每日仅上报一次',
increment_value INT NOT NULL DEFAULT 1 COMMENT '本次上报的进度增量',
reported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上报时间戳',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_challenge_progress_reports_challenge FOREIGN KEY (challenge_id) REFERENCES t_challenges (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_challenge_progress_reports_user FOREIGN KEY (user_id) REFERENCES t_users (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT uq_challenge_progress_reports_day UNIQUE KEY (challenge_id, user_id, report_date)
) ENGINE=InnoDB

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,45 @@
-- 创建用户饮食记录表
-- 该表用于存储用户通过AI视觉识别或手动输入的饮食记录
-- 包含详细的营养成分信息,支持营养分析和健康建议功能
CREATE TABLE IF NOT EXISTS `t_user_diet_history` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`meal_type` enum('breakfast','lunch','dinner','snack','other') NOT NULL DEFAULT 'other' COMMENT '餐次类型',
`food_name` varchar(100) NOT NULL COMMENT '食物名称',
`food_description` varchar(500) DEFAULT NULL COMMENT '食物描述(详细信息)',
`weight_grams` float DEFAULT NULL COMMENT '食物重量(克)',
`portion_description` varchar(50) DEFAULT NULL COMMENT '份量描述1碗、2片、100g等',
`estimated_calories` float DEFAULT NULL COMMENT '估算总热量(卡路里)',
`protein_grams` float DEFAULT NULL COMMENT '蛋白质含量(克)',
`carbohydrate_grams` float DEFAULT NULL COMMENT '碳水化合物含量(克)',
`fat_grams` float DEFAULT NULL COMMENT '脂肪含量(克)',
`fiber_grams` float DEFAULT NULL COMMENT '膳食纤维含量(克)',
`sugar_grams` float DEFAULT NULL COMMENT '糖分含量(克)',
`sodium_mg` float DEFAULT NULL COMMENT '钠含量(毫克)',
`additional_nutrition` json DEFAULT NULL COMMENT '其他营养信息(维生素、矿物质等)',
`source` enum('manual','vision','other') NOT NULL DEFAULT 'manual' COMMENT '记录来源',
`meal_time` datetime DEFAULT NULL COMMENT '用餐时间',
`image_url` varchar(500) DEFAULT NULL COMMENT '食物图片URL',
`ai_analysis_result` json DEFAULT NULL COMMENT 'AI识别原始结果',
`notes` text DEFAULT NULL COMMENT '用户备注',
`deleted` tinyint(1) NOT NULL DEFAULT '0' 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`),
KEY `idx_user_id` (`user_id`),
KEY `idx_meal_type` (`meal_type`),
KEY `idx_created_at` (`created_at`),
KEY `idx_user_created` (`user_id`, `created_at`),
KEY `idx_deleted` (`deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户饮食记录表';
-- 创建索引以优化查询性能
CREATE INDEX `idx_user_meal_time` ON `t_user_diet_history` (`user_id`, `meal_time`);
CREATE INDEX `idx_source` ON `t_user_diet_history` (`source`);
-- 示例数据(可选)
-- INSERT INTO `t_user_diet_history` (`user_id`, `meal_type`, `food_name`, `food_description`, `portion_description`, `estimated_calories`, `protein_grams`, `carbohydrate_grams`, `fat_grams`, `fiber_grams`, `source`, `meal_time`, `notes`) VALUES
-- ('test_user_001', 'breakfast', '燕麦粥', '燕麦片加牛奶和香蕉', '1碗', 320, 12.5, 45.2, 8.3, 6.8, 'manual', '2024-01-15 08:00:00', '早餐很有营养'),
-- ('test_user_001', 'lunch', '鸡胸肉沙拉', '烤鸡胸肉配蔬菜沙拉', '1份', 280, 35.0, 15.5, 8.0, 5.2, 'vision', '2024-01-15 12:30:00', 'AI识别添加'),
-- ('test_user_001', 'dinner', '三文鱼配糙米', '煎三文鱼配蒸糙米和西兰花', '1份', 450, 28.5, 52.0, 18.2, 4.5, 'manual', '2024-01-15 19:00:00', '晚餐丰富');

View File

@@ -0,0 +1,41 @@
-- 修复字符集排序规则不一致的问题
-- 将所有相关表的字符集统一为 utf8mb4_unicode_ci
-- 检查当前表的字符集和排序规则
SELECT
TABLE_NAME,
TABLE_COLLATION,
CHARACTER_SET_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('t_users', 't_challenge_participants', 't_challenges');
-- 检查列的字符集和排序规则
SELECT
TABLE_NAME,
COLUMN_NAME,
COLLATION_NAME,
CHARACTER_SET_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('t_users', 't_challenge_participants', 't_challenges')
AND COLUMN_NAME IN ('id', 'user_id', 'challenge_id');
-- 修改表字符集和排序规则
ALTER TABLE t_users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE t_challenge_participants CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE t_challenges CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 修改特定列的字符集和排序规则(如果需要)
ALTER TABLE t_users MODIFY id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE t_challenge_participants MODIFY user_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE t_challenge_participants MODIFY challenge_id CHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 验证修复结果
SELECT
TABLE_NAME,
TABLE_COLLATION,
CHARACTER_SET_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('t_users', 't_challenge_participants', 't_challenges');

View File

@@ -0,0 +1,85 @@
-- 插入更多示例食物数据
-- 清空现有数据(如果需要重新初始化)
-- DELETE FROM `t_food_library`;
-- DELETE FROM `t_food_categories`;
-- 插入食物分类数据
INSERT IGNORE INTO `t_food_categories` (`key`, `name`, `sort_order`, `is_system`) VALUES
('common', '常见', 1, 1),
('fruits_vegetables', '水果蔬菜', 2, 1),
('meat_eggs_dairy', '肉蛋奶', 3, 1),
('beans_nuts', '豆类坚果', 4, 1),
('snacks_drinks', '零食饮料', 5, 1),
('staple_food', '主食', 6, 1),
('dishes', '菜肴', 7, 1);
-- 插入常见食物(这些食物会显示在"常见"分类中)
INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES
-- 常见食物(会显示在常见分类中)
('无糖美式咖啡', 'snacks_drinks', 1, 0.1, 0, 0, 0, 0, 2, 1, 1),
('荷包蛋(油煎)', 'meat_eggs_dairy', 195, 13.3, 0.7, 15.3, 0, 0.7, 124, 1, 2),
('鸡蛋', 'meat_eggs_dairy', 139, 13.3, 2.8, 8.8, 0, 2.8, 131, 1, 3),
('香蕉', 'fruits_vegetables', 93, 1.4, 22.8, 0.2, 1.7, 17.2, 1, 1, 4),
('猕猴桃', 'fruits_vegetables', 61, 1.1, 14.7, 0.5, 3, 9, 3, 1, 5),
('苹果', 'fruits_vegetables', 53, 0.3, 14.1, 0.2, 2.4, 10.4, 1, 1, 6),
('草莓', 'fruits_vegetables', 32, 0.7, 7.7, 0.3, 2, 4.9, 1, 1, 7),
('蛋烧麦', 'staple_food', 157, 6.2, 22.2, 5.2, 1.1, 1.8, 230, 1, 8),
('米饭', 'staple_food', 116, 2.6, 25.9, 0.3, 0.3, 0.1, 5, 1, 9);
-- 插入水果蔬菜分类的其他食物
INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES
('橙子', 'fruits_vegetables', 47, 0.9, 11.8, 0.1, 2.4, 9.4, 0, 0, 1),
('葡萄', 'fruits_vegetables', 69, 0.7, 17.2, 0.2, 0.9, 16.3, 2, 0, 2),
('西瓜', 'fruits_vegetables', 30, 0.6, 7.6, 0.2, 0.4, 6.2, 1, 0, 3),
('菠菜', 'fruits_vegetables', 23, 2.9, 3.6, 0.4, 2.2, 0.4, 79, 0, 4),
('西兰花', 'fruits_vegetables', 34, 2.8, 7, 0.4, 2.6, 1.5, 33, 0, 5),
('胡萝卜', 'fruits_vegetables', 41, 0.9, 9.6, 0.2, 2.8, 4.7, 69, 0, 6),
('西红柿', 'fruits_vegetables', 18, 0.9, 3.9, 0.2, 1.2, 2.6, 5, 0, 7);
-- 插入肉蛋奶分类的其他食物
INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES
('鸡胸肉', 'meat_eggs_dairy', 165, 31, 0, 3.6, 0, 0, 74, 0, 1),
('牛肉', 'meat_eggs_dairy', 250, 26, 0, 15, 0, 0, 72, 0, 2),
('猪肉', 'meat_eggs_dairy', 242, 27, 0, 14, 0, 0, 58, 0, 3),
('三文鱼', 'meat_eggs_dairy', 208, 25, 0, 12, 0, 0, 44, 0, 4),
('牛奶', 'meat_eggs_dairy', 54, 3.4, 5.1, 1.9, 0, 5.1, 44, 0, 5),
('酸奶', 'meat_eggs_dairy', 99, 10, 3.6, 5.3, 0, 3.2, 36, 0, 6),
('奶酪', 'meat_eggs_dairy', 113, 25, 1.3, 0.2, 0, 1.3, 515, 0, 7);
-- 插入豆类坚果分类的食物
INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES
('黄豆', 'beans_nuts', 446, 36, 30, 20, 15, 7, 2, 0, 1),
('黑豆', 'beans_nuts', 341, 21, 63, 1.4, 15, 2.1, 2, 0, 2),
('红豆', 'beans_nuts', 309, 20, 63, 0.5, 12, 2.2, 2, 0, 3),
('核桃', 'beans_nuts', 654, 15, 14, 65, 6.7, 2.6, 2, 0, 4),
('杏仁', 'beans_nuts', 579, 21, 22, 50, 12, 4.4, 1, 0, 5),
('花生', 'beans_nuts', 567, 26, 16, 49, 8.5, 4.7, 18, 0, 6),
('腰果', 'beans_nuts', 553, 18, 30, 44, 3.3, 5.9, 12, 0, 7);
-- 插入主食分类的其他食物
INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES
('白面包', 'staple_food', 265, 9, 49, 3.2, 2.7, 5.7, 491, 0, 1),
('全麦面包', 'staple_food', 247, 13, 41, 4.2, 7, 6, 396, 0, 2),
('燕麦', 'staple_food', 389, 17, 66, 6.9, 10, 0.99, 2, 0, 3),
('小米', 'staple_food', 378, 11, 73, 4.2, 8.5, 1.7, 5, 0, 4),
('玉米', 'staple_food', 365, 9.4, 74, 4.7, 7.3, 6.3, 35, 0, 5),
('红薯', 'staple_food', 86, 1.6, 20, 0.1, 3, 4.2, 54, 0, 6),
('土豆', 'staple_food', 77, 2, 17, 0.1, 2.2, 0.8, 6, 0, 7);
-- 插入零食饮料分类的其他食物
INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES
('绿茶', 'snacks_drinks', 1, 0, 0, 0, 0, 0, 3, 0, 1),
('红茶', 'snacks_drinks', 1, 0, 0.3, 0, 0, 0, 3, 0, 2),
('柠檬水', 'snacks_drinks', 22, 0.4, 6.9, 0.2, 1.6, 1.5, 1, 0, 3),
('苏打水', 'snacks_drinks', 0, 0, 0, 0, 0, 0, 21, 0, 4),
('黑巧克力', 'snacks_drinks', 546, 7.8, 61, 31, 11, 48, 20, 0, 5),
('饼干', 'snacks_drinks', 502, 5.9, 68, 23, 2.1, 27, 386, 0, 6);
-- 插入菜肴分类的食物
INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES
('宫保鸡丁', 'dishes', 194, 18, 8, 11, 2, 4, 590, 0, 1),
('麻婆豆腐', 'dishes', 164, 11, 6, 12, 2, 3, 680, 0, 2),
('红烧肉', 'dishes', 395, 15, 8, 35, 1, 6, 720, 0, 3),
('清蒸鱼', 'dishes', 112, 20, 2, 3, 0, 1, 280, 0, 4),
('蒸蛋羹', 'dishes', 62, 5.8, 1.2, 4.1, 0, 1, 156, 0, 5),
('凉拌黄瓜', 'dishes', 16, 0.7, 3.6, 0.1, 0.5, 1.7, 6, 0, 6);

View File

@@ -0,0 +1,80 @@
-- 创建食物分类表
-- 该表用于存储食物的分类信息,如常见、水果蔬菜、肉蛋奶等
CREATE TABLE IF NOT EXISTS `t_food_categories` (
`key` varchar(50) NOT NULL COMMENT '分类唯一键(英文/下划线)',
`name` varchar(50) NOT NULL COMMENT '分类中文名称',
`icon` varchar(100) DEFAULT NULL COMMENT '分类图标',
`sort_order` int NOT NULL DEFAULT '0' COMMENT '排序(升序)',
`is_system` 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 '更新时间',
PRIMARY KEY (`key`),
KEY `idx_sort_order` (`sort_order`),
KEY `idx_is_system` (`is_system`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='食物分类表';
-- 创建食物库表
-- 该表用于存储食物的基本信息和营养成分
CREATE TABLE IF NOT EXISTS `t_food_library` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(100) NOT NULL COMMENT '食物名称',
`description` varchar(500) DEFAULT NULL COMMENT '食物描述',
`category_key` varchar(50) NOT NULL COMMENT '分类键',
`calories_per_100g` float DEFAULT NULL COMMENT '每100克热量卡路里',
`protein_per_100g` float DEFAULT NULL COMMENT '每100克蛋白质含量',
`carbohydrate_per_100g` float DEFAULT NULL COMMENT '每100克碳水化合物含量',
`fat_per_100g` float DEFAULT NULL COMMENT '每100克脂肪含量',
`fiber_per_100g` float DEFAULT NULL COMMENT '每100克膳食纤维含量',
`sugar_per_100g` float DEFAULT NULL COMMENT '每100克糖分含量',
`sodium_per_100g` float DEFAULT NULL COMMENT '每100克钠含量毫克',
`additional_nutrition` json DEFAULT NULL COMMENT '其他营养信息(维生素、矿物质等)',
`is_common` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否常见食物1常见0不常见',
`image_url` varchar(500) DEFAULT NULL COMMENT '食物图片URL',
`sort_order` int NOT NULL DEFAULT '0' 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`),
KEY `idx_category_key` (`category_key`),
KEY `idx_is_common` (`is_common`),
KEY `idx_name` (`name`),
KEY `idx_category_sort` (`category_key`, `sort_order`),
CONSTRAINT `fk_food_category` FOREIGN KEY (`category_key`) REFERENCES `t_food_categories` (`key`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='食物库表';
-- 插入食物分类数据
INSERT INTO `t_food_categories` (`key`, `name`, `sort_order`, `is_system`) VALUES
('common', '常见', 1, 1),
('fruits_vegetables', '水果蔬菜', 2, 1),
('meat_eggs_dairy', '肉蛋奶', 3, 1),
('beans_nuts', '豆类坚果', 4, 1),
('snacks_drinks', '零食饮料', 5, 1),
('staple_food', '主食', 6, 1),
('dishes', '菜肴', 7, 1);
-- 插入示例食物数据
INSERT INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES
-- 常见食物
('无糖美式咖啡', 'common', 1, 0.1, 0, 0, 0, 0, 2, 1, 1),
('荷包蛋(油煎)', 'common', 195, 13.3, 0.7, 15.3, 0, 0.7, 124, 1, 2),
('鸡蛋', 'common', 139, 13.3, 2.8, 8.8, 0, 2.8, 131, 1, 3),
-- 水果蔬菜
('香蕉', 'fruits_vegetables', 93, 1.4, 22.8, 0.2, 1.7, 17.2, 1, 1, 1),
('猕猴桃', 'fruits_vegetables', 61, 1.1, 14.7, 0.5, 3, 9, 3, 1, 2),
('苹果', 'fruits_vegetables', 53, 0.3, 14.1, 0.2, 2.4, 10.4, 1, 1, 3),
('草莓', 'fruits_vegetables', 32, 0.7, 7.7, 0.3, 2, 4.9, 1, 1, 4),
-- 主食
('蛋烧麦', 'staple_food', 157, 6.2, 22.2, 5.2, 1.1, 1.8, 230, 1, 1),
('米饭', 'staple_food', 116, 2.6, 25.9, 0.3, 0.3, 0.1, 5, 1, 2),
-- 零食饮料
('无糖美式咖啡', 'snacks_drinks', 1, 0.1, 0, 0, 0, 0, 2, 0, 1),
-- 肉蛋奶
('鸡蛋', 'meat_eggs_dairy', 139, 13.3, 2.8, 8.8, 0, 2.8, 131, 0, 1),
('荷包蛋(油煎)', 'meat_eggs_dairy', 195, 13.3, 0.7, 15.3, 0, 0.7, 124, 0, 2);
-- 创建索引以优化查询性能
CREATE INDEX `idx_food_common_category` ON `t_food_library` (`is_common`, `category_key`);
CREATE INDEX `idx_food_name_search` ON `t_food_library` (`name`);

View File

@@ -0,0 +1,49 @@
-- 创建目标子任务表
CREATE TABLE IF NOT EXISTS `t_goal_tasks` (
`id` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '任务ID',
`goal_id` CHAR(36) NOT NULL COMMENT '目标ID',
`user_id` VARCHAR(255) NOT NULL COMMENT '用户ID',
`title` VARCHAR(255) NOT NULL COMMENT '任务标题',
`description` TEXT COMMENT '任务描述',
`start_date` DATE NOT NULL COMMENT '任务开始日期',
`end_date` DATE NOT NULL COMMENT '任务结束日期',
`target_count` INT NOT NULL DEFAULT 1 COMMENT '任务目标次数如喝水8次',
`current_count` INT NOT NULL DEFAULT 0 COMMENT '任务当前完成次数',
`status` ENUM('pending', 'in_progress', 'completed', 'overdue', 'skipped') NOT NULL DEFAULT 'pending' COMMENT '任务状态',
`progress_percentage` INT NOT NULL DEFAULT 0 COMMENT '完成进度百分比 (0-100)',
`completed_at` DATETIME COMMENT '任务完成时间',
`notes` TEXT COMMENT '任务备注',
`metadata` JSON 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_goal_id` (`goal_id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_status` (`status`),
INDEX `idx_start_date` (`start_date`),
INDEX `idx_end_date` (`end_date`),
INDEX `idx_deleted` (`deleted`),
INDEX `idx_user_goal` (`user_id`, `goal_id`),
INDEX `idx_user_status` (`user_id`, `status`),
INDEX `idx_user_date_range` (`user_id`, `start_date`, `end_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='目标子任务表';
-- 添加外键约束
ALTER TABLE `t_goal_tasks`
ADD CONSTRAINT `fk_goal_tasks_goal_id`
FOREIGN KEY (`goal_id`) REFERENCES `t_goals` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
-- 添加检查约束MySQL 8.0+
-- ALTER TABLE `t_goal_tasks`
-- ADD CONSTRAINT `chk_target_count_positive` CHECK (`target_count` > 0);
--
-- ALTER TABLE `t_goal_tasks`
-- ADD CONSTRAINT `chk_current_count_non_negative` CHECK (`current_count` >= 0);
--
-- ALTER TABLE `t_goal_tasks`
-- ADD CONSTRAINT `chk_progress_percentage_range` CHECK (`progress_percentage` >= 0 AND `progress_percentage` <= 100);
--
-- ALTER TABLE `t_goal_tasks`
-- ADD CONSTRAINT `chk_date_range` CHECK (`end_date` >= `start_date`);

View File

@@ -0,0 +1,60 @@
-- 创建目标表
CREATE TABLE IF NOT EXISTS `t_goals` (
`id` char(36) NOT NULL COMMENT '主键ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`title` varchar(255) NOT NULL COMMENT '目标标题',
`description` text COMMENT '目标描述',
`repeat_type` enum('daily','weekly','monthly','custom') NOT NULL DEFAULT 'daily' COMMENT '重复周期类型daily-每日weekly-每周monthly-每月custom-自定义',
`frequency` int NOT NULL DEFAULT 1 COMMENT '频率(每天/每周/每月多少次)',
`custom_repeat_rule` json DEFAULT NULL COMMENT '自定义重复规则(如每周几)',
`start_date` date NOT NULL COMMENT '目标开始日期',
`end_date` date DEFAULT NULL COMMENT '目标结束日期',
`status` enum('active','paused','completed','cancelled') NOT NULL DEFAULT 'active' COMMENT '目标状态active-激活paused-暂停completed-已完成cancelled-已取消',
`completed_count` int NOT NULL DEFAULT 0 COMMENT '已完成次数',
`target_count` int DEFAULT NULL COMMENT '目标总次数null表示无限制',
`category` varchar(100) DEFAULT NULL COMMENT '目标分类标签',
`priority` int NOT NULL DEFAULT 0 COMMENT '优先级(数字越大优先级越高)',
`has_reminder` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否提醒',
`reminder_time` time DEFAULT NULL COMMENT '提醒时间',
`reminder_settings` 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_status` (`status`),
KEY `idx_repeat_type` (`repeat_type`),
KEY `idx_category` (`category`),
KEY `idx_start_date` (`start_date`),
KEY `idx_deleted` (`deleted`),
KEY `idx_user_status` (`user_id`, `status`, `deleted`),
CONSTRAINT `chk_frequency` CHECK (`frequency` > 0 AND `frequency` <= 100),
CONSTRAINT `chk_priority` CHECK (`priority` >= 0 AND `priority` <= 10),
CONSTRAINT `chk_target_count` CHECK (`target_count` IS NULL OR `target_count` > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户目标表';
-- 创建目标完成记录表
CREATE TABLE IF NOT EXISTS `t_goal_completions` (
`id` char(36) NOT NULL COMMENT '主键ID',
`goal_id` char(36) NOT NULL COMMENT '目标ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`completed_at` datetime NOT NULL COMMENT '完成日期',
`completion_count` int NOT NULL DEFAULT 1 COMMENT '完成次数',
`notes` text COMMENT '完成备注',
`metadata` 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_goal_id` (`goal_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_completed_at` (`completed_at`),
KEY `idx_deleted` (`deleted`),
KEY `idx_goal_completed` (`goal_id`, `completed_at`, `deleted`),
CONSTRAINT `fk_goal_completions_goal` FOREIGN KEY (`goal_id`) REFERENCES `t_goals` (`id`) ON DELETE CASCADE,
CONSTRAINT `chk_completion_count` CHECK (`completion_count` > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='目标完成记录表';
-- 创建额外的复合索引以优化查询性能
CREATE INDEX IF NOT EXISTS `idx_goals_user_date` ON `t_goals` (`user_id`, `start_date`, `deleted`);
CREATE INDEX IF NOT EXISTS `idx_goal_completions_user_date` ON `t_goal_completions` (`user_id`, `completed_at`, `deleted`);

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,24 @@
-- 心情打卡表
CREATE TABLE IF NOT EXISTS `t_mood_checkins` (
`id` varchar(36) NOT NULL COMMENT '主键ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`mood_type` enum('happy','excited','thrilled','calm','anxious','sad','lonely','wronged','angry','tired') NOT NULL COMMENT '心情类型:开心、心动、兴奋、平静、焦虑、难过、孤独、委屈、生气、心累',
`intensity` int NOT NULL DEFAULT '5' COMMENT '心情强度1-10',
`description` text COMMENT '心情描述',
`checkin_date` date NOT NULL COMMENT '打卡日期YYYY-MM-DD',
`metadata` 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_checkin_date` (`checkin_date`),
KEY `idx_mood_type` (`mood_type`),
KEY `idx_user_date` (`user_id`, `checkin_date`),
KEY `idx_deleted` (`deleted`),
CONSTRAINT `fk_mood_checkins_user_id` FOREIGN KEY (`user_id`) REFERENCES `t_users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='心情打卡表';
-- 添加索引以优化查询性能
CREATE INDEX `idx_user_mood_date` ON `t_mood_checkins` (`user_id`, `mood_type`, `checkin_date`);
CREATE INDEX `idx_intensity` ON `t_mood_checkins` (`intensity`);

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,72 @@
-- 推送令牌表
CREATE TABLE t_user_push_tokens (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
user_id VARCHAR(255) NOT NULL COMMENT '用户ID',
device_token VARCHAR(255) NOT NULL COMMENT '设备推送令牌',
device_type ENUM('IOS', 'ANDROID') NOT NULL DEFAULT 'IOS' COMMENT '设备类型',
app_version VARCHAR(50) NULL COMMENT '应用版本',
os_version VARCHAR(50) NULL COMMENT '操作系统版本',
device_name VARCHAR(255) NULL COMMENT '设备名称',
is_active BOOLEAN DEFAULT TRUE COMMENT '是否激活',
last_used_at DATETIME NULL COMMENT '最后使用时间',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_device_token (device_token),
INDEX idx_user_device (user_id, device_token),
UNIQUE KEY uk_user_device_token (user_id, device_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户推送令牌表';
-- 推送消息表
CREATE TABLE t_push_messages (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
user_id VARCHAR(255) NOT NULL COMMENT '用户ID',
device_token VARCHAR(255) NOT NULL COMMENT '设备推送令牌',
message_type VARCHAR(50) NOT NULL COMMENT '消息类型',
title VARCHAR(255) NULL COMMENT '推送标题',
body TEXT NULL COMMENT '推送内容',
payload JSON NULL COMMENT '自定义负载数据',
push_type ENUM('ALERT', 'BACKGROUND', 'VOIP', 'LIVEACTIVITY') NOT NULL DEFAULT 'ALERT' COMMENT '推送类型',
priority TINYINT NOT NULL DEFAULT 10 COMMENT '优先级',
expiry DATETIME NULL COMMENT '过期时间',
collapse_id VARCHAR(64) NULL COMMENT '折叠ID',
status ENUM('PENDING', 'SENT', 'FAILED', 'EXPIRED') NOT NULL DEFAULT 'PENDING' COMMENT '推送状态',
apns_response JSON NULL COMMENT 'APNs响应数据',
error_message TEXT NULL COMMENT '错误信息',
sent_at DATETIME NULL COMMENT '发送时间',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
INDEX idx_message_type (message_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推送消息表';
-- 推送模板表
CREATE TABLE t_push_templates (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
template_key VARCHAR(100) NOT NULL UNIQUE COMMENT '模板键',
title VARCHAR(255) NOT NULL COMMENT '模板标题',
body TEXT NOT NULL COMMENT '模板内容',
payload_template JSON NULL COMMENT '负载模板',
push_type ENUM('ALERT', 'BACKGROUND', 'VOIP', 'LIVEACTIVITY') NOT NULL DEFAULT 'ALERT' COMMENT '推送类型',
priority TINYINT NOT NULL DEFAULT 10 COMMENT '优先级',
is_active BOOLEAN DEFAULT TRUE COMMENT '是否激活',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_template_key (template_key),
INDEX idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推送模板表';
-- 插入默认推送模板
INSERT INTO t_push_templates (template_key, title, body, payload_template, push_type, priority, is_active) VALUES
('training_reminder', '训练提醒', '您好{{userName}},您今天的{{trainingName}}训练还未完成,快来打卡吧!', '{"type": "training_reminder", "trainingId": "{{trainingId}}"}', 'ALERT', 10, TRUE),
('diet_record_reminder', '饮食记录提醒', '您好{{userName}},您还没有记录今天的饮食,记得及时记录哦!', '{"type": "diet_record_reminder"}', 'ALERT', 8, TRUE),
('challenge_progress', '挑战进度', '恭喜您!您已完成{{challengeName}}挑战的{{progress}}%,继续加油!', '{"type": "challenge_progress", "challengeId": "{{challengeId}}"}', 'ALERT', 9, TRUE),
('membership_expiring', '会员到期提醒', '您好{{userName}},您的会员将在{{days}}天后到期,请及时续费以免影响使用。', '{"type": "membership_expiring", "days": {{days}}}', 'ALERT', 10, TRUE),
('membership_expired', '会员已到期', '您好{{userName}},您的会员已到期,请续费以继续享受会员服务。', '{"type": "membership_expired"}', 'ALERT', 10, TRUE),
('achievement_unlocked', '成就解锁', '恭喜您解锁了"{{achievementName}}"成就!', '{"type": "achievement_unlocked", "achievementId": "{{achievementId}}"}', 'ALERT', 9, TRUE),
('workout_completed', '训练完成', '太棒了!您已完成{{workoutName}}训练,消耗了{{calories}}卡路里。', '{"type": "workout_completed", "workoutId": "{{workoutId}}", "calories": {{calories}}}', 'ALERT', 8, TRUE);

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

@@ -0,0 +1,36 @@
-- 创建用户活跃记录表
CREATE TABLE IF NOT EXISTS `t_user_activities` (
`id` int NOT NULL AUTO_INCREMENT,
`userId` varchar(255) NOT NULL COMMENT '用户ID',
`activityType` tinyint NOT NULL COMMENT '活跃类型1-登录2-训练3-饮食记录4-体重记录5-资料更新6-打卡',
`activityDate` date NOT NULL COMMENT '活跃日期 YYYY-MM-DD',
`level` tinyint NOT NULL DEFAULT 1 COMMENT '活跃等级0-无活跃1-低活跃2-中活跃3-高活跃',
`remark` text COMMENT '备注信息',
`createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_activity_date_type` (`userId`, `activityDate`, `activityType`),
KEY `idx_user_activity_date` (`userId`, `activityDate`),
KEY `idx_activity_date` (`activityDate`),
-- 添加枚举约束
CONSTRAINT `chk_activity_type` CHECK (`activityType` IN (1, 2, 3, 4, 5, 6)),
CONSTRAINT `chk_activity_level` CHECK (`level` IN (0, 1, 2, 3))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户活跃记录表';
-- 创建索引以优化查询性能
CREATE INDEX IF NOT EXISTS `idx_user_activity_level` ON `user_activities` (`userId`, `activityDate`, `level`);
-- 枚举值说明
-- activityType 枚举值:
-- 1: 登录 (LOGIN)
-- 2: 训练 (WORKOUT)
-- 3: 饮食记录 (DIET_RECORD)
-- 4: 体重记录 (WEIGHT_RECORD)
-- 5: 资料更新 (PROFILE_UPDATE)
-- 6: 打卡 (CHECKIN)
-- level 枚举值:
-- 0: 无活跃 (NONE)
-- 1: 低活跃 (LOW)
-- 2: 中活跃 (MEDIUM)
-- 3: 高活跃 (HIGH)

View File

@@ -0,0 +1,26 @@
-- 创建用户自定义食物表
-- 该表用于存储用户自定义添加的食物信息
CREATE TABLE IF NOT EXISTS `t_user_custom_foods` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`name` varchar(100) NOT NULL COMMENT '食物名称',
`description` varchar(500) DEFAULT NULL COMMENT '食物描述',
`calories_per_100g` float DEFAULT NULL COMMENT '每100克热量卡路里',
`protein_per_100g` float DEFAULT NULL COMMENT '每100克蛋白质含量',
`carbohydrate_per_100g` float DEFAULT NULL COMMENT '每100克碳水化合物含量',
`fat_per_100g` float DEFAULT NULL COMMENT '每100克脂肪含量',
`fiber_per_100g` float DEFAULT NULL COMMENT '每100克膳食纤维含量',
`sugar_per_100g` float DEFAULT NULL COMMENT '每100克糖分含量',
`sodium_per_100g` float DEFAULT NULL COMMENT '每100克钠含量毫克',
`additional_nutrition` json DEFAULT NULL COMMENT '其他营养信息(维生素、矿物质等)',
`image_url` varchar(500) DEFAULT NULL COMMENT '食物图片URL',
`sort_order` int NOT NULL DEFAULT '0' 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`),
KEY `idx_user_id` (`user_id`),
KEY `idx_category_key` (`category_key`),
KEY `idx_name` (`name`),
KEY `idx_user_category_sort` (`user_id`, `category_key`, `sort_order`),
CONSTRAINT `fk_user_custom_food_category` FOREIGN KEY (`category_key`) REFERENCES `t_food_categories` (`key`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户自定义食物表';

View File

@@ -0,0 +1,14 @@
-- 用户食物收藏表
CREATE TABLE IF NOT EXISTS `t_user_food_favorites` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` VARCHAR(255) NOT NULL COMMENT '用户ID',
`food_id` BIGINT NOT NULL COMMENT '食物ID',
`food_type` ENUM('system', 'custom') NOT NULL DEFAULT 'system' COMMENT '食物类型system: 系统食物, custom: 用户自定义食物)',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_food` (`user_id`, `food_id`, `food_type`),
KEY `idx_user_id` (`user_id`),
KEY `idx_food_id` (`food_id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户食物收藏表';

View File

@@ -0,0 +1,17 @@
-- 创建用户喝水记录表
CREATE TABLE IF NOT EXISTS `t_user_water_history` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` VARCHAR(255) NOT NULL COMMENT '用户ID',
`amount` INT NOT NULL COMMENT '喝水量(毫升)',
`source` ENUM('manual', 'auto', 'other') NOT NULL DEFAULT 'manual' COMMENT '记录来源',
`remark` VARCHAR(255) NULL COMMENT '备注',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户喝水记录表';
-- 为用户档案表添加喝水目标字段
ALTER TABLE `t_user_profile`
ADD COLUMN `daily_water_goal` INT NULL COMMENT '每日喝水目标(毫升)' AFTER `activity_level`;

View File

@@ -4,9 +4,11 @@ import { User } from '../../users/models/user.model';
export enum ActivityEntityType {
USER = 'USER',
USER_PROFILE = 'USER_PROFILE',
USER_WEIGHT_HISTORY = 'USER_WEIGHT_HISTORY',
CHECKIN = 'CHECKIN',
TRAINING_PLAN = 'TRAINING_PLAN',
WORKOUT = 'WORKOUT',
DIET_RECORD = 'DIET_RECORD',
}
@@ -37,7 +39,7 @@ export class ActivityLog extends Model {
declare user?: User;
@Column({
type: DataType.ENUM('USER', 'USER_PROFILE', 'CHECKIN', 'TRAINING_PLAN'),
type: DataType.ENUM('USER', 'USER_PROFILE', 'USER_WEIGHT_HISTORY', 'CHECKIN', 'TRAINING_PLAN', 'WORKOUT', 'DIET_RECORD'),
allowNull: false,
comment: '实体类型',
})

View File

@@ -1,18 +1,26 @@
import { Body, Controller, Delete, Get, Param, Post, Query, Res, StreamableFile, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpException, HttpStatus, Logger, Param, Post, Query, Res, StreamableFile, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiQuery, ApiParam } from '@nestjs/swagger';
import { Response } from 'express';
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 { AiCoachService } from './ai-coach.service';
import { AiChatRequestDto, AiChatResponseDto } from './dto/ai-chat.dto';
import { AiChatRequestDto, AiChatResponseDto, AiResponseDataDto } from './dto/ai-chat.dto';
import { PostureAssessmentRequestDto, PostureAssessmentResponseDto } from './dto/posture-assessment.dto';
import { FoodRecognitionRequestDto, FoodRecognitionResponseDto, TextFoodAnalysisRequestDto } from './dto/food-recognition.dto';
import { DietAnalysisService } from './services/diet-analysis.service';
import { UsersService } from '../users/users.service';
@ApiTags('ai-coach')
@Controller('ai-coach')
@UseGuards(JwtAuthGuard)
export class AiCoachController {
constructor(private readonly aiCoachService: AiCoachService) { }
private readonly logger = new Logger(AiCoachController.name);
constructor(
private readonly aiCoachService: AiCoachService,
private readonly dietAnalysisService: DietAnalysisService,
private readonly usersService: UsersService,
) { }
@Post('chat')
@ApiOperation({ summary: '流式大模型对话(普拉提教练)' })
@@ -23,9 +31,12 @@ export class AiCoachController {
@Res({ passthrough: false }) res: Response,
): Promise<StreamableFile | AiChatResponseDto | void> {
const userId = user.sub;
this.logger.log(`chat: ${userId} chat body ${JSON.stringify(body, null, 2)}`);
const stream = body.stream !== false; // 默认流式
const userContent = body.messages?.[body.messages.length - 1]?.content || '';
// 创建或沿用会话ID并保存用户消息
const { conversationId } = await this.aiCoachService.createOrAppendMessages({
userId,
@@ -33,54 +44,55 @@ export class AiCoachController {
userContent,
});
let weightInfo: { weightKg?: number; systemNotice?: string } = {};
// 判断用户是否有聊天次数
const usageCount = await this.usersService.getUserUsageCount(userId);
if (usageCount <= 0) {
this.logger.warn(`chat: ${userId} has no usage count`);
// 体重识别逻辑优化:
// 1. 如果有图片URL使用原有的图片识别逻辑
// 2. 如果没有图片URL但文本中包含体重信息使用新的文本识别逻辑
try {
if (body.imageUrl) {
// 原有逻辑:从图片识别体重
const imageWeightInfo = await this.aiCoachService.maybeExtractAndUpdateWeight(
userId,
body.imageUrl,
userContent,
);
if (imageWeightInfo.weightKg) {
weightInfo = {
weightKg: imageWeightInfo.weightKg,
systemNotice: `系统提示:已从图片识别体重为${imageWeightInfo.weightKg}kg并已为你更新到个人资料。`
};
}
} else {
// 新逻辑:从文本识别体重,并获取历史对比信息
const textWeightInfo = await this.aiCoachService.processWeightFromText(userId, userContent);
if (textWeightInfo.weightKg && textWeightInfo.systemNotice) {
weightInfo = {
weightKg: textWeightInfo.weightKg,
systemNotice: textWeightInfo.systemNotice
};
}
}
} catch (error) {
// 体重识别失败不影响正常对话
console.error('体重识别失败:', error);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.send({
conversationId,
text: '聊天次数用完了,明天再来吧~',
});
return
}
const result = await this.aiCoachService.streamChat({
userId,
conversationId,
userContent,
imageUrls: body.imageUrls,
selectedChoiceId: body.selectedChoiceId,
confirmationData: body.confirmationData,
});
// 普通流式/非流式响应
const readable = result as any;
// 检查是否返回结构化数据(如确认选项)
// 结构化数据必须使用非流式模式返回
if (typeof result === 'object' && 'type' in result) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.send({
conversationId,
data: result.data
});
return;
}
if (!stream) {
// 非流式:聚合后一次性返回文本
const readable = await this.aiCoachService.streamChat({
userId,
conversationId,
userContent,
systemNotice: weightInfo.systemNotice,
});
let text = '';
for await (const chunk of readable) {
text += chunk.toString();
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.send({ conversationId, text, weightKg: weightInfo.weightKg });
res.send({ conversationId, text });
return;
}
@@ -89,13 +101,6 @@ export class AiCoachController {
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Transfer-Encoding', 'chunked');
const readable = await this.aiCoachService.streamChat({
userId,
conversationId,
userContent,
systemNotice: weightInfo.systemNotice,
});
readable.on('data', (chunk) => {
res.write(chunk);
});
@@ -157,6 +162,98 @@ export class AiCoachController {
});
return res as any;
}
@Post('food-recognition')
@ApiOperation({
summary: '食物识别服务',
description: '识别图片中的食物并返回数组格式的选项列表,支持多食物识别。如果图片中不包含食物,会返回相应提示信息。'
})
@ApiBody({ type: FoodRecognitionRequestDto })
async recognizeFood(
@Body() body: FoodRecognitionRequestDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<FoodRecognitionResponseDto> {
this.logger.log(`Food recognition request from user: ${user.sub}, images: ${body.imageUrls?.length || 0}`);
const language = await this.usersService.getUserLanguage(user.sub);
const result = await this.dietAnalysisService.recognizeFoodForConfirmation(body.imageUrls, language);
// 转换为DTO格式
const response: FoodRecognitionResponseDto = {
items: result.items.map(item => ({
id: item.id,
label: item.label,
foodName: item.foodName,
portion: item.portion,
calories: item.calories,
mealType: item.mealType,
nutritionData: {
proteinGrams: item.nutritionData.proteinGrams,
carbohydrateGrams: item.nutritionData.carbohydrateGrams,
fatGrams: item.nutritionData.fatGrams,
fiberGrams: item.nutritionData.fiberGrams,
}
})),
analysisText: result.analysisText,
confidence: result.confidence,
isFoodDetected: result.isFoodDetected,
nonFoodMessage: result.nonFoodMessage
};
if (!result.isFoodDetected) {
this.logger.log(`Non-food detected for user: ${user.sub}, message: ${result.nonFoodMessage}`);
} else {
this.logger.log(`Food recognition completed: ${result.items.length} items recognized`);
}
return response;
}
@Post('text-food-analysis')
@ApiOperation({
summary: '文本食物分析服务',
description: '分析用户口述的饮食文本内容,识别食物并返回数组格式的选项列表。支持中文食物描述,如"吃了一碗米饭"、"喝了一杯牛奶"等。返回数据结构与图片识别接口保持一致。'
})
@ApiBody({ type: TextFoodAnalysisRequestDto })
async analyzeTextFood(
@Body() body: TextFoodAnalysisRequestDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<FoodRecognitionResponseDto> {
this.logger.log(`Text food analysis request from user: ${user.sub}, text: "${body.text}"`);
const language = await this.usersService.getUserLanguage(user.sub);
const result = await this.dietAnalysisService.analyzeTextFoodForConfirmation(body.text, language);
// 转换为DTO格式
const response: FoodRecognitionResponseDto = {
items: result.items.map(item => ({
id: item.id,
label: item.label,
foodName: item.foodName,
portion: item.portion,
calories: item.calories,
mealType: item.mealType,
nutritionData: {
proteinGrams: item.nutritionData.proteinGrams,
carbohydrateGrams: item.nutritionData.carbohydrateGrams,
fatGrams: item.nutritionData.fatGrams,
fiberGrams: item.nutritionData.fiberGrams,
}
})),
analysisText: result.analysisText,
confidence: result.confidence,
isFoodDetected: result.isFoodDetected,
nonFoodMessage: result.nonFoodMessage
};
if (!result.isFoodDetected) {
this.logger.log(`Non-food detected in text for user: ${user.sub}, message: ${result.nonFoodMessage}`);
} else {
this.logger.log(`Text food analysis completed: ${result.items.length} items recognized`);
}
return response;
}
}

View File

@@ -1,21 +1,36 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
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,
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]),
forwardRef(() => UsersModule),
forwardRef(() => DietRecordsModule),
forwardRef(() => MedicationsModule),
forwardRef(() => MoodCheckinsModule),
forwardRef(() => WaterRecordsModule),
forwardRef(() => ChallengesModule),
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment, AiReportHistory]),
],
controllers: [AiCoachController],
providers: [AiCoachService],
providers: [AiCoachService, DietAnalysisService, AiReportService, CosService],
exports: [DietAnalysisService, AiReportService],
})
export class AiCoachModule { }

View File

@@ -7,8 +7,92 @@ import { AiConversation } from './models/ai-conversation.model';
import { PostureAssessment } from './models/posture-assessment.model';
import { UserProfile } from '../users/models/user-profile.model';
import { UsersService } from '../users/users.service';
import { DietAnalysisService, DietAnalysisResult, FoodRecognitionResult, FoodConfirmationOption } from './services/diet-analysis.service';
enum SelectChoiceId {
Diet = 'diet_confirmation',
TrendAnalysis = 'trend_analysis'
}
const SYSTEM_PROMPT = `作为一名资深的健康管家兼营养分析师Nutrition Analyst和健身教练我拥有丰富的专业知识包括但不限于
运动领域:运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练、运动损伤预防与恢复。
营养领域:基础营养学、饮食结构优化、宏量与微量营养素分析、能量平衡、运动表现与恢复的饮食搭配、特殊人群(如素食者、轻度肥胖人群、体重管理需求者)的饮食指导。
请遵循以下指导原则进行交流:
1. 话题范围
仅限于 健康、健身、普拉提、康复、形体训练、柔韧性提升、力量训练、运动损伤预防与恢复、营养与饮食 等领域。
涉及营养时,我会结合 个体化饮食分析(如热量、蛋白质、碳水、脂肪、维生素、矿物质比例)和 生活方式建议,帮助优化饮食习惯。
2. 拒绝回答的内容
不涉及医疗诊断、处方药建议、情感心理咨询、金融投资分析或编程等高风险或不相关内容。
若遇到超出专业范围的问题,我会礼貌说明并尝试引导回相关话题。
3. 语言风格
回复以 亲切、专业、清晰分点 为主。
会给出 在家可实践的具体步骤,并提供注意事项与替代方案。
针对不同水平或有伤病史的用户,提供调整建议与安全提示。
4. 个性化与安全性
强调每个人身体和饮食需求的独特性。
提供训练和饮食建议时,会提醒用户根据自身情况调整强度与摄入量。
如涉及严重疼痛、慢性病或旧伤复发,强烈建议先咨询医生或注册营养师再执行。
5. 设备与工具要求
运动部分默认用户仅有基础家庭健身器材(瑜伽垫、弹力带、泡沫轴)。
营养部分会给出简单可操作的食材替代方案,避免过度依赖难获取或昂贵的补剂。
所有建议附带大致的 频率/时长/摄入参考量,并分享 自我监测与调整的方法(如训练日志、饮食记录、身体反馈观察)。`;
const NUTRITION_ANALYST_PROMPT = `营养分析师模式(仅在检测为营养/饮食相关话题时启用):
原则与优先级:
- 本轮以营养分析师视角回答;若与其它系统指令冲突,以本提示为准;话题结束后自动恢复默认角色。
- 只输出结论与结构化内容,不展示推理过程。
- 信息不足时先提出1-3个关键追问如餐次、份量、目标、过敏/限制)。
输出结构(精简分点):
1) 饮食分解:按餐次(早餐/午餐/晚餐/加餐)整理;给出每餐热量与三大营养素的估算(用“约/范围”表述)。
2) 营养分析:
- 全天热量与宏量营养素比例是否匹配目标(减脂/增肌/维持/恢复/表现)。
- 关键微量营养素关注点膳食纤维、维生素D、钙、铁、钾、镁、钠等
- 指出过量/不足与可观测风险(如蛋白不足、添加糖偏高、钠摄入偏高等)。
3) 优化建议(可执行):
- 食材替换给出2-3条替换示例如“白米→糙米/藜麦”,“香肠→瘦牛肉/鸡胸”,“含糖酸奶→无糖酸奶+水果”)。
- 结构调整:分配蛋白质到三餐/加餐、碳水时机(训练前后)、蔬果与纤维补足。
- 目标化策略:分别给出减脂/增肌/维持/恢复/表现的要点(热量/蛋白/碳水/脂肪的方向性调整)。
4) 安全与个体差异提醒:过敏与不耐受、疾病或孕期需个体化;必要时建议咨询医生/注册营养师。
表述规范:
- 语气亲切专业分点清晰避免过度精确如“约300kcal”、“蛋白约25-35g”
- 无法确定时给出区间与假设,并提示用户完善信息。
`;
/**
* 指令解析结果接口
*/
interface CommandResult {
isCommand: boolean;
command?: 'weight' | 'diet';
originalText: string;
cleanText: string;
}
const SYSTEM_PROMPT = `作为一名资深的普拉提与运动康复教练Pilates Coach我拥有丰富的专业知识包括但不限于运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练以及营养与饮食建议。请遵循以下指导原则进行交流 - **话题范围**:讨论将仅限于健康、健身、普拉提、康复、形体训练、柔韧性提升、力量训练、运动损伤预防与恢复、营养与饮食等领域。 - **拒绝回答的内容**:对于医疗诊断、情感心理支持、时政金融分析或编程等非相关或高风险问题,我会礼貌地解释为何这些不在我的专业范围内,并尝试将对话引导回上述合适的话题领域内。 - **语言风格**:我的回复将以亲切且专业的态度呈现,尽量做到条理清晰、分点阐述;当需要时,会提供可以在家轻松实践的具体步骤指南及注意事项;同时考虑到不同水平参与者的需求,特别是那些可能有轻微不适或曾受过伤的人群,我会给出相应的调整建议和安全提示。 - **个性化与安全性**:强调每个人的身体状况都是独一无二的,在提出任何锻炼计划之前都会提醒大家根据自身情况适当调整强度;如果涉及到具体的疼痛问题或是旧伤复发的情况,则强烈建议先咨询医生的意见再开始新的训练项目。 - **设备要求**:所有推荐的练习都假设参与者只有基础的家庭健身器材可用,比如瑜伽垫、弹力带或者泡沫轴等;此外还会对每项活动的大致持续时间和频率做出估计,并分享一些自我监测进步的方法。 请告诉我您具体想了解哪方面的信息,以便我能更好地为您提供帮助。`;
@Injectable()
export class AiCoachService {
@@ -17,7 +101,11 @@ export class AiCoachService {
private readonly model: string;
private readonly visionModel: string;
constructor(private readonly configService: ConfigService, private readonly usersService: UsersService) {
constructor(
private readonly configService: ConfigService,
private readonly usersService: UsersService,
private readonly dietAnalysisService: DietAnalysisService,
) {
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
@@ -27,7 +115,7 @@ export class AiCoachService {
});
// 默认选择通义千问对话模型OpenAI兼容名可通过环境覆盖
this.model = this.configService.get<string>('DASHSCOPE_MODEL') || 'qwen-flash';
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-plus';
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max';
}
async createOrAppendMessages(params: {
@@ -65,24 +153,398 @@ export class AiCoachService {
return messages;
};
async streamChat(params: {
userId: string;
conversationId: string;
userContent: string;
systemNotice?: string;
}): Promise<Readable> {
// 上下文:系统提示 + 历史 + 当前用户消息
const messages = await this.buildChatHistory(params.userId, params.conversationId);
if (params.systemNotice) {
messages.unshift({ role: 'system', content: params.systemNotice });
imageUrls?: string[];
selectedChoiceId?: SelectChoiceId;
confirmationData?: any;
}): Promise<Readable | { type: 'structured'; data: any }> {
try {
// 1. 优先处理用户选择(选择逻辑)
if (params.selectedChoiceId && [SelectChoiceId.Diet, SelectChoiceId.TrendAnalysis].includes(params.selectedChoiceId)) {
return await this.handleUserChoice({
userId: params.userId,
conversationId: params.conversationId,
userContent: params.userContent,
selectedChoiceId: params.selectedChoiceId,
confirmationData: params.confirmationData
});
}
// 2. 解析用户输入的指令
const commandResult = this.parseCommand(params.userContent);
// 3. 构建基础消息上下文
const messages = await this.buildChatHistory(params.userId, params.conversationId);
if (params.systemNotice) {
messages.unshift({ role: 'system', content: params.systemNotice });
}
// 4. 处理指令
if (commandResult.command) {
return await this.handleCommand(commandResult, params, messages);
}
// 5. 处理普通对话(包括营养话题检测)
return await this.handleNormalChat(params, messages);
} catch (error) {
this.logger.error(`streamChat error: ${error instanceof Error ? error.message : String(error)}`);
return this.createStreamFromText('处理失败,请稍后重试');
}
}
/**
* 处理用户选择
*/
private async handleUserChoice(params: {
userId: string;
conversationId: string;
userContent: string;
selectedChoiceId: SelectChoiceId;
confirmationData?: any;
}): Promise<Readable | { type: 'structured'; data: any }> {
// 处理体重趋势分析选择
if (params.selectedChoiceId === 'trend_analysis' && params.confirmationData?.weightRecordData) {
return await this.handleWeightTrendAnalysis({
userId: params.userId,
conversationId: params.conversationId,
confirmationData: params.confirmationData
});
}
// 处理饮食确认选择
if (params.selectedChoiceId === 'diet_confirmation' && params.confirmationData) {
return await this.handleDietConfirmation({
userId: params.userId,
conversationId: params.conversationId,
selectedChoiceId: params.selectedChoiceId,
confirmationData: params.confirmationData
});
}
// 其他选择类型的处理...
throw new Error(`未知的选择类型: ${params.selectedChoiceId}`);
}
/**
* 处理指令
*/
private async handleCommand(
commandResult: CommandResult,
params: any,
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
): Promise<Readable | { type: 'structured'; data: any }> {
if (commandResult.command === 'weight') {
return await this.handleWeightCommand(params);
}
if (commandResult.command === 'diet') {
return await this.handleDietCommand(commandResult, params, messages);
}
// 其他指令处理...
throw new Error(`未知的指令: ${commandResult.command}`);
}
/**
* 处理体重趋势分析选择
*/
private async handleWeightTrendAnalysis(params: {
userId: string;
conversationId: string;
confirmationData: { weightRecordData: any };
}): Promise<Readable> {
const analysisContent = await this.generateWeightTrendAnalysis(
params.userId,
params.confirmationData.weightRecordData
);
// 保存消息记录
await Promise.all([
AiMessage.create({
conversationId: params.conversationId,
userId: params.userId,
role: RoleType.User,
content: '用户选择查看体重趋势分析',
metadata: null,
}),
AiMessage.create({
conversationId: params.conversationId,
userId: params.userId,
role: RoleType.Assistant,
content: analysisContent,
metadata: { model: this.model, interactionType: 'weight_trend_analysis' },
})
]);
// 更新对话时间
await AiConversation.update(
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(analysisContent) },
{ where: { id: params.conversationId, userId: params.userId } }
);
return this.createStreamFromText(analysisContent);
}
/**
* 处理体重指令
*/
private async handleWeightCommand(params: {
userId: string;
conversationId: string;
userContent: string;
}): Promise<{ type: 'structured'; data: any }> {
const weightKg = this.extractWeightFromText(params.userContent);
if (!weightKg) {
throw new Error('无法提取有效的体重数值');
}
// 更新体重到数据库
await this.usersService.addWeightByVision(params.userId, weightKg);
// 构建成功消息
const responseContent = `已成功记录体重:${weightKg}kg`;
// 保存消息
await AiMessage.create({
conversationId: params.conversationId,
userId: params.userId,
role: RoleType.Assistant,
content: responseContent,
metadata: {
model: this.model,
interactionType: 'weight_record_success',
weightData: { newWeight: weightKg, recordedAt: new Date().toISOString() }
},
});
// 更新对话时间
await AiConversation.update(
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(responseContent) },
{ where: { id: params.conversationId, userId: params.userId } }
);
return {
type: 'structured',
data: {
content: responseContent,
choices: [
{
id: 'trend_analysis',
label: '查看体重趋势分析',
value: 'weight_trend_analysis',
recommended: true
},
{
id: 'continue_chat',
label: '继续对话',
value: 'continue_normal_chat',
recommended: false
}
],
interactionType: 'weight_record_success',
pendingData: {
weightRecordData: { newWeight: weightKg, recordedAt: new Date().toISOString() }
},
context: { command: 'weight', step: 'record_success' }
}
};
}
/**
* 处理饮食确认选择
*/
private async handleDietConfirmation(params: {
userId: string;
conversationId: string;
selectedChoiceId: string;
confirmationData: any;
}): Promise<Readable> {
// 饮食确认逻辑保持原样
const { selectedOption, imageUrl } = params.confirmationData;
const createDto = await this.dietAnalysisService.createDietRecordFromConfirmation(
params.userId,
selectedOption,
imageUrl || ''
);
if (!createDto) {
throw new Error('饮食记录创建失败');
}
const messages = await this.buildChatHistory(params.userId, params.conversationId);
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
if (nutritionContext) {
messages.unshift({ role: 'system', content: nutritionContext });
}
messages.push({
role: 'user',
content: `用户确认记录饮食:${selectedOption.label}`
});
messages.unshift({
role: 'system',
content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt()
});
return this.generateAIResponse(params.conversationId, params.userId, messages);
}
/**
* 处理饮食指令
*/
private async handleDietCommand(
commandResult: CommandResult,
params: any,
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
): Promise<Readable | { type: 'structured'; data: any }> {
if (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 => ({
id: item.id,
label: item.label,
value: item,
recommended: recognitionResult.items.indexOf(item) === 0
}));
const responseContent = `我识别到了以下食物,请选择要记录的内容:\n\n${recognitionResult.analysisText}`;
await AiMessage.create({
conversationId: params.conversationId,
userId: params.userId,
role: RoleType.Assistant,
content: responseContent,
metadata: {
model: this.model,
interactionType: 'food_confirmation',
choices: choices.length
},
});
await AiConversation.update(
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(responseContent) },
{ where: { id: params.conversationId, userId: params.userId } }
);
return {
type: 'structured',
data: {
content: responseContent,
choices,
interactionType: 'food_confirmation',
pendingData: {
imageUrl: params.imageUrls[0],
recognitionResult
},
context: {
command: 'diet',
step: 'confirmation'
}
}
};
} else {
messages.push({
role: 'user',
content: `用户尝试记录饮食但识别失败:${recognitionResult.analysisText}`
});
return this.generateAIResponse(params.conversationId, params.userId, messages);
}
} else {
// 处理文本饮食记录
// const language = await this.usersService.getUserLanguage(params.userId);
// TODO: analyzeDietFromText 也需要支持多语言
const textAnalysisResult = await this.dietAnalysisService.analyzeDietFromText(commandResult.cleanText);
if (textAnalysisResult.shouldRecord && textAnalysisResult.extractedData) {
const createDto = await this.dietAnalysisService.processDietRecord(
params.userId,
textAnalysisResult,
''
);
if (createDto) {
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
if (nutritionContext) {
messages.unshift({ role: 'system', content: nutritionContext });
}
params.systemNotice = `系统提示:已成功为您记录了${createDto.foodName}的饮食信息(${createDto.portionDescription || ''},约${createDto.estimatedCalories || 0}卡路里)。`;
messages.push({
role: 'user',
content: `用户通过文本记录饮食:${textAnalysisResult.analysisText}`
});
messages.unshift({ role: 'system', content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt() });
}
} else {
messages.push({
role: 'user',
content: `用户提到饮食相关内容:${commandResult.cleanText}。分析结果:${textAnalysisResult.analysisText}`
});
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
if (nutritionContext) {
messages.unshift({ role: 'system', content: nutritionContext });
}
messages.unshift({ role: 'system', content: NUTRITION_ANALYST_PROMPT });
}
return this.generateAIResponse(params.conversationId, params.userId, messages);
}
}
/**
* 处理普通对话
*/
private async handleNormalChat(
params: any,
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
): Promise<Readable> {
// 检测营养话题
if (this.isLikelyNutritionTopic(params.userContent, messages)) {
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
if (nutritionContext) {
messages.unshift({ role: 'system', content: nutritionContext });
}
messages.unshift({ role: 'system', content: NUTRITION_ANALYST_PROMPT });
}
// 普通聊天才需要扣减次数
await this.usersService.deductUserUsageCount(params.userId);
return this.generateAIResponse(params.conversationId, params.userId, messages);
}
/**
* 生成AI响应的通用方法
*/
private async generateAIResponse(
conversationId: string,
userId: string,
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
): Promise<Readable> {
const stream = await this.client.chat.completions.create({
model: this.model,
messages,
stream: true,
temperature: 0.7,
max_tokens: 1024,
temperature: 1,
max_completion_tokens: 500,
});
const readable = new Readable({ read() { } });
@@ -97,15 +559,19 @@ export class AiCoachService {
readable.push(delta);
}
}
// 结束将assistant消息入库
await AiMessage.create({
conversationId: params.conversationId,
userId: params.userId,
conversationId,
userId,
role: RoleType.Assistant,
content: assistantContent,
metadata: { model: this.model },
});
await AiConversation.update({ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(assistantContent) }, { where: { id: params.conversationId, userId: params.userId } });
await AiConversation.update(
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(assistantContent) },
{ where: { id: conversationId, userId } }
);
} catch (error) {
this.logger.error(`stream error: ${error?.message || error}`);
readable.push('\n[对话发生错误,请稍后重试]');
@@ -117,6 +583,142 @@ export class AiCoachService {
return readable;
}
/**
* 从文本创建流式响应
*/
private createStreamFromText(text: string): Readable {
const readable = new Readable({ read() { } });
setTimeout(() => {
const chunks = text.split('');
let index = 0;
const pushChunk = () => {
if (index < chunks.length) {
readable.push(chunks[index]);
index++;
setTimeout(pushChunk, 20);
} else {
readable.push(null);
}
};
pushChunk();
}, 100);
return readable;
}
private isLikelyNutritionTopic(
currentText: string | undefined,
messages?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
): boolean {
if (!currentText && !messages?.length) return false;
const recentTexts: string[] = [];
if (currentText) recentTexts.push(currentText);
if (messages && messages.length > 0) {
const tail = messages.slice(-6).map((m) => (m?.content || ''));
recentTexts.push(...tail);
}
const text = recentTexts.join('\n').toLowerCase();
const keywordPatterns = [
/营养|饮食|配餐|食谱|餐单|膳食|食材|食物|加餐|早餐|午餐|晚餐|零食|控糖|控卡|代餐|膳食纤维|纤维|维生素|矿物质|微量营养素|宏量营养素|热量|卡路里|大卡/i,
/protein|carb|carbohydrate|fat|fats|calorie|calories|kcal|macro|micronutrient|vitamin|fiber|diet|meal|breakfast|lunch|dinner|snack|bulking|cutting/i,
/蛋白|蛋白质|碳水|脂肪|糖|升糖指数|gi|低碳|生酮|高蛋白|低脂|清淡/i,
];
const structureHints = [
/\b\d+\s*(?:g|克|ml|毫?升|大?卡|kcal)\b/i,
/\b[0-9]{2,4}\s*kcal\b/i,
/(鸡胸|牛肉|鸡蛋|燕麦|藜麦|糙米|白米|土豆|红薯|酸奶|牛奶|坚果|鳄梨|沙拉|面包|米饭|面条)/i,
/(替换|替代|换成).*(食材|主食|配菜|零食)/i,
];
const goalHints = [
/减脂|增肌|维持|控重|体重管理|恢复|训练表现|运动表现/i,
/weight\s*loss|fat\s*loss|muscle\s*gain|maintenance|performance|recovery/i,
];
const matched = [...keywordPatterns, ...structureHints, ...goalHints].some((re) => re.test(text));
// 若用户发的是极短的承接语,但上下文包含饮食关键词,也认为是营养话题
if (!matched && currentText && currentText.length <= 8) {
const shortFollowUps = /(那早餐呢|那午餐呢|那晚餐呢|那怎么吃|吃什么|怎么搭配|怎么配|怎么安排|如何吃)/i;
if (shortFollowUps.test(currentText)) {
const context = (messages || []).slice(-8).map((m) => m.content).join('\n');
if ([...keywordPatterns, ...structureHints].some((re) => re.test(context))) return true;
}
}
return matched;
}
/**
* 解析用户输入的指令(以 # 开头)
* @param text 用户输入文本
* @returns 指令解析结果
*/
private parseCommand(text: string): CommandResult {
if (!text || !text.trim().startsWith('#')) {
return {
isCommand: false,
originalText: text || '',
cleanText: text || ''
};
}
const trimmedText = text.trim();
const commandMatch = trimmedText.match(/^#([^\s:]+)[:]?\s*(.*)$/);
if (!commandMatch) {
return {
isCommand: false,
originalText: text,
cleanText: text
};
}
const [, commandPart, restText] = commandMatch;
const cleanText = restText.trim();
// 识别体重记录指令
if (/^记体重$/.test(commandPart)) {
return {
isCommand: true,
command: 'weight',
originalText: text,
cleanText: cleanText || '记录体重'
};
}
// 识别饮食记录指令
if (/^记饮食$/.test(commandPart)) {
return {
isCommand: true,
command: 'diet',
originalText: text,
cleanText: cleanText || '记录饮食'
};
}
// 未识别的指令,当作普通文本处理
return {
isCommand: false,
originalText: text,
cleanText: text
};
}
private deriveTitleIfEmpty(assistantReply: string): string | null {
if (!assistantReply) return null;
const firstLine = assistantReply.split(/\r?\n/).find(Boolean) || '';
@@ -262,51 +864,6 @@ export class AiCoachService {
return { id: rec.id, overallScore, result };
}
private isLikelyWeightLogIntent(text: string | undefined): boolean {
if (!text) return false;
const t = text.toLowerCase();
return /体重|称重|秤|kg|公斤|weigh|weight/.test(t);
}
async maybeExtractAndUpdateWeight(userId: string, imageUrl?: string, userText?: string): Promise<{ weightKg?: number }> {
if (!imageUrl || !this.isLikelyWeightLogIntent(userText)) return {};
try {
const sys = '从照片中读取电子秤的数字单位通常为kg。仅返回JSON例如 {"weightKg": 65.2},若无法识别,返回 {"weightKg": null}。不要添加其他文本。';
const completion = await this.client.chat.completions.create({
model: this.visionModel,
messages: [
{ role: 'system', content: sys },
{
role: 'user',
content: [
{ type: 'text', text: '请从图片中提取体重kg。若图中单位为斤或lb请换算为kg。' },
{ type: 'image_url', image_url: { url: imageUrl } as any },
] as any,
},
],
temperature: 0,
response_format: { type: 'json_object' } as any,
});
const raw = completion.choices?.[0]?.message?.content || '';
let weightKg: number | undefined;
try {
const obj = JSON.parse(raw);
weightKg = typeof obj.weightKg === 'number' ? obj.weightKg : undefined;
} catch {
const m = raw.match(/\d+(?:\.\d+)?/);
weightKg = m ? parseFloat(m[0]) : undefined;
}
if (weightKg && isFinite(weightKg) && weightKg > 0 && weightKg < 400) {
await this.usersService.addWeightByVision(userId, weightKg);
return { weightKg };
}
return {};
} catch (err) {
this.logger.error(`maybeExtractAndUpdateWeight error: ${err instanceof Error ? err.message : String(err)}`);
return {};
}
}
/**
* 从用户文本中识别体重信息
* 支持多种格式65kg、65公斤、65.5kg、体重65等
@@ -318,16 +875,36 @@ export class AiCoachService {
// 匹配各种体重格式的正则表达式
const patterns = [
/(?:体重|称重|秤|重量|weight).*?(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)/i,
// 匹配 "#记体重80 kg" 格式
/#(?:记体重|体重|称重|记录体重)[:]\s*(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)/i,
// 匹配带单位的体重 "80kg", "80.5公斤", "80 kg"
/(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)/i,
/(?:体重|称重|秤|重量|weight).*?(\d+(?:\.\d+)?)/i,
/我(?:现在|今天)?(?:体重|重量|称重)?(?:是|为|有)?(\d+(?:\.\d+)?)/i,
// 匹配体重关键词后的数字 "体重65", "weight 70.5"
/(?:体重|称重|秤|重量|weight)[:]?\s*(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)?/i,
// 匹配口语化表达 "我体重65", "我现在70kg"
/我(?:现在|今天)?(?:体重|重量|称重)?(?:是|为|有||:)?\s*(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)?/i,
// 匹配简单数字+单位格式 "65.5kg"
/^(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)$/i,
// 匹配斤单位并转换为kg "130斤"
/(\d+(?:\.\d+)?)\s*斤/i,
];
for (const pattern of patterns) {
const match = t.match(pattern);
if (match) {
const weight = parseFloat(match[1]);
let weight = parseFloat(match[1]);
// 如果是斤单位转换为kg (1斤 = 0.5kg)
// 只有专门匹配斤单位的模式才进行转换,避免"公斤"等词被误判
if (pattern.source.includes('斤') && !pattern.source.includes('公斤')) {
weight = weight * 0.5;
}
// 合理的体重范围检查 (20-400kg)
if (weight >= 20 && weight <= 400) {
return weight;
@@ -338,134 +915,33 @@ export class AiCoachService {
return null;
}
/**
* 获取用户体重历史记录
*/
async getUserWeightHistory(userId: string, limit: number = 10): Promise<{
currentWeight?: number;
history: Array<{ weight: number; source: string; createdAt: Date }>;
}> {
* 生成体重趋势分析
*/
async generateWeightTrendAnalysis(userId: string, weightRecordData: any): Promise<string> {
try {
// 获取当前体重
const profile = await UserProfile.findOne({ where: { userId } });
const currentWeight = profile?.weight;
const { newWeight } = weightRecordData;
// 获取体重历史
const history = await this.usersService.getWeightHistory(userId, { limit });
const weightAnalysisPrompt = `用户刚刚记录了体重${newWeight}kg请提供体重趋势分析、健康建议和鼓励。语言风格亲切、专业、鼓励性。`;
return {
currentWeight: currentWeight || undefined,
history
};
const completion = await this.client.chat.completions.create({
model: this.model,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: weightAnalysisPrompt }
],
temperature: 0.7,
max_tokens: 300,
});
return completion.choices?.[0]?.message?.content || '体重趋势分析生成失败,请稍后重试。';
} catch (error) {
this.logger.error(`获取用户体重历史失败: ${error instanceof Error ? error.message : String(error)}`);
return { history: [] };
}
}
/**
* 构建体重更新的系统提示信息
*/
private buildWeightUpdateSystemNotice(
newWeight: number,
currentWeight?: number,
history: Array<{ weight: number; source: string; createdAt: Date }> = []
): string {
let notice = `系统提示:已为你更新体重为${newWeight}kg。`;
if (currentWeight && currentWeight !== newWeight) {
const diff = newWeight - currentWeight;
const diffText = diff > 0 ? `增加了${diff.toFixed(1)}kg` : `减少了${Math.abs(diff).toFixed(1)}kg`;
notice += `相比之前的${currentWeight}kg${diffText}`;
}
// 添加历史对比信息
if (history.length > 0) {
const recentWeights = history.slice(0, 3);
if (recentWeights.length > 1) {
const trend = this.analyzeWeightTrend(recentWeights, newWeight);
notice += trend;
}
}
return notice;
}
/**
* 分析体重趋势
*/
private analyzeWeightTrend(
recentWeights: Array<{ weight: number; createdAt: Date }>,
newWeight: number
): string {
if (recentWeights.length < 2) return '';
const weights = [newWeight, ...recentWeights.map(w => w.weight)];
let trend = '';
// 计算最近几次的平均变化
let totalChange = 0;
for (let i = 0; i < weights.length - 1; i++) {
totalChange += weights[i] - weights[i + 1];
}
const avgChange = totalChange / (weights.length - 1);
if (Math.abs(avgChange) < 0.5) {
trend = '你的体重保持相对稳定,继续保持良好的生活习惯!';
} else if (avgChange > 0) {
trend = `最近体重呈上升趋势,建议加强运动和注意饮食控制。`;
} else {
trend = `最近体重呈下降趋势,很棒的进步!继续坚持健康的生活方式。`;
}
return trend;
}
/**
* 处理体重记录和更新(无图片版本)
* 从用户文本中识别体重,更新记录,并返回相关信息
*/
async processWeightFromText(userId: string, userText?: string): Promise<{
weightKg?: number;
systemNotice?: string;
shouldSkipChat?: boolean;
}> {
if (!userText) return {};
// 检查是否是体重记录意图
if (!this.isLikelyWeightLogIntent(userText)) {
return {};
}
try {
// 从文本中提取体重
const extractedWeight = this.extractWeightFromText(userText);
if (!extractedWeight) {
return {};
}
// 获取用户体重历史
const { currentWeight, history } = await this.getUserWeightHistory(userId);
// 更新体重到数据库
await this.usersService.addWeightByVision(userId, extractedWeight);
// 构建系统提示
const systemNotice = this.buildWeightUpdateSystemNotice(
extractedWeight,
currentWeight || undefined,
history
);
return {
weightKg: extractedWeight,
systemNotice,
shouldSkipChat: false // 仍然需要与AI聊天让AI给出激励回复
};
} catch (error) {
this.logger.error(`处理文本体重记录失败: ${error instanceof Error ? error.message : String(error)}`);
return {};
this.logger.error(`生成体重趋势分析失败: ${error instanceof Error ? error.message : String(error)}`);
return '体重趋势分析生成失败,请稍后重试。';
}
}
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength, IsInt, Min, Max } from 'class-validator';
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength, IsInt, Min, Max, IsEnum } from 'class-validator';
export class AiChatMessageDto {
@ApiProperty({ enum: ['user', 'assistant', 'system'] })
@@ -25,18 +25,200 @@ export class AiChatRequestDto {
@ApiProperty({ required: false, description: '当用户要记体重时的图片URL电子秤等' })
@IsOptional()
@IsString()
imageUrl?: string;
@IsArray()
@IsString({ each: true })
imageUrls?: string[];
@ApiProperty({ required: false, description: '是否启用流式输出', default: true })
@IsOptional()
@IsBoolean()
stream?: boolean;
@ApiProperty({ required: false, description: '用户选择的选项ID用于确认流程' })
@IsOptional()
@IsString()
selectedChoiceId?: any;
@ApiProperty({ required: false, description: '用户确认的数据(用于确认流程)' })
@IsOptional()
confirmationData?: any;
}
// 选择选项
export class AiChoiceOptionDto {
@ApiProperty({ description: '选项ID' })
@IsString()
@IsNotEmpty()
id: string;
@ApiProperty({ description: '选项显示文本' })
@IsString()
@IsNotEmpty()
label: string;
@ApiProperty({ description: '选项值/数据' })
@IsOptional()
value?: any;
@ApiProperty({ description: '是否为推荐选项', default: false })
@IsOptional()
@IsBoolean()
recommended?: boolean;
}
// 扩展的AI响应数据
export class AiResponseDataDto {
@ApiProperty({ description: 'AI回复的文本内容' })
@IsString()
content: string;
@ApiProperty({ type: [AiChoiceOptionDto], description: '选择选项(可选)', required: false })
@IsOptional()
@IsArray()
choices?: AiChoiceOptionDto[];
@ApiProperty({ description: '交互类型', enum: ['text', 'food_confirmation', 'weight_record_success', 'selection'], required: false })
@IsOptional()
@IsString()
interactionType?: 'text' | 'food_confirmation' | 'weight_record_success' | 'selection';
@ApiProperty({ description: '需要用户确认的数据(可选)', required: false })
@IsOptional()
pendingData?: any;
@ApiProperty({ description: '上下文信息(可选)', required: false })
@IsOptional()
context?: any;
}
export class AiChatResponseDto {
@ApiProperty()
conversationId: string;
@ApiProperty({ type: AiResponseDataDto, description: '响应数据(非流式时返回)', required: false })
@IsOptional()
data?: AiResponseDataDto;
@ApiProperty({ description: '用户剩余的AI聊天次数', required: false })
@IsOptional()
usageCount?: number;
@ApiProperty({ description: 'AI回复的文本内容非流式时返回', required: false })
@IsOptional()
text?: string;
}
// 营养分析相关的DTO
export enum NutritionGoal {
WEIGHT_LOSS = 'weight_loss',
MUSCLE_GAIN = 'muscle_gain',
MAINTENANCE = 'maintenance',
PERFORMANCE = 'performance',
RECOVERY = 'recovery',
GENERAL_HEALTH = 'general_health'
}
export class MealItemDto {
@ApiProperty({ description: '食物名称' })
@IsString()
@IsNotEmpty()
foodName: string;
@ApiProperty({ description: '食物份量1碗、200g、1个等', required: false })
@IsOptional()
@IsString()
portion?: string;
@ApiProperty({ description: '估算热量(卡路里)', required: false })
@IsOptional()
@IsInt()
@Min(0)
estimatedCalories?: number;
}
export class MealDto {
@ApiProperty({ description: '餐次名称(早餐/午餐/晚餐/加餐)' })
@IsString()
@IsNotEmpty()
mealType: string;
@ApiProperty({ description: '用餐时间8:00', required: false })
@IsOptional()
@IsString()
mealTime?: string;
@ApiProperty({ type: [MealItemDto], description: '该餐的食物列表' })
@IsArray()
items: MealItemDto[];
}
export class NutritionAnalysisChatRequestDto {
@ApiProperty({ description: '会话ID。未提供则创建新会话' })
@IsOptional()
@IsString()
conversationId?: string;
@ApiProperty({ type: [MealDto], description: '全天的饮食记录' })
@IsArray()
meals: MealDto[];
@ApiProperty({ enum: NutritionGoal, description: '营养目标' })
@IsEnum(NutritionGoal)
goal: NutritionGoal;
@ApiProperty({ description: '用户当前体重kg', required: false })
@IsOptional()
@IsInt()
@Min(20)
@Max(300)
currentWeight?: number;
@ApiProperty({ description: '用户身高cm', required: false })
@IsOptional()
@IsInt()
@Min(100)
@Max(250)
height?: number;
@ApiProperty({ description: '运动强度1-5级1最轻5最重', required: false })
@IsOptional()
@IsInt()
@Min(1)
@Max(5)
activityLevel?: number;
@ApiProperty({ description: '特殊饮食需求或限制(如:素食、无麸质等)', required: false })
@IsOptional()
@IsString()
dietaryRestrictions?: string;
@ApiProperty({ description: '过敏食物列表', required: false })
@IsOptional()
@IsArray()
@IsString({ each: true })
allergies?: string[];
@ApiProperty({ description: '是否启用流式输出', default: true })
@IsOptional()
@IsBoolean()
stream?: boolean;
}
export class NutritionAnalysisResponseDto {
@ApiProperty()
conversationId: string;
@ApiProperty({ description: '营养分析结果' })
analysis: {
totalCalories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
goalMatchScore: number; // 0-100
recommendations: string[];
warnings: string[];
};
}

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,148 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
/**
* 食物营养数据DTO
*/
export class FoodNutritionDataDto {
@ApiProperty({ description: '蛋白质含量(克)', required: false })
@IsOptional()
@IsInt()
@Min(0)
proteinGrams?: number;
@ApiProperty({ description: '碳水化合物含量(克)', required: false })
@IsOptional()
@IsInt()
@Min(0)
carbohydrateGrams?: number;
@ApiProperty({ description: '脂肪含量(克)', required: false })
@IsOptional()
@IsInt()
@Min(0)
fatGrams?: number;
@ApiProperty({ description: '膳食纤维含量(克)', required: false })
@IsOptional()
@IsInt()
@Min(0)
fiberGrams?: number;
}
/**
* 食物确认选项DTO
*/
export class FoodConfirmationOptionDto {
@ApiProperty({ description: '食物选项唯一标识符' })
@IsString()
@IsNotEmpty()
id: string;
@ApiProperty({ description: '显示给用户的完整选项文本' })
@IsString()
@IsNotEmpty()
label: string;
@ApiProperty({ description: '食物名称' })
@IsString()
@IsNotEmpty()
foodName: string;
@ApiProperty({ description: '份量描述' })
@IsString()
@IsNotEmpty()
portion: string;
@ApiProperty({ description: '估算热量' })
@IsInt()
@Min(0)
@Max(5000)
calories: number;
@ApiProperty({ description: '餐次类型' })
@IsString()
@IsNotEmpty()
mealType: string;
@ApiProperty({ description: '营养数据', type: FoodNutritionDataDto })
nutritionData: FoodNutritionDataDto;
}
/**
* 食物识别请求DTO
*/
export class FoodRecognitionRequestDto {
@ApiProperty({ description: '图片URL数组' })
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
imageUrls: string[];
}
/**
* 食物识别响应DTO - 总是返回数组结构
*/
export class FoodRecognitionResponseDto {
@ApiProperty({
description: '识别到的食物列表,即使只有一种食物也返回数组格式',
type: [FoodConfirmationOptionDto]
})
@IsArray()
items: FoodConfirmationOptionDto[];
@ApiProperty({ description: '识别说明文字' })
@IsString()
analysisText: string;
@ApiProperty({
description: '识别置信度',
minimum: 0,
maximum: 100
})
@IsInt()
@Min(0)
@Max(100)
confidence: number;
@ApiProperty({
description: '是否识别到食物',
default: true
})
@IsBoolean()
isFoodDetected: boolean;
@ApiProperty({
description: '非食物提示信息当isFoodDetected为false时显示',
required: false
})
@IsOptional()
@IsString()
nonFoodMessage?: string;
}
/**
* 食物确认请求DTO
*/
export class FoodConfirmationRequestDto {
@ApiProperty({ description: '用户选择的食物选项' })
selectedOption: FoodConfirmationOptionDto;
@ApiProperty({ description: '图片URL', required: false })
@IsOptional()
@IsString()
imageUrl?: string;
}
/**
* 文本食物分析请求DTO
*/
export class TextFoodAnalysisRequestDto {
@ApiProperty({
description: '用户描述的饮食文本内容',
example: '今天早餐吃了一碗燕麦粥加香蕉'
})
@IsString()
@IsNotEmpty()
text: string;
}

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

@@ -0,0 +1,174 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { DietAnalysisService } from './diet-analysis.service';
import { DietRecordsService } from '../../diet-records/diet-records.service';
describe('DietAnalysisService - Text Analysis', () => {
let service: DietAnalysisService;
let mockDietRecordsService: Partial<DietRecordsService>;
let mockConfigService: Partial<ConfigService>;
beforeEach(async () => {
// Mock services
mockDietRecordsService = {
addDietRecord: jest.fn().mockResolvedValue({}),
getDietHistory: jest.fn().mockResolvedValue({ total: 0, records: [] }),
getRecentNutritionSummary: jest.fn().mockResolvedValue({
recordCount: 0,
totalCalories: 0,
totalProtein: 0,
totalCarbohydrates: 0,
totalFat: 0,
totalFiber: 0
})
};
mockConfigService = {
get: jest.fn().mockImplementation((key: string) => {
switch (key) {
case 'DASHSCOPE_API_KEY':
return 'test-api-key';
case 'DASHSCOPE_BASE_URL':
return 'https://test-api.com';
case 'DASHSCOPE_VISION_MODEL':
return 'test-model';
default:
return undefined;
}
})
};
const module: TestingModule = await Test.createTestingModule({
providers: [
DietAnalysisService,
{ provide: DietRecordsService, useValue: mockDietRecordsService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<DietAnalysisService>(DietAnalysisService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('buildTextDietAnalysisPrompt', () => {
it('should build a proper prompt for text analysis', () => {
// 通过反射访问私有方法进行测试
const prompt = (service as any).buildTextDietAnalysisPrompt('breakfast');
expect(prompt).toContain('作为专业营养分析师');
expect(prompt).toContain('breakfast');
expect(prompt).toContain('shouldRecord');
expect(prompt).toContain('confidence');
expect(prompt).toContain('extractedData');
expect(prompt).toContain('analysisText');
});
});
describe('Text diet analysis scenarios', () => {
const testCases = [
{
description: '应该识别简单的早餐描述',
input: '今天早餐吃了一碗燕麦粥',
expectedFood: '燕麦粥',
shouldRecord: true
},
{
description: '应该识别午餐描述',
input: '午餐点了一份鸡胸肉沙拉',
expectedFood: '鸡胸肉沙拉',
shouldRecord: true
},
{
description: '应该识别零食描述',
input: '刚吃了两个苹果当零食',
expectedFood: '苹果',
shouldRecord: true
},
{
description: '不应该记录模糊的描述',
input: '今天吃得不错',
shouldRecord: false
}
];
testCases.forEach(testCase => {
it(testCase.description, () => {
// 这里我们主要测试prompt构建逻辑
// 实际的AI调用需要真实的API密钥在单元测试中我们跳过
const prompt = (service as any).buildTextDietAnalysisPrompt('breakfast');
expect(prompt).toBeDefined();
expect(typeof prompt).toBe('string');
expect(prompt.length).toBeGreaterThan(100);
});
});
});
describe('processDietRecord', () => {
it('should handle text-based diet records without image URL', async () => {
const mockAnalysisResult = {
shouldRecord: true,
confidence: 85,
extractedData: {
foodName: '燕麦粥',
mealType: 'breakfast' as any,
portionDescription: '1碗',
estimatedCalories: 200,
proteinGrams: 8,
carbohydrateGrams: 35,
fatGrams: 3,
fiberGrams: 4,
nutritionDetails: {
mainIngredients: ['燕麦'],
cookingMethod: '煮制',
foodCategories: ['主食']
}
},
analysisText: '识别到燕麦粥'
};
const result = await service.processDietRecord('test-user-id', mockAnalysisResult);
expect(result).toBeDefined();
expect(result?.foodName).toBe('燕麦粥');
expect(result?.source).toBe('manual'); // 文本记录应该是manual源
expect(result?.imageUrl).toBeUndefined();
expect(mockDietRecordsService.addDietRecord).toHaveBeenCalledWith('test-user-id', expect.objectContaining({
foodName: '燕麦粥',
source: 'manual'
}));
});
it('should handle image-based diet records with image URL', async () => {
const mockAnalysisResult = {
shouldRecord: true,
confidence: 90,
extractedData: {
foodName: '鸡胸肉沙拉',
mealType: 'lunch' as any,
portionDescription: '1份',
estimatedCalories: 300,
proteinGrams: 25,
carbohydrateGrams: 10,
fatGrams: 15,
fiberGrams: 5,
nutritionDetails: {
mainIngredients: ['鸡胸肉', '生菜'],
cookingMethod: '生食',
foodCategories: ['蛋白质', '蔬菜']
}
},
analysisText: '识别到鸡胸肉沙拉'
};
const result = await service.processDietRecord('test-user-id', mockAnalysisResult, 'https://example.com/image.jpg');
expect(result).toBeDefined();
expect(result?.foodName).toBe('鸡胸肉沙拉');
expect(result?.source).toBe('vision'); // 有图片URL应该是vision源
expect(result?.imageUrl).toBe('https://example.com/image.jpg');
});
});
});

File diff suppressed because it is too large Load Diff

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,16 @@ 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';
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: [
@@ -20,6 +33,19 @@ import { WorkoutsModule } from './workouts/workouts.module';
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,
@@ -30,9 +56,24 @@ import { WorkoutsModule } from './workouts/workouts.module';
RecommendationsModule,
ActivityLogsModule,
ExercisesModule,
WorkoutsModule,
MoodCheckinsModule,
GoalsModule,
DietRecordsModule,
FoodLibraryModule,
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

@@ -0,0 +1,241 @@
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';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto';
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) { }
@Get()
@Public()
@UseGuards(JwtAuthGuard)
async getChallenges(
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<ChallengeListItemDto[]>> {
const data = await this.challengesService.getChallengesForUser(user?.sub);
return {
code: ResponseCode.SUCCESS,
message: '获取挑战列表成功',
data,
};
}
@Get(':id')
@Public()
@UseGuards(JwtAuthGuard)
async getChallengeDetail(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<ChallengeDetailDto>> {
const data = await this.challengesService.getChallengeDetail(id, user?.sub);
return {
code: ResponseCode.SUCCESS,
message: '获取挑战详情成功',
data,
};
}
@Get(':id/rankings')
@Public()
async getChallengeRankings(
@Param('id') id: string,
@Query() query: GetChallengeRankingQueryDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<ChallengeRankingListDto>> {
const data = await this.challengesService.getChallengeRankings(id, {
page: query.page,
pageSize: query.pageSize,
userId: user?.sub,
});
return {
code: ResponseCode.SUCCESS,
message: '获取挑战排行榜成功',
data,
};
}
@Post(':id/join')
@UseGuards(JwtAuthGuard)
async joinChallenge(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<ChallengeProgressDto>> {
const data = await this.challengesService.joinChallenge(user.sub, id);
return {
code: ResponseCode.SUCCESS,
message: '加入挑战成功',
data,
};
}
@Post(':id/leave')
@UseGuards(JwtAuthGuard)
async leaveChallenge(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<boolean>> {
const data = await this.challengesService.leaveChallenge(user.sub, id);
return {
code: ResponseCode.SUCCESS,
message: '退出挑战成功',
data,
};
}
@Post(':id/progress')
@UseGuards(JwtAuthGuard)
async reportProgress(
@Param('id') id: string,
@Body() dto: UpdateChallengeProgressDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<ChallengeProgressDto>> {
const data = await this.challengesService.reportProgress(user.sub, id, dto);
return {
code: ResponseCode.SUCCESS,
message: '进度更新成功',
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

@@ -0,0 +1,21 @@
import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { ChallengesController } from './challenges.controller';
import { ChallengesService } from './challenges.service';
import { Challenge } from './models/challenge.model';
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, BadgeConfig]),
forwardRef(() => UsersModule),
],
controllers: [ChallengesController],
providers: [ChallengesService],
exports: [ChallengesService],
})
export class ChallengesModule { }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
import { ChallengeProgressDto, RankingItemDto } from './challenge-progress.dto';
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;
title: string;
image: string | null;
periodLabel: string | null;
durationLabel: string;
requirementLabel: string;
summary: string | null;
rankingDescription: string | null;
highlightTitle: string;
highlightSubtitle: string;
ctaLabel: string;
minimumCheckInDays: number;
participantsCount: number;
progress?: ChallengeProgressDto;
rankings: RankingItemDto[];
userRank?: number;
type: ChallengeType;
unit: string;
badge?: BadgeInfoDto;
creatorId: string | null;
shareCode?: string | null;
source: ChallengeSource;
isCreator: boolean;
}

View File

@@ -0,0 +1,39 @@
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;
image: string | null;
periodLabel: string | null;
durationLabel: string;
requirementLabel: string;
status: ChallengeStatus;
startAt: number;
endAt: number;
participantsCount: number;
rankingDescription: string | null;
highlightTitle: string;
highlightSubtitle: string;
ctaLabel: string;
minimumCheckInDays: number;
progress?: ChallengeProgressDto;
isJoined: boolean;
type: ChallengeType;
unit: string;
badge?: BadgeInfoDto;
source: ChallengeSource;
isCreator: boolean;
}
export interface ChallengeListResponseDto {
challenges: ChallengeListItemDto[];
}

View File

@@ -0,0 +1,16 @@
export interface ChallengeProgressDto {
completed: number;
target: number;
remaining: number;
checkedInToday: boolean;
}
export interface RankingItemDto {
id: string;
name: string;
avatar: string | null;
metric: string;
badge?: string;
todayReportedValue: number;
todayTargetValue: number;
}

View File

@@ -0,0 +1,23 @@
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { RankingItemDto } from './challenge-progress.dto';
export class GetChallengeRankingQueryDto {
@IsOptional()
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
}
export interface ChallengeRankingListDto {
total: number;
page: number;
pageSize: number;
items: RankingItemDto[];
}

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,7 @@
import { IsInt, Min } from 'class-validator';
export class UpdateChallengeProgressDto {
@IsInt()
@Min(0)
value!: number;
}

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

@@ -0,0 +1,99 @@
import {
Table,
Column,
DataType,
Model,
ForeignKey,
BelongsTo,
Index,
} from 'sequelize-typescript';
import { Challenge } from './challenge.model';
import { User } from '../../users/models/user.model';
export enum ChallengeParticipantStatus {
ACTIVE = 'active',
COMPLETED = 'completed',
LEFT = 'left',
}
@Table({
tableName: 't_challenge_participants',
underscored: true,
})
export class ChallengeParticipant extends Model {
@Column({
type: DataType.CHAR(36),
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@ForeignKey(() => Challenge)
@Column({
type: DataType.CHAR(36),
allowNull: false,
comment: '挑战 ID',
})
declare challengeId: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(64),
allowNull: false,
comment: '用户 ID',
})
declare userId: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '当前进度值',
})
declare progressValue: number;
@Column({
type: DataType.INTEGER,
allowNull: false,
comment: '目标值,通常与挑战 targetValue 相同',
})
declare targetValue: number;
@Column({
type: DataType.ENUM('active', 'completed', 'left'),
allowNull: false,
defaultValue: ChallengeParticipantStatus.ACTIVE,
comment: '参与状态',
})
declare status: ChallengeParticipantStatus;
@Column({
type: DataType.DATE,
allowNull: false,
defaultValue: DataType.NOW,
comment: '加入时间',
})
declare joinedAt: Date;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '退出时间',
})
declare leftAt: Date | null;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '最近一次更新进度的时间',
})
declare lastProgressAt: Date | null;
@BelongsTo(() => Challenge)
declare challenge?: Challenge;
@BelongsTo(() => User)
declare user?: User;
}

View File

@@ -0,0 +1,62 @@
import { Table, Column, DataType, Model, ForeignKey, BelongsTo, Index } from 'sequelize-typescript';
import { Challenge } from './challenge.model';
import { User } from '../../users/models/user.model';
@Table({
tableName: 't_challenge_progress_reports',
underscored: true,
})
export class ChallengeProgressReport extends Model {
@Column({
type: DataType.CHAR(36),
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@ForeignKey(() => Challenge)
@Column({
type: DataType.CHAR(36),
allowNull: false,
comment: '挑战 ID',
})
declare challengeId: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(64),
allowNull: false,
comment: '用户 ID',
})
declare userId: string;
@Column({
type: DataType.DATEONLY,
allowNull: false,
comment: '自然日,确保每日仅上报一次',
})
declare reportDate: string;
@Column({
field: 'increment_value',
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '参加挑战某一天上报的原始数据值',
})
declare reportedValue: number;
@Column({
type: DataType.DATE,
allowNull: false,
defaultValue: DataType.NOW,
comment: '上报时间戳',
})
declare reportedAt: Date;
@BelongsTo(() => Challenge)
declare challenge?: Challenge;
@BelongsTo(() => User)
declare user?: User;
}

View File

@@ -0,0 +1,206 @@
import { Table, Column, DataType, Model, HasMany } from 'sequelize-typescript';
import { ChallengeParticipant } from './challenge-participant.model';
import { col } from 'sequelize';
export enum ChallengeStatus {
UPCOMING = 'upcoming',
ONGOING = 'ongoing',
EXPIRED = 'expired',
}
export enum ChallengeType {
WATER = 'water',
EXERCISE = 'exercise',
DIET = 'diet',
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({
tableName: 't_challenges',
underscored: true,
})
export class Challenge extends Model {
@Column({
type: DataType.CHAR(36),
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@Column({
type: DataType.STRING(255),
allowNull: false,
comment: '挑战标题',
})
declare title: string;
@Column({
type: DataType.STRING(512),
allowNull: true,
comment: '挑战封面图',
})
declare image: string;
@Column({
type: DataType.DATE,
allowNull: false,
comment: '挑战开始时间',
})
declare startAt: Date;
@Column({
type: DataType.DATE,
allowNull: false,
comment: '挑战结束时间',
})
declare endAt: Date;
@Column({
type: DataType.STRING(128),
allowNull: true,
comment: '周期标签例如「21天挑战」',
})
declare periodLabel: string | null;
@Column({
type: DataType.STRING(128),
allowNull: false,
comment: '持续时间标签例如「持续21天」',
})
declare durationLabel: string;
@Column({
type: DataType.STRING(255),
allowNull: true,
comment: '挑战要求标签,例如「每日练习 1 次」',
})
declare requirementLabel?: string;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '挑战概要说明',
})
declare summary: string | null;
@Column({
type: DataType.INTEGER,
allowNull: false,
comment: '挑战目标值(例如需要完成的天数)',
})
declare targetValue: number;
@Column({
type: DataType.STRING(64),
allowNull: false,
defaultValue: '天',
comment: '进度单位,用于展示排行榜指标',
})
declare progressUnit: string;
@Column({
type: DataType.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: '最低打卡天数,用于判断挑战成功',
})
declare minimumCheckInDays: number;
@Column({
type: DataType.STRING(255),
allowNull: true,
comment: '排行榜描述,例如「连续打卡榜」',
})
declare rankingDescription: string | null;
@Column({
type: DataType.STRING(255),
allowNull: false,
comment: '高亮标题',
})
declare highlightTitle: string;
@Column({
type: DataType.STRING(255),
allowNull: false,
comment: '高亮副标题',
})
declare highlightSubtitle: string;
@Column({
type: DataType.STRING(128),
allowNull: true,
comment: 'CTA 按钮文字',
})
declare ctaLabel: string;
@Column({
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'];
},
);

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