diff --git a/docs/WATER_RECORDS.md b/docs/WATER_RECORDS.md deleted file mode 100644 index 664f5b2..0000000 --- a/docs/WATER_RECORDS.md +++ /dev/null @@ -1,140 +0,0 @@ -# 喝水记录功能 - -## 功能概述 - -新增了用户喝水记录功能,支持用户记录每日的喝水情况,设置喝水目标,并查看统计信息。 - -## 新增文件 - -### 模型文件 -- `src/users/models/user-water-history.model.ts` - 喝水记录模型 -- 更新了 `src/users/models/user-profile.model.ts` - 添加了 `dailyWaterGoal` 字段 - -### DTO文件 -- `src/users/dto/water-record.dto.ts` - 喝水记录相关的DTO - -### 服务文件 -- `src/users/services/water-record.service.ts` - 喝水记录服务 - -### 数据库脚本 -- `sql-scripts/user-water-records-table.sql` - 数据库迁移脚本 - -### 测试脚本 -- `test-water-records.sh` - API接口测试脚本 - -## API接口 - -### 1. 创建喝水记录 -``` -POST /users/water-records -``` - -请求体: -```json -{ - "amount": 250, - "source": "manual", - "remark": "早晨第一杯水" -} -``` - -### 2. 获取喝水记录列表 -``` -GET /users/water-records?limit=10&offset=0 -``` - -### 3. 更新喝水记录 -``` -PUT /users/water-records/:id -``` - -请求体: -```json -{ - "amount": 300, - "remark": "修改后的备注" -} -``` - -### 4. 删除喝水记录 -``` -DELETE /users/water-records/:id -``` - -### 5. 更新喝水目标 -``` -PUT /users/water-goal -``` - -请求体: -```json -{ - "dailyWaterGoal": 2000 -} -``` - -### 6. 获取今日喝水统计 -``` -GET /users/water-stats/today -``` - -响应: -```json -{ - "code": 200, - "message": "success", - "data": { - "totalAmount": 1500, - "recordCount": 6, - "dailyGoal": 2000, - "completionRate": 0.75 - } -} -``` - -## 数据库表结构 - -### t_user_water_history (喝水记录表) -- `id` - 主键,自增 -- `user_id` - 用户ID -- `amount` - 喝水量(毫升) -- `source` - 记录来源(manual/auto/other) -- `remark` - 备注 -- `created_at` - 创建时间 -- `updated_at` - 更新时间 - -### t_user_profile (用户档案表 - 新增字段) -- `daily_water_goal` - 每日喝水目标(毫升) - -## 功能特点 - -1. **完整的CRUD操作** - 支持喝水记录的增删改查 -2. **目标设置** - 用户可以设置每日喝水目标 -3. **统计功能** - 提供今日喝水统计,包括总量、记录数、完成率等 -4. **数据验证** - 对输入数据进行严格验证 -5. **错误处理** - 完善的错误处理机制 -6. **日志记录** - 详细的操作日志 -7. **权限控制** - 所有接口都需要JWT认证 - -## 部署说明 - -1. 运行数据库迁移脚本: - ```bash - mysql -u username -p database_name < sql-scripts/user-water-records-table.sql - ``` - -2. 重启应用服务 - -3. 使用测试脚本验证功能: - ```bash - ./test-water-records.sh - ``` - -## 注意事项 - -1. 喝水目标字段是可选的,可以为空 -2. 喝水记录的来源默认为 'manual' -3. 喝水量的范围限制在 1-5000 毫升之间 -4. 喝水目标的范围限制在 500-10000 毫升之间 -5. 获取profile接口会返回用户的喝水目标 -6. 喝水目标的更新集成在喝水接口中,避免用户服务文件过大 \ No newline at end of file diff --git a/docs/topic-favorite-api.md b/docs/topic-favorite-api.md deleted file mode 100644 index 891f2c2..0000000 --- a/docs/topic-favorite-api.md +++ /dev/null @@ -1,230 +0,0 @@ -# 话题收藏功能 API 文档 - -## 概述 - -话题收藏功能允许用户收藏喜欢的话题,方便后续查看和使用。本文档描述了话题收藏相关的所有API接口。 - -## 功能特性 - -- ✅ 收藏话题 -- ✅ 取消收藏话题 -- ✅ 获取收藏话题列表 -- ✅ 话题列表中显示收藏状态 -- ✅ 防重复收藏 -- ✅ 完整的错误处理 - -## API 接口 - -### 1. 收藏话题 - -**接口地址:** `POST /api/topic/favorite` - -**请求参数:** -```json -{ - "topicId": 123 -} -``` - -**响应示例:** -```json -{ - "code": 200, - "message": "收藏成功", - "data": { - "success": true, - "isFavorited": true, - "topicId": 123 - } -} -``` - -**错误情况:** -- 话题不存在:`{ "code": 400, "message": "话题不存在" }` -- 已收藏:`{ "code": 200, "message": "已经收藏过该话题" }` - -### 2. 取消收藏话题 - -**接口地址:** `POST /api/topic/unfavorite` - -**请求参数:** -```json -{ - "topicId": 123 -} -``` - -**响应示例:** -```json -{ - "code": 200, - "message": "取消收藏成功", - "data": { - "success": true, - "isFavorited": false, - "topicId": 123 - } -} -``` - -### 3. 获取收藏话题列表 - -**接口地址:** `POST /api/topic/favorites` - -**请求参数:** -```json -{ - "page": 1, - "pageSize": 10 -} -``` - -**响应示例:** -```json -{ - "code": 200, - "message": "success", - "data": { - "list": [ - { - "id": 123, - "topic": "约会话题", - "opening": { - "text": "今天天气真不错...", - "scenarios": ["咖啡厅", "公园"] - }, - "scriptType": "初识破冰", - "scriptTopic": "天气", - "keywords": "天气,约会,轻松", - "isFavorited": true, - "createdAt": "2024-01-01T00:00:00.000Z", - "updatedAt": "2024-01-01T00:00:00.000Z" - } - ], - "total": 5, - "page": 1, - "pageSize": 10 - } -} -``` - -### 4. 获取话题列表(已包含收藏状态) - -**接口地址:** `POST /api/topic/list` - -现在所有话题列表都会包含 `isFavorited` 字段,表示当前用户是否已收藏该话题。 - -**响应示例:** -```json -{ - "code": 200, - "message": "success", - "data": { - "list": [ - { - "id": 123, - "topic": "约会话题", - "opening": { - "text": "今天天气真不错...", - "scenarios": ["咖啡厅", "公园"] - }, - "scriptType": "初识破冰", - "scriptTopic": "天气", - "keywords": "天气,约会,轻松", - "isFavorited": true, // ← 新增的收藏状态字段 - "createdAt": "2024-01-01T00:00:00.000Z", - "updatedAt": "2024-01-01T00:00:00.000Z" - }, - { - "id": 124, - "topic": "工作话题", - "opening": "关于工作的开场白...", - "scriptType": "深度交流", - "scriptTopic": "职业", - "keywords": "工作,职业,发展", - "isFavorited": false, // ← 未收藏 - "createdAt": "2024-01-01T00:00:00.000Z", - "updatedAt": "2024-01-01T00:00:00.000Z" - } - ], - "total": 20, - "page": 1, - "pageSize": 10 - } -} -``` - -## 数据库变更 - -### 新增表:t_topic_favorites - -```sql -CREATE TABLE `t_topic_favorites` ( - `id` int NOT NULL AUTO_INCREMENT, - `user_id` varchar(255) NOT NULL COMMENT '用户ID', - `topic_id` int NOT NULL COMMENT '话题ID', - `created_at` datetime DEFAULT CURRENT_TIMESTAMP, - `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - UNIQUE KEY `unique_user_topic_favorite` (`user_id`, `topic_id`), - KEY `idx_user_id` (`user_id`), - KEY `idx_topic_id` (`topic_id`), - FOREIGN KEY (`user_id`) REFERENCES `t_users` (`id`) ON DELETE CASCADE, - FOREIGN KEY (`topic_id`) REFERENCES `t_topic_library` (`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -``` - -## 使用场景 - -### 1. 用户收藏话题 -```javascript -// 收藏话题 -const response = await fetch('/api/topic/favorite', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your-jwt-token' - }, - body: JSON.stringify({ topicId: 123 }) -}); -``` - -### 2. 取消收藏话题 -```javascript -// 取消收藏 -const response = await fetch('/api/topic/unfavorite', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your-jwt-token' - }, - body: JSON.stringify({ topicId: 123 }) -}); -``` - -### 3. 查看收藏列表 -```javascript -// 获取收藏的话题 -const response = await fetch('/api/topic/favorites', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your-jwt-token' - }, - body: JSON.stringify({ page: 1, pageSize: 10 }) -}); -``` - -## 注意事项 - -1. **防重复收藏:** 数据库层面通过唯一索引保证同一用户不能重复收藏同一话题 -2. **级联删除:** 用户删除或话题删除时,相关收藏记录会自动删除 -3. **性能优化:** 获取话题列表时通过单次查询获取用户所有收藏状态,避免N+1查询问题 -4. **权限控制:** 所有接口都需要用户登录(JWT认证) - -## 错误码说明 - -- `200`: 操作成功 -- `400`: 请求参数错误(如话题不存在) -- `401`: 未授权(需要登录) -- `500`: 服务器内部错误 \ No newline at end of file diff --git a/docs/water-records-api.md b/docs/water-records-api.md deleted file mode 100644 index ea37d65..0000000 --- a/docs/water-records-api.md +++ /dev/null @@ -1,473 +0,0 @@ -# 喝水记录 API 客户端接入说明 - -## 概述 - -喝水记录 API 提供了完整的喝水记录管理功能,包括记录创建、查询、更新、删除,以及喝水目标设置和统计查询等功能。 - -## 基础信息 - -- **Base URL**: `https://your-api-domain.com/api` -- **认证方式**: JWT Bearer Token -- **Content-Type**: `application/json` - -## 认证 - -所有 API 请求都需要在请求头中包含有效的 JWT Token: - -```http -Authorization: Bearer -``` - -## 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封装提供了完整的喝水记录管理功能,可以直接在客户端项目中使用。 \ No newline at end of file diff --git a/docs/winston-logger-guide.md b/docs/winston-logger-guide.md deleted file mode 100644 index 77af128..0000000 --- a/docs/winston-logger-guide.md +++ /dev/null @@ -1,233 +0,0 @@ -# Winston Logger 配置指南 - -本项目已配置了基于 Winston 的日志系统,支持日志文件输出、按日期滚动和自动清理。 - -## 功能特性 - -- ✅ **日志文件输出**: 自动将日志写入文件 -- ✅ **按日期滚动**: 每天生成新的日志文件 -- ✅ **自动清理**: 保留最近7天的日志文件 -- ✅ **分级日志**: 支持不同级别的日志分离 -- ✅ **结构化日志**: 支持JSON格式的结构化日志 -- ✅ **异常处理**: 自动记录未捕获的异常和Promise拒绝 - -## 日志文件结构 - -``` -logs/ -├── app-2025-07-21.log # 应用日志 (info级别及以上) -├── error-2025-07-21.log # 错误日志 (error级别) -├── debug-2025-07-21.log # 调试日志 (仅开发环境) -├── exceptions-2025-07-21.log # 未捕获异常 -├── rejections-2025-07-21.log # 未处理的Promise拒绝 -└── .audit-*.json # 日志轮转审计文件 -``` - -## 配置说明 - -### 日志级别 -- **生产环境**: `info` 及以上级别 -- **开发环境**: `debug` 及以上级别 - -### 文件轮转配置 -- **日期模式**: `YYYY-MM-DD` -- **保留天数**: 7天 -- **单文件大小**: 最大20MB -- **自动压缩**: 支持 - -## 使用方法 - -### 1. 在服务中使用 NestJS Logger - -```typescript -import { Injectable, Logger } from '@nestjs/common'; - -@Injectable() -export class YourService { - private readonly logger = new Logger(YourService.name); - - someMethod() { - this.logger.log('这是一条信息日志'); - this.logger.warn('这是一条警告日志'); - this.logger.error('这是一条错误日志'); - this.logger.debug('这是一条调试日志'); - } -} -``` - -### 2. 直接使用 Winston Logger (推荐用于结构化日志) - -```typescript -import { Injectable, Inject } from '@nestjs/common'; -import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; -import { Logger as WinstonLogger } from 'winston'; - -@Injectable() -export class YourService { - constructor( - @Inject(WINSTON_MODULE_PROVIDER) - private readonly winstonLogger: WinstonLogger, - ) {} - - someMethod() { - // 结构化日志 - this.winstonLogger.info('用户登录', { - context: 'AuthService', - userId: 'user123', - email: 'user@example.com', - timestamp: new Date().toISOString() - }); - - // 错误日志 - this.winstonLogger.error('数据库连接失败', { - context: 'DatabaseService', - error: 'Connection timeout', - retryCount: 3 - }); - } -} -``` - -### 3. 在控制器中使用 - -```typescript -import { Controller, Logger } from '@nestjs/common'; - -@Controller('users') -export class UsersController { - private readonly logger = new Logger(UsersController.name); - - @Get() - findAll() { - this.logger.log('获取用户列表请求'); - // 业务逻辑 - } -} -``` - -## 日志格式 - -### 控制台输出格式 -``` -2025-07-21 10:08:38 info [ServiceName] 日志消息 -``` - -### 文件输出格式 -``` -2025-07-21 10:08:38 [INFO] [ServiceName] 日志消息 -``` - -### 结构化日志格式 -```json -{ - "timestamp": "2025-07-21 10:08:38", - "level": "info", - "message": "用户登录", - "context": "AuthService", - "userId": "user123", - "email": "user@example.com" -} -``` - -## 环境变量配置 - -可以通过环境变量调整日志行为: - -```bash -# 日志级别 (development: debug, production: info) -NODE_ENV=production - -# 自定义日志目录 (可选) -LOG_DIR=/var/log/pilates-server -``` - -## 最佳实践 - -### 1. 使用合适的日志级别 -- `error`: 错误和异常 -- `warn`: 警告信息 -- `info`: 重要的业务信息 -- `debug`: 调试信息 (仅开发环境) - -### 2. 结构化日志 -对于重要的业务事件,使用结构化日志: - -```typescript -this.winstonLogger.info('订单创建', { - context: 'OrderService', - orderId: order.id, - userId: user.id, - amount: order.amount, - currency: 'CNY' -}); -``` - -### 3. 错误日志包含上下文 -```typescript -try { - // 业务逻辑 -} catch (error) { - this.winstonLogger.error('处理订单失败', { - context: 'OrderService', - orderId: order.id, - error: error.message, - stack: error.stack - }); -} -``` - -### 4. 避免敏感信息 -不要在日志中记录密码、令牌等敏感信息: - -```typescript -// ❌ 错误 -this.logger.log(`用户登录: ${JSON.stringify(loginData)}`); - -// ✅ 正确 -this.logger.log(`用户登录: ${loginData.email}`); -``` - -## 监控和维护 - -### 查看实时日志 -```bash -# 查看应用日志 -tail -f logs/app-$(date +%Y-%m-%d).log - -# 查看错误日志 -tail -f logs/error-$(date +%Y-%m-%d).log -``` - -### 日志分析 -```bash -# 统计错误数量 -grep -c "ERROR" logs/app-*.log - -# 查找特定用户的日志 -grep "userId.*user123" logs/app-*.log -``` - -### 清理旧日志 -日志系统会自动清理7天前的日志文件,无需手动维护。 - -## 故障排除 - -### 1. 日志文件未生成 -- 检查 `logs` 目录权限 -- 确认应用有写入权限 -- 查看控制台是否有错误信息 - -### 2. 日志级别不正确 -- 检查 `NODE_ENV` 环境变量 -- 确认 winston 配置中的日志级别设置 - -### 3. 日志文件过大 -- 检查日志轮转配置 -- 确认 `maxSize` 和 `maxFiles` 设置 - -## 相关文件 - -- [`src/common/logger/winston.config.ts`](../src/common/logger/winston.config.ts) - Winston 配置 -- [`src/common/logger/logger.module.ts`](../src/common/logger/logger.module.ts) - Logger 模块 -- [`src/main.ts`](../src/main.ts) - 应用启动配置 -- [`src/app.module.ts`](../src/app.module.ts) - 应用模块配置 \ No newline at end of file diff --git a/docs/workout-sessions-api-guide.md b/docs/workout-sessions-api-guide.md deleted file mode 100644 index 2466fa1..0000000 --- a/docs/workout-sessions-api-guide.md +++ /dev/null @@ -1,245 +0,0 @@ -# 训练会话 API 使用指南 - -## 架构说明 - -新的训练系统采用了分离的架构设计,符合健身应用的最佳实践: - -### 1. 训练计划模板 (Training Plans) -- **用途**: 用户创建和管理的训练计划模板 -- **表**: `t_training_plans` + `t_schedule_exercises` -- **特点**: 静态配置,不包含完成状态 -- **API**: `/training-plans` - -### 2. 训练会话实例 (Workout Sessions) -- **用途**: 每日实际训练,从训练计划模板复制而来 -- **表**: `t_workout_sessions` + `t_workout_exercises` -- **特点**: 动态数据,包含完成状态、进度追踪 -- **API**: `/workouts` - -## API 使用流程 - -### 第一步:创建训练计划模板 - -```bash -# 1. 创建训练计划 -POST /training-plans -{ - "name": "全身力量训练", - "startDate": "2024-01-15T00:00:00.000Z", - "mode": "daysOfWeek", - "daysOfWeek": [1, 3, 5], - "goal": "core_strength" -} - -# 2. 添加训练动作到计划 -POST /training-plans/{planId}/exercises -{ - "exerciseKey": "squat", - "name": "深蹲训练", - "sets": 3, - "reps": 15, - "itemType": "exercise" -} - -# 3. 激活训练计划 -POST /training-plans/{planId}/activate -``` - -### 第二步:开始训练(三种方式) - -#### 方式一:自动获取今日训练(推荐) -```bash -# 获取今日训练会话(如不存在则自动创建) -GET /workouts/today -# 系统会自动基于激活的训练计划创建今日训练会话 -``` - -#### 方式二:基于训练计划手动创建 -```bash -POST /workouts/sessions -{ - "trainingPlanId": "{planId}", - "name": "晚间训练", - "scheduledDate": "2024-01-15T19:00:00.000Z" -} -``` - -#### 方式三:创建完全自定义训练 -```bash -POST /workouts/sessions -{ - "name": "自定义核心训练", - "scheduledDate": "2024-01-15T15:00:00.000Z", - "customExercises": [ - { - "exerciseKey": "plank", - "name": "平板支撑", - "plannedDurationSec": 60, - "itemType": "exercise", - "sortOrder": 1 - }, - { - "name": "休息", - "plannedDurationSec": 30, - "itemType": "rest", - "sortOrder": 2 - }, - { - "name": "自定义深蹲变式", - "plannedSets": 3, - "plannedReps": 20, - "note": "脚距离肩膀更宽", - "itemType": "exercise", - "sortOrder": 3 - } - ] -} -``` - -### 第三步:执行训练 - -```bash -# 1. 开始训练会话(可选) -POST /workouts/sessions/{sessionId}/start - -# 2. 动态添加动作(如果需要) -POST /workouts/sessions/{sessionId}/exercises -{ - "name": "额外的拉伸", - "plannedDurationSec": 120, - "itemType": "exercise" -} - -# 3. 开始特定动作 -POST /workouts/sessions/{sessionId}/exercises/{exerciseId}/start - -# 4. 完成动作 -POST /workouts/sessions/{sessionId}/exercises/{exerciseId}/complete -{ - "completedSets": 3, - "completedReps": 15, - "actualDurationSec": 180, - "performanceData": { - "sets": [ - { "reps": 15, "difficulty": 7 }, - { "reps": 12, "difficulty": 8 }, - { "reps": 10, "difficulty": 9 } - ], - "perceivedExertion": 8 - } -} - -# 注意:当所有动作完成后,训练会话会自动标记为完成 -``` - -## 主要优势 - -### 1. 数据分离 -- 训练计划是可重用的模板 -- 每日训练是独立的实例 -- 修改计划不影响历史训练记录 - -### 2. 灵活的创建方式 -- 自动创建:基于激活计划的今日训练 -- 计划创建:基于指定训练计划创建 -- 自定义创建:完全自定义的训练动作 - -### 3. 进度追踪 -- 每个训练会话都有完整的状态跟踪 -- 支持详细的性能数据记录 -- 可以分析训练趋势和进步情况 - -### 4. 动态调整能力 -- 支持训练中动态添加动作 -- 支持跳过或修改特定动作 -- 自动完成会话管理 -- 自动计算训练统计数据 - -## API 端点总览 - -### 训练会话管理 -- `GET /workouts/today` - 获取/自动创建今日训练会话 ⭐ -- `POST /workouts/sessions` - 手动创建训练会话(支持基于计划或自定义动作) -- `GET /workouts/sessions` - 获取训练会话列表 -- `GET /workouts/sessions/{id}` - 获取训练会话详情 -- `POST /workouts/sessions/{id}/start` - 开始训练(可选) -- `DELETE /workouts/sessions/{id}` - 删除训练会话 -- 注意:训练会话在所有动作完成后自动完成 - -### 训练动作管理 -- `POST /workouts/sessions/{id}/exercises` - 向训练会话添加自定义动作 ⭐ -- `GET /workouts/sessions/{id}/exercises` - 获取训练动作列表 -- `GET /workouts/sessions/{id}/exercises/{exerciseId}` - 获取动作详情 -- `POST /workouts/sessions/{id}/exercises/{exerciseId}/start` - 开始动作 -- `POST /workouts/sessions/{id}/exercises/{exerciseId}/complete` - 完成动作 -- `POST /workouts/sessions/{id}/exercises/{exerciseId}/skip` - 跳过动作 -- `PUT /workouts/sessions/{id}/exercises/{exerciseId}` - 更新动作信息 - -### 统计和快捷功能 -- `GET /workouts/sessions/{id}/stats` - 获取训练统计 -- `GET /workouts/recent` - 获取最近训练 - -## 数据模型 - -### WorkoutSession (训练会话) -```typescript -{ - id: string; - userId: string; - trainingPlanId: string; // 关联的训练计划模板 - name: string; - scheduledDate: Date; - startedAt?: Date; - completedAt?: Date; - status: 'planned' | 'in_progress' | 'completed' | 'skipped'; - totalDurationSec?: number; - summary?: string; - caloriesBurned?: number; - stats?: { - totalExercises: number; - completedExercises: number; - totalSets: number; - completedSets: number; - // ... - }; -} -``` - -### WorkoutExercise (训练动作实例) -```typescript -{ - id: string; - workoutSessionId: string; - exerciseKey?: string; // 关联动作库 - name: string; - plannedSets?: number; // 计划数值 - completedSets?: number; // 实际完成数值 - plannedReps?: number; - completedReps?: number; - plannedDurationSec?: number; - actualDurationSec?: number; - status: 'pending' | 'in_progress' | 'completed' | 'skipped'; - startedAt?: Date; - completedAt?: Date; - performanceData?: { // 详细性能数据 - sets: Array<{ - reps?: number; - weight?: number; - difficulty?: number; - notes?: string; - }>; - heartRate?: { avg: number; max: number }; - perceivedExertion?: number; // RPE 1-10 - }; -} -``` - -## 迁移说明 - -如果您之前使用了 `training-plans` 的完成状态功能,现在需要: - -1. 使用 `/workouts/sessions` 来创建每日训练 -2. 使用新的完成状态 API 来跟踪进度 -3. 原有的训练计划数据保持不变,作为模板使用 - -这样的架构分离使得系统更加清晰、可维护,也更符合健身应用的实际使用场景。 diff --git a/package-lock.json b/package-lock.json index ecc6b96..bb19bf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@nestjs/schedule": "^6.0.1", "@nestjs/sequelize": "^11.0.0", "@nestjs/swagger": "^11.1.0", + "@nestjs/throttler": "^6.4.0", "@openrouter/sdk": "^0.1.27", "@parse/node-apn": "^5.0.0", "@types/jsonwebtoken": "^9.0.9", @@ -2559,6 +2560,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.4.0", + "resolved": "https://mirrors.tencent.com/npm/@nestjs/throttler/-/throttler-6.4.0.tgz", + "integrity": "sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 5beeb77..cbec5fe 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@nestjs/schedule": "^6.0.1", "@nestjs/sequelize": "^11.0.0", "@nestjs/swagger": "^11.1.0", + "@nestjs/throttler": "^6.4.0", "@openrouter/sdk": "^0.1.27", "@parse/node-apn": "^5.0.0", "@types/jsonwebtoken": "^9.0.9", diff --git a/sql-scripts/create_ai_report_history.sql b/sql-scripts/create_ai_report_history.sql new file mode 100644 index 0000000..9c47908 --- /dev/null +++ b/sql-scripts/create_ai_report_history.sql @@ -0,0 +1,21 @@ +-- AI 健康报告生成历史记录表 +-- 记录每次生成的报告信息,包括 prompt 和图片地址 + +CREATE TABLE IF NOT EXISTS `t_ai_report_history` ( + `id` VARCHAR(36) NOT NULL COMMENT '记录ID', + `user_id` VARCHAR(36) NOT NULL COMMENT '用户ID', + `report_date` DATE NOT NULL COMMENT '报告日期', + `prompt` TEXT NOT NULL COMMENT '生成图像使用的 Prompt', + `image_url` VARCHAR(500) NOT NULL COMMENT '生成的图片地址', + `api_provider` VARCHAR(20) DEFAULT NULL COMMENT 'API 提供商: openrouter | grsai', + `model_name` VARCHAR(50) DEFAULT NULL COMMENT '使用的模型名称', + `generation_time_ms` INT DEFAULT NULL COMMENT '生成耗时(毫秒)', + `status` VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '生成状态: success | failed', + `error_message` TEXT DEFAULT NULL COMMENT '失败原因(如果失败)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_report_date` (`report_date`), + INDEX `idx_user_report_date` (`user_id`, `report_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI健康报告生成历史'; diff --git a/src/ai-coach/ai-coach.module.ts b/src/ai-coach/ai-coach.module.ts index 0aacbe2..174e0c1 100644 --- a/src/ai-coach/ai-coach.module.ts +++ b/src/ai-coach/ai-coach.module.ts @@ -8,12 +8,14 @@ import { AiReportService } from './services/ai-report.service'; import { AiMessage } from './models/ai-message.model'; import { AiConversation } from './models/ai-conversation.model'; import { PostureAssessment } from './models/posture-assessment.model'; +import { AiReportHistory } from './models/ai-report-history.model'; import { UsersModule } from '../users/users.module'; import { DietRecordsModule } from '../diet-records/diet-records.module'; import { MedicationsModule } from '../medications/medications.module'; import { WorkoutsModule } from '../workouts/workouts.module'; import { MoodCheckinsModule } from '../mood-checkins/mood-checkins.module'; import { WaterRecordsModule } from '../water-records/water-records.module'; +import { ChallengesModule } from '../challenges/challenges.module'; import { CosService } from '../users/cos.service'; @Module({ @@ -25,7 +27,8 @@ import { CosService } from '../users/cos.service'; forwardRef(() => WorkoutsModule), forwardRef(() => MoodCheckinsModule), forwardRef(() => WaterRecordsModule), - SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]), + forwardRef(() => ChallengesModule), + SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment, AiReportHistory]), ], controllers: [AiCoachController], providers: [AiCoachService, DietAnalysisService, AiReportService, CosService], diff --git a/src/ai-coach/dto/ai-report-history.dto.ts b/src/ai-coach/dto/ai-report-history.dto.ts new file mode 100644 index 0000000..3ec3f8c --- /dev/null +++ b/src/ai-coach/dto/ai-report-history.dto.ts @@ -0,0 +1,93 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsNumber, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ResponseCode } from '../../base.dto'; + +/** + * AI 健康报告历史记录查询 DTO + */ +export class GetAiReportHistoryQueryDto { + @ApiPropertyOptional({ description: '页码,从1开始', default: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ description: '每页条数,默认10,最大50', default: 10 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(50) + pageSize?: number = 10; + + @ApiPropertyOptional({ description: '开始日期,格式 YYYY-MM-DD' }) + @IsOptional() + @IsString() + startDate?: string; + + @ApiPropertyOptional({ description: '结束日期,格式 YYYY-MM-DD' }) + @IsOptional() + @IsString() + endDate?: string; + + @ApiPropertyOptional({ description: '状态筛选: pending | processing | success | failed,不传则返回所有状态' }) + @IsOptional() + @IsString() + status?: string; +} + +/** + * AI 健康报告历史记录项 + */ +export class AiReportHistoryItemDto { + @ApiProperty({ description: '记录ID' }) + id: string; + + @ApiProperty({ description: '报告日期,格式 YYYY-MM-DD' }) + reportDate: string; + + @ApiProperty({ description: '生成的图片地址' }) + imageUrl: string; + + @ApiProperty({ description: '生成状态: success | failed' }) + status: string; + + @ApiProperty({ description: '创建时间' }) + createdAt: Date; +} + +/** + * AI 健康报告历史记录列表响应 + */ +export class AiReportHistoryListDto { + @ApiProperty({ description: '记录列表', type: [AiReportHistoryItemDto] }) + records: AiReportHistoryItemDto[]; + + @ApiProperty({ description: '总记录数' }) + total: number; + + @ApiProperty({ description: '当前页码' }) + page: number; + + @ApiProperty({ description: '每页条数' }) + pageSize: number; + + @ApiProperty({ description: '总页数' }) + totalPages: number; +} + +/** + * 获取 AI 健康报告历史记录响应 DTO + */ +export class GetAiReportHistoryResponseDto { + @ApiProperty({ description: '响应状态码', example: ResponseCode.SUCCESS }) + declare code: ResponseCode; + + @ApiProperty({ description: '响应消息', example: 'success' }) + declare message: string; + + @ApiProperty({ description: '报告历史数据', type: AiReportHistoryListDto }) + declare data: AiReportHistoryListDto; +} diff --git a/src/ai-coach/models/ai-report-history.model.ts b/src/ai-coach/models/ai-report-history.model.ts new file mode 100644 index 0000000..e3dbcd8 --- /dev/null +++ b/src/ai-coach/models/ai-report-history.model.ts @@ -0,0 +1,110 @@ +import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript'; + +/** + * AI 报告生成状态枚举 + */ +export enum AiReportStatus { + PENDING = 'pending', // 未开始 + PROCESSING = 'processing', // 进行中 + SUCCESS = 'success', // 已完成 + FAILED = 'failed', // 已失败 +} + +/** + * AI 健康报告生成历史记录表 + * 记录每次生成的报告信息,包括 prompt 和图片地址 + */ +@Table({ + tableName: 't_ai_report_history', + underscored: true, +}) +export class AiReportHistory extends Model { + @PrimaryKey + @Column({ + type: DataType.STRING(36), + allowNull: false, + comment: '记录ID', + }) + declare id: string; + + @Index + @Column({ + type: DataType.STRING(36), + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @Index + @Column({ + type: DataType.DATEONLY, + allowNull: false, + comment: '报告日期', + }) + declare reportDate: string; + + @Column({ + type: DataType.TEXT, + allowNull: true, + comment: '生成图像使用的 Prompt', + }) + declare prompt: string | null; + + @Column({ + type: DataType.STRING(500), + allowNull: true, + comment: '生成的图片地址', + }) + declare imageUrl: string | null; + + @Column({ + type: DataType.STRING(20), + allowNull: true, + comment: 'API 提供商: openrouter | grsai', + }) + declare apiProvider: string | null; + + @Column({ + type: DataType.STRING(50), + allowNull: true, + comment: '使用的模型名称', + }) + declare modelName: string | null; + + @Column({ + type: DataType.INTEGER, + allowNull: true, + comment: '生成耗时(毫秒)', + }) + declare generationTimeMs: number | null; + + @Index + @Column({ + type: DataType.STRING(20), + allowNull: false, + defaultValue: AiReportStatus.PENDING, + comment: '生成状态: pending | processing | success | failed', + }) + declare status: string; + + @Column({ + type: DataType.TEXT, + allowNull: true, + comment: '失败原因(如果失败)', + }) + declare errorMessage: string | null; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + comment: '创建时间', + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + comment: '更新时间', + }) + declare updatedAt: Date; +} diff --git a/src/ai-coach/services/ai-report.service.ts b/src/ai-coach/services/ai-report.service.ts index e910c72..09fb4c1 100644 --- a/src/ai-coach/services/ai-report.service.ts +++ b/src/ai-coach/services/ai-report.service.ts @@ -1,9 +1,12 @@ import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { AxiosResponse, AxiosRequestConfig, AxiosResponseHeaders } from 'axios'; +import { InjectModel } from '@nestjs/sequelize'; +import { Op } from 'sequelize'; import * as dayjs from 'dayjs'; -import { OpenRouter } from '@openrouter/sdk'; +import { v4 as uuidv4 } from 'uuid'; +import OpenAI from 'openai'; import { CosService } from '../../users/cos.service'; +import { AiReportHistory, AiReportStatus } from '../models/ai-report-history.model'; // 假设各个模块的服务都已正确导出 import { UsersService } from '../../users/users.service'; @@ -12,6 +15,7 @@ import { DietRecordsService } from '../../diet-records/diet-records.service'; import { WaterRecordsService } from '../../water-records/water-records.service'; import { MoodCheckinsService } from '../../mood-checkins/mood-checkins.service'; import { WorkoutsService } from '../../workouts/workouts.service'; +import { ChallengesService } from '../../challenges/challenges.service'; /** * 聚合的每日健康数据接口 @@ -52,6 +56,9 @@ user?: { latestChest?: number; latestWaist?: number; }; + challenges: { + activeChallengeCount: number; + }; } @Injectable() @@ -60,6 +67,8 @@ export class AiReportService { constructor( private readonly configService: ConfigService, + @InjectModel(AiReportHistory) + private readonly aiReportHistoryModel: typeof AiReportHistory, @Inject(forwardRef(() => UsersService)) private readonly usersService: UsersService, @Inject(forwardRef(() => MedicationStatsService)) @@ -72,31 +81,216 @@ export class AiReportService { private readonly moodCheckinsService: MoodCheckinsService, @Inject(forwardRef(() => WorkoutsService)) private readonly workoutsService: WorkoutsService, + @Inject(forwardRef(() => ChallengesService)) + private readonly challengesService: ChallengesService, private readonly cosService: CosService, ) {} + /** + * 检查用户当天成功生成的报告数量 + * @param userId 用户ID + * @param date 日期,格式 YYYY-MM-DD + * @returns 当天成功生成的报告数量 + */ + private async getTodaySuccessfulReportCount(userId: string, date: string): Promise { + const count = await this.aiReportHistoryModel.count({ + where: { + userId, + reportDate: date, + status: AiReportStatus.SUCCESS, + }, + }); + return count; + } + /** * 主入口:生成用户的AI健康报告图片 + * 流程: + * 0. 检查当天生成次数限制(最多2次) + * 1. 创建记录,状态为 processing(进行中) + * 2. 聚合数据、生成 Prompt、调用图像生成 API + * 3. 根据结果更新记录状态为 success 或 failed + * * @param userId 用户ID * @param date 目标日期,格式 YYYY-MM-DD,默认为今天 - * @returns 图片的 URL 或 Base64 数据 + * @returns 包含记录ID和图片URL(如果成功) */ - async generateHealthReportImage(userId: string, date?: string): Promise<{ imageUrl: string }> { + async generateHealthReportImage(userId: string, date?: string): Promise<{ id: string; imageUrl: string }> { const targetDate = date || dayjs().format('YYYY-MM-DD'); this.logger.log(`开始为用户 ${userId} 生成 ${targetDate} 的AI健康报告`); - // 1. 聚合数据 - const dailyData = await this.gatherDailyData(userId, targetDate); + // Step 0: 检查当天成功生成的报告数量,最多允许2次 + const maxDailyReports = 2; + const todaySuccessCount = await this.getTodaySuccessfulReportCount(userId, targetDate); + if (todaySuccessCount >= maxDailyReports) { + this.logger.warn(`用户 ${userId} 在 ${targetDate} 已成功生成 ${todaySuccessCount} 次报告,已达到每日上限`); + throw new Error(`每天最多只能生成 ${maxDailyReports} 次健康报告`); + } - // 2. 生成图像生成Prompt - const imagePrompt = await this.generateImagePrompt(dailyData); - this.logger.log(`为用户 ${userId} 生成了图像Prompt: ${imagePrompt}`); + const startTime = Date.now(); + const apiProvider = this.configService.get('IMAGE_API_PROVIDER') || 'openrouter'; + const recordId = uuidv4(); - // 3. 调用图像生成API - const imageUrl = await this.callImageGenerationApi(imagePrompt); - this.logger.log(`为用户 ${userId} 成功生成图像: ${imageUrl}`); + // Step 1: 创建记录,状态为 processing + await this.createReportRecord({ + id: recordId, + userId, + reportDate: targetDate, + apiProvider, + status: AiReportStatus.PROCESSING, + }); - return { imageUrl }; + let imagePrompt = ''; + let imageUrl = ''; + + try { + // Step 2: 聚合数据 + const dailyData = await this.gatherDailyData(userId, targetDate); + + // Step 3: 获取用户语言偏好 + const userLanguage = await this.getUserLanguage(userId); + + // Step 4: 生成图像生成Prompt + imagePrompt = await this.generateImagePrompt(dailyData, userLanguage); + this.logger.log(`为用户 ${userId} 生成了图像Prompt`); + + // 更新 Prompt 到记录 + await this.updateReportRecord(recordId, { prompt: imagePrompt }); + + // Step 5: 调用图像生成API + imageUrl = await this.callImageGenerationApi(imagePrompt); + this.logger.log(`为用户 ${userId} 成功生成图像: ${imageUrl}`); + + // Step 6: 更新记录状态为 success + await this.updateReportRecord(recordId, { + imageUrl, + status: AiReportStatus.SUCCESS, + generationTimeMs: Date.now() - startTime, + modelName: apiProvider === 'grsai' ? 'nano-banana-pro' : 'google/gemini-3-pro-image-preview', + }); + + return { id: recordId, imageUrl }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error(`为用户 ${userId} 生成报告失败: ${errorMessage}`); + + // Step 6: 更新记录状态为 failed + await this.updateReportRecord(recordId, { + prompt: imagePrompt || null, + status: AiReportStatus.FAILED, + errorMessage, + generationTimeMs: Date.now() - startTime, + modelName: apiProvider === 'grsai' ? 'nano-banana-pro' : 'google/gemini-3-pro-image-preview', + }); + + throw error; + } + } + + /** + * 创建报告记录 + */ + private async createReportRecord(data: { + id: string; + userId: string; + reportDate: string; + apiProvider: string; + status: AiReportStatus; + }): Promise { + try { + const record = await this.aiReportHistoryModel.create({ + id: data.id, + userId: data.userId, + reportDate: data.reportDate, + apiProvider: data.apiProvider, + status: data.status, + prompt: null, + imageUrl: null, + }); + this.logger.log(`创建报告记录成功,ID: ${data.id}, 状态: ${data.status}`); + return record; + } catch (error) { + this.logger.error(`创建报告记录失败: ${error instanceof Error ? error.message : String(error)}`); + throw error; + } + } + + /** + * 更新报告记录 + */ + private async updateReportRecord( + recordId: string, + data: Partial<{ + prompt: string | null; + imageUrl: string | null; + status: AiReportStatus; + errorMessage: string | null; + generationTimeMs: number; + modelName: string; + }>, + ): Promise { + try { + await this.aiReportHistoryModel.update(data, { + where: { id: recordId }, + }); + this.logger.log(`更新报告记录成功,ID: ${recordId}, 更新字段: ${Object.keys(data).join(', ')}`); + } catch (error) { + // 更新失败不影响主流程,仅记录日志 + this.logger.error(`更新报告记录失败: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * 获取用户的报告生成历史(分页) + * @param userId 用户ID + * @param options 查询选项 + */ + async getReportHistory( + userId: string, + options?: { page?: number; pageSize?: number; startDate?: string; endDate?: string; status?: string }, + ): Promise<{ records: AiReportHistory[]; total: number; page: number; pageSize: number; totalPages: number }> { + const { page = 1, pageSize = 10, startDate, endDate, status } = options || {}; + const offset = (page - 1) * pageSize; + + const where: any = { userId }; + + // 如果传了 status 参数则按指定状态筛选,否则返回所有状态 + if (status) { + where.status = status; + } + + if (startDate || endDate) { + where.reportDate = {}; + if (startDate) where.reportDate[Op.gte] = startDate; + if (endDate) where.reportDate[Op.lte] = endDate; + } + + const { rows, count } = await this.aiReportHistoryModel.findAndCountAll({ + where, + attributes: ['id', 'reportDate', 'imageUrl', 'status', 'createdAt'], + order: [['createdAt', 'DESC']], + limit: pageSize, + offset, + }); + + return { + records: rows, + total: count, + page, + pageSize, + totalPages: Math.ceil(count / pageSize) + }; + } + + /** + * 根据记录ID获取报告详情 + * @param recordId 记录ID + * @param userId 用户ID(用于权限校验) + */ + async getReportById(recordId: string, userId: string): Promise { + return this.aiReportHistoryModel.findOne({ + where: { id: recordId, userId }, + }); } /** @@ -106,14 +300,15 @@ export class AiReportService { const dayStart = dayjs(date).startOf('day').toISOString(); const dayEnd = dayjs(date).endOf('day').toISOString(); - const [userProfile, medicationStats, dietHistory, waterStats, moodStats] = await Promise.all([ + const [userProfile, medicationStats, dietHistory, waterStats, moodStats, activeChallengeCount] = await Promise.all([ this.usersService.getProfile({ sub: userId, email: '', isGuest: false } as any).then(p => p.data).catch(() => null), this.medicationStatsService.getDailyStats(userId, date).catch(() => null), // 获取当日饮食记录并聚合 this.dietRecordsService.getDietHistory(userId, { startDate: dayStart, endDate: dayEnd, limit: 100 }).catch(() => ({ records: [] })), this.waterRecordsService.getWaterStats(userId, date).catch(() => null), this.moodCheckinsService.getDaily(userId, date).catch(() => null), - // 获取最近的训练会话,并在后续筛选 + // 获取用户当前参与的活跃挑战数量 + this.challengesService.getActiveParticipatingChallengeCount(userId).catch(() => 0), ]); // 处理饮食数据聚合 @@ -173,22 +368,30 @@ export class AiReportService { latestChest: latestChest?.value, latestWaist: latestWaist?.value, }, + challenges: { + activeChallengeCount: activeChallengeCount, + }, }; } /** * 2. 生成固定格式的 prompt,适用于 Nano Banana Pro 模型 - * 优化:不再使用 LLM 生成 prompt,而是使用固定模版,并确保包含准确的中文文本 + * 优化:支持多语言,根据用户语言偏好生成对应的文本 */ - private async generateImagePrompt(data: DailyHealthData): Promise { - // 格式化日期为 "12月01日" - const dateStr = dayjs(data.date).format('MM月DD日'); + private async generateImagePrompt(data: DailyHealthData, language: string): Promise { + const isEnglish = language.toLowerCase().startsWith('en'); + + // 格式化日期 + const dateStr = isEnglish + ? dayjs(data.date).format('MMM DD') // "Dec 01" + : dayjs(data.date).format('MM月DD日'); // "12-01" // 准备数据文本 - const moodText = this.translateMood(data.mood.primaryMood); + const moodText = this.translateMood(data.mood.primaryMood, language); const medRate = Math.round(data.medications.completionRate); // 取整 const calories = Math.round(data.diet.totalCalories); const water = Math.round(data.water.totalAmount); + const challengeCount = data.challenges.activeChallengeCount; // 根据性别调整角色描述 let characterDesc = 'A happy cute character or animal mascot'; @@ -198,19 +401,46 @@ export class AiReportService { characterDesc = 'A happy cute girl character in yoga outfit'; } + // 构建多语言文本内容 + const textContent = isEnglish ? { + languageInstruction: 'Please render the following specific text in English correctly:', + title: `${dateStr} Health Report`, + medication: `Medication: ${medRate}%`, + diet: `Calories: ${calories} kcal`, + water: `Water: ${water}ml`, + mood: `Mood: ${moodText}`, + challenges: challengeCount > 0 ? `Challenges: ${challengeCount}` : null, + } : { + languageInstruction: 'Please render the following specific text in Chinese correctly:', + title: `${dateStr} 健康日报`, + medication: `用药: ${medRate}%`, + diet: `热量: ${calories}千卡`, + water: `饮水: ${water}ml`, + mood: `心情: ${moodText}`, + challenges: challengeCount > 0 ? `挑战: ${challengeCount}个` : null, + }; + + // 构建挑战相关的 prompt 部分 + const challengeTextSection = textContent.challenges + ? `- Challenge section text: "${textContent.challenges}"\n` + : ''; + const challengeIconSection = challengeCount > 0 + ? `- Icon for challenges (trophy or flag icon representing ${challengeCount} active ${challengeCount === 1 ? 'challenge' : 'challenges'}).\n` + : ''; + // 构建 Prompt // 格式:[风格描述] + [主体内容] + [文本渲染指令] + [细节描述] const prompt = ` A cute, hand-drawn style health journal page illustration, kawaii aesthetic, soft pastel colors, warm lighting. Vertical 9:16 aspect ratio. High quality, 1k resolution. The image features a cute layout with icons and text boxes. -Please render the following specific text in Chinese correctly: -- Title text: "${dateStr} 健康日报" -- Medication section text: "用药: ${medRate}%" -- Diet section text: "热量: ${calories}千卡" -- Water section text: "饮水: ${water}ml" -- Mood section text: "心情: ${moodText}" - +${textContent.languageInstruction} +- Title text: "${textContent.title}" +- Medication section text: "${textContent.medication}" +- Diet section text: "${textContent.diet}" +- Water section text: "${textContent.water}" +- Mood section text: "${textContent.mood}" +${challengeTextSection} Visual elements: - ${characterDesc} representing the user. - Icon for medication (pill bottle or pills). @@ -218,7 +448,7 @@ Visual elements: - Icon for water (water glass or drop). - Icon for exercise (sneakers or dumbbell). - Icon for mood (a smiley face representing ${moodText}). - +${challengeIconSection} Composition: Clean, organized, magazine layout style, decorative stickers and washi tape effects. `.trim(); @@ -226,10 +456,28 @@ Composition: Clean, organized, magazine layout style, decorative stickers and wa } /** - * 将心情类型翻译成中文(为了Prompt生成) + * 根据语言翻译心情类型 */ - private translateMood(moodType?: string): string { - const moodMap: Record = { + private translateMood(moodType?: string, language: string = 'zh-CN'): string { + const isEnglish = language.toLowerCase().startsWith('en'); + + if (isEnglish) { + const moodMapEn: Record = { + HAPPY: 'Happy', + EXCITED: 'Excited', + THRILLED: 'Thrilled', + CALM: 'Calm', + ANXIOUS: 'Anxious', + SAD: 'Sad', + LONELY: 'Lonely', + WRONGED: 'Wronged', + ANGRY: 'Angry', + TIRED: 'Tired', + }; + return moodMapEn[moodType || ''] || 'Calm'; + } + + const moodMapZh: Record = { HAPPY: '开心', EXCITED: '兴奋', THRILLED: '激动', @@ -241,107 +489,310 @@ Composition: Clean, organized, magazine layout style, decorative stickers and wa ANGRY: '生气', TIRED: '心累', }; - // 如果心情未知,返回"开心"以保持积极的氛围,或者使用"平和" - return moodMap[moodType || ''] || '开心'; + return moodMapZh[moodType || ''] || '平静'; } /** - * 3. 调用 OpenRouter SDK 生成图片并上传到 COS + * 获取用户语言偏好 + */ + private async getUserLanguage(userId: string): Promise { + try { + return await this.usersService.getUserLanguage(userId); + } catch (error) { + this.logger.error(`获取用户语言失败,使用默认中文: ${error instanceof Error ? error.message : String(error)}`); + return 'zh-CN'; + } + } + + /** + * 3. 调用图像生成 API,支持 OpenRouter 和 GRSAI 两种提供商 + * 通过环境变量 IMAGE_API_PROVIDER 配置:'openrouter' | 'grsai',默认 'openrouter' */ private async callImageGenerationApi(prompt: string): Promise { - this.logger.log(`准备调用 OpenRouter SDK 生成图像`); + const apiProvider = this.configService.get('IMAGE_API_PROVIDER') || 'openrouter'; + this.logger.log(`准备调用 ${apiProvider} 生成图像`); this.logger.log(`使用Prompt: ${prompt}`); - const openRouterApiKey = this.configService.get('OPENROUTER_API_KEY'); - if (!openRouterApiKey) { + if (apiProvider === 'grsai') { + return this.callGrsaiImageApi(prompt); + } else { + return this.callOpenRouterImageApi(prompt); + } + } + + /** + * 调用 OpenRouter API 生成图像 + */ + private async callOpenRouterImageApi(prompt: string): Promise { + const apiKey = this.configService.get('OPENROUTER_API_KEY'); + if (!apiKey) { this.logger.error('OpenRouter API Key 未配置'); throw new Error('OpenRouter API Key 未配置'); } try { - // 初始化 OpenRouter 客户端 - const openrouter = new OpenRouter({ - apiKey: openRouterApiKey, + const client = new OpenAI({ + baseURL: 'https://openrouter.ai/api/v1', + apiKey, }); - // 调用图像生成API - const result = await openrouter.chat.send({ - model: "google/gemini-3-pro-image-preview", + this.logger.log(`使用 OpenRouter API, 模型: google/gemini-3-pro-image-preview`); + + const apiResponse = await client.chat.completions.create({ + model: 'google/gemini-3-pro-image-preview', messages: [ { - role: "user", + role: 'user' as const, content: prompt, }, ], + // @ts-ignore - 扩展参数,用于支持图像生成 + modalities: ['image', 'text'], }); - const message = result.choices[0].message; - - // 处理不同格式的响应内容 - let imageData: string | undefined; - let isBase64 = false; - - if (typeof message.content === 'string') { - // 检查是否为 base64 数据 - // base64 图像通常以 data:image/ 开头,或者是纯 base64 字符串 - if (message.content.startsWith('data:image/')) { - // 完整的 data URL 格式:data:image/png;base64,xxxxx - imageData = message.content; - isBase64 = true; - this.logger.log('检测到 Data URL 格式的 base64 图像'); - } else if (/^[A-Za-z0-9+/=]+$/.test(message.content.substring(0, 100))) { - // 纯 base64 字符串(检查前100个字符) - imageData = message.content; - isBase64 = true; - this.logger.log('检测到纯 base64 格式的图像数据'); - } else { - // 尝试提取 HTTP URL - const urlMatch = message.content.match(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp)/i); - if (urlMatch) { - imageData = urlMatch[0]; - isBase64 = false; - this.logger.log(`检测到 HTTP URL: ${imageData}`); - } - } - } else if (Array.isArray(message.content)) { - // 检查内容数组中是否有图像项 - const imageItem = message.content.find(item => item.type === 'image_url'); - if (imageItem && imageItem.imageUrl) { - imageData = imageItem.imageUrl.url; - // 判断是 URL 还是 base64 - isBase64 = imageData.startsWith('data:image/') || /^[A-Za-z0-9+/=]+$/.test(imageData.substring(0, 100)); - } + const message = apiResponse.choices[0].message; + this.logger.log(`OpenRouter 收到响应: ${JSON.stringify(message, null, 2)}`); + + const imageData = this.extractImageData(message); + if (imageData) { + return await this.processAndUploadImage(imageData); } - if (imageData) { - if (isBase64) { - // 处理 base64 数据并上传到 COS - this.logger.log('开始处理 base64 图像数据'); - const cosImageUrl = await this.uploadBase64ToCos(imageData); - this.logger.log(`Base64 图像上传到 COS 成功: ${cosImageUrl}`); - return cosImageUrl; - } else { - // 下载 HTTP URL 图像并上传到 COS - this.logger.log(`OpenRouter 返回图像 URL: ${imageData}`); - const cosImageUrl = await this.downloadAndUploadToCos(imageData); - this.logger.log(`图像上传到 COS 成功: ${cosImageUrl}`); - return cosImageUrl; - } - } else { - this.logger.error('OpenRouter 响应中未包含图像数据'); - this.logger.error(`实际响应内容类型: ${typeof message.content}`); - this.logger.error(`实际响应内容: ${JSON.stringify(message.content).substring(0, 500)}`); - throw new Error('图像生成失败:响应中未包含图像数据'); - } + this.logger.error('OpenRouter 响应中未包含图像数据'); + this.logger.error(`实际响应内容: ${JSON.stringify(message).substring(0, 500)}`); + throw new Error('图像生成失败:响应中未包含图像数据'); } catch (error) { - this.logger.error(`调用 OpenRouter SDK 失败: ${error.message}`); - if (error.response) { - this.logger.error(`OpenRouter 错误详情: ${JSON.stringify(error.response.data)}`); - } + this.logger.error(`调用 OpenRouter 失败: ${error.message}`); throw error; } } + /** + * 调用 GRSAI API 生成图像(提交任务 + 轮询结果) + * API 文档: + * - 提交任务: POST https://grsai.dakka.com.cn/v1/draw/nano-banana + * - 查询结果: POST https://grsai.dakka.com.cn/v1/draw/result + */ + private async callGrsaiImageApi(prompt: string): Promise { + const apiKey = this.configService.get('GRSAI_API_KEY'); + if (!apiKey) { + this.logger.error('GRSAI API Key 未配置'); + throw new Error('GRSAI API Key 未配置'); + } + + const baseURL = 'https://grsai.dakka.com.cn'; + const axios = require('axios'); + + // 通用请求头 + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }; + + try { + // Step 1: 提交图像生成任务 + this.logger.log('GRSAI: 提交图像生成任务...'); + const submitResponse = await axios.post( + `${baseURL}/v1/draw/nano-banana`, + { + model: 'nano-banana-pro', + prompt: prompt, + webHook: '-1', // 必填 -1,表示不使用 webhook + }, + { headers, timeout: 30000 }, + ); + + // 检查提交响应 + if (submitResponse.data.code !== 0) { + this.logger.error(`GRSAI 任务提交失败: ${submitResponse.data.msg}`); + throw new Error(`GRSAI 任务提交失败: ${submitResponse.data.msg}`); + } + + const taskId = submitResponse.data.data?.id; + if (!taskId) { + this.logger.error('GRSAI 任务提交成功但未返回任务ID'); + throw new Error('GRSAI 任务提交失败:未返回任务ID'); + } + + this.logger.log(`GRSAI: 任务提交成功,任务ID: ${taskId}`); + + // Step 2: 轮询获取结果 + const imageUrl = await this.pollGrsaiResult(baseURL, headers, taskId); + + // Step 3: 下载图像并上传到 COS + return await this.processAndUploadImage(imageUrl); + } catch (error) { + this.logger.error(`调用 GRSAI 失败: ${error.message}`); + throw error; + } + } + + /** + * 轮询 GRSAI 任务结果 + * @param baseURL API 基础地址 + * @param headers 请求头 + * @param taskId 任务ID + * @returns 生成的图像 URL + */ + private async pollGrsaiResult( + baseURL: string, + headers: Record, + taskId: string, + ): Promise { + const axios = require('axios'); + + // 轮询配置 + const maxAttempts = 60; // 最大尝试次数 + const pollInterval = 2000; // 轮询间隔 2 秒 + const maxWaitTime = 120000; // 最大等待时间 2 分钟 + + const startTime = Date.now(); + let attempts = 0; + + while (attempts < maxAttempts) { + attempts++; + const elapsedTime = Date.now() - startTime; + + // 检查是否超时 + if (elapsedTime > maxWaitTime) { + this.logger.error(`GRSAI: 轮询超时,已等待 ${elapsedTime / 1000} 秒`); + throw new Error('GRSAI 图像生成超时'); + } + + this.logger.log(`GRSAI: 第 ${attempts} 次轮询,任务ID: ${taskId}`); + + try { + const resultResponse = await axios.post( + `${baseURL}/v1/draw/result`, + { id: taskId }, + { headers, timeout: 10000 }, + ); + + const data = resultResponse.data; + + // 检查响应码 + if (data.code !== 0) { + this.logger.error(`GRSAI 查询失败: ${data.msg}`); + throw new Error(`GRSAI 查询失败: ${data.msg}`); + } + + const taskData = data.data; + const status = taskData?.status; + const progress = taskData?.progress || 0; + + this.logger.log(`GRSAI: 任务状态: ${status}, 进度: ${progress}%`); + + // 根据状态处理 + switch (status) { + case 'succeeded': + // 任务成功,提取图像 URL + const results = taskData.results; + if (results && results.length > 0 && results[0].url) { + const imageUrl = results[0].url; + this.logger.log(`GRSAI: 图像生成成功: ${imageUrl}`); + return imageUrl; + } + throw new Error('GRSAI 任务成功但未返回图像URL'); + + case 'failed': + // 任务失败 + const failureReason = taskData.failure_reason || taskData.error || '未知错误'; + this.logger.error(`GRSAI: 任务失败: ${failureReason}`); + throw new Error(`GRSAI 图像生成失败: ${failureReason}`); + + case 'pending': + case 'processing': + case 'running': + // 任务进行中,继续轮询 + await this.sleep(pollInterval); + break; + + default: + // 未知状态,继续轮询 + this.logger.warn(`GRSAI: 未知任务状态: ${status}`); + await this.sleep(pollInterval); + } + } catch (error) { + // 如果是网络错误,继续重试 + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + this.logger.warn(`GRSAI: 轮询请求超时,继续重试...`); + await this.sleep(pollInterval); + continue; + } + throw error; + } + } + + throw new Error(`GRSAI 图像生成失败:超过最大轮询次数 ${maxAttempts}`); + } + + /** + * 延时函数 + */ + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * 从 API 响应中提取图像数据 + */ + private extractImageData(message: any): string | undefined { + let imageData: string | undefined; + + // 检查 images 数组(OpenRouter/GRSAI 特有的响应格式) + if (message.images && Array.isArray(message.images) && message.images.length > 0) { + const firstImage = message.images[0]; + if (firstImage.image_url?.url) { + imageData = firstImage.image_url.url; + this.logger.log(`检测到 images 数组中的图像数据`); + } + } + + // 如果 images 数组中没有,检查 content 字段 + if (!imageData && typeof message.content === 'string') { + // 检查是否为 base64 数据 + if (message.content.startsWith('data:image/')) { + imageData = message.content; + this.logger.log('检测到 Data URL 格式的 base64 图像'); + } else if (/^[A-Za-z0-9+/=]+$/.test(message.content.substring(0, 100))) { + imageData = message.content; + this.logger.log('检测到纯 base64 格式的图像数据'); + } else { + // 尝试提取 HTTP URL + const urlMatch = message.content.match(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp)/i); + if (urlMatch) { + imageData = urlMatch[0]; + this.logger.log(`检测到 HTTP URL: ${imageData}`); + } + } + } + + return imageData; + } + + /** + * 处理图像数据并上传到 COS + */ + private async processAndUploadImage(imageData: string): Promise { + // 判断是 base64 还是 URL + const isBase64 = imageData.startsWith('data:image/') || + (/^[A-Za-z0-9+/=]+$/.test(imageData.substring(0, 100)) && !imageData.startsWith('http')); + + if (isBase64) { + // 处理 base64 数据并上传到 COS + this.logger.log('开始处理 base64 图像数据'); + const cosImageUrl = await this.uploadBase64ToCos(imageData); + this.logger.log(`Base64 图像上传到 COS 成功: ${cosImageUrl}`); + return cosImageUrl; + } else { + // 下载 HTTP URL 图像并上传到 COS + this.logger.log(`API 返回图像 URL: ${imageData}`); + const cosImageUrl = await this.downloadAndUploadToCos(imageData); + this.logger.log(`图像上传到 COS 成功: ${cosImageUrl}`); + return cosImageUrl; + } + } + /** * 将 base64 图像数据上传到 COS */ diff --git a/src/app.module.ts b/src/app.module.ts index 5f9ff37..5f87940 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,12 @@ import { Module } from "@nestjs/common"; +import { APP_GUARD } from '@nestjs/core'; import { AppController } from "./app.controller"; import { AppService } from "./app.service"; import { DatabaseModule } from "./database/database.module"; import { UsersModule } from "./users/users.module"; import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { LoggerModule } from './common/logger/logger.module'; import { CheckinsModule } from './checkins/checkins.module'; import { AiCoachModule } from './ai-coach/ai-coach.module'; @@ -30,6 +32,10 @@ import { MedicationsModule } from './medications/medications.module'; envFilePath: '.env', }), ScheduleModule.forRoot(), + ThrottlerModule.forRoot([{ + ttl: 60000, // 时间窗口:60秒 + limit: 100, // 每个时间窗口最多100个请求 + }]), LoggerModule, DatabaseModule, UsersModule, @@ -51,6 +57,12 @@ import { MedicationsModule } from './medications/medications.module'; MedicationsModule, ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], }) export class AppModule { } diff --git a/src/challenges/challenges.service.ts b/src/challenges/challenges.service.ts index 3b84b6d..4e3a478 100644 --- a/src/challenges/challenges.service.ts +++ b/src/challenges/challenges.service.ts @@ -788,6 +788,37 @@ export class ChallengesService { return code; } + /** + * 获取用户当前参与的活跃挑战数量(正在进行中且未过期) + * @param userId 用户ID + * @returns 活跃挑战数量 + */ + async getActiveParticipatingChallengeCount(userId: string): Promise { + const now = new Date(); + + // 查询用户参与的所有活跃状态的挑战 + const participants = await this.participantModel.findAll({ + where: { + userId, + status: { + [Op.in]: [ChallengeParticipantStatus.ACTIVE, ChallengeParticipantStatus.COMPLETED], + }, + }, + include: [{ + model: Challenge, + as: 'challenge', + where: { + challengeState: ChallengeState.ACTIVE, + startAt: { [Op.lte]: now }, + endAt: { [Op.gte]: now }, + }, + required: true, + }], + }); + + return participants.length; + } + /** * 获取用户加入的自定义挑战 ID 列表 */ diff --git a/src/database/database.module.ts b/src/database/database.module.ts index d732fe2..c337720 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -22,7 +22,7 @@ import { ConfigService } from '@nestjs/config'; collate: 'utf8mb4_0900_ai_ci', }, autoLoadModels: true, - synchronize: true, + synchronize: false, }), }), ], diff --git a/src/main.ts b/src/main.ts index 439dc13..7982005 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,7 +29,8 @@ async function bootstrap() { res.on('finish', () => { const duration = Date.now() - startTime; - const logMessage = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`; + const appVersion = req.headers['x-app-version'] || 'unknown'; + const logMessage = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms [v${appVersion}]`; if (res.statusCode >= 400) { logger.error(`${logMessage} - Body: ${JSON.stringify(req.body)}`); diff --git a/src/medications/AI_RECOGNITION.md b/src/medications/AI_RECOGNITION.md deleted file mode 100644 index f6dbbca..0000000 --- a/src/medications/AI_RECOGNITION.md +++ /dev/null @@ -1,583 +0,0 @@ -# AI 药物识别功能说明 - -## 功能概述 - -AI 药物识别功能允许用户通过上传药品照片,自动识别药品信息并创建药物记录。系统使用 GLM-4V-Plus 视觉模型和 GLM-4-Flash 文本模型进行多阶段分析,提供完整的药品信息和健康建议。 - -## 核心特性 - -### 1. 多图片识别 - -- **正面图片**(必需):药品包装正面,包含药品名称 -- **侧面图片**(必需):药品包装侧面,包含规格信息 -- **辅助图片**(可选):药品说明书或其他辅助信息 - -### 2. 多阶段分析 - -系统分4个阶段进行识别和分析: - -1. **产品识别** (0-40%):识别药品基本信息(名称、剂型、剂量等) -2. **适宜人群分析** (40-60%):分析适合人群和禁忌人群 -3. **成分分析** (60-80%):分析主要成分和用途 -4. **副作用分析** (80-100%):分析副作用、储存建议和健康建议 - -### 3. 实时状态追踪 - -- 支持轮询查询识别进度 -- 提供详细的步骤描述 -- 实时更新进度百分比 - -### 4. 智能质量控制 - -系统会在识别前先判断图片质量: - -- **图片可读性检查**:AI 会首先判断图片是否足够清晰可读 -- **置信度评估**:识别置信度低于 60% 时会自动失败 -- **严格验证**:宁可识别失败,也不提供不准确的药品信息 -- **友好提示**:失败时会给出明确的改进建议 - -### 5. 结构化输出 - -识别结果包含完整的药品信息: - -- 质量指标:图片可读性、识别置信度 -- 基本信息:名称、剂型、剂量、服用次数、服药时间 -- 适宜性分析:适合人群、不适合人群 -- 成分分析:主要成分、主要用途 -- 安全信息:副作用、储存建议、健康建议 - -## API 接口 - -### 1. 创建识别任务 - -**接口**: `POST /medications/ai-recognize` - -**权限要求**: 需要 VIP 会员或有 AI 使用次数 - -**请求参数**: - -```json -{ - "frontImageUrl": "https://cdn.example.com/front.jpg", - "sideImageUrl": "https://cdn.example.com/side.jpg", - "auxiliaryImageUrl": "https://cdn.example.com/auxiliary.jpg" // 可选 -} -``` - -**响应示例**: - -```json -{ - "code": 0, - "message": "识别任务创建成功", - "data": { - "taskId": "task_user123_1234567890", - "status": "pending" - } -} -``` - -**注意事项**: - -- 必须提供正面和侧面图片 -- 任务创建成功后立即扣减 1 次 AI 使用次数 -- 识别过程异步执行,不阻塞当前请求 - -### 2. 查询识别状态 - -**接口**: `GET /medications/ai-recognize/:taskId/status` - -**轮询建议**: 每 2-3 秒查询一次 - -**响应示例**: - -```json -{ - "code": 0, - "message": "查询成功", - "data": { - "taskId": "task_user123_1234567890", - "status": "analyzing_product", - "currentStep": "正在识别药品基本信息...", - "progress": 25, - "createdAt": "2025-01-20T12:00:00.000Z" - } -} -``` - -**状态说明**: - -- `pending`: 任务已创建,等待处理 -- `analyzing_product`: 正在识别药品基本信息 -- `analyzing_suitability`: 正在分析适宜人群 -- `analyzing_ingredients`: 正在分析主要成分 -- `analyzing_effects`: 正在分析副作用和健康建议 -- `completed`: 识别完成 -- `failed`: 识别失败 - -**完成后的响应示例**: - -```json -{ - "code": 0, - "message": "查询成功", - "data": { - "taskId": "task_user123_1234567890", - "status": "completed", - "currentStep": "识别完成", - "progress": 100, - "result": { - "name": "阿莫西林胶囊", - "photoUrl": "https://cdn.example.com/front.jpg", - "form": "capsule", - "dosageValue": 0.25, - "dosageUnit": "g", - "timesPerDay": 3, - "medicationTimes": ["08:00", "14:00", "20:00"], - "suitableFor": ["成年人", "细菌感染患者"], - "unsuitableFor": ["青霉素过敏者", "孕妇"], - "mainIngredients": ["阿莫西林"], - "mainUsage": "用于敏感菌引起的各种感染", - "sideEffects": ["恶心", "呕吐", "腹泻"], - "storageAdvice": ["密封保存", "室温避光"], - "healthAdvice": ["按时服药", "多喝水"], - "confidence": 0.95 - }, - "createdAt": "2025-01-20T12:00:00.000Z", - "completedAt": "2025-01-20T12:01:30.000Z" - } -} -``` - -### 3. 确认并创建药物 - -**接口**: `POST /medications/ai-recognize/:taskId/confirm` - -**说明**: 用户确认识别结果后创建药物记录,可以对识别结果进行调整 - -**请求参数** (可选,用于调整识别结果): - -```json -{ - "name": "调整后的药品名称", - "timesPerDay": 2, - "medicationTimes": ["09:00", "21:00"], - "startDate": "2025-01-20T00:00:00.000Z", - "endDate": "2025-02-20T00:00:00.000Z", - "note": "饭后服用" -} -``` - -**响应示例**: - -```json -{ - "code": 0, - "message": "创建成功", - "data": { - "id": "med_abc123", - "name": "阿莫西林胶囊", - "form": "capsule", - "dosageValue": 0.25, - "dosageUnit": "g", - "timesPerDay": 3, - "medicationTimes": ["08:00", "14:00", "20:00"], - "isActive": true, - "aiAnalysis": "{...完整的AI分析结果...}", - "createdAt": "2025-01-20T12:02:00.000Z" - } -} -``` - -## 前端集成示例 - -### 完整流程 - -```typescript -// 1. 上传图片并创建识别任务 -async function startRecognition( - frontImage: string, - sideImage: string, - auxiliaryImage?: string -) { - const response = await fetch("/medications/ai-recognize", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - frontImageUrl: frontImage, - sideImageUrl: sideImage, - auxiliaryImageUrl: auxiliaryImage, - }), - }); - - const result = await response.json(); - if (result.code === 0) { - return result.data.taskId; - } - throw new Error(result.message); -} - -// 2. 轮询查询识别状态 -async function pollRecognitionStatus(taskId: string) { - return new Promise((resolve, reject) => { - const pollInterval = setInterval(async () => { - try { - const response = await fetch( - `/medications/ai-recognize/${taskId}/status`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); - - const result = await response.json(); - if (result.code !== 0) { - clearInterval(pollInterval); - reject(new Error(result.message)); - return; - } - - const { - status, - progress, - currentStep, - result: recognitionResult, - errorMessage, - } = result.data; - - // 更新UI显示进度 - updateProgress(progress, currentStep); - - if (status === "completed") { - clearInterval(pollInterval); - resolve(recognitionResult); - } else if (status === "failed") { - clearInterval(pollInterval); - reject(new Error(errorMessage || "识别失败")); - } - } catch (error) { - clearInterval(pollInterval); - reject(error); - } - }, 2000); // 每2秒查询一次 - }); -} - -// 3. 确认并创建药物 -async function confirmAndCreate(taskId: string, adjustments?: any) { - const response = await fetch(`/medications/ai-recognize/${taskId}/confirm`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(adjustments || {}), - }); - - const result = await response.json(); - if (result.code === 0) { - return result.data; - } - throw new Error(result.message); -} - -// 完整使用示例 -async function recognizeAndCreateMedication() { - try { - // 步骤1: 创建识别任务 - showLoading("正在创建识别任务..."); - const taskId = await startRecognition( - frontImageUrl, - sideImageUrl, - auxiliaryImageUrl - ); - - // 步骤2: 轮询查询状态 - showProgress("识别进行中...", 0); - const recognitionResult = await pollRecognitionStatus(taskId); - - // 步骤3: 展示结果给用户确认 - const confirmed = await showConfirmationDialog(recognitionResult); - if (!confirmed) return; - - // 步骤4: 创建药物记录 - showLoading("正在创建药物记录..."); - const medication = await confirmAndCreate(taskId, userAdjustments); - - showSuccess("药物创建成功!"); - navigateToMedicationDetail(medication.id); - } catch (error) { - showError(error.message); - } -} -``` - -### UI 交互建议 - -#### 1. 上传阶段 - -```typescript -// 显示图片上传引导 -
-
- - 正面照片(必需) -

