Compare commits
61 Commits
fafb618c32
...
feature/pu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c2c9dfae8 | ||
|
|
cc83b84c80 | ||
|
|
38dd740c8c | ||
|
|
305a969912 | ||
|
|
999fc7f793 | ||
|
|
87c3cbfac9 | ||
|
|
f13953030b | ||
|
|
12acbbd166 | ||
|
|
64460a9d68 | ||
|
|
d87fc84575 | ||
|
|
22fcf694a6 | ||
|
|
ae8039c9ed | ||
|
|
1b7132a325 | ||
|
|
8e51994e71 | ||
|
|
21b00cee0d | ||
|
|
c9eda4577f | ||
|
|
e2fcb1c428 | ||
| dc06dfbebd | |||
|
|
cf02fda4ec | ||
|
|
090b91e72d | ||
|
|
97e6a0ff6d | ||
|
|
d34f752776 | ||
|
|
02f21f0858 | ||
|
|
730b1df35e | ||
|
|
2c2e964199 | ||
|
|
0488fe62a1 | ||
| d0b02b6228 | |||
|
|
6542988cb6 | ||
|
|
c0bdb3bf0a | ||
|
|
8a69f4f1af | ||
|
|
74faebd73d | ||
|
|
a1c21d8a23 | ||
|
|
17ee96638e | ||
|
|
e3cd496f33 | ||
|
|
04903426d1 | ||
|
|
c3961150ab | ||
|
|
79aa300aa1 | ||
|
|
a8c67ceb17 | ||
| 8aca29e2b3 | |||
| 475f928990 | |||
| cba56021de | |||
|
|
f6b4c99e75 | ||
|
|
3530d123fc | ||
|
|
062a78a839 | ||
|
|
acf8d0c48c | ||
|
|
ffc0cd1d13 | ||
| 270b59c599 | |||
|
|
f26d8e64c6 | ||
|
|
513d6e071d | ||
|
|
73f53ac5e4 | ||
|
|
94e1b124df | ||
|
|
4cd8d59f12 | ||
|
|
8e27e3d3e3 | ||
|
|
a56d1d5255 | ||
|
|
ede5730647 | ||
|
|
485ba1f67c | ||
|
|
3d36ee90f0 | ||
|
|
eb71f845e5 | ||
| e358b3d2fd | |||
| e719c959aa | |||
| 477f5b4b79 |
24
.env.glm.example
Normal file
24
.env.glm.example
Normal 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
9
.kilocode/rules/rule.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# rule.md
|
||||||
|
|
||||||
|
你是一名拥有 20 年服务端开发经验的 javascript 工程师,这是一个 nodejs 基于 nestjs 框架的项目,与健康、健身、减肥相关
|
||||||
|
|
||||||
|
## 指导原则
|
||||||
|
|
||||||
|
- 不要随意新增 markdown 文档
|
||||||
|
- 代码提交 message 用中文
|
||||||
|
- 注意代码的可读性、架构实现要清晰
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"kiroAgent.configureMCP": "Enabled"
|
||||||
|
}
|
||||||
125
CLAUDE.md
Normal file
125
CLAUDE.md
Normal 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
|
||||||
130
CUSTOM_FOODS_IMPLEMENTATION.md
Normal file
130
CUSTOM_FOODS_IMPLEMENTATION.md
Normal 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. **食物模板**:可以提供常见食物的营养模板,方便用户快速创建
|
||||||
6
SubscriptionKey_3YKHQZ374P.p8
Normal file
6
SubscriptionKey_3YKHQZ374P.p8
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgZM2yBrDe1RyBvk+V
|
||||||
|
UrMDhiiUjNhmqyYizbj++CUgleOgCgYIKoZIzj0DAQehRANCAASvI6b4Japk/hyH
|
||||||
|
GGTMQZEdo++TRs8/9dyVic271ERjQbIFCXOkKiASgyObxih2RuessC/t2+VPZx4F
|
||||||
|
Db0U/xrS
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
-----BEGIN PRIVATE KEY-----
|
|
||||||
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgONlcciOyI4UqtLhW
|
|
||||||
4EwWvkjRybvNNg15/m6voi4vx0agCgYIKoZIzj0DAQehRANCAAQeTAmBTidpkDwT
|
|
||||||
FWUrxN+HfXhKbiDloQ68fc//+jeVQtC5iUKOZp38P/IqI+9lUIWoLKsryCxKeAkb
|
|
||||||
8U5D2WWu
|
|
||||||
-----END PRIVATE KEY-----
|
|
||||||
192
docs/DIET_ANALYSIS_REFACTORING_SUMMARY.md
Normal file
192
docs/DIET_ANALYSIS_REFACTORING_SUMMARY.md
Normal 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原则
|
||||||
|
|
||||||
|
重构后的代码更加专业、清晰,为后续的功能扩展和维护奠定了良好的基础。
|
||||||
148
docs/DIET_CONFIRMATION_IMPLEMENTATION_SUMMARY.md
Normal file
148
docs/DIET_CONFIRMATION_IMPLEMENTATION_SUMMARY.md
Normal 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. 添加食物历史记录快速选择
|
||||||
165
docs/DIET_RECORDS_IMPLEMENTATION_SUMMARY.md
Normal file
165
docs/DIET_RECORDS_IMPLEMENTATION_SUMMARY.md
Normal 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
140
docs/WATER_RECORDS.md
Normal 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. 喝水目标的更新集成在喝水接口中,避免用户服务文件过大
|
||||||
182
docs/WATER_RECORDS_MODULE.md
Normal file
182
docs/WATER_RECORDS_MODULE.md
Normal 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
236
docs/challenges-api.md
Normal 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` 显示用户当前排名。
|
||||||
|
- 进度上报建议结合业务埋点,确保重复提交时可处理幂等性(服务端会封顶到目标值)。
|
||||||
239
docs/diet-confirmation-flow-api.md
Normal file
239
docs/diet-confirmation-flow-api.md
Normal 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 // 第二阶段可以使用流式
|
||||||
|
};
|
||||||
|
```
|
||||||
159
docs/diet-records-api-guide.md
Normal file
159
docs/diet-records-api-guide.md
Normal 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. **性能优化**:使用了合适的数据库索引来优化查询性能
|
||||||
|
|
||||||
|
## 扩展功能
|
||||||
|
|
||||||
|
未来可以考虑添加:
|
||||||
|
- 食物营养数据库集成
|
||||||
|
- 更精确的营养成分计算
|
||||||
|
- 饮食目标设定和追踪
|
||||||
|
- 营养师在线咨询
|
||||||
|
- 社交分享功能
|
||||||
304
docs/goal-tasks-api-guide.md
Normal file
304
docs/goal-tasks-api-guide.md
Normal 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
399
docs/goals-api-guide.md
Normal 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. 达到目标总次数时,目标状态会自动变为已完成
|
||||||
288
docs/ios-push-implementation-plan.md
Normal file
288
docs/ios-push-implementation-plan.md
Normal 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调用来发送各种类型的推送通知,提升用户体验和业务指标。
|
||||||
265
docs/ios-push-notification-design.md
Normal file
265
docs/ios-push-notification-design.md
Normal 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 运维监控
|
||||||
|
- 推送服务健康检查
|
||||||
|
- 性能指标监控
|
||||||
|
- 告警机制设置
|
||||||
295
docs/nutrition-analysis-api.md
Normal file
295
docs/nutrition-analysis-api.md
Normal 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 | 初始版本,支持营养成分表图片分析功能 |
|
||||||
|
|
||||||
|
## 技术支持
|
||||||
|
|
||||||
|
如有技术问题或集成困难,请联系开发团队获取支持。
|
||||||
474
docs/push-notifications-usage-guide.md
Normal file
474
docs/push-notifications-usage-guide.md
Normal 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推送功能已完全集成到系统中,提供了完整的推送令牌管理、消息发送和模板系统。通过遵循本指南,您可以轻松地在应用中实现各种推送场景,提升用户体验和参与度。
|
||||||
|
|
||||||
|
如有任何问题或需要进一步的技术支持,请参考相关文档或联系开发团队。
|
||||||
673
docs/push-service-architecture.md
Normal file
673
docs/push-service-architecture.md
Normal 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 缓存策略
|
||||||
|
- 缓存用户设备令牌减少数据库查询
|
||||||
|
- 缓存推送模板提高渲染性能
|
||||||
|
- 实现分布式缓存支持集群部署
|
||||||
151
docs/stream-response-solution-options.md
Normal file
151
docs/stream-response-solution-options.md
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案4:SSE (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
473
docs/water-records-api.md
Normal 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
186
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
14
sql-scripts/add-challenge-type-column.sql
Normal file
14
sql-scripts/add-challenge-type-column.sql
Normal 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;
|
||||||
54
sql-scripts/add-member-number-migration.sql
Normal file
54
sql-scripts/add-member-number-migration.sql
Normal 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. 执行前请备份数据库
|
||||||
63
sql-scripts/body-measurements-migration.sql
Normal file
63
sql-scripts/body-measurements-migration.sql
Normal 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'
|
||||||
|
);
|
||||||
56
sql-scripts/challenges.sql
Normal file
56
sql-scripts/challenges.sql
Normal 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
|
||||||
45
sql-scripts/diet-records-table-create.sql
Normal file
45
sql-scripts/diet-records-table-create.sql
Normal 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', '晚餐丰富');
|
||||||
41
sql-scripts/fix-collation.sql
Normal file
41
sql-scripts/fix-collation.sql
Normal 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');
|
||||||
85
sql-scripts/food-library-sample-data.sql
Normal file
85
sql-scripts/food-library-sample-data.sql
Normal 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);
|
||||||
80
sql-scripts/food-library-tables-create.sql
Normal file
80
sql-scripts/food-library-tables-create.sql
Normal 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`);
|
||||||
49
sql-scripts/goal-tasks-table-create.sql
Normal file
49
sql-scripts/goal-tasks-table-create.sql
Normal 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`);
|
||||||
60
sql-scripts/goals-tables-create.sql
Normal file
60
sql-scripts/goals-tables-create.sql
Normal 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`);
|
||||||
24
sql-scripts/mood-checkins-table-create.sql
Normal file
24
sql-scripts/mood-checkins-table-create.sql
Normal 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`);
|
||||||
72
sql-scripts/push-notifications-tables-create.sql
Normal file
72
sql-scripts/push-notifications-tables-create.sql
Normal 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);
|
||||||
36
sql-scripts/user-activity-table-create.sql
Normal file
36
sql-scripts/user-activity-table-create.sql
Normal 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)
|
||||||
26
sql-scripts/user-custom-foods-table.sql
Normal file
26
sql-scripts/user-custom-foods-table.sql
Normal 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='用户自定义食物表';
|
||||||
14
sql-scripts/user-food-favorites-table-create.sql
Normal file
14
sql-scripts/user-food-favorites-table-create.sql
Normal 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='用户食物收藏表';
|
||||||
17
sql-scripts/user-water-records-table.sql
Normal file
17
sql-scripts/user-water-records-table.sql
Normal 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`;
|
||||||
@@ -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: '实体类型',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 { }
|
||||||
|
|
||||||
|
|||||||
@@ -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 {};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
148
src/ai-coach/dto/food-recognition.dto.ts
Normal file
148
src/ai-coach/dto/food-recognition.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
174
src/ai-coach/services/diet-analysis.service.spec.ts
Normal file
174
src/ai-coach/services/diet-analysis.service.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1028
src/ai-coach/services/diet-analysis.service.ts
Normal file
1028
src/ai-coach/services/diet-analysis.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
107
src/challenges/challenges.controller.ts
Normal file
107
src/challenges/challenges.controller.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/challenges/challenges.module.ts
Normal file
20
src/challenges/challenges.module.ts
Normal 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 { }
|
||||||
580
src/challenges/challenges.service.ts
Normal file
580
src/challenges/challenges.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/challenges/dto/challenge-detail.dto.ts
Normal file
23
src/challenges/dto/challenge-detail.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
28
src/challenges/dto/challenge-list.dto.ts
Normal file
28
src/challenges/dto/challenge-list.dto.ts
Normal 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[];
|
||||||
|
}
|
||||||
16
src/challenges/dto/challenge-progress.dto.ts
Normal file
16
src/challenges/dto/challenge-progress.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
23
src/challenges/dto/challenge-ranking.dto.ts
Normal file
23
src/challenges/dto/challenge-ranking.dto.ts
Normal 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[];
|
||||||
|
}
|
||||||
|
|
||||||
7
src/challenges/dto/update-challenge-progress.dto.ts
Normal file
7
src/challenges/dto/update-challenge-progress.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsInt, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateChallengeProgressDto {
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
value!: number;
|
||||||
|
}
|
||||||
99
src/challenges/models/challenge-participant.model.ts
Normal file
99
src/challenges/models/challenge-participant.model.ts
Normal 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;
|
||||||
|
}
|
||||||
62
src/challenges/models/challenge-progress-report.model.ts
Normal file
62
src/challenges/models/challenge-progress-report.model.ts
Normal 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;
|
||||||
|
}
|
||||||
149
src/challenges/models/challenge.model.ts
Normal file
149
src/challenges/models/challenge.model.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
221
src/diet-records/diet-records.controller.ts
Normal file
221
src/diet-records/diet-records.controller.ts
Normal 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: '营养成分表分析失败,请稍后重试'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/diet-records/diet-records.module.ts
Normal file
21
src/diet-records/diet-records.module.ts
Normal 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 { }
|
||||||
403
src/diet-records/diet-records.service.ts
Normal file
403
src/diet-records/diet-records.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/diet-records/dto/nutrition-analysis-request.dto.ts
Normal file
13
src/diet-records/dto/nutrition-analysis-request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
32
src/diet-records/dto/nutrition-analysis.dto.ts
Normal file
32
src/diet-records/dto/nutrition-analysis.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
282
src/diet-records/services/nutrition-analysis.service.ts
Normal file
282
src/diet-records/services/nutrition-analysis.service.ts
Normal 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: '营养成分表处理失败,请稍后重试'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/food-library/README.md
Normal file
94
src/food-library/README.md
Normal 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克)
|
||||||
|
- 添加按钮(+)
|
||||||
|
|
||||||
|
## 扩展功能
|
||||||
|
|
||||||
|
未来可以扩展的功能:
|
||||||
|
- 用户自定义食物
|
||||||
|
- 收藏食物功能
|
||||||
|
- 食物图片上传
|
||||||
|
- 营养成分详细分析
|
||||||
|
- 食物推荐算法
|
||||||
127
src/food-library/USER_CUSTOM_FOODS.md
Normal file
127
src/food-library/USER_CUSTOM_FOODS.md
Normal 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);
|
||||||
|
```
|
||||||
147
src/food-library/dto/food-library.dto.ts
Normal file
147
src/food-library/dto/food-library.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
152
src/food-library/food-library.controller.ts
Normal file
152
src/food-library/food-library.controller.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/food-library/food-library.module.ts
Normal file
20
src/food-library/food-library.module.ts
Normal 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 { }
|
||||||
351
src/food-library/food-library.service.ts
Normal file
351
src/food-library/food-library.service.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/food-library/models/food-category.model.ts
Normal file
54
src/food-library/models/food-category.model.ts
Normal 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;
|
||||||
|
}
|
||||||
126
src/food-library/models/food-library.model.ts
Normal file
126
src/food-library/models/food-library.model.ts
Normal 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;
|
||||||
|
}
|
||||||
114
src/food-library/models/user-custom-food.model.ts
Normal file
114
src/food-library/models/user-custom-food.model.ts
Normal 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;
|
||||||
|
}
|
||||||
43
src/food-library/models/user-food-favorite.model.ts
Normal file
43
src/food-library/models/user-food-favorite.model.ts
Normal 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;
|
||||||
|
}
|
||||||
104
src/goals/dto/create-goal.dto.ts
Normal file
104
src/goals/dto/create-goal.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
33
src/goals/dto/goal-completion.dto.ts
Normal file
33
src/goals/dto/goal-completion.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
46
src/goals/dto/goal-query.dto.ts
Normal file
46
src/goals/dto/goal-query.dto.ts
Normal 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';
|
||||||
|
}
|
||||||
120
src/goals/dto/goal-task.dto.ts
Normal file
120
src/goals/dto/goal-task.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
10
src/goals/dto/update-goal.dto.ts
Normal file
10
src/goals/dto/update-goal.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
299
src/goals/goals.controller.ts
Normal file
299
src/goals/goals.controller.ts
Normal 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
20
src/goals/goals.module.ts
Normal 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
441
src/goals/goals.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/goals/models/goal-completion.model.ts
Normal file
81
src/goals/models/goal-completion.model.ts
Normal 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;
|
||||||
|
}
|
||||||
166
src/goals/models/goal-task.model.ts
Normal file
166
src/goals/models/goal-task.model.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
189
src/goals/models/goal.model.ts
Normal file
189
src/goals/models/goal.model.ts
Normal 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[];
|
||||||
|
}
|
||||||
626
src/goals/services/goal-task.service.ts
Normal file
626
src/goals/services/goal-task.service.ts
Normal 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
Reference in New Issue
Block a user