feat(ai): 添加AI报告生成历史记录功能,支持每日生成限制和双API提供商
This commit is contained in:
@@ -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. 喝水目标的更新集成在喝水接口中,避免用户服务文件过大
|
|
||||||
@@ -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`: 服务器内部错误
|
|
||||||
@@ -1,473 +0,0 @@
|
|||||||
# 喝水记录 API 客户端接入说明
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
喝水记录 API 提供了完整的喝水记录管理功能,包括记录创建、查询、更新、删除,以及喝水目标设置和统计查询等功能。
|
|
||||||
|
|
||||||
## 基础信息
|
|
||||||
|
|
||||||
- **Base URL**: `https://your-api-domain.com/api`
|
|
||||||
- **认证方式**: JWT Bearer Token
|
|
||||||
- **Content-Type**: `application/json`
|
|
||||||
|
|
||||||
## 认证
|
|
||||||
|
|
||||||
所有 API 请求都需要在请求头中包含有效的 JWT Token:
|
|
||||||
|
|
||||||
```http
|
|
||||||
Authorization: Bearer <your-jwt-token>
|
|
||||||
```
|
|
||||||
|
|
||||||
## API 接口列表
|
|
||||||
|
|
||||||
### 1. 创建喝水记录
|
|
||||||
|
|
||||||
**接口地址**: `POST /water-records`
|
|
||||||
|
|
||||||
**描述**: 创建一条新的喝水记录
|
|
||||||
|
|
||||||
**请求参数**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"amount": 250, // 必填,喝水量(毫升),范围:1-5000
|
|
||||||
"recordedAt": "2023-12-01T10:00:00.000Z", // 可选,记录时间,默认为当前时间
|
|
||||||
"source": "Manual", // 可选,记录来源:Manual(手动) | Auto(自动)
|
|
||||||
"note": "早晨第一杯水" // 可选,备注,最大100字符
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "操作成功",
|
|
||||||
"data": {
|
|
||||||
"id": 1,
|
|
||||||
"amount": 250,
|
|
||||||
"recordedAt": "2023-12-01T10:00:00.000Z",
|
|
||||||
"note": "早晨第一杯水",
|
|
||||||
"createdAt": "2023-12-01T10:00:00.000Z",
|
|
||||||
"updatedAt": "2023-12-01T10:00:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**客户端示例代码**:
|
|
||||||
```javascript
|
|
||||||
// JavaScript/TypeScript
|
|
||||||
const createWaterRecord = async (recordData) => {
|
|
||||||
const response = await fetch('/api/water-records', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(recordData)
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 使用示例
|
|
||||||
const result = await createWaterRecord({
|
|
||||||
amount: 250,
|
|
||||||
note: "早晨第一杯水"
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 获取喝水记录列表
|
|
||||||
|
|
||||||
**接口地址**: `GET /water-records`
|
|
||||||
|
|
||||||
**描述**: 获取用户的喝水记录列表,支持分页和日期筛选
|
|
||||||
|
|
||||||
**查询参数**:
|
|
||||||
- `startDate` (可选): 开始日期,格式:YYYY-MM-DD
|
|
||||||
- `endDate` (可选): 结束日期,格式:YYYY-MM-DD
|
|
||||||
- `page` (可选): 页码,默认1
|
|
||||||
- `limit` (可选): 每页数量,默认20,最大100
|
|
||||||
|
|
||||||
**请求示例**:
|
|
||||||
```
|
|
||||||
GET /water-records?startDate=2023-12-01&endDate=2023-12-31&page=1&limit=20
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "操作成功",
|
|
||||||
"data": {
|
|
||||||
"records": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"amount": 250,
|
|
||||||
"recordedAt": "2023-12-01T10:00:00.000Z",
|
|
||||||
"note": "早晨第一杯水",
|
|
||||||
"createdAt": "2023-12-01T10:00:00.000Z",
|
|
||||||
"updatedAt": "2023-12-01T10:00:00.000Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pagination": {
|
|
||||||
"page": 1,
|
|
||||||
"limit": 20,
|
|
||||||
"total": 100,
|
|
||||||
"totalPages": 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**客户端示例代码**:
|
|
||||||
```javascript
|
|
||||||
const getWaterRecords = async (params = {}) => {
|
|
||||||
const queryString = new URLSearchParams(params).toString();
|
|
||||||
const response = await fetch(`/api/water-records?${queryString}`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 使用示例
|
|
||||||
const records = await getWaterRecords({
|
|
||||||
startDate: '2023-12-01',
|
|
||||||
endDate: '2023-12-31',
|
|
||||||
page: 1,
|
|
||||||
limit: 20
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 更新喝水记录
|
|
||||||
|
|
||||||
**接口地址**: `PUT /water-records/:id`
|
|
||||||
|
|
||||||
**描述**: 更新指定的喝水记录
|
|
||||||
|
|
||||||
**路径参数**:
|
|
||||||
- `id`: 记录ID
|
|
||||||
|
|
||||||
**请求参数** (所有字段都是可选的):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"amount": 300, // 可选,喝水量(毫升)
|
|
||||||
"recordedAt": "2023-12-01T11:00:00.000Z", // 可选,记录时间
|
|
||||||
"source": "Manual", // 可选,记录来源
|
|
||||||
"note": "修改后的备注" // 可选,备注
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "操作成功",
|
|
||||||
"data": {
|
|
||||||
"id": 1,
|
|
||||||
"amount": 300,
|
|
||||||
"recordedAt": "2023-12-01T11:00:00.000Z",
|
|
||||||
"note": "修改后的备注",
|
|
||||||
"createdAt": "2023-12-01T10:00:00.000Z",
|
|
||||||
"updatedAt": "2023-12-01T11:30:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**客户端示例代码**:
|
|
||||||
```javascript
|
|
||||||
const updateWaterRecord = async (recordId, updateData) => {
|
|
||||||
const response = await fetch(`/api/water-records/${recordId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(updateData)
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 删除喝水记录
|
|
||||||
|
|
||||||
**接口地址**: `DELETE /water-records/:id`
|
|
||||||
|
|
||||||
**描述**: 删除指定的喝水记录
|
|
||||||
|
|
||||||
**路径参数**:
|
|
||||||
- `id`: 记录ID
|
|
||||||
|
|
||||||
**响应**: HTTP 204 No Content (成功删除)
|
|
||||||
|
|
||||||
**客户端示例代码**:
|
|
||||||
```javascript
|
|
||||||
const deleteWaterRecord = async (recordId) => {
|
|
||||||
const response = await fetch(`/api/water-records/${recordId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response.status === 204;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 更新每日喝水目标
|
|
||||||
|
|
||||||
**接口地址**: `PUT /water-records/goal/daily`
|
|
||||||
|
|
||||||
**描述**: 设置或更新用户的每日喝水目标
|
|
||||||
|
|
||||||
**请求参数**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dailyWaterGoal": 2000 // 必填,每日喝水目标(毫升),范围:500-10000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "操作成功",
|
|
||||||
"data": {
|
|
||||||
"dailyWaterGoal": 2000,
|
|
||||||
"updatedAt": "2023-12-01T12:00:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**客户端示例代码**:
|
|
||||||
```javascript
|
|
||||||
const updateWaterGoal = async (goalAmount) => {
|
|
||||||
const response = await fetch('/api/water-records/goal/daily', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ dailyWaterGoal: goalAmount })
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 获取指定日期的喝水统计
|
|
||||||
|
|
||||||
**接口地址**: `GET /water-records/stats`
|
|
||||||
|
|
||||||
**描述**: 获取指定日期的喝水统计信息,包括总量、完成率等
|
|
||||||
|
|
||||||
**查询参数**:
|
|
||||||
- `date` (可选): 查询日期,格式:YYYY-MM-DD,不传则默认为今天
|
|
||||||
|
|
||||||
**请求示例**:
|
|
||||||
```
|
|
||||||
GET /water-records/stats?date=2023-12-01
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "操作成功",
|
|
||||||
"data": {
|
|
||||||
"date": "2023-12-01",
|
|
||||||
"totalAmount": 1500, // 当日总喝水量(毫升)
|
|
||||||
"dailyGoal": 2000, // 每日目标(毫升)
|
|
||||||
"completionRate": 75.0, // 完成率(百分比)
|
|
||||||
"recordCount": 6, // 记录次数
|
|
||||||
"records": [ // 当日所有记录
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"amount": 250,
|
|
||||||
"recordedAt": "2023-12-01T08:00:00.000Z",
|
|
||||||
"note": "早晨第一杯水"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"amount": 300,
|
|
||||||
"recordedAt": "2023-12-01T10:30:00.000Z",
|
|
||||||
"note": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**客户端示例代码**:
|
|
||||||
```javascript
|
|
||||||
const getWaterStats = async (date) => {
|
|
||||||
const params = date ? `?date=${date}` : '';
|
|
||||||
const response = await fetch(`/api/water-records/stats${params}`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 使用示例
|
|
||||||
const todayStats = await getWaterStats(); // 获取今天的统计
|
|
||||||
const specificDateStats = await getWaterStats('2023-12-01'); // 获取指定日期的统计
|
|
||||||
```
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
### 常见错误码
|
|
||||||
|
|
||||||
- `400 Bad Request`: 请求参数错误
|
|
||||||
- `401 Unauthorized`: 未授权,Token无效或过期
|
|
||||||
- `404 Not Found`: 资源不存在
|
|
||||||
- `422 Unprocessable Entity`: 数据验证失败
|
|
||||||
- `500 Internal Server Error`: 服务器内部错误
|
|
||||||
|
|
||||||
### 错误响应格式
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"message": "错误描述",
|
|
||||||
"error": {
|
|
||||||
"code": "ERROR_CODE",
|
|
||||||
"details": "详细错误信息"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 客户端错误处理示例
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const handleApiCall = async (apiFunction) => {
|
|
||||||
try {
|
|
||||||
const result = await apiFunction();
|
|
||||||
if (result.success) {
|
|
||||||
return result.data;
|
|
||||||
} else {
|
|
||||||
throw new Error(result.message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API调用失败:', error.message);
|
|
||||||
// 根据错误类型进行相应处理
|
|
||||||
if (error.status === 401) {
|
|
||||||
// Token过期,重新登录
|
|
||||||
redirectToLogin();
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 数据类型说明
|
|
||||||
|
|
||||||
### WaterRecordSource 枚举
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
enum WaterRecordSource {
|
|
||||||
Manual = 'Manual', // 手动记录
|
|
||||||
Auto = 'Auto' // 自动记录
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 日期格式
|
|
||||||
|
|
||||||
- 所有日期时间字段使用 ISO 8601 格式:`YYYY-MM-DDTHH:mm:ss.sssZ`
|
|
||||||
- 查询参数中的日期使用简化格式:`YYYY-MM-DD`
|
|
||||||
|
|
||||||
## 最佳实践
|
|
||||||
|
|
||||||
1. **错误处理**: 始终检查响应的 `success` 字段,并妥善处理错误情况
|
|
||||||
2. **Token管理**: 实现Token自动刷新机制,避免因Token过期导致的请求失败
|
|
||||||
3. **数据验证**: 在发送请求前进行客户端数据验证,提升用户体验
|
|
||||||
4. **缓存策略**: 对于统计数据等相对稳定的信息,可以实现适当的缓存策略
|
|
||||||
5. **分页处理**: 处理列表数据时,注意分页信息,避免一次性加载过多数据
|
|
||||||
|
|
||||||
## 完整的客户端封装示例
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
class WaterRecordsAPI {
|
|
||||||
constructor(baseURL, token) {
|
|
||||||
this.baseURL = baseURL;
|
|
||||||
this.token = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
async request(endpoint, options = {}) {
|
|
||||||
const url = `${this.baseURL}${endpoint}`;
|
|
||||||
const config = {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${this.token}`,
|
|
||||||
...options.headers
|
|
||||||
},
|
|
||||||
...options
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(url, config);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
throw new Error(data.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建喝水记录
|
|
||||||
async createRecord(recordData) {
|
|
||||||
return this.request('/water-records', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(recordData)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取记录列表
|
|
||||||
async getRecords(params = {}) {
|
|
||||||
const queryString = new URLSearchParams(params).toString();
|
|
||||||
return this.request(`/water-records?${queryString}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新记录
|
|
||||||
async updateRecord(recordId, updateData) {
|
|
||||||
return this.request(`/water-records/${recordId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(updateData)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除记录
|
|
||||||
async deleteRecord(recordId) {
|
|
||||||
await this.request(`/water-records/${recordId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新喝水目标
|
|
||||||
async updateGoal(goalAmount) {
|
|
||||||
return this.request('/water-records/goal/daily', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ dailyWaterGoal: goalAmount })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取统计数据
|
|
||||||
async getStats(date) {
|
|
||||||
const params = date ? `?date=${date}` : '';
|
|
||||||
return this.request(`/water-records/stats${params}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用示例
|
|
||||||
const api = new WaterRecordsAPI('https://your-api-domain.com/api', 'your-jwt-token');
|
|
||||||
|
|
||||||
// 创建记录
|
|
||||||
const newRecord = await api.createRecord({
|
|
||||||
amount: 250,
|
|
||||||
note: '早晨第一杯水'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取今日统计
|
|
||||||
const todayStats = await api.getStats();
|
|
||||||
```
|
|
||||||
|
|
||||||
这个API封装提供了完整的喝水记录管理功能,可以直接在客户端项目中使用。
|
|
||||||
@@ -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) - 应用模块配置
|
|
||||||
@@ -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. 原有的训练计划数据保持不变,作为模板使用
|
|
||||||
|
|
||||||
这样的架构分离使得系统更加清晰、可维护,也更符合健身应用的实际使用场景。
|
|
||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"@nestjs/schedule": "^6.0.1",
|
"@nestjs/schedule": "^6.0.1",
|
||||||
"@nestjs/sequelize": "^11.0.0",
|
"@nestjs/sequelize": "^11.0.0",
|
||||||
"@nestjs/swagger": "^11.1.0",
|
"@nestjs/swagger": "^11.1.0",
|
||||||
|
"@nestjs/throttler": "^6.4.0",
|
||||||
"@openrouter/sdk": "^0.1.27",
|
"@openrouter/sdk": "^0.1.27",
|
||||||
"@parse/node-apn": "^5.0.0",
|
"@parse/node-apn": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.9",
|
"@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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"@nestjs/schedule": "^6.0.1",
|
"@nestjs/schedule": "^6.0.1",
|
||||||
"@nestjs/sequelize": "^11.0.0",
|
"@nestjs/sequelize": "^11.0.0",
|
||||||
"@nestjs/swagger": "^11.1.0",
|
"@nestjs/swagger": "^11.1.0",
|
||||||
|
"@nestjs/throttler": "^6.4.0",
|
||||||
"@openrouter/sdk": "^0.1.27",
|
"@openrouter/sdk": "^0.1.27",
|
||||||
"@parse/node-apn": "^5.0.0",
|
"@parse/node-apn": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.9",
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
|
|||||||
21
sql-scripts/create_ai_report_history.sql
Normal file
21
sql-scripts/create_ai_report_history.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- AI 健康报告生成历史记录表
|
||||||
|
-- 记录每次生成的报告信息,包括 prompt 和图片地址
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_ai_report_history` (
|
||||||
|
`id` VARCHAR(36) NOT NULL COMMENT '记录ID',
|
||||||
|
`user_id` VARCHAR(36) NOT NULL COMMENT '用户ID',
|
||||||
|
`report_date` DATE NOT NULL COMMENT '报告日期',
|
||||||
|
`prompt` TEXT NOT NULL COMMENT '生成图像使用的 Prompt',
|
||||||
|
`image_url` VARCHAR(500) NOT NULL COMMENT '生成的图片地址',
|
||||||
|
`api_provider` VARCHAR(20) DEFAULT NULL COMMENT 'API 提供商: openrouter | grsai',
|
||||||
|
`model_name` VARCHAR(50) DEFAULT NULL COMMENT '使用的模型名称',
|
||||||
|
`generation_time_ms` INT DEFAULT NULL COMMENT '生成耗时(毫秒)',
|
||||||
|
`status` VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '生成状态: success | failed',
|
||||||
|
`error_message` TEXT DEFAULT NULL COMMENT '失败原因(如果失败)',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_user_id` (`user_id`),
|
||||||
|
INDEX `idx_report_date` (`report_date`),
|
||||||
|
INDEX `idx_user_report_date` (`user_id`, `report_date`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI健康报告生成历史';
|
||||||
@@ -8,12 +8,14 @@ import { AiReportService } from './services/ai-report.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 { AiReportHistory } from './models/ai-report-history.model';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { DietRecordsModule } from '../diet-records/diet-records.module';
|
import { DietRecordsModule } from '../diet-records/diet-records.module';
|
||||||
import { MedicationsModule } from '../medications/medications.module';
|
import { MedicationsModule } from '../medications/medications.module';
|
||||||
import { WorkoutsModule } from '../workouts/workouts.module';
|
import { WorkoutsModule } from '../workouts/workouts.module';
|
||||||
import { MoodCheckinsModule } from '../mood-checkins/mood-checkins.module';
|
import { MoodCheckinsModule } from '../mood-checkins/mood-checkins.module';
|
||||||
import { WaterRecordsModule } from '../water-records/water-records.module';
|
import { WaterRecordsModule } from '../water-records/water-records.module';
|
||||||
|
import { ChallengesModule } from '../challenges/challenges.module';
|
||||||
import { CosService } from '../users/cos.service';
|
import { CosService } from '../users/cos.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -25,7 +27,8 @@ import { CosService } from '../users/cos.service';
|
|||||||
forwardRef(() => WorkoutsModule),
|
forwardRef(() => WorkoutsModule),
|
||||||
forwardRef(() => MoodCheckinsModule),
|
forwardRef(() => MoodCheckinsModule),
|
||||||
forwardRef(() => WaterRecordsModule),
|
forwardRef(() => WaterRecordsModule),
|
||||||
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]),
|
forwardRef(() => ChallengesModule),
|
||||||
|
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment, AiReportHistory]),
|
||||||
],
|
],
|
||||||
controllers: [AiCoachController],
|
controllers: [AiCoachController],
|
||||||
providers: [AiCoachService, DietAnalysisService, AiReportService, CosService],
|
providers: [AiCoachService, DietAnalysisService, AiReportService, CosService],
|
||||||
|
|||||||
93
src/ai-coach/dto/ai-report-history.dto.ts
Normal file
93
src/ai-coach/dto/ai-report-history.dto.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, IsNumber, Min, Max } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ResponseCode } from '../../base.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 健康报告历史记录查询 DTO
|
||||||
|
*/
|
||||||
|
export class GetAiReportHistoryQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: '页码,从1开始', default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '每页条数,默认10,最大50', default: 10 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(50)
|
||||||
|
pageSize?: number = 10;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '开始日期,格式 YYYY-MM-DD' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '结束日期,格式 YYYY-MM-DD' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
endDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '状态筛选: pending | processing | success | failed,不传则返回所有状态' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 健康报告历史记录项
|
||||||
|
*/
|
||||||
|
export class AiReportHistoryItemDto {
|
||||||
|
@ApiProperty({ description: '记录ID' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '报告日期,格式 YYYY-MM-DD' })
|
||||||
|
reportDate: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '生成的图片地址' })
|
||||||
|
imageUrl: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '生成状态: success | failed' })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '创建时间' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 健康报告历史记录列表响应
|
||||||
|
*/
|
||||||
|
export class AiReportHistoryListDto {
|
||||||
|
@ApiProperty({ description: '记录列表', type: [AiReportHistoryItemDto] })
|
||||||
|
records: AiReportHistoryItemDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总记录数' })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '当前页码' })
|
||||||
|
page: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '每页条数' })
|
||||||
|
pageSize: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总页数' })
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 AI 健康报告历史记录响应 DTO
|
||||||
|
*/
|
||||||
|
export class GetAiReportHistoryResponseDto {
|
||||||
|
@ApiProperty({ description: '响应状态码', example: ResponseCode.SUCCESS })
|
||||||
|
declare code: ResponseCode;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '响应消息', example: 'success' })
|
||||||
|
declare message: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '报告历史数据', type: AiReportHistoryListDto })
|
||||||
|
declare data: AiReportHistoryListDto;
|
||||||
|
}
|
||||||
110
src/ai-coach/models/ai-report-history.model.ts
Normal file
110
src/ai-coach/models/ai-report-history.model.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 报告生成状态枚举
|
||||||
|
*/
|
||||||
|
export enum AiReportStatus {
|
||||||
|
PENDING = 'pending', // 未开始
|
||||||
|
PROCESSING = 'processing', // 进行中
|
||||||
|
SUCCESS = 'success', // 已完成
|
||||||
|
FAILED = 'failed', // 已失败
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 健康报告生成历史记录表
|
||||||
|
* 记录每次生成的报告信息,包括 prompt 和图片地址
|
||||||
|
*/
|
||||||
|
@Table({
|
||||||
|
tableName: 't_ai_report_history',
|
||||||
|
underscored: true,
|
||||||
|
})
|
||||||
|
export class AiReportHistory extends Model {
|
||||||
|
@PrimaryKey
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING(36),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '记录ID',
|
||||||
|
})
|
||||||
|
declare id: string;
|
||||||
|
|
||||||
|
@Index
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING(36),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '用户ID',
|
||||||
|
})
|
||||||
|
declare userId: string;
|
||||||
|
|
||||||
|
@Index
|
||||||
|
@Column({
|
||||||
|
type: DataType.DATEONLY,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '报告日期',
|
||||||
|
})
|
||||||
|
declare reportDate: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '生成图像使用的 Prompt',
|
||||||
|
})
|
||||||
|
declare prompt: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING(500),
|
||||||
|
allowNull: true,
|
||||||
|
comment: '生成的图片地址',
|
||||||
|
})
|
||||||
|
declare imageUrl: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'API 提供商: openrouter | grsai',
|
||||||
|
})
|
||||||
|
declare apiProvider: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING(50),
|
||||||
|
allowNull: true,
|
||||||
|
comment: '使用的模型名称',
|
||||||
|
})
|
||||||
|
declare modelName: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '生成耗时(毫秒)',
|
||||||
|
})
|
||||||
|
declare generationTimeMs: number | null;
|
||||||
|
|
||||||
|
@Index
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING(20),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: AiReportStatus.PENDING,
|
||||||
|
comment: '生成状态: pending | processing | success | failed',
|
||||||
|
})
|
||||||
|
declare status: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '失败原因(如果失败)',
|
||||||
|
})
|
||||||
|
declare errorMessage: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.DATE,
|
||||||
|
defaultValue: DataType.NOW,
|
||||||
|
comment: '创建时间',
|
||||||
|
})
|
||||||
|
declare createdAt: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.DATE,
|
||||||
|
defaultValue: DataType.NOW,
|
||||||
|
comment: '更新时间',
|
||||||
|
})
|
||||||
|
declare updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
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 * 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 { CosService } from '../../users/cos.service';
|
||||||
|
import { AiReportHistory, AiReportStatus } from '../models/ai-report-history.model';
|
||||||
|
|
||||||
// 假设各个模块的服务都已正确导出
|
// 假设各个模块的服务都已正确导出
|
||||||
import { UsersService } from '../../users/users.service';
|
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 { WaterRecordsService } from '../../water-records/water-records.service';
|
||||||
import { MoodCheckinsService } from '../../mood-checkins/mood-checkins.service';
|
import { MoodCheckinsService } from '../../mood-checkins/mood-checkins.service';
|
||||||
import { WorkoutsService } from '../../workouts/workouts.service';
|
import { WorkoutsService } from '../../workouts/workouts.service';
|
||||||
|
import { ChallengesService } from '../../challenges/challenges.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 聚合的每日健康数据接口
|
* 聚合的每日健康数据接口
|
||||||
@@ -52,6 +56,9 @@ user?: {
|
|||||||
latestChest?: number;
|
latestChest?: number;
|
||||||
latestWaist?: number;
|
latestWaist?: number;
|
||||||
};
|
};
|
||||||
|
challenges: {
|
||||||
|
activeChallengeCount: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -60,6 +67,8 @@ export class AiReportService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
|
@InjectModel(AiReportHistory)
|
||||||
|
private readonly aiReportHistoryModel: typeof AiReportHistory,
|
||||||
@Inject(forwardRef(() => UsersService))
|
@Inject(forwardRef(() => UsersService))
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
@Inject(forwardRef(() => MedicationStatsService))
|
@Inject(forwardRef(() => MedicationStatsService))
|
||||||
@@ -72,31 +81,216 @@ export class AiReportService {
|
|||||||
private readonly moodCheckinsService: MoodCheckinsService,
|
private readonly moodCheckinsService: MoodCheckinsService,
|
||||||
@Inject(forwardRef(() => WorkoutsService))
|
@Inject(forwardRef(() => WorkoutsService))
|
||||||
private readonly workoutsService: WorkoutsService,
|
private readonly workoutsService: WorkoutsService,
|
||||||
|
@Inject(forwardRef(() => ChallengesService))
|
||||||
|
private readonly challengesService: ChallengesService,
|
||||||
private readonly cosService: CosService,
|
private readonly cosService: CosService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户当天成功生成的报告数量
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param date 日期,格式 YYYY-MM-DD
|
||||||
|
* @returns 当天成功生成的报告数量
|
||||||
|
*/
|
||||||
|
private async getTodaySuccessfulReportCount(userId: string, date: string): Promise<number> {
|
||||||
|
const count = await this.aiReportHistoryModel.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
reportDate: date,
|
||||||
|
status: AiReportStatus.SUCCESS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主入口:生成用户的AI健康报告图片
|
* 主入口:生成用户的AI健康报告图片
|
||||||
|
* 流程:
|
||||||
|
* 0. 检查当天生成次数限制(最多2次)
|
||||||
|
* 1. 创建记录,状态为 processing(进行中)
|
||||||
|
* 2. 聚合数据、生成 Prompt、调用图像生成 API
|
||||||
|
* 3. 根据结果更新记录状态为 success 或 failed
|
||||||
|
*
|
||||||
* @param userId 用户ID
|
* @param userId 用户ID
|
||||||
* @param date 目标日期,格式 YYYY-MM-DD,默认为今天
|
* @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');
|
const targetDate = date || dayjs().format('YYYY-MM-DD');
|
||||||
this.logger.log(`开始为用户 ${userId} 生成 ${targetDate} 的AI健康报告`);
|
this.logger.log(`开始为用户 ${userId} 生成 ${targetDate} 的AI健康报告`);
|
||||||
|
|
||||||
// 1. 聚合数据
|
// Step 0: 检查当天成功生成的报告数量,最多允许2次
|
||||||
const dailyData = await this.gatherDailyData(userId, targetDate);
|
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 startTime = Date.now();
|
||||||
const imagePrompt = await this.generateImagePrompt(dailyData);
|
const apiProvider = this.configService.get<string>('IMAGE_API_PROVIDER') || 'openrouter';
|
||||||
this.logger.log(`为用户 ${userId} 生成了图像Prompt: ${imagePrompt}`);
|
const recordId = uuidv4();
|
||||||
|
|
||||||
// 3. 调用图像生成API
|
// Step 1: 创建记录,状态为 processing
|
||||||
const imageUrl = await this.callImageGenerationApi(imagePrompt);
|
await this.createReportRecord({
|
||||||
this.logger.log(`为用户 ${userId} 成功生成图像: ${imageUrl}`);
|
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<AiReportHistory> {
|
||||||
|
try {
|
||||||
|
const record = await this.aiReportHistoryModel.create({
|
||||||
|
id: data.id,
|
||||||
|
userId: data.userId,
|
||||||
|
reportDate: data.reportDate,
|
||||||
|
apiProvider: data.apiProvider,
|
||||||
|
status: data.status,
|
||||||
|
prompt: null,
|
||||||
|
imageUrl: null,
|
||||||
|
});
|
||||||
|
this.logger.log(`创建报告记录成功,ID: ${data.id}, 状态: ${data.status}`);
|
||||||
|
return record;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`创建报告记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新报告记录
|
||||||
|
*/
|
||||||
|
private async updateReportRecord(
|
||||||
|
recordId: string,
|
||||||
|
data: Partial<{
|
||||||
|
prompt: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
status: AiReportStatus;
|
||||||
|
errorMessage: string | null;
|
||||||
|
generationTimeMs: number;
|
||||||
|
modelName: string;
|
||||||
|
}>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.aiReportHistoryModel.update(data, {
|
||||||
|
where: { id: recordId },
|
||||||
|
});
|
||||||
|
this.logger.log(`更新报告记录成功,ID: ${recordId}, 更新字段: ${Object.keys(data).join(', ')}`);
|
||||||
|
} catch (error) {
|
||||||
|
// 更新失败不影响主流程,仅记录日志
|
||||||
|
this.logger.error(`更新报告记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的报告生成历史(分页)
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param options 查询选项
|
||||||
|
*/
|
||||||
|
async getReportHistory(
|
||||||
|
userId: string,
|
||||||
|
options?: { page?: number; pageSize?: number; startDate?: string; endDate?: string; status?: string },
|
||||||
|
): Promise<{ records: AiReportHistory[]; total: number; page: number; pageSize: number; totalPages: number }> {
|
||||||
|
const { page = 1, pageSize = 10, startDate, endDate, status } = options || {};
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where: any = { userId };
|
||||||
|
|
||||||
|
// 如果传了 status 参数则按指定状态筛选,否则返回所有状态
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
where.reportDate = {};
|
||||||
|
if (startDate) where.reportDate[Op.gte] = startDate;
|
||||||
|
if (endDate) where.reportDate[Op.lte] = endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows, count } = await this.aiReportHistoryModel.findAndCountAll({
|
||||||
|
where,
|
||||||
|
attributes: ['id', 'reportDate', 'imageUrl', 'status', 'createdAt'],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit: pageSize,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
records: rows,
|
||||||
|
total: count,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(count / pageSize)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据记录ID获取报告详情
|
||||||
|
* @param recordId 记录ID
|
||||||
|
* @param userId 用户ID(用于权限校验)
|
||||||
|
*/
|
||||||
|
async getReportById(recordId: string, userId: string): Promise<AiReportHistory | null> {
|
||||||
|
return this.aiReportHistoryModel.findOne({
|
||||||
|
where: { id: recordId, userId },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,14 +300,15 @@ export class AiReportService {
|
|||||||
const dayStart = dayjs(date).startOf('day').toISOString();
|
const dayStart = dayjs(date).startOf('day').toISOString();
|
||||||
const dayEnd = dayjs(date).endOf('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.usersService.getProfile({ sub: userId, email: '', isGuest: false } as any).then(p => p.data).catch(() => null),
|
||||||
this.medicationStatsService.getDailyStats(userId, date).catch(() => null),
|
this.medicationStatsService.getDailyStats(userId, date).catch(() => null),
|
||||||
// 获取当日饮食记录并聚合
|
// 获取当日饮食记录并聚合
|
||||||
this.dietRecordsService.getDietHistory(userId, { startDate: dayStart, endDate: dayEnd, limit: 100 }).catch(() => ({ records: [] })),
|
this.dietRecordsService.getDietHistory(userId, { startDate: dayStart, endDate: dayEnd, limit: 100 }).catch(() => ({ records: [] })),
|
||||||
this.waterRecordsService.getWaterStats(userId, date).catch(() => null),
|
this.waterRecordsService.getWaterStats(userId, date).catch(() => null),
|
||||||
this.moodCheckinsService.getDaily(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,
|
latestChest: latestChest?.value,
|
||||||
latestWaist: latestWaist?.value,
|
latestWaist: latestWaist?.value,
|
||||||
},
|
},
|
||||||
|
challenges: {
|
||||||
|
activeChallengeCount: activeChallengeCount,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2. 生成固定格式的 prompt,适用于 Nano Banana Pro 模型
|
* 2. 生成固定格式的 prompt,适用于 Nano Banana Pro 模型
|
||||||
* 优化:不再使用 LLM 生成 prompt,而是使用固定模版,并确保包含准确的中文文本
|
* 优化:支持多语言,根据用户语言偏好生成对应的文本
|
||||||
*/
|
*/
|
||||||
private async generateImagePrompt(data: DailyHealthData): Promise<string> {
|
private async generateImagePrompt(data: DailyHealthData, language: string): Promise<string> {
|
||||||
// 格式化日期为 "12月01日"
|
const isEnglish = language.toLowerCase().startsWith('en');
|
||||||
const dateStr = dayjs(data.date).format('MM月DD日');
|
|
||||||
|
// 格式化日期
|
||||||
|
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 medRate = Math.round(data.medications.completionRate); // 取整
|
||||||
const calories = Math.round(data.diet.totalCalories);
|
const calories = Math.round(data.diet.totalCalories);
|
||||||
const water = Math.round(data.water.totalAmount);
|
const water = Math.round(data.water.totalAmount);
|
||||||
|
const challengeCount = data.challenges.activeChallengeCount;
|
||||||
|
|
||||||
// 根据性别调整角色描述
|
// 根据性别调整角色描述
|
||||||
let characterDesc = 'A happy cute character or animal mascot';
|
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';
|
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
|
// 构建 Prompt
|
||||||
// 格式:[风格描述] + [主体内容] + [文本渲染指令] + [细节描述]
|
// 格式:[风格描述] + [主体内容] + [文本渲染指令] + [细节描述]
|
||||||
const 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.
|
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.
|
The image features a cute layout with icons and text boxes.
|
||||||
Please render the following specific text in Chinese correctly:
|
${textContent.languageInstruction}
|
||||||
- Title text: "${dateStr} 健康日报"
|
- Title text: "${textContent.title}"
|
||||||
- Medication section text: "用药: ${medRate}%"
|
- Medication section text: "${textContent.medication}"
|
||||||
- Diet section text: "热量: ${calories}千卡"
|
- Diet section text: "${textContent.diet}"
|
||||||
- Water section text: "饮水: ${water}ml"
|
- Water section text: "${textContent.water}"
|
||||||
- Mood section text: "心情: ${moodText}"
|
- Mood section text: "${textContent.mood}"
|
||||||
|
${challengeTextSection}
|
||||||
Visual elements:
|
Visual elements:
|
||||||
- ${characterDesc} representing the user.
|
- ${characterDesc} representing the user.
|
||||||
- Icon for medication (pill bottle or pills).
|
- Icon for medication (pill bottle or pills).
|
||||||
@@ -218,7 +448,7 @@ Visual elements:
|
|||||||
- Icon for water (water glass or drop).
|
- Icon for water (water glass or drop).
|
||||||
- Icon for exercise (sneakers or dumbbell).
|
- Icon for exercise (sneakers or dumbbell).
|
||||||
- Icon for mood (a smiley face representing ${moodText}).
|
- Icon for mood (a smiley face representing ${moodText}).
|
||||||
|
${challengeIconSection}
|
||||||
Composition: Clean, organized, magazine layout style, decorative stickers and washi tape effects.
|
Composition: Clean, organized, magazine layout style, decorative stickers and washi tape effects.
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
@@ -226,10 +456,28 @@ Composition: Clean, organized, magazine layout style, decorative stickers and wa
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将心情类型翻译成中文(为了Prompt生成)
|
* 根据语言翻译心情类型
|
||||||
*/
|
*/
|
||||||
private translateMood(moodType?: string): string {
|
private translateMood(moodType?: string, language: string = 'zh-CN'): string {
|
||||||
const moodMap: Record<string, string> = {
|
const isEnglish = language.toLowerCase().startsWith('en');
|
||||||
|
|
||||||
|
if (isEnglish) {
|
||||||
|
const moodMapEn: Record<string, string> = {
|
||||||
|
HAPPY: 'Happy',
|
||||||
|
EXCITED: 'Excited',
|
||||||
|
THRILLED: 'Thrilled',
|
||||||
|
CALM: 'Calm',
|
||||||
|
ANXIOUS: 'Anxious',
|
||||||
|
SAD: 'Sad',
|
||||||
|
LONELY: 'Lonely',
|
||||||
|
WRONGED: 'Wronged',
|
||||||
|
ANGRY: 'Angry',
|
||||||
|
TIRED: 'Tired',
|
||||||
|
};
|
||||||
|
return moodMapEn[moodType || ''] || 'Calm';
|
||||||
|
}
|
||||||
|
|
||||||
|
const moodMapZh: Record<string, string> = {
|
||||||
HAPPY: '开心',
|
HAPPY: '开心',
|
||||||
EXCITED: '兴奋',
|
EXCITED: '兴奋',
|
||||||
THRILLED: '激动',
|
THRILLED: '激动',
|
||||||
@@ -241,107 +489,310 @@ Composition: Clean, organized, magazine layout style, decorative stickers and wa
|
|||||||
ANGRY: '生气',
|
ANGRY: '生气',
|
||||||
TIRED: '心累',
|
TIRED: '心累',
|
||||||
};
|
};
|
||||||
// 如果心情未知,返回"开心"以保持积极的氛围,或者使用"平和"
|
return moodMapZh[moodType || ''] || '平静';
|
||||||
return moodMap[moodType || ''] || '开心';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 3. 调用 OpenRouter SDK 生成图片并上传到 COS
|
* 获取用户语言偏好
|
||||||
|
*/
|
||||||
|
private async getUserLanguage(userId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await this.usersService.getUserLanguage(userId);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`获取用户语言失败,使用默认中文: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return 'zh-CN';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3. 调用图像生成 API,支持 OpenRouter 和 GRSAI 两种提供商
|
||||||
|
* 通过环境变量 IMAGE_API_PROVIDER 配置:'openrouter' | 'grsai',默认 'openrouter'
|
||||||
*/
|
*/
|
||||||
private async callImageGenerationApi(prompt: string): Promise<string> {
|
private async callImageGenerationApi(prompt: string): Promise<string> {
|
||||||
this.logger.log(`准备调用 OpenRouter SDK 生成图像`);
|
const apiProvider = this.configService.get<string>('IMAGE_API_PROVIDER') || 'openrouter';
|
||||||
|
this.logger.log(`准备调用 ${apiProvider} 生成图像`);
|
||||||
this.logger.log(`使用Prompt: ${prompt}`);
|
this.logger.log(`使用Prompt: ${prompt}`);
|
||||||
|
|
||||||
const openRouterApiKey = this.configService.get<string>('OPENROUTER_API_KEY');
|
if (apiProvider === 'grsai') {
|
||||||
if (!openRouterApiKey) {
|
return this.callGrsaiImageApi(prompt);
|
||||||
|
} else {
|
||||||
|
return this.callOpenRouterImageApi(prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 OpenRouter API 生成图像
|
||||||
|
*/
|
||||||
|
private async callOpenRouterImageApi(prompt: string): Promise<string> {
|
||||||
|
const apiKey = this.configService.get<string>('OPENROUTER_API_KEY');
|
||||||
|
if (!apiKey) {
|
||||||
this.logger.error('OpenRouter API Key 未配置');
|
this.logger.error('OpenRouter API Key 未配置');
|
||||||
throw new Error('OpenRouter API Key 未配置');
|
throw new Error('OpenRouter API Key 未配置');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 初始化 OpenRouter 客户端
|
const client = new OpenAI({
|
||||||
const openrouter = new OpenRouter({
|
baseURL: 'https://openrouter.ai/api/v1',
|
||||||
apiKey: openRouterApiKey,
|
apiKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 调用图像生成API
|
this.logger.log(`使用 OpenRouter API, 模型: google/gemini-3-pro-image-preview`);
|
||||||
const result = await openrouter.chat.send({
|
|
||||||
model: "google/gemini-3-pro-image-preview",
|
const apiResponse = await client.chat.completions.create({
|
||||||
|
model: 'google/gemini-3-pro-image-preview',
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: 'user' as const,
|
||||||
content: prompt,
|
content: prompt,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// @ts-ignore - 扩展参数,用于支持图像生成
|
||||||
|
modalities: ['image', 'text'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const message = result.choices[0].message;
|
const message = apiResponse.choices[0].message;
|
||||||
|
this.logger.log(`OpenRouter 收到响应: ${JSON.stringify(message, null, 2)}`);
|
||||||
// 处理不同格式的响应内容
|
|
||||||
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 imageData = this.extractImageData(message);
|
||||||
if (imageData) {
|
if (imageData) {
|
||||||
if (isBase64) {
|
return await this.processAndUploadImage(imageData);
|
||||||
// 处理 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) {
|
} catch (error) {
|
||||||
this.logger.error(`调用 OpenRouter SDK 失败: ${error.message}`);
|
this.logger.error(`调用 OpenRouter 失败: ${error.message}`);
|
||||||
if (error.response) {
|
|
||||||
this.logger.error(`OpenRouter 错误详情: ${JSON.stringify(error.response.data)}`);
|
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 GRSAI API 生成图像(提交任务 + 轮询结果)
|
||||||
|
* API 文档:
|
||||||
|
* - 提交任务: POST https://grsai.dakka.com.cn/v1/draw/nano-banana
|
||||||
|
* - 查询结果: POST https://grsai.dakka.com.cn/v1/draw/result
|
||||||
|
*/
|
||||||
|
private async callGrsaiImageApi(prompt: string): Promise<string> {
|
||||||
|
const apiKey = this.configService.get<string>('GRSAI_API_KEY');
|
||||||
|
if (!apiKey) {
|
||||||
|
this.logger.error('GRSAI API Key 未配置');
|
||||||
|
throw new Error('GRSAI API Key 未配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseURL = 'https://grsai.dakka.com.cn';
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// 通用请求头
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: 提交图像生成任务
|
||||||
|
this.logger.log('GRSAI: 提交图像生成任务...');
|
||||||
|
const submitResponse = await axios.post(
|
||||||
|
`${baseURL}/v1/draw/nano-banana`,
|
||||||
|
{
|
||||||
|
model: 'nano-banana-pro',
|
||||||
|
prompt: prompt,
|
||||||
|
webHook: '-1', // 必填 -1,表示不使用 webhook
|
||||||
|
},
|
||||||
|
{ headers, timeout: 30000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查提交响应
|
||||||
|
if (submitResponse.data.code !== 0) {
|
||||||
|
this.logger.error(`GRSAI 任务提交失败: ${submitResponse.data.msg}`);
|
||||||
|
throw new Error(`GRSAI 任务提交失败: ${submitResponse.data.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = submitResponse.data.data?.id;
|
||||||
|
if (!taskId) {
|
||||||
|
this.logger.error('GRSAI 任务提交成功但未返回任务ID');
|
||||||
|
throw new Error('GRSAI 任务提交失败:未返回任务ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`GRSAI: 任务提交成功,任务ID: ${taskId}`);
|
||||||
|
|
||||||
|
// Step 2: 轮询获取结果
|
||||||
|
const imageUrl = await this.pollGrsaiResult(baseURL, headers, taskId);
|
||||||
|
|
||||||
|
// Step 3: 下载图像并上传到 COS
|
||||||
|
return await this.processAndUploadImage(imageUrl);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`调用 GRSAI 失败: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询 GRSAI 任务结果
|
||||||
|
* @param baseURL API 基础地址
|
||||||
|
* @param headers 请求头
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @returns 生成的图像 URL
|
||||||
|
*/
|
||||||
|
private async pollGrsaiResult(
|
||||||
|
baseURL: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
taskId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// 轮询配置
|
||||||
|
const maxAttempts = 60; // 最大尝试次数
|
||||||
|
const pollInterval = 2000; // 轮询间隔 2 秒
|
||||||
|
const maxWaitTime = 120000; // 最大等待时间 2 分钟
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
attempts++;
|
||||||
|
const elapsedTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 检查是否超时
|
||||||
|
if (elapsedTime > maxWaitTime) {
|
||||||
|
this.logger.error(`GRSAI: 轮询超时,已等待 ${elapsedTime / 1000} 秒`);
|
||||||
|
throw new Error('GRSAI 图像生成超时');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`GRSAI: 第 ${attempts} 次轮询,任务ID: ${taskId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resultResponse = await axios.post(
|
||||||
|
`${baseURL}/v1/draw/result`,
|
||||||
|
{ id: taskId },
|
||||||
|
{ headers, timeout: 10000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = resultResponse.data;
|
||||||
|
|
||||||
|
// 检查响应码
|
||||||
|
if (data.code !== 0) {
|
||||||
|
this.logger.error(`GRSAI 查询失败: ${data.msg}`);
|
||||||
|
throw new Error(`GRSAI 查询失败: ${data.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskData = data.data;
|
||||||
|
const status = taskData?.status;
|
||||||
|
const progress = taskData?.progress || 0;
|
||||||
|
|
||||||
|
this.logger.log(`GRSAI: 任务状态: ${status}, 进度: ${progress}%`);
|
||||||
|
|
||||||
|
// 根据状态处理
|
||||||
|
switch (status) {
|
||||||
|
case 'succeeded':
|
||||||
|
// 任务成功,提取图像 URL
|
||||||
|
const results = taskData.results;
|
||||||
|
if (results && results.length > 0 && results[0].url) {
|
||||||
|
const imageUrl = results[0].url;
|
||||||
|
this.logger.log(`GRSAI: 图像生成成功: ${imageUrl}`);
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
throw new Error('GRSAI 任务成功但未返回图像URL');
|
||||||
|
|
||||||
|
case 'failed':
|
||||||
|
// 任务失败
|
||||||
|
const failureReason = taskData.failure_reason || taskData.error || '未知错误';
|
||||||
|
this.logger.error(`GRSAI: 任务失败: ${failureReason}`);
|
||||||
|
throw new Error(`GRSAI 图像生成失败: ${failureReason}`);
|
||||||
|
|
||||||
|
case 'pending':
|
||||||
|
case 'processing':
|
||||||
|
case 'running':
|
||||||
|
// 任务进行中,继续轮询
|
||||||
|
await this.sleep(pollInterval);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 未知状态,继续轮询
|
||||||
|
this.logger.warn(`GRSAI: 未知任务状态: ${status}`);
|
||||||
|
await this.sleep(pollInterval);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 如果是网络错误,继续重试
|
||||||
|
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||||
|
this.logger.warn(`GRSAI: 轮询请求超时,继续重试...`);
|
||||||
|
await this.sleep(pollInterval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`GRSAI 图像生成失败:超过最大轮询次数 ${maxAttempts}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延时函数
|
||||||
|
*/
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 API 响应中提取图像数据
|
||||||
|
*/
|
||||||
|
private extractImageData(message: any): string | undefined {
|
||||||
|
let imageData: string | undefined;
|
||||||
|
|
||||||
|
// 检查 images 数组(OpenRouter/GRSAI 特有的响应格式)
|
||||||
|
if (message.images && Array.isArray(message.images) && message.images.length > 0) {
|
||||||
|
const firstImage = message.images[0];
|
||||||
|
if (firstImage.image_url?.url) {
|
||||||
|
imageData = firstImage.image_url.url;
|
||||||
|
this.logger.log(`检测到 images 数组中的图像数据`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 images 数组中没有,检查 content 字段
|
||||||
|
if (!imageData && typeof message.content === 'string') {
|
||||||
|
// 检查是否为 base64 数据
|
||||||
|
if (message.content.startsWith('data:image/')) {
|
||||||
|
imageData = message.content;
|
||||||
|
this.logger.log('检测到 Data URL 格式的 base64 图像');
|
||||||
|
} else if (/^[A-Za-z0-9+/=]+$/.test(message.content.substring(0, 100))) {
|
||||||
|
imageData = message.content;
|
||||||
|
this.logger.log('检测到纯 base64 格式的图像数据');
|
||||||
|
} else {
|
||||||
|
// 尝试提取 HTTP URL
|
||||||
|
const urlMatch = message.content.match(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp)/i);
|
||||||
|
if (urlMatch) {
|
||||||
|
imageData = urlMatch[0];
|
||||||
|
this.logger.log(`检测到 HTTP URL: ${imageData}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理图像数据并上传到 COS
|
||||||
|
*/
|
||||||
|
private async processAndUploadImage(imageData: string): Promise<string> {
|
||||||
|
// 判断是 base64 还是 URL
|
||||||
|
const isBase64 = imageData.startsWith('data:image/') ||
|
||||||
|
(/^[A-Za-z0-9+/=]+$/.test(imageData.substring(0, 100)) && !imageData.startsWith('http'));
|
||||||
|
|
||||||
|
if (isBase64) {
|
||||||
|
// 处理 base64 数据并上传到 COS
|
||||||
|
this.logger.log('开始处理 base64 图像数据');
|
||||||
|
const cosImageUrl = await this.uploadBase64ToCos(imageData);
|
||||||
|
this.logger.log(`Base64 图像上传到 COS 成功: ${cosImageUrl}`);
|
||||||
|
return cosImageUrl;
|
||||||
|
} else {
|
||||||
|
// 下载 HTTP URL 图像并上传到 COS
|
||||||
|
this.logger.log(`API 返回图像 URL: ${imageData}`);
|
||||||
|
const cosImageUrl = await this.downloadAndUploadToCos(imageData);
|
||||||
|
this.logger.log(`图像上传到 COS 成功: ${cosImageUrl}`);
|
||||||
|
return cosImageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 base64 图像数据上传到 COS
|
* 将 base64 图像数据上传到 COS
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
import { AppService } from "./app.service";
|
import { AppService } from "./app.service";
|
||||||
import { DatabaseModule } from "./database/database.module";
|
import { DatabaseModule } from "./database/database.module";
|
||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
import { LoggerModule } from './common/logger/logger.module';
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
import { CheckinsModule } from './checkins/checkins.module';
|
import { CheckinsModule } from './checkins/checkins.module';
|
||||||
import { AiCoachModule } from './ai-coach/ai-coach.module';
|
import { AiCoachModule } from './ai-coach/ai-coach.module';
|
||||||
@@ -30,6 +32,10 @@ import { MedicationsModule } from './medications/medications.module';
|
|||||||
envFilePath: '.env',
|
envFilePath: '.env',
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
|
ThrottlerModule.forRoot([{
|
||||||
|
ttl: 60000, // 时间窗口:60秒
|
||||||
|
limit: 100, // 每个时间窗口最多100个请求
|
||||||
|
}]),
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
@@ -51,6 +57,12 @@ import { MedicationsModule } from './medications/medications.module';
|
|||||||
MedicationsModule,
|
MedicationsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [
|
||||||
|
AppService,
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule { }
|
export class AppModule { }
|
||||||
|
|||||||
@@ -788,6 +788,37 @@ export class ChallengesService {
|
|||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户当前参与的活跃挑战数量(正在进行中且未过期)
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @returns 活跃挑战数量
|
||||||
|
*/
|
||||||
|
async getActiveParticipatingChallengeCount(userId: string): Promise<number> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// 查询用户参与的所有活跃状态的挑战
|
||||||
|
const participants = await this.participantModel.findAll({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: {
|
||||||
|
[Op.in]: [ChallengeParticipantStatus.ACTIVE, ChallengeParticipantStatus.COMPLETED],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: [{
|
||||||
|
model: Challenge,
|
||||||
|
as: 'challenge',
|
||||||
|
where: {
|
||||||
|
challengeState: ChallengeState.ACTIVE,
|
||||||
|
startAt: { [Op.lte]: now },
|
||||||
|
endAt: { [Op.gte]: now },
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
return participants.length;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户加入的自定义挑战 ID 列表
|
* 获取用户加入的自定义挑战 ID 列表
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
collate: 'utf8mb4_0900_ai_ci',
|
collate: 'utf8mb4_0900_ai_ci',
|
||||||
},
|
},
|
||||||
autoLoadModels: true,
|
autoLoadModels: true,
|
||||||
synchronize: true,
|
synchronize: false,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ async function bootstrap() {
|
|||||||
|
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
const duration = Date.now() - startTime;
|
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) {
|
if (res.statusCode >= 400) {
|
||||||
logger.error(`${logMessage} - Body: ${JSON.stringify(req.body)}`);
|
logger.error(`${logMessage} - Body: ${JSON.stringify(req.body)}`);
|
||||||
|
|||||||
@@ -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
|
|
||||||
// 显示图片上传引导
|
|
||||||
<div className="upload-guide">
|
|
||||||
<div className="upload-item required">
|
|
||||||
<Icon name="front" />
|
|
||||||
<span>正面照片(必需)</span>
|
|
||||||
<p>拍摄药品包装正面,确保药品名称清晰可见</p>
|
|
||||||
</div>
|
|
||||||
<div className="upload-item required">
|
|
||||||
<Icon name="side" />
|
|
||||||
<span>侧面照片(必需)</span>
|
|
||||||
<p>拍摄药品包装侧面,确保规格剂量清晰可见</p>
|
|
||||||
</div>
|
|
||||||
<div className="upload-item optional">
|
|
||||||
<Icon name="document" />
|
|
||||||
<span>说明书(可选)</span>
|
|
||||||
<p>拍摄药品说明书,可提高识别准确度</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 识别阶段
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 显示识别进度和当前步骤
|
|
||||||
<div className="recognition-progress">
|
|
||||||
<ProgressBar value={progress} />
|
|
||||||
<div className="current-step">
|
|
||||||
{status === 'analyzing_product' && '📦 正在识别药品信息...'}
|
|
||||||
{status === 'analyzing_suitability' && '👥 正在分析适宜人群...'}
|
|
||||||
{status === 'analyzing_ingredients' && '🧪 正在分析主要成分...'}
|
|
||||||
{status === 'analyzing_effects' && '⚠️ 正在分析副作用...'}
|
|
||||||
</div>
|
|
||||||
<div className="progress-text">{progress}%</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 确认阶段
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 展示识别结果供用户确认和编辑
|
|
||||||
<div className="recognition-result">
|
|
||||||
<div className="confidence-badge">
|
|
||||||
识别置信度: {(result.confidence * 100).toFixed(0)}%
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EditableField
|
|
||||||
label="药品名称"
|
|
||||||
value={result.name}
|
|
||||||
onChange={(v) => setAdjustments({...adjustments, name: v})}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditableField
|
|
||||||
label="每日服用次数"
|
|
||||||
value={result.timesPerDay}
|
|
||||||
onChange={(v) => setAdjustments({...adjustments, timesPerDay: v})}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 更多可编辑字段 */}
|
|
||||||
|
|
||||||
<div className="ai-analysis">
|
|
||||||
<h3>AI 健康分析</h3>
|
|
||||||
<Section title="适合人群" items={result.suitableFor} />
|
|
||||||
<Section title="不适合人群" items={result.unsuitableFor} />
|
|
||||||
<Section title="主要成分" items={result.mainIngredients} />
|
|
||||||
<Section title="副作用" items={result.sideEffects} />
|
|
||||||
<Section title="健康建议" items={result.healthAdvice} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="action-buttons">
|
|
||||||
<Button onClick={handleCancel}>取消</Button>
|
|
||||||
<Button primary onClick={handleConfirm}>确认创建</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
### 常见错误及解决方案
|
|
||||||
|
|
||||||
| 错误码 | 错误信息 | 原因 | 解决方案 |
|
|
||||||
| ------ | ---------------------- | ------------------ | ---------------------- |
|
|
||||||
| 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. **用户体验**:
|
|
||||||
- 支持离线识别(边缘计算)
|
|
||||||
- 实时预览识别结果
|
|
||||||
- 智能纠错和建议
|
|
||||||
@@ -207,17 +207,24 @@ export class MedicationAnalysisService {
|
|||||||
`用户 ${userId} 的用药计划分析结果: ${JSON.stringify(medicationAnalysis, null, 2)}`,
|
`用户 ${userId} 的用药计划分析结果: ${JSON.stringify(medicationAnalysis, null, 2)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 获取用户语言配置
|
||||||
|
const languageConfig = await this.getUserLanguageConfig(userId);
|
||||||
|
|
||||||
// 没有开启的用药计划时,不调用大模型
|
// 没有开启的用药计划时,不调用大模型
|
||||||
if (!medicationAnalysis.length) {
|
if (!medicationAnalysis.length) {
|
||||||
|
const noDataMessage = languageConfig.label === 'English'
|
||||||
|
? 'No active medication plans found. Please add or activate medications to view insights.'
|
||||||
|
: '当前没有开启的用药计划,请先添加或激活用药后再查看解读。';
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
medicationAnalysis,
|
medicationAnalysis,
|
||||||
keyInsights: '当前没有开启的用药计划,请先添加或激活用药后再查看解读。',
|
keyInsights: noDataMessage,
|
||||||
};
|
};
|
||||||
await this.saveSummary(userId, summaryDate, result);
|
await this.saveSummary(userId, summaryDate, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = this.buildMedicationSummaryPrompt(medicationAnalysis);
|
const prompt = this.buildMedicationSummaryPrompt(medicationAnalysis, languageConfig);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.client.chat.completions.create({
|
const response = await this.client.chat.completions.create({
|
||||||
@@ -541,6 +548,7 @@ export class MedicationAnalysisService {
|
|||||||
*/
|
*/
|
||||||
private buildMedicationSummaryPrompt(
|
private buildMedicationSummaryPrompt(
|
||||||
medications: MedicationPlanItemDto[],
|
medications: MedicationPlanItemDto[],
|
||||||
|
languageConfig: LanguageConfig,
|
||||||
): string {
|
): string {
|
||||||
const medicationList = medications
|
const medicationList = medications
|
||||||
.map(
|
.map(
|
||||||
@@ -549,12 +557,19 @@ export class MedicationAnalysisService {
|
|||||||
)
|
)
|
||||||
.join('\n');
|
.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}
|
${medicationList}
|
||||||
|
|
||||||
写作要求:
|
写作要求:
|
||||||
|
- 语言:${languageInstruction}
|
||||||
- 口吻:专业、温和、以患者安全为先
|
- 口吻:专业、温和、以患者安全为先
|
||||||
- 内容:结合完成率与计划总次数,点评用药依从性、潜在风险与监测要点(胃肠道、肝肾功能、血压/血糖等),提出需要复诊或调整的建议
|
- 内容:结合完成率与计划总次数,点评用药依从性、潜在风险与监测要点(胃肠道、肝肾功能、血压/血糖等),提出需要复诊或调整的建议
|
||||||
- 形式:只输出一段文字,不要使用列表或分段,不要重复列出药品清单,不要添加免责声明`;
|
- 形式:只输出一段文字,不要使用列表或分段,不要重复列出药品清单,不要添加免责声明`;
|
||||||
@@ -608,7 +623,6 @@ ${medication.note ? `- 用户备注:${medication.note}` : ''}
|
|||||||
|
|
||||||
**语言要求**:
|
**语言要求**:
|
||||||
${languageConfig.analysisInstruction}
|
${languageConfig.analysisInstruction}
|
||||||
- 将所有标题、要点、提醒翻译为${languageConfig.label}(保留药品名称、成分等专有名词的常用写法),不要混用其他语言
|
|
||||||
|
|
||||||
**输出格式要求**:
|
**输出格式要求**:
|
||||||
|
|
||||||
@@ -803,16 +817,16 @@ ${medication.note ? `- 用户备注:${medication.note}` : ''}
|
|||||||
if (normalized.startsWith('en')) {
|
if (normalized.startsWith('en')) {
|
||||||
return {
|
return {
|
||||||
label: 'English',
|
label: 'English',
|
||||||
analysisInstruction: 'Respond entirely in English. Translate every section title, bullet point and paragraph; keep emojis unchanged.',
|
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 values in English. Keep JSON keys exactly as defined in the schema.',
|
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.',
|
unableToIdentifyMessage: 'Unable to identify the medication, please provide a more accurate name or image.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: '简体中文',
|
label: '简体中文',
|
||||||
analysisInstruction: '请使用简体中文输出全部内容(包括标题、要点和提醒),不要混用其他语言。',
|
analysisInstruction: '请使用简体中文输出全部内容(包括所有标题、要点、段落和提醒),emoji 保持不变,不要混用其他语言。',
|
||||||
jsonInstruction: '请确保 JSON 中的值使用简体中文,字段名保持英文。',
|
jsonInstruction: '请确保 JSON 中所有字段的值都使用简体中文,字段名(key)保持英文不变。',
|
||||||
unableToIdentifyMessage: '无法识别药品,请提供更准确的名称或图片。',
|
unableToIdentifyMessage: '无法识别药品,请提供更准确的名称或图片。',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard';
|
|||||||
import { ResponseCode } from 'src/base.dto';
|
import { ResponseCode } from 'src/base.dto';
|
||||||
import { CosService } from './cos.service';
|
import { CosService } from './cos.service';
|
||||||
import { AiReportService } from '../ai-coach/services/ai-report.service';
|
import { AiReportService } from '../ai-coach/services/ai-report.service';
|
||||||
|
import { GetAiReportHistoryQueryDto, GetAiReportHistoryResponseDto } from '../ai-coach/dto/ai-report-history.dto';
|
||||||
|
|
||||||
|
|
||||||
@ApiTags('users')
|
@ApiTags('users')
|
||||||
@@ -497,6 +498,7 @@ export class UsersController {
|
|||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
id: { type: 'string', example: '550e8400-e29b-41d4-a716-446655440000' },
|
||||||
imageUrl: { type: 'string', example: 'https://example.com/generated-image.png' }
|
imageUrl: { type: 'string', example: 'https://example.com/generated-image.png' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,7 +508,7 @@ export class UsersController {
|
|||||||
async generateAiHealthReport(
|
async generateAiHealthReport(
|
||||||
@CurrentUser() user: AccessTokenPayload,
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
@Body() body: { date?: string },
|
@Body() body: { date?: string },
|
||||||
): Promise<{ code: ResponseCode; message: string; data: { imageUrl: string } }> {
|
): Promise<{ code: ResponseCode; message: string; data: { id: string; imageUrl: string } }> {
|
||||||
try {
|
try {
|
||||||
this.logger.log(`生成AI健康报告请求 - 用户ID: ${user.sub}, 日期: ${body.date || '今天'}`);
|
this.logger.log(`生成AI健康报告请求 - 用户ID: ${user.sub}, 日期: ${body.date || '今天'}`);
|
||||||
|
|
||||||
@@ -516,6 +518,7 @@ export class UsersController {
|
|||||||
code: ResponseCode.SUCCESS,
|
code: ResponseCode.SUCCESS,
|
||||||
message: 'AI健康报告生成成功',
|
message: 'AI健康报告生成成功',
|
||||||
data: {
|
data: {
|
||||||
|
id: result.id,
|
||||||
imageUrl: result.imageUrl,
|
imageUrl: result.imageUrl,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -531,10 +534,78 @@ export class UsersController {
|
|||||||
code: ResponseCode.ERROR,
|
code: ResponseCode.ERROR,
|
||||||
message: `生成失败: ${(error as Error).message}`,
|
message: `生成失败: ${(error as Error).message}`,
|
||||||
data: {
|
data: {
|
||||||
|
id: '',
|
||||||
imageUrl: '',
|
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<GetAiReportHistoryResponseDto> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=== 测试完成 ==="
|
|
||||||
@@ -1105,6 +1105,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "2.8.1"
|
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":
|
"@nodelib/fs.scandir@2.1.5":
|
||||||
version "2.1.5"
|
version "2.1.5"
|
||||||
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
|
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user