拍摄药品包装正面,确保药品名称清晰可见

-
-
- - 侧面照片(必需) -

拍摄药品包装侧面,确保规格剂量清晰可见

-
-
- - 说明书(可选) -

拍摄药品说明书,可提高识别准确度

-
-
-``` - -#### 2. 识别阶段 - -```typescript -// 显示识别进度和当前步骤 -
- -
- {status === 'analyzing_product' && '📦 正在识别药品信息...'} - {status === 'analyzing_suitability' && '👥 正在分析适宜人群...'} - {status === 'analyzing_ingredients' && '🧪 正在分析主要成分...'} - {status === 'analyzing_effects' && '⚠️ 正在分析副作用...'} -
-
{progress}%
-
-``` - -#### 3. 确认阶段 - -```typescript -// 展示识别结果供用户确认和编辑 -
-
- 识别置信度: {(result.confidence * 100).toFixed(0)}% -
- - setAdjustments({...adjustments, name: v})} - /> - - setAdjustments({...adjustments, timesPerDay: v})} - /> - - {/* 更多可编辑字段 */} - -
-

AI 健康分析

-
-
-
-
-
-
- -
- - -
-
-``` - -## 错误处理 - -### 常见错误及解决方案 - -| 错误码 | 错误信息 | 原因 | 解决方案 | -| ------ | ---------------------- | ------------------ | ---------------------- | -| 400 | 必须提供正面和侧面图片 | 缺少必需的图片 | 确保上传正面和侧面图片 | -| 403 | 免费使用次数已用完 | AI 使用次数不足 | 引导用户开通 VIP 会员 | -| 404 | 识别任务不存在 | 任务ID错误或已过期 | 重新创建识别任务 | -| 500 | AI响应格式错误 | 模型返回异常 | 提示用户重试或联系客服 | - -### 识别失败处理 - -当识别状态为 `failed` 时,系统会提供明确的失败原因: - -#### 失败原因分类 - -1. **图片质量问题**: - - - 错误信息:`图片模糊或光线不足,无法清晰识别药品信息` - - 用户建议: - - 在光线充足的环境下重新拍摄 - - 确保相机对焦清晰 - - 避免手抖造成的模糊 - - 避免反光和阴影 - -2. **药品信息不可见**: - - - 错误信息:`无法从图片中识别出药品名称` - - 用户建议: - - 确保药品名称完整出现在画面中 - - 调整拍摄角度,避免遮挡 - - 拍摄药品包装正面和侧面 - - 如有说明书可一并拍摄 - -3. **识别置信度过低**: - - 错误信息:`识别置信度过低 (XX%)。建议重新拍摄更清晰的照片` - - 用户建议: - - 拍摄更清晰的照片 - - 确保药品规格、剂量等信息清晰可见 - - 可选择手动输入药品信息 - -#### 前端处理建议 - -```typescript -if (status === "failed") { - // 根据错误信息提供针对性的指导 - if (errorMessage.includes("图片模糊")) { - showTip("拍照小贴士", [ - "在明亮的环境下拍摄", - "保持相机稳定,避免抖动", - "等待相机对焦后再拍摄", - ]); - } else if (errorMessage.includes("无法识别出药品名称")) { - showTip("拍照小贴士", [ - "确保药品名称完整可见", - "拍摄药品包装的正面", - "避免手指或其他物体遮挡", - ]); - } else if (errorMessage.includes("置信度过低")) { - showTip("识别建议", [ - "重新拍摄更清晰的照片", - "同时拍摄说明书可提高准确度", - "或选择手动输入药品信息", - ]); - } - - // 提供重试和手动输入选项 - showActions([ - { text: "重新拍摄", action: retryPhoto }, - { text: "手动输入", action: manualInput }, - ]); -} -``` - -## 性能优化建议 - -### 1. 图片优化 - -- 上传前压缩图片(建议最大 2MB) -- 使用 WebP 格式减小体积 -- 限制图片尺寸(建议 1920x1080 以内) - -### 2. 轮询优化 - -- 使用指数退避策略(2s, 3s, 5s...) -- 设置最大轮询次数(如 30 次,约 1 分钟) -- 超时后提示用户刷新页面 - -### 3. 用户体验优化 - -- 显示预估完成时间(约 30-60 秒) -- 支持后台识别,用户可离开页面 -- 完成后发送推送通知 - -## 数据安全 - -1. **图片安全**: - - - 图片 URL 应使用腾讯云 COS 临时访问凭证 - - 识别完成后可选择删除图片 - -2. **数据隐私**: - - - 识别任务数据仅用户本人可查看 - - AI 分析结果不会共享给第三方 - - 支持用户主动删除识别历史 - -3. **任务清理**: - - 建议定期清理 30 天前的识别任务记录 - - 可通过定时任务自动清理 - -## 最佳实践 - -### 拍照技巧 - -1. 确保光线充足,避免反光 -2. 药品名称和规格清晰可见 -3. 尽量拍摄完整的包装盒 -4. 避免手指遮挡关键信息 -5. 保持相机稳定,避免模糊 - -### 识别准确度 - -**质量控制标准**: - -- 图片必须清晰可读(isReadable = true) -- 识别置信度必须 ≥ 60% 才能通过 -- 置信度 60-75%:会给出警告,建议用户核对 -- 置信度 ≥ 75%:可信度较高 - -**推荐使用标准**: - -- 正面 + 侧面:准确度约 85-90% -- 正面 + 侧面 + 说明书:准确度约 90-95% -- 置信度 ≥ 0.8:可直接使用 -- 置信度 0.75-0.8:建议人工核对 -- 置信度 0.6-0.75:必须人工核对 -- 置信度 < 0.6:自动识别失败,需重新拍摄 - -**安全优先原则**: - -- 宁可识别失败,也不提供不准确的药品信息 -- 当 AI 无法确认信息准确性时,会主动返回失败 -- 用户可选择手动输入以确保用药安全 - -## 技术架构 - -### 模型选择 - -- **GLM-4V-Plus**:视觉识别模型,识别药品图片 -- **GLM-4-Flash**:文本分析模型,深度分析和结构化输出 - -### 数据流 - -1. 客户端上传图片到 COS,获取 URL -2. 调用 `/ai-recognize` 创建任务 -3. 服务端异步调用 AI 模型进行多阶段分析 -4. 客户端轮询查询状态和结果 -5. 用户确认后创建药物记录 - -### 状态管理 - -- 使用数据库表 `t_medication_recognition_tasks` 持久化状态 -- 支持断点续传和故障恢复 -- 任务完成后保留 7 天供查询 - -## 未来规划 - -1. **功能增强**: - - - 支持批量识别多个药品 - - 支持视频识别 - - 支持语音输入药品名称 - -2. **模型优化**: - - - 训练专用的药品识别模型 - - 提高中文药品识别准确度 - - 支持更多药品类型 - -3. **用户体验**: - - 支持离线识别(边缘计算) - - 实时预览识别结果 - - 智能纠错和建议 diff --git a/src/medications/services/medication-analysis.service.ts b/src/medications/services/medication-analysis.service.ts index 9af254c..9c29e1a 100644 --- a/src/medications/services/medication-analysis.service.ts +++ b/src/medications/services/medication-analysis.service.ts @@ -207,17 +207,24 @@ export class MedicationAnalysisService { `用户 ${userId} 的用药计划分析结果: ${JSON.stringify(medicationAnalysis, null, 2)}`, ); + // 获取用户语言配置 + const languageConfig = await this.getUserLanguageConfig(userId); + // 没有开启的用药计划时,不调用大模型 if (!medicationAnalysis.length) { + const noDataMessage = languageConfig.label === 'English' + ? 'No active medication plans found. Please add or activate medications to view insights.' + : '当前没有开启的用药计划,请先添加或激活用药后再查看解读。'; + const result = { medicationAnalysis, - keyInsights: '当前没有开启的用药计划,请先添加或激活用药后再查看解读。', + keyInsights: noDataMessage, }; await this.saveSummary(userId, summaryDate, result); return result; } - const prompt = this.buildMedicationSummaryPrompt(medicationAnalysis); + const prompt = this.buildMedicationSummaryPrompt(medicationAnalysis, languageConfig); try { const response = await this.client.chat.completions.create({ @@ -541,6 +548,7 @@ export class MedicationAnalysisService { */ private buildMedicationSummaryPrompt( medications: MedicationPlanItemDto[], + languageConfig: LanguageConfig, ): string { const medicationList = medications .map( @@ -549,12 +557,19 @@ export class MedicationAnalysisService { ) .join('\n'); - return `你是一名持证的临床医生与药学专家,请基于以下正在进行中的用药方案,输出中文“重点解读”一段话(200字以内),重点围绕计划 vs 实际的执行情况给出建议。 + const languageInstruction = languageConfig.label === 'English' + ? 'Please provide your response entirely in English.' + : '请使用简体中文输出全部内容。'; + + const wordLimit = languageConfig.label === 'English' ? '300 words' : '400字'; + + return `你是一名持证的临床医生与药学专家,请基于以下正在进行中的用药方案,输出"重点解读"一段话(${wordLimit}以内),重点围绕计划 vs 实际的执行情况给出建议。 用药列表: ${medicationList} 写作要求: +- 语言:${languageInstruction} - 口吻:专业、温和、以患者安全为先 - 内容:结合完成率与计划总次数,点评用药依从性、潜在风险与监测要点(胃肠道、肝肾功能、血压/血糖等),提出需要复诊或调整的建议 - 形式:只输出一段文字,不要使用列表或分段,不要重复列出药品清单,不要添加免责声明`; @@ -608,7 +623,6 @@ ${medication.note ? `- 用户备注:${medication.note}` : ''} **语言要求**: ${languageConfig.analysisInstruction} -- 将所有标题、要点、提醒翻译为${languageConfig.label}(保留药品名称、成分等专有名词的常用写法),不要混用其他语言 **输出格式要求**: @@ -803,16 +817,16 @@ ${medication.note ? `- 用户备注:${medication.note}` : ''} if (normalized.startsWith('en')) { return { label: 'English', - analysisInstruction: 'Respond entirely in English. Translate every section title, bullet point and paragraph; keep emojis unchanged.', - jsonInstruction: 'Return all JSON values in English. Keep JSON keys exactly as defined in the schema.', + analysisInstruction: 'Please respond entirely in English. Output all section titles, bullet points, paragraphs, and reminders in English. Keep emojis unchanged. Do not mix other languages.', + jsonInstruction: 'Return all JSON field values in English. Keep JSON keys exactly as defined in the schema.', unableToIdentifyMessage: 'Unable to identify the medication, please provide a more accurate name or image.', }; } return { label: '简体中文', - analysisInstruction: '请使用简体中文输出全部内容(包括标题、要点和提醒),不要混用其他语言。', - jsonInstruction: '请确保 JSON 中的值使用简体中文,字段名保持英文。', + analysisInstruction: '请使用简体中文输出全部内容(包括所有标题、要点、段落和提醒),emoji 保持不变,不要混用其他语言。', + jsonInstruction: '请确保 JSON 中所有字段的值都使用简体中文,字段名(key)保持英文不变。', unableToIdentifyMessage: '无法识别药品,请提供更准确的名称或图片。', }; } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index a411bd1..11602a2 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -47,6 +47,7 @@ import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard'; import { ResponseCode } from 'src/base.dto'; import { CosService } from './cos.service'; import { AiReportService } from '../ai-coach/services/ai-report.service'; +import { GetAiReportHistoryQueryDto, GetAiReportHistoryResponseDto } from '../ai-coach/dto/ai-report-history.dto'; @ApiTags('users') @@ -497,6 +498,7 @@ export class UsersController { data: { type: 'object', properties: { + id: { type: 'string', example: '550e8400-e29b-41d4-a716-446655440000' }, imageUrl: { type: 'string', example: 'https://example.com/generated-image.png' } } } @@ -506,7 +508,7 @@ export class UsersController { async generateAiHealthReport( @CurrentUser() user: AccessTokenPayload, @Body() body: { date?: string }, - ): Promise<{ code: ResponseCode; message: string; data: { imageUrl: string } }> { + ): Promise<{ code: ResponseCode; message: string; data: { id: string; imageUrl: string } }> { try { this.logger.log(`生成AI健康报告请求 - 用户ID: ${user.sub}, 日期: ${body.date || '今天'}`); @@ -516,6 +518,7 @@ export class UsersController { code: ResponseCode.SUCCESS, message: 'AI健康报告生成成功', data: { + id: result.id, imageUrl: result.imageUrl, }, }; @@ -531,10 +534,78 @@ export class UsersController { code: ResponseCode.ERROR, message: `生成失败: ${(error as Error).message}`, data: { + id: '', imageUrl: '', }, }; } } + /** + * 获取用户的 AI 健康报告历史列表 + */ + @UseGuards(JwtAuthGuard) + @Get('ai-report/history') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取用户的 AI 健康报告历史列表(分页)' }) + @ApiQuery({ name: 'page', required: false, description: '页码,从1开始,默认1' }) + @ApiQuery({ name: 'pageSize', required: false, description: '每页条数,默认10,最大50' }) + @ApiQuery({ name: 'startDate', required: false, description: '开始日期,格式 YYYY-MM-DD' }) + @ApiQuery({ name: 'endDate', required: false, description: '结束日期,格式 YYYY-MM-DD' }) + @ApiQuery({ name: 'status', required: false, description: '状态筛选: pending | processing | success | failed,不传则返回所有状态' }) + @ApiResponse({ status: 200, description: '成功获取报告历史列表', type: GetAiReportHistoryResponseDto }) + async getAiReportHistory( + @CurrentUser() user: AccessTokenPayload, + @Query() query: GetAiReportHistoryQueryDto, + ): Promise { + try { + this.logger.log(`获取AI健康报告历史 - 用户ID: ${user.sub}, 页码: ${query.page}, 每页: ${query.pageSize}`); + + const result = await this.aiReportService.getReportHistory(user.sub, { + page: query.page, + pageSize: query.pageSize, + startDate: query.startDate, + endDate: query.endDate, + status: query.status, + }); + + return { + code: ResponseCode.SUCCESS, + message: 'success', + data: { + records: result.records.map(r => ({ + id: r.id, + reportDate: r.reportDate, + imageUrl: r.imageUrl || '', // 成功记录一定有 imageUrl + status: r.status, + createdAt: r.createdAt, + })), + total: result.total, + page: result.page, + pageSize: result.pageSize, + totalPages: result.totalPages, + }, + }; + } catch (error) { + this.winstonLogger.error('获取AI健康报告历史失败', { + context: 'UsersController', + userId: user?.sub, + error: (error as Error).message, + stack: (error as Error).stack, + }); + + return { + code: ResponseCode.ERROR, + message: `获取失败: ${(error as Error).message}`, + data: { + records: [], + total: 0, + page: 1, + pageSize: 10, + totalPages: 0, + }, + }; + } + } + } diff --git a/test-custom-foods.sh b/test-custom-foods.sh deleted file mode 100644 index 5deb3bf..0000000 --- a/test-custom-foods.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash - -# 用户自定义食物功能测试脚本 - -echo "=== 用户自定义食物功能测试 ===" - -# 设置基础URL和测试用户token -BASE_URL="http://localhost:3000" -# 请替换为实际的用户token -ACCESS_TOKEN="your_access_token_here" - -echo "1. 测试获取食物库列表(包含用户自定义食物)" -curl -X GET "$BASE_URL/food-library" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "Content-Type: application/json" - -echo -e "\n\n2. 测试创建用户自定义食物" -curl -X POST "$BASE_URL/food-library/custom" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "我的自制沙拉", - "description": "自制蔬菜沙拉", - "categoryKey": "dishes", - "caloriesPer100g": 120, - "proteinPer100g": 5.2, - "carbohydratePer100g": 15.3, - "fatPer100g": 8.1, - "fiberPer100g": 3.2, - "sugarPer100g": 2.5, - "sodiumPer100g": 150 - }' - -echo -e "\n\n3. 测试搜索食物(包含用户自定义食物)" -curl -X GET "$BASE_URL/food-library/search?keyword=沙拉" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "Content-Type: application/json" - -echo -e "\n\n4. 测试获取食物详情" -# 请替换为实际的食物ID -FOOD_ID="1" -curl -X GET "$BASE_URL/food-library/$FOOD_ID" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "Content-Type: application/json" - -echo -e "\n\n5. 测试删除用户自定义食物" -# 请替换为实际的自定义食物ID -CUSTOM_FOOD_ID="1" -curl -X DELETE "$BASE_URL/food-library/custom/$CUSTOM_FOOD_ID" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "Content-Type: application/json" - -echo -e "\n\n=== 测试完成 ===" \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index ed02046..9064754 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1105,6 +1105,11 @@ dependencies: tslib "2.8.1" +"@nestjs/throttler@^6.4.0": + version "6.4.0" + resolved "https://mirrors.tencent.com/npm/@nestjs/throttler/-/throttler-6.4.0.tgz" + integrity sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"