Compare commits

...

61 Commits

Author SHA1 Message Date
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
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
e719c959aa 增强自定义训练会话创建逻辑,添加详细日志记录以便于调试和错误追踪;调整训练会话查询排序方式,改为按创建时间排序。 2025-08-16 14:19:50 +08:00
477f5b4b79 增强文章控制器的安全性,添加JWT身份验证守卫;优化训练计划服务,简化日志记录逻辑,确保使用计划创建训练会话时的准确性;更新训练会话模型,允许训练计划ID为可空字段。 2025-08-16 13:42:36 +08:00
160 changed files with 21417 additions and 371 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

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

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

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

@@ -0,0 +1,3 @@
{
"kiroAgent.configureMCP": "Enabled"
}

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

@@ -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教练对话的完整流程
该实现完全按照您的要求,参照体重记录的实现模式,提供了完整、智能、用户友好的饮食记录功能。

140
docs/WATER_RECORDS.md Normal file
View File

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

View File

@@ -0,0 +1,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,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. **性能优化**:使用了合适的数据库索引来优化查询性能
## 扩展功能
未来可以考虑添加:
- 食物营养数据库集成
- 更精确的营养成分计算
- 饮食目标设定和追踪
- 营养师在线咨询
- 社交分享功能

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,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设计

473
docs/water-records-api.md Normal file
View File

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

186
package-lock.json generated
View File

@@ -16,14 +16,17 @@
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/sequelize": "^11.0.0", "@nestjs/sequelize": "^11.0.0",
"@nestjs/swagger": "^11.1.0", "@nestjs/swagger": "^11.1.0",
"@parse/node-apn": "^5.0.0",
"@types/jsonwebtoken": "^9.0.9", "@types/jsonwebtoken": "^9.0.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"apns2": "^12.2.0",
"axios": "^1.10.0", "axios": "^1.10.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cos-nodejs-sdk-v5": "^2.14.7", "cos-nodejs-sdk-v5": "^2.14.7",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.18",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0", "jwks-rsa": "^3.2.0",
@@ -49,6 +52,7 @@
"@swc/core": "^1.10.7", "@swc/core": "^1.10.7",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/sequelize": "^4.28.20", "@types/sequelize": "^4.28.20",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
@@ -1884,6 +1888,15 @@
"node": ">=8" "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": { "node_modules/@microsoft/tsdoc": {
"version": "0.15.1", "version": "0.15.1",
"resolved": "https://mirrors.tencent.com/npm/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", "resolved": "https://mirrors.tencent.com/npm/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz",
@@ -2585,6 +2598,79 @@
"npm": ">=5.10.0" "npm": ">=5.10.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": { "node_modules/@pkgr/core": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz",
@@ -3385,6 +3471,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "22.13.13", "version": "22.13.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.13.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.13.tgz",
@@ -4319,6 +4415,19 @@
"node": ">= 8" "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": { "node_modules/append-field": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@ -4382,6 +4491,17 @@
"safer-buffer": "~2.1.0" "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": { "node_modules/assert-plus": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
@@ -4735,6 +4855,11 @@
"node": ">= 0.8.0" "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": { "node_modules/bodec": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://mirrors.tencent.com/npm/bodec/-/bodec-0.1.0.tgz", "resolved": "https://mirrors.tencent.com/npm/bodec/-/bodec-0.1.0.tgz",
@@ -5669,10 +5794,9 @@
} }
}, },
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.13", "version": "1.11.18",
"resolved": "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.13.tgz", "resolved": "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.18.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debounce-fn": { "node_modules/debounce-fn": {
@@ -6804,6 +6928,21 @@
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT" "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": { "node_modules/fast-levenshtein": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
@@ -9525,6 +9664,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -9578,6 +9723,15 @@
"mkdirp": "bin/cmd.js" "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": { "node_modules/module-details-from-path": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://mirrors.tencent.com/npm/module-details-from-path/-/module-details-from-path-1.0.3.tgz", "resolved": "https://mirrors.tencent.com/npm/module-details-from-path/-/module-details-from-path-1.0.3.tgz",
@@ -9836,6 +9990,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": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -9925,6 +10088,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -12890,6 +13059,15 @@
"through": "^2.3.8" "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": { "node_modules/undici-types": {
"version": "6.20.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",

View File

@@ -34,14 +34,17 @@
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/sequelize": "^11.0.0", "@nestjs/sequelize": "^11.0.0",
"@nestjs/swagger": "^11.1.0", "@nestjs/swagger": "^11.1.0",
"@parse/node-apn": "^5.0.0",
"@types/jsonwebtoken": "^9.0.9", "@types/jsonwebtoken": "^9.0.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"apns2": "^12.2.0",
"axios": "^1.10.0", "axios": "^1.10.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cos-nodejs-sdk-v5": "^2.14.7", "cos-nodejs-sdk-v5": "^2.14.7",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.18",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0", "jwks-rsa": "^3.2.0",
@@ -67,6 +70,7 @@
"@swc/core": "^1.10.7", "@swc/core": "^1.10.7",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/sequelize": "^4.28.20", "@types/sequelize": "^4.28.20",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",

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,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,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,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,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,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 { export enum ActivityEntityType {
USER = 'USER', USER = 'USER',
USER_PROFILE = 'USER_PROFILE', USER_PROFILE = 'USER_PROFILE',
USER_WEIGHT_HISTORY = 'USER_WEIGHT_HISTORY',
CHECKIN = 'CHECKIN', CHECKIN = 'CHECKIN',
TRAINING_PLAN = 'TRAINING_PLAN', TRAINING_PLAN = 'TRAINING_PLAN',
WORKOUT = 'WORKOUT', WORKOUT = 'WORKOUT',
DIET_RECORD = 'DIET_RECORD',
} }
@@ -37,7 +39,7 @@ export class ActivityLog extends Model {
declare user?: User; declare user?: User;
@Column({ @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, allowNull: false,
comment: '实体类型', 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 { ApiTags, ApiOperation, ApiBody, ApiQuery, ApiParam } from '@nestjs/swagger';
import { Response } from 'express'; import { Response } from 'express';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service'; import { AccessTokenPayload } from '../users/services/apple-auth.service';
import { AiCoachService } from './ai-coach.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 { 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') @ApiTags('ai-coach')
@Controller('ai-coach') @Controller('ai-coach')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class AiCoachController { 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') @Post('chat')
@ApiOperation({ summary: '流式大模型对话(普拉提教练)' }) @ApiOperation({ summary: '流式大模型对话(普拉提教练)' })
@@ -23,9 +31,12 @@ export class AiCoachController {
@Res({ passthrough: false }) res: Response, @Res({ passthrough: false }) res: Response,
): Promise<StreamableFile | AiChatResponseDto | void> { ): Promise<StreamableFile | AiChatResponseDto | void> {
const userId = user.sub; const userId = user.sub;
this.logger.log(`chat: ${userId} chat body ${JSON.stringify(body, null, 2)}`);
const stream = body.stream !== false; // 默认流式 const stream = body.stream !== false; // 默认流式
const userContent = body.messages?.[body.messages.length - 1]?.content || ''; const userContent = body.messages?.[body.messages.length - 1]?.content || '';
// 创建或沿用会话ID并保存用户消息 // 创建或沿用会话ID并保存用户消息
const { conversationId } = await this.aiCoachService.createOrAppendMessages({ const { conversationId } = await this.aiCoachService.createOrAppendMessages({
userId, userId,
@@ -33,54 +44,55 @@ export class AiCoachController {
userContent, 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`);
// 体重识别逻辑优化: res.setHeader('Content-Type', 'application/json; charset=utf-8');
// 1. 如果有图片URL使用原有的图片识别逻辑 res.send({
// 2. 如果没有图片URL但文本中包含体重信息使用新的文本识别逻辑 conversationId,
try { text: '聊天次数用完了,明天再来吧~',
if (body.imageUrl) { });
// 原有逻辑:从图片识别体重 return
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);
} }
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) { if (!stream) {
// 非流式:聚合后一次性返回文本 // 非流式:聚合后一次性返回文本
const readable = await this.aiCoachService.streamChat({
userId,
conversationId,
userContent,
systemNotice: weightInfo.systemNotice,
});
let text = ''; let text = '';
for await (const chunk of readable) { for await (const chunk of readable) {
text += chunk.toString(); text += chunk.toString();
} }
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.send({ conversationId, text, weightKg: weightInfo.weightKg }); res.send({ conversationId, text });
return; return;
} }
@@ -89,13 +101,6 @@ export class AiCoachController {
res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Transfer-Encoding', 'chunked'); res.setHeader('Transfer-Encoding', 'chunked');
const readable = await this.aiCoachService.streamChat({
userId,
conversationId,
userContent,
systemNotice: weightInfo.systemNotice,
});
readable.on('data', (chunk) => { readable.on('data', (chunk) => {
res.write(chunk); res.write(chunk);
}); });
@@ -157,6 +162,96 @@ export class AiCoachController {
}); });
return res as any; 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 result = await this.dietAnalysisService.recognizeFoodForConfirmation(body.imageUrls);
// 转换为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 result = await this.dietAnalysisService.analyzeTextFoodForConfirmation(body.text);
// 转换为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,25 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize'; import { SequelizeModule } from '@nestjs/sequelize';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { AiCoachController } from './ai-coach.controller'; import { AiCoachController } from './ai-coach.controller';
import { AiCoachService } from './ai-coach.service'; import { AiCoachService } from './ai-coach.service';
import { DietAnalysisService } from './services/diet-analysis.service';
import { AiMessage } from './models/ai-message.model'; import { AiMessage } from './models/ai-message.model';
import { AiConversation } from './models/ai-conversation.model'; import { AiConversation } from './models/ai-conversation.model';
import { PostureAssessment } from './models/posture-assessment.model'; import { PostureAssessment } from './models/posture-assessment.model';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { DietRecordsModule } from '../diet-records/diet-records.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule, ConfigModule,
UsersModule, UsersModule,
forwardRef(() => DietRecordsModule),
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]), SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]),
], ],
controllers: [AiCoachController], controllers: [AiCoachController],
providers: [AiCoachService], providers: [AiCoachService, DietAnalysisService],
exports: [DietAnalysisService],
}) })
export class AiCoachModule { } export class AiCoachModule { }

View File

@@ -7,8 +7,92 @@ import { AiConversation } from './models/ai-conversation.model';
import { PostureAssessment } from './models/posture-assessment.model'; import { PostureAssessment } from './models/posture-assessment.model';
import { UserProfile } from '../users/models/user-profile.model'; import { UserProfile } from '../users/models/user-profile.model';
import { UsersService } from '../users/users.service'; 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() @Injectable()
export class AiCoachService { export class AiCoachService {
@@ -17,7 +101,11 @@ export class AiCoachService {
private readonly model: string; private readonly model: string;
private readonly visionModel: 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 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'; const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
@@ -27,7 +115,7 @@ export class AiCoachService {
}); });
// 默认选择通义千问对话模型OpenAI兼容名可通过环境覆盖 // 默认选择通义千问对话模型OpenAI兼容名可通过环境覆盖
this.model = this.configService.get<string>('DASHSCOPE_MODEL') || 'qwen-flash'; 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: { async createOrAppendMessages(params: {
@@ -65,24 +153,395 @@ export class AiCoachService {
return messages; return messages;
}; };
async streamChat(params: { async streamChat(params: {
userId: string; userId: string;
conversationId: string; conversationId: string;
userContent: string; userContent: string;
systemNotice?: string; systemNotice?: string;
}): Promise<Readable> { imageUrls?: string[];
// 上下文:系统提示 + 历史 + 当前用户消息 selectedChoiceId?: SelectChoiceId;
const messages = await this.buildChatHistory(params.userId, params.conversationId); confirmationData?: any;
if (params.systemNotice) { }): Promise<Readable | { type: 'structured'; data: any }> {
messages.unshift({ role: 'system', content: params.systemNotice }); 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 recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation(params.imageUrls);
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 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({ const stream = await this.client.chat.completions.create({
model: this.model, model: this.model,
messages, messages,
stream: true, stream: true,
temperature: 0.7, temperature: 1,
max_tokens: 1024, max_completion_tokens: 500,
}); });
const readable = new Readable({ read() { } }); const readable = new Readable({ read() { } });
@@ -97,15 +556,19 @@ export class AiCoachService {
readable.push(delta); readable.push(delta);
} }
} }
// 结束将assistant消息入库
await AiMessage.create({ await AiMessage.create({
conversationId: params.conversationId, conversationId,
userId: params.userId, userId,
role: RoleType.Assistant, role: RoleType.Assistant,
content: assistantContent, content: assistantContent,
metadata: { model: this.model }, 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) { } catch (error) {
this.logger.error(`stream error: ${error?.message || error}`); this.logger.error(`stream error: ${error?.message || error}`);
readable.push('\n[对话发生错误,请稍后重试]'); readable.push('\n[对话发生错误,请稍后重试]');
@@ -117,6 +580,142 @@ export class AiCoachService {
return readable; 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 { private deriveTitleIfEmpty(assistantReply: string): string | null {
if (!assistantReply) return null; if (!assistantReply) return null;
const firstLine = assistantReply.split(/\r?\n/).find(Boolean) || ''; const firstLine = assistantReply.split(/\r?\n/).find(Boolean) || '';
@@ -262,51 +861,6 @@ export class AiCoachService {
return { id: rec.id, overallScore, result }; 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等 * 支持多种格式65kg、65公斤、65.5kg、体重65等
@@ -318,16 +872,36 @@ export class AiCoachService {
// 匹配各种体重格式的正则表达式 // 匹配各种体重格式的正则表达式
const patterns = [ 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, /(\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) { for (const pattern of patterns) {
const match = t.match(pattern); const match = t.match(pattern);
if (match) { 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) // 合理的体重范围检查 (20-400kg)
if (weight >= 20 && weight <= 400) { if (weight >= 20 && weight <= 400) {
return weight; return weight;
@@ -338,134 +912,33 @@ export class AiCoachService {
return null; return null;
} }
/** /**
* 获取用户体重历史记录 * 生成体重趋势分析
*/ */
async getUserWeightHistory(userId: string, limit: number = 10): Promise<{ async generateWeightTrendAnalysis(userId: string, weightRecordData: any): Promise<string> {
currentWeight?: number;
history: Array<{ weight: number; source: string; createdAt: Date }>;
}> {
try { try {
// 获取当前体重 const { newWeight } = weightRecordData;
const profile = await UserProfile.findOne({ where: { userId } });
const currentWeight = profile?.weight;
// 获取体重历史 const weightAnalysisPrompt = `用户刚刚记录了体重${newWeight}kg请提供体重趋势分析、健康建议和鼓励。语言风格亲切、专业、鼓励性。`;
const history = await this.usersService.getWeightHistory(userId, { limit });
return { const completion = await this.client.chat.completions.create({
currentWeight: currentWeight || undefined, model: this.model,
history messages: [
}; { role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: weightAnalysisPrompt }
],
temperature: 0.7,
max_tokens: 300,
});
return completion.choices?.[0]?.message?.content || '体重趋势分析生成失败,请稍后重试。';
} catch (error) { } catch (error) {
this.logger.error(`获取用户体重历史失败: ${error instanceof Error ? error.message : String(error)}`); this.logger.error(`生成体重趋势分析失败: ${error instanceof Error ? error.message : String(error)}`);
return { history: [] }; return '体重趋势分析生成失败,请稍后重试。';
}
}
/**
* 构建体重更新的系统提示信息
*/
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 {};
} }
} }
} }

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; 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 { export class AiChatMessageDto {
@ApiProperty({ enum: ['user', 'assistant', 'system'] }) @ApiProperty({ enum: ['user', 'assistant', 'system'] })
@@ -25,18 +25,200 @@ export class AiChatRequestDto {
@ApiProperty({ required: false, description: '当用户要记体重时的图片URL电子秤等' }) @ApiProperty({ required: false, description: '当用户要记体重时的图片URL电子秤等' })
@IsOptional() @IsOptional()
@IsString() @IsArray()
imageUrl?: string; @IsString({ each: true })
imageUrls?: string[];
@ApiProperty({ required: false, description: '是否启用流式输出', default: true }) @ApiProperty({ required: false, description: '是否启用流式输出', default: true })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
stream?: boolean; 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 { export class AiChatResponseDto {
@ApiProperty() @ApiProperty()
conversationId: string; 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 NutritionAnalysisRequestDto {
@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,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,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

@@ -13,6 +13,13 @@ import { RecommendationsModule } from './recommendations/recommendations.module'
import { ActivityLogsModule } from './activity-logs/activity-logs.module'; import { ActivityLogsModule } from './activity-logs/activity-logs.module';
import { ExercisesModule } from './exercises/exercises.module'; import { ExercisesModule } from './exercises/exercises.module';
import { WorkoutsModule } from './workouts/workouts.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';
@Module({ @Module({
imports: [ imports: [
@@ -31,6 +38,13 @@ import { WorkoutsModule } from './workouts/workouts.module';
ActivityLogsModule, ActivityLogsModule,
ExercisesModule, ExercisesModule,
WorkoutsModule, WorkoutsModule,
MoodCheckinsModule,
GoalsModule,
DietRecordsModule,
FoodLibraryModule,
WaterRecordsModule,
ChallengesModule,
PushNotificationsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@@ -6,7 +6,7 @@ import { CreateArticleDto, QueryArticlesDto, CreateArticleResponseDto, QueryArti
@ApiTags('articles') @ApiTags('articles')
@Controller('articles') @Controller('articles')
@UseGuards(JwtAuthGuard)
export class ArticlesController { export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) { } constructor(private readonly articlesService: ArticlesService) { }
@@ -15,12 +15,14 @@ export class ArticlesController {
@ApiOperation({ summary: '创建文章' }) @ApiOperation({ summary: '创建文章' })
@ApiBody({ type: CreateArticleDto }) @ApiBody({ type: CreateArticleDto })
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@UseGuards(JwtAuthGuard)
async create(@Body() dto: CreateArticleDto): Promise<CreateArticleResponseDto> { async create(@Body() dto: CreateArticleDto): Promise<CreateArticleResponseDto> {
return this.articlesService.create(dto); return this.articlesService.create(dto);
} }
@Get('list') @Get('list')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: '查询文章列表(分页)' }) @ApiOperation({ summary: '查询文章列表(分页)' })
async list(@Query() query: QueryArticlesDto): Promise<QueryArticlesResponseDto> { async list(@Query() query: QueryArticlesDto): Promise<QueryArticlesResponseDto> {
return this.articlesService.query(query); return this.articlesService.query(query);
@@ -36,6 +38,7 @@ export class ArticlesController {
// 增加阅读数 // 增加阅读数
@Post(':id/read-count') @Post(':id/read-count')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: '增加文章阅读数' }) @ApiOperation({ summary: '增加文章阅读数' })
async increaseReadCount(@Param('id') id: string): Promise<CreateArticleResponseDto> { async increaseReadCount(@Param('id') id: string): Promise<CreateArticleResponseDto> {
return this.articlesService.increaseReadCount(id); return this.articlesService.increaseReadCount(id);

View File

@@ -0,0 +1,107 @@
import { Controller, Get, Param, Post, Body, UseGuards, Query } from '@nestjs/common';
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 { Public } from 'src/common/decorators/public.decorator';
@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()
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,
};
}
}

View File

@@ -0,0 +1,20 @@
import { Module } 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';
@Module({
imports: [
SequelizeModule.forFeature([Challenge, ChallengeParticipant, ChallengeProgressReport, User]),
UsersModule,
],
controllers: [ChallengesController],
providers: [ChallengesService],
exports: [ChallengesService],
})
export class ChallengesModule { }

View File

@@ -0,0 +1,580 @@
import { Injectable, NotFoundException, BadRequestException, ConflictException, Inject } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Challenge, ChallengeStatus } from './models/challenge.model';
import { ChallengeParticipant, ChallengeParticipantStatus } from './models/challenge-participant.model';
import { ChallengeProgressReport } from './models/challenge-progress-report.model';
import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto';
import { ChallengeDetailDto } from './dto/challenge-detail.dto';
import { ChallengeListItemDto } from './dto/challenge-list.dto';
import { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.dto';
import { ChallengeRankingListDto } from './dto/challenge-ranking.dto';
import { fn, col, Op, UniqueConstraintError } from 'sequelize';
import * as dayjs from 'dayjs';
import { User } from '../users/models/user.model';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger as WinstonLogger } from 'winston';
@Injectable()
export class ChallengesService {
constructor(
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger,
@InjectModel(Challenge)
private readonly challengeModel: typeof Challenge,
@InjectModel(ChallengeParticipant)
private readonly participantModel: typeof ChallengeParticipant,
@InjectModel(ChallengeProgressReport)
private readonly progressReportModel: typeof ChallengeProgressReport,
) { }
async getChallengesForUser(userId?: string): Promise<ChallengeListItemDto[]> {
const challenges = await this.challengeModel.findAll({
order: [['startAt', 'ASC']],
});
if (!challenges.length) {
return [];
}
const challengeIds = challenges.map((challenge) => challenge.id);
const statusPriority: Record<ChallengeStatus, number> = {
[ChallengeStatus.ONGOING]: 0,
[ChallengeStatus.UPCOMING]: 1,
[ChallengeStatus.EXPIRED]: 2,
};
const challengesWithStatus = challenges
.map((challenge) => ({
challenge,
status: this.computeStatus(challenge.startAt, challenge.endAt),
}))
.filter(({ status }) => status !== ChallengeStatus.UPCOMING)
.sort((a, b) => {
const priorityDiff = statusPriority[a.status] - statusPriority[b.status];
if (priorityDiff !== 0) {
return priorityDiff;
}
if (a.challenge.startAt !== b.challenge.startAt) {
return Number(a.challenge.startAt) - Number(b.challenge.startAt);
}
return Number(a.challenge.endAt) - Number(b.challenge.endAt);
});
const participantCountsRaw = await this.participantModel.findAll({
attributes: ['challengeId', [fn('COUNT', col('id')), 'count']],
where: {
challengeId: challengeIds,
status: ChallengeParticipantStatus.ACTIVE,
},
group: ['challenge_id'],
raw: true,
});
const participantsCountMap = new Map<string, number>();
for (const item of participantCountsRaw as any[]) {
const key = item.challengeId ?? item.challenge_id;
if (key) {
participantsCountMap.set(key, Number(item.count));
}
}
const participationMap = new Map<string, ChallengeParticipant>();
if (userId) {
const userParticipations = await this.participantModel.findAll({
where: {
challengeId: challengeIds,
userId,
status: {
[Op.ne]: ChallengeParticipantStatus.LEFT,
},
},
});
for (const participation of userParticipations) {
participationMap.set(participation.challengeId, participation);
}
}
return challengesWithStatus.map(({ challenge, status }) => {
const completionTarget = challenge.minimumCheckInDays
const participation = participationMap.get(challenge.id);
const progress = participation
? this.buildChallengeProgress(participation.progressValue, completionTarget, participation.lastProgressAt)
: undefined;
return {
id: challenge.id,
title: challenge.title,
image: challenge.image,
periodLabel: challenge.periodLabel,
durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel,
status,
unit: challenge.progressUnit,
startAt: challenge.startAt,
endAt: challenge.endAt,
participantsCount: participantsCountMap.get(challenge.id) ?? 0,
rankingDescription: challenge.rankingDescription,
highlightTitle: challenge.highlightTitle,
highlightSubtitle: challenge.highlightSubtitle,
ctaLabel: challenge.ctaLabel,
minimumCheckInDays: completionTarget,
progress,
isJoined: Boolean(participation),
type: challenge.type,
};
});
}
async getChallengeDetail(challengeId: string, userId?: string,): Promise<ChallengeDetailDto> {
const challenge = await this.challengeModel.findByPk(challengeId);
if (!challenge) {
throw new NotFoundException('挑战不存在');
}
this.winstonLogger.info('start get detail', {
context: 'getChallengeDetail',
userId,
challengeId,
});
const [participantsCount, participation] = await Promise.all([
this.participantModel.count({
where: {
challengeId,
status: ChallengeParticipantStatus.ACTIVE,
},
}),
userId
? this.participantModel.findOne({
where: {
challengeId,
userId,
status: {
[Op.ne]: ChallengeParticipantStatus.LEFT,
},
},
})
: null,
]);
this.winstonLogger.info('end get detail', {
context: 'getChallengeDetail',
userId,
challengeId,
participantsCount,
participation,
});
const rankingResult = await this.buildChallengeRankings(challenge, { page: 1, pageSize: 10 });
this.winstonLogger.info('fetch rankings end', {
context: 'getChallengeDetail',
userId,
challengeId,
participantsCount,
participation,
rankingsCount: rankingResult.items.length,
});
const completionTarget = challenge.minimumCheckInDays
const progress = participation
? this.buildChallengeProgress(participation.progressValue, completionTarget, participation.lastProgressAt)
: undefined;
const rankings: RankingItemDto[] = rankingResult.items;
const userRank = participation ? await this.calculateUserRank(challengeId, participation) : undefined;
return {
id: challenge.id,
title: challenge.title,
image: challenge.image,
periodLabel: challenge.periodLabel,
durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel,
summary: challenge.summary,
rankingDescription: challenge.rankingDescription,
highlightTitle: challenge.highlightTitle,
highlightSubtitle: challenge.highlightSubtitle,
ctaLabel: challenge.ctaLabel,
minimumCheckInDays: completionTarget,
participantsCount,
progress,
rankings,
userRank,
unit: challenge.progressUnit,
type: challenge.type,
};
}
async getChallengeRankings(
challengeId: string,
params: { page?: number; pageSize?: number; userId?: string } = {},
): Promise<ChallengeRankingListDto> {
const challenge = await this.challengeModel.findByPk(challengeId);
if (!challenge) {
throw new NotFoundException('挑战不存在');
}
const { userId } = params;
const page = params.page && params.page > 0 ? params.page : 1;
const requestedPageSize = params.pageSize && params.pageSize > 0 ? params.pageSize : 20;
const pageSize = Math.min(requestedPageSize, 100);
this.winstonLogger.info('get challenge rankings start', {
context: 'getChallengeRankings',
challengeId,
userId,
page,
pageSize,
});
const rankingResult = await this.buildChallengeRankings(challenge, { page, pageSize });
this.winstonLogger.info('get challenge rankings end', {
context: 'getChallengeRankings',
challengeId,
userId,
page,
pageSize,
total: rankingResult.total,
itemsCount: rankingResult.items.length,
});
return {
total: rankingResult.total,
page,
pageSize,
items: rankingResult.items,
};
}
async joinChallenge(userId: string, challengeId: string): Promise<ChallengeProgressDto> {
const challenge = await this.challengeModel.findByPk(challengeId);
if (!challenge) {
throw new NotFoundException('挑战不存在');
}
const status = this.computeStatus(challenge.startAt, challenge.endAt);
if (status === ChallengeStatus.EXPIRED) {
throw new BadRequestException('挑战已过期,无法加入');
}
const completionTarget = challenge.minimumCheckInDays
if (completionTarget <= 0) {
throw new BadRequestException('挑战配置存在问题,请联系管理员');
}
const existing = await this.participantModel.findOne({
where: {
challengeId,
userId,
},
});
if (existing && existing.status === ChallengeParticipantStatus.ACTIVE) {
throw new ConflictException('已加入该挑战');
}
if (existing && existing.status === ChallengeParticipantStatus.COMPLETED) {
throw new ConflictException('该挑战已完成,如需重新参加请先退出');
}
if (existing && existing.status === ChallengeParticipantStatus.LEFT) {
existing.progressValue = 0;
existing.targetValue = completionTarget;
existing.status = ChallengeParticipantStatus.ACTIVE;
existing.joinedAt = new Date();
existing.leftAt = null;
existing.lastProgressAt = null;
await existing.save();
return this.buildChallengeProgress(existing.progressValue, completionTarget, existing.lastProgressAt);
}
const participant = await this.participantModel.create({
challengeId,
userId,
progressValue: 0,
targetValue: completionTarget,
status: ChallengeParticipantStatus.ACTIVE,
joinedAt: new Date(),
});
return this.buildChallengeProgress(participant.progressValue, completionTarget, participant.lastProgressAt);
}
async leaveChallenge(userId: string, challengeId: string): Promise<boolean> {
const participant = await this.participantModel.findOne({
where: {
challengeId,
userId,
status: {
[Op.ne]: ChallengeParticipantStatus.LEFT,
},
},
});
if (!participant) {
throw new NotFoundException('尚未加入该挑战');
}
participant.status = ChallengeParticipantStatus.LEFT;
participant.leftAt = new Date();
await participant.save();
return true;
}
async reportProgress(userId: string, challengeId: string, dto: UpdateChallengeProgressDto): Promise<ChallengeProgressDto> {
const challenge = await this.challengeModel.findByPk(challengeId);
if (!challenge) {
throw new NotFoundException('挑战不存在');
}
const status = this.computeStatus(challenge.startAt, challenge.endAt);
if (status === ChallengeStatus.UPCOMING) {
throw new BadRequestException('挑战尚未开始,无法上报进度');
}
if (status === ChallengeStatus.EXPIRED) {
throw new BadRequestException('挑战已过期,无法上报进度');
}
const participant = await this.participantModel.findOne({
where: {
challengeId,
userId,
status: ChallengeParticipantStatus.ACTIVE,
},
include: [{
model: Challenge,
as: 'challenge',
attributes: ['minimumCheckInDays', 'targetValue'],
}],
});
this.winstonLogger.info('start report progress', {
context: 'reportProgress',
userId,
challengeId,
participant,
});
if (!participant) {
throw new NotFoundException('请先加入挑战');
}
// 如果要完成当日挑战,最低的上报数据
const reportCompletedValue = challenge.targetValue
if (reportCompletedValue <= 0) {
throw new BadRequestException('挑战配置存在问题,请联系管理员');
}
if (dto.value === undefined || dto.value === null) {
throw new BadRequestException('缺少上报的进度数据');
}
if (dto.value < 0) {
throw new BadRequestException('进度数据必须大于等于 0');
}
const reportedValue = dto.value;
const reportDate = dayjs().format('YYYY-MM-DD');
const now = new Date();
try {
const [report, wasCreated] = await this.progressReportModel.findOrCreate({
where: {
challengeId,
userId,
reportDate,
},
defaults: {
reportedValue,
reportedAt: now,
},
});
if (wasCreated) {
if (report.reportedValue !== reportedValue) {
await report.update({
reportedValue,
reportedAt: now,
});
}
} else {
if (report.reportedValue !== reportedValue) {
report.reportedAt = now;
report.reportedValue = reportedValue;
await report.save();
}
}
if (report.reportedValue >= reportCompletedValue && !dayjs(participant.lastProgressAt).isSame(dayjs(), 'd')) {
participant.progressValue++
participant.lastProgressAt = now;
}
if (participant.progressValue >= (participant.challenge?.minimumCheckInDays || 0) && participant.status !== ChallengeParticipantStatus.COMPLETED) {
participant.status = ChallengeParticipantStatus.COMPLETED;
}
await participant.save();
this.winstonLogger.info('end report progress', {
context: 'reportProgress',
userId,
challengeId,
participant,
});
return this.buildChallengeProgress(participant.progressValue, participant.targetValue, participant.lastProgressAt);
} catch (error) {
if (error instanceof UniqueConstraintError) {
return this.buildChallengeProgress(participant.progressValue, participant.targetValue, participant.lastProgressAt);
}
throw error;
}
}
private buildChallengeProgress(
completed: number,
target: number,
lastProgressAt?: Date | string | null,
unit = '天',
): ChallengeProgressDto {
const remaining = Math.max(target - completed, 0);
const checkedInToday = lastProgressAt ? dayjs(lastProgressAt).isSame(dayjs(), 'day') : false;
return {
completed,
target,
remaining,
checkedInToday,
};
}
private computeStatus(startAt: number, endAt: number): ChallengeStatus {
const now = dayjs();
const start = dayjs(startAt);
const end = dayjs(endAt);
if (now.isBefore(start, 'minute')) {
return ChallengeStatus.UPCOMING;
}
if (now.isAfter(end, 'minute')) {
return ChallengeStatus.EXPIRED;
}
return ChallengeStatus.ONGOING;
}
private resolveRankingBadge(index: number): string | undefined {
if (index === 0) return 'gold';
if (index === 1) return 'silver';
if (index === 2) return 'bronze';
return undefined;
}
private async calculateUserRank(challengeId: string, participation: ChallengeParticipant): Promise<number> {
const { progressValue, updatedAt } = participation;
const higherProgressCount = await this.participantModel.count({
where: {
challengeId,
status: ChallengeParticipantStatus.ACTIVE,
[Op.or]: [
{ progressValue: { [Op.gt]: progressValue } },
{
progressValue,
updatedAt: { [Op.lt]: updatedAt },
},
],
},
});
return higherProgressCount + 1;
}
private async buildChallengeRankings(
challenge: Challenge,
params: { page: number; pageSize: number },
): Promise<{ items: RankingItemDto[]; total: number }> {
const { page, pageSize } = params;
const offset = (page - 1) * pageSize;
const { rows, count } = await this.participantModel.findAndCountAll({
where: {
challengeId: challenge.id,
status: ChallengeParticipantStatus.ACTIVE,
},
include: [{ model: User, attributes: ['id', 'name', 'avatar'] }],
order: [
['progressValue', 'DESC'],
['updatedAt', 'ASC'],
],
limit: pageSize,
offset,
});
const today = dayjs().format('YYYY-MM-DD');
const todayReportsMap = new Map<string, number>();
if (rows.length) {
const reports = await this.progressReportModel.findAll({
where: {
challengeId: challenge.id,
reportDate: today,
userId: {
[Op.in]: rows.map((item) => item.userId),
},
},
});
for (const report of reports) {
todayReportsMap.set(report.userId, report.reportedValue ?? 0);
}
}
const completionTarget = challenge.minimumCheckInDays
const items = rows.map((item, index) => {
const listIndex = offset + index;
const itemTarget = item.targetValue && item.targetValue > 0 ? item.targetValue : completionTarget;
return {
id: item.user?.id ?? item.userId,
name: item.user?.name ?? '未知用户',
avatar: item.user?.avatar ?? null,
metric: `${item.progressValue}/${itemTarget}`,
badge: this.resolveRankingBadge(listIndex),
todayReportedValue: todayReportsMap.get(item.userId) ?? 0,
todayTargetValue: challenge.targetValue,
};
});
return {
items,
total: count,
};
}
}

View File

@@ -0,0 +1,23 @@
import { ChallengeProgressDto, RankingItemDto } from './challenge-progress.dto';
import { ChallengeType } from '../models/challenge.model';
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;
}

View File

@@ -0,0 +1,28 @@
import { ChallengeStatus, ChallengeType } from '../models/challenge.model';
import { ChallengeProgressDto } from './challenge-progress.dto';
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;
}
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,7 @@
import { IsInt, Min } from 'class-validator';
export class UpdateChallengeProgressDto {
@IsInt()
@Min(0)
value!: number;
}

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,149 @@
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',
}
@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.BIGINT,
allowNull: false,
comment: '挑战开始时间(时间戳)',
})
declare startAt: number;
@Column({
type: DataType.BIGINT,
allowNull: false,
comment: '挑战结束时间(时间戳)',
})
declare endAt: number;
@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: false,
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,
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: false,
comment: 'CTA 按钮文字',
})
declare ctaLabel: string;
@Column({
type: DataType.ENUM('water', 'exercise', 'diet', 'mood', 'sleep', 'weight'),
allowNull: false,
defaultValue: ChallengeType.WATER,
comment: '挑战类型',
})
declare type: ChallengeType;
@HasMany(() => ChallengeParticipant)
declare participants?: ChallengeParticipant[];
}

View File

@@ -17,21 +17,35 @@ export class JwtAuthGuard implements CanActivate {
context.getClass(), context.getClass(),
]); ]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization; const authHeader = request.headers.authorization;
this.logger.log(`authHeader: ${authHeader}`); this.logger.log(`authHeader: ${authHeader}, isPublic: ${isPublic}`);
const token = this.appleAuthService.extractTokenFromHeader(authHeader);
if (isPublic) {
// 公开接口如果有 token也可以尝试获取用户信息
if (token) {
try {
const payload = this.appleAuthService.verifyAccessToken(token);
this.logger.log(`鉴权成功: ${JSON.stringify(payload)}, token: ${token}`);
// 将用户信息添加到请求对象中
request.user = payload;
} catch (error) {
this.logger.error(`鉴权失败: ${error.message}, token: ${token}`);
}
}
return true;
}
if (!authHeader) { if (!authHeader) {
throw new UnauthorizedException('缺少授权头'); throw new UnauthorizedException('缺少授权头');
} }
try { try {
const token = this.appleAuthService.extractTokenFromHeader(authHeader);
const payload = this.appleAuthService.verifyAccessToken(token); const payload = this.appleAuthService.verifyAccessToken(token);
this.logger.log(`鉴权成功: ${JSON.stringify(payload)}, token: ${token}`); this.logger.log(`鉴权成功: ${JSON.stringify(payload)}, token: ${token}`);

View File

@@ -13,6 +13,14 @@ import { ConfigService } from '@nestjs/config';
username: configService.get('DB_USERNAME'), username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'), password: configService.get('DB_PASSWORD'),
database: configService.get('DB_DATABASE'), database: configService.get('DB_DATABASE'),
dialectOptions: {
charset: 'utf8mb4',
collate: 'utf8mb4_0900_ai_ci',
},
define: {
charset: 'utf8mb4',
collate: 'utf8mb4_0900_ai_ci',
},
autoLoadModels: true, autoLoadModels: true,
synchronize: true, synchronize: true,
}), }),

View File

@@ -0,0 +1,221 @@
import {
Controller,
Get,
Post,
Body,
Param,
HttpCode,
HttpStatus,
Put,
Delete,
Query,
Logger,
UseGuards,
NotFoundException,
} from '@nestjs/common';
import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger';
import { DietRecordsService } from './diet-records.service';
import { NutritionAnalysisService } from './services/nutrition-analysis.service';
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto, FoodRecognitionRequestDto, FoodRecognitionResponseDto, FoodRecognitionToDietRecordsResponseDto } from '../users/dto/diet-record.dto';
import { NutritionAnalysisResponseDto } from './dto/nutrition-analysis.dto';
import { NutritionAnalysisRequestDto } from './dto/nutrition-analysis-request.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
@ApiTags('diet-records')
@Controller('diet-records')
export class DietRecordsController {
private readonly logger = new Logger(DietRecordsController.name);
constructor(
private readonly dietRecordsService: DietRecordsService,
private readonly nutritionAnalysisService: NutritionAnalysisService,
) { }
/**
* 添加饮食记录
*/
@UseGuards(JwtAuthGuard)
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '添加饮食记录' })
@ApiBody({ type: CreateDietRecordDto })
@ApiResponse({ status: 201, description: '成功添加饮食记录', type: DietRecordResponseDto })
async addDietRecord(
@Body() createDto: CreateDietRecordDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<DietRecordResponseDto> {
this.logger.log(`添加饮食记录 - 用户ID: ${user.sub}, 食物: ${createDto.foodName}`);
return this.dietRecordsService.addDietRecord(user.sub, createDto);
}
/**
* 获取饮食记录历史
*/
@UseGuards(JwtAuthGuard)
@Get()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取饮食记录历史' })
@ApiQuery({ name: 'startDate', required: false, description: '开始日期' })
@ApiQuery({ name: 'endDate', required: false, description: '结束日期' })
@ApiQuery({ name: 'mealType', required: false, description: '餐次类型' })
@ApiQuery({ name: 'page', required: false, description: '页码' })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
@ApiResponse({ status: 200, description: '成功获取饮食记录', type: DietHistoryResponseDto })
async getDietHistory(
@Query() query: GetDietHistoryQueryDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<DietHistoryResponseDto> {
this.logger.log(`获取饮食记录 - 用户ID: ${user.sub}`);
return this.dietRecordsService.getDietHistory(user.sub, query);
}
/**
* 更新饮食记录
*/
@UseGuards(JwtAuthGuard)
@Put(':id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新饮食记录' })
@ApiBody({ type: UpdateDietRecordDto })
@ApiResponse({ status: 200, description: '成功更新饮食记录', type: DietRecordResponseDto })
async updateDietRecord(
@Param('id') recordId: string,
@Body() updateDto: UpdateDietRecordDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<DietRecordResponseDto> {
this.logger.log(`更新饮食记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`);
return this.dietRecordsService.updateDietRecord(user.sub, parseInt(recordId), updateDto);
}
/**
* 删除饮食记录
*/
@UseGuards(JwtAuthGuard)
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: '删除饮食记录' })
@ApiResponse({ status: 204, description: '成功删除饮食记录' })
async deleteDietRecord(
@Param('id') recordId: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<void> {
this.logger.log(`删除饮食记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`);
const success = await this.dietRecordsService.deleteDietRecord(user.sub, parseInt(recordId));
if (!success) {
throw new NotFoundException('饮食记录不存在');
}
}
/**
* 获取营养汇总分析
*/
@UseGuards(JwtAuthGuard)
@Get('nutrition-summary')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取营养汇总分析' })
@ApiQuery({ name: 'mealCount', required: false, description: '分析的餐次数量默认10' })
@ApiResponse({ status: 200, description: '成功获取营养汇总', type: NutritionSummaryDto })
async getNutritionSummary(
@Query('mealCount') mealCount: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<NutritionSummaryDto> {
this.logger.log(`获取营养汇总 - 用户ID: ${user.sub}`);
const count = mealCount ? parseInt(mealCount) : 10;
return this.dietRecordsService.getRecentNutritionSummary(user.sub, count);
}
/**
* 根据图片URL识别食物并转换为饮食记录格式
*/
@UseGuards(JwtAuthGuard)
@Post('recognize-food-to-records')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '根据图片URL识别食物并转换为饮食记录格式' })
@ApiBody({ type: FoodRecognitionRequestDto })
@ApiResponse({ status: 200, description: '成功识别食物并转换为饮食记录格式', type: FoodRecognitionToDietRecordsResponseDto })
async recognizeFoodToDietRecords(
@Body() requestDto: FoodRecognitionRequestDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<FoodRecognitionToDietRecordsResponseDto> {
this.logger.log(`识别食物转饮食记录 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
return this.dietRecordsService.recognizeFoodToDietRecords(
requestDto.imageUrl,
requestDto.mealType
);
}
/**
* 根据图片URL识别食物原始格式
*/
@UseGuards(JwtAuthGuard)
@Post('recognize-food')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '根据图片URL识别食物' })
@ApiBody({ type: FoodRecognitionRequestDto })
@ApiResponse({ status: 200, description: '成功识别食物', type: FoodRecognitionResponseDto })
async recognizeFood(
@Body() requestDto: FoodRecognitionRequestDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<FoodRecognitionResponseDto> {
this.logger.log(`识别食物 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
return this.dietRecordsService.recognizeFood(
requestDto.imageUrl,
requestDto.mealType
);
}
/**
* 分析食物营养成分表图片
*/
@UseGuards(JwtAuthGuard)
@Post('analyze-nutrition-image')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '分析食物营养成分表图片' })
@ApiBody({ type: NutritionAnalysisRequestDto })
@ApiResponse({ status: 200, description: '成功分析营养成分表', type: NutritionAnalysisResponseDto })
@ApiResponse({ status: 400, description: '请求参数错误' })
@ApiResponse({ status: 401, description: '未授权访问' })
@ApiResponse({ status: 500, description: '服务器内部错误' })
async analyzeNutritionImage(
@Body() requestDto: NutritionAnalysisRequestDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<NutritionAnalysisResponseDto> {
this.logger.log(`分析营养成分表 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
if (!requestDto.imageUrl) {
return {
success: false,
data: [],
message: '请提供图片URL'
};
}
// 验证URL格式
try {
new URL(requestDto.imageUrl);
} catch (error) {
return {
success: false,
data: [],
message: '图片URL格式不正确'
};
}
try {
const result = await this.nutritionAnalysisService.analyzeNutritionImage(requestDto.imageUrl);
this.logger.log(`营养成分表分析完成 - 用户ID: ${user.sub}, 成功: ${result.success}, 营养素数量: ${result.data.length}`);
return result;
} catch (error) {
this.logger.error(`营养成分表分析失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`);
return {
success: false,
data: [],
message: '营养成分表分析失败,请稍后重试'
};
}
}
}

View File

@@ -0,0 +1,21 @@
import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { DietRecordsController } from './diet-records.controller';
import { DietRecordsService } from './diet-records.service';
import { NutritionAnalysisService } from './services/nutrition-analysis.service';
import { UserDietHistory } from '../users/models/user-diet-history.model';
import { ActivityLog } from '../activity-logs/models/activity-log.model';
import { UsersModule } from '../users/users.module';
import { AiCoachModule } from '../ai-coach/ai-coach.module';
@Module({
imports: [
SequelizeModule.forFeature([UserDietHistory, ActivityLog]),
UsersModule,
forwardRef(() => AiCoachModule),
],
controllers: [DietRecordsController],
providers: [DietRecordsService, NutritionAnalysisService],
exports: [DietRecordsService, NutritionAnalysisService],
})
export class DietRecordsModule { }

View File

@@ -0,0 +1,403 @@
import { Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Op, Transaction } from 'sequelize';
import { Sequelize } from 'sequelize-typescript';
import { UserDietHistory } from '../users/models/user-diet-history.model';
import { ActivityActionType, ActivityEntityType, ActivityLog } from '../activity-logs/models/activity-log.model';
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto, FoodRecognitionRequestDto, FoodRecognitionResponseDto, FoodRecognitionToDietRecordsResponseDto } from '../users/dto/diet-record.dto';
import { DietRecordSource, MealType } from '../users/models/user-diet-history.model';
import { ResponseCode } from '../base.dto';
import { DietAnalysisService } from '../ai-coach/services/diet-analysis.service';
@Injectable()
export class DietRecordsService {
private readonly logger = new Logger(DietRecordsService.name);
constructor(
@InjectModel(UserDietHistory)
private readonly userDietHistoryModel: typeof UserDietHistory,
@InjectModel(ActivityLog)
private readonly activityLogModel: typeof ActivityLog,
private readonly sequelize: Sequelize,
@Inject(forwardRef(() => DietAnalysisService))
private readonly dietAnalysisService: DietAnalysisService,
) { }
/**
* 添加饮食记录
*/
async addDietRecord(userId: string, createDto: CreateDietRecordDto): Promise<DietRecordResponseDto> {
const t = await this.sequelize.transaction();
try {
this.logger.log(`addDietRecord - userId: ${userId}, createDto: ${JSON.stringify(createDto)}`);
// 创建饮食记录
const dietRecord = await this.userDietHistoryModel.create({
userId,
mealType: createDto.mealType,
foodName: createDto.foodName,
foodDescription: createDto.foodDescription,
weightGrams: createDto.weightGrams,
portionDescription: createDto.portionDescription,
estimatedCalories: createDto.estimatedCalories,
proteinGrams: createDto.proteinGrams,
carbohydrateGrams: createDto.carbohydrateGrams,
fatGrams: createDto.fatGrams,
fiberGrams: createDto.fiberGrams,
sugarGrams: createDto.sugarGrams,
sodiumMg: createDto.sodiumMg,
additionalNutrition: createDto.additionalNutrition,
source: createDto.source || DietRecordSource.Manual,
mealTime: createDto.mealTime ? new Date(createDto.mealTime) : new Date(),
imageUrl: createDto.imageUrl,
aiAnalysisResult: createDto.aiAnalysisResult,
notes: createDto.notes,
}, { transaction: t });
// 记录活动日志
await this.activityLogModel.create({
userId,
action: ActivityActionType.CREATE,
entityType: ActivityEntityType.DIET_RECORD,
entityId: dietRecord.id.toString(),
changes: {
recordId: dietRecord.id,
foodName: createDto.foodName,
mealType: createDto.mealType,
calories: createDto.estimatedCalories,
source: createDto.source || DietRecordSource.Manual,
},
}, { transaction: t });
await t.commit();
return this.mapDietRecordToDto(dietRecord)
} catch (e) {
await t.rollback();
this.logger.error(`addDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
throw e;
}
}
/**
* 通过视觉识别添加饮食记录
*/
async addDietRecordByVision(userId: string, dietData: CreateDietRecordDto): Promise<DietRecordResponseDto> {
return this.addDietRecord(userId, {
...dietData,
source: DietRecordSource.Vision
});
}
/**
* 获取饮食记录历史
*/
async getDietHistory(userId: string, query: GetDietHistoryQueryDto): Promise<DietHistoryResponseDto> {
const where: any = { userId, deleted: false };
// 日期过滤
if (query.startDate || query.endDate) {
where.createdAt = {} as any;
if (query.startDate) where.createdAt[Op.gte] = new Date(query.startDate);
if (query.endDate) where.createdAt[Op.lte] = new Date(query.endDate);
}
// 餐次类型过滤
if (query.mealType) {
where.mealType = query.mealType;
}
const limit = Math.min(100, Math.max(1, query.limit || 20));
const page = Math.max(1, query.page || 1);
const offset = (page - 1) * limit;
const { rows, count } = await this.userDietHistoryModel.findAndCountAll({
where,
order: [['created_at', 'DESC']],
limit,
offset,
});
const totalPages = Math.ceil(count / limit);
return {
records: rows.map(record => this.mapDietRecordToDto(record)),
total: count,
page,
limit,
totalPages,
};
}
/**
* 更新饮食记录
*/
async updateDietRecord(userId: string, recordId: number, updateDto: UpdateDietRecordDto): Promise<DietRecordResponseDto> {
const t = await this.sequelize.transaction();
try {
const record = await this.userDietHistoryModel.findOne({
where: { id: recordId, userId, deleted: false },
transaction: t,
});
if (!record) {
throw new NotFoundException('饮食记录不存在');
}
// 更新记录
await record.update({
mealType: updateDto.mealType ?? record.mealType,
foodName: updateDto.foodName ?? record.foodName,
foodDescription: updateDto.foodDescription ?? record.foodDescription,
weightGrams: updateDto.weightGrams ?? record.weightGrams,
portionDescription: updateDto.portionDescription ?? record.portionDescription,
estimatedCalories: updateDto.estimatedCalories ?? record.estimatedCalories,
proteinGrams: updateDto.proteinGrams ?? record.proteinGrams,
carbohydrateGrams: updateDto.carbohydrateGrams ?? record.carbohydrateGrams,
fatGrams: updateDto.fatGrams ?? record.fatGrams,
fiberGrams: updateDto.fiberGrams ?? record.fiberGrams,
sugarGrams: updateDto.sugarGrams ?? record.sugarGrams,
sodiumMg: updateDto.sodiumMg ?? record.sodiumMg,
additionalNutrition: updateDto.additionalNutrition ?? record.additionalNutrition,
mealTime: updateDto.mealTime ? new Date(updateDto.mealTime) : record.mealTime,
imageUrl: updateDto.imageUrl ?? record.imageUrl,
notes: updateDto.notes ?? record.notes,
}, { transaction: t });
// 记录活动日志
await this.activityLogModel.create({
userId,
action: ActivityActionType.UPDATE,
entityType: ActivityEntityType.DIET_RECORD,
entityId: record.id.toString(),
changes: {
recordId: record.id,
foodName: record.foodName,
updates: updateDto,
},
}, { transaction: t });
await t.commit();
return this.mapDietRecordToDto(record)
} catch (e) {
await t.rollback();
this.logger.error(`updateDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
throw e;
}
}
/**
* 删除饮食记录
*/
async deleteDietRecord(userId: string, recordId: number): Promise<boolean> {
const t = await this.sequelize.transaction();
try {
const record = await this.userDietHistoryModel.findOne({
where: { id: recordId, userId, deleted: false },
transaction: t,
});
if (!record) {
return false;
}
// 软删除
await record.update({ deleted: true }, { transaction: t });
// 记录活动日志
await this.activityLogModel.create({
userId,
action: ActivityActionType.DELETE,
entityType: ActivityEntityType.DIET_RECORD,
entityId: record.id.toString(),
changes: {
recordId: record.id,
foodName: record.foodName,
mealType: record.mealType,
},
}, { transaction: t });
await t.commit();
return true;
} catch (e) {
await t.rollback();
this.logger.error(`deleteDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
throw e;
}
}
/**
* 获取最近的营养汇总
*/
async getRecentNutritionSummary(userId: string, mealCount: number = 10): Promise<NutritionSummaryDto> {
const records = await this.userDietHistoryModel.findAll({
where: { userId, deleted: false },
order: [['created_at', 'DESC']],
limit: mealCount,
});
if (records.length === 0) {
const now = new Date();
return {
totalCalories: 0,
totalProtein: 0,
totalCarbohydrates: 0,
totalFat: 0,
totalFiber: 0,
totalSugar: 0,
totalSodium: 0,
recordCount: 0,
dateRange: {
start: now,
end: now,
},
averageCaloriesPerMeal: 0,
mealTypeDistribution: {},
};
}
const totalCalories = records.reduce((sum, r) => sum + (r.estimatedCalories || 0), 0);
const totalProtein = records.reduce((sum, r) => sum + (r.proteinGrams || 0), 0);
const totalCarbohydrates = records.reduce((sum, r) => sum + (r.carbohydrateGrams || 0), 0);
const totalFat = records.reduce((sum, r) => sum + (r.fatGrams || 0), 0);
const totalFiber = records.reduce((sum, r) => sum + (r.fiberGrams || 0), 0);
const totalSugar = records.reduce((sum, r) => sum + (r.sugarGrams || 0), 0);
const totalSodium = records.reduce((sum, r) => sum + (r.sodiumMg || 0), 0);
// 餐次分布统计
const mealTypeDistribution = records.reduce((dist, record) => {
const mealType = record.mealType;
dist[mealType] = (dist[mealType] || 0) + 1;
return dist;
}, {} as Record<string, number>);
return {
totalCalories,
totalProtein,
totalCarbohydrates,
totalFat,
totalFiber,
totalSugar,
totalSodium,
recordCount: records.length,
dateRange: {
start: records[records.length - 1].createdAt,
end: records[0].createdAt,
},
averageCaloriesPerMeal: records.length > 0 ? totalCalories / records.length : 0,
mealTypeDistribution,
};
}
/**
* 根据图片URL识别食物并转换为饮食记录格式
* @param imageUrl 图片URL
* @param suggestedMealType 建议的餐次类型(可选)
* @returns 食物识别结果转换为饮食记录格式
*/
async recognizeFoodToDietRecords(
imageUrl: string,
suggestedMealType?: MealType
): Promise<FoodRecognitionToDietRecordsResponseDto> {
try {
this.logger.log(`recognizeFoodToDietRecords - imageUrl: ${imageUrl}, suggestedMealType: ${suggestedMealType}`);
// 调用 DietAnalysisService 进行食物识别
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl]);
// 将识别结果转换为 CreateDietRecordDto 格式
const dietRecords: CreateDietRecordDto[] = recognitionResult.items.map(item => ({
mealType: suggestedMealType || item.mealType,
foodName: item.foodName,
portionDescription: item.portion,
estimatedCalories: item.calories,
proteinGrams: item.nutritionData.proteinGrams,
carbohydrateGrams: item.nutritionData.carbohydrateGrams,
fatGrams: item.nutritionData.fatGrams,
fiberGrams: item.nutritionData.fiberGrams,
source: DietRecordSource.Vision,
imageUrl: imageUrl,
aiAnalysisResult: {
recognitionId: item.id,
confidence: recognitionResult.confidence,
analysisText: recognitionResult.analysisText,
originalLabel: item.label
}
}));
return {
dietRecords,
analysisText: recognitionResult.analysisText,
confidence: recognitionResult.confidence,
imageUrl: imageUrl
};
} catch (error) {
this.logger.error(`recognizeFoodToDietRecords error: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
/**
* 根据图片URL识别食物原始格式
* @param imageUrl 图片URL
* @param suggestedMealType 建议的餐次类型(可选)
* @returns 食物识别结果
*/
async recognizeFood(
imageUrl: string,
suggestedMealType?: MealType
): Promise<FoodRecognitionResponseDto> {
try {
this.logger.log(`recognizeFood - imageUrl: ${imageUrl}, suggestedMealType: ${suggestedMealType}`);
// 调用 DietAnalysisService 进行食物识别
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl]);
// 如果指定了建议的餐次类型,更新所有识别项的餐次类型
if (suggestedMealType) {
recognitionResult.items.forEach(item => {
item.mealType = suggestedMealType;
});
}
return {
recognizedItems: recognitionResult.items,
analysisText: recognitionResult.analysisText,
confidence: recognitionResult.confidence,
imageUrl: imageUrl
};
} catch (error) {
this.logger.error(`recognizeFood error: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
/**
* 将数据库记录映射为DTO
*/
private mapDietRecordToDto(record: UserDietHistory): any {
return {
id: record.id,
mealType: record.mealType,
foodName: record.foodName,
foodDescription: record.foodDescription,
weightGrams: record.weightGrams,
portionDescription: record.portionDescription,
estimatedCalories: record.estimatedCalories,
proteinGrams: record.proteinGrams,
carbohydrateGrams: record.carbohydrateGrams,
fatGrams: record.fatGrams,
fiberGrams: record.fiberGrams,
sugarGrams: record.sugarGrams,
sodiumMg: record.sodiumMg,
additionalNutrition: record.additionalNutrition,
source: record.source,
mealTime: record.mealTime,
imageUrl: record.imageUrl,
aiAnalysisResult: record.aiAnalysisResult,
notes: record.notes,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
};
}
}

View File

@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* 营养成分分析请求DTO
*/
export class NutritionAnalysisRequestDto {
@ApiProperty({
description: '营养成分表图片URL',
example: 'https://example.com/nutrition-label.jpg',
required: true
})
imageUrl: string;
}

View File

@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* 营养成分分析结果项
*/
export class NutritionAnalysisItemDto {
@ApiProperty({ description: '营养素的唯一标识', example: 'energy_kcal' })
key: string;
@ApiProperty({ description: '营养素的中文名称', example: '热量' })
name: string;
@ApiProperty({ description: '从图片中识别的原始值和单位', example: '840千焦' })
value: string;
@ApiProperty({ description: '针对该营养素的详细健康建议', example: '840千焦约等于201卡路里占成人每日推荐摄入总热量的10%,属于中等热量水平。' })
analysis: string;
}
/**
* 营养成分分析响应DTO
*/
export class NutritionAnalysisResponseDto {
@ApiProperty({ description: '操作是否成功', example: true })
success: boolean;
@ApiProperty({ description: '营养成分分析结果数组', type: [NutritionAnalysisItemDto] })
data: NutritionAnalysisItemDto[];
@ApiProperty({ description: '响应消息', required: false })
message?: string;
}

View File

@@ -0,0 +1,282 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OpenAI } from 'openai';
/**
* 营养成分分析结果接口
*/
export interface NutritionAnalysisResult {
key: string; // 营养素的唯一标识,如 energy_kcal
name: string; // 营养素的中文名称,如"热量"
value: string; // 从图片中识别的原始值和单位,如"840千焦"
analysis: string; // 针对该营养素的详细健康建议
}
/**
* 营养成分分析响应接口
*/
export interface NutritionAnalysisResponse {
success: boolean;
data: NutritionAnalysisResult[];
message?: string;
}
/**
* 营养成分分析服务
* 负责处理食物营养成分表的AI分析
*
* 支持多种AI模型
* - GLM-4.5V (智谱AI) - 设置 AI_VISION_PROVIDER=glm
* - Qwen VL (阿里云DashScope) - 设置 AI_VISION_PROVIDER=dashscope (默认)
*/
@Injectable()
export class NutritionAnalysisService {
private readonly logger = new Logger(NutritionAnalysisService.name);
private readonly client: OpenAI;
private readonly visionModel: string;
private readonly apiProvider: string;
constructor(private readonly configService: ConfigService) {
// Support both GLM-4.5V and DashScope (Qwen) models
this.apiProvider = this.configService.get<string>('AI_VISION_PROVIDER') || 'dashscope';
if (this.apiProvider === 'glm') {
// GLM-4.5V Configuration
const glmApiKey = this.configService.get<string>('GLM_API_KEY');
const glmBaseURL = this.configService.get<string>('GLM_BASE_URL') || 'https://open.bigmodel.cn/api/paas/v4';
this.client = new OpenAI({
apiKey: glmApiKey,
baseURL: glmBaseURL,
});
this.visionModel = this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4v-plus';
} else {
// DashScope Configuration (default)
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';
this.client = new OpenAI({
apiKey: dashScopeApiKey,
baseURL,
});
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max';
}
}
/**
* 分析食物营养成分表图片
* @param imageUrl 图片URL
* @returns 营养成分分析结果
*/
async analyzeNutritionImage(imageUrl: string): Promise<NutritionAnalysisResponse> {
try {
this.logger.log(`开始分析营养成分表图片: ${imageUrl}`);
const prompt = this.buildNutritionAnalysisPrompt();
const completion = await this.makeVisionApiCall(prompt, [imageUrl]);
const rawResult = completion.choices?.[0]?.message?.content || '[]';
this.logger.log(`营养成分分析原始结果: ${rawResult}`);
return this.parseNutritionAnalysisResult(rawResult);
} catch (error) {
this.logger.error(`营养成分表分析失败: ${error instanceof Error ? error.message : String(error)}`);
return {
success: false,
data: [],
message: '营养成分表分析失败,请稍后重试'
};
}
}
/**
* 制作视觉模型API调用 - 兼容GLM-4.5V和DashScope
* @param prompt 提示文本
* @param imageUrls 图片URL数组
* @returns API响应
*/
private async makeVisionApiCall(prompt: string, imageUrls: string[]) {
const baseParams = {
model: this.visionModel,
temperature: 0.3,
response_format: { type: 'json_object' } as any,
};
// 处理图片URL
const processedImages = imageUrls.map((imageUrl) => ({
type: 'image_url',
image_url: { url: imageUrl } as any,
}));
if (this.apiProvider === 'glm') {
// GLM-4.5V format
return await this.client.chat.completions.create({
...baseParams,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
...processedImages,
] as any,
},
],
} as any);
} else {
// DashScope format (default)
return await this.client.chat.completions.create({
...baseParams,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
...processedImages,
] as any,
},
],
});
}
}
/**
* 构建营养成分分析提示
* @returns 提示文本
*/
private buildNutritionAnalysisPrompt(): string {
return `作为专业的营养分析师,请仔细分析这张图片中的营养成分表。
**任务要求:**
1. 识别图片中的营养成分表,提取所有可见的营养素信息
2. 为每个营养素提供详细的健康建议和分析
3. 返回严格的JSON数组格式不包含任何额外的解释或对话文本
**输出格式要求:**
请严格按照以下JSON数组格式返回每个对象包含四个字段
[
{
"key": "energy_kcal",
"name": "热量",
"value": "840千焦",
"analysis": "840千焦约等于201卡路里占成人每日推荐摄入总热量的10%,属于中等热量水平。"
},
{
"key": "protein",
"name": "蛋白质",
"value": "12.5g",
"analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。"
}
]
**营养素标识符对照表:**
- 热量/能量: energy_kcal
- 蛋白质: protein
- 脂肪: fat
- 碳水化合物: carbohydrate
- 膳食纤维: fiber
- 钠: sodium
- 钙: calcium
- 铁: iron
- 锌: zinc
- 维生素C: vitamin_c
- 维生素A: vitamin_a
- 维生素D: vitamin_d
- 维生素E: vitamin_e
- 维生素B1: vitamin_b1
- 维生素B2: vitamin_b2
- 维生素B6: vitamin_b6
- 维生素B12: vitamin_b12
- 叶酸: folic_acid
- 胆固醇: cholesterol
- 饱和脂肪: saturated_fat
- 反式脂肪: trans_fat
- 糖: sugar
- 其他营养素: other_nutrient
**分析要求:**
1. 如果图片中没有营养成分表,返回空数组 []
2. 为每个识别到的营养素提供具体的健康建议
3. 建议应包含营养素的作用、摄入量参考和健康影响
4. 数值分析要准确,建议要专业且实用
5. 只返回JSON数组不要包含任何其他文本
**重要提醒:**
- 严格按照JSON数组格式返回
- 不要添加任何解释性文字或对话内容
- 确保JSON格式正确可以被直接解析`;
}
/**
* 解析营养成分分析结果
* @param rawResult 原始结果字符串
* @returns 解析后的分析结果
*/
private parseNutritionAnalysisResult(rawResult: string): NutritionAnalysisResponse {
try {
// 尝试解析JSON
let parsedResult: any;
try {
parsedResult = JSON.parse(rawResult);
} catch (parseError) {
this.logger.error(`营养成分分析JSON解析失败: ${parseError}`);
this.logger.error(`原始结果: ${rawResult}`);
return {
success: false,
data: [],
message: '营养成分表解析失败,无法识别有效的营养信息'
};
}
// 确保结果是数组
if (!Array.isArray(parsedResult)) {
this.logger.error(`营养成分分析结果不是数组格式: ${typeof parsedResult}`);
return {
success: false,
data: [],
message: '营养成分表格式错误,无法识别有效的营养信息'
};
}
// 验证和标准化每个营养素项
const nutritionData: NutritionAnalysisResult[] = [];
for (const item of parsedResult) {
if (item && typeof item === 'object' && item.key && item.name && item.value && item.analysis) {
nutritionData.push({
key: String(item.key).trim(),
name: String(item.name).trim(),
value: String(item.value).trim(),
analysis: String(item.analysis).trim()
});
} else {
this.logger.warn(`跳过无效的营养素项: ${JSON.stringify(item)}`);
}
}
if (nutritionData.length === 0) {
return {
success: false,
data: [],
message: '图片中未检测到有效的营养成分表信息'
};
}
this.logger.log(`成功解析 ${nutritionData.length} 项营养素信息`);
return {
success: true,
data: nutritionData
};
} catch (error) {
this.logger.error(`营养成分分析结果处理失败: ${error instanceof Error ? error.message : String(error)}`);
return {
success: false,
data: [],
message: '营养成分表处理失败,请稍后重试'
};
}
}
}

View File

@@ -0,0 +1,94 @@
# 食物库功能
## 功能概述
食物库功能提供了一个完整的食物数据库,包含各种食物的营养信息。用户可以通过分类浏览或搜索来查找食物。
## 数据库设计
### 食物分类表 (t_food_categories)
- `key`: 分类唯一键common, fruits_vegetables等
- `name`: 分类中文名称(如:常见、水果蔬菜等)
- `icon`: 分类图标(可选)
- `sort_order`: 排序顺序
- `is_system`: 是否系统分类
### 食物库表 (t_food_library)
- `id`: 食物唯一ID
- `name`: 食物名称
- `category_key`: 所属分类
- `calories_per_100g`: 每100克热量
- `protein_per_100g`: 每100克蛋白质含量
- `carbohydrate_per_100g`: 每100克碳水化合物含量
- `fat_per_100g`: 每100克脂肪含量
- `fiber_per_100g`: 每100克膳食纤维含量
- `sugar_per_100g`: 每100克糖分含量
- `sodium_per_100g`: 每100克钠含量
- `additional_nutrition`: 其他营养信息JSON格式
- `is_common`: 是否常见食物
- `image_url`: 食物图片URL
- `sort_order`: 排序顺序
## 特殊逻辑
### 常见食物分类
- 标记为 `is_common = true` 的食物会显示在"常见"分类中
- 其他分类只显示 `is_common = false` 的食物
- 这样避免了食物在多个分类中重复显示
## API接口
### 1. 获取食物库列表
```
GET /food-library
```
返回按分类组织的食物列表,常见食物会归类到"常见"分类中。
### 2. 搜索食物
```
GET /food-library/search?keyword=关键词
```
根据关键词搜索食物,常见食物会优先显示。
### 3. 获取食物详情
```
GET /food-library/:id
```
根据ID获取特定食物的详细信息。
## 数据初始化
1. 执行表结构创建脚本:
```bash
mysql -u root -p pilates_db < sql-scripts/food-library-tables-create.sql
```
2. 插入示例数据:
```bash
mysql -u root -p pilates_db < sql-scripts/food-library-sample-data.sql
```
## 客户端界面对应
根据提供的客户端界面,食物库包含以下分类:
- 常见:显示标记为常见的食物
- 水果蔬菜fruits_vegetables 分类的非常见食物
- 肉蛋奶meat_eggs_dairy 分类的非常见食物
- 豆类坚果beans_nuts 分类的非常见食物
- 零食饮料snacks_drinks 分类的非常见食物
- 主食staple_food 分类的非常见食物
- 菜肴dishes 分类的非常见食物
每个食物显示:
- 食物名称
- 营养信息139千卡/100克
- 添加按钮(+
## 扩展功能
未来可以扩展的功能:
- 用户自定义食物
- 收藏食物功能
- 食物图片上传
- 营养成分详细分析
- 食物推荐算法

View File

@@ -0,0 +1,127 @@
# 用户自定义食物功能
## 功能概述
用户自定义食物功能允许用户添加自己的食物到食物库中,这些自定义食物只对创建它们的用户可见。
## 数据库设计
### 用户自定义食物表 (t_user_custom_foods)
- `id`: 自定义食物唯一ID
- `user_id`: 用户ID关联到用户
- `name`: 食物名称
- `category_key`: 所属分类
- `calories_per_100g`: 每100克热量
- `protein_per_100g`: 每100克蛋白质含量
- `carbohydrate_per_100g`: 每100克碳水化合物含量
- `fat_per_100g`: 每100克脂肪含量
- `fiber_per_100g`: 每100克膳食纤维含量
- `sugar_per_100g`: 每100克糖分含量
- `sodium_per_100g`: 每100克钠含量
- `additional_nutrition`: 其他营养信息JSON格式
- `image_url`: 食物图片URL
- `sort_order`: 排序顺序
## API接口
### 1. 获取食物库列表(包含用户自定义食物)
```
GET /food-library
Authorization: Bearer <access_token>
```
返回按分类组织的食物列表,包含系统食物和用户自定义食物。用户自定义食物会显示在对应的分类中。
### 2. 搜索食物(包含用户自定义食物)
```
GET /food-library/search?keyword=关键词
Authorization: Bearer <access_token>
```
根据关键词搜索食物,包含系统食物和用户自定义食物。用户自定义食物会优先显示。
### 3. 创建用户自定义食物
```
POST /food-library/custom
Authorization: Bearer <access_token>
Content-Type: application/json
{
"name": "我的自制沙拉",
"description": "自制蔬菜沙拉",
"categoryKey": "dishes",
"caloriesPer100g": 120,
"proteinPer100g": 5.2,
"carbohydratePer100g": 15.3,
"fatPer100g": 8.1,
"fiberPer100g": 3.2,
"sugarPer100g": 2.5,
"sodiumPer100g": 150,
"imageUrl": "https://example.com/image.jpg"
}
```
### 4. 删除用户自定义食物
```
DELETE /food-library/custom/{id}
Authorization: Bearer <access_token>
```
### 5. 获取食物详情(支持系统食物和用户自定义食物)
```
GET /food-library/{id}
Authorization: Bearer <access_token>
```
## 特殊逻辑
### 食物显示规则
1. **常见分类**: 只显示系统食物中标记为常见的食物,不包含用户自定义食物
2. **其他分类**: 显示该分类下的系统食物和用户自定义食物
3. **搜索结果**: 用户自定义食物优先显示,然后是系统食物
### 权限控制
- 用户只能看到自己创建的自定义食物
- 用户只能删除自己创建的自定义食物
- 所有接口都需要用户认证
### 数据验证
- 食物名称:必填,字符串类型
- 分类键:必填,必须是有效的分类
- 营养成分:可选,数值类型,有合理的范围限制
- 图片URL可选字符串类型
## 使用示例
### 创建自定义食物
```javascript
const response = await fetch('/food-library/custom', {
method: 'POST',
headers: {
'Authorization': 'Bearer your_access_token',
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: '我的蛋白质奶昔',
description: '自制高蛋白奶昔',
categoryKey: 'snacks_drinks',
caloriesPer100g: 180,
proteinPer100g: 25,
carbohydratePer100g: 10,
fatPer100g: 5
})
});
const customFood = await response.json();
console.log('创建的自定义食物:', customFood);
```
### 获取包含自定义食物的食物库
```javascript
const response = await fetch('/food-library', {
headers: {
'Authorization': 'Bearer your_access_token'
}
});
const foodLibrary = await response.json();
console.log('食物库(包含自定义食物):', foodLibrary);
```

View File

@@ -0,0 +1,147 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, Max } from 'class-validator';
export class FoodItemDto {
@ApiProperty({ description: '食物ID' })
id: number;
@ApiProperty({ description: '食物名称' })
name: string;
@ApiProperty({ description: '食物描述', required: false })
description?: string;
@ApiProperty({ description: '每100克热量卡路里', required: false })
caloriesPer100g?: number;
@ApiProperty({ description: '每100克蛋白质含量', required: false })
proteinPer100g?: number;
@ApiProperty({ description: '每100克碳水化合物含量', required: false })
carbohydratePer100g?: number;
@ApiProperty({ description: '每100克脂肪含量', required: false })
fatPer100g?: number;
@ApiProperty({ description: '每100克膳食纤维含量', required: false })
fiberPer100g?: number;
@ApiProperty({ description: '每100克糖分含量', required: false })
sugarPer100g?: number;
@ApiProperty({ description: '每100克钠含量毫克', required: false })
sodiumPer100g?: number;
@ApiProperty({ description: '其他营养信息', required: false })
additionalNutrition?: Record<string, any>;
@ApiProperty({ description: '是否常见食物' })
isCommon: boolean;
@ApiProperty({ description: '食物图片URL', required: false })
imageUrl?: string;
@ApiProperty({ description: '排序', required: false })
sortOrder?: number;
@ApiProperty({ description: '是否为用户自定义食物', required: false })
isCustom?: boolean;
@ApiProperty({ description: '是否已收藏', required: false })
isFavorite?: boolean;
}
export class FoodCategoryDto {
@ApiProperty({ description: '分类键' })
key: string;
@ApiProperty({ description: '分类名称' })
name: string;
@ApiProperty({ description: '分类图标', required: false })
icon?: string;
@ApiProperty({ description: '排序', required: false })
sortOrder?: number;
@ApiProperty({ description: '是否系统分类' })
isSystem: boolean;
@ApiProperty({ description: '该分类下的食物列表', type: [FoodItemDto] })
foods: FoodItemDto[];
}
export class FoodLibraryResponseDto {
@ApiProperty({ description: '食物分类列表', type: [FoodCategoryDto] })
categories: FoodCategoryDto[];
}
export class CreateCustomFoodDto {
@ApiProperty({ description: '食物名称', example: '我的自制沙拉' })
@IsString({ message: '食物名称必须是字符串' })
@IsNotEmpty({ message: '食物名称不能为空' })
name: string;
@ApiProperty({ description: '食物描述', required: false, example: '自制蔬菜沙拉' })
@IsOptional()
@IsString({ message: '食物描述必须是字符串' })
description?: string;
@ApiProperty({ description: '每100克热量卡路里', required: false, example: 120 })
@IsOptional()
@IsNumber({}, { message: '热量必须是数字' })
@Min(0, { message: '热量不能为负数' })
@Max(9999, { message: '热量不能超过9999' })
caloriesPer100g?: number;
@ApiProperty({ description: '每100克蛋白质含量', required: false, example: 5.2 })
@IsOptional()
@IsNumber({}, { message: '蛋白质含量必须是数字' })
@Min(0, { message: '蛋白质含量不能为负数' })
@Max(100, { message: '蛋白质含量不能超过100克' })
proteinPer100g?: number;
@ApiProperty({ description: '每100克碳水化合物含量', required: false, example: 15.3 })
@IsOptional()
@IsNumber({}, { message: '碳水化合物含量必须是数字' })
@Min(0, { message: '碳水化合物含量不能为负数' })
@Max(100, { message: '碳水化合物含量不能超过100克' })
carbohydratePer100g?: number;
@ApiProperty({ description: '每100克脂肪含量', required: false, example: 8.1 })
@IsOptional()
@IsNumber({}, { message: '脂肪含量必须是数字' })
@Min(0, { message: '脂肪含量不能为负数' })
@Max(100, { message: '脂肪含量不能超过100克' })
fatPer100g?: number;
@ApiProperty({ description: '每100克膳食纤维含量', required: false, example: 3.2 })
@IsOptional()
@IsNumber({}, { message: '膳食纤维含量必须是数字' })
@Min(0, { message: '膳食纤维含量不能为负数' })
@Max(100, { message: '膳食纤维含量不能超过100克' })
fiberPer100g?: number;
@ApiProperty({ description: '每100克糖分含量', required: false, example: 2.5 })
@IsOptional()
@IsNumber({}, { message: '糖分含量必须是数字' })
@Min(0, { message: '糖分含量不能为负数' })
@Max(100, { message: '糖分含量不能超过100克' })
sugarPer100g?: number;
@ApiProperty({ description: '每100克钠含量毫克', required: false, example: 150 })
@IsOptional()
@IsNumber({}, { message: '钠含量必须是数字' })
@Min(0, { message: '钠含量不能为负数' })
@Max(99999, { message: '钠含量不能超过99999毫克' })
sodiumPer100g?: number;
@ApiProperty({ description: '其他营养信息', required: false })
@IsOptional()
additionalNutrition?: Record<string, any>;
@ApiProperty({ description: '食物图片URL', required: false })
@IsOptional()
@IsString({ message: '图片URL必须是字符串' })
imageUrl?: string;
}

View File

@@ -0,0 +1,152 @@
import { Controller, Get, Post, Delete, Query, Param, ParseIntPipe, NotFoundException, Body, UseGuards, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam, ApiBearerAuth } from '@nestjs/swagger';
import { FoodLibraryService } from './food-library.service';
import { FoodLibraryResponseDto, FoodItemDto, CreateCustomFoodDto } from './dto/food-library.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
@ApiTags('食物库')
@Controller('food-library')
export class FoodLibraryController {
constructor(private readonly foodLibraryService: FoodLibraryService) { }
@Get()
@ApiOperation({ summary: '获取食物库列表' })
@ApiResponse({
status: 200,
description: '成功获取食物库列表',
type: FoodLibraryResponseDto,
})
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async getFoodLibrary(@CurrentUser() user: AccessTokenPayload): Promise<FoodLibraryResponseDto> {
return this.foodLibraryService.getFoodLibrary(user.sub);
}
@Get('search')
@ApiOperation({ summary: '搜索食物' })
@ApiQuery({ name: 'keyword', description: '搜索关键词', required: true })
@ApiResponse({
status: 200,
description: '成功搜索食物',
type: [FoodItemDto],
})
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async searchFoods(
@Query('keyword') keyword: string,
@CurrentUser() user: AccessTokenPayload
): Promise<FoodItemDto[]> {
if (!keyword || keyword.trim().length === 0) {
return [];
}
return this.foodLibraryService.searchFoods(keyword.trim(), user.sub);
}
@Post('custom')
@ApiOperation({ summary: '创建用户自定义食物' })
@ApiResponse({
status: 201,
description: '成功创建自定义食物',
type: FoodItemDto,
})
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async createCustomFood(
@Body() createCustomFoodDto: CreateCustomFoodDto,
@CurrentUser() user: AccessTokenPayload
): Promise<FoodItemDto> {
return this.foodLibraryService.createCustomFood(user.sub, createCustomFoodDto);
}
@Delete('custom/:id')
@ApiOperation({ summary: '删除用户自定义食物' })
@ApiParam({ name: 'id', description: '自定义食物ID', type: 'number' })
@ApiResponse({
status: 200,
description: '成功删除自定义食物',
})
@ApiResponse({
status: 404,
description: '自定义食物不存在',
})
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async deleteCustomFood(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: AccessTokenPayload
): Promise<{ success: boolean }> {
const success = await this.foodLibraryService.deleteCustomFood(user.sub, id);
if (!success) {
throw new NotFoundException('自定义食物不存在');
}
return { success };
}
@Get(':id')
@ApiOperation({ summary: '根据ID获取食物详情' })
@ApiParam({ name: 'id', description: '食物ID', type: 'number' })
@ApiResponse({
status: 200,
description: '成功获取食物详情',
type: FoodItemDto,
})
@ApiResponse({
status: 404,
description: '食物不存在',
})
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async getFoodById(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: AccessTokenPayload
): Promise<FoodItemDto> {
const food = await this.foodLibraryService.getFoodById(id, user.sub);
if (!food) {
throw new NotFoundException('食物不存在');
}
return food;
}
@Post(':id/favorite')
@ApiOperation({ summary: '收藏食物' })
@ApiParam({ name: 'id', description: '食物ID', type: 'number' })
@ApiResponse({
status: 201,
description: '成功收藏食物',
})
@ApiResponse({
status: 404,
description: '食物不存在',
})
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async favoriteFood(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: AccessTokenPayload
): Promise<{ success: boolean }> {
const success = await this.foodLibraryService.favoriteFood(user.sub, id);
if (!success) {
throw new NotFoundException('食物不存在');
}
return { success };
}
@Delete(':id/favorite')
@ApiOperation({ summary: '取消收藏食物' })
@ApiParam({ name: 'id', description: '食物ID', type: 'number' })
@ApiResponse({
status: 200,
description: '成功取消收藏食物',
})
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async unfavoriteFood(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: AccessTokenPayload
): Promise<{ success: boolean }> {
const success = await this.foodLibraryService.unfavoriteFood(user.sub, id);
return { success };
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { FoodLibraryController } from './food-library.controller';
import { FoodLibraryService } from './food-library.service';
import { FoodCategory } from './models/food-category.model';
import { FoodLibrary } from './models/food-library.model';
import { UserCustomFood } from './models/user-custom-food.model';
import { UserFoodFavorite } from './models/user-food-favorite.model';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
SequelizeModule.forFeature([FoodCategory, FoodLibrary, UserCustomFood, UserFoodFavorite]),
UsersModule,
],
controllers: [FoodLibraryController],
providers: [FoodLibraryService],
exports: [FoodLibraryService],
})
export class FoodLibraryModule { }

View File

@@ -0,0 +1,351 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Op } from 'sequelize';
import { FoodCategory } from './models/food-category.model';
import { FoodLibrary } from './models/food-library.model';
import { UserCustomFood } from './models/user-custom-food.model';
import { UserFoodFavorite } from './models/user-food-favorite.model';
import { FoodCategoryDto, FoodItemDto, FoodLibraryResponseDto, CreateCustomFoodDto } from './dto/food-library.dto';
@Injectable()
export class FoodLibraryService {
constructor(
@InjectModel(FoodCategory)
private readonly foodCategoryModel: typeof FoodCategory,
@InjectModel(FoodLibrary)
private readonly foodLibraryModel: typeof FoodLibrary,
@InjectModel(UserCustomFood)
private readonly userCustomFoodModel: typeof UserCustomFood,
@InjectModel(UserFoodFavorite)
private readonly userFoodFavoriteModel: typeof UserFoodFavorite,
) { }
/**
* 将系统食物模型转换为DTO
*/
private mapFoodToDto(food: FoodLibrary, isFavorite: boolean = false): FoodItemDto {
return {
id: food.id,
name: food.name,
description: food.description,
caloriesPer100g: food.caloriesPer100g,
proteinPer100g: food.proteinPer100g,
carbohydratePer100g: food.carbohydratePer100g,
fatPer100g: food.fatPer100g,
fiberPer100g: food.fiberPer100g,
sugarPer100g: food.sugarPer100g,
sodiumPer100g: food.sodiumPer100g,
additionalNutrition: food.additionalNutrition,
isCommon: food.isCommon,
imageUrl: food.imageUrl,
sortOrder: food.sortOrder,
isCustom: false,
isFavorite,
};
}
/**
* 将用户自定义食物模型转换为DTO
*/
private mapCustomFoodToDto(food: UserCustomFood, isFavorite: boolean = false): FoodItemDto {
return {
id: food.id,
name: food.name,
description: food.description,
caloriesPer100g: food.caloriesPer100g,
proteinPer100g: food.proteinPer100g,
carbohydratePer100g: food.carbohydratePer100g,
fatPer100g: food.fatPer100g,
fiberPer100g: food.fiberPer100g,
sugarPer100g: food.sugarPer100g,
sodiumPer100g: food.sodiumPer100g,
additionalNutrition: food.additionalNutrition,
isCommon: false, // 用户自定义食物不会是常见食物
imageUrl: food.imageUrl,
sortOrder: food.sortOrder,
isCustom: true,
isFavorite,
};
}
/**
* 获取食物库列表,按分类组织
* 常见食物会被归类到"常见"分类中,用户自定义食物会被归类到"自定义"分类中
*/
async getFoodLibrary(userId?: string): Promise<FoodLibraryResponseDto> {
try {
// 获取用户收藏的食物ID
const favoriteIds = userId ? await this.getUserFavoriteIds(userId) : { systemIds: new Set<number>(), customIds: new Set<number>() };
// 分别获取所有数据
const [categories, allSystemFoods, commonFoods, userCustomFoods] = await Promise.all([
// 获取所有分类
this.foodCategoryModel.findAll({
order: [['sortOrder', 'ASC']],
}),
// 获取所有系统食物
this.foodLibraryModel.findAll({
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
}),
// 获取所有常见食物
this.foodLibraryModel.findAll({
where: { isCommon: true },
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
}),
// 获取用户自定义食物如果有用户ID
userId ? this.userCustomFoodModel.findAll({
where: { userId },
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
}) : Promise.resolve([]),
]);
// 将系统食物按分类分组
const systemFoodsByCategory = new Map<string, FoodLibrary[]>();
allSystemFoods.forEach((food: FoodLibrary) => {
const categoryKey = food.categoryKey;
if (!systemFoodsByCategory.has(categoryKey)) {
systemFoodsByCategory.set(categoryKey, []);
}
systemFoodsByCategory.get(categoryKey)!.push(food);
});
// 构建结果
const result: FoodCategoryDto[] = categories.map((category: FoodCategory) => {
let allFoods: FoodItemDto[] = [];
if (category.key === 'common') {
// 常见分类:使用常见食物(只包含系统食物)
allFoods = commonFoods.map((food: FoodLibrary) => this.mapFoodToDto(food, favoriteIds.systemIds.has(food.id)));
} else if (category.key === 'custom') {
// 自定义分类:只包含用户自定义食物
allFoods = userCustomFoods.map((food: UserCustomFood) => this.mapCustomFoodToDto(food, favoriteIds.customIds.has(food.id)));
} else {
// 其他分类:只包含系统食物
const systemFoods = systemFoodsByCategory.get(category.key) || [];
allFoods = systemFoods.map((food: FoodLibrary) => this.mapFoodToDto(food, favoriteIds.systemIds.has(food.id)));
}
return {
key: category.key,
name: category.name,
icon: category.icon,
sortOrder: category.sortOrder,
isSystem: category.isSystem,
foods: allFoods,
};
});
return { categories: result };
} catch (error) {
throw new Error(`Failed to get food library: ${error.message}`);
}
}
/**
* 根据关键词搜索食物(包含系统食物和用户自定义食物)
*/
async searchFoods(keyword: string, userId?: string): Promise<FoodItemDto[]> {
try {
if (!keyword || keyword.trim().length === 0) {
return [];
}
// 获取用户收藏的食物ID
const favoriteIds = userId ? await this.getUserFavoriteIds(userId) : { systemIds: new Set<number>(), customIds: new Set<number>() };
const [systemFoods, customFoods] = await Promise.all([
// 搜索系统食物
this.foodLibraryModel.findAll({
where: {
name: {
[Op.like]: `%${keyword.trim()}%`
}
},
order: [['isCommon', 'DESC'], ['name', 'ASC']],
limit: 25,
}),
// 搜索用户自定义食物如果有用户ID
userId ? this.userCustomFoodModel.findAll({
where: {
userId,
name: {
[Op.like]: `%${keyword.trim()}%`
}
},
order: [['name', 'ASC']],
limit: 25,
}) : Promise.resolve([]),
]);
// 合并结果,用户自定义食物优先显示
const allFoods: FoodItemDto[] = [
...customFoods.map((food: UserCustomFood) => this.mapCustomFoodToDto(food, favoriteIds.customIds.has(food.id))),
...systemFoods.map((food: FoodLibrary) => this.mapFoodToDto(food, favoriteIds.systemIds.has(food.id))),
];
return allFoods;
} catch (error) {
throw new Error(`Failed to search foods: ${error.message}`);
}
}
/**
* 根据ID获取食物详情支持系统食物和用户自定义食物
*/
async getFoodById(id: number, userId?: string): Promise<FoodItemDto | null> {
try {
if (!id || id <= 0) {
return null;
}
// 获取用户收藏的食物ID
const favoriteIds = userId ? await this.getUserFavoriteIds(userId) : { systemIds: new Set<number>(), customIds: new Set<number>() };
// 先尝试从系统食物中查找
const systemFood = await this.foodLibraryModel.findByPk(id);
if (systemFood) {
return this.mapFoodToDto(systemFood, favoriteIds.systemIds.has(id));
}
// 如果提供了用户ID则从用户自定义食物中查找
if (userId) {
const customFood = await this.userCustomFoodModel.findOne({
where: { id, userId }
});
if (customFood) {
return this.mapCustomFoodToDto(customFood, favoriteIds.customIds.has(id));
}
}
return null;
} catch (error) {
throw new Error(`Failed to get food by id: ${error.message}`);
}
}
/**
* 创建用户自定义食物
*/
async createCustomFood(userId: string, createCustomFoodDto: CreateCustomFoodDto): Promise<FoodItemDto> {
try {
// 获取用户自定义食物的最大排序值
const maxSortOrder = await this.userCustomFoodModel.max('sortOrder', {
where: { userId }
}) as number || 0;
// 创建用户自定义食物
const customFood = await this.userCustomFoodModel.create({
userId,
name: createCustomFoodDto.name,
description: createCustomFoodDto.description,
caloriesPer100g: createCustomFoodDto.caloriesPer100g,
proteinPer100g: createCustomFoodDto.proteinPer100g,
carbohydratePer100g: createCustomFoodDto.carbohydratePer100g,
fatPer100g: createCustomFoodDto.fatPer100g,
fiberPer100g: createCustomFoodDto.fiberPer100g,
sugarPer100g: createCustomFoodDto.sugarPer100g,
sodiumPer100g: createCustomFoodDto.sodiumPer100g,
additionalNutrition: createCustomFoodDto.additionalNutrition,
imageUrl: createCustomFoodDto.imageUrl,
sortOrder: maxSortOrder + 1,
});
return this.mapCustomFoodToDto(customFood, false); // 新创建的食物默认未收藏
} catch (error) {
throw new Error(`Failed to create custom food: ${error.message}`);
}
}
/**
* 删除用户自定义食物
*/
async deleteCustomFood(userId: string, foodId: number): Promise<boolean> {
try {
const result = await this.userCustomFoodModel.destroy({
where: {
id: foodId,
userId
}
});
return result > 0;
} catch (error) {
throw new Error(`Failed to delete custom food: ${error.message}`);
}
}
/**
* 获取用户收藏的食物ID列表
*/
private async getUserFavoriteIds(userId: string): Promise<{ systemIds: Set<number>, customIds: Set<number> }> {
try {
const favorites = await this.userFoodFavoriteModel.findAll({
where: { userId },
attributes: ['foodId', 'foodType']
});
const systemIds = new Set<number>();
const customIds = new Set<number>();
favorites.forEach(fav => {
if (fav.foodType === 'system') {
systemIds.add(fav.foodId);
} else {
customIds.add(fav.foodId);
}
});
return { systemIds, customIds };
} catch (error) {
throw new Error(`Failed to get user favorite ids: ${error.message}`);
}
}
/**
* 收藏食物
*/
async favoriteFood(userId: string, foodId: number): Promise<boolean> {
try {
// 首先检查食物是否存在(系统食物或用户自定义食物)
const [systemFood, customFood] = await Promise.all([
this.foodLibraryModel.findByPk(foodId),
this.userCustomFoodModel.findOne({ where: { id: foodId, userId } })
]);
if (!systemFood && !customFood) {
return false;
}
const foodType = systemFood ? 'system' : 'custom';
// 使用 upsert 来处理重复收藏
await this.userFoodFavoriteModel.upsert({
userId,
foodId,
foodType
});
return true;
} catch (error) {
throw new Error(`Failed to favorite food: ${error.message}`);
}
}
/**
* 取消收藏食物
*/
async unfavoriteFood(userId: string, foodId: number): Promise<boolean> {
try {
const result = await this.userFoodFavoriteModel.destroy({
where: {
userId,
foodId
}
});
return result > 0;
} catch (error) {
throw new Error(`Failed to unfavorite food: ${error.message}`);
}
}
}

View File

@@ -0,0 +1,54 @@
import { Column, DataType, HasMany, Model, Table } from 'sequelize-typescript';
import { FoodLibrary } from './food-library.model';
@Table({
tableName: 't_food_categories',
underscored: true,
})
export class FoodCategory extends Model {
@Column({
type: DataType.STRING,
primaryKey: true,
comment: '分类唯一键(英文/下划线)',
})
declare key: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '分类中文名称',
})
declare name: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '分类图标',
})
declare icon: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序(升序)',
})
declare sortOrder: number;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '是否系统分类true系统false用户自定义',
})
declare isSystem: boolean;
@HasMany(() => FoodLibrary, { foreignKey: 'categoryKey', sourceKey: 'key' })
declare foods: FoodLibrary[];
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare createdAt: Date;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare updatedAt: Date;
}

View File

@@ -0,0 +1,126 @@
import { BelongsTo, Column, DataType, ForeignKey, Model, Table } from 'sequelize-typescript';
import { FoodCategory } from './food-category.model';
@Table({
tableName: 't_food_library',
underscored: true,
})
export class FoodLibrary extends Model {
@Column({
type: DataType.BIGINT,
primaryKey: true,
autoIncrement: true,
comment: '主键ID',
})
declare id: number;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '食物名称',
})
declare name: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '食物描述',
})
declare description: string;
@ForeignKey(() => FoodCategory)
@Column({
type: DataType.STRING,
allowNull: false,
comment: '分类键',
})
declare categoryKey: string;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克热量卡路里',
})
declare caloriesPer100g: number;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克蛋白质含量',
})
declare proteinPer100g: number;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克碳水化合物含量',
})
declare carbohydratePer100g: number;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克脂肪含量',
})
declare fatPer100g: number;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克膳食纤维含量',
})
declare fiberPer100g: number;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克糖分含量',
})
declare sugarPer100g: number;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克钠含量毫克',
})
declare sodiumPer100g: number;
@Column({
type: DataType.JSON,
allowNull: true,
comment: '其他营养信息(维生素、矿物质等)',
})
declare additionalNutrition: Record<string, any>;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否常见食物true常见false不常见',
})
declare isCommon: boolean;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '食物图片URL',
})
declare imageUrl: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序(分类内)',
})
declare sortOrder: number;
@BelongsTo(() => FoodCategory, { foreignKey: 'categoryKey', targetKey: 'key' })
declare category: FoodCategory;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare createdAt: Date;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare updatedAt: Date;
}

View File

@@ -0,0 +1,114 @@
import { Column, DataType, Model, Table } from 'sequelize-typescript';
@Table({
tableName: 't_user_custom_foods',
underscored: true,
})
export class UserCustomFood extends Model {
@Column({
type: DataType.BIGINT,
primaryKey: true,
autoIncrement: true,
comment: '主键ID',
})
declare id: number;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '食物名称',
})
declare name: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '食物描述',
})
declare description: string;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克热量卡路里',
})
declare caloriesPer100g: number;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克蛋白质含量',
})
declare proteinPer100g: number;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克碳水化合物含量',
})
declare carbohydratePer100g: number;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克脂肪含量',
})
declare fatPer100g: number;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克膳食纤维含量',
})
declare fiberPer100g: number;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克糖分含量',
})
declare sugarPer100g: number;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '每100克钠含量毫克',
})
declare sodiumPer100g: number;
@Column({
type: DataType.JSON,
allowNull: true,
comment: '其他营养信息(维生素、矿物质等)',
})
declare additionalNutrition: Record<string, any>;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '食物图片URL',
})
declare imageUrl: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序(分类内)',
})
declare sortOrder: number;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare createdAt: Date;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare updatedAt: Date;
}

View File

@@ -0,0 +1,43 @@
import { Column, DataType, Model, Table } from 'sequelize-typescript';
@Table({
tableName: 't_user_food_favorites',
underscored: true,
})
export class UserFoodFavorite extends Model {
@Column({
type: DataType.BIGINT,
primaryKey: true,
autoIncrement: true,
comment: '主键ID',
})
declare id: number;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.BIGINT,
allowNull: false,
comment: '食物ID',
})
declare foodId: number;
@Column({
type: DataType.ENUM('system', 'custom'),
allowNull: false,
defaultValue: 'system',
comment: '食物类型system: 系统食物, custom: 用户自定义食物)',
})
declare foodType: 'system' | 'custom';
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare createdAt: Date;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare updatedAt: Date;
}

View File

@@ -0,0 +1,104 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, IsInt, IsDateString, IsBoolean, Min, Max, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { GoalRepeatType } from '../models/goal.model';
export class CustomRepeatRuleDto {
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(0, { each: true })
@Max(6, { each: true })
weekdays?: number[]; // 0-6 表示周日到周六
@IsOptional()
@IsInt()
@Min(1)
@Max(31)
dayOfMonth?: number; // 每月第几天
@IsOptional()
@IsInt()
@Min(1)
@Max(12)
monthOfYear?: number; // 每年第几月
}
export class ReminderSettingsDto {
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(0, { each: true })
@Max(6, { each: true })
weekdays?: number[]; // 提醒的星期几
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
export class CreateGoalDto {
@IsString()
@IsNotEmpty({ message: '目标标题不能为空' })
title: string;
@IsOptional()
@IsString()
description?: string;
@IsEnum(GoalRepeatType, { message: '重复周期类型无效' })
repeatType: GoalRepeatType;
@IsInt()
@Min(1, { message: '频率必须大于0' })
@Max(100, { message: '频率不能超过100' })
frequency: number;
@IsOptional()
@ValidateNested()
@Type(() => CustomRepeatRuleDto)
customRepeatRule?: CustomRepeatRuleDto;
@IsDateString({}, { message: '开始日期格式无效' })
@IsOptional()
startDate?: string;
@IsOptional()
@IsDateString({}, { message: '结束日期格式无效' })
endDate?: string;
@IsOptional()
@IsInt()
startTime: number;
@IsOptional()
@IsInt()
endTime: number;
@IsOptional()
@IsInt()
@Min(1, { message: '目标总次数必须大于0' })
targetCount?: number;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsInt()
@Min(0, { message: '优先级不能小于0' })
@Max(10, { message: '优先级不能超过10' })
priority?: number;
@IsOptional()
@IsBoolean()
hasReminder?: boolean;
@IsOptional()
@IsString()
reminderTime?: string; // HH:mm 格式
@IsOptional()
@ValidateNested()
@Type(() => ReminderSettingsDto)
reminderSettings?: ReminderSettingsDto;
}

View File

@@ -0,0 +1,33 @@
import { IsString, IsOptional, IsInt, IsDateString, Min, IsUUID } from 'class-validator';
export class CreateGoalCompletionDto {
@IsUUID()
goalId: string;
@IsOptional()
@IsDateString({}, { message: '完成日期格式无效' })
completedAt?: string;
@IsOptional()
@IsInt()
@Min(1, { message: '完成次数必须大于0' })
completionCount?: number;
@IsOptional()
@IsString()
notes?: string;
}
export class GoalCompletionQueryDto {
@IsOptional()
@IsUUID()
goalId?: string;
@IsOptional()
@IsDateString({}, { message: '开始日期格式无效' })
startDate?: string;
@IsOptional()
@IsDateString({}, { message: '结束日期格式无效' })
endDate?: string;
}

View File

@@ -0,0 +1,46 @@
import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from 'class-validator';
import { Transform } from 'class-transformer';
import { GoalStatus, GoalRepeatType } from '../models/goal.model';
export class GoalQueryDto {
@IsOptional()
@IsInt()
@Min(1)
@Transform(({ value }) => parseInt(value))
page?: number = 1;
@IsOptional()
@IsInt()
@Min(1)
@Max(500)
@Transform(({ value }) => parseInt(value))
pageSize?: number = 50;
@IsOptional()
@IsEnum(GoalStatus)
status?: GoalStatus;
@IsOptional()
@IsEnum(GoalRepeatType)
repeatType?: GoalRepeatType;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
startDate?: string; // 开始日期范围
@IsOptional()
@IsString()
endDate?: string; // 结束日期范围
@IsOptional()
@IsString()
sortBy?: 'createdAt' | 'updatedAt' | 'priority' | 'title' | 'startDate' = 'createdAt';
@IsOptional()
@IsString()
sortOrder?: 'asc' | 'desc' = 'desc';
}

View File

@@ -0,0 +1,120 @@
import { IsString, IsOptional, IsInt, IsDateString, Min, IsUUID, IsEnum, Max } from 'class-validator';
import { TaskStatus } from '../models/goal-task.model';
export class CreateGoalTaskDto {
@IsUUID()
goalId: string;
@IsString()
title: string;
@IsOptional()
@IsString()
description?: string;
@IsDateString({}, { message: '开始日期格式无效' })
startDate: string;
@IsDateString({}, { message: '结束日期格式无效' })
endDate: string;
@IsInt()
@Min(1, { message: '目标次数必须大于0' })
targetCount: number;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
metadata?: any;
}
export class UpdateGoalTaskDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsDateString({}, { message: '开始日期格式无效' })
startDate?: string;
@IsOptional()
@IsDateString({}, { message: '结束日期格式无效' })
endDate?: string;
@IsOptional()
@IsInt()
@Min(1, { message: '目标次数必须大于0' })
targetCount?: number;
@IsOptional()
@IsEnum(TaskStatus, { message: '任务状态无效' })
status?: TaskStatus;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
metadata?: any;
}
export class GoalTaskQueryDto {
@IsOptional()
@IsUUID()
goalId?: string;
@IsOptional()
@IsEnum(TaskStatus, { message: '任务状态无效' })
status?: TaskStatus;
@IsOptional()
@IsDateString({}, { message: '开始日期格式无效' })
startDate?: string;
@IsOptional()
@IsDateString({}, { message: '结束日期格式无效' })
endDate?: string;
@IsOptional()
@IsInt()
page?: number = 1;
@IsOptional()
@IsInt()
pageSize?: number = 200;
}
export class CompleteGoalTaskDto {
@IsOptional()
@IsInt()
@Min(1, { message: '完成次数必须大于0' })
count?: number = 1;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsDateString({}, { message: '完成时间格式无效' })
completedAt?: string;
}
export class GoalTaskStatsDto {
total: number;
pending: number;
inProgress: number;
completed: number;
overdue: number;
skipped: number;
totalProgress: number; // 总体完成进度
todayTasks: number;
weekTasks: number;
monthTasks: number;
}

View File

@@ -0,0 +1,10 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateGoalDto } from './create-goal.dto';
import { IsOptional, IsEnum } from 'class-validator';
import { GoalStatus } from '../models/goal.model';
export class UpdateGoalDto extends PartialType(CreateGoalDto) {
@IsOptional()
@IsEnum(GoalStatus, { message: '目标状态无效' })
status?: GoalStatus;
}

View File

@@ -0,0 +1,299 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { GoalsService } from './goals.service';
import { CreateGoalDto } from './dto/create-goal.dto';
import { UpdateGoalDto } from './dto/update-goal.dto';
import { GoalQueryDto } from './dto/goal-query.dto';
import { CreateGoalCompletionDto } from './dto/goal-completion.dto';
import { GoalTaskService } from './services/goal-task.service';
import { GoalTaskQueryDto, CompleteGoalTaskDto, UpdateGoalTaskDto } from './dto/goal-task.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { BaseResponseDto, ResponseCode } from '../base.dto';
import { GoalStatus } from './models/goal.model';
import { CurrentUser } from 'src/common/decorators/current-user.decorator';
import { AccessTokenPayload } from 'src/users/services/apple-auth.service';
@Controller('goals')
@UseGuards(JwtAuthGuard)
export class GoalsController {
constructor(
private readonly goalsService: GoalsService,
private readonly goalTaskService: GoalTaskService,
) { }
/**
* 创建目标
*/
@Post()
async createGoal(
@Body() createGoalDto: CreateGoalDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<any>> {
const goal = await this.goalsService.createGoal(user.sub, createGoalDto);
return {
code: ResponseCode.SUCCESS,
message: '目标创建成功',
data: goal,
};
}
/**
* 获取目标列表
*/
@Get()
async getGoals(
@Query() query: GoalQueryDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<any>> {
const result = await this.goalsService.getGoals(user.sub, query);
return {
code: ResponseCode.SUCCESS,
message: '获取目标列表成功',
data: result,
};
}
/**
* 更新目标
*/
@Put(':id')
async updateGoal(
@Param('id') id: string,
@Body() updateGoalDto: UpdateGoalDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<any>> {
const goal = await this.goalsService.updateGoal(user.sub, id, updateGoalDto);
return {
code: ResponseCode.SUCCESS,
message: '目标更新成功',
data: goal,
};
}
/**
* 删除目标
*/
@Delete(':id')
@HttpCode(HttpStatus.OK)
async deleteGoal(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<boolean>> {
const result = await this.goalsService.deleteGoal(user.sub, id);
return {
code: ResponseCode.SUCCESS,
message: '目标删除成功',
data: result,
};
}
/**
* 记录目标完成
*/
@Post(':id/complete')
async completeGoal(
@Param('id') id: string,
@Body() createCompletionDto: CreateGoalCompletionDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<any>> {
// 确保完成记录的目标ID与路径参数一致
createCompletionDto.goalId = id;
const completion = await this.goalsService.completeGoal(user.sub, createCompletionDto);
return {
code: ResponseCode.SUCCESS,
message: '目标完成记录成功',
data: completion,
};
}
/**
* 获取目标完成记录
*/
@Get(':id/completions')
async getGoalCompletions(
@Param('id') id: string,
@Query() query: any,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<any>> {
const result = await this.goalsService.getGoalCompletions(user.sub, id, query);
return {
code: ResponseCode.SUCCESS,
message: '获取目标完成记录成功',
data: result,
};
}
/**
* 获取目标统计信息
*/
@Get('stats/overview')
async getGoalStats(
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<any>> {
const stats = await this.goalsService.getGoalStats(user.sub);
return {
code: ResponseCode.SUCCESS,
message: '获取目标统计成功',
data: stats,
};
}
/**
* 批量操作目标
*/
@Post('batch')
async batchUpdateGoals(
@Body() body: {
goalIds: string[];
action: 'pause' | 'resume' | 'complete' | 'delete';
data?: any;
},
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<any>> {
const { goalIds, action, data } = body;
const results: { goalId: string; success: boolean; error?: string }[] = [];
for (const goalId of goalIds) {
try {
switch (action) {
case 'pause':
await this.goalsService.updateGoal(user.sub, goalId, { status: GoalStatus.PAUSED });
break;
case 'resume':
await this.goalsService.updateGoal(user.sub, goalId, { status: GoalStatus.ACTIVE });
break;
case 'complete':
await this.goalsService.updateGoal(user.sub, goalId, { status: GoalStatus.COMPLETED });
break;
case 'delete':
await this.goalsService.deleteGoal(user.sub, goalId);
break;
}
results.push({ goalId, success: true });
} catch (error) {
results.push({ goalId, success: false, error: error.message });
}
}
return {
code: ResponseCode.SUCCESS,
message: '批量操作完成',
data: results,
};
}
// ==================== 子任务相关API ====================
/**
* 获取任务列表
*/
@Get('tasks')
async getTasks(
@Query() query: GoalTaskQueryDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<any>> {
const result = await this.goalTaskService.getTasks(user.sub, query);
return {
code: ResponseCode.SUCCESS,
message: '获取任务列表成功',
data: result,
};
}
/**
* 完成任务
*/
@Post('tasks/:taskId/complete')
async completeTask(
@Param('taskId') taskId: string,
@Body() completeDto: CompleteGoalTaskDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<any>> {
const task = await this.goalTaskService.completeTask(user.sub, taskId, completeDto);
return {
code: ResponseCode.SUCCESS,
message: '任务完成成功',
data: task,
};
}
/**
* 更新任务
*/
@Put('tasks/:taskId')
async updateTask(
@Param('taskId') taskId: string,
@Body() updateDto: UpdateGoalTaskDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<any>> {
const task = await this.goalTaskService.updateTask(user.sub, taskId, updateDto);
return {
code: ResponseCode.SUCCESS,
message: '任务更新成功',
data: task,
};
}
/**
* 跳过任务
*/
@Post('tasks/:taskId/skip')
async skipTask(
@Param('taskId') taskId: string,
@Body() body: { reason?: string },
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<any>> {
const task = await this.goalTaskService.skipTask(user.sub, taskId, body.reason);
return {
code: ResponseCode.SUCCESS,
message: '任务跳过成功',
data: task,
};
}
/**
* 获取任务统计
*/
@Get('tasks/stats/overview')
async getTaskStats(
@CurrentUser() user: AccessTokenPayload,
@Query('goalId') goalId?: string,
): Promise<BaseResponseDto<any>> {
const stats = await this.goalTaskService.getTaskStats(user.sub, goalId);
return {
code: ResponseCode.SUCCESS,
message: '获取任务统计成功',
data: stats,
};
}
/**
* 获取特定目标的任务列表
*/
@Get(':id/tasks')
async getGoalTasks(
@Param('id') goalId: string,
@Query() query: Omit<GoalTaskQueryDto, 'goalId'>,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<any>> {
const taskQuery = { ...query, goalId };
const result = await this.goalTaskService.getTasks(user.sub, taskQuery);
return {
code: ResponseCode.SUCCESS,
message: '获取目标任务列表成功',
data: result,
};
}
}

20
src/goals/goals.module.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { GoalsController } from './goals.controller';
import { GoalsService } from './goals.service';
import { GoalTaskService } from './services/goal-task.service';
import { Goal } from './models/goal.model';
import { GoalCompletion } from './models/goal-completion.model';
import { GoalTask } from './models/goal-task.model';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
SequelizeModule.forFeature([Goal, GoalCompletion, GoalTask]),
UsersModule,
],
controllers: [GoalsController],
providers: [GoalsService, GoalTaskService],
exports: [GoalsService, GoalTaskService],
})
export class GoalsModule { }

441
src/goals/goals.service.ts Normal file
View File

@@ -0,0 +1,441 @@
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectModel, InjectConnection } from '@nestjs/sequelize';
import { Op, WhereOptions, Order, Transaction } from 'sequelize';
import { Sequelize } from 'sequelize-typescript';
import { Goal, GoalStatus, GoalRepeatType } from './models/goal.model';
import { GoalCompletion } from './models/goal-completion.model';
import { GoalTask } from './models/goal-task.model';
import { CreateGoalDto } from './dto/create-goal.dto';
import { UpdateGoalDto } from './dto/update-goal.dto';
import { GoalQueryDto } from './dto/goal-query.dto';
import { CreateGoalCompletionDto } from './dto/goal-completion.dto';
import { GoalTaskService } from './services/goal-task.service';
import * as dayjs from 'dayjs';
@Injectable()
export class GoalsService {
private readonly logger = new Logger(GoalsService.name);
constructor(
@InjectModel(Goal)
private readonly goalModel: typeof Goal,
@InjectModel(GoalCompletion)
private readonly goalCompletionModel: typeof GoalCompletion,
@InjectModel(GoalTask)
private readonly goalTaskModel: typeof GoalTask,
@InjectConnection()
private readonly sequelize: Sequelize,
private readonly goalTaskService: GoalTaskService,
) { }
/**
* 创建目标
*/
async createGoal(userId: string, createGoalDto: CreateGoalDto): Promise<Goal> {
try {
this.logger.log(`createGoal: ${JSON.stringify(createGoalDto, null, 2)}`);
// 验证自定义重复规则
if (createGoalDto.repeatType === GoalRepeatType.CUSTOM && !createGoalDto.customRepeatRule) {
throw new BadRequestException('自定义重复类型必须提供自定义重复规则');
}
// 验证日期逻辑
if (createGoalDto.endDate && dayjs(createGoalDto.endDate).isBefore(createGoalDto.startDate)) {
throw new BadRequestException('结束日期不能早于开始日期');
}
const goal = await this.goalModel.create({
userId,
...createGoalDto,
startDate: createGoalDto.startDate ? new Date(createGoalDto.startDate) : undefined,
endDate: createGoalDto.endDate ? new Date(createGoalDto.endDate) : undefined,
startTime: createGoalDto.startTime ? createGoalDto.startTime : undefined,
endTime: createGoalDto.endTime ? createGoalDto.endTime : undefined,
});
this.logger.log(`用户 ${userId} 创建了目标: ${goal.title}`);
return goal;
} catch (error) {
this.logger.error(`创建目标失败: ${error.message}`);
throw error;
}
}
/**
* 获取用户的目标列表
*/
async getGoals(userId: string, query: GoalQueryDto) {
try {
// 惰性生成任务
await this.goalTaskService.generateTasksLazily(userId);
const { page = 1, pageSize = 20, status, repeatType, category, startDate, endDate, sortBy = 'createdAt', sortOrder = 'desc' } = query;
const offset = (page - 1) * pageSize;
// 构建查询条件
const where: WhereOptions = {
userId,
deleted: false,
};
if (status) {
where.status = status;
}
if (repeatType) {
where.repeatType = repeatType;
}
if (category) {
where.category = category;
}
if (startDate || endDate) {
where.startDate = {};
if (startDate) {
where.startDate[Op.gte] = new Date(startDate);
}
if (endDate) {
where.startDate[Op.lte] = new Date(endDate);
}
}
this.logger.log(`查询条件: ${JSON.stringify(where)}`);
// 构建排序条件
const order: Order = [[sortBy, sortOrder.toUpperCase()]];
const { rows: goals, count } = await this.goalModel.findAndCountAll({
where,
order,
offset,
limit: pageSize,
include: [
{
model: GoalCompletion,
as: 'completions',
where: { deleted: false },
required: false,
},
{
model: GoalTask,
as: 'tasks',
where: { deleted: false },
required: false,
limit: 5, // 只显示最近5个任务
order: [['startDate', 'DESC']],
},
],
});
return {
page,
pageSize,
total: count,
list: goals.map(goal => this.formatGoalResponse(goal)),
};
} catch (error) {
this.logger.error(`获取目标列表失败: ${error.message}`);
throw error;
}
}
/**
* 更新目标
*/
async updateGoal(userId: string, goalId: string, updateGoalDto: UpdateGoalDto): Promise<Goal> {
try {
this.logger.log(`updateGoal updateGoalDto: ${JSON.stringify(updateGoalDto, null, 2)}`);
const goal = await this.goalModel.findOne({
where: { id: goalId, userId, deleted: false },
});
if (!goal) {
throw new NotFoundException('目标不存在');
}
// 验证日期逻辑
if (updateGoalDto.endDate && updateGoalDto.startDate) {
if (dayjs(updateGoalDto.endDate).isBefore(updateGoalDto.startDate)) {
throw new BadRequestException('结束日期不能早于开始日期');
}
}
// 如果目标已完成,不允许修改
if (goal.status === GoalStatus.COMPLETED && updateGoalDto.status !== GoalStatus.COMPLETED) {
throw new BadRequestException('已完成的目标不能修改状态');
}
await goal.update({
...updateGoalDto,
endDate: updateGoalDto.endDate ? new Date(updateGoalDto.endDate) : undefined,
});
this.logger.log(`用户 ${userId} 更新了目标: ${goal.title}`);
return this.formatGoalResponse(goal);
} catch (error) {
this.logger.error(`更新目标失败: ${error.message}`);
throw error;
}
}
/**
* 删除目标
*/
async deleteGoal(userId: string, goalId: string): Promise<boolean> {
const transaction = await this.sequelize.transaction();
try {
// 验证目标存在
const goal = await this.goalModel.findOne({
where: { id: goalId, userId, deleted: false },
transaction,
});
if (!goal) {
await transaction.rollback();
throw new NotFoundException('目标不存在');
}
// 使用事务删除目标及其相关数据
await Promise.all([
// 软删除目标本身
this.goalModel.update(
{ deleted: true },
{
where: { id: goalId, userId, deleted: false },
transaction
}
),
// 软删除目标完成记录
this.goalCompletionModel.update(
{ deleted: true },
{
where: { goalId, userId, deleted: false },
transaction
}
),
// 软删除与目标关联的任务
this.goalTaskModel.update(
{ deleted: true },
{
where: { goalId, userId, deleted: false },
transaction
}
),
]);
// 提交事务
await transaction.commit();
this.logger.log(`用户 ${userId} 删除了目标: ${goal.title}`);
return true;
} catch (error) {
// 回滚事务
await transaction.rollback();
this.logger.error(`删除目标失败: ${error.message}`);
throw error;
}
}
/**
* 记录目标完成
*/
async completeGoal(userId: string, createCompletionDto: CreateGoalCompletionDto): Promise<GoalCompletion> {
try {
const goal = await this.goalModel.findOne({
where: { id: createCompletionDto.goalId, userId, deleted: false },
});
if (!goal) {
throw new NotFoundException('目标不存在');
}
if (goal.status !== GoalStatus.ACTIVE) {
throw new BadRequestException('只有激活状态的目标才能记录完成');
}
const completionCount = createCompletionDto.completionCount || 1;
const completedAt = createCompletionDto.completedAt ? new Date(createCompletionDto.completedAt) : new Date();
// 创建完成记录
const completion = await this.goalCompletionModel.create({
goalId: createCompletionDto.goalId,
userId,
completedAt,
completionCount,
notes: createCompletionDto.notes,
});
// 更新目标的完成次数
const newCompletedCount = goal.completedCount + completionCount;
await goal.update({ completedCount: newCompletedCount });
// 检查是否达到目标总次数
if (goal.targetCount && newCompletedCount >= goal.targetCount) {
await goal.update({ status: GoalStatus.COMPLETED });
}
this.logger.log(`用户 ${userId} 完成了目标: ${goal.title}`);
return completion;
} catch (error) {
this.logger.error(`记录目标完成失败: ${error.message}`);
throw error;
}
}
/**
* 获取目标完成记录
*/
async getGoalCompletions(userId: string, goalId: string, query: any = {}) {
try {
const { page = 1, pageSize = 20, startDate, endDate } = query;
const offset = (page - 1) * pageSize;
// 验证目标存在
const goal = await this.goalModel.findOne({
where: { id: goalId, userId, deleted: false },
});
if (!goal) {
throw new NotFoundException('目标不存在');
}
// 构建查询条件
const where: WhereOptions = {
goalId,
userId,
deleted: false,
};
if (startDate || endDate) {
where.completedAt = {};
if (startDate) {
where.completedAt[Op.gte] = new Date(startDate);
}
if (endDate) {
where.completedAt[Op.lte] = new Date(endDate);
}
}
const { rows: completions, count } = await this.goalCompletionModel.findAndCountAll({
where,
order: [['completedAt', 'DESC']],
offset,
limit: pageSize,
include: [
{
model: Goal,
as: 'goal',
attributes: ['id', 'title', 'repeatType', 'frequency'],
},
],
});
return {
page,
pageSize,
total: count,
items: completions,
};
} catch (error) {
this.logger.error(`获取目标完成记录失败: ${error.message}`);
throw error;
}
}
/**
* 获取目标统计信息
*/
async getGoalStats(userId: string) {
try {
const goals = await this.goalModel.findAll({
where: { userId, deleted: false },
include: [
{
model: GoalCompletion,
as: 'completions',
where: { deleted: false },
required: false,
},
],
});
const stats = {
total: goals.length,
active: goals.filter(g => g.status === GoalStatus.ACTIVE).length,
completed: goals.filter(g => g.status === GoalStatus.COMPLETED).length,
paused: goals.filter(g => g.status === GoalStatus.PAUSED).length,
cancelled: goals.filter(g => g.status === GoalStatus.CANCELLED).length,
byCategory: {},
byRepeatType: {},
totalCompletions: 0,
thisWeekCompletions: 0,
thisMonthCompletions: 0,
};
const now = dayjs();
const weekStart = now.startOf('week');
const monthStart = now.startOf('month');
goals.forEach(goal => {
// 按分类统计
if (goal.category) {
stats.byCategory[goal.category] = (stats.byCategory[goal.category] || 0) + 1;
}
// 按重复类型统计
stats.byRepeatType[goal.repeatType] = (stats.byRepeatType[goal.repeatType] || 0) + 1;
// 统计完成次数
stats.totalCompletions += goal.completedCount;
// 统计本周和本月的完成次数
goal.completions?.forEach(completion => {
const completionDate = dayjs(completion.completedAt);
if (completionDate.isAfter(weekStart)) {
stats.thisWeekCompletions += completion.completionCount;
}
if (completionDate.isAfter(monthStart)) {
stats.thisMonthCompletions += completion.completionCount;
}
});
});
return stats;
} catch (error) {
this.logger.error(`获取目标统计失败: ${error.message}`);
throw error;
}
}
/**
* 格式化目标响应
*/
private formatGoalResponse(goal: Goal) {
const goalData = goal.toJSON();
// 计算进度百分比
if (goalData.targetCount) {
goalData.progressPercentage = Math.min(100, Math.round((goalData.completedCount / goalData.targetCount) * 100));
} else {
goalData.progressPercentage = 0;
}
// 计算剩余天数
if (goalData.endDate) {
const endDate = dayjs(goalData.endDate);
const now = dayjs();
goalData.daysRemaining = Math.max(0, endDate.diff(now, 'day'));
} else {
goalData.daysRemaining = null;
}
return goalData;
}
}

View File

@@ -0,0 +1,81 @@
import { Column, DataType, Model, Table, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { Goal } from './goal.model';
@Table({
tableName: 't_goal_completions',
underscored: true,
})
export class GoalCompletion extends Model {
@Column({
type: DataType.CHAR(36),
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@ForeignKey(() => Goal)
@Column({
type: DataType.CHAR(36),
allowNull: false,
comment: '目标ID',
})
declare goalId: string;
@BelongsTo(() => Goal)
declare goal: Goal;
@Column({
type: DataType.STRING(255),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.DATE,
allowNull: false,
comment: '完成日期',
})
declare completedAt: Date;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 1,
comment: '完成次数',
})
declare completionCount: number;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '完成备注',
})
declare notes: string;
@Column({
type: DataType.JSON,
allowNull: true,
comment: '完成时的额外数据',
})
declare metadata: any;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
@Column({
type: DataType.BOOLEAN,
defaultValue: false,
comment: '是否删除',
})
declare deleted: boolean;
}

View File

@@ -0,0 +1,166 @@
import { Column, DataType, Model, Table, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { Goal } from './goal.model';
export enum TaskStatus {
PENDING = 'pending',
IN_PROGRESS = 'in_progress',
COMPLETED = 'completed',
OVERDUE = 'overdue',
SKIPPED = 'skipped'
}
@Table({
tableName: 't_goal_tasks',
underscored: true,
})
export class GoalTask extends Model {
@Column({
type: DataType.CHAR(36),
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@ForeignKey(() => Goal)
@Column({
type: DataType.CHAR(36),
allowNull: false,
comment: '目标ID',
})
declare goalId: string;
@BelongsTo(() => Goal)
declare goal: Goal;
@Column({
type: DataType.STRING(255),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING(255),
allowNull: false,
comment: '任务标题',
})
declare title: string;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '任务描述',
})
declare description: string;
@Column({
type: DataType.DATEONLY,
allowNull: false,
comment: '任务开始日期',
})
declare startDate: Date;
@Column({
type: DataType.DATEONLY,
allowNull: false,
comment: '任务结束日期',
})
declare endDate: Date;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 1,
comment: '任务目标次数如喝水8次',
})
declare targetCount: number;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '任务当前完成次数',
})
declare currentCount: number;
@Column({
type: DataType.ENUM('pending', 'in_progress', 'completed', 'overdue', 'skipped'),
allowNull: false,
defaultValue: TaskStatus.PENDING,
comment: '任务状态',
})
declare status: TaskStatus;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '完成进度百分比 (0-100)',
})
declare progressPercentage: number;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '任务完成时间',
})
declare completedAt: Date;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '任务备注',
})
declare notes: string;
@Column({
type: DataType.JSON,
allowNull: true,
comment: '任务额外数据',
})
declare metadata: any;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
@Column({
type: DataType.BOOLEAN,
defaultValue: false,
comment: '是否删除',
})
declare deleted: boolean;
// 计算完成进度
updateProgress(): void {
if (this.targetCount > 0) {
this.progressPercentage = Math.min(100, Math.round((this.currentCount / this.targetCount) * 100));
// 更新状态
if (this.currentCount >= this.targetCount) {
this.status = TaskStatus.COMPLETED;
this.completedAt = new Date();
} else if (this.currentCount > 0) {
this.status = TaskStatus.IN_PROGRESS;
}
}
}
// 检查是否过期
checkOverdue(): void {
const now = new Date();
const endDate = new Date(this.endDate);
if (now > endDate && this.status !== TaskStatus.COMPLETED && this.status !== TaskStatus.SKIPPED) {
this.status = TaskStatus.OVERDUE;
}
}
}

View File

@@ -0,0 +1,189 @@
import { Column, DataType, Model, Table, HasMany } from 'sequelize-typescript';
import { GoalCompletion } from './goal-completion.model';
import { GoalTask } from './goal-task.model';
export enum GoalRepeatType {
DAILY = 'daily',
WEEKLY = 'weekly',
MONTHLY = 'monthly',
CUSTOM = 'custom'
}
export enum GoalStatus {
ACTIVE = 'active',
PAUSED = 'paused',
COMPLETED = 'completed',
CANCELLED = 'cancelled'
}
@Table({
tableName: 't_goals',
underscored: true,
})
export class Goal extends Model {
@Column({
type: DataType.CHAR(36),
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@Column({
type: DataType.STRING(255),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING(255),
allowNull: false,
comment: '目标标题',
})
declare title: string;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '目标描述',
})
declare description: string;
@Column({
type: DataType.ENUM('daily', 'weekly', 'monthly', 'custom'),
allowNull: false,
defaultValue: GoalRepeatType.DAILY,
comment: '重复周期类型',
})
declare repeatType: GoalRepeatType;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 1,
comment: '频率(每天/每周/每月多少次)',
})
declare frequency: number;
@Column({
type: DataType.JSON,
allowNull: true,
comment: '自定义重复规则(如每周几)',
})
declare customRepeatRule: any;
@Column({
type: DataType.DATEONLY,
allowNull: true,
comment: '目标开始日期',
})
declare startDate: Date;
@Column({
type: DataType.DATEONLY,
allowNull: true,
comment: '目标结束日期',
})
declare endDate: Date;
// 开始时间,分钟
@Column({
type: DataType.INTEGER,
allowNull: true,
comment: '开始时间,分钟',
})
declare startTime: number;
// 结束时间,分钟
@Column({
type: DataType.INTEGER,
allowNull: true,
comment: '结束时间,分钟',
})
declare endTime: number;
@Column({
type: DataType.ENUM('active', 'paused', 'completed', 'cancelled'),
allowNull: false,
defaultValue: GoalStatus.ACTIVE,
comment: '目标状态',
})
declare status: GoalStatus;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '已完成次数',
})
declare completedCount: number;
@Column({
type: DataType.INTEGER,
allowNull: true,
comment: '目标总次数null表示无限制',
})
declare targetCount: number;
@Column({
type: DataType.STRING(100),
allowNull: true,
comment: '目标分类标签',
})
declare category: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '优先级(数字越大优先级越高)',
})
declare priority: number;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否提醒',
})
declare hasReminder: boolean;
@Column({
type: DataType.TIME,
allowNull: true,
comment: '提醒时间',
})
declare reminderTime: string;
@Column({
type: DataType.JSON,
allowNull: true,
comment: '提醒设置(如每周几提醒)',
})
declare reminderSettings: any;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
@Column({
type: DataType.BOOLEAN,
defaultValue: false,
comment: '是否删除',
})
declare deleted: boolean;
@HasMany(() => GoalCompletion, 'goalId')
declare completions: GoalCompletion[];
@HasMany(() => GoalTask, 'goalId')
declare tasks: GoalTask[];
}

View File

@@ -0,0 +1,626 @@
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Op, WhereOptions } from 'sequelize';
import { Goal, GoalRepeatType, GoalStatus } from '../models/goal.model';
import { GoalTask, TaskStatus } from '../models/goal-task.model';
import { UpdateGoalTaskDto, GoalTaskQueryDto, CompleteGoalTaskDto } from '../dto/goal-task.dto';
import * as dayjs from 'dayjs';
import * as weekOfYear from 'dayjs/plugin/weekOfYear';
import * as isoWeek from 'dayjs/plugin/isoWeek';
import * as isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import * as isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import { ActivityLevel, ActivityType } from 'src/users/models/user-activity.model';
import { UserActivityService } from 'src/users/services/user-activity.service';
dayjs.extend(weekOfYear);
dayjs.extend(isoWeek);
dayjs.extend(isSameOrBefore);
dayjs.extend(isSameOrAfter);
@Injectable()
export class GoalTaskService {
private readonly logger = new Logger(GoalTaskService.name);
constructor(
@InjectModel(Goal)
private readonly goalModel: typeof Goal,
@InjectModel(GoalTask)
private readonly goalTaskModel: typeof GoalTask,
private readonly userActivityService: UserActivityService,
) { }
/**
* 惰性生成任务 - 每次获取任务列表时调用
*/
async generateTasksLazily(userId: string, goalId?: string): Promise<void> {
try {
const where: WhereOptions = {
userId,
deleted: false,
status: GoalStatus.ACTIVE,
};
if (goalId) {
where.id = goalId;
}
const goals = await this.goalModel.findAll({ where });
this.logger.log(`为用户 ${userId} 找到 ${goals.length} 个活跃目标`);
for (const goal of goals) {
this.logger.log(`开始为目标 ${goal.title} (${goal.repeatType}) 生成任务`);
await this.generateTasksForGoal(goal);
}
} catch (error) {
this.logger.error(`惰性生成任务失败: ${error.message}`);
throw error;
}
}
/**
* 为单个目标生成任务
*/
private async generateTasksForGoal(goal: Goal): Promise<void> {
const now = dayjs();
const startDate = goal.startDate ? dayjs(goal.startDate) : now;
const endDate = goal.endDate ? dayjs(goal.endDate) : now.add(1, 'year');
// 获取已存在的任务
const existingTasks = await this.goalTaskModel.findAll({
where: {
goalId: goal.id,
userId: goal.userId,
deleted: false,
},
});
// 根据重复类型生成任务
switch (goal.repeatType) {
case GoalRepeatType.DAILY:
await this.generateDailyTasks(goal, startDate, endDate, existingTasks);
break;
case GoalRepeatType.WEEKLY:
await this.generateWeeklyTasks(goal, startDate, endDate, existingTasks);
break;
case GoalRepeatType.MONTHLY:
await this.generateMonthlyTasks(goal, startDate, endDate, existingTasks);
break;
case GoalRepeatType.CUSTOM:
await this.generateCustomTasks(goal, startDate, endDate, existingTasks);
break;
default:
this.logger.warn(`未知的重复类型: ${goal.repeatType}`);
break;
}
// 更新过期任务状态
await this.updateOverdueTasks(goal.id, goal.userId);
}
/**
* 生成每日任务
*/
private async generateDailyTasks(
goal: Goal,
startDate: dayjs.Dayjs,
endDate: dayjs.Dayjs,
existingTasks: GoalTask[]
): Promise<void> {
const today = dayjs();
const generateUntil = today.add(7, 'day'); // 提前生成7天的任务
const actualEndDate = endDate.isBefore(generateUntil) ? endDate : generateUntil;
let current = startDate.isBefore(today) ? today : startDate;
while (current.isSameOrBefore(actualEndDate)) {
const taskDate = current.format('YYYY-MM-DD');
// 检查是否已存在该日期的任务
const existingTask = existingTasks.find(task =>
dayjs(task.startDate).format('YYYY-MM-DD') === taskDate
);
if (!existingTask) {
const taskTitle = goal.title;
await this.goalTaskModel.create({
goalId: goal.id,
userId: goal.userId,
title: taskTitle,
description: `每日目标:完成${goal.frequency}`,
startDate: current.toDate(),
endDate: current.toDate(),
targetCount: goal.frequency,
currentCount: 0,
status: TaskStatus.PENDING,
});
this.logger.log(`为目标 ${goal.title} 生成每日任务: ${taskTitle}`);
}
current = current.add(1, 'day');
}
}
/**
* 生成每周任务
*/
private async generateWeeklyTasks(
goal: Goal,
startDate: dayjs.Dayjs,
endDate: dayjs.Dayjs,
existingTasks: GoalTask[]
): Promise<void> {
const today = dayjs();
const generateUntil = today.add(4, 'week'); // 提前生成4周的任务
const actualEndDate = endDate.isBefore(generateUntil) ? endDate : generateUntil;
// 检查是否有自定义重复规则指定星期几
const weekdays = goal.customRepeatRule?.weekdays;
if (weekdays && weekdays.length > 0) {
// 如果有指定星期几,按指定星期几生成任务
this.logger.log(`为目标 ${goal.title} 生成每周任务,指定星期几: ${weekdays}`);
// 从今天开始生成,如果开始日期晚于今天则从开始日期开始
let current = startDate.isBefore(today) ? today : startDate;
let generatedCount = 0;
while (current.isSameOrBefore(actualEndDate)) {
const dayOfWeek = current.day(); // 0=周日, 6=周六
if (weekdays.includes(dayOfWeek)) {
const taskDate = current.format('YYYY-MM-DD');
// 检查是否已存在该日期的任务
const existingTask = existingTasks.find(task =>
dayjs(task.startDate).format('YYYY-MM-DD') === taskDate
);
if (!existingTask) {
const weekDayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const taskTitle = `${goal.title} - ${current.format('YYYY年MM月DD日')} ${weekDayNames[dayOfWeek]}`;
await this.goalTaskModel.create({
goalId: goal.id,
userId: goal.userId,
title: taskTitle,
description: `每周目标:完成${goal.frequency}`,
startDate: current.toDate(),
endDate: current.toDate(),
targetCount: goal.frequency,
currentCount: 0,
status: TaskStatus.PENDING,
});
generatedCount++;
this.logger.log(`为目标 ${goal.title} 生成每周任务: ${taskTitle}`);
}
}
current = current.add(1, 'day');
}
this.logger.log(`为目标 ${goal.title} 生成了 ${generatedCount} 个每周任务`);
}
}
/**
* 生成每月任务
*/
private async generateMonthlyTasks(
goal: Goal,
startDate: dayjs.Dayjs,
endDate: dayjs.Dayjs,
existingTasks: GoalTask[]
): Promise<void> {
const today = dayjs();
const generateUntil = today.add(6, 'month'); // 提前生成6个月的任务
const actualEndDate = endDate.isBefore(generateUntil) ? endDate : generateUntil;
// 检查是否有自定义重复规则指定每月第几天
let targetDayOfMonth = 1; // 默认每月1号
if (goal.customRepeatRule && goal.customRepeatRule.dayOfMonth) {
targetDayOfMonth = goal.customRepeatRule.dayOfMonth;
this.logger.log(`目标 ${goal.title} 设置为每月第 ${targetDayOfMonth}`);
}
// 从开始日期开始,逐月生成任务
let current = startDate.startOf('month');
let generatedCount = 0;
this.logger.log(`开始生成每月任务,目标日期:每月第 ${targetDayOfMonth}`);
while (current.isSameOrBefore(actualEndDate)) {
// 计算该月的目标日期
const targetDate = current.date(targetDayOfMonth);
// 如果目标日期超出了该月的天数,则使用该月的最后一天
const daysInMonth = current.daysInMonth();
const actualTargetDate = targetDayOfMonth > daysInMonth ? current.date(daysInMonth) : targetDate;
// 检查是否已经过了该月的目标日期
if (actualTargetDate.isBefore(today)) {
this.logger.log(`跳过 ${current.format('YYYY年MM月')},目标日期 ${actualTargetDate.format('MM-DD')} 已过期`);
current = current.add(1, 'month');
continue;
}
// 检查是否已存在该月的任务
const existingTask = existingTasks.find(task => {
const taskDate = dayjs(task.startDate);
return taskDate.isSame(actualTargetDate, 'day');
});
if (!existingTask && actualTargetDate.isSameOrAfter(startDate)) {
const taskTitle = `${goal.title} - ${actualTargetDate.format('YYYY年MM月DD日')}`;
await this.goalTaskModel.create({
goalId: goal.id,
userId: goal.userId,
title: taskTitle,
description: `每月目标:完成${goal.frequency}`,
startDate: actualTargetDate.toDate(),
endDate: actualTargetDate.toDate(), // 任务在当天完成
targetCount: goal.frequency,
currentCount: 0,
status: TaskStatus.PENDING,
});
generatedCount++;
this.logger.log(`为目标 ${goal.title} 生成每月任务: ${taskTitle}`);
}
current = current.add(1, 'month');
}
this.logger.log(`为目标 ${goal.title} 生成了 ${generatedCount} 个每月任务`);
}
/**
* 生成自定义周期任务
*/
private async generateCustomTasks(
goal: Goal,
startDate: dayjs.Dayjs,
endDate: dayjs.Dayjs,
existingTasks: GoalTask[]
): Promise<void> {
if (!goal.customRepeatRule) {
this.logger.warn(`目标 ${goal.title} 缺少自定义重复规则`);
return;
}
const { weekdays } = goal.customRepeatRule;
this.logger.log(`为目标 ${goal.title} 生成自定义任务,重复规则: ${JSON.stringify(goal.customRepeatRule)}`);
if (weekdays && weekdays.length > 0) {
// 按指定星期几生成任务
const today = dayjs();
const generateUntil = today.add(4, 'week'); // 提前生成4周的任务确保有足够的任务
const actualEndDate = endDate.isBefore(generateUntil) ? endDate : generateUntil;
// 从今天开始生成,如果开始日期晚于今天则从开始日期开始
let current = startDate.isBefore(today) ? today : startDate;
let generatedCount = 0;
this.logger.log(`开始生成自定义任务,日期范围: ${current.format('YYYY-MM-DD')}${actualEndDate.format('YYYY-MM-DD')}`);
while (current.isSameOrBefore(actualEndDate)) {
const dayOfWeek = current.day(); // 0=周日, 6=周六
if (weekdays.includes(dayOfWeek)) {
const taskDate = current.format('YYYY-MM-DD');
const existingTask = existingTasks.find(task =>
dayjs(task.startDate).format('YYYY-MM-DD') === taskDate
);
if (!existingTask) {
const weekDayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const taskTitle = `${goal.title} - ${current.format('YYYY年MM月DD日')} ${weekDayNames[dayOfWeek]}`;
await this.goalTaskModel.create({
goalId: goal.id,
userId: goal.userId,
title: taskTitle,
description: `自定义目标:完成${goal.frequency}`,
startDate: current.toDate(),
endDate: current.toDate(),
targetCount: goal.frequency,
currentCount: 0,
status: TaskStatus.PENDING,
});
generatedCount++;
this.logger.log(`为目标 ${goal.title} 生成自定义任务: ${taskTitle}`);
}
}
current = current.add(1, 'day');
}
this.logger.log(`为目标 ${goal.title} 生成了 ${generatedCount} 个自定义任务`);
} else {
this.logger.warn(`目标 ${goal.title} 的自定义重复规则中没有指定星期几`);
}
}
/**
* 更新过期任务状态
*/
private async updateOverdueTasks(goalId: string, userId: string): Promise<void> {
const now = new Date();
await this.goalTaskModel.update(
{ status: TaskStatus.OVERDUE },
{
where: {
goalId,
userId,
deleted: false,
endDate: { [Op.lt]: now },
status: { [Op.notIn]: [TaskStatus.COMPLETED, TaskStatus.SKIPPED, TaskStatus.OVERDUE] },
},
}
);
}
/**
* 获取任务列表
*/
async getTasks(userId: string, query: GoalTaskQueryDto) {
try {
// 先进行惰性生成
await this.generateTasksLazily(userId, query.goalId);
const { page = 1, pageSize = 200, goalId, status, startDate, endDate } = query;
const offset = (page - 1) * pageSize;
const where: WhereOptions = {
userId,
deleted: false,
};
if (goalId) {
where.goalId = goalId;
}
if (status) {
where.status = status;
}
if (startDate || endDate) {
where.startDate = {};
if (startDate) {
where.startDate[Op.gte] = new Date(startDate);
}
if (endDate) {
where.startDate[Op.lte] = new Date(endDate);
}
}
const { rows: tasks, count } = await this.goalTaskModel.findAndCountAll({
where,
order: [['startDate', 'ASC'], ['createdAt', 'DESC']],
offset,
limit: pageSize,
});
return {
page,
pageSize,
total: count,
list: tasks.map(task => this.formatTaskResponse(task)),
};
} catch (error) {
this.logger.error(`获取任务列表失败: ${error.message}`);
throw error;
}
}
/**
* 完成任务
*/
async completeTask(userId: string, taskId: string, completeDto: CompleteGoalTaskDto): Promise<GoalTask> {
try {
const task = await this.goalTaskModel.findOne({
where: { id: taskId, userId, deleted: false },
});
if (!task) {
throw new NotFoundException('任务不存在');
}
if (task.status === TaskStatus.COMPLETED) {
throw new BadRequestException('任务已完成');
}
const { count = 1, notes, completedAt } = completeDto;
// 更新完成次数
task.currentCount = Math.min(task.currentCount + count, task.targetCount);
task.notes = notes || task.notes;
if (completedAt) {
task.completedAt = new Date(completedAt);
}
// 更新进度和状态
task.updateProgress();
await task.save();
try {
const today = dayjs().format('YYYY-MM-DD');
await this.userActivityService.recordActivity(userId, {
activityType: ActivityType.GOAL,
activityDate: today,
level: ActivityLevel.MEDIUM,
remark: `完成目标任务: ${task.title}`,
});
this.logger.log(`记录用户活跃 - 用户: ${userId} 完成目标任务: ${task.title}`);
} catch (activityError) {
// 记录活跃失败不影响主要业务流程
this.logger.error(`记录用户活跃失败: ${activityError.message}`);
}
this.logger.log(`用户 ${userId} 完成任务: ${task.title}, 当前进度: ${task.currentCount}/${task.targetCount}`);
return this.formatTaskResponse(task);
} catch (error) {
this.logger.error(`完成任务失败: ${error.message}`);
throw error;
}
}
/**
* 更新任务
*/
async updateTask(userId: string, taskId: string, updateDto: UpdateGoalTaskDto): Promise<GoalTask> {
try {
const task = await this.goalTaskModel.findOne({
where: { id: taskId, userId, deleted: false },
});
if (!task) {
throw new NotFoundException('任务不存在');
}
await task.update({
...updateDto,
startDate: updateDto.startDate ? new Date(updateDto.startDate) : task.startDate,
endDate: updateDto.endDate ? new Date(updateDto.endDate) : task.endDate,
});
// 如果更新了目标次数,重新计算进度
if (updateDto.targetCount !== undefined) {
task.updateProgress();
await task.save();
}
this.logger.log(`用户 ${userId} 更新任务: ${task.title}`);
return this.formatTaskResponse(task);
} catch (error) {
this.logger.error(`更新任务失败: ${error.message}`);
throw error;
}
}
/**
* 跳过任务
*/
async skipTask(userId: string, taskId: string, reason?: string): Promise<GoalTask> {
try {
const task = await this.goalTaskModel.findOne({
where: { id: taskId, userId, deleted: false },
});
if (!task) {
throw new NotFoundException('任务不存在');
}
await task.update({
status: TaskStatus.SKIPPED,
notes: reason || '用户主动跳过',
});
this.logger.log(`用户 ${userId} 跳过任务: ${task.title}`);
return this.formatTaskResponse(task);
} catch (error) {
this.logger.error(`跳过任务失败: ${error.message}`);
throw error;
}
}
/**
* 获取任务统计
*/
async getTaskStats(userId: string, goalId?: string) {
try {
const where: WhereOptions = {
userId,
deleted: false,
};
if (goalId) {
where.goalId = goalId;
}
const tasks = await this.goalTaskModel.findAll({ where });
const now = dayjs();
const todayStart = now.startOf('day');
const weekStart = now.startOf('isoWeek');
const monthStart = now.startOf('month');
const stats = {
total: tasks.length,
pending: tasks.filter(t => t.status === TaskStatus.PENDING).length,
inProgress: tasks.filter(t => t.status === TaskStatus.IN_PROGRESS).length,
completed: tasks.filter(t => t.status === TaskStatus.COMPLETED).length,
overdue: tasks.filter(t => t.status === TaskStatus.OVERDUE).length,
skipped: tasks.filter(t => t.status === TaskStatus.SKIPPED).length,
totalProgress: 0,
todayTasks: 0,
weekTasks: 0,
monthTasks: 0,
};
// 计算总体进度
if (tasks.length > 0) {
const totalProgress = tasks.reduce((sum, task) => sum + task.progressPercentage, 0);
stats.totalProgress = Math.round(totalProgress / tasks.length);
}
// 统计时间范围内的任务
tasks.forEach(task => {
const taskDate = dayjs(task.startDate);
if (taskDate.isSame(todayStart, 'day')) {
stats.todayTasks++;
}
if (taskDate.isSameOrAfter(weekStart)) {
stats.weekTasks++;
}
if (taskDate.isSameOrAfter(monthStart)) {
stats.monthTasks++;
}
});
return stats;
} catch (error) {
this.logger.error(`获取任务统计失败: ${error.message}`);
throw error;
}
}
/**
* 格式化任务响应
*/
private formatTaskResponse(task: GoalTask) {
const taskData = task.toJSON();
// 检查是否过期
task.checkOverdue();
// 计算剩余天数
const endDate = dayjs(taskData.endDate);
const now = dayjs();
taskData.daysRemaining = Math.max(0, endDate.diff(now, 'day'));
// 计算是否为今日任务
taskData.isToday = dayjs(taskData.startDate).isSame(now, 'day');
return taskData;
}
}

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