Compare commits

..

24 Commits

Author SHA1 Message Date
richarjiang
8fa741cc1b refactor(expo-updates): 优化hash计算和并行处理逻辑,提升性能和调试能力 2025-12-06 10:05:12 +08:00
richarjiang
51d0dabc9a refactor(expo-updates): 重构manifest构建逻辑,支持动态获取metadata和真实hash计算 2025-12-05 22:34:33 +08:00
richarjiang
190bc5bce9 refactor(expo-updates): 优化更新ID生成逻辑,基于bundle hash确保ID唯一性 2025-12-05 20:11:29 +08:00
richarjiang
14d791f552 feat: 添加Expo Updates服务端模块和就医资料管理功能 2025-12-05 16:08:53 +08:00
richarjiang
de67132a36 refactor: 移除workouts模块依赖,清理训练计划相关注释 2025-12-05 10:28:50 +08:00
richarjiang
b46d99fe69 feat: 集成Redis模块并重构限流存储机制 2025-12-05 10:03:59 +08:00
richarjiang
8a43b1795b fix: invite 2025-12-04 18:56:01 +08:00
richarjiang
eecc14d45a feat(health-profiles): 禁用家庭成员模型的时间戳字段 2025-12-04 17:55:51 +08:00
richarjiang
2d7e067888 feat(health-profiles): 添加健康档案模块,支持健康史记录、家庭健康管理和档案概览功能 2025-12-04 17:15:11 +08:00
richarjiang
03bd0b041e feat(medications): 添加药品过期预警功能,支持多时间段提醒和标记重置 2025-12-03 19:22:46 +08:00
richarjiang
b12956d80a feat(ai): 优化健康日报生成,集成用户健康统计数据并增强视觉提示 2025-12-03 10:13:05 +08:00
richarjiang
2ff2c58b43 feat(users): 添加用户每日健康数据步数字段,支持步数记录和更新 2025-12-03 09:00:26 +08:00
richarjiang
c3b59752ee feat(users): 添加用户每日健康数据记录功能,支持多维度健康指标更新 2025-12-02 19:11:17 +08:00
richarjiang
6cdd2bc137 feat(users): 添加App版本号追踪功能,支持用户版本更新记录 2025-12-02 14:40:59 +08:00
richarjiang
562c66a930 feat(ai): 添加AI报告生成历史记录功能,支持每日生成限制和双API提供商 2025-12-02 12:07:08 +08:00
richarjiang
5b89a07751 stash 2025-12-01 18:12:09 +08:00
richarjiang
7ce51409af feat(medications): 添加用药AI总结功能,支持生成用户用药计划的重点解读 2025-12-01 11:21:57 +08:00
ae41a2b643 feat(users): add version checking endpoint
Add app version checking functionality to notify users when updates are available. The feature extracts the current version from the x-App-Version header, compares it with the latest configured version, and returns update information including download links and release notes.
2025-11-29 20:47:01 +08:00
richarjiang
ff2dfd5bb3 feat(ai): 支持多语言AI分析响应并优化药品识别流程
- 饮食分析与药品分析服务新增多语言支持(zh-CN/en-US),根据用户偏好动态调整 Prompt 和返回信息
- 重构药品识别流程,利用 GLM-4.5v 模型将多阶段分析合并为单次全量分析,提升响应速度
- 增加用户语言获取逻辑,并在异步任务状态更新中支持本地化文案
- 移除废弃的药品分析 V1 接口,升级底层模型配置
2025-11-28 16:02:16 +08:00
richarjiang
43f378d44d feat(users): 添加用户语言偏好字段
- 在 User 模型中添加 language 字段,默认值为 'zh-CN'
- 更新 UpdateUserDto 以支持语言偏好参数
- 在用户更新服务中实现语言偏好的保存逻辑
2025-11-27 11:17:27 +08:00
richarjiang
ac231a7742 feat(challenges): 支持自定义挑战类型并优化必填字段验证
- 新增 CUSTOM 挑战类型枚举值
- requirementLabel 字段改为可选,允许为空并添加默认值处理
- minimumCheckInDays 最大值从 365 提升至 1000,支持更长周期挑战
- 推送通知模板支持自定义挑战的动态文案生成
- 新增 getCustomEncouragementTemplate 和 getCustomInvitationTemplate 函数
2025-11-27 11:11:26 +08:00
richarjiang
7a05097226 feat(challenges): add vip user restrictions for challenge creation
限制非会员用户只能创建一个未归档的自定义挑战,添加用户VIP状态检查和挑战数量限制逻辑
2025-11-27 08:33:46 +08:00
richarjiang
5d64a99ce5 feat(challenges): 添加挑战创建者标识和归档状态过滤
- 为挑战详情和列表接口添加isCreator字段标识创建者
- 过滤掉已归档的挑战,避免在列表和操作中显示
- 为挑战详情接口添加JWT认证守卫
- 将自定义挑战的progressUnit字段设为必填
- 优化挑战编辑时的错误提示信息
- 移除冗余的isCreator私有方法,直接在响应中设置标识
2025-11-26 18:57:13 +08:00
richarjiang
26e88ae610 feat(challenges): 添加挑战源和分享代码字段,更新挑战详情和列表接口 2025-11-26 17:26:27 +08:00
100 changed files with 7395 additions and 3981 deletions

View File

@@ -1,14 +1 @@
{
"mcpServers": {
"context7": {
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp"
],
"env": {
"DEFAULT_MINIMUM_TOKENS": ""
}
}
}
}
{"mcpServers":{"context7":{"command":"npx","args":["-y","@upstash/context7-mcp"],"env":{"DEFAULT_MINIMUM_TOKENS":""},"alwaysAllow":["get-library-docs","resolve-library-id"]}}}

View File

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

View File

@@ -0,0 +1,456 @@
# AI 健康报告接口文档
## 接口概述
生成用户的 AI 健康报告图片接口,基于用户的健康数据(体重、饮食、运动等)生成可视化的健康分析报告图片。
---
## 接口信息
- **接口名称**: 生成 AI 健康报告
- **接口路径**: `/users/ai-report`
- **请求方法**: `POST`
- **认证方式**: JWT Token (Bearer Token)
- **内容类型**: `application/json`
---
## 请求说明
### 请求头 (Headers)
| 参数名 | 类型 | 必填 | 说明 | 示例 |
| ------------- | ------ | ---- | ------------ | ------------------------------------------------ |
| Authorization | string | 是 | JWT 访问令牌 | `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...` |
| Content-Type | string | 是 | 请求内容类型 | `application/json` |
### 请求体 (Body)
| 参数名 | 类型 | 必填 | 说明 | 示例 |
| ------ | ------ | ---- | ------------------------------------------------------------- | -------------- |
| date | string | 否 | 指定生成报告的日期,格式 YYYY-MM-DD。不传则默认生成今天的报告 | `"2024-01-15"` |
### 请求示例
#### 示例 1: 生成今天的报告
```bash
curl -X POST 'https://api.example.com/users/ai-report' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
-H 'Content-Type: application/json' \
-d '{}'
```
#### 示例 2: 生成指定日期的报告
```bash
curl -X POST 'https://api.example.com/users/ai-report' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
-H 'Content-Type: application/json' \
-d '{
"date": "2024-01-15"
}'
```
#### JavaScript/TypeScript 示例
```typescript
// 使用 fetch
async function generateHealthReport(date?: string) {
const response = await fetch("https://api.example.com/users/ai-report", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(date ? { date } : {}),
});
const result = await response.json();
return result;
}
// 使用 axios
import axios from "axios";
async function generateHealthReport(date?: string) {
const response = await axios.post(
"https://api.example.com/users/ai-report",
date ? { date } : {},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
return response.data;
}
```
#### Swift 示例
```swift
func generateHealthReport(date: String? = nil, completion: @escaping (Result<HealthReportResponse, Error>) -> Void) {
let url = URL(string: "https://api.example.com/users/ai-report")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = [:]
if let date = date {
body["date"] = date
}
if !body.isEmpty {
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "", code: -1, userInfo: nil)))
return
}
do {
let decoder = JSONDecoder()
let result = try decoder.decode(HealthReportResponse.self, from: data)
completion(.success(result))
} catch {
completion(.failure(error))
}
}.resume()
}
// 响应模型
struct HealthReportResponse: Codable {
let code: Int
let message: String
let data: HealthReportData
}
struct HealthReportData: Codable {
let imageUrl: String
}
```
---
## 响应说明
### 响应格式
所有响应都遵循统一的响应格式:
```typescript
{
code: number; // 响应码: 0-成功, 1-失败
message: string; // 响应消息
data: {
imageUrl: string; // 生成的报告图片 URL
}
}
```
### 成功响应
**HTTP 状态码**: `200 OK`
**响应体示例**:
```json
{
"code": 0,
"message": "AI健康报告生成成功",
"data": {
"imageUrl": "https://pilates-1234567890.cos.ap-guangzhou.myqcloud.com/health-reports/user-123/2024-01-15/report-xxxxx.png"
}
}
```
**字段说明**:
| 字段 | 类型 | 说明 |
| ------------- | ------ | -------------------------------------------- |
| code | number | 响应码0 表示成功 |
| message | string | 响应消息,成功时为 "AI健康报告生成成功" |
| data.imageUrl | string | 生成的健康报告图片完整 URL可直接访问和下载 |
### 失败响应
**HTTP 状态码**: `200 OK` (业务失败也返回 200通过 code 字段判断)
**响应体示例**:
```json
{
"code": 1,
"message": "生成失败: 用户健康数据不足",
"data": {
"imageUrl": ""
}
}
```
**常见错误消息**:
| 错误消息 | 说明 | 解决方案 |
| ----------------------------- | ------------------------------ | ---------------------------------------------- |
| `生成失败: 用户健康数据不足` | 用户没有足够的健康数据生成报告 | 引导用户添加更多健康数据(体重、饮食、运动等) |
| `生成失败: 日期格式不正确` | date 参数格式错误 | 确保日期格式为 YYYY-MM-DD |
| `生成失败: 未找到用户信息` | 用户不存在或 Token 无效 | 检查认证 Token 是否有效 |
| `生成失败: AI 服务暂时不可用` | AI 模型服务异常 | 稍后重试 |
### 认证失败响应
**HTTP 状态码**: `401 Unauthorized`
**响应体示例**:
```json
{
"statusCode": 401,
"message": "Unauthorized"
}
```
---
## 业务逻辑说明
### 报告内容
AI 健康报告会基于用户的以下数据生成:
1. **体重数据**: 当天或最近的体重记录
2. **饮食数据**: 当天的饮食记录和营养摄入
3. **运动数据**: 当天的运动记录和卡路里消耗
4. **围度数据**: 身体各部位的围度测量数据
5. **目标进度**: 用户设定的健康目标完成情况
### 报告生成逻辑
- 如果不传 `date` 参数,默认生成今天的报告
- 如果指定日期没有数据,会返回数据不足的提示
- 报告图片为 PNG 格式,尺寸适配移动端展示
- 图片会存储在腾讯云 COS有效期永久或根据策略定期清理
### 缓存策略
- 同一天同一用户的报告会缓存,重复请求会返回相同的图片 URL
- 如果用户更新了当天的数据,可以重新生成覆盖旧报告
---
## 错误处理
### 客户端错误处理示例
```typescript
async function fetchHealthReport(date?: string) {
try {
const response = await fetch("https://api.example.com/users/ai-report", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(date ? { date } : {}),
});
// 检查 HTTP 状态码
if (response.status === 401) {
// Token 失效,需要重新登录
throw new Error("认证失败,请重新登录");
}
const result = await response.json();
// 检查业务状态码
if (result.code !== 0) {
// 业务失败
throw new Error(result.message);
}
// 成功,返回图片 URL
return result.data.imageUrl;
} catch (error) {
console.error("生成健康报告失败:", error);
throw error;
}
}
// 使用示例
try {
const imageUrl = await fetchHealthReport();
console.log("报告生成成功:", imageUrl);
// 在 UI 中展示图片
} catch (error) {
// 向用户展示错误提示
alert(error.message);
}
```
---
## 使用建议
### 1. 前置检查
在调用接口前,建议先检查:
- 用户是否已登录(有有效的 JWT Token
- 用户是否有足够的健康数据(可通过其他接口查询)
- 网络连接是否正常
### 2. 加载提示
由于报告生成需要调用 AI 服务,可能需要几秒钟时间,建议:
- 显示加载动画或进度提示
- 设置合理的超时时间(建议 30-60 秒)
- 提供取消操作的选项
### 3. 图片展示
获取到图片 URL 后:
- 可以直接在 Image 组件中使用该 URL
- 支持下载保存到本地相册
- 支持分享到社交媒体
### 4. 错误处理
- 网络错误:提示用户检查网络连接
- 数据不足:引导用户添加健康数据
- Token 过期:自动刷新 Token 或引导重新登录
---
## 完整示例
### React Native 完整示例
```typescript
import React, { useState } from 'react';
import { View, Image, Button, Text, ActivityIndicator } from 'react-native';
import axios from 'axios';
const HealthReportScreen = () => {
const [loading, setLoading] = useState(false);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const generateReport = async (date?: string) => {
setLoading(true);
setError(null);
try {
const response = await axios.post(
'https://api.example.com/users/ai-report',
date ? { date } : {},
{
headers: {
'Authorization': `Bearer ${accessToken}`,
},
timeout: 60000, // 60秒超时
}
);
if (response.data.code === 0) {
setImageUrl(response.data.data.imageUrl);
} else {
setError(response.data.message);
}
} catch (err) {
if (axios.isAxiosError(err)) {
if (err.response?.status === 401) {
setError('登录已过期,请重新登录');
} else if (err.code === 'ECONNABORTED') {
setError('请求超时,请检查网络连接');
} else {
setError(err.response?.data?.message || '生成报告失败');
}
} else {
setError('未知错误');
}
} finally {
setLoading(false);
}
};
return (
<View style={{ flex: 1, padding: 20 }}>
<Button
title="生成今天的健康报告"
onPressed={() => generateReport()}
disabled={loading}
/>
{loading && (
<View style={{ marginTop: 20, alignItems: 'center' }}>
<ActivityIndicator size="large" />
<Text style={{ marginTop: 10 }}>正在生成报告...</Text>
</View>
)}
{error && (
<Text style={{ color: 'red', marginTop: 20 }}>
{error}
</Text>
)}
{imageUrl && (
<Image
source={{ uri: imageUrl }}
style={{ width: '100%', height: 400, marginTop: 20 }}
resizeMode="contain"
/>
)}
</View>
);
};
export default HealthReportScreen;
```
---
## 注意事项
1. **认证要求**: 必须携带有效的 JWT Token否则返回 401
2. **请求频率**: 建议不要频繁调用,同一用户同一天建议缓存结果
3. **图片有效期**: 图片 URL 长期有效,可以缓存在客户端
4. **数据依赖**: 需要用户有足够的健康数据才能生成有意义的报告
5. **网络要求**: AI 报告生成需要调用外部服务,需要稳定的网络连接
6. **超时设置**: 建议设置 30-60 秒的请求超时时间
---
## 相关接口
- [用户信息接口](./API-USER-INFO.md)
- [体重记录接口](./API-WEIGHT-RECORDS.md)
- [饮食记录接口](./API-DIET-RECORDS.md)
- [运动记录接口](./API-WORKOUT-RECORDS.md)
---
## 更新日志
| 版本 | 日期 | 更新内容 |
| ---- | ---------- | ------------------------------ |
| v1.0 | 2024-01-15 | 初始版本,支持基础健康报告生成 |
---
## 技术支持
如有问题,请联系技术支持团队。

View File

@@ -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. 喝水目标的更新集成在喝水接口中,避免用户服务文件过大

133
docs/expo-updates.md Normal file
View File

@@ -0,0 +1,133 @@
# Expo Updates 服务端实现COS 资源版)
本服务实现了 Expo Updates 协议 v0 和 v1支持 React Native 应用的 OTA 热更新。
资源文件存储在腾讯云 COS 上,服务端只负责返回 manifest。
## API 端点
### 1. 获取 Manifest
```
GET /expo-updates/manifest
```
**请求头:**
| 头部 | 必需 | 说明 |
|------|------|------|
| `expo-platform` | 是 | 平台类型:`ios``android` |
| `expo-runtime-version` | 是 | 运行时版本号 |
| `expo-protocol-version` | 否 | 协议版本:`0``1`(默认 `0` |
| `expo-current-update-id` | 否 | 当前更新 ID |
### 2. 注册更新版本
```
POST /expo-updates/updates
```
**请求体:**
```json
{
"runtimeVersion": "1.0.0",
"createdAt": "2024-01-01T00:00:00.000Z",
"ios": {
"launchAsset": {
"url": "https://your-bucket.cos.ap-guangzhou.myqcloud.com/updates/1.0.0/ios/bundle.js",
"hash": "Base64URL编码的SHA256哈希"
},
"assets": [
{
"url": "https://your-bucket.cos.ap-guangzhou.myqcloud.com/updates/1.0.0/assets/icon.png",
"hash": "Base64URL编码的SHA256哈希",
"key": "icon",
"contentType": "image/png",
"fileExtension": ".png"
}
]
},
"android": {
"launchAsset": {
"url": "https://your-bucket.cos.ap-guangzhou.myqcloud.com/updates/1.0.0/android/bundle.js",
"hash": "Base64URL编码的SHA256哈希"
},
"assets": []
},
"expoClient": {
"name": "YourApp",
"version": "1.0.0"
}
}
```
### 3. 获取所有更新版本
```
GET /expo-updates/updates
```
### 4. 获取指定版本
```
GET /expo-updates/updates/:runtimeVersion
```
### 5. 删除更新版本
```
DELETE /expo-updates/updates/:runtimeVersion
```
## 客户端配置
在 React Native 应用的 `app.json` 中:
```json
{
"expo": {
"updates": {
"url": "https://your-server.com/expo-updates/manifest",
"enabled": true
},
"runtimeVersion": "1.0.0"
}
}
```
## 生成资源哈希
资源的 hash 需要是 Base64URL 编码的 SHA256 哈希:
```javascript
const crypto = require('crypto');
const fs = require('fs');
function getAssetHash(filePath) {
const content = fs.readFileSync(filePath);
const hash = crypto.createHash('sha256').update(content).digest('base64');
// 转换为 Base64URL
return hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
```
## 测试
```bash
# 注册更新
curl -X POST http://localhost:3000/expo-updates/updates \
-H "Content-Type: application/json" \
-d '{
"runtimeVersion": "1.0.0",
"ios": {
"launchAsset": {
"url": "https://cos.example.com/bundle.js",
"hash": "abc123"
},
"assets": []
}
}'
# 获取 manifest
curl -H "expo-platform: ios" \
-H "expo-runtime-version: 1.0.0" \
http://localhost:3000/expo-updates/manifest
```

View File

@@ -1,230 +0,0 @@
# 话题收藏功能 API 文档
## 概述
话题收藏功能允许用户收藏喜欢的话题方便后续查看和使用。本文档描述了话题收藏相关的所有API接口。
## 功能特性
- ✅ 收藏话题
- ✅ 取消收藏话题
- ✅ 获取收藏话题列表
- ✅ 话题列表中显示收藏状态
- ✅ 防重复收藏
- ✅ 完整的错误处理
## API 接口
### 1. 收藏话题
**接口地址:** `POST /api/topic/favorite`
**请求参数:**
```json
{
"topicId": 123
}
```
**响应示例:**
```json
{
"code": 200,
"message": "收藏成功",
"data": {
"success": true,
"isFavorited": true,
"topicId": 123
}
}
```
**错误情况:**
- 话题不存在:`{ "code": 400, "message": "话题不存在" }`
- 已收藏:`{ "code": 200, "message": "已经收藏过该话题" }`
### 2. 取消收藏话题
**接口地址:** `POST /api/topic/unfavorite`
**请求参数:**
```json
{
"topicId": 123
}
```
**响应示例:**
```json
{
"code": 200,
"message": "取消收藏成功",
"data": {
"success": true,
"isFavorited": false,
"topicId": 123
}
}
```
### 3. 获取收藏话题列表
**接口地址:** `POST /api/topic/favorites`
**请求参数:**
```json
{
"page": 1,
"pageSize": 10
}
```
**响应示例:**
```json
{
"code": 200,
"message": "success",
"data": {
"list": [
{
"id": 123,
"topic": "约会话题",
"opening": {
"text": "今天天气真不错...",
"scenarios": ["咖啡厅", "公园"]
},
"scriptType": "初识破冰",
"scriptTopic": "天气",
"keywords": "天气,约会,轻松",
"isFavorited": true,
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
}
],
"total": 5,
"page": 1,
"pageSize": 10
}
}
```
### 4. 获取话题列表(已包含收藏状态)
**接口地址:** `POST /api/topic/list`
现在所有话题列表都会包含 `isFavorited` 字段,表示当前用户是否已收藏该话题。
**响应示例:**
```json
{
"code": 200,
"message": "success",
"data": {
"list": [
{
"id": 123,
"topic": "约会话题",
"opening": {
"text": "今天天气真不错...",
"scenarios": ["咖啡厅", "公园"]
},
"scriptType": "初识破冰",
"scriptTopic": "天气",
"keywords": "天气,约会,轻松",
"isFavorited": true, // ← 新增的收藏状态字段
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
},
{
"id": 124,
"topic": "工作话题",
"opening": "关于工作的开场白...",
"scriptType": "深度交流",
"scriptTopic": "职业",
"keywords": "工作,职业,发展",
"isFavorited": false, // ← 未收藏
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
}
],
"total": 20,
"page": 1,
"pageSize": 10
}
}
```
## 数据库变更
### 新增表t_topic_favorites
```sql
CREATE TABLE `t_topic_favorites` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`topic_id` int NOT NULL COMMENT '话题ID',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_topic_favorite` (`user_id`, `topic_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_topic_id` (`topic_id`),
FOREIGN KEY (`user_id`) REFERENCES `t_users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`topic_id`) REFERENCES `t_topic_library` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## 使用场景
### 1. 用户收藏话题
```javascript
// 收藏话题
const response = await fetch('/api/topic/favorite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-jwt-token'
},
body: JSON.stringify({ topicId: 123 })
});
```
### 2. 取消收藏话题
```javascript
// 取消收藏
const response = await fetch('/api/topic/unfavorite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-jwt-token'
},
body: JSON.stringify({ topicId: 123 })
});
```
### 3. 查看收藏列表
```javascript
// 获取收藏的话题
const response = await fetch('/api/topic/favorites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-jwt-token'
},
body: JSON.stringify({ page: 1, pageSize: 10 })
});
```
## 注意事项
1. **防重复收藏:** 数据库层面通过唯一索引保证同一用户不能重复收藏同一话题
2. **级联删除:** 用户删除或话题删除时,相关收藏记录会自动删除
3. **性能优化:** 获取话题列表时通过单次查询获取用户所有收藏状态避免N+1查询问题
4. **权限控制:** 所有接口都需要用户登录JWT认证
## 错误码说明
- `200`: 操作成功
- `400`: 请求参数错误(如话题不存在)
- `401`: 未授权(需要登录)
- `500`: 服务器内部错误

View File

@@ -1,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封装提供了完整的喝水记录管理功能可以直接在客户端项目中使用。

View File

@@ -1,233 +0,0 @@
# Winston Logger 配置指南
本项目已配置了基于 Winston 的日志系统,支持日志文件输出、按日期滚动和自动清理。
## 功能特性
-**日志文件输出**: 自动将日志写入文件
-**按日期滚动**: 每天生成新的日志文件
-**自动清理**: 保留最近7天的日志文件
-**分级日志**: 支持不同级别的日志分离
-**结构化日志**: 支持JSON格式的结构化日志
-**异常处理**: 自动记录未捕获的异常和Promise拒绝
## 日志文件结构
```
logs/
├── app-2025-07-21.log # 应用日志 (info级别及以上)
├── error-2025-07-21.log # 错误日志 (error级别)
├── debug-2025-07-21.log # 调试日志 (仅开发环境)
├── exceptions-2025-07-21.log # 未捕获异常
├── rejections-2025-07-21.log # 未处理的Promise拒绝
└── .audit-*.json # 日志轮转审计文件
```
## 配置说明
### 日志级别
- **生产环境**: `info` 及以上级别
- **开发环境**: `debug` 及以上级别
### 文件轮转配置
- **日期模式**: `YYYY-MM-DD`
- **保留天数**: 7天
- **单文件大小**: 最大20MB
- **自动压缩**: 支持
## 使用方法
### 1. 在服务中使用 NestJS Logger
```typescript
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class YourService {
private readonly logger = new Logger(YourService.name);
someMethod() {
this.logger.log('这是一条信息日志');
this.logger.warn('这是一条警告日志');
this.logger.error('这是一条错误日志');
this.logger.debug('这是一条调试日志');
}
}
```
### 2. 直接使用 Winston Logger (推荐用于结构化日志)
```typescript
import { Injectable, Inject } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger as WinstonLogger } from 'winston';
@Injectable()
export class YourService {
constructor(
@Inject(WINSTON_MODULE_PROVIDER)
private readonly winstonLogger: WinstonLogger,
) {}
someMethod() {
// 结构化日志
this.winstonLogger.info('用户登录', {
context: 'AuthService',
userId: 'user123',
email: 'user@example.com',
timestamp: new Date().toISOString()
});
// 错误日志
this.winstonLogger.error('数据库连接失败', {
context: 'DatabaseService',
error: 'Connection timeout',
retryCount: 3
});
}
}
```
### 3. 在控制器中使用
```typescript
import { Controller, Logger } from '@nestjs/common';
@Controller('users')
export class UsersController {
private readonly logger = new Logger(UsersController.name);
@Get()
findAll() {
this.logger.log('获取用户列表请求');
// 业务逻辑
}
}
```
## 日志格式
### 控制台输出格式
```
2025-07-21 10:08:38 info [ServiceName] 日志消息
```
### 文件输出格式
```
2025-07-21 10:08:38 [INFO] [ServiceName] 日志消息
```
### 结构化日志格式
```json
{
"timestamp": "2025-07-21 10:08:38",
"level": "info",
"message": "用户登录",
"context": "AuthService",
"userId": "user123",
"email": "user@example.com"
}
```
## 环境变量配置
可以通过环境变量调整日志行为:
```bash
# 日志级别 (development: debug, production: info)
NODE_ENV=production
# 自定义日志目录 (可选)
LOG_DIR=/var/log/pilates-server
```
## 最佳实践
### 1. 使用合适的日志级别
- `error`: 错误和异常
- `warn`: 警告信息
- `info`: 重要的业务信息
- `debug`: 调试信息 (仅开发环境)
### 2. 结构化日志
对于重要的业务事件,使用结构化日志:
```typescript
this.winstonLogger.info('订单创建', {
context: 'OrderService',
orderId: order.id,
userId: user.id,
amount: order.amount,
currency: 'CNY'
});
```
### 3. 错误日志包含上下文
```typescript
try {
// 业务逻辑
} catch (error) {
this.winstonLogger.error('处理订单失败', {
context: 'OrderService',
orderId: order.id,
error: error.message,
stack: error.stack
});
}
```
### 4. 避免敏感信息
不要在日志中记录密码、令牌等敏感信息:
```typescript
// ❌ 错误
this.logger.log(`用户登录: ${JSON.stringify(loginData)}`);
// ✅ 正确
this.logger.log(`用户登录: ${loginData.email}`);
```
## 监控和维护
### 查看实时日志
```bash
# 查看应用日志
tail -f logs/app-$(date +%Y-%m-%d).log
# 查看错误日志
tail -f logs/error-$(date +%Y-%m-%d).log
```
### 日志分析
```bash
# 统计错误数量
grep -c "ERROR" logs/app-*.log
# 查找特定用户的日志
grep "userId.*user123" logs/app-*.log
```
### 清理旧日志
日志系统会自动清理7天前的日志文件无需手动维护。
## 故障排除
### 1. 日志文件未生成
- 检查 `logs` 目录权限
- 确认应用有写入权限
- 查看控制台是否有错误信息
### 2. 日志级别不正确
- 检查 `NODE_ENV` 环境变量
- 确认 winston 配置中的日志级别设置
### 3. 日志文件过大
- 检查日志轮转配置
- 确认 `maxSize``maxFiles` 设置
## 相关文件
- [`src/common/logger/winston.config.ts`](../src/common/logger/winston.config.ts) - Winston 配置
- [`src/common/logger/logger.module.ts`](../src/common/logger/logger.module.ts) - Logger 模块
- [`src/main.ts`](../src/main.ts) - 应用启动配置
- [`src/app.module.ts`](../src/app.module.ts) - 应用模块配置

View File

@@ -1,245 +0,0 @@
# 训练会话 API 使用指南
## 架构说明
新的训练系统采用了分离的架构设计,符合健身应用的最佳实践:
### 1. 训练计划模板 (Training Plans)
- **用途**: 用户创建和管理的训练计划模板
- **表**: `t_training_plans` + `t_schedule_exercises`
- **特点**: 静态配置,不包含完成状态
- **API**: `/training-plans`
### 2. 训练会话实例 (Workout Sessions)
- **用途**: 每日实际训练,从训练计划模板复制而来
- **表**: `t_workout_sessions` + `t_workout_exercises`
- **特点**: 动态数据,包含完成状态、进度追踪
- **API**: `/workouts`
## API 使用流程
### 第一步:创建训练计划模板
```bash
# 1. 创建训练计划
POST /training-plans
{
"name": "全身力量训练",
"startDate": "2024-01-15T00:00:00.000Z",
"mode": "daysOfWeek",
"daysOfWeek": [1, 3, 5],
"goal": "core_strength"
}
# 2. 添加训练动作到计划
POST /training-plans/{planId}/exercises
{
"exerciseKey": "squat",
"name": "深蹲训练",
"sets": 3,
"reps": 15,
"itemType": "exercise"
}
# 3. 激活训练计划
POST /training-plans/{planId}/activate
```
### 第二步:开始训练(三种方式)
#### 方式一:自动获取今日训练(推荐)
```bash
# 获取今日训练会话(如不存在则自动创建)
GET /workouts/today
# 系统会自动基于激活的训练计划创建今日训练会话
```
#### 方式二:基于训练计划手动创建
```bash
POST /workouts/sessions
{
"trainingPlanId": "{planId}",
"name": "晚间训练",
"scheduledDate": "2024-01-15T19:00:00.000Z"
}
```
#### 方式三:创建完全自定义训练
```bash
POST /workouts/sessions
{
"name": "自定义核心训练",
"scheduledDate": "2024-01-15T15:00:00.000Z",
"customExercises": [
{
"exerciseKey": "plank",
"name": "平板支撑",
"plannedDurationSec": 60,
"itemType": "exercise",
"sortOrder": 1
},
{
"name": "休息",
"plannedDurationSec": 30,
"itemType": "rest",
"sortOrder": 2
},
{
"name": "自定义深蹲变式",
"plannedSets": 3,
"plannedReps": 20,
"note": "脚距离肩膀更宽",
"itemType": "exercise",
"sortOrder": 3
}
]
}
```
### 第三步:执行训练
```bash
# 1. 开始训练会话(可选)
POST /workouts/sessions/{sessionId}/start
# 2. 动态添加动作(如果需要)
POST /workouts/sessions/{sessionId}/exercises
{
"name": "额外的拉伸",
"plannedDurationSec": 120,
"itemType": "exercise"
}
# 3. 开始特定动作
POST /workouts/sessions/{sessionId}/exercises/{exerciseId}/start
# 4. 完成动作
POST /workouts/sessions/{sessionId}/exercises/{exerciseId}/complete
{
"completedSets": 3,
"completedReps": 15,
"actualDurationSec": 180,
"performanceData": {
"sets": [
{ "reps": 15, "difficulty": 7 },
{ "reps": 12, "difficulty": 8 },
{ "reps": 10, "difficulty": 9 }
],
"perceivedExertion": 8
}
}
# 注意:当所有动作完成后,训练会话会自动标记为完成
```
## 主要优势
### 1. 数据分离
- 训练计划是可重用的模板
- 每日训练是独立的实例
- 修改计划不影响历史训练记录
### 2. 灵活的创建方式
- 自动创建:基于激活计划的今日训练
- 计划创建:基于指定训练计划创建
- 自定义创建:完全自定义的训练动作
### 3. 进度追踪
- 每个训练会话都有完整的状态跟踪
- 支持详细的性能数据记录
- 可以分析训练趋势和进步情况
### 4. 动态调整能力
- 支持训练中动态添加动作
- 支持跳过或修改特定动作
- 自动完成会话管理
- 自动计算训练统计数据
## API 端点总览
### 训练会话管理
- `GET /workouts/today` - 获取/自动创建今日训练会话 ⭐
- `POST /workouts/sessions` - 手动创建训练会话(支持基于计划或自定义动作)
- `GET /workouts/sessions` - 获取训练会话列表
- `GET /workouts/sessions/{id}` - 获取训练会话详情
- `POST /workouts/sessions/{id}/start` - 开始训练(可选)
- `DELETE /workouts/sessions/{id}` - 删除训练会话
- 注意:训练会话在所有动作完成后自动完成
### 训练动作管理
- `POST /workouts/sessions/{id}/exercises` - 向训练会话添加自定义动作 ⭐
- `GET /workouts/sessions/{id}/exercises` - 获取训练动作列表
- `GET /workouts/sessions/{id}/exercises/{exerciseId}` - 获取动作详情
- `POST /workouts/sessions/{id}/exercises/{exerciseId}/start` - 开始动作
- `POST /workouts/sessions/{id}/exercises/{exerciseId}/complete` - 完成动作
- `POST /workouts/sessions/{id}/exercises/{exerciseId}/skip` - 跳过动作
- `PUT /workouts/sessions/{id}/exercises/{exerciseId}` - 更新动作信息
### 统计和快捷功能
- `GET /workouts/sessions/{id}/stats` - 获取训练统计
- `GET /workouts/recent` - 获取最近训练
## 数据模型
### WorkoutSession (训练会话)
```typescript
{
id: string;
userId: string;
trainingPlanId: string; // 关联的训练计划模板
name: string;
scheduledDate: Date;
startedAt?: Date;
completedAt?: Date;
status: 'planned' | 'in_progress' | 'completed' | 'skipped';
totalDurationSec?: number;
summary?: string;
caloriesBurned?: number;
stats?: {
totalExercises: number;
completedExercises: number;
totalSets: number;
completedSets: number;
// ...
};
}
```
### WorkoutExercise (训练动作实例)
```typescript
{
id: string;
workoutSessionId: string;
exerciseKey?: string; // 关联动作库
name: string;
plannedSets?: number; // 计划数值
completedSets?: number; // 实际完成数值
plannedReps?: number;
completedReps?: number;
plannedDurationSec?: number;
actualDurationSec?: number;
status: 'pending' | 'in_progress' | 'completed' | 'skipped';
startedAt?: Date;
completedAt?: Date;
performanceData?: { // 详细性能数据
sets: Array<{
reps?: number;
weight?: number;
difficulty?: number;
notes?: string;
}>;
heartRate?: { avg: number; max: number };
perceivedExertion?: number; // RPE 1-10
};
}
```
## 迁移说明
如果您之前使用了 `training-plans` 的完成状态功能,现在需要:
1. 使用 `/workouts/sessions` 来创建每日训练
2. 使用新的完成状态 API 来跟踪进度
3. 原有的训练计划数据保持不变,作为模板使用
这样的架构分离使得系统更加清晰、可维护,也更符合健身应用的实际使用场景。

126
package-lock.json generated
View File

@@ -17,7 +17,10 @@
"@nestjs/schedule": "^6.0.1",
"@nestjs/sequelize": "^11.0.0",
"@nestjs/swagger": "^11.1.0",
"@nestjs/throttler": "^6.4.0",
"@openrouter/sdk": "^0.1.27",
"@parse/node-apn": "^5.0.0",
"@types/ioredis": "^4.28.10",
"@types/jsonwebtoken": "^9.0.9",
"@types/uuid": "^10.0.0",
"apns2": "^12.2.0",
@@ -28,7 +31,9 @@
"cos-nodejs-sdk-v5": "^2.14.7",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.18",
"form-data": "^4.0.5",
"fs": "^0.0.1-security",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"mysql2": "^3.14.0",
@@ -1360,6 +1365,12 @@
}
}
},
"node_modules/@ioredis/commands": {
"version": "1.4.0",
"resolved": "https://mirrors.tencent.com/npm/@ioredis/commands/-/commands-1.4.0.tgz",
"integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2558,6 +2569,17 @@
}
}
},
"node_modules/@nestjs/throttler": {
"version": "6.4.0",
"resolved": "https://mirrors.tencent.com/npm/@nestjs/throttler/-/throttler-6.4.0.tgz",
"integrity": "sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2612,6 +2634,15 @@
"npm": ">=5.10.0"
}
},
"node_modules/@openrouter/sdk": {
"version": "0.1.27",
"resolved": "https://mirrors.tencent.com/npm/@openrouter/sdk/-/sdk-0.1.27.tgz",
"integrity": "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ==",
"license": "Apache-2.0",
"dependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@parse/node-apn": {
"version": "5.2.3",
"resolved": "https://mirrors.tencent.com/npm/@parse/node-apn/-/node-apn-5.2.3.tgz",
@@ -3405,6 +3436,15 @@
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
"license": "MIT"
},
"node_modules/@types/ioredis": {
"version": "4.28.10",
"resolved": "https://mirrors.tencent.com/npm/@types/ioredis/-/ioredis-4.28.10.tgz",
"integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -5400,6 +5440,14 @@
"node": ">=0.8"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -7329,14 +7377,14 @@
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT",
"version": "4.0.5",
"resolved": "https://mirrors.tencent.com/npm/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -8050,6 +8098,30 @@
"kind-of": "^6.0.2"
}
},
"node_modules/ioredis": {
"version": "5.8.2",
"resolved": "https://mirrors.tencent.com/npm/ioredis/-/ioredis-5.8.2.tgz",
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.4.0",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ip-address": {
"version": "9.0.5",
"resolved": "https://mirrors.tencent.com/npm/ip-address/-/ip-address-9.0.5.tgz",
@@ -9335,12 +9407,23 @@
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://mirrors.tencent.com/npm/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://mirrors.tencent.com/npm/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://mirrors.tencent.com/npm/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://mirrors.tencent.com/npm/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -11244,6 +11327,26 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://mirrors.tencent.com/npm/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://mirrors.tencent.com/npm/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -12181,6 +12284,12 @@
"node": ">=8"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://mirrors.tencent.com/npm/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -13742,6 +13851,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://mirrors.tencent.com/npm/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -35,7 +35,10 @@
"@nestjs/schedule": "^6.0.1",
"@nestjs/sequelize": "^11.0.0",
"@nestjs/swagger": "^11.1.0",
"@nestjs/throttler": "^6.4.0",
"@openrouter/sdk": "^0.1.27",
"@parse/node-apn": "^5.0.0",
"@types/ioredis": "^4.28.10",
"@types/jsonwebtoken": "^9.0.9",
"@types/uuid": "^10.0.0",
"apns2": "^12.2.0",
@@ -46,7 +49,9 @@
"cos-nodejs-sdk-v5": "^2.14.7",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.18",
"form-data": "^4.0.5",
"fs": "^0.0.1-security",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"mysql2": "^3.14.0",

View File

@@ -0,0 +1,20 @@
-- 用药 AI 总结表创建脚本
-- 创建时间: 2025-01-18
-- 说明: 按天存储用户的用药AI总结避免重复调用大模型
CREATE TABLE IF NOT EXISTS `t_medication_ai_summaries` (
`id` varchar(50) NOT NULL COMMENT '唯一标识',
`user_id` varchar(50) NOT NULL COMMENT '用户ID',
`summary_date` date NOT NULL COMMENT '统计日期YYYY-MM-DD',
`medication_analysis` json NOT NULL COMMENT '用药计划与进度统计',
`key_insights` text NOT NULL COMMENT 'AI重点解读',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_user_date` (`user_id`, `summary_date`),
KEY `idx_user_date` (`user_id`, `summary_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用药AI总结表按天缓存';
-- 使用说明:
-- 1) 每位用户每日最多一条记录用于缓存当天的用药AI总结。
-- 2) medication_analysis 字段存储 JSON 数组medicationAnalysis 列表key_insights 存储生成的重点解读文本。

View File

@@ -0,0 +1,21 @@
-- AI 健康报告生成历史记录表
-- 记录每次生成的报告信息,包括 prompt 和图片地址
CREATE TABLE IF NOT EXISTS `t_ai_report_history` (
`id` VARCHAR(36) NOT NULL COMMENT '记录ID',
`user_id` VARCHAR(36) NOT NULL COMMENT '用户ID',
`report_date` DATE NOT NULL COMMENT '报告日期',
`prompt` TEXT NOT NULL COMMENT '生成图像使用的 Prompt',
`image_url` VARCHAR(500) NOT NULL COMMENT '生成的图片地址',
`api_provider` VARCHAR(20) DEFAULT NULL COMMENT 'API 提供商: openrouter | grsai',
`model_name` VARCHAR(50) DEFAULT NULL COMMENT '使用的模型名称',
`generation_time_ms` INT DEFAULT NULL COMMENT '生成耗时(毫秒)',
`status` VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '生成状态: success | failed',
`error_message` TEXT DEFAULT NULL COMMENT '失败原因(如果失败)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_report_date` (`report_date`),
INDEX `idx_user_report_date` (`user_id`, `report_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI健康报告生成历史';

View File

@@ -0,0 +1,24 @@
-- ============================================================
-- 用户每日健康记录表
-- 每日每个用户只会生成一条数据,通过 user_id + record_date 唯一确定
-- ============================================================
CREATE TABLE IF NOT EXISTS `t_user_daily_health` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` VARCHAR(64) NOT NULL COMMENT '用户ID',
`record_date` DATE NOT NULL COMMENT '记录日期 (YYYY-MM-DD)',
`water_intake` INT NULL COMMENT '饮水量 (毫升 ml)',
`exercise_minutes` INT NULL COMMENT '锻炼分钟数',
`calories_burned` FLOAT NULL COMMENT '消耗卡路里 (千卡 kcal)',
`standing_minutes` INT NULL COMMENT '站立时间 (分钟)',
`basal_metabolism` FLOAT NULL COMMENT '基础代谢 (千卡 kcal)',
`sleep_minutes` INT NULL COMMENT '睡眠分钟数',
`blood_oxygen` FLOAT NULL COMMENT '血氧饱和度 (百分比 %)',
`stress_level` DECIMAL(5,1) NULL COMMENT '压力 (ms保留一位小数)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_record_date` (`user_id`, `record_date`),
KEY `idx_user_id` (`user_id`),
KEY `idx_record_date` (`record_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户每日健康记录表';

View File

@@ -1,31 +0,0 @@
-- 创建训练会话表
CREATE TABLE t_workout_sessions (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
user_id VARCHAR(255) NOT NULL COMMENT '用户ID',
training_plan_id VARCHAR(36) NOT NULL COMMENT '关联的训练计划模板',
name VARCHAR(255) NOT NULL COMMENT '训练会话名称',
scheduled_date DATETIME NOT NULL COMMENT '计划训练日期',
started_at DATETIME NULL COMMENT '实际开始时间',
completed_at DATETIME NULL COMMENT '实际结束时间',
status ENUM('planned', 'in_progress', 'completed', 'skipped') NOT NULL DEFAULT 'planned' COMMENT '训练状态',
total_duration_sec INT NULL COMMENT '总时长(秒)',
summary TEXT NULL COMMENT '训练总结/备注',
calories_burned INT NULL COMMENT '消耗卡路里(估算)',
stats JSON NULL COMMENT '训练统计数据',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE COMMENT '是否已删除',
-- 外键约束
FOREIGN KEY (training_plan_id) REFERENCES t_training_plans(id),
-- 索引
INDEX idx_user_id (user_id),
INDEX idx_training_plan_id (training_plan_id),
INDEX idx_scheduled_date (scheduled_date),
INDEX idx_status (status),
INDEX idx_deleted (deleted)
);
-- 添加表注释
ALTER TABLE t_workout_sessions COMMENT = '训练会话表';

View File

@@ -1,85 +0,0 @@
-- 训练会话相关表创建脚本
-- 用于支持每日训练实例功能
-- 禁用外键检查
SET FOREIGN_KEY_CHECKS = 0;
-- 删除训练会话相关表(如果存在)
DROP TABLE IF EXISTS `t_workout_exercises`;
DROP TABLE IF EXISTS `t_workout_sessions`;
-- 重新启用外键检查
SET FOREIGN_KEY_CHECKS = 1;
-- 创建训练会话表
CREATE TABLE `t_workout_sessions` (
`id` char(36) NOT NULL COMMENT '训练会话唯一ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`training_plan_id` char(36) NOT NULL COMMENT '关联的训练计划模板',
`name` varchar(255) NOT NULL COMMENT '训练会话名称',
`scheduled_date` datetime NOT NULL COMMENT '计划训练日期',
`started_at` datetime DEFAULT NULL COMMENT '实际开始时间',
`completed_at` datetime DEFAULT NULL COMMENT '实际结束时间',
`status` enum('planned','in_progress','completed','skipped') NOT NULL DEFAULT 'planned' COMMENT '训练状态',
`total_duration_sec` int DEFAULT NULL COMMENT '总时长(秒)',
`summary` text COMMENT '训练总结/备注',
`calories_burned` int DEFAULT NULL COMMENT '消耗卡路里(估算)',
`stats` json DEFAULT NULL COMMENT '训练统计数据',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_training_plan_id` (`training_plan_id`),
KEY `idx_scheduled_date` (`scheduled_date`),
KEY `idx_status` (`status`),
KEY `idx_deleted` (`deleted`),
KEY `idx_user_date` (`user_id`, `scheduled_date`, `deleted`),
CONSTRAINT `fk_workout_sessions_training_plan` FOREIGN KEY (`training_plan_id`) REFERENCES `t_training_plans` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='训练会话表(每日训练实例)';
-- 创建训练会话动作表
CREATE TABLE `t_workout_exercises` (
`id` char(36) NOT NULL COMMENT '训练动作唯一ID',
`workout_session_id` char(36) NOT NULL COMMENT '所属训练会话ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`exercise_key` varchar(255) DEFAULT NULL COMMENT '关联的动作key仅exercise类型',
`name` varchar(255) NOT NULL COMMENT '项目名称',
`planned_sets` int DEFAULT NULL COMMENT '计划组数',
`completed_sets` int DEFAULT NULL COMMENT '实际完成组数',
`planned_reps` int DEFAULT NULL COMMENT '计划重复次数',
`completed_reps` int DEFAULT NULL COMMENT '实际完成重复次数',
`planned_duration_sec` int DEFAULT NULL COMMENT '计划持续时长(秒)',
`actual_duration_sec` int DEFAULT NULL COMMENT '实际持续时长(秒)',
`rest_sec` int DEFAULT NULL COMMENT '休息时长(秒)',
`note` text COMMENT '备注',
`item_type` enum('exercise','rest','note') NOT NULL DEFAULT 'exercise' COMMENT '项目类型',
`status` enum('pending','in_progress','completed','skipped') NOT NULL DEFAULT 'pending' COMMENT '动作状态',
`sort_order` int NOT NULL COMMENT '排序顺序',
`started_at` datetime DEFAULT NULL COMMENT '开始时间',
`completed_at` datetime DEFAULT NULL COMMENT '完成时间',
`performance_data` json DEFAULT NULL COMMENT '详细执行数据',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`),
KEY `idx_workout_session_id` (`workout_session_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_exercise_key` (`exercise_key`),
KEY `idx_sort_order` (`sort_order`),
KEY `idx_status` (`status`),
KEY `idx_deleted` (`deleted`),
KEY `idx_session_order` (`workout_session_id`, `sort_order`, `deleted`),
CONSTRAINT `fk_workout_exercises_session` FOREIGN KEY (`workout_session_id`) REFERENCES `t_workout_sessions` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_workout_exercises_exercise` FOREIGN KEY (`exercise_key`) REFERENCES `t_exercises` (`key`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='训练会话动作表(每日训练实例动作)';
-- 为 t_schedule_exercises 表添加注释,澄清其用途
ALTER TABLE `t_schedule_exercises` COMMENT = '训练计划动作表(训练计划模板的动作配置)';
-- 创建一些有用的索引
CREATE INDEX `idx_workout_sessions_user_status` ON `t_workout_sessions` (`user_id`, `status`, `deleted`);
CREATE INDEX `idx_workout_exercises_session_type` ON `t_workout_exercises` (`workout_session_id`, `item_type`, `deleted`);
-- 插入一些示例数据来测试
-- 注意实际使用时应该通过API来创建数据

View File

@@ -175,7 +175,8 @@ export class AiCoachController {
): Promise<FoodRecognitionResponseDto> {
this.logger.log(`Food recognition request from user: ${user.sub}, images: ${body.imageUrls?.length || 0}`);
const result = await this.dietAnalysisService.recognizeFoodForConfirmation(body.imageUrls);
const language = await this.usersService.getUserLanguage(user.sub);
const result = await this.dietAnalysisService.recognizeFoodForConfirmation(body.imageUrls, language);
// 转换为DTO格式
const response: FoodRecognitionResponseDto = {
@@ -220,7 +221,8 @@ export class AiCoachController {
): Promise<FoodRecognitionResponseDto> {
this.logger.log(`Text food analysis request from user: ${user.sub}, text: "${body.text}"`);
const result = await this.dietAnalysisService.analyzeTextFoodForConfirmation(body.text);
const language = await this.usersService.getUserLanguage(user.sub);
const result = await this.dietAnalysisService.analyzeTextFoodForConfirmation(body.text, language);
// 转换为DTO格式
const response: FoodRecognitionResponseDto = {

View File

@@ -4,22 +4,33 @@ import { ConfigModule } from '@nestjs/config';
import { AiCoachController } from './ai-coach.controller';
import { AiCoachService } from './ai-coach.service';
import { DietAnalysisService } from './services/diet-analysis.service';
import { AiReportService } from './services/ai-report.service';
import { AiMessage } from './models/ai-message.model';
import { AiConversation } from './models/ai-conversation.model';
import { PostureAssessment } from './models/posture-assessment.model';
import { AiReportHistory } from './models/ai-report-history.model';
import { UsersModule } from '../users/users.module';
import { DietRecordsModule } from '../diet-records/diet-records.module';
import { MedicationsModule } from '../medications/medications.module';
import { MoodCheckinsModule } from '../mood-checkins/mood-checkins.module';
import { WaterRecordsModule } from '../water-records/water-records.module';
import { ChallengesModule } from '../challenges/challenges.module';
import { CosService } from '../users/cos.service';
@Module({
imports: [
ConfigModule,
UsersModule,
forwardRef(() => UsersModule),
forwardRef(() => DietRecordsModule),
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]),
forwardRef(() => MedicationsModule),
forwardRef(() => MoodCheckinsModule),
forwardRef(() => WaterRecordsModule),
forwardRef(() => ChallengesModule),
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment, AiReportHistory]),
],
controllers: [AiCoachController],
providers: [AiCoachService, DietAnalysisService],
exports: [DietAnalysisService],
providers: [AiCoachService, DietAnalysisService, AiReportService, CosService],
exports: [DietAnalysisService, AiReportService],
})
export class AiCoachModule { }

View File

@@ -413,7 +413,8 @@ export class AiCoachService {
): Promise<Readable | { type: 'structured'; data: any }> {
if (params.imageUrls) {
// 处理图片饮食记录
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation(params.imageUrls);
const language = await this.usersService.getUserLanguage(params.userId);
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation(params.imageUrls, language);
if (recognitionResult.items.length > 0) {
const choices = recognitionResult.items.map(item => ({
@@ -467,6 +468,8 @@ export class AiCoachService {
}
} else {
// 处理文本饮食记录
// const language = await this.usersService.getUserLanguage(params.userId);
// TODO: analyzeDietFromText 也需要支持多语言
const textAnalysisResult = await this.dietAnalysisService.analyzeDietFromText(commandResult.cleanText);
if (textAnalysisResult.shouldRecord && textAnalysisResult.extractedData) {

View File

@@ -0,0 +1,93 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ResponseCode } from '../../base.dto';
/**
* AI 健康报告历史记录查询 DTO
*/
export class GetAiReportHistoryQueryDto {
@ApiPropertyOptional({ description: '页码从1开始', default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: '每页条数默认10最大50', default: 10 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(50)
pageSize?: number = 10;
@ApiPropertyOptional({ description: '开始日期,格式 YYYY-MM-DD' })
@IsOptional()
@IsString()
startDate?: string;
@ApiPropertyOptional({ description: '结束日期,格式 YYYY-MM-DD' })
@IsOptional()
@IsString()
endDate?: string;
@ApiPropertyOptional({ description: '状态筛选: pending | processing | success | failed不传则返回所有状态' })
@IsOptional()
@IsString()
status?: string;
}
/**
* AI 健康报告历史记录项
*/
export class AiReportHistoryItemDto {
@ApiProperty({ description: '记录ID' })
id: string;
@ApiProperty({ description: '报告日期,格式 YYYY-MM-DD' })
reportDate: string;
@ApiProperty({ description: '生成的图片地址' })
imageUrl: string;
@ApiProperty({ description: '生成状态: success | failed' })
status: string;
@ApiProperty({ description: '创建时间' })
createdAt: Date;
}
/**
* AI 健康报告历史记录列表响应
*/
export class AiReportHistoryListDto {
@ApiProperty({ description: '记录列表', type: [AiReportHistoryItemDto] })
records: AiReportHistoryItemDto[];
@ApiProperty({ description: '总记录数' })
total: number;
@ApiProperty({ description: '当前页码' })
page: number;
@ApiProperty({ description: '每页条数' })
pageSize: number;
@ApiProperty({ description: '总页数' })
totalPages: number;
}
/**
* 获取 AI 健康报告历史记录响应 DTO
*/
export class GetAiReportHistoryResponseDto {
@ApiProperty({ description: '响应状态码', example: ResponseCode.SUCCESS })
declare code: ResponseCode;
@ApiProperty({ description: '响应消息', example: 'success' })
declare message: string;
@ApiProperty({ description: '报告历史数据', type: AiReportHistoryListDto })
declare data: AiReportHistoryListDto;
}

View File

@@ -0,0 +1,110 @@
import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript';
/**
* AI 报告生成状态枚举
*/
export enum AiReportStatus {
PENDING = 'pending', // 未开始
PROCESSING = 'processing', // 进行中
SUCCESS = 'success', // 已完成
FAILED = 'failed', // 已失败
}
/**
* AI 健康报告生成历史记录表
* 记录每次生成的报告信息,包括 prompt 和图片地址
*/
@Table({
tableName: 't_ai_report_history',
underscored: true,
})
export class AiReportHistory extends Model {
@PrimaryKey
@Column({
type: DataType.STRING(36),
allowNull: false,
comment: '记录ID',
})
declare id: string;
@Index
@Column({
type: DataType.STRING(36),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Index
@Column({
type: DataType.DATEONLY,
allowNull: false,
comment: '报告日期',
})
declare reportDate: string;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '生成图像使用的 Prompt',
})
declare prompt: string | null;
@Column({
type: DataType.STRING(500),
allowNull: true,
comment: '生成的图片地址',
})
declare imageUrl: string | null;
@Column({
type: DataType.STRING(20),
allowNull: true,
comment: 'API 提供商: openrouter | grsai',
})
declare apiProvider: string | null;
@Column({
type: DataType.STRING(50),
allowNull: true,
comment: '使用的模型名称',
})
declare modelName: string | null;
@Column({
type: DataType.INTEGER,
allowNull: true,
comment: '生成耗时(毫秒)',
})
declare generationTimeMs: number | null;
@Index
@Column({
type: DataType.STRING(20),
allowNull: false,
defaultValue: AiReportStatus.PENDING,
comment: '生成状态: pending | processing | success | failed',
})
declare status: string;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '失败原因(如果失败)',
})
declare errorMessage: string | null;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '创建时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '更新时间',
})
declare updatedAt: Date;
}

View File

@@ -0,0 +1,945 @@
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectModel } from '@nestjs/sequelize';
import { Op } from 'sequelize';
import * as dayjs from 'dayjs';
import { v4 as uuidv4 } from 'uuid';
import OpenAI from 'openai';
import { CosService } from '../../users/cos.service';
import { AiReportHistory, AiReportStatus } from '../models/ai-report-history.model';
// 假设各个模块的服务都已正确导出
import { UsersService } from '../../users/users.service';
import { MedicationStatsService } from '../../medications/medication-stats.service';
import { DietRecordsService } from '../../diet-records/diet-records.service';
import { WaterRecordsService } from '../../water-records/water-records.service';
import { MoodCheckinsService } from '../../mood-checkins/mood-checkins.service';
import { ChallengesService } from '../../challenges/challenges.service';
/**
* 聚合的每日健康数据接口
*/
interface DailyHealthData {
date: string;
user?: {
name: string;
avatar?: string;
gender?: string;
};
medications: {
totalScheduled: number;
taken: number;
missed: number;
completionRate: number;
};
diet: {
totalCalories: number;
totalProtein: number;
totalCarbohydrates: number;
totalFat: number;
averageCaloriesPerMeal: number;
mealTypeDistribution: Record<string, number>;
};
mood: {
primaryMood?: string;
averageIntensity?: number;
totalCheckins: number;
};
water: {
totalAmount: number;
dailyGoal: number;
completionRate: number;
};
bodyMeasurements: {
weight?: number;
latestChest?: number;
latestWaist?: number;
};
challenges: {
activeChallengeCount: number;
};
// 来自 UserDailyHealth 的健康统计数据
healthStats: {
exerciseMinutes?: number; // 锻炼分钟数
caloriesBurned?: number; // 消耗卡路里
standingMinutes?: number; // 站立时间
basalMetabolism?: number; // 基础代谢
sleepMinutes?: number; // 睡眠分钟数
bloodOxygen?: number; // 血氧饱和度
stressLevel?: number; // 压力值
steps?: number; // 步数
};
}
@Injectable()
export class AiReportService {
private readonly logger = new Logger(AiReportService.name);
constructor(
private readonly configService: ConfigService,
@InjectModel(AiReportHistory)
private readonly aiReportHistoryModel: typeof AiReportHistory,
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
@Inject(forwardRef(() => MedicationStatsService))
private readonly medicationStatsService: MedicationStatsService,
@Inject(forwardRef(() => DietRecordsService))
private readonly dietRecordsService: DietRecordsService,
@Inject(forwardRef(() => WaterRecordsService))
private readonly waterRecordsService: WaterRecordsService,
@Inject(forwardRef(() => MoodCheckinsService))
private readonly moodCheckinsService: MoodCheckinsService,
@Inject(forwardRef(() => ChallengesService))
private readonly challengesService: ChallengesService,
private readonly cosService: CosService,
) {}
/**
* 检查用户当天成功生成的报告数量
* @param userId 用户ID
* @param date 日期,格式 YYYY-MM-DD
* @returns 当天成功生成的报告数量
*/
private async getTodaySuccessfulReportCount(userId: string, date: string): Promise<number> {
const count = await this.aiReportHistoryModel.count({
where: {
userId,
reportDate: date,
status: AiReportStatus.SUCCESS,
},
});
return count;
}
/**
* 主入口生成用户的AI健康报告图片
* 流程:
* 0. 检查当天生成次数限制最多2次
* 1. 创建记录,状态为 processing进行中
* 2. 聚合数据、生成 Prompt、调用图像生成 API
* 3. 根据结果更新记录状态为 success 或 failed
*
* @param userId 用户ID
* @param date 目标日期,格式 YYYY-MM-DD默认为今天
* @returns 包含记录ID和图片URL如果成功
*/
async generateHealthReportImage(userId: string, date?: string): Promise<{ id: string; imageUrl: string }> {
const targetDate = date || dayjs().format('YYYY-MM-DD');
this.logger.log(`开始为用户 ${userId} 生成 ${targetDate} 的AI健康报告`);
// Step 0: 检查当天成功生成的报告数量最多允许2次
const maxDailyReports = 2;
const todaySuccessCount = await this.getTodaySuccessfulReportCount(userId, targetDate);
if (todaySuccessCount >= maxDailyReports) {
this.logger.warn(`用户 ${userId}${targetDate} 已成功生成 ${todaySuccessCount} 次报告,已达到每日上限`);
throw new Error(`每天最多只能生成 ${maxDailyReports} 次健康报告`);
}
const startTime = Date.now();
const apiProvider = this.configService.get<string>('IMAGE_API_PROVIDER') || 'openrouter';
const recordId = uuidv4();
// Step 1: 创建记录,状态为 processing
await this.createReportRecord({
id: recordId,
userId,
reportDate: targetDate,
apiProvider,
status: AiReportStatus.PROCESSING,
});
let imagePrompt = '';
let imageUrl = '';
try {
// Step 2: 聚合数据
const dailyData = await this.gatherDailyData(userId, targetDate);
// Step 3: 获取用户语言偏好
const userLanguage = await this.getUserLanguage(userId);
// Step 4: 生成图像生成Prompt
imagePrompt = await this.generateImagePrompt(dailyData, userLanguage);
this.logger.log(`为用户 ${userId} 生成了图像Prompt`);
// 更新 Prompt 到记录
await this.updateReportRecord(recordId, { prompt: imagePrompt });
// Step 5: 调用图像生成API
imageUrl = await this.callImageGenerationApi(imagePrompt);
this.logger.log(`为用户 ${userId} 成功生成图像: ${imageUrl}`);
// Step 6: 更新记录状态为 success
await this.updateReportRecord(recordId, {
imageUrl,
status: AiReportStatus.SUCCESS,
generationTimeMs: Date.now() - startTime,
modelName: apiProvider === 'grsai' ? 'nano-banana-pro' : 'google/gemini-3-pro-image-preview',
});
return { id: recordId, imageUrl };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`为用户 ${userId} 生成报告失败: ${errorMessage}`);
// Step 6: 更新记录状态为 failed
await this.updateReportRecord(recordId, {
prompt: imagePrompt || null,
status: AiReportStatus.FAILED,
errorMessage,
generationTimeMs: Date.now() - startTime,
modelName: apiProvider === 'grsai' ? 'nano-banana-pro' : 'google/gemini-3-pro-image-preview',
});
throw error;
}
}
/**
* 创建报告记录
*/
private async createReportRecord(data: {
id: string;
userId: string;
reportDate: string;
apiProvider: string;
status: AiReportStatus;
}): Promise<AiReportHistory> {
try {
const record = await this.aiReportHistoryModel.create({
id: data.id,
userId: data.userId,
reportDate: data.reportDate,
apiProvider: data.apiProvider,
status: data.status,
prompt: null,
imageUrl: null,
});
this.logger.log(`创建报告记录成功ID: ${data.id}, 状态: ${data.status}`);
return record;
} catch (error) {
this.logger.error(`创建报告记录失败: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
/**
* 更新报告记录
*/
private async updateReportRecord(
recordId: string,
data: Partial<{
prompt: string | null;
imageUrl: string | null;
status: AiReportStatus;
errorMessage: string | null;
generationTimeMs: number;
modelName: string;
}>,
): Promise<void> {
try {
await this.aiReportHistoryModel.update(data, {
where: { id: recordId },
});
this.logger.log(`更新报告记录成功ID: ${recordId}, 更新字段: ${Object.keys(data).join(', ')}`);
} catch (error) {
// 更新失败不影响主流程,仅记录日志
this.logger.error(`更新报告记录失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* 获取用户的报告生成历史(分页)
* @param userId 用户ID
* @param options 查询选项
*/
async getReportHistory(
userId: string,
options?: { page?: number; pageSize?: number; startDate?: string; endDate?: string; status?: string },
): Promise<{ records: AiReportHistory[]; total: number; page: number; pageSize: number; totalPages: number }> {
const { page = 1, pageSize = 10, startDate, endDate, status } = options || {};
const offset = (page - 1) * pageSize;
const where: any = { userId };
// 如果传了 status 参数则按指定状态筛选,否则返回所有状态
if (status) {
where.status = status;
}
if (startDate || endDate) {
where.reportDate = {};
if (startDate) where.reportDate[Op.gte] = startDate;
if (endDate) where.reportDate[Op.lte] = endDate;
}
const { rows, count } = await this.aiReportHistoryModel.findAndCountAll({
where,
attributes: ['id', 'reportDate', 'imageUrl', 'status', 'createdAt'],
order: [['createdAt', 'DESC']],
limit: pageSize,
offset,
});
return {
records: rows,
total: count,
page,
pageSize,
totalPages: Math.ceil(count / pageSize)
};
}
/**
* 根据记录ID获取报告详情
* @param recordId 记录ID
* @param userId 用户ID用于权限校验
*/
async getReportById(recordId: string, userId: string): Promise<AiReportHistory | null> {
return this.aiReportHistoryModel.findOne({
where: { id: recordId, userId },
});
}
/**
* 1. 聚合用户指定日期的各项健康数据
*/
private async gatherDailyData(userId: string, date: string): Promise<DailyHealthData> {
const dayStart = dayjs(date).startOf('day').toISOString();
const dayEnd = dayjs(date).endOf('day').toISOString();
const [userProfile, medicationStats, dietHistory, waterStats, moodStats, activeChallengeCount, dailyHealth] = await Promise.all([
this.usersService.getProfile({ sub: userId, email: '', isGuest: false } as any).then(p => p.data).catch(() => null),
this.medicationStatsService.getDailyStats(userId, date).catch(() => null),
// 获取当日饮食记录并聚合
this.dietRecordsService.getDietHistory(userId, { startDate: dayStart, endDate: dayEnd, limit: 100 }).catch(() => ({ records: [] })),
this.waterRecordsService.getWaterStats(userId, date).catch(() => null),
this.moodCheckinsService.getDaily(userId, date).catch(() => null),
// 获取用户当前参与的活跃挑战数量
this.challengesService.getActiveParticipatingChallengeCount(userId).catch(() => 0),
// 获取用户每日健康统计数据
this.usersService.getDailyHealth(userId, date).catch(() => null),
]);
// 处理饮食数据聚合
const dietRecords: any[] = (dietHistory as any).records || [];
const dietStats = {
totalCalories: dietRecords.reduce((sum, r) => sum + (r.estimatedCalories || 0), 0),
totalProtein: dietRecords.reduce((sum, r) => sum + (r.proteinGrams || 0), 0),
totalCarbohydrates: dietRecords.reduce((sum, r) => sum + (r.carbohydrateGrams || 0), 0),
totalFat: dietRecords.reduce((sum, r) => sum + (r.fatGrams || 0), 0),
averageCaloriesPerMeal: dietRecords.length > 0 ? (dietRecords.reduce((sum, r) => sum + (r.estimatedCalories || 0), 0) / dietRecords.length) : 0,
mealTypeDistribution: dietRecords.reduce((acc, r) => {
acc[r.mealType] = (acc[r.mealType] || 0) + 1;
return acc;
}, {} as Record<string, number>)
};
// 获取身体测量数据(最新的体重和围度)
const bodyMeasurements = await this.usersService.getBodyMeasurementHistory(userId, undefined).then(res => res.data).catch(() => []);
const latestWeight = await this.usersService.getWeightHistory(userId, { limit: 1 }).then(res => res[0]).catch(() => null);
const latestChest = bodyMeasurements.find(m => m.measurementType === 'chestCircumference');
const latestWaist = bodyMeasurements.find(m => m.measurementType === 'waistCircumference');
return {
date,
user: userProfile ? {
name: userProfile.name,
avatar: userProfile.avatar,
gender: (userProfile as any).gender,
} : undefined,
medications: medicationStats ? {
totalScheduled: medicationStats.totalScheduled,
taken: medicationStats.taken,
missed: medicationStats.missed,
completionRate: medicationStats.completionRate,
} : { totalScheduled: 0, taken: 0, missed: 0, completionRate: 0 },
diet: {
totalCalories: dietStats.totalCalories,
totalProtein: dietStats.totalProtein,
totalCarbohydrates: dietStats.totalCarbohydrates,
totalFat: dietStats.totalFat,
averageCaloriesPerMeal: dietStats.averageCaloriesPerMeal,
mealTypeDistribution: dietStats.mealTypeDistribution,
},
water: waterStats?.data ? {
totalAmount: waterStats.data.totalAmount,
dailyGoal: waterStats.data.dailyGoal,
completionRate: waterStats.data.completionRate,
} : { totalAmount: 0, dailyGoal: 0, completionRate: 0 },
mood: moodStats?.data && Array.isArray(moodStats.data) && moodStats.data.length > 0 ? {
primaryMood: moodStats.data[0].moodType,
averageIntensity: moodStats.data.reduce((sum: number, m: any) => sum + m.intensity, 0) / moodStats.data.length,
totalCheckins: moodStats.data.length,
} : { totalCheckins: 0 },
bodyMeasurements: {
weight: latestWeight?.weight,
latestChest: latestChest?.value,
latestWaist: latestWaist?.value,
},
challenges: {
activeChallengeCount: activeChallengeCount,
},
// 健康统计数据
healthStats: dailyHealth ? {
exerciseMinutes: dailyHealth.exerciseMinutes ?? undefined,
caloriesBurned: dailyHealth.caloriesBurned ?? undefined,
standingMinutes: dailyHealth.standingMinutes ?? undefined,
basalMetabolism: dailyHealth.basalMetabolism ?? undefined,
sleepMinutes: dailyHealth.sleepMinutes ?? undefined,
bloodOxygen: dailyHealth.bloodOxygen ?? undefined,
stressLevel: dailyHealth.stressLevel ?? undefined,
steps: dailyHealth.steps ?? undefined,
} : {},
};
}
/**
* 2. 生成固定格式的 prompt适用于 Nano Banana Pro 模型
* 优化:支持多语言,根据用户语言偏好生成对应的文本
* 包含健康统计数据:步数、睡眠、运动、卡路里消耗、血氧、压力等
*/
private async generateImagePrompt(data: DailyHealthData, language: string): Promise<string> {
const isEnglish = language.toLowerCase().startsWith('en');
// 格式化日期
const dateStr = isEnglish
? dayjs(data.date).format('MMM DD') // "Dec 01"
: dayjs(data.date).format('MM月DD日'); // "12月01日"
// 准备数据文本
const moodText = this.translateMood(data.mood.primaryMood, language);
const medRate = Math.round(data.medications.completionRate);
const calories = Math.round(data.diet.totalCalories);
const water = Math.round(data.water.totalAmount);
const challengeCount = data.challenges.activeChallengeCount;
// 健康统计数据
const { healthStats } = data;
const steps = healthStats.steps;
const sleepHours = healthStats.sleepMinutes ? Math.round(healthStats.sleepMinutes / 60 * 10) / 10 : undefined;
const exerciseMinutes = healthStats.exerciseMinutes;
const caloriesBurned = healthStats.caloriesBurned ? Math.round(healthStats.caloriesBurned) : undefined;
const bloodOxygen = healthStats.bloodOxygen ? Math.round(healthStats.bloodOxygen) : undefined;
const stressLevel = healthStats.stressLevel;
// 根据性别调整角色描述 - 优化版本,更具体的形象描述
let characterDesc: string;
let characterPose: string;
if (data.user?.gender === 'male') {
characterDesc = 'A cheerful young man with short hair, wearing a comfortable athletic t-shirt and shorts, fit and healthy looking';
characterPose = 'standing confidently with a thumbs up or stretching pose';
} else if (data.user?.gender === 'female') {
characterDesc = 'A cheerful young woman with ponytail hair, wearing a stylish yoga top and leggings, fit and energetic looking';
characterPose = 'doing a gentle yoga pose or stretching gracefully';
} else {
characterDesc = 'A cute friendly mascot character (like a happy cat or bunny) wearing a small fitness headband';
characterPose = 'jumping happily or giving a cheerful wave';
}
// 构建多语言文本内容
const textContent = isEnglish ? {
languageInstruction: 'Please render the following specific text in English correctly:',
title: `${dateStr} Health Report`,
medication: medRate > 0 ? `Medication: ${medRate}%` : null,
diet: calories > 0 ? `Calories In: ${calories} kcal` : null,
water: water > 0 ? `Water: ${water}ml` : null,
mood: `Mood: ${moodText}`,
challenges: challengeCount > 0 ? `Challenges: ${challengeCount}` : null,
// 健康统计
steps: steps !== undefined ? `Steps: ${steps.toLocaleString()}` : null,
sleep: sleepHours !== undefined ? `Sleep: ${sleepHours}h` : null,
exercise: exerciseMinutes !== undefined ? `Exercise: ${exerciseMinutes}min` : null,
caloriesBurned: caloriesBurned !== undefined ? `Burned: ${caloriesBurned} kcal` : null,
bloodOxygen: bloodOxygen !== undefined ? `SpO2: ${bloodOxygen}%` : null,
stress: stressLevel !== undefined ? `Stress: ${stressLevel}` : null,
} : {
languageInstruction: 'Please render the following specific text in Chinese correctly:',
title: `${dateStr} 健康日报`,
medication: medRate > 0 ? `用药: ${medRate}%` : null,
diet: calories > 0 ? `摄入: ${calories}千卡` : null,
water: water > 0 ? `饮水: ${water}ml` : null,
mood: `心情: ${moodText}`,
challenges: challengeCount > 0 ? `挑战: ${challengeCount}` : null,
// 健康统计
steps: steps !== undefined ? `步数: ${steps.toLocaleString()}` : null,
sleep: sleepHours !== undefined ? `睡眠: ${sleepHours}小时` : null,
exercise: exerciseMinutes !== undefined ? `运动: ${exerciseMinutes}分钟` : null,
caloriesBurned: caloriesBurned !== undefined ? `消耗: ${caloriesBurned}千卡` : null,
bloodOxygen: bloodOxygen !== undefined ? `血氧: ${bloodOxygen}%` : null,
stress: stressLevel !== undefined ? `压力: ${stressLevel}` : null,
};
// 构建文本部分 - 只包含有数据的项
const textSections: string[] = [];
textSections.push(`- Title text: "${textContent.title}"`);
if (textContent.medication) textSections.push(`- Medication section text: "${textContent.medication}"`);
if (textContent.diet) textSections.push(`- Diet section text: "${textContent.diet}"`);
if (textContent.water) textSections.push(`- Water section text: "${textContent.water}"`);
textSections.push(`- Mood section text: "${textContent.mood}"`);
if (textContent.challenges) textSections.push(`- Challenge section text: "${textContent.challenges}"`);
// 健康统计文本
if (textContent.steps) textSections.push(`- Steps section text: "${textContent.steps}"`);
if (textContent.sleep) textSections.push(`- Sleep section text: "${textContent.sleep}"`);
if (textContent.exercise) textSections.push(`- Exercise section text: "${textContent.exercise}"`);
if (textContent.caloriesBurned) textSections.push(`- Calories burned section text: "${textContent.caloriesBurned}"`);
if (textContent.bloodOxygen) textSections.push(`- Blood oxygen section text: "${textContent.bloodOxygen}"`);
if (textContent.stress) textSections.push(`- Stress section text: "${textContent.stress}"`);
// 构建图标部分 - 只包含有数据的项
const iconSections: string[] = [];
iconSections.push(`- ${characterDesc}, ${characterPose}, representing the user.`);
if (textContent.medication) iconSections.push('- Icon for medication (pill bottle or pills).');
if (textContent.diet) iconSections.push('- Icon for diet (healthy food bowl or apple).');
if (textContent.water) iconSections.push('- Icon for water (water glass or water drop).');
iconSections.push(`- Icon for mood (a ${moodText.toLowerCase()} face emoji).`);
if (textContent.challenges) iconSections.push(`- Icon for challenges (trophy or flag icon representing ${challengeCount} active ${challengeCount === 1 ? 'challenge' : 'challenges'}).`);
// 健康统计图标
if (textContent.steps) iconSections.push('- Icon for steps (footprints or walking figure).');
if (textContent.sleep) iconSections.push('- Icon for sleep (moon and stars or sleeping face).');
if (textContent.exercise) iconSections.push('- Icon for exercise (running figure or dumbbell).');
if (textContent.caloriesBurned) iconSections.push('- Icon for calories burned (flame or fire icon).');
if (textContent.bloodOxygen) iconSections.push('- Icon for blood oxygen (heart with pulse or O2 symbol).');
if (textContent.stress) iconSections.push('- Icon for stress level (brain or meditation icon).');
// 构建 Prompt
const prompt = `
A cute, hand-drawn style health journal page illustration, kawaii aesthetic, soft pastel colors (pink, mint, lavender, peach), warm lighting. Vertical 9:16 aspect ratio. High quality, 1k resolution.
The image features a cute organized layout with icons and text boxes arranged in a grid or card style.
${textContent.languageInstruction}
${textSections.join('\n')}
Visual elements:
${iconSections.join('\n')}
Composition: Clean, organized, magazine layout style with rounded corners on each section card, decorative stickers and washi tape effects, small sparkles and stars as decorations. The character should be prominently featured at the top or center of the design.
`.trim();
return prompt;
}
/**
* 根据语言翻译心情类型
*/
private translateMood(moodType?: string, language: string = 'zh-CN'): string {
const isEnglish = language.toLowerCase().startsWith('en');
if (isEnglish) {
const moodMapEn: Record<string, string> = {
HAPPY: 'Happy',
EXCITED: 'Excited',
THRILLED: 'Thrilled',
CALM: 'Calm',
ANXIOUS: 'Anxious',
SAD: 'Sad',
LONELY: 'Lonely',
WRONGED: 'Wronged',
ANGRY: 'Angry',
TIRED: 'Tired',
};
return moodMapEn[moodType || ''] || 'Calm';
}
const moodMapZh: Record<string, string> = {
HAPPY: '开心',
EXCITED: '兴奋',
THRILLED: '激动',
CALM: '平静',
ANXIOUS: '焦虑',
SAD: '难过',
LONELY: '孤独',
WRONGED: '委屈',
ANGRY: '生气',
TIRED: '心累',
};
return moodMapZh[moodType || ''] || '平静';
}
/**
* 获取用户语言偏好
*/
private async getUserLanguage(userId: string): Promise<string> {
try {
return await this.usersService.getUserLanguage(userId);
} catch (error) {
this.logger.error(`获取用户语言失败,使用默认中文: ${error instanceof Error ? error.message : String(error)}`);
return 'zh-CN';
}
}
/**
* 3. 调用图像生成 API支持 OpenRouter 和 GRSAI 两种提供商
* 通过环境变量 IMAGE_API_PROVIDER 配置:'openrouter' | 'grsai',默认 'openrouter'
*/
private async callImageGenerationApi(prompt: string): Promise<string> {
const apiProvider = this.configService.get<string>('IMAGE_API_PROVIDER') || 'openrouter';
this.logger.log(`准备调用 ${apiProvider} 生成图像`);
this.logger.log(`使用Prompt: ${prompt}`);
if (apiProvider === 'grsai') {
return this.callGrsaiImageApi(prompt);
} else {
return this.callOpenRouterImageApi(prompt);
}
}
/**
* 调用 OpenRouter API 生成图像
*/
private async callOpenRouterImageApi(prompt: string): Promise<string> {
const apiKey = this.configService.get<string>('OPENROUTER_API_KEY');
if (!apiKey) {
this.logger.error('OpenRouter API Key 未配置');
throw new Error('OpenRouter API Key 未配置');
}
try {
const client = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey,
});
this.logger.log(`使用 OpenRouter API, 模型: google/gemini-3-pro-image-preview`);
const apiResponse = await client.chat.completions.create({
model: 'google/gemini-3-pro-image-preview',
messages: [
{
role: 'user' as const,
content: prompt,
},
],
// @ts-ignore - 扩展参数,用于支持图像生成
modalities: ['image', 'text'],
});
const message = apiResponse.choices[0].message;
this.logger.log(`OpenRouter 收到响应: ${JSON.stringify(message, null, 2)}`);
const imageData = this.extractImageData(message);
if (imageData) {
return await this.processAndUploadImage(imageData);
}
this.logger.error('OpenRouter 响应中未包含图像数据');
this.logger.error(`实际响应内容: ${JSON.stringify(message).substring(0, 500)}`);
throw new Error('图像生成失败:响应中未包含图像数据');
} catch (error) {
this.logger.error(`调用 OpenRouter 失败: ${error.message}`);
throw error;
}
}
/**
* 调用 GRSAI API 生成图像(提交任务 + 轮询结果)
* API 文档:
* - 提交任务: POST https://grsai.dakka.com.cn/v1/draw/nano-banana
* - 查询结果: POST https://grsai.dakka.com.cn/v1/draw/result
*/
private async callGrsaiImageApi(prompt: string): Promise<string> {
const apiKey = this.configService.get<string>('GRSAI_API_KEY');
if (!apiKey) {
this.logger.error('GRSAI API Key 未配置');
throw new Error('GRSAI API Key 未配置');
}
const baseURL = 'https://grsai.dakka.com.cn';
const axios = require('axios');
// 通用请求头
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
};
try {
// Step 1: 提交图像生成任务
this.logger.log('GRSAI: 提交图像生成任务...');
const submitResponse = await axios.post(
`${baseURL}/v1/draw/nano-banana`,
{
model: 'nano-banana-pro',
prompt: prompt,
webHook: '-1', // 必填 -1表示不使用 webhook
},
{ headers, timeout: 30000 },
);
// 检查提交响应
if (submitResponse.data.code !== 0) {
this.logger.error(`GRSAI 任务提交失败: ${submitResponse.data.msg}`);
throw new Error(`GRSAI 任务提交失败: ${submitResponse.data.msg}`);
}
const taskId = submitResponse.data.data?.id;
if (!taskId) {
this.logger.error('GRSAI 任务提交成功但未返回任务ID');
throw new Error('GRSAI 任务提交失败未返回任务ID');
}
this.logger.log(`GRSAI: 任务提交成功任务ID: ${taskId}`);
// Step 2: 轮询获取结果
const imageUrl = await this.pollGrsaiResult(baseURL, headers, taskId);
// Step 3: 下载图像并上传到 COS
return await this.processAndUploadImage(imageUrl);
} catch (error) {
this.logger.error(`调用 GRSAI 失败: ${error.message}`);
throw error;
}
}
/**
* 轮询 GRSAI 任务结果
* @param baseURL API 基础地址
* @param headers 请求头
* @param taskId 任务ID
* @returns 生成的图像 URL
*/
private async pollGrsaiResult(
baseURL: string,
headers: Record<string, string>,
taskId: string,
): Promise<string> {
const axios = require('axios');
// 轮询配置
const maxAttempts = 60; // 最大尝试次数
const pollInterval = 2000; // 轮询间隔 2 秒
const maxWaitTime = 120000; // 最大等待时间 2 分钟
const startTime = Date.now();
let attempts = 0;
while (attempts < maxAttempts) {
attempts++;
const elapsedTime = Date.now() - startTime;
// 检查是否超时
if (elapsedTime > maxWaitTime) {
this.logger.error(`GRSAI: 轮询超时,已等待 ${elapsedTime / 1000}`);
throw new Error('GRSAI 图像生成超时');
}
this.logger.log(`GRSAI: 第 ${attempts} 次轮询任务ID: ${taskId}`);
try {
const resultResponse = await axios.post(
`${baseURL}/v1/draw/result`,
{ id: taskId },
{ headers, timeout: 10000 },
);
const data = resultResponse.data;
// 检查响应码
if (data.code !== 0) {
this.logger.error(`GRSAI 查询失败: ${data.msg}`);
throw new Error(`GRSAI 查询失败: ${data.msg}`);
}
const taskData = data.data;
const status = taskData?.status;
const progress = taskData?.progress || 0;
this.logger.log(`GRSAI: 任务状态: ${status}, 进度: ${progress}%`);
// 根据状态处理
switch (status) {
case 'succeeded':
// 任务成功,提取图像 URL
const results = taskData.results;
if (results && results.length > 0 && results[0].url) {
const imageUrl = results[0].url;
this.logger.log(`GRSAI: 图像生成成功: ${imageUrl}`);
return imageUrl;
}
throw new Error('GRSAI 任务成功但未返回图像URL');
case 'failed':
// 任务失败
const failureReason = taskData.failure_reason || taskData.error || '未知错误';
this.logger.error(`GRSAI: 任务失败: ${failureReason}`);
throw new Error(`GRSAI 图像生成失败: ${failureReason}`);
case 'pending':
case 'processing':
case 'running':
// 任务进行中,继续轮询
await this.sleep(pollInterval);
break;
default:
// 未知状态,继续轮询
this.logger.warn(`GRSAI: 未知任务状态: ${status}`);
await this.sleep(pollInterval);
}
} catch (error) {
// 如果是网络错误,继续重试
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
this.logger.warn(`GRSAI: 轮询请求超时,继续重试...`);
await this.sleep(pollInterval);
continue;
}
throw error;
}
}
throw new Error(`GRSAI 图像生成失败:超过最大轮询次数 ${maxAttempts}`);
}
/**
* 延时函数
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 从 API 响应中提取图像数据
*/
private extractImageData(message: any): string | undefined {
let imageData: string | undefined;
// 检查 images 数组OpenRouter/GRSAI 特有的响应格式)
if (message.images && Array.isArray(message.images) && message.images.length > 0) {
const firstImage = message.images[0];
if (firstImage.image_url?.url) {
imageData = firstImage.image_url.url;
this.logger.log(`检测到 images 数组中的图像数据`);
}
}
// 如果 images 数组中没有,检查 content 字段
if (!imageData && typeof message.content === 'string') {
// 检查是否为 base64 数据
if (message.content.startsWith('data:image/')) {
imageData = message.content;
this.logger.log('检测到 Data URL 格式的 base64 图像');
} else if (/^[A-Za-z0-9+/=]+$/.test(message.content.substring(0, 100))) {
imageData = message.content;
this.logger.log('检测到纯 base64 格式的图像数据');
} else {
// 尝试提取 HTTP URL
const urlMatch = message.content.match(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp)/i);
if (urlMatch) {
imageData = urlMatch[0];
this.logger.log(`检测到 HTTP URL: ${imageData}`);
}
}
}
return imageData;
}
/**
* 处理图像数据并上传到 COS
*/
private async processAndUploadImage(imageData: string): Promise<string> {
// 判断是 base64 还是 URL
const isBase64 = imageData.startsWith('data:image/') ||
(/^[A-Za-z0-9+/=]+$/.test(imageData.substring(0, 100)) && !imageData.startsWith('http'));
if (isBase64) {
// 处理 base64 数据并上传到 COS
this.logger.log('开始处理 base64 图像数据');
const cosImageUrl = await this.uploadBase64ToCos(imageData);
this.logger.log(`Base64 图像上传到 COS 成功: ${cosImageUrl}`);
return cosImageUrl;
} else {
// 下载 HTTP URL 图像并上传到 COS
this.logger.log(`API 返回图像 URL: ${imageData}`);
const cosImageUrl = await this.downloadAndUploadToCos(imageData);
this.logger.log(`图像上传到 COS 成功: ${cosImageUrl}`);
return cosImageUrl;
}
}
/**
* 将 base64 图像数据上传到 COS
*/
private async uploadBase64ToCos(base64Data: string): Promise<string> {
try {
this.logger.log('开始处理 base64 图像数据');
let base64String = base64Data;
let mimeType = 'image/png'; // 默认类型
// 如果是 data URL 格式,提取 MIME 类型和纯 base64 数据
if (base64Data.startsWith('data:')) {
const matches = base64Data.match(/^data:([^;]+);base64,(.+)$/);
if (matches) {
mimeType = matches[1];
base64String = matches[2];
this.logger.log(`检测到 MIME 类型: ${mimeType}`);
}
}
// 将 base64 转换为 Buffer
const imageBuffer = Buffer.from(base64String, 'base64');
this.logger.log(`Base64 数据转换成功,大小: ${imageBuffer.length} bytes`);
// 根据 MIME 类型确定文件扩展名
const ext = mimeType.split('/')[1] || 'png';
const fileName = `ai-health-report-${Date.now()}.${ext}`;
// 使用 CosService 的 uploadBuffer 方法上传
const uploadResult = await this.cosService.uploadBuffer('ai-report', imageBuffer, fileName, mimeType);
this.logger.log(`Base64 图像上传成功: ${uploadResult.fileUrl}`);
return uploadResult.fileUrl;
} catch (error) {
this.logger.error(`上传 base64 图像到 COS 失败: ${error.message}`);
throw new Error(`Base64 图像处理失败: ${error.message}`);
}
}
/**
* 下载图像并上传到 COS
*/
private async downloadAndUploadToCos(imageUrl: string): Promise<string> {
try {
this.logger.log(`开始下载图像: ${imageUrl}`);
// 下载图像
const axios = require('axios');
const response = await axios.get(imageUrl, {
responseType: 'arraybuffer',
timeout: 30000, // 30秒超时
});
// 检查响应状态
if (response.status !== 200) {
throw new Error(`图像下载失败HTTP状态码: ${response.status}`);
}
// 检查响应数据
if (!response.data) {
throw new Error('图像下载失败:响应数据为空');
}
this.logger.log(`图像下载成功,大小: ${response.data.length} bytes`);
// 创建模拟文件对象用于上传
const imageBuffer = Buffer.from(response.data);
const fileName = `ai-health-report-${Date.now()}.png`;
// 检测 MIME 类型
let mimeType = 'image/png';
if (response.headers['content-type']) {
mimeType = response.headers['content-type'];
}
// 使用 CosService 的 uploadBuffer 方法上传
const uploadResult = await this.cosService.uploadBuffer('ai-report', imageBuffer, fileName, mimeType);
return uploadResult.fileUrl;
} catch (error) {
this.logger.error(`下载或上传图像到COS失败: ${error.message}`);
throw new Error(`图像处理失败: ${error.message}`);
}
}
}

View File

@@ -1,10 +1,57 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OpenAI } from 'openai';
import { DietRecordsService } from '../../diet-records/diet-records.service';
import { CreateDietRecordDto } from '../../users/dto/diet-record.dto';
import { MealType, DietRecordSource } from '../../users/models/user-diet-history.model';
const MESSAGES = {
'zh-CN': {
recognitionFailed: '食物识别失败,请稍后重试',
serviceUnavailable: '服务暂时不可用,请稍后重试',
analysisFailed: '图片分析失败,请稍后重试',
textAnalysisFailed: '文本饮食分析失败,请稍后重试',
textFoodAnalysisFailed: '文本食物分析失败,请稍后重试',
noFoodInText: '未能从文本中识别到具体食物信息',
provideMoreDetails: '请描述更具体的食物信息,如"吃了一碗米饭"、"喝了一杯牛奶"等',
basedOnDescription: (text: string) => `基于您的描述"${text}",识别出以下食物`,
textAnalysisParseFailed: '文本分析失败:无法解析识别结果',
textAnalysisFailedRetry: '文本分析失败,请重新描述您吃的食物',
recognizedCount: (text: string, count: number) => `基于您的描述"${text}",识别出 ${count} 种食物`,
imageAnalysisFailed: '图片分析失败:无法解析识别结果',
uploadImageRetry: '图片分析失败,请重新上传图片',
noFoodDetected: '图片中未检测到食物',
imageBlurred: '图片模糊,无法准确识别食物',
foodRecognized: '已识别图片中的食物',
uploadFoodImage: '图片中未检测到食物,请上传包含食物的图片',
parseError: '图片分析失败:无法解析分析结果',
noAnalysisDesc: '未提供分析说明',
unknownFood: '未知食物'
},
'en-US': {
recognitionFailed: 'Food recognition failed, please try again later',
serviceUnavailable: 'Service temporarily unavailable, please try again later',
analysisFailed: 'Image analysis failed, please try again later',
textAnalysisFailed: 'Text diet analysis failed, please try again later',
textFoodAnalysisFailed: 'Text food analysis failed, please try again later',
noFoodInText: 'No specific food information identified from the text',
provideMoreDetails: 'Please describe more specific food information, e.g., "ate a bowl of rice", "drank a glass of milk"',
basedOnDescription: (text: string) => `Based on your description "${text}", the following foods were identified`,
textAnalysisParseFailed: 'Text analysis failed: Unable to parse recognition result',
textAnalysisFailedRetry: 'Text analysis failed, please describe your food again',
recognizedCount: (text: string, count: number) => `Based on your description "${text}", ${count} foods were identified`,
imageAnalysisFailed: 'Image analysis failed: Unable to parse recognition result',
uploadImageRetry: 'Image analysis failed, please upload image again',
noFoodDetected: 'No food detected in the image',
imageBlurred: 'Image is blurred, unable to accurately recognize food',
foodRecognized: 'Food recognized in the image',
uploadFoodImage: 'No food detected in the image, please upload an image containing food',
parseError: 'Image analysis failed: Unable to parse analysis result',
noAnalysisDesc: 'No analysis description provided',
unknownFood: 'Unknown Food'
}
};
/**
* 饮食分析结果接口
*/
@@ -72,6 +119,7 @@ export class DietAnalysisService {
constructor(
private readonly configService: ConfigService,
@Inject(forwardRef(() => DietRecordsService))
private readonly dietRecordsService: DietRecordsService,
) {
// Support both GLM-4.5V and DashScope (Qwen) models
@@ -88,7 +136,7 @@ export class DietAnalysisService {
});
this.model = this.configService.get<string>('GLM_MODEL') || 'glm-4-flash';
this.visionModel = this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4v-plus';
this.visionModel = 'glm-4v-flash'
} else {
// DashScope Configuration (default)
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
@@ -178,29 +226,31 @@ export class DietAnalysisService {
/**
* 食物识别用于用户确认 - 新的确认流程
* @param imageUrls 图片URL数组
* @param language 语言代码,默认 zh-CN
* @returns 食物识别确认结果
*/
async recognizeFoodForConfirmation(imageUrls: string[]): Promise<FoodRecognitionResult> {
async recognizeFoodForConfirmation(imageUrls: string[], language: string = 'zh-CN'): Promise<FoodRecognitionResult> {
try {
const currentHour = new Date().getHours();
const suggestedMealType = this.getSuggestedMealType(currentHour);
const prompt = this.buildFoodRecognitionPrompt(suggestedMealType);
const prompt = this.buildFoodRecognitionPrompt(suggestedMealType, language);
const completion = await this.makeVisionApiCall(prompt, imageUrls);
const rawResult = completion.choices?.[0]?.message?.content || '{}';
this.logger.log(`Food recognition result: ${rawResult}`);
return this.parseRecognitionResult(rawResult, suggestedMealType);
return this.parseRecognitionResult(rawResult, suggestedMealType, language);
} catch (error) {
this.logger.error(`食物识别失败: ${error instanceof Error ? error.message : String(error)}`);
const msgs = this.getMessages(language);
return {
items: [],
analysisText: '食物识别失败,请稍后重试',
analysisText: msgs.recognitionFailed,
confidence: 0,
isFoodDetected: false,
nonFoodMessage: '服务暂时不可用,请稍后重试'
nonFoodMessage: msgs.serviceUnavailable
};
}
}
@@ -264,9 +314,10 @@ export class DietAnalysisService {
/**
* 分析文本中的食物用于用户确认 - 与图片识别接口保持数据结构一致
* @param userText 用户输入的文本描述
* @param language 语言代码,默认 zh-CN
* @returns 食物识别确认结果
*/
async analyzeTextFoodForConfirmation(userText: string): Promise<FoodRecognitionResult> {
async analyzeTextFoodForConfirmation(userText: string, language: string = 'zh-CN'): Promise<FoodRecognitionResult> {
try {
this.logger.log(`Text food analysis request: ${userText}`);
@@ -274,22 +325,23 @@ export class DietAnalysisService {
const suggestedMealType = this.getSuggestedMealType(currentHour);
// 使用专门的多食物文本分析 prompt
const prompt = this.buildMultiFoodTextAnalysisPrompt(suggestedMealType);
const prompt = this.buildMultiFoodTextAnalysisPrompt(suggestedMealType, language);
const completion = await this.makeTextApiCall(prompt, userText);
const rawResult = completion.choices?.[0]?.message?.content || '{}';
this.logger.log(`Multi-food text analysis result: ${rawResult}`);
// 直接解析为多食物结构
return this.parseMultiFoodTextResult(rawResult, suggestedMealType, userText);
return this.parseMultiFoodTextResult(rawResult, suggestedMealType, userText, language);
} catch (error) {
this.logger.error(`文本食物分析失败: ${error instanceof Error ? error.message : String(error)}`);
const msgs = this.getMessages(language);
return {
items: [],
analysisText: '文本食物分析失败,请稍后重试',
analysisText: msgs.textFoodAnalysisFailed,
confidence: 0,
isFoodDetected: false,
nonFoodMessage: '服务暂时不可用,请稍后重试'
nonFoodMessage: msgs.serviceUnavailable
};
}
}
@@ -461,9 +513,10 @@ export class DietAnalysisService {
/**
* 构建食物识别提示(用于确认流程)
* @param suggestedMealType 建议的餐次类型
* @param language 语言代码
* @returns 提示文本
*/
private buildFoodRecognitionPrompt(suggestedMealType: MealType): string {
private buildFoodRecognitionPrompt(suggestedMealType: MealType, language: string): string {
return `作为专业营养分析师,请分析这张图片并判断是否包含食物。
当前时间建议餐次:${suggestedMealType}
@@ -475,17 +528,17 @@ export class DietAnalysisService {
返回以下格式的JSON
{
"confidence": number, // 整体识别置信度 0-100
"analysisText": string, // 简短的识别说明文字
"analysisText": string, // 简短的识别说明文字,请使用${language}语言
"isFoodDetected": boolean, // 是否检测到食物
"nonFoodMessage": string, // 当isFoodDetected为false时的提示信息
"nonFoodMessage": string, // 当isFoodDetected为false时的提示信息,请使用${language}语言
"recognizedItems": [ // 识别的食物列表(如果是食物才有内容)
{
"id": string, // 唯一标识符
"foodName": string, // 食物名称
"portion": string, // 份量描述(如"1碗"、"150g"等)
"foodName": string, // 食物名称,请使用${language}语言
"portion": string, // 份量描述,请使用${language}语言(如"1碗"、"150g"等)
"calories": number, // 估算热量
"mealType": "${suggestedMealType}", // 餐次类型
"label": string, // 显示给用户的完整选项文本(如"一条鱼 200卡"
"label": string, // 显示给用户的完整选项文本,请使用${language}语言(如"一条鱼 200卡"
"nutritionData": {
"proteinGrams": number, // 蛋白质
"carbohydrateGrams": number, // 碳水化合物
@@ -515,7 +568,11 @@ export class DietAnalysisService {
3. **模糊情况:**
- 如果图片模糊但能看出是食物相关,设置 isFoodDetected: true但返回空的recognizedItems数组
- analysisText 说明"图片模糊,无法准确识别食物"`;
- analysisText 说明"图片模糊,无法准确识别食物"
**重要提示:**
请使用 ${language} 语言返回所有文本内容包括label, analysisText, nonFoodMessage, foodName, portion等
Please respond in ${language}.`;
}
/**
@@ -605,9 +662,10 @@ export class DietAnalysisService {
/**
* 构建多食物文本分析提示 - 支持识别多种食物
* @param suggestedMealType 建议的餐次类型
* @param language 语言代码
* @returns 提示文本
*/
private buildMultiFoodTextAnalysisPrompt(suggestedMealType: MealType): string {
private buildMultiFoodTextAnalysisPrompt(suggestedMealType: MealType, language: string): string {
return `作为专业营养分析师,请分析用户描述的饮食内容,支持识别多种食物。
当前时间建议餐次:${suggestedMealType}
@@ -615,17 +673,17 @@ export class DietAnalysisService {
请返回以下格式的JSON不要包含其他文本
{
"confidence": number, // 整体识别置信度 0-100
"analysisText": string, // 简短的识别说明文字
"analysisText": string, // 简短的识别说明文字,请使用${language}语言
"isFoodDetected": boolean, // 是否检测到食物
"nonFoodMessage": string, // 当isFoodDetected为false时的提示信息
"nonFoodMessage": string, // 当isFoodDetected为false时的提示信息,请使用${language}语言
"recognizedItems": [ // 识别的食物列表
{
"id": string, // 唯一标识符(使用 food_1, food_2 等)
"foodName": string, // 食物名称(简洁)
"portion": string, // 份量描述(如"1碗"、"1份"等)
"foodName": string, // 食物名称(简洁),请使用${language}语言
"portion": string, // 份量描述(如"1碗"、"1份"等),请使用${language}语言
"calories": number, // 估算热量
"mealType": "${suggestedMealType}", // 餐次类型
"label": string, // 显示给用户的完整选项文本(如"一碗米饭 280卡"
"label": string, // 显示给用户的完整选项文本(如"一碗米饭 280卡",请使用${language}语言
"nutritionData": {
"proteinGrams": number, // 蛋白质
"carbohydrateGrams": number, // 碳水化合物
@@ -660,7 +718,11 @@ export class DietAnalysisService {
- "今天中午吃了一碗米饭,一份麻辣香锅" → 识别为2个选项
- "早餐吃了燕麦粥加香蕉和牛奶" → 可识别为1个复合选项或3个独立选项
- "晚上吃了牛肉面" → 识别为1个选项面条+牛肉的复合菜品)
- "喝了水" → isFoodDetected: false水不是营养食物`;
- "喝了水" → isFoodDetected: false水不是营养食物
**重要提示:**
请使用 ${language} 语言返回所有文本内容包括label, analysisText, nonFoodMessage, foodName, portion等
Please respond in ${language}.`;
}
/**
@@ -716,9 +778,11 @@ export class DietAnalysisService {
* @param rawResult 原始结果字符串
* @param suggestedMealType 建议的餐次类型
* @param originalText 原始用户文本
* @param language 语言代码
* @returns 解析后的识别结果
*/
private parseMultiFoodTextResult(rawResult: string, suggestedMealType: MealType, originalText: string): FoodRecognitionResult {
private parseMultiFoodTextResult(rawResult: string, suggestedMealType: MealType, originalText: string, language: string): FoodRecognitionResult {
const msgs = this.getMessages(language);
let parsedResult: any;
try {
parsedResult = JSON.parse(rawResult);
@@ -726,10 +790,10 @@ export class DietAnalysisService {
this.logger.error(`多食物文本分析JSON解析失败: ${parseError}`);
return {
items: [],
analysisText: '文本分析失败:无法解析识别结果',
analysisText: msgs.textAnalysisParseFailed,
confidence: 0,
isFoodDetected: false,
nonFoodMessage: '文本分析失败,请重新描述您吃的食物'
nonFoodMessage: msgs.textAnalysisFailedRetry
};
}
@@ -764,11 +828,11 @@ export class DietAnalysisService {
// 根据是否识别到食物设置不同的分析文本
let analysisText = parsedResult.analysisText || '';
if (!isFoodDetected) {
analysisText = analysisText || '文本中未检测到具体食物信息';
analysisText = analysisText || msgs.noFoodInText;
} else if (recognizedItems.length === 0) {
analysisText = analysisText || '无法准确解析食物信息';
analysisText = analysisText || msgs.noFoodInText;
} else {
analysisText = analysisText || `基于您的描述"${originalText}",识别出 ${recognizedItems.length} 种食物`;
analysisText = analysisText || msgs.recognizedCount(originalText, recognizedItems.length);
}
return {
@@ -776,7 +840,7 @@ export class DietAnalysisService {
analysisText,
confidence: Math.min(100, Math.max(0, parsedResult.confidence || 0)),
isFoodDetected,
nonFoodMessage: !isFoodDetected ? (nonFoodMessage || '请描述更具体的食物信息,如"吃了一碗米饭"、"喝了一杯牛奶"等') : undefined
nonFoodMessage: !isFoodDetected ? (nonFoodMessage || msgs.provideMoreDetails) : undefined
};
}
@@ -784,9 +848,11 @@ export class DietAnalysisService {
* 解析食物识别结果
* @param rawResult 原始结果字符串
* @param suggestedMealType 建议的餐次类型
* @param language 语言代码
* @returns 解析后的识别结果
*/
private parseRecognitionResult(rawResult: string, suggestedMealType: MealType): FoodRecognitionResult {
private parseRecognitionResult(rawResult: string, suggestedMealType: MealType, language: string): FoodRecognitionResult {
const msgs = this.getMessages(language);
let parsedResult: any;
try {
parsedResult = JSON.parse(rawResult);
@@ -794,10 +860,10 @@ export class DietAnalysisService {
this.logger.error(`食物识别JSON解析失败: ${parseError}`);
return {
items: [],
analysisText: '图片分析失败:无法解析识别结果',
analysisText: msgs.imageAnalysisFailed,
confidence: 0,
isFoodDetected: false,
nonFoodMessage: '图片分析失败,请重新上传图片'
nonFoodMessage: msgs.uploadImageRetry
};
}
@@ -832,11 +898,11 @@ export class DietAnalysisService {
// 根据是否识别到食物设置不同的分析文本
let analysisText = parsedResult.analysisText || '';
if (!isFoodDetected) {
analysisText = analysisText || '图片中未检测到食物';
analysisText = analysisText || msgs.noFoodDetected;
} else if (recognizedItems.length === 0) {
analysisText = analysisText || '图片模糊,无法准确识别食物';
analysisText = analysisText || msgs.imageBlurred;
} else {
analysisText = analysisText || '已识别图片中的食物';
analysisText = analysisText || msgs.foodRecognized;
}
return {
@@ -844,7 +910,7 @@ export class DietAnalysisService {
analysisText,
confidence: Math.min(100, Math.max(0, parsedResult.confidence || 0)),
isFoodDetected,
nonFoodMessage: !isFoodDetected ? (nonFoodMessage || '图片中未检测到食物,请上传包含食物的图片') : undefined
nonFoodMessage: !isFoodDetected ? (nonFoodMessage || msgs.uploadFoodImage) : undefined
};
}
@@ -1025,4 +1091,15 @@ export class DietAnalysisService {
if (isNaN(num)) return undefined;
return Math.max(min, Math.min(max, num));
}
/**
* 获取多语言消息
*/
private getMessages(language: string) {
let langCode = 'zh-CN';
if (language.toLowerCase().startsWith('en')) {
langCode = 'en-US';
}
return MESSAGES[langCode] || MESSAGES['zh-CN'];
}
}

View File

@@ -1,11 +1,14 @@
import { Module } from "@nestjs/common";
import { APP_GUARD } from '@nestjs/core';
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { DatabaseModule } from "./database/database.module";
import { UsersModule } from "./users/users.module";
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { LoggerModule } from './common/logger/logger.module';
import { RedisModule, ThrottlerStorageRedisService } from './redis';
import { CheckinsModule } from './checkins/checkins.module';
import { AiCoachModule } from './ai-coach/ai-coach.module';
import { TrainingPlansModule } from './training-plans/training-plans.module';
@@ -13,7 +16,6 @@ import { ArticlesModule } from './articles/articles.module';
import { RecommendationsModule } from './recommendations/recommendations.module';
import { ActivityLogsModule } from './activity-logs/activity-logs.module';
import { ExercisesModule } from './exercises/exercises.module';
import { WorkoutsModule } from './workouts/workouts.module';
import { MoodCheckinsModule } from './mood-checkins/mood-checkins.module';
import { GoalsModule } from './goals/goals.module';
import { DietRecordsModule } from './diet-records/diet-records.module';
@@ -22,6 +24,8 @@ import { WaterRecordsModule } from './water-records/water-records.module';
import { ChallengesModule } from './challenges/challenges.module';
import { PushNotificationsModule } from './push-notifications/push-notifications.module';
import { MedicationsModule } from './medications/medications.module';
import { HealthProfilesModule } from './health-profiles/health-profiles.module';
import { ExpoUpdatesModule } from './expo-updates/expo-updates.module';
@Module({
imports: [
@@ -30,6 +34,18 @@ import { MedicationsModule } from './medications/medications.module';
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
// 限流模块必须在 RedisModule 之后导入,以确保 Redis 连接可用
RedisModule,
ThrottlerModule.forRootAsync({
useFactory: (throttlerStorage: ThrottlerStorageRedisService) => ({
throttlers: [{
ttl: 60000, // 时间窗口60秒
limit: 100, // 每个时间窗口最多100个请求
}],
storage: throttlerStorage,
}),
inject: [ThrottlerStorageRedisService],
}),
LoggerModule,
DatabaseModule,
UsersModule,
@@ -40,7 +56,6 @@ import { MedicationsModule } from './medications/medications.module';
RecommendationsModule,
ActivityLogsModule,
ExercisesModule,
WorkoutsModule,
MoodCheckinsModule,
GoalsModule,
DietRecordsModule,
@@ -49,8 +64,16 @@ import { MedicationsModule } from './medications/medications.module';
ChallengesModule,
PushNotificationsModule,
MedicationsModule,
HealthProfilesModule,
ExpoUpdatesModule,
],
controllers: [AppController],
providers: [AppService],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule { }

View File

@@ -38,6 +38,7 @@ export class ChallengesController {
@Get(':id')
@Public()
@UseGuards(JwtAuthGuard)
async getChallengeDetail(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { ChallengesController } from './challenges.controller';
import { ChallengesService } from './challenges.service';
@@ -12,7 +12,7 @@ import { BadgeConfig } from '../users/models/badge-config.model';
@Module({
imports: [
SequelizeModule.forFeature([Challenge, ChallengeParticipant, ChallengeProgressReport, User, BadgeConfig]),
UsersModule,
forwardRef(() => UsersModule),
],
controllers: [ChallengesController],
providers: [ChallengesService],

View File

@@ -48,7 +48,7 @@ export class ChallengesService {
const joinedChallengeIds = await this.getJoinedCustomChallengeIds(userId);
whereConditions.push(
{ creatorId: userId, source: ChallengeSource.CUSTOM }, // 我创建的
{ creatorId: userId, source: ChallengeSource.CUSTOM, challengeState: { [Op.ne]: ChallengeState.ARCHIVED } }, // 我创建的
{
id: { [Op.in]: joinedChallengeIds },
source: ChallengeSource.CUSTOM,
@@ -60,6 +60,7 @@ export class ChallengesService {
const challenges = await this.challengeModel.findAll({
where: {
[Op.or]: whereConditions,
challengeState: { [Op.ne]: ChallengeState.ARCHIVED }, // 过滤掉已归档的挑战
},
order: [['startAt', 'ASC']],
});
@@ -163,7 +164,7 @@ export class ChallengesService {
image: challenge.image,
periodLabel: challenge.periodLabel,
durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel,
requirementLabel: challenge.requirementLabel || '',
status,
unit: challenge.progressUnit,
startAt: new Date(challenge.startAt).getTime(),
@@ -178,6 +179,9 @@ export class ChallengesService {
isJoined: Boolean(participation),
type: challenge.type,
badge: challenge.type === ChallengeType.SLEEP ? sleepBadge : undefined,
source: challenge.source,
shareCode: challenge.shareCode,
isCreator: userId ? challenge.creatorId === userId : false,
};
});
}
@@ -189,6 +193,10 @@ export class ChallengesService {
throw new NotFoundException('挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('挑战不存在');
}
this.winstonLogger.info('start get detail', {
context: 'getChallengeDetail',
userId,
@@ -270,7 +278,7 @@ export class ChallengesService {
image: challenge.image,
periodLabel: challenge.periodLabel,
durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel,
requirementLabel: challenge.requirementLabel || '',
summary: challenge.summary,
rankingDescription: challenge.rankingDescription,
highlightTitle: challenge.highlightTitle,
@@ -284,6 +292,10 @@ export class ChallengesService {
unit: challenge.progressUnit,
type: challenge.type,
badge,
creatorId: challenge.creatorId,
shareCode: challenge.shareCode,
source: challenge.source,
isCreator: userId ? challenge.creatorId === userId : false,
};
}
@@ -297,6 +309,10 @@ export class ChallengesService {
throw new NotFoundException('挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('挑战不存在');
}
const { userId } = params;
const page = params.page && params.page > 0 ? params.page : 1;
const requestedPageSize = params.pageSize && params.pageSize > 0 ? params.pageSize : 20;
@@ -337,6 +353,10 @@ export class ChallengesService {
throw new NotFoundException('挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('挑战不存在');
}
const status = this.computeStatus(challenge.startAt, challenge.endAt);
if (status === ChallengeStatus.EXPIRED) {
throw new BadRequestException('挑战已过期,无法加入');
@@ -387,6 +407,15 @@ export class ChallengesService {
}
async leaveChallenge(userId: string, challengeId: string): Promise<boolean> {
// 先检查挑战是否存在且未归档
const challenge = await this.challengeModel.findByPk(challengeId);
if (!challenge) {
throw new NotFoundException('挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('挑战不存在');
}
const participant = await this.participantModel.findOne({
where: {
challengeId,
@@ -416,6 +445,10 @@ export class ChallengesService {
throw new NotFoundException('挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('挑战不存在');
}
const status = this.computeStatus(challenge.startAt, challenge.endAt);
if (status === ChallengeStatus.UPCOMING) {
throw new BadRequestException('挑战尚未开始,无法上报进度');
@@ -755,6 +788,37 @@ export class ChallengesService {
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 列表
*/
@@ -773,14 +837,6 @@ export class ChallengesService {
return participants.map(p => p.challengeId);
}
/**
* 检查用户是否为挑战创建者
*/
private async isCreator(userId: string, challengeId: string): Promise<boolean> {
const challenge = await this.challengeModel.findByPk(challengeId);
return challenge?.source === ChallengeSource.CUSTOM && challenge.creatorId === userId;
}
/**
* 检查挑战是否可以加入
*/
@@ -831,6 +887,29 @@ export class ChallengesService {
throw new BadRequestException('无效的时间戳');
}
// 获取用户信息,检查会员状态
const user = await User.findByPk(userId);
if (!user) {
throw new NotFoundException('用户不存在');
}
// 检查非会员用户已创建的未归档挑战数量
if (!user.isVip) {
const existingChallengeCount = await this.challengeModel.count({
where: {
creatorId: userId,
source: ChallengeSource.CUSTOM,
challengeState: {
[Op.ne]: ChallengeState.ARCHIVED, // 不包含已归档的挑战
},
},
});
if (existingChallengeCount >= 1) {
throw new BadRequestException('非会员用户只能创建一个挑战,您可以先归档现有挑战或升级会员');
}
}
// 检查创建频率限制(每天最多创建 5 个)
const recentCount = await this.challengeModel.count({
where: {
@@ -883,6 +962,8 @@ export class ChallengesService {
userId,
challengeId: challenge.id,
shareCode,
isVip: user.isVip,
existingChallengeCount: user.isVip ? null : 1,
});
return this.buildCustomChallengeResponse(challenge, userId);
@@ -903,6 +984,10 @@ export class ChallengesService {
throw new NotFoundException('分享码无效或挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('分享码无效或挑战不存在');
}
// 检查是否可以加入
const { canJoin, reason } = await this.canJoinChallenge(challenge);
if (!canJoin) {
@@ -928,6 +1013,10 @@ export class ChallengesService {
throw new NotFoundException('分享码无效或挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('分享码无效或挑战不存在');
}
return this.getChallengeDetail(challenge.id, userId);
}
@@ -945,6 +1034,10 @@ export class ChallengesService {
throw new NotFoundException('挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('挑战不存在');
}
if (challenge.source !== ChallengeSource.CUSTOM) {
throw new BadRequestException('只能编辑自定义挑战');
}
@@ -963,6 +1056,12 @@ export class ChallengesService {
'highlightTitle',
'highlightSubtitle',
'ctaLabel',
'title',
'summary',
'maxParticipants',
'highlightSubtitle',
'highlightTitle',
'image'
];
const restrictedFields = Object.keys(dto).filter(
@@ -970,7 +1069,8 @@ export class ChallengesService {
);
if (restrictedFields.length > 0) {
throw new BadRequestException('挑战已开始,只能编辑概要、公开性和展示文案');
const allowedFieldsDescription = '概要(summary)、公开性(isPublic)、展示文案(highlightTitle、highlightSubtitle、ctaLabel)、标题(title)、图片(image)和最大参与人数(maxParticipants)';
throw new BadRequestException(`挑战已开始,只能编辑部分字段。可编辑的字段包括:${allowedFieldsDescription}。您尝试编辑的字段:${restrictedFields.join('、')} 不在允许范围内。`);
}
}
@@ -1026,6 +1126,10 @@ export class ChallengesService {
throw new NotFoundException('挑战不存在');
}
if (challenge.challengeState === ChallengeState.ARCHIVED) {
throw new NotFoundException('挑战不存在');
}
if (challenge.source !== ChallengeSource.CUSTOM) {
throw new BadRequestException('只能为自定义挑战重新生成分享码');
}
@@ -1132,7 +1236,7 @@ export class ChallengesService {
endAt: new Date(challenge.endAt).getTime(),
periodLabel: challenge.periodLabel,
durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel,
requirementLabel: challenge.requirementLabel || '',
summary: challenge.summary,
targetValue: challenge.targetValue,
progressUnit: challenge.progressUnit,

View File

@@ -1,5 +1,5 @@
import { ChallengeProgressDto, RankingItemDto } from './challenge-progress.dto';
import { ChallengeType } from '../models/challenge.model';
import { ChallengeSource, ChallengeType } from '../models/challenge.model';
export interface BadgeInfoDto {
code: string;
@@ -29,4 +29,8 @@ export interface ChallengeDetailDto {
type: ChallengeType;
unit: string;
badge?: BadgeInfoDto;
creatorId: string | null;
shareCode?: string | null;
source: ChallengeSource;
isCreator: boolean;
}

View File

@@ -1,4 +1,4 @@
import { ChallengeStatus, ChallengeType } from '../models/challenge.model';
import { ChallengeSource, ChallengeStatus, ChallengeType } from '../models/challenge.model';
import { ChallengeProgressDto } from './challenge-progress.dto';
export interface BadgeInfoDto {
@@ -30,6 +30,8 @@ export interface ChallengeListItemDto {
type: ChallengeType;
unit: string;
badge?: BadgeInfoDto;
source: ChallengeSource;
isCreator: boolean;
}
export interface ChallengeListResponseDto {

View File

@@ -22,12 +22,10 @@ export class CreateCustomChallengeDto {
@ApiProperty({ description: '开始时间戳(毫秒)', example: 1704067200000 })
@IsNumber()
@Min(Date.now())
startAt: number;
@ApiProperty({ description: '结束时间戳(毫秒)', example: 1705881600000 })
@IsNumber()
@Min(Date.now() + 86400000) // 至少未来 1 天
endAt: number;
@ApiProperty({ description: '每日目标值如喝水8杯', example: 8, minimum: 1, maximum: 1000 })
@@ -36,10 +34,10 @@ export class CreateCustomChallengeDto {
@Max(1000)
targetValue: number;
@ApiProperty({ description: '最少打卡天数', example: 21, minimum: 1, maximum: 365 })
@ApiProperty({ description: '最少打卡天数', example: 21, minimum: 1, maximum: 1000 })
@IsNumber()
@Min(1)
@Max(365)
@Max(1000)
minimumCheckInDays: number;
@ApiProperty({ description: '持续时间标签', example: '持续21天' })
@@ -50,7 +48,7 @@ export class CreateCustomChallengeDto {
@ApiProperty({ description: '挑战要求标签', example: '每日喝水8杯' })
@IsString()
@IsNotEmpty()
@IsOptional()
@MaxLength(255)
requirementLabel: string;
@@ -59,9 +57,8 @@ export class CreateCustomChallengeDto {
@IsOptional()
summary?: string;
@ApiProperty({ description: '进度单位', example: '天', required: false })
@ApiProperty({ description: '进度单位', example: '天', required: true })
@IsString()
@IsOptional()
@MaxLength(64)
progressUnit?: string;

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsOptional, IsBoolean, MaxLength, IsNumber, Min, Max } from 'class-validator';
import { IsString, IsOptional, IsBoolean, MaxLength, IsNumber, Min, Max, IsEnum } from 'class-validator';
export class UpdateCustomChallengeDto {
@ApiProperty({ description: '挑战标题', required: false })

View File

@@ -15,6 +15,7 @@ export enum ChallengeType {
MOOD = 'mood',
SLEEP = 'sleep',
WEIGHT = 'weight',
CUSTOM = 'custom',
}
export enum ChallengeSource {
@@ -84,10 +85,10 @@ export class Challenge extends Model {
@Column({
type: DataType.STRING(255),
allowNull: false,
allowNull: true,
comment: '挑战要求标签,例如「每日练习 1 次」',
})
declare requirementLabel: string;
declare requirementLabel?: string;
@Column({
type: DataType.TEXT,
@@ -112,7 +113,7 @@ export class Challenge extends Model {
declare progressUnit: string;
@Column({
type: DataType.INTEGER,
type: DataType.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: '最低打卡天数,用于判断挑战成功',
@@ -148,7 +149,7 @@ export class Challenge extends Model {
declare ctaLabel: string;
@Column({
type: DataType.ENUM('water', 'exercise', 'diet', 'mood', 'sleep', 'weight'),
type: DataType.ENUM('water', 'exercise', 'diet', 'mood', 'sleep', 'weight', 'custom'),
allowNull: false,
defaultValue: ChallengeType.WATER,
comment: '挑战类型',

View File

@@ -0,0 +1,12 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* 从请求头中获取应用版本号的装饰器
* 从 x-App-Version 请求头中提取版本信息
*/
export const AppVersion = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string | undefined => {
const request = ctx.switchToHttp().getRequest();
return request.headers['x-app-version'];
},
);

View File

@@ -22,7 +22,7 @@ import { ConfigService } from '@nestjs/config';
collate: 'utf8mb4_0900_ai_ci',
},
autoLoadModels: true,
synchronize: true,
synchronize: false,
}),
}),
],

View File

@@ -144,6 +144,7 @@ export class DietRecordsController {
): Promise<FoodRecognitionToDietRecordsResponseDto> {
this.logger.log(`识别食物转饮食记录 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
return this.dietRecordsService.recognizeFoodToDietRecords(
user.sub,
requestDto.imageUrl,
requestDto.mealType
);
@@ -164,6 +165,7 @@ export class DietRecordsController {
): Promise<FoodRecognitionResponseDto> {
this.logger.log(`识别食物 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
return this.dietRecordsService.recognizeFood(
user.sub,
requestDto.imageUrl,
requestDto.mealType
);

View File

@@ -12,7 +12,7 @@ import { AiCoachModule } from '../ai-coach/ai-coach.module';
@Module({
imports: [
SequelizeModule.forFeature([UserDietHistory, ActivityLog, NutritionAnalysisRecord]),
UsersModule,
forwardRef(() => UsersModule),
forwardRef(() => AiCoachModule),
],
controllers: [DietRecordsController],

View File

@@ -8,6 +8,7 @@ import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietR
import { DietRecordSource, MealType } from '../users/models/user-diet-history.model';
import { ResponseCode } from '../base.dto';
import { DietAnalysisService } from '../ai-coach/services/diet-analysis.service';
import { UsersService } from '../users/users.service';
@Injectable()
export class DietRecordsService {
@@ -21,6 +22,7 @@ export class DietRecordsService {
private readonly sequelize: Sequelize,
@Inject(forwardRef(() => DietAnalysisService))
private readonly dietAnalysisService: DietAnalysisService,
private readonly usersService: UsersService,
) { }
/**
@@ -296,14 +298,17 @@ export class DietRecordsService {
* @returns 食物识别结果转换为饮食记录格式
*/
async recognizeFoodToDietRecords(
userId: string,
imageUrl: string,
suggestedMealType?: MealType
): Promise<FoodRecognitionToDietRecordsResponseDto> {
try {
this.logger.log(`recognizeFoodToDietRecords - imageUrl: ${imageUrl}, suggestedMealType: ${suggestedMealType}`);
this.logger.log(`recognizeFoodToDietRecords - userId: ${userId}, imageUrl: ${imageUrl}, suggestedMealType: ${suggestedMealType}`);
const language = await this.usersService.getUserLanguage(userId);
// 调用 DietAnalysisService 进行食物识别
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl]);
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl], language);
// 将识别结果转换为 CreateDietRecordDto 格式
const dietRecords: CreateDietRecordDto[] = recognitionResult.items.map(item => ({
@@ -344,14 +349,17 @@ export class DietRecordsService {
* @returns 食物识别结果
*/
async recognizeFood(
userId: string,
imageUrl: string,
suggestedMealType?: MealType
): Promise<FoodRecognitionResponseDto> {
try {
this.logger.log(`recognizeFood - imageUrl: ${imageUrl}, suggestedMealType: ${suggestedMealType}`);
this.logger.log(`recognizeFood - userId: ${userId}, imageUrl: ${imageUrl}, suggestedMealType: ${suggestedMealType}`);
const language = await this.usersService.getUserLanguage(userId);
// 调用 DietAnalysisService 进行食物识别
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl]);
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl], language);
// 如果指定了建议的餐次类型,更新所有识别项的餐次类型
if (suggestedMealType) {

View File

@@ -0,0 +1,103 @@
import {
Controller,
Get,
Query,
Headers,
Res,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiHeader, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { Response } from 'express';
import * as FormData from 'form-data';
import { ExpoUpdatesService } from './expo-updates.service';
import { logger } from 'src/common/logger/winston.config';
@ApiTags('Expo Updates')
@Controller('expo-updates')
export class ExpoUpdatesController {
constructor(private readonly expoUpdatesService: ExpoUpdatesService) {}
@Get('manifest')
@ApiOperation({ summary: '获取 Expo 更新 manifest' })
@ApiHeader({ name: 'expo-platform', description: '平台类型 (ios/android)', required: false })
@ApiHeader({ name: 'expo-runtime-version', description: '运行时版本', required: false })
@ApiHeader({ name: 'expo-protocol-version', description: '协议版本 (0/1)', required: false })
@ApiHeader({ name: 'expo-current-update-id', description: '当前更新ID', required: false })
@ApiQuery({ name: 'platform', description: '平台类型', required: false })
@ApiQuery({ name: 'runtime-version', description: '运行时版本', required: false })
@ApiResponse({ status: 200, description: '返回更新 manifest' })
async getManifest(
@Headers('expo-platform') headerPlatform: string,
@Headers('expo-runtime-version') headerRuntimeVersion: string,
@Headers('expo-protocol-version') protocolVersionHeader: string,
@Headers('expo-current-update-id') currentUpdateId: string,
@Query('platform') queryPlatform: string,
@Query('runtime-version') queryRuntimeVersion: string,
@Res() res: Response,
) {
const protocolVersion = parseInt(protocolVersionHeader || '0', 10);
if (![0, 1].includes(protocolVersion)) {
throw new BadRequestException('Unsupported protocol version. Expected either 0 or 1.');
}
const platform = headerPlatform || queryPlatform;
if (platform !== 'ios' && platform !== 'android') {
throw new BadRequestException('Unsupported platform. Expected either ios or android.');
}
const runtimeVersion = headerRuntimeVersion || queryRuntimeVersion;
if (!runtimeVersion) {
throw new BadRequestException('No runtimeVersion provided.');
}
logger.info(`Getting manifest for platform: ${platform}, runtimeVersion: ${runtimeVersion}`);
const manifest = await this.expoUpdatesService.buildManifest(platform as 'ios' | 'android', runtimeVersion);
logger.info(`Manifest: ${JSON.stringify(manifest)}`);
// 已是最新版本
if (currentUpdateId === manifest.id && protocolVersion === 1) {
return this.sendNoUpdateAvailable(res);
}
// 使用 form-data 构建正确的 multipart 响应
const form = new FormData();
form.append('manifest', JSON.stringify(manifest), {
contentType: 'application/json',
header: {
'content-type': 'application/json; charset=utf-8',
},
});
form.append('extensions', JSON.stringify({ assetRequestHeaders: {} }), {
contentType: 'application/json',
});
res.statusCode = 200;
res.setHeader('expo-protocol-version', protocolVersion);
res.setHeader('expo-sfv-version', 0);
res.setHeader('cache-control', 'private, max-age=0');
res.setHeader('content-type', `multipart/mixed; boundary=${form.getBoundary()}`);
res.send(form.getBuffer());
}
private sendNoUpdateAvailable(res: Response) {
const form = new FormData();
const directive = this.expoUpdatesService.createNoUpdateAvailableDirective();
form.append('directive', JSON.stringify(directive), {
contentType: 'application/json',
header: {
'content-type': 'application/json; charset=utf-8',
},
});
res.statusCode = 200;
res.setHeader('expo-protocol-version', 1);
res.setHeader('expo-sfv-version', 0);
res.setHeader('cache-control', 'private, max-age=0');
res.setHeader('content-type', `multipart/mixed; boundary=${form.getBoundary()}`);
res.send(form.getBuffer());
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ExpoUpdatesController } from './expo-updates.controller';
import { ExpoUpdatesService } from './expo-updates.service';
@Module({
controllers: [ExpoUpdatesController],
providers: [ExpoUpdatesService],
exports: [ExpoUpdatesService],
})
export class ExpoUpdatesModule {}

View File

@@ -0,0 +1,271 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { logger } from 'src/common/logger/winston.config';
import axios from 'axios';
import * as crypto from 'crypto';
export interface AssetMetadata {
hash: string;
key: string;
contentType: string;
fileExtension?: string;
url: string;
}
export interface UpdateManifest {
id: string;
createdAt: string;
runtimeVersion: string;
assets: AssetMetadata[];
launchAsset: AssetMetadata;
metadata: Record<string, any>;
extra: {
expoClient?: Record<string, any>;
};
}
export interface NoUpdateAvailableDirective {
type: 'noUpdateAvailable';
}
interface MetadataFileAsset {
path: string;
ext: string;
}
interface MetadataFile {
version: number;
bundler: string;
fileMetadata: {
ios?: {
bundle: string;
assets: MetadataFileAsset[];
};
android?: {
bundle: string;
assets: MetadataFileAsset[];
};
};
}
// 缓存 metadata 数据
interface MetadataCache {
data: MetadataFile;
timestamp: number;
}
// 缓存 hash 数据
interface HashCache {
hash: string;
timestamp: number;
}
@Injectable()
export class ExpoUpdatesService {
private metadataCache: Map<string, MetadataCache> = new Map();
private hashCache: Map<string, HashCache> = new Map();
private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
constructor(private configService: ConfigService) {}
/**
* 从环境变量构建 manifest
*
* 环境变量配置:
* - EXPO_UPDATE_ID: 更新ID可选
* - EXPO_RUNTIME_VERSION: 运行时版本
* - EXPO_IOS_METADATA_URL: iOS metadata.json URL
* - EXPO_ANDROID_METADATA_URL: Android metadata.json URL
*/
async buildManifest(platform: 'ios' | 'android', runtimeVersion: string): Promise<UpdateManifest> {
const configRuntimeVersion = this.configService.get<string>('EXPO_RUNTIME_VERSION');
logger.info(`buildManifest: configRuntimeVersion=${configRuntimeVersion}, runtimeVersion=${runtimeVersion}`);
// 检查运行时版本是否匹配
if (configRuntimeVersion && configRuntimeVersion !== runtimeVersion) {
throw new BadRequestException(`No update available for runtime version: ${runtimeVersion}`);
}
const metadataUrl = platform === 'ios'
? this.configService.get<string>('EXPO_IOS_METADATA_URL')
: this.configService.get<string>('EXPO_ANDROID_METADATA_URL');
if (!metadataUrl) {
throw new BadRequestException(`No metadata URL configured for platform: ${platform}`);
}
// 获取 metadata.json 内容
const metadata = await this.fetchMetadata(metadataUrl);
const platformMetadata = metadata.fileMetadata[platform];
if (!platformMetadata) {
throw new BadRequestException(`No ${platform} metadata found in metadata.json`);
}
// 计算基础 URLmetadata.json 所在目录)
const baseUrl = metadataUrl.substring(0, metadataUrl.lastIndexOf('/') + 1);
// 构建 bundle URL 并计算真实 hash
const bundleUrl = baseUrl + platformMetadata.bundle;
const bundleHash = await this.calculateFileHash(bundleUrl);
// 构建 assets需要计算每个文件的真实 hash
const assets = await this.buildAssetsWithHash(platformMetadata.assets, baseUrl);
// ID 基于 bundle hash 生成,确保内容不变时 ID 固定
const updateId = this.configService.get<string>('EXPO_UPDATE_ID')
|| this.convertSHA256HashToUUID(bundleHash);
return {
id: updateId,
createdAt: new Date().toISOString(),
runtimeVersion: configRuntimeVersion || runtimeVersion,
launchAsset: {
hash: bundleHash,
key: 'bundle',
contentType: 'application/javascript',
url: bundleUrl,
},
assets,
metadata: {},
extra: {},
};
}
/**
* 获取 metadata.json 内容(带缓存)
*/
private async fetchMetadata(url: string): Promise<MetadataFile> {
const cached = this.metadataCache.get(url);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
logger.info(`Using cached metadata for ${url}`);
return cached.data;
}
logger.info(`Fetching metadata from ${url}`);
try {
const response = await axios.get<MetadataFile>(url, { timeout: 10000 });
const data = response.data;
// 缓存数据
this.metadataCache.set(url, {
data,
timestamp: Date.now(),
});
return data;
} catch (error) {
logger.error(`Failed to fetch metadata: ${error.message}`);
throw new BadRequestException(`Failed to fetch metadata from ${url}`);
}
}
/**
* 计算文件的 SHA-256 hashBase64URL 编码)
*/
private async calculateFileHash(url: string): Promise<string> {
// 检查缓存
const cacheKey = `hash:${url}`;
const cached = this.hashCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.hash;
}
try {
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
});
const hash = crypto.createHash('sha256').update(Buffer.from(response.data)).digest('base64url');
// 缓存 hash
this.hashCache.set(cacheKey, { hash, timestamp: Date.now() });
logger.debug(`Calculated hash for ${url}: ${hash}`);
return hash;
} catch (error) {
logger.error(`Failed to calculate hash for ${url}: ${error.message}`);
throw new BadRequestException(`Failed to fetch asset: ${url}`);
}
}
/**
* 构建 assets 列表(带真实 hash 计算)
*/
private async buildAssetsWithHash(assets: MetadataFileAsset[], baseUrl: string): Promise<AssetMetadata[]> {
// 去重:相同 path 的 asset 只保留一个
const uniqueAssets = new Map<string, MetadataFileAsset>();
for (const asset of assets) {
if (!uniqueAssets.has(asset.path)) {
uniqueAssets.set(asset.path, asset);
}
}
const assetList = Array.from(uniqueAssets.values());
logger.info(`Building ${assetList.length} unique assets`);
// 分批并行计算每批10个避免并发过多
const batchSize = 10;
const results: AssetMetadata[] = [];
for (let i = 0; i < assetList.length; i += batchSize) {
const batch = assetList.slice(i, i + batchSize);
const batchResults = await Promise.all(
batch.map(async (asset) => {
const url = baseUrl + asset.path;
const key = asset.path.split('/').pop() || ''; // 使用文件名作为 key
const hash = await this.calculateFileHash(url);
return {
hash,
key,
contentType: this.getContentType(asset.ext),
fileExtension: `.${asset.ext}`,
url,
};
})
);
results.push(...batchResults);
}
return results;
}
/**
* 根据扩展名获取 content type
*/
private getContentType(ext: string): string {
const contentTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
ttf: 'font/ttf',
otf: 'font/otf',
woff: 'font/woff',
woff2: 'font/woff2',
js: 'application/javascript',
json: 'application/json',
mp3: 'audio/mpeg',
mp4: 'video/mp4',
wav: 'audio/wav',
};
return contentTypes[ext.toLowerCase()] || 'application/octet-stream';
}
createNoUpdateAvailableDirective(): NoUpdateAvailableDirective {
return { type: 'noUpdateAvailable' };
}
/**
* 将 SHA-256 hash 转换为 UUID 格式
*/
private convertSHA256HashToUUID(hash: string): string {
// 将 base64url 转为 hex然后格式化为 UUID
const hex = Buffer.from(hash, 'base64url').toString('hex').slice(0, 32);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
}

View File

@@ -0,0 +1,3 @@
export * from './expo-updates.module';
export * from './expo-updates.service';
export * from './expo-updates.controller';

View File

@@ -0,0 +1,82 @@
/**
* 健康史推荐选项常量
* 用于前端展示和数据验证
*/
export const HEALTH_HISTORY_RECOMMENDATIONS = {
allergy: [
'penicillin', // 青霉素
'sulfonamides', // 磺胺类
'peanuts', // 花生
'seafood', // 海鲜
'pollen', // 花粉
'dustMites', // 尘螨
'alcohol', // 酒精
'mango', // 芒果
],
disease: [
'hypertension', // 高血压
'diabetes', // 糖尿病
'asthma', // 哮喘
'heartDisease', // 心脏病
'gastritis', // 胃炎
'migraine', // 偏头痛
],
surgery: [
'appendectomy', // 阑尾切除术
'cesareanSection', // 剖腹产
'tonsillectomy', // 扁桃体切除术
'fractureRepair', // 骨折复位术
'none', // 无
],
familyDisease: [
'hypertension', // 高血压
'diabetes', // 糖尿病
'cancer', // 癌症
'heartDisease', // 心脏病
'stroke', // 中风
'alzheimers', // 阿尔茨海默病
],
};
/**
* 健康异常检测规则
*/
export interface HealthAbnormalityRule {
indicatorName: string;
condition: 'gt' | 'lt' | 'eq' | 'range';
threshold: number | [number, number];
severity: 'info' | 'warning' | 'critical';
message: string;
}
export const ABNORMALITY_RULES: HealthAbnormalityRule[] = [
{
indicatorName: '收缩压',
condition: 'gt',
threshold: 140,
severity: 'warning',
message: '血压偏高,建议关注',
},
{
indicatorName: '舒张压',
condition: 'gt',
threshold: 90,
severity: 'warning',
message: '舒张压偏高,建议关注',
},
{
indicatorName: '血糖',
condition: 'gt',
threshold: 7.0,
severity: 'warning',
message: '血糖偏高,建议复查',
},
{
indicatorName: '总胆固醇',
condition: 'gt',
threshold: 5.2,
severity: 'info',
message: '胆固醇偏高,建议注意饮食',
},
];

View File

@@ -0,0 +1,182 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsBoolean, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { FamilyRole } from '../enums/health-profile.enum';
/**
* 创建家庭组请求 DTO
*/
export class CreateFamilyGroupDto {
@ApiPropertyOptional({ description: '家庭组名称', default: '我的家庭' })
@IsOptional()
@IsString()
name?: string;
}
/**
* 生成邀请码请求 DTO
*/
export class GenerateInviteCodeDto {
@ApiPropertyOptional({ description: '邀请码有效期(小时)', default: 24 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(168) // 最多7天
expiresInHours?: number = 24;
}
/**
* 加入家庭组请求 DTO
*/
export class JoinFamilyGroupDto {
@ApiProperty({ description: '邀请码' })
@IsString()
inviteCode: string;
}
/**
* 更新成员权限请求 DTO
*/
export class UpdateFamilyMemberDto {
@ApiPropertyOptional({ description: '是否可查看健康数据' })
@IsOptional()
@IsBoolean()
canViewHealthData?: boolean;
@ApiPropertyOptional({ description: '是否可管理健康数据' })
@IsOptional()
@IsBoolean()
canManageHealthData?: boolean;
@ApiPropertyOptional({ description: '是否接收异常提醒' })
@IsOptional()
@IsBoolean()
receiveAlerts?: boolean;
@ApiPropertyOptional({ description: '关系(如:配偶、父母、子女)' })
@IsOptional()
@IsString()
relationship?: string;
}
/**
* 家庭成员响应 DTO
*/
export class FamilyMemberResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
userId: string;
@ApiProperty()
userName: string;
@ApiPropertyOptional()
userAvatar?: string;
@ApiProperty({ enum: FamilyRole })
role: FamilyRole;
@ApiPropertyOptional()
relationship?: string;
@ApiProperty()
canViewHealthData: boolean;
@ApiProperty()
canManageHealthData: boolean;
@ApiProperty()
receiveAlerts: boolean;
@ApiProperty()
joinedAt: string;
}
/**
* 家庭组响应 DTO
*/
export class FamilyGroupResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
ownerId: string;
@ApiProperty()
name: string;
@ApiPropertyOptional()
inviteCode?: string;
@ApiPropertyOptional()
inviteCodeExpiresAt?: string;
@ApiProperty()
maxMembers: number;
@ApiProperty({ type: [FamilyMemberResponseDto] })
members: FamilyMemberResponseDto[];
@ApiProperty()
createdAt: string;
@ApiProperty()
updatedAt: string;
}
/**
* 获取家庭组响应 DTO
*/
export class GetFamilyGroupResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({ type: FamilyGroupResponseDto, nullable: true })
data: FamilyGroupResponseDto | null;
}
/**
* 邀请码响应 DTO
*/
export class FamilyInviteResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({
example: {
familyGroupId: 'uuid',
inviteCode: 'ABC123',
expiresAt: '2024-01-02T00:00:00Z',
qrCodeUrl: 'https://...',
},
})
data: {
familyGroupId: string;
inviteCode: string;
expiresAt: string;
qrCodeUrl: string;
};
}
/**
* 获取家庭成员列表响应 DTO
*/
export class GetFamilyMembersResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({ type: [FamilyMemberResponseDto] })
data: FamilyMemberResponseDto[];
}

View File

@@ -0,0 +1,151 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsString, IsOptional, IsArray, ValidateNested, IsEnum, IsDateString } from 'class-validator';
import { Type } from 'class-transformer';
/**
* 健康史详情项 DTO
*/
export class HealthHistoryItemDto {
@ApiPropertyOptional({ description: '已有项的ID更新时传入' })
@IsOptional()
@IsString()
id?: string;
@ApiProperty({ description: '名称(如:青霉素、高血压)' })
@IsString()
name: string;
@ApiPropertyOptional({ description: '确诊/发生日期YYYY-MM-DD' })
@IsOptional()
@IsDateString()
date?: string;
@ApiPropertyOptional({ description: '是否为推荐选项' })
@IsOptional()
@IsBoolean()
isRecommendation?: boolean;
@ApiPropertyOptional({ description: '备注' })
@IsOptional()
@IsString()
note?: string;
}
/**
* 更新健康史分类请求 DTO
*/
export class UpdateHealthHistoryDto {
@ApiProperty({ description: '是否有该类健康史', nullable: true })
@IsBoolean()
hasHistory: boolean;
@ApiProperty({ description: '健康史详情列表', type: [HealthHistoryItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => HealthHistoryItemDto)
items: HealthHistoryItemDto[];
}
/**
* 健康史详情项响应
*/
export class HealthHistoryItemResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiPropertyOptional()
date?: string;
@ApiPropertyOptional()
isRecommendation?: boolean;
@ApiPropertyOptional()
note?: string;
}
/**
* 健康史分类响应
*/
export class HealthHistoryCategoryResponseDto {
@ApiProperty({ nullable: true })
hasHistory: boolean | null;
@ApiProperty({ type: [HealthHistoryItemResponseDto] })
items: HealthHistoryItemResponseDto[];
}
/**
* 获取健康史响应
*/
export class GetHealthHistoryResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({
description: '按分类组织的健康史数据',
example: {
allergy: { hasHistory: true, items: [] },
disease: { hasHistory: false, items: [] },
surgery: { hasHistory: null, items: [] },
familyDisease: { hasHistory: true, items: [] },
},
})
data: {
allergy: HealthHistoryCategoryResponseDto;
disease: HealthHistoryCategoryResponseDto;
surgery: HealthHistoryCategoryResponseDto;
familyDisease: HealthHistoryCategoryResponseDto;
};
}
/**
* 更新健康史分类响应
*/
export class UpdateHealthHistoryCategoryResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({ type: HealthHistoryCategoryResponseDto })
data: HealthHistoryCategoryResponseDto;
}
/**
* 健康史完成度响应
*/
export class HealthHistoryProgressResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({
example: {
progress: 75,
details: {
allergy: true,
disease: true,
surgery: true,
familyDisease: false,
},
},
})
data: {
progress: number;
details: {
allergy: boolean;
disease: boolean;
surgery: boolean;
familyDisease: boolean;
};
};
}

View File

@@ -0,0 +1,81 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* 基础信息概览
*/
export class BasicInfoOverviewDto {
@ApiProperty({ description: '完成度百分比' })
progress: number;
@ApiProperty({
description: '基础数据',
example: {
height: '175',
weight: '70',
bmi: '22.9',
waistCircumference: 80,
},
})
data: {
height?: string;
weight?: string;
bmi?: string;
waistCircumference?: number;
};
}
/**
* 健康史概览
*/
export class HealthHistoryOverviewDto {
@ApiProperty({ description: '完成度百分比' })
progress: number;
@ApiProperty({ description: '已回答的分类', type: [String] })
answeredCategories: string[];
@ApiProperty({ description: '待回答的分类', type: [String] })
pendingCategories: string[];
}
/**
* 药物管理概览
*/
export class MedicationsOverviewDto {
@ApiProperty({ description: '当前用药数量' })
activeCount: number;
@ApiProperty({ description: '今日服药完成率' })
todayCompletionRate: number;
}
/**
* 健康档案概览响应 DTO
*/
export class GetHealthOverviewResponseDto {
@ApiProperty()
code: number;
@ApiProperty()
message: string;
@ApiProperty({
example: {
basicInfo: {
progress: 100,
data: { height: '175', weight: '70', bmi: '22.9', waistCircumference: 80 },
},
healthHistory: {
progress: 75,
answeredCategories: ['allergy', 'disease', 'surgery'],
pendingCategories: ['familyDisease'],
},
medications: { activeCount: 3, todayCompletionRate: 66.7 },
},
})
data: {
basicInfo: BasicInfoOverviewDto;
healthHistory: HealthHistoryOverviewDto;
medications: MedicationsOverviewDto;
};
}

View File

@@ -0,0 +1,4 @@
export * from './health-history.dto';
export * from './family-health.dto';
export * from './health-overview.dto';
export * from './medical-records.dto';

View File

@@ -0,0 +1,181 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsEnum,
IsArray,
IsOptional,
IsDateString,
MaxLength,
MinLength,
ArrayMinSize,
ArrayMaxSize,
IsUrl,
} from 'class-validator';
import { MedicalRecordType, UploadFileType } from '../enums/health-profile.enum';
import { ResponseCode } from '../../base.dto';
/**
* 就医资料条目
*/
export class MedicalRecordItemDto {
@ApiProperty({ description: '唯一标识符', example: 'rec_1234567890' })
id: string;
@ApiProperty({
description: '资料类型',
enum: MedicalRecordType,
example: MedicalRecordType.MEDICAL_RECORD,
})
type: MedicalRecordType;
@ApiProperty({ description: '标题', example: '血常规检查' })
title: string;
@ApiProperty({ description: '日期 (YYYY-MM-DD)', example: '2024-12-01' })
date: string;
@ApiProperty({
description: '图片/文件 URL 数组',
type: [String],
example: ['https://cdn.example.com/images/blood_test_1.jpg'],
})
images: string[];
@ApiPropertyOptional({ description: '备注', example: '空腹检查结果' })
note?: string;
@ApiProperty({ description: '创建时间', example: '2024-12-01T08:30:00Z' })
createdAt: string;
@ApiProperty({ description: '更新时间', example: '2024-12-01T08:30:00Z' })
updatedAt: string;
}
/**
* 就医资料数据集
*/
export class MedicalRecordsDataDto {
@ApiProperty({ description: '病历资料列表', type: [MedicalRecordItemDto] })
records: MedicalRecordItemDto[];
@ApiProperty({ description: '处方单据列表', type: [MedicalRecordItemDto] })
prescriptions: MedicalRecordItemDto[];
}
/**
* 获取就医资料列表响应
*/
export class GetMedicalRecordsResponseDto {
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
code: number;
@ApiProperty({ description: '响应消息', example: 'success' })
message: string;
@ApiProperty({ description: '就医资料数据', type: MedicalRecordsDataDto })
data: MedicalRecordsDataDto;
}
/**
* 添加就医资料请求
*/
export class CreateMedicalRecordDto {
@ApiProperty({
description: '资料类型',
enum: MedicalRecordType,
example: MedicalRecordType.MEDICAL_RECORD,
})
@IsEnum(MedicalRecordType, { message: '资料类型必须是 medical_record 或 prescription' })
type: MedicalRecordType;
@ApiProperty({ description: '标题最多100字符', example: '胸部X光检查' })
@IsString({ message: '标题必须是字符串' })
@MinLength(1, { message: '标题不能为空' })
@MaxLength(100, { message: '标题最多100字符' })
title: string;
@ApiProperty({ description: '日期格式YYYY-MM-DD不能是未来日期', example: '2024-12-05' })
@IsDateString({}, { message: '日期格式必须是 YYYY-MM-DD' })
date: string;
@ApiProperty({
description: '图片URL数组至少1张最多9张',
type: [String],
example: ['https://cdn.example.com/uploads/temp/xray_001.jpg'],
})
@IsArray({ message: '图片必须是数组' })
@ArrayMinSize(1, { message: '至少需要上传一张图片' })
@ArrayMaxSize(9, { message: '最多支持9张图片' })
@IsUrl({}, { each: true, message: '图片URL格式不正确' })
images: string[];
@ApiPropertyOptional({ description: '备注最多500字符', example: '体检常规项目' })
@IsOptional()
@IsString({ message: '备注必须是字符串' })
@MaxLength(500, { message: '备注最多500字符' })
note?: string;
}
/**
* 添加就医资料响应
*/
export class CreateMedicalRecordResponseDto {
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
code: number;
@ApiProperty({ description: '响应消息', example: '添加成功' })
message: string;
@ApiProperty({ description: '新创建的就医资料', type: MedicalRecordItemDto })
data: MedicalRecordItemDto;
}
/**
* 删除就医资料响应
*/
export class DeleteMedicalRecordResponseDto {
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
code: number;
@ApiProperty({ description: '响应消息', example: '删除成功' })
message: string;
}
/**
* 上传图片请求
*/
export class UploadMedicalFilesDto {
@ApiProperty({
description: '上传类型',
enum: UploadFileType,
example: UploadFileType.IMAGE,
})
@IsEnum(UploadFileType, { message: '上传类型必须是 image 或 document' })
type: UploadFileType;
}
/**
* 上传图片响应数据
*/
export class UploadMedicalFilesDataDto {
@ApiProperty({
description: '上传成功的文件URL列表',
type: [String],
example: ['https://cdn.example.com/uploads/temp/file_001.jpg'],
})
urls: string[];
}
/**
* 上传图片响应
*/
export class UploadMedicalFilesResponseDto {
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
code: number;
@ApiProperty({ description: '响应消息', example: '上传成功' })
message: string;
@ApiProperty({ description: '上传结果', type: UploadMedicalFilesDataDto })
data: UploadMedicalFilesDataDto;
}

View File

@@ -0,0 +1,30 @@
/**
* 健康档案相关枚举定义
*/
// 健康史分类
export enum HealthHistoryCategory {
ALLERGY = 'allergy', // 过敏史
DISEASE = 'disease', // 疾病史
SURGERY = 'surgery', // 手术史
FAMILY_DISEASE = 'familyDisease', // 家族疾病史
}
// 家庭成员角色
export enum FamilyRole {
OWNER = 'owner', // 创建者
ADMIN = 'admin', // 管理员
MEMBER = 'member', // 普通成员
}
// 就医资料类型
export enum MedicalRecordType {
MEDICAL_RECORD = 'medical_record', // 病历资料(检查报告、诊断证明等)
PRESCRIPTION = 'prescription', // 处方单据(处方单、用药清单等)
}
// 上传文件类型
export enum UploadFileType {
IMAGE = 'image', // 图片
DOCUMENT = 'document', // 文档PDF
}

View File

@@ -0,0 +1,241 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
UseGuards,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiParam } from '@nestjs/swagger';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
import { ResponseCode } from '../base.dto';
// Services
import { HealthProfilesService } from './health-profiles.service';
import { HealthHistoryService } from './services/health-history.service';
import { FamilyHealthService } from './services/family-health.service';
import { MedicalRecordsService } from './services/medical-records.service';
// DTOs
import {
UpdateHealthHistoryDto,
GetHealthHistoryResponseDto,
UpdateHealthHistoryCategoryResponseDto,
HealthHistoryProgressResponseDto,
} from './dto/health-history.dto';
import {
GenerateInviteCodeDto,
JoinFamilyGroupDto,
UpdateFamilyMemberDto,
GetFamilyGroupResponseDto,
FamilyInviteResponseDto,
GetFamilyMembersResponseDto,
} from './dto/family-health.dto';
import { GetHealthOverviewResponseDto } from './dto/health-overview.dto';
import {
CreateMedicalRecordDto,
GetMedicalRecordsResponseDto,
CreateMedicalRecordResponseDto,
DeleteMedicalRecordResponseDto,
} from './dto/medical-records.dto';
import { HealthHistoryCategory } from './enums/health-profile.enum';
@ApiTags('health-profiles')
@Controller('health-profiles')
@UseGuards(JwtAuthGuard)
export class HealthProfilesController {
private readonly logger = new Logger(HealthProfilesController.name);
constructor(
private readonly healthProfilesService: HealthProfilesService,
private readonly healthHistoryService: HealthHistoryService,
private readonly familyHealthService: FamilyHealthService,
private readonly medicalRecordsService: MedicalRecordsService,
) {}
// ==================== 健康档案概览 ====================
@Get('overview')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取健康档案概览' })
@ApiResponse({ status: 200, type: GetHealthOverviewResponseDto })
async getHealthOverview(@CurrentUser() user: AccessTokenPayload): Promise<GetHealthOverviewResponseDto> {
this.logger.log(`获取健康档案概览 - 用户ID: ${user.sub}`);
return this.healthProfilesService.getHealthOverview(user.sub);
}
// ==================== 健康史 API ====================
@Get('history')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取用户健康史' })
@ApiResponse({ status: 200, type: GetHealthHistoryResponseDto })
async getHealthHistory(@CurrentUser() user: AccessTokenPayload): Promise<GetHealthHistoryResponseDto> {
this.logger.log(`获取健康史 - 用户ID: ${user.sub}`);
const data = await this.healthHistoryService.getHealthHistory(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Put('history/:category')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新健康史分类' })
@ApiParam({ name: 'category', enum: HealthHistoryCategory, description: '健康史分类' })
@ApiBody({ type: UpdateHealthHistoryDto })
@ApiResponse({ status: 200, type: UpdateHealthHistoryCategoryResponseDto })
async updateHealthHistoryCategory(
@Param('category') category: HealthHistoryCategory,
@Body() updateDto: UpdateHealthHistoryDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<UpdateHealthHistoryCategoryResponseDto> {
this.logger.log(`更新健康史分类 - 用户ID: ${user.sub}, 分类: ${category}`);
const data = await this.healthHistoryService.updateHealthHistoryCategory(user.sub, category, updateDto);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Get('history/progress')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取健康史完成度' })
@ApiResponse({ status: 200, type: HealthHistoryProgressResponseDto })
async getHealthHistoryProgress(@CurrentUser() user: AccessTokenPayload): Promise<HealthHistoryProgressResponseDto> {
this.logger.log(`获取健康史完成度 - 用户ID: ${user.sub}`);
const data = await this.healthHistoryService.getHealthHistoryProgress(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
// ==================== 家庭健康管理 API ====================
@Get('family/group')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取家庭组' })
@ApiResponse({ status: 200, type: GetFamilyGroupResponseDto })
async getFamilyGroup(@CurrentUser() user: AccessTokenPayload): Promise<GetFamilyGroupResponseDto> {
this.logger.log(`获取家庭组 - 用户ID: ${user.sub}`);
const data = await this.familyHealthService.getFamilyGroup(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Post('family/group/invite')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '生成邀请码' })
@ApiBody({ type: GenerateInviteCodeDto })
@ApiResponse({ status: 200, type: FamilyInviteResponseDto })
async generateInviteCode(
@Body() dto: GenerateInviteCodeDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<FamilyInviteResponseDto> {
this.logger.log(`生成邀请码 - 用户ID: ${user.sub}`);
const data = await this.familyHealthService.generateInviteCode(user.sub, dto);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Post('family/group/join')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '加入家庭组' })
@ApiBody({ type: JoinFamilyGroupDto })
@ApiResponse({ status: 200, type: GetFamilyGroupResponseDto })
async joinFamilyGroup(
@Body() dto: JoinFamilyGroupDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<GetFamilyGroupResponseDto> {
this.logger.log(`加入家庭组 - 用户ID: ${user.sub}`);
const data = await this.familyHealthService.joinFamilyGroup(user.sub, dto);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Get('family/members')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取家庭成员列表' })
@ApiResponse({ status: 200, type: GetFamilyMembersResponseDto })
async getFamilyMembers(@CurrentUser() user: AccessTokenPayload): Promise<GetFamilyMembersResponseDto> {
this.logger.log(`获取家庭成员列表 - 用户ID: ${user.sub}`);
const data = await this.familyHealthService.getFamilyMembers(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Put('family/members/:memberId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新成员权限' })
@ApiParam({ name: 'memberId', description: '成员ID' })
@ApiBody({ type: UpdateFamilyMemberDto })
async updateFamilyMember(
@Param('memberId') memberId: string,
@Body() updateDto: UpdateFamilyMemberDto,
@CurrentUser() user: AccessTokenPayload,
) {
this.logger.log(`更新成员权限 - 用户ID: ${user.sub}, 成员ID: ${memberId}`);
const data = await this.familyHealthService.updateFamilyMember(user.sub, memberId, updateDto);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Delete('family/members/:memberId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '移除家庭成员' })
@ApiParam({ name: 'memberId', description: '成员ID' })
async removeFamilyMember(
@Param('memberId') memberId: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<{ code: number; message: string }> {
this.logger.log(`移除家庭成员 - 用户ID: ${user.sub}, 成员ID: ${memberId}`);
await this.familyHealthService.removeFamilyMember(user.sub, memberId);
return { code: ResponseCode.SUCCESS, message: 'success' };
}
@Post('family/leave')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '退出家庭组' })
async leaveFamilyGroup(@CurrentUser() user: AccessTokenPayload): Promise<{ code: number; message: string }> {
this.logger.log(`退出家庭组 - 用户ID: ${user.sub}`);
await this.familyHealthService.leaveFamilyGroup(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success' };
}
// ==================== 就医资料管理 API ====================
@Get('medical-records')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取就医资料列表' })
@ApiResponse({ status: 200, type: GetMedicalRecordsResponseDto })
async getMedicalRecords(@CurrentUser() user: AccessTokenPayload): Promise<GetMedicalRecordsResponseDto> {
this.logger.log(`获取就医资料列表 - 用户ID: ${user.sub}`);
const data = await this.medicalRecordsService.getMedicalRecords(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
@Post('medical-records')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '添加就医资料' })
@ApiBody({ type: CreateMedicalRecordDto })
@ApiResponse({ status: 201, type: CreateMedicalRecordResponseDto })
@ApiResponse({ status: 400, description: '请求参数错误' })
async createMedicalRecord(
@Body() dto: CreateMedicalRecordDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<CreateMedicalRecordResponseDto> {
this.logger.log(`添加就医资料 - 用户ID: ${user.sub}, 类型: ${dto.type}`);
const data = await this.medicalRecordsService.createMedicalRecord(user.sub, dto);
return { code: ResponseCode.SUCCESS, message: '添加成功', data };
}
@Delete('medical-records/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除就医资料' })
@ApiParam({ name: 'id', description: '资料记录ID' })
@ApiResponse({ status: 200, type: DeleteMedicalRecordResponseDto })
@ApiResponse({ status: 404, description: '资料不存在或已被删除' })
@ApiResponse({ status: 403, description: '无权限删除该资料' })
async deleteMedicalRecord(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<DeleteMedicalRecordResponseDto> {
this.logger.log(`删除就医资料 - 用户ID: ${user.sub}, 资料ID: ${id}`);
await this.medicalRecordsService.deleteMedicalRecord(user.sub, id);
return { code: ResponseCode.SUCCESS, message: '删除成功' };
}
}

View File

@@ -0,0 +1,58 @@
import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
// Models
import { HealthHistory } from './models/health-history.model';
import { HealthHistoryItem } from './models/health-history-item.model';
import { FamilyGroup } from './models/family-group.model';
import { FamilyMember } from './models/family-member.model';
import { MedicalRecord } from './models/medical-record.model';
// User models (for relations)
import { User } from '../users/models/user.model';
import { UserProfile } from '../users/models/user-profile.model';
// Controller
import { HealthProfilesController } from './health-profiles.controller';
// Services
import { HealthProfilesService } from './health-profiles.service';
import { HealthHistoryService } from './services/health-history.service';
import { FamilyHealthService } from './services/family-health.service';
import { MedicalRecordsService } from './services/medical-records.service';
// Modules
import { UsersModule } from '../users/users.module';
@Module({
imports: [
SequelizeModule.forFeature([
// Health History
HealthHistory,
HealthHistoryItem,
// Family Health
FamilyGroup,
FamilyMember,
// Medical Records
MedicalRecord,
// User models for relations
User,
UserProfile,
]),
forwardRef(() => UsersModule),
],
controllers: [HealthProfilesController],
providers: [
HealthProfilesService,
HealthHistoryService,
FamilyHealthService,
MedicalRecordsService,
],
exports: [
HealthProfilesService,
HealthHistoryService,
FamilyHealthService,
MedicalRecordsService,
],
})
export class HealthProfilesModule {}

View File

@@ -0,0 +1,115 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { UserProfile } from '../users/models/user-profile.model';
import { HealthHistoryService } from './services/health-history.service';
import { GetHealthOverviewResponseDto } from './dto/health-overview.dto';
import { ResponseCode } from '../base.dto';
@Injectable()
export class HealthProfilesService {
private readonly logger = new Logger(HealthProfilesService.name);
constructor(
@InjectModel(UserProfile)
private readonly userProfileModel: typeof UserProfile,
private readonly healthHistoryService: HealthHistoryService,
) {}
/**
* 获取健康档案概览
*/
async getHealthOverview(userId: string): Promise<GetHealthOverviewResponseDto> {
try {
// 1. 基础信息概览
const profile = await this.userProfileModel.findOne({
where: { userId },
});
const basicInfo = this.calculateBasicInfoOverview(profile);
// 2. 健康史概览
const healthHistoryProgress = await this.healthHistoryService.getHealthHistoryProgress(userId);
const healthHistory = {
progress: healthHistoryProgress.progress,
answeredCategories: Object.entries(healthHistoryProgress.details)
.filter(([_, answered]) => answered)
.map(([category]) => category),
pendingCategories: Object.entries(healthHistoryProgress.details)
.filter(([_, answered]) => !answered)
.map(([category]) => category),
};
// 3. 药物管理概览(需要从药物模块获取,这里先返回默认值)
// TODO: 注入 MedicationsService 获取实际数据
const medications = {
activeCount: 0,
todayCompletionRate: 0,
};
return {
code: ResponseCode.SUCCESS,
message: 'success',
data: {
basicInfo,
healthHistory,
medications,
},
};
} catch (error) {
this.logger.error(`获取健康档案概览失败: ${error instanceof Error ? error.message : '未知错误'}`);
return {
code: ResponseCode.ERROR,
message: `获取健康档案概览失败: ${error instanceof Error ? error.message : '未知错误'}`,
data: null as any,
};
}
}
/**
* 计算基础信息概览
*/
private calculateBasicInfoOverview(profile: UserProfile | null): {
progress: number;
data: {
height?: string;
weight?: string;
bmi?: string;
waistCircumference?: number;
};
} {
if (!profile) {
return { progress: 0, data: {} };
}
let filledCount = 0;
const totalFields = 3; // height, weight, waistCircumference
const data: any = {};
if (profile.height && profile.height > 0) {
filledCount++;
data.height = profile.height.toString();
}
if (profile.weight && profile.weight > 0) {
filledCount++;
data.weight = profile.weight.toString();
// 计算 BMI
if (profile.height && profile.height > 0) {
const heightInMeters = profile.height / 100;
const bmi = profile.weight / (heightInMeters * heightInMeters);
data.bmi = bmi.toFixed(1);
}
}
if (profile.waistCircumference && profile.waistCircumference > 0) {
filledCount++;
data.waistCircumference = profile.waistCircumference;
}
const progress = Math.round((filledCount / totalFields) * 100);
return { progress, data };
}
}

View File

@@ -0,0 +1,79 @@
import { Column, Model, Table, DataType, HasMany, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { User } from '../../users/models/user.model';
import { FamilyMember } from './family-member.model';
/**
* 家庭组表
*/
@Table({
tableName: 't_family_groups',
underscored: true,
})
export class FamilyGroup extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '家庭组ID',
})
declare id: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '创建者用户ID',
})
declare ownerId: string;
@Column({
type: DataType.STRING(100),
allowNull: false,
defaultValue: '我的家庭',
comment: '家庭组名称',
})
declare name: string;
@Column({
type: DataType.STRING(20),
allowNull: true,
unique: true,
comment: '邀请码',
})
declare inviteCode: string | null;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '邀请码过期时间',
})
declare inviteCodeExpiresAt: Date | null;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 6,
comment: '最大成员数',
})
declare maxMembers: number;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '创建时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '更新时间',
})
declare updatedAt: Date;
// 关联关系
@BelongsTo(() => User, 'ownerId')
declare owner: User;
@HasMany(() => FamilyMember, 'familyGroupId')
declare members: FamilyMember[];
}

View File

@@ -0,0 +1,97 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { FamilyRole } from '../enums/health-profile.enum';
import { User } from '../../users/models/user.model';
import { FamilyGroup } from './family-group.model';
/**
* 家庭成员表
*/
@Table({
tableName: 't_family_members',
underscored: true,
indexes: [
{
unique: true,
fields: ['family_group_id', 'user_id'],
},
],
createdAt: false,
updatedAt: false,
})
export class FamilyMember extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '成员记录ID',
})
declare id: string;
@ForeignKey(() => FamilyGroup)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '家庭组ID',
})
declare familyGroupId: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING(20),
allowNull: false,
defaultValue: FamilyRole.MEMBER,
comment: '角色owner | admin | member',
})
declare role: FamilyRole;
@Column({
type: DataType.STRING(50),
allowNull: true,
comment: '关系(如:配偶、父母、子女)',
})
declare relationship: string | null;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '是否可查看健康数据',
})
declare canViewHealthData: boolean;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否可管理健康数据',
})
declare canManageHealthData: boolean;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '是否接收异常提醒',
})
declare receiveAlerts: boolean;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '加入时间',
})
declare joinedAt: Date;
// 关联关系
@BelongsTo(() => FamilyGroup, 'familyGroupId')
declare familyGroup: FamilyGroup;
@BelongsTo(() => User, 'userId')
declare user: User;
}

View File

@@ -0,0 +1,86 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { User } from '../../users/models/user.model';
import { HealthHistory } from './health-history.model';
/**
* 健康史详情表
* 记录具体的过敏源、疾病、手术等信息
*/
@Table({
tableName: 't_health_history_items',
underscored: true,
})
export class HealthHistoryItem extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '详情ID',
})
declare id: string;
@ForeignKey(() => HealthHistory)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '关联的健康史ID',
})
declare healthHistoryId: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING(255),
allowNull: false,
comment: '名称(如:青霉素、高血压)',
})
declare name: string;
@Column({
type: DataType.DATEONLY,
allowNull: true,
comment: '确诊/发生日期',
})
declare diagnosisDate: string | null;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否为推荐选项',
})
declare isRecommendation: boolean;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '备注',
})
declare note: string | null;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '创建时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '更新时间',
})
declare updatedAt: Date;
// 关联关系
@BelongsTo(() => HealthHistory, 'healthHistoryId')
declare healthHistory: HealthHistory;
@BelongsTo(() => User, 'userId')
declare user: User;
}

View File

@@ -0,0 +1,64 @@
import { Column, Model, Table, DataType, HasMany, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { HealthHistoryCategory } from '../enums/health-profile.enum';
import { User } from '../../users/models/user.model';
import { HealthHistoryItem } from './health-history-item.model';
/**
* 健康史主表
* 记录用户各分类的健康史状态
*/
@Table({
tableName: 't_health_histories',
underscored: true,
})
export class HealthHistory extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '健康史记录ID',
})
declare id: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '健康史分类allergy | disease | surgery | familyDisease',
})
declare category: HealthHistoryCategory;
@Column({
type: DataType.BOOLEAN,
allowNull: true,
comment: '是否有该类健康史null=未回答, true=有, false=无',
})
declare hasHistory: boolean | null;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '创建时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '更新时间',
})
declare updatedAt: Date;
// 关联关系
@BelongsTo(() => User, 'userId')
declare user: User;
@HasMany(() => HealthHistoryItem, 'healthHistoryId')
declare items: HealthHistoryItem[];
}

View File

@@ -0,0 +1,10 @@
// Health History
export * from './health-history.model';
export * from './health-history-item.model';
// Family Health
export * from './family-group.model';
export * from './family-member.model';
// Medical Records
export * from './medical-record.model';

View File

@@ -0,0 +1,90 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { User } from '../../users/models/user.model';
import { MedicalRecordType } from '../enums/health-profile.enum';
/**
* 就医资料表
* 存储用户的病历资料和处方单据
*/
@Table({
tableName: 't_medical_records',
underscored: true,
paranoid: true, // 软删除
})
export class MedicalRecord extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '就医资料ID',
})
declare id: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.STRING(20),
allowNull: false,
comment: '资料类型medical_record | prescription',
})
declare type: MedicalRecordType;
@Column({
type: DataType.STRING(100),
allowNull: false,
comment: '标题',
})
declare title: string;
@Column({
type: DataType.DATEONLY,
allowNull: false,
comment: '日期 (YYYY-MM-DD)',
})
declare date: string;
@Column({
type: DataType.JSON,
allowNull: false,
defaultValue: [],
comment: '图片/文件 URL 数组',
})
declare images: string[];
@Column({
type: DataType.STRING(500),
allowNull: true,
comment: '备注',
})
declare note: string | null;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '创建时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '更新时间',
})
declare updatedAt: Date;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '删除时间(软删除)',
})
declare deletedAt: Date | null;
// 关联关系
@BelongsTo(() => User, 'userId')
declare user: User;
}

View File

@@ -0,0 +1,404 @@
import { Injectable, Logger, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
import { InjectModel, InjectConnection } from '@nestjs/sequelize';
import { Sequelize } from 'sequelize-typescript';
import { Op } from 'sequelize';
import { v4 as uuidv4 } from 'uuid';
import * as dayjs from 'dayjs';
import { FamilyGroup } from '../models/family-group.model';
import { FamilyMember } from '../models/family-member.model';
import { User } from '../../users/models/user.model';
import { FamilyRole } from '../enums/health-profile.enum';
import {
CreateFamilyGroupDto,
GenerateInviteCodeDto,
JoinFamilyGroupDto,
UpdateFamilyMemberDto,
FamilyGroupResponseDto,
FamilyMemberResponseDto,
} from '../dto/family-health.dto';
@Injectable()
export class FamilyHealthService {
private readonly logger = new Logger(FamilyHealthService.name);
constructor(
@InjectModel(FamilyGroup)
private readonly familyGroupModel: typeof FamilyGroup,
@InjectModel(FamilyMember)
private readonly familyMemberModel: typeof FamilyMember,
@InjectModel(User)
private readonly userModel: typeof User,
@InjectConnection()
private readonly sequelize: Sequelize,
) {}
/**
* 获取用户的家庭组
*/
async getFamilyGroup(userId: string): Promise<FamilyGroupResponseDto | null> {
// 先查找用户所属的家庭成员记录
const membership = await this.familyMemberModel.findOne({
where: { userId },
});
if (!membership) {
return null;
}
const familyGroup = await this.familyGroupModel.findOne({
where: { id: membership.familyGroupId },
include: [
{
model: FamilyMember,
as: 'members',
include: [{ model: User, as: 'user' }],
},
],
});
if (!familyGroup) {
return null;
}
return this.mapGroupToResponse(familyGroup);
}
/**
* 创建家庭组
*/
async createFamilyGroup(userId: string, createDto: CreateFamilyGroupDto): Promise<FamilyGroupResponseDto> {
// 检查用户是否已经有家庭组
const existingMembership = await this.familyMemberModel.findOne({
where: { userId },
});
if (existingMembership) {
throw new BadRequestException('您已经是一个家庭组的成员,请先退出当前家庭组');
}
const transaction = await this.sequelize.transaction();
try {
// 创建家庭组
const familyGroup = await this.familyGroupModel.create(
{
id: uuidv4(),
ownerId: userId,
name: createDto.name || '我的家庭',
},
{ transaction },
);
// 将创建者添加为 owner 成员
await this.familyMemberModel.create(
{
id: uuidv4(),
familyGroupId: familyGroup.id,
userId,
role: FamilyRole.OWNER,
canViewHealthData: true,
canManageHealthData: true,
receiveAlerts: true,
},
{ transaction },
);
await transaction.commit();
this.logger.log(`用户 ${userId} 创建家庭组 ${familyGroup.id} 成功`);
// 重新查询以获取完整数据
return this.getFamilyGroup(userId) as Promise<FamilyGroupResponseDto>;
} catch (error) {
await transaction.rollback();
this.logger.error(`创建家庭组失败: ${error instanceof Error ? error.message : '未知错误'}`);
throw error;
}
}
/**
* 获取或生成邀请码
* 如果用户没有家庭组,自动创建一个
* 如果已有有效邀请码,直接返回
*/
async generateInviteCode(
userId: string,
dto: GenerateInviteCodeDto,
): Promise<{ familyGroupId: string; inviteCode: string; expiresAt: string; qrCodeUrl: string }> {
let membership = await this.familyMemberModel.findOne({
where: { userId },
});
// 如果用户没有家庭组,自动创建一个
if (!membership) {
this.logger.log(`用户 ${userId} 没有家庭组,自动创建`);
await this.createFamilyGroup(userId, { name: '我的家庭' });
membership = await this.familyMemberModel.findOne({
where: { userId },
});
}
// 只有 owner 和 admin 可以生成邀请码
if (membership!.role === FamilyRole.MEMBER) {
throw new ForbiddenException('只有管理员可以生成邀请码');
}
const familyGroup = await this.familyGroupModel.findByPk(membership!.familyGroupId);
if (!familyGroup) {
throw new NotFoundException('家庭组不存在');
}
// 如果已有有效邀请码,直接返回
if (familyGroup.inviteCode && familyGroup.inviteCodeExpiresAt && dayjs(familyGroup.inviteCodeExpiresAt).isAfter(dayjs())) {
this.logger.log(`用户 ${userId} 获取家庭组 ${familyGroup.id} 的现有邀请码 ${familyGroup.inviteCode}`);
return {
familyGroupId: familyGroup.id,
inviteCode: familyGroup.inviteCode,
expiresAt: familyGroup.inviteCodeExpiresAt.toISOString(),
qrCodeUrl: `outlive://family/join?code=${familyGroup.inviteCode}`,
};
}
// 生成新邀请码
const inviteCode = this.generateUniqueInviteCode();
const expiresAt = dayjs().add(dto.expiresInHours || 24, 'hour').toDate();
familyGroup.inviteCode = inviteCode;
familyGroup.inviteCodeExpiresAt = expiresAt;
await familyGroup.save();
this.logger.log(`用户 ${userId} 为家庭组 ${familyGroup.id} 生成邀请码 ${inviteCode}`);
return {
familyGroupId: familyGroup.id,
inviteCode,
expiresAt: expiresAt.toISOString(),
qrCodeUrl: `outlive://family/join?code=${inviteCode}`,
};
}
/**
* 加入家庭组
*/
async joinFamilyGroup(userId: string, dto: JoinFamilyGroupDto): Promise<FamilyGroupResponseDto> {
// 检查用户是否已经有家庭组
const existingMembership = await this.familyMemberModel.findOne({
where: { userId },
});
if (existingMembership) {
throw new BadRequestException('您已经是一个家庭组的成员,请先退出当前家庭组');
}
// 查找邀请码对应的家庭组
const familyGroup = await this.familyGroupModel.findOne({
where: {
inviteCode: dto.inviteCode,
inviteCodeExpiresAt: { [Op.gt]: new Date() },
},
});
if (!familyGroup) {
throw new BadRequestException('邀请码无效或已过期');
}
// 检查成员数量
const memberCount = await this.familyMemberModel.count({
where: { familyGroupId: familyGroup.id },
});
if (memberCount >= familyGroup.maxMembers) {
throw new BadRequestException('家庭组已满员');
}
// 添加成员
await this.familyMemberModel.create({
id: uuidv4(),
familyGroupId: familyGroup.id,
userId,
role: FamilyRole.MEMBER,
canViewHealthData: true,
canManageHealthData: false,
receiveAlerts: true,
});
this.logger.log(`用户 ${userId} 加入家庭组 ${familyGroup.id}`);
return this.getFamilyGroup(userId) as Promise<FamilyGroupResponseDto>;
}
/**
* 获取家庭成员列表
*/
async getFamilyMembers(userId: string): Promise<FamilyMemberResponseDto[]> {
const membership = await this.familyMemberModel.findOne({
where: { userId },
});
if (!membership) {
throw new NotFoundException('您还没有家庭组');
}
const members = await this.familyMemberModel.findAll({
where: { familyGroupId: membership.familyGroupId },
include: [{ model: User, as: 'user' }],
});
return members.map(this.mapMemberToResponse);
}
/**
* 更新成员权限
*/
async updateFamilyMember(
userId: string,
memberId: string,
updateDto: UpdateFamilyMemberDto,
): Promise<FamilyMemberResponseDto> {
const currentMembership = await this.familyMemberModel.findOne({
where: { userId },
});
if (!currentMembership) {
throw new NotFoundException('您还没有家庭组');
}
// 只有 owner 和 admin 可以修改成员权限
if (currentMembership.role === FamilyRole.MEMBER) {
throw new ForbiddenException('只有管理员可以修改成员权限');
}
const targetMember = await this.familyMemberModel.findOne({
where: { id: memberId, familyGroupId: currentMembership.familyGroupId },
include: [{ model: User, as: 'user' }],
});
if (!targetMember) {
throw new NotFoundException('成员不存在');
}
// 不能修改 owner 的权限
if (targetMember.role === FamilyRole.OWNER && currentMembership.role !== FamilyRole.OWNER) {
throw new ForbiddenException('不能修改创建者的权限');
}
// 更新权限
if (updateDto.canViewHealthData !== undefined) targetMember.canViewHealthData = updateDto.canViewHealthData;
if (updateDto.canManageHealthData !== undefined) targetMember.canManageHealthData = updateDto.canManageHealthData;
if (updateDto.receiveAlerts !== undefined) targetMember.receiveAlerts = updateDto.receiveAlerts;
if (updateDto.relationship !== undefined) targetMember.relationship = updateDto.relationship;
await targetMember.save();
this.logger.log(`用户 ${userId} 更新成员 ${memberId} 的权限`);
return this.mapMemberToResponse(targetMember);
}
/**
* 移除家庭成员
*/
async removeFamilyMember(userId: string, memberId: string): Promise<void> {
const currentMembership = await this.familyMemberModel.findOne({
where: { userId },
});
if (!currentMembership) {
throw new NotFoundException('您还没有家庭组');
}
const targetMember = await this.familyMemberModel.findOne({
where: { id: memberId, familyGroupId: currentMembership.familyGroupId },
});
if (!targetMember) {
throw new NotFoundException('成员不存在');
}
// 不能移除 owner
if (targetMember.role === FamilyRole.OWNER) {
throw new ForbiddenException('不能移除创建者');
}
// 只有 owner 和 admin 可以移除成员,或者成员自己退出
if (
currentMembership.role === FamilyRole.MEMBER &&
currentMembership.id !== memberId
) {
throw new ForbiddenException('只有管理员可以移除成员');
}
await targetMember.destroy();
this.logger.log(`成员 ${memberId} 已从家庭组移除`);
}
/**
* 退出家庭组
*/
async leaveFamilyGroup(userId: string): Promise<void> {
const membership = await this.familyMemberModel.findOne({
where: { userId },
});
if (!membership) {
throw new NotFoundException('您还没有家庭组');
}
// owner 不能直接退出,需要先转让或解散
if (membership.role === FamilyRole.OWNER) {
throw new BadRequestException('创建者不能直接退出,请先转让管理权或解散家庭组');
}
await membership.destroy();
this.logger.log(`用户 ${userId} 退出家庭组`);
}
/**
* 生成唯一邀请码
*/
private generateUniqueInviteCode(): string {
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
let code = '';
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
/**
* 映射家庭组到响应 DTO
*/
private mapGroupToResponse(group: FamilyGroup): FamilyGroupResponseDto {
return {
id: group.id,
ownerId: group.ownerId,
name: group.name,
inviteCode: group.inviteCode || undefined,
inviteCodeExpiresAt: group.inviteCodeExpiresAt?.toISOString() || undefined,
maxMembers: group.maxMembers,
members: group.members?.map(this.mapMemberToResponse) || [],
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
};
}
/**
* 映射成员到响应 DTO
*/
private mapMemberToResponse(member: FamilyMember): FamilyMemberResponseDto {
return {
id: member.id,
userId: member.userId,
userName: member.user?.name || '未知用户',
userAvatar: member.user?.avatar || undefined,
role: member.role,
relationship: member.relationship || undefined,
canViewHealthData: member.canViewHealthData,
canManageHealthData: member.canManageHealthData,
receiveAlerts: member.receiveAlerts,
joinedAt: member.joinedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,222 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectModel, InjectConnection } from '@nestjs/sequelize';
import { Sequelize } from 'sequelize-typescript';
import { v4 as uuidv4 } from 'uuid';
import { HealthHistory } from '../models/health-history.model';
import { HealthHistoryItem } from '../models/health-history-item.model';
import { HealthHistoryCategory } from '../enums/health-profile.enum';
import {
UpdateHealthHistoryDto,
HealthHistoryCategoryResponseDto,
HealthHistoryItemResponseDto,
} from '../dto/health-history.dto';
import { HEALTH_HISTORY_RECOMMENDATIONS } from '../constants/health-recommendations';
@Injectable()
export class HealthHistoryService {
private readonly logger = new Logger(HealthHistoryService.name);
constructor(
@InjectModel(HealthHistory)
private readonly healthHistoryModel: typeof HealthHistory,
@InjectModel(HealthHistoryItem)
private readonly healthHistoryItemModel: typeof HealthHistoryItem,
@InjectConnection()
private readonly sequelize: Sequelize,
) {}
/**
* 获取用户所有健康史数据
*/
async getHealthHistory(userId: string): Promise<{
allergy: HealthHistoryCategoryResponseDto;
disease: HealthHistoryCategoryResponseDto;
surgery: HealthHistoryCategoryResponseDto;
familyDisease: HealthHistoryCategoryResponseDto;
}> {
const categories = Object.values(HealthHistoryCategory);
const result: any = {};
for (const category of categories) {
const history = await this.healthHistoryModel.findOne({
where: { userId, category },
include: [{ model: HealthHistoryItem, as: 'items' }],
});
result[category] = {
hasHistory: history?.hasHistory ?? null,
items: history?.items?.map(this.mapItemToResponse) ?? [],
};
}
return result;
}
/**
* 更新指定分类的健康史
*/
async updateHealthHistoryCategory(
userId: string,
category: HealthHistoryCategory,
updateDto: UpdateHealthHistoryDto,
): Promise<HealthHistoryCategoryResponseDto> {
const transaction = await this.sequelize.transaction();
try {
// 查找或创建健康史主记录
let history = await this.healthHistoryModel.findOne({
where: { userId, category },
transaction,
});
if (!history) {
history = await this.healthHistoryModel.create(
{
id: uuidv4(),
userId,
category,
hasHistory: updateDto.hasHistory,
},
{ transaction },
);
} else {
history.hasHistory = updateDto.hasHistory;
await history.save({ transaction });
}
// 如果 hasHistory 为 false清空所有 items
if (!updateDto.hasHistory) {
await this.healthHistoryItemModel.destroy({
where: { healthHistoryId: history.id },
transaction,
});
await transaction.commit();
return { hasHistory: false, items: [] };
}
// 处理 items - 全量更新模式
// 1. 获取现有的 items
const existingItems = await this.healthHistoryItemModel.findAll({
where: { healthHistoryId: history.id },
transaction,
});
const existingItemIds = new Set(existingItems.map(item => item.id));
// 2. 处理传入的 items
const newItemIds = new Set<string>();
const updatedItems: HealthHistoryItem[] = [];
for (const itemDto of updateDto.items) {
if (itemDto.id && existingItemIds.has(itemDto.id)) {
// 更新现有项
const existingItem = existingItems.find(i => i.id === itemDto.id);
if (existingItem) {
existingItem.name = itemDto.name;
existingItem.diagnosisDate = itemDto.date || null;
existingItem.isRecommendation = itemDto.isRecommendation ?? this.isRecommendation(category, itemDto.name);
existingItem.note = itemDto.note || null;
await existingItem.save({ transaction });
updatedItems.push(existingItem);
newItemIds.add(itemDto.id);
}
} else {
// 新增项
const newItem = await this.healthHistoryItemModel.create(
{
id: uuidv4(),
healthHistoryId: history.id,
userId,
name: itemDto.name,
diagnosisDate: itemDto.date || null,
isRecommendation: itemDto.isRecommendation ?? this.isRecommendation(category, itemDto.name),
note: itemDto.note || null,
},
{ transaction },
);
updatedItems.push(newItem);
newItemIds.add(newItem.id);
}
}
// 3. 删除不在新列表中的旧项
const itemsToDelete = existingItems.filter(item => !newItemIds.has(item.id));
for (const item of itemsToDelete) {
await item.destroy({ transaction });
}
await transaction.commit();
this.logger.log(`用户 ${userId} 更新健康史分类 ${category} 成功`);
return {
hasHistory: updateDto.hasHistory,
items: updatedItems.map(this.mapItemToResponse),
};
} catch (error) {
await transaction.rollback();
this.logger.error(`更新健康史失败: ${error instanceof Error ? error.message : '未知错误'}`);
throw error;
}
}
/**
* 获取健康史完成度
*/
async getHealthHistoryProgress(userId: string): Promise<{
progress: number;
details: {
allergy: boolean;
disease: boolean;
surgery: boolean;
familyDisease: boolean;
};
}> {
const categories = Object.values(HealthHistoryCategory);
const details: any = {};
let answeredCount = 0;
for (const category of categories) {
const history = await this.healthHistoryModel.findOne({
where: { userId, category },
});
// 只要回答了是否有历史hasHistory !== null就算已完成
const isAnswered = history?.hasHistory !== null && history?.hasHistory !== undefined;
details[category] = isAnswered;
if (isAnswered) answeredCount++;
}
const progress = Math.round((answeredCount / categories.length) * 100);
return { progress, details };
}
/**
* 获取推荐选项
*/
getRecommendations(category: HealthHistoryCategory): string[] {
return HEALTH_HISTORY_RECOMMENDATIONS[category] || [];
}
/**
* 判断是否为推荐选项
*/
private isRecommendation(category: HealthHistoryCategory, name: string): boolean {
const recommendations = HEALTH_HISTORY_RECOMMENDATIONS[category] || [];
return recommendations.includes(name);
}
/**
* 映射 item 到响应 DTO
*/
private mapItemToResponse(item: HealthHistoryItem): HealthHistoryItemResponseDto {
return {
id: item.id,
name: item.name,
date: item.diagnosisDate || undefined,
isRecommendation: item.isRecommendation,
note: item.note || undefined,
};
}
}

View File

@@ -0,0 +1,128 @@
import {
Injectable,
Logger,
NotFoundException,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { v4 as uuidv4 } from 'uuid';
import { MedicalRecord } from '../models/medical-record.model';
import { MedicalRecordType } from '../enums/health-profile.enum';
import {
CreateMedicalRecordDto,
MedicalRecordItemDto,
MedicalRecordsDataDto,
} from '../dto/medical-records.dto';
@Injectable()
export class MedicalRecordsService {
private readonly logger = new Logger(MedicalRecordsService.name);
constructor(
@InjectModel(MedicalRecord)
private readonly medicalRecordModel: typeof MedicalRecord,
) {}
/**
* 获取用户的就医资料列表
*/
async getMedicalRecords(userId: string): Promise<MedicalRecordsDataDto> {
this.logger.log(`获取就医资料列表 - 用户ID: ${userId}`);
const records = await this.medicalRecordModel.findAll({
where: { userId },
order: [['date', 'DESC'], ['createdAt', 'DESC']],
});
// 分类整理
const medicalRecords: MedicalRecordItemDto[] = [];
const prescriptions: MedicalRecordItemDto[] = [];
for (const record of records) {
const item = this.toMedicalRecordItemDto(record);
if (record.type === MedicalRecordType.MEDICAL_RECORD) {
medicalRecords.push(item);
} else {
prescriptions.push(item);
}
}
return {
records: medicalRecords,
prescriptions,
};
}
/**
* 添加就医资料
*/
async createMedicalRecord(
userId: string,
dto: CreateMedicalRecordDto,
): Promise<MedicalRecordItemDto> {
this.logger.log(`添加就医资料 - 用户ID: ${userId}, 类型: ${dto.type}`);
// 验证日期不能是未来日期
const recordDate = new Date(dto.date);
const today = new Date();
today.setHours(23, 59, 59, 999);
if (recordDate > today) {
throw new BadRequestException('日期不能是未来日期');
}
// 生成ID前缀
const idPrefix = dto.type === MedicalRecordType.MEDICAL_RECORD ? 'rec_' : 'presc_';
const id = `${idPrefix}${uuidv4().replace(/-/g, '').substring(0, 16)}`;
const record = await this.medicalRecordModel.create({
id,
userId,
type: dto.type,
title: dto.title,
date: dto.date,
images: dto.images,
note: dto.note || null,
});
this.logger.log(`就医资料添加成功 - ID: ${id}`);
return this.toMedicalRecordItemDto(record);
}
/**
* 删除就医资料
*/
async deleteMedicalRecord(userId: string, recordId: string): Promise<void> {
this.logger.log(`删除就医资料 - 用户ID: ${userId}, 资料ID: ${recordId}`);
const record = await this.medicalRecordModel.findByPk(recordId);
if (!record) {
throw new NotFoundException('资料不存在或已被删除');
}
if (record.userId !== userId) {
throw new ForbiddenException('无权限删除该资料');
}
// 软删除
await record.destroy();
this.logger.log(`就医资料删除成功 - ID: ${recordId}`);
}
/**
* 转换为 DTO
*/
private toMedicalRecordItemDto(record: MedicalRecord): MedicalRecordItemDto {
return {
id: record.id,
type: record.type,
title: record.title,
date: record.date,
images: record.images || [],
note: record.note || undefined,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
}

View File

@@ -27,14 +27,35 @@ async function bootstrap() {
app.use((req, res, next) => {
const startTime = Date.now();
// 捕获响应体
const originalSend = res.send;
let responseBody: any;
res.send = function (body) {
responseBody = body;
return originalSend.call(this, body);
};
res.on('finish', () => {
const duration = Date.now() - startTime;
const logMessage = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`;
const appVersion = req.headers['x-app-version'] || 'unknown';
const logMessage = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms [v${appVersion}]`;
// 解析响应体
let responseStr = '';
try {
if (typeof responseBody === 'string') {
responseStr = responseBody;
} else if (responseBody) {
responseStr = JSON.stringify(responseBody);
}
} catch {
responseStr = '[Unable to stringify response]';
}
if (res.statusCode >= 400) {
logger.error(`${logMessage} - Body: ${JSON.stringify(req.body)}`);
logger.error(`${logMessage} - Body: ${JSON.stringify(req.body)} - Response: ${responseStr}`);
} else {
logger.log(`${logMessage} - Body: ${JSON.stringify(req.body)}`);
logger.log(`${logMessage} - Body: ${JSON.stringify(req.body)} - Response: ${responseStr}`);
}
});

View File

@@ -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. **用户体验**
- 支持离线识别边缘计算
- 实时预览识别结果
- 智能纠错和建议

View File

@@ -0,0 +1,41 @@
import { ApiProperty } from '@nestjs/swagger';
export class MedicationPlanItemDto {
@ApiProperty({ description: '药物ID' })
id: string;
@ApiProperty({ description: '药物名称' })
name: string;
@ApiProperty({ description: '开始服药日期YYYY-MM-DD' })
startDate: string;
@ApiProperty({ description: '计划统计的天数从开始日期到今天或计划结束日期未来开始则为0' })
plannedDays: number;
@ApiProperty({ description: '计划每日服药次数' })
timesPerDay: number;
@ApiProperty({ description: '计划总服药次数plannedDays * timesPerDay' })
plannedDoses: number;
@ApiProperty({ description: '已打卡完成的次数' })
takenDoses: number;
@ApiProperty({ description: '完成率0-1之间的小数保留两位', example: 0.82 })
completionRate: number;
}
export class MedicationAiSummaryDto {
@ApiProperty({
description: '当前正在服用的药物列表',
type: [MedicationPlanItemDto],
})
medicationAnalysis: MedicationPlanItemDto[];
@ApiProperty({
description: 'AI 针对当前用药搭配的重点解读200字以内',
example: '当前方案以控制炎症与镇痛为主,请留意胃肠不适并按时复诊,避免自行叠加非甾体药物,如出现头晕或皮疹需及时就医。',
})
keyInsights: string;
}

View File

@@ -8,11 +8,9 @@ import {
Param,
Query,
UseGuards,
Res,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Response } from 'express';
import { MedicationsService } from './medications.service';
import { CreateMedicationDto } from './dto/create-medication.dto';
import { UpdateMedicationDto } from './dto/update-medication.dto';
@@ -29,6 +27,7 @@ import { MedicationRecognitionService } from './services/medication-recognition.
import { UsersService } from '../users/users.service';
import { RepeatPatternEnum } from './enums/repeat-pattern.enum';
import { RecognitionStatusEnum } from './enums/recognition-status.enum';
import { MedicationAiSummaryDto } from './dto/medication-ai-summary.dto';
/**
* 药物管理控制器
@@ -85,6 +84,60 @@ export class MedicationsController {
return ApiResponseDto.success(result, '查询成功');
}
@Get('ai-summary')
@ApiOperation({
summary: '获取用药AI总结',
description: '汇总当前开启的用药计划并生成200字以内的专业搭配建议',
})
@ApiResponse({
status: 200,
description: '获取成功',
type: MedicationAiSummaryDto,
})
@ApiResponse({
status: 403,
description: '免费使用次数已用完',
})
async getMedicationAiSummary(@CurrentUser() user: any) {
try {
const cached = await this.analysisService.getTodaySummaryIfExists(user.sub);
if (cached) {
return ApiResponseDto.success(cached, '获取成功(缓存)');
}
const userUsageCount = await this.usersService.getUserUsageCount(user.sub);
if (userUsageCount <= 0) {
this.logger.warn(`用药AI总结失败 - 用户ID: ${user.sub}, 免费次数不足`);
return ApiResponseDto.error('免费使用次数已用完,请开通会员获取更多使用次数', 403);
}
const summary = await this.analysisService.summarizeActiveMedications(user.sub);
// 只有在实际调用大模型时才扣减次数(有开启的用药计划)
if (summary.medicationAnalysis.length > 0) {
try {
await this.usersService.deductUserUsageCount(user.sub, 1);
this.logger.log(
`用药AI总结成功已扣减用户免费次数 - 用户ID: ${user.sub}, 剩余次数: ${userUsageCount - 1}`,
);
} catch (deductError) {
this.logger.error(
`扣减用户免费次数失败 - 用户ID: ${user.sub}, 错误: ${deductError instanceof Error ? deductError.message : String(deductError)}`,
);
}
}
return ApiResponseDto.success(summary, '获取成功');
} catch (error) {
this.logger.error(
`获取用药AI总结失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`,
);
return ApiResponseDto.error(
error instanceof Error ? error.message : '获取AI总结失败',
);
}
}
@Get(':id')
@ApiOperation({ summary: '获取药物详情' })
@ApiResponse({ status: 200, description: '查询成功' })
@@ -156,88 +209,6 @@ export class MedicationsController {
return ApiResponseDto.success(medication, '激活成功');
}
@Post(':id/ai-analysis')
@ApiOperation({
summary: '获取药品AI分析',
description: '使用大模型分析药品信息提供专业的用药指导、注意事项和健康建议。支持视觉识别药品图片。返回Server-Sent Events流式响应。'
})
@ApiResponse({
status: 200,
description: '返回流式文本分析结果',
content: {
'text/event-stream': {
schema: {
type: 'string',
example: '药品分析内容...'
}
}
}
})
@ApiResponse({
status: 403,
description: '免费使用次数已用完'
})
async getAiAnalysis(
@CurrentUser() user: any,
@Param('id') id: string,
@Res() res: Response,
) {
try {
// 检查用户免费使用次数
const userUsageCount = await this.usersService.getUserUsageCount(user.sub);
// 如果用户不是VIP且免费次数不足返回错误
if (userUsageCount <= 0) {
this.logger.warn(`药品AI分析失败 - 用户ID: ${user.sub}, 免费次数不足`);
res.status(403).json(
ApiResponseDto.error('免费使用次数已用完,请开通会员获取更多使用次数'),
);
return;
}
// 设置SSE响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // 禁用nginx缓冲
// 获取分析流
const stream = await this.analysisService.analyzeMedication(id, user.sub);
// 分析成功后扣减用户免费使用次数
try {
await this.usersService.deductUserUsageCount(user.sub, 1);
this.logger.log(`药品AI分析成功已扣减用户免费次数 - 用户ID: ${user.sub}, 剩余次数: ${userUsageCount - 1}`);
} catch (deductError) {
this.logger.error(`扣减用户免费次数失败 - 用户ID: ${user.sub}, 错误: ${deductError instanceof Error ? deductError.message : String(deductError)}`);
// 不影响主流程,继续返回分析结果
}
// 将流式数据写入响应
stream.on('data', (chunk: Buffer) => {
res.write(chunk.toString());
});
stream.on('end', () => {
res.end();
});
stream.on('error', (error) => {
res.status(500).json(
ApiResponseDto.error(
error instanceof Error ? error.message : '分析过程中发生错误',
),
);
});
} catch (error) {
res.status(500).json(
ApiResponseDto.error(
error instanceof Error ? error.message : '药品分析失败',
),
);
}
}
@Post(':id/ai-analysis/v2')
@ApiOperation({
summary: '获取药品AI分析 (V2)',
@@ -486,4 +457,4 @@ export class MedicationsController {
);
}
}
}
}

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { ScheduleModule } from '@nestjs/schedule';
import { ConfigModule } from '@nestjs/config';
@@ -7,6 +7,7 @@ import { ConfigModule } from '@nestjs/config';
import { Medication } from './models/medication.model';
import { MedicationRecord } from './models/medication-record.model';
import { MedicationRecognitionTask } from './models/medication-recognition-task.model';
import { MedicationAiSummary } from './models/medication-ai-summary.model';
// Controllers
import { MedicationsController } from './medications.controller';
@@ -38,10 +39,11 @@ import { UsersModule } from '../users/users.module';
Medication,
MedicationRecord,
MedicationRecognitionTask,
MedicationAiSummary,
]),
ScheduleModule.forRoot(), // 启用定时任务
PushNotificationsModule, // 推送通知功能
UsersModule, // 用户认证服务
forwardRef(() => PushNotificationsModule), // 推送通知功能
forwardRef(() => UsersModule), // 用户认证服务
],
controllers: [
MedicationsController,
@@ -64,4 +66,4 @@ import { UsersModule } from '../users/users.module';
MedicationStatsService,
],
})
export class MedicationsModule {}
export class MedicationsModule {}

View File

@@ -93,6 +93,20 @@ export class MedicationsService {
return { rows, total: count };
}
/**
* 获取用户所有正在进行的用药计划
*/
async findActiveMedications(userId: string): Promise<Medication[]> {
return this.medicationModel.findAll({
where: {
userId,
isActive: true,
deleted: false,
},
order: [['startDate', 'ASC']],
});
}
/**
* 根据ID获取药物详情
*/
@@ -330,4 +344,4 @@ export class MedicationsService {
`成功批量软删除了 ${affectedCount}${medication.id} 的记录`,
);
}
}
}

View File

@@ -0,0 +1,59 @@
import { Column, Model, Table, DataType, Index } from 'sequelize-typescript';
@Table({
tableName: 't_medication_ai_summaries',
underscored: true,
paranoid: false,
})
export class MedicationAiSummary extends Model {
@Column({
type: DataType.STRING(50),
primaryKey: true,
comment: '唯一标识',
})
declare id: string;
@Index('idx_user_date')
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Index('idx_user_date')
@Column({
type: DataType.DATEONLY,
allowNull: false,
comment: '统计日期YYYY-MM-DD',
})
declare summaryDate: string;
@Column({
type: DataType.JSON,
allowNull: false,
comment: '用药计划与进度统计',
})
declare medicationAnalysis: any;
@Column({
type: DataType.TEXT,
allowNull: false,
comment: 'AI重点解读',
})
declare keyInsights: string;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '创建时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '更新时间',
})
declare updatedAt: Date;
}

View File

@@ -162,6 +162,30 @@ export class Medication extends Model {
})
declare deleted: boolean;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否已发送一个月过期预警',
})
declare expiryOneMonthWarned: boolean;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否已发送一周过期预警',
})
declare expiryOneWeekWarned: boolean;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否已发送一天过期预警',
})
declare expiryOneDayWarned: boolean;
// 关联关系
@HasMany(() => MedicationRecord, 'medicationId')
declare records: MedicationRecord[];

View File

@@ -3,9 +3,27 @@ import { ConfigService } from '@nestjs/config';
import { InjectModel } from '@nestjs/sequelize';
import { OpenAI } from 'openai';
import { Readable } from 'stream';
import * as dayjs from 'dayjs';
import { Op } from 'sequelize';
import { UsersService } from '../../users/users.service';
import { MedicationsService } from '../medications.service';
import { Medication } from '../models/medication.model';
import { MedicationRecord } from '../models/medication-record.model';
import { MedicationAiSummary } from '../models/medication-ai-summary.model';
import { MedicationStatusEnum } from '../enums/medication-status.enum';
import { AiAnalysisResultDto } from '../dto/ai-analysis-result.dto';
import {
MedicationAiSummaryDto,
MedicationPlanItemDto,
} from '../dto/medication-ai-summary.dto';
import { v4 as uuidv4 } from 'uuid';
interface LanguageConfig {
label: string;
analysisInstruction: string;
jsonInstruction: string;
unableToIdentifyMessage: string;
}
/**
* 药品AI分析服务
@@ -21,8 +39,13 @@ export class MedicationAnalysisService {
constructor(
private readonly configService: ConfigService,
private readonly medicationsService: MedicationsService,
private readonly usersService: UsersService,
@InjectModel(Medication)
private readonly medicationModel: typeof Medication,
@InjectModel(MedicationRecord)
private readonly recordModel: typeof MedicationRecord,
@InjectModel(MedicationAiSummary)
private readonly summaryModel: typeof MedicationAiSummary,
) {
// GLM-4.5V Configuration
const glmApiKey = this.configService.get<string>('GLM_API_KEY');
@@ -33,8 +56,8 @@ export class MedicationAnalysisService {
baseURL: glmBaseURL,
});
this.model = this.configService.get<string>('GLM_MODEL') || 'glm-4-flash';
this.visionModel = this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4v-plus';
this.model = this.configService.get<string>('GLM_MODEL') || 'glm-4.6';
this.visionModel = this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4.5v';
}
/**
@@ -48,10 +71,11 @@ export class MedicationAnalysisService {
try {
// 1. 获取药品信息
const medication = await this.medicationsService.findOne(medicationId, userId);
const languageConfig = await this.getUserLanguageConfig(userId);
this.logger.log(`获取到药品信息: ${JSON.stringify(medication, null, 2)}`)
// 2. 构建专业医药分析提示
const prompt = this.buildMedicationAnalysisPrompt(medication);
const prompt = this.buildMedicationAnalysisPrompt(medication, languageConfig);
// 3. 调用AI模型进行分析
if (medication.photoUrl) {
@@ -78,10 +102,11 @@ export class MedicationAnalysisService {
try {
// 1. 获取药品信息
const medication = await this.medicationsService.findOne(medicationId, userId);
const languageConfig = await this.getUserLanguageConfig(userId);
this.logger.log(`获取到药品信息: ${JSON.stringify(medication, null, 2)}`);
// 2. 构建专业医药分析提示
const prompt = this.buildMedicationAnalysisPromptV2(medication);
const prompt = this.buildMedicationAnalysisPromptV2(medication, languageConfig);
let result: AiAnalysisResultDto;
@@ -104,6 +129,131 @@ export class MedicationAnalysisService {
}
}
/**
* 获取当日已生成的用药总结(若存在缓存)
*/
async getTodaySummaryIfExists(userId: string): Promise<MedicationAiSummaryDto | null> {
const summaryDate = dayjs().format('YYYY-MM-DD');
return this.getCachedSummary(userId, summaryDate);
}
/**
* 总结用户当前所有开启的用药方案并生成AI重点解读
* @param userId 用户ID
*/
async summarizeActiveMedications(userId: string): Promise<MedicationAiSummaryDto> {
const summaryDate = dayjs().format('YYYY-MM-DD');
const summaryDay = dayjs(summaryDate);
const today = summaryDay.endOf('day');
const cached = await this.getCachedSummary(userId, summaryDate);
if (cached) {
this.logger.log(`命中用药AI总结缓存 - 用户ID: ${userId}, 日期: ${summaryDate}`);
return cached;
}
const activeMedications = await this.medicationsService.findActiveMedications(
userId,
);
const medicationAnalysis: MedicationPlanItemDto[] = await Promise.all(
activeMedications.map(async (medication) => {
const startDate = dayjs(medication.startDate).startOf('day');
const planEnd = medication.endDate
? dayjs(medication.endDate).endOf('day')
: today;
const effectiveEnd = planEnd.isAfter(today) ? today : planEnd;
// 未来开始的计划天数与计划剂量均为0
const plannedDays = startDate.isAfter(effectiveEnd)
? 0
: Math.max(effectiveEnd.diff(startDate, 'day') + 1, 0);
const plannedDoses = plannedDays * medication.timesPerDay;
// 统计已完成的打卡次数(只统计 TAKEN
let takenDoses = 0;
if (plannedDoses > 0) {
takenDoses = await this.recordModel.count({
where: {
medicationId: medication.id,
userId,
deleted: false,
status: MedicationStatusEnum.TAKEN,
scheduledTime: {
[Op.between]: [startDate.toDate(), effectiveEnd.toDate()],
},
},
});
}
const completionRate =
plannedDoses > 0
? Math.round((takenDoses / plannedDoses) * 100) / 100
: 0;
return {
id: medication.id,
name: medication.name,
startDate: startDate.format('YYYY-MM-DD'),
plannedDays,
timesPerDay: medication.timesPerDay,
plannedDoses,
takenDoses,
completionRate,
};
}),
);
this.logger.log(
`用户 ${userId} 的用药计划分析结果: ${JSON.stringify(medicationAnalysis, null, 2)}`,
);
// 获取用户语言配置
const languageConfig = await this.getUserLanguageConfig(userId);
// 没有开启的用药计划时,不调用大模型
if (!medicationAnalysis.length) {
const noDataMessage = languageConfig.label === 'English'
? 'No active medication plans found. Please add or activate medications to view insights.'
: '当前没有开启的用药计划,请先添加或激活用药后再查看解读。';
const result = {
medicationAnalysis,
keyInsights: noDataMessage,
};
await this.saveSummary(userId, summaryDate, result);
return result;
}
const prompt = this.buildMedicationSummaryPrompt(medicationAnalysis, languageConfig);
try {
const response = await this.client.chat.completions.create({
model: this.model,
temperature: 0.4,
messages: [
{
role: 'user',
content: prompt,
},
],
});
const keyInsights = response.choices?.[0]?.message?.content?.trim() || '';
const result: MedicationAiSummaryDto = {
medicationAnalysis,
keyInsights,
};
await this.saveSummary(userId, summaryDate, result);
return result;
} catch (error) {
this.logger.error(
`生成用药总结失败 - 用户ID: ${userId}, 错误: ${error instanceof Error ? error.message : String(error)}`,
);
throw new Error('生成用药总结失败,请稍后再试');
}
}
/**
* 使用视觉模型分析药品(带图片)
* @param prompt 分析提示
@@ -341,12 +491,96 @@ export class MedicationAnalysisService {
return readable;
}
private async getCachedSummary(
userId: string,
summaryDate: string,
): Promise<MedicationAiSummaryDto | null> {
const record = await this.summaryModel.findOne({
where: {
userId,
summaryDate,
},
});
if (!record) {
return null;
}
return {
medicationAnalysis: record.medicationAnalysis,
keyInsights: record.keyInsights,
};
}
private async saveSummary(
userId: string,
summaryDate: string,
summary: MedicationAiSummaryDto,
): Promise<void> {
try {
const existing = await this.summaryModel.findOne({
where: { userId, summaryDate },
});
if (existing) {
await existing.update({
medicationAnalysis: summary.medicationAnalysis,
keyInsights: summary.keyInsights,
});
} else {
await this.summaryModel.create({
id: uuidv4(),
userId,
summaryDate,
medicationAnalysis: summary.medicationAnalysis,
keyInsights: summary.keyInsights,
});
}
} catch (error) {
this.logger.error(
`保存用药AI总结失败 - 用户ID: ${userId}, 日期: ${summaryDate}, 错误: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* 构建用药总结的提示词输出200字以内的重点解读
*/
private buildMedicationSummaryPrompt(
medications: MedicationPlanItemDto[],
languageConfig: LanguageConfig,
): string {
const medicationList = medications
.map(
(med, index) =>
`${index + 1}${med.name} | 开始:${med.startDate} | 计划:${med.timesPerDay}次/天,至今共${med.plannedDoses}次 | 已完成:${med.takenDoses}次 | 完成率:${Math.round(med.completionRate * 100)}%`,
)
.join('\n');
const languageInstruction = languageConfig.label === 'English'
? 'Please provide your response entirely in English.'
: '请使用简体中文输出全部内容。';
const wordLimit = languageConfig.label === 'English' ? '300 words' : '400字';
return `你是一名持证的临床医生与药学专家,请基于以下正在进行中的用药方案,输出"重点解读"一段话(${wordLimit}以内),重点围绕计划 vs 实际的执行情况给出建议。
用药列表:
${medicationList}
写作要求:
- 语言:${languageInstruction}
- 口吻:专业、温和、以患者安全为先
- 内容:结合完成率与计划总次数,点评用药依从性、潜在风险与监测要点(胃肠道、肝肾功能、血压/血糖等),提出需要复诊或调整的建议
- 形式:只输出一段文字,不要使用列表或分段,不要重复列出药品清单,不要添加免责声明`;
}
/**
* 构建专业医药分析提示
* @param medication 药品信息
* @returns 分析提示文本
*/
private buildMedicationAnalysisPrompt(medication: Medication): string {
private buildMedicationAnalysisPrompt(medication: Medication, languageConfig: LanguageConfig): string {
const formName = this.getMedicationFormName(medication.form);
const dosageInfo = `${medication.dosageValue}${medication.dosageUnit}`;
@@ -387,6 +621,9 @@ ${medication.note ? `- 用户备注:${medication.note}` : ''}
6. 给予健康关怀和鼓励
7. 如果有图片,请结合图片信息提供更准确的分析
**语言要求**
${languageConfig.analysisInstruction}
**输出格式要求**
**情况A无法识别药品时**(药品名称不明确、过于笼统、随意输入、或缺少必要信息),请使用以下格式:
@@ -491,7 +728,7 @@ ${medication.note ? `- 用户备注:${medication.note}` : ''}
* @param medication 药品信息
* @returns 分析提示文本
*/
private buildMedicationAnalysisPromptV2(medication: Medication): string {
private buildMedicationAnalysisPromptV2(medication: Medication, languageConfig: LanguageConfig): string {
const formName = this.getMedicationFormName(medication.form);
const dosageInfo = `${medication.dosageValue}${medication.dosageUnit}`;
@@ -507,6 +744,11 @@ ${medication.note ? `- 用户备注:${medication.note}` : ''}
${medication.photoUrl ? '- 药品图片:已提供(请结合图片中的药品外观、包装、说明书等信息进行分析)' : ''}
${medication.note ? `- 用户备注:${medication.note}` : ''}
**语言要求**
- ${languageConfig.jsonInstruction}
- 如果需要描述或解释,请使用${languageConfig.label}
- 无法识别药品时mainUsage 字段返回 "${languageConfig.unableToIdentifyMessage}"
**重要指示**
请以严格的 JSON 格式返回分析结果,不要包含任何 Markdown 标记或其他文本。JSON 结构如下:
@@ -529,7 +771,7 @@ ${medication.note ? `- 用户备注:${medication.note}` : ''}
6. storageAdvice: 储存和保管建议,字符串数组
7. healthAdvice: 健康关怀建议(生活方式、饮食等),字符串数组
如果无法识别药品请在所有数组字段返回空数组mainUsage 返回 "无法识别药品,请提供更准确的名称或图片"。
如果无法识别药品请在所有数组字段返回空数组mainUsage 返回 "${languageConfig.unableToIdentifyMessage}"。
`;
}
@@ -552,4 +794,40 @@ ${medication.note ? `- 用户备注:${medication.note}` : ''}
};
return formNames[form] || form;
}
}
/**
* 根据用户ID获取语言配置
*/
private async getUserLanguageConfig(userId: string): Promise<LanguageConfig> {
try {
const language = await this.usersService.getUserLanguage(userId);
return this.buildLanguageConfig(language);
} catch (error) {
this.logger.error(`获取用户语言失败,使用默认中文: ${error instanceof Error ? error.message : String(error)}`);
return this.buildLanguageConfig();
}
}
/**
* 将语言代码映射为提示配置
*/
private buildLanguageConfig(language?: string): LanguageConfig {
const normalized = (language || '').toLowerCase();
if (normalized.startsWith('en')) {
return {
label: 'English',
analysisInstruction: 'Please respond entirely in English. Output all section titles, bullet points, paragraphs, and reminders in English. Keep emojis unchanged. Do not mix other languages.',
jsonInstruction: 'Return all JSON field values in English. Keep JSON keys exactly as defined in the schema.',
unableToIdentifyMessage: 'Unable to identify the medication, please provide a more accurate name or image.',
};
}
return {
label: '简体中文',
analysisInstruction: '请使用简体中文输出全部内容包括所有标题、要点、段落和提醒emoji 保持不变,不要混用其他语言。',
jsonInstruction: '请确保 JSON 中所有字段的值都使用简体中文字段名key保持英文不变。',
unableToIdentifyMessage: '无法识别药品,请提供更准确的名称或图片。',
};
}
}

View File

@@ -10,6 +10,34 @@ import {
RecognitionStatusEnum,
RECOGNITION_STATUS_DESCRIPTIONS,
} from '../enums/recognition-status.enum';
import { UsersService } from '../../users/users.service';
const STATUS_MESSAGES = {
'zh-CN': {
[RecognitionStatusEnum.PENDING]: '任务已创建,等待处理',
[RecognitionStatusEnum.ANALYZING_PRODUCT]: '正在全方位分析药品信息...',
ANALYZING_PRODUCT_DONE: '药品分析完成',
[RecognitionStatusEnum.ANALYZING_SUITABILITY]: '正在分析适宜人群...',
ANALYZING_SUITABILITY_DONE: '适宜人群分析完成',
[RecognitionStatusEnum.ANALYZING_INGREDIENTS]: '正在分析主要成分...',
ANALYZING_INGREDIENTS_DONE: '成分分析完成',
[RecognitionStatusEnum.ANALYZING_EFFECTS]: '正在分析副作用和健康建议...',
[RecognitionStatusEnum.COMPLETED]: '识别完成',
[RecognitionStatusEnum.FAILED]: '识别失败',
},
'en-US': {
[RecognitionStatusEnum.PENDING]: 'Task created, waiting for processing',
[RecognitionStatusEnum.ANALYZING_PRODUCT]: 'Analyzing medication information comprehensively...',
ANALYZING_PRODUCT_DONE: 'Medication analysis completed',
[RecognitionStatusEnum.ANALYZING_SUITABILITY]: 'Analyzing suitable users...',
ANALYZING_SUITABILITY_DONE: 'Suitability analysis completed',
[RecognitionStatusEnum.ANALYZING_INGREDIENTS]: 'Analyzing main ingredients...',
ANALYZING_INGREDIENTS_DONE: 'Ingredients analysis completed',
[RecognitionStatusEnum.ANALYZING_EFFECTS]: 'Analyzing side effects and health advice...',
[RecognitionStatusEnum.COMPLETED]: 'Recognition completed',
[RecognitionStatusEnum.FAILED]: 'Recognition failed',
},
};
/**
* 药物AI识别服务
@@ -26,6 +54,7 @@ export class MedicationRecognitionService {
private readonly configService: ConfigService,
@InjectModel(MedicationRecognitionTask)
private readonly taskModel: typeof MedicationRecognitionTask,
private readonly usersService: UsersService,
) {
const glmApiKey = this.configService.get<string>('GLM_API_KEY');
const glmBaseURL =
@@ -40,7 +69,7 @@ export class MedicationRecognitionService {
this.visionModel =
this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4.5v';
this.textModel =
this.configService.get<string>('GLM_MODEL') || 'glm-4.5-air';
this.configService.get<string>('GLM_MODEL') || 'glm-4.5-flash';
}
/**
@@ -54,6 +83,13 @@ export class MedicationRecognitionService {
this.logger.log(`创建药物识别任务: ${taskId}, 用户: ${userId}`);
// 获取用户语言
const language = await this.usersService.getUserLanguage(userId);
const currentStep = this.getStatusMessage(
RecognitionStatusEnum.PENDING,
language,
);
await this.taskModel.create({
id: taskId,
userId,
@@ -61,7 +97,7 @@ export class MedicationRecognitionService {
sideImageUrl: dto.sideImageUrl,
auxiliaryImageUrl: dto.auxiliaryImageUrl,
status: RecognitionStatusEnum.PENDING,
currentStep: RECOGNITION_STATUS_DESCRIPTIONS[RecognitionStatusEnum.PENDING],
currentStep,
progress: 0,
});
@@ -112,66 +148,25 @@ export class MedicationRecognitionService {
const task = await this.taskModel.findByPk(taskId);
if (!task) return;
// 阶段1: 产品识别分析 (0-40%)
// 获取用户语言
const language = await this.usersService.getUserLanguage(task.userId);
// 阶段1: 全量识别分析 (0-90%)
// 使用 GLM-4.5v 强大的多模态能力,一次性提取所有信息,避免多次调用
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_PRODUCT,
'正在识别药品基本信息...',
this.getStatusMessage(RecognitionStatusEnum.ANALYZING_PRODUCT, language),
10,
);
const productInfo = await this.recognizeProduct(task);
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_PRODUCT,
'药品基本信息识别完成',
40,
);
this.logger.log(`任务 ${taskId} 开始执行全量识别分析(视觉+知识库)`);
const recognitionResult = await this.recognizeProduct(task, language);
// 阶段2: 适宜人群分析 (40-60%)
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_SUITABILITY,
'正在分析适宜人群...',
50,
);
const suitabilityInfo = await this.analyzeSuitability(productInfo);
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_SUITABILITY,
'适宜人群分析完成',
60,
);
// 阶段3: 成分分析 (60-80%)
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_INGREDIENTS,
'正在分析主要成分...',
70,
);
const ingredientsInfo = await this.analyzeIngredients(productInfo);
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_INGREDIENTS,
'成分分析完成',
80,
);
// 阶段4: 副作用分析 (80-100%)
await this.updateTaskStatus(
taskId,
RecognitionStatusEnum.ANALYZING_EFFECTS,
'正在分析副作用和健康建议...',
90,
);
const effectsInfo = await this.analyzeEffects(productInfo);
// 合并所有结果透传所有原始图片URL避免被AI模型修改
// 合并结果透传所有原始图片URL
const finalResult = {
...productInfo,
...suitabilityInfo,
...ingredientsInfo,
...effectsInfo,
...recognitionResult,
// 强制使用任务记录中存储的原始图片URL覆盖AI可能返回的不正确链接
photoUrl: task.frontImageUrl,
sideImageUrl: task.sideImageUrl,
@@ -179,23 +174,33 @@ export class MedicationRecognitionService {
} as RecognitionResultDto;
// 完成识别
await this.completeTask(taskId, finalResult);
await this.completeTask(taskId, finalResult, language);
this.logger.log(`识别任务 ${taskId} 完成`);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
this.logger.error(`识别任务 ${taskId} 失败: ${errorMessage}`);
await this.failTask(taskId, errorMessage);
// 尝试获取任务和语言信息以更新失败状态
try {
const taskInfo = await this.taskModel.findByPk(taskId);
const lang = taskInfo
? await this.usersService.getUserLanguage(taskInfo.userId)
: 'zh-CN';
await this.failTask(taskId, errorMessage, lang);
} catch (e) {
this.logger.error(`更新失败状态出错: ${e}`);
}
}
}
/**
* 阶段1: 识别药品基本信息
* 执行全量药品识别(包含基本信息和详细分析)
*/
private async recognizeProduct(
task: MedicationRecognitionTask,
language: string,
): Promise<Partial<RecognitionResultDto>> {
const prompt = this.buildProductRecognitionPrompt();
const prompt = this.buildProductRecognitionPrompt(language);
const images = [task.frontImageUrl, task.sideImageUrl];
if (task.auxiliaryImageUrl) images.push(task.auxiliaryImageUrl);
@@ -256,100 +261,10 @@ export class MedicationRecognitionService {
}
/**
* 阶段2: 分析适宜人群
* 构建全量产品识别提示词
*/
private async analyzeSuitability(
productInfo: Partial<RecognitionResultDto>,
): Promise<Partial<RecognitionResultDto>> {
const prompt = this.buildSuitabilityAnalysisPrompt(productInfo);
this.logger.log(`分析适宜人群: ${productInfo.name}`);
const response = await this.client.chat.completions.create({
model: this.textModel,
temperature: 0.7,
messages: [
{
role: 'user',
content: prompt,
},
],
response_format: { type: 'json_object' },
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('AI模型返回内容为空');
}
return this.parseJsonResponse(content);
}
/**
* 阶段3: 分析主要成分
*/
private async analyzeIngredients(
productInfo: Partial<RecognitionResultDto>,
): Promise<Partial<RecognitionResultDto>> {
const prompt = this.buildIngredientsAnalysisPrompt(productInfo);
this.logger.log(`分析主要成分: ${productInfo.name}`);
const response = await this.client.chat.completions.create({
model: this.textModel,
temperature: 0.7,
messages: [
{
role: 'user',
content: prompt,
},
],
response_format: { type: 'json_object' },
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('AI模型返回内容为空');
}
return this.parseJsonResponse(content);
}
/**
* 阶段4: 分析副作用和健康建议
*/
private async analyzeEffects(
productInfo: Partial<RecognitionResultDto>,
): Promise<Partial<RecognitionResultDto>> {
const prompt = this.buildEffectsAnalysisPrompt(productInfo);
this.logger.log(`分析副作用和健康建议: ${productInfo.name}`);
const response = await this.client.chat.completions.create({
model: this.textModel,
temperature: 0.7,
messages: [
{
role: 'user',
content: prompt,
},
],
response_format: { type: 'json_object' },
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('AI模型返回内容为空');
}
return this.parseJsonResponse(content);
}
/**
* 构建产品识别提示词
*/
private buildProductRecognitionPrompt(): string {
return `你是一位拥有20年从业经验的资深药剂师请根据提供的药品图片包括正面、侧面和可能的辅助面进行详细分析。
private buildProductRecognitionPrompt(language: string): string {
return `你是一位拥有20年从业经验的资深药剂师请根据提供的药品图片包括正面、侧面和可能的辅助面进行全方位的详细分析。
**重要前提条件 - 图片可读性判断**
⚠️ 在进行任何识别之前,你必须首先判断图片是否足够清晰可读:
@@ -360,10 +275,10 @@ export class MedicationRecognitionService {
**只有在图片清晰可读的情况下才能继续分析**
1. 仔细观察药品包装、说明书上的所有信息
2. 识别药品的完整名称(通用名和商品名)
3. 确定药物剂型(片剂/胶囊/注射剂等)
4. 提取规格剂量信息
5. 推荐合理的服用次数和时间
2. 识别药品的完整名称、剂型、规格剂量
3. 分析适宜人群、禁忌人群
4. 提取主要成分、副作用、储存建议
5. 给出健康建议和服用时间
**置信度评估标准(仅在图片可读时评估)**
- 如果图片清晰且信息完整,置信度应 >= 0.8
@@ -371,9 +286,13 @@ export class MedicationRecognitionService {
- 如果关键信息缺失或模糊不清,置信度 < 0.6name返回"无法识别"
- 置信度评估必须严格基于实际可见信息,不能猜测或臆断
**重要提示**
请使用 ${language} 语言返回所有文本内容。
Please respond in ${language}.
**返回严格的JSON格式**不要包含任何markdown标记
{
"isReadable": true或false(图片是否足够清晰可读),
"isReadable": true或false,
"name": "药品完整名称",
"photoUrl": "使用正面图片URL",
"form": "剂型(tablet/capsule/injection/drops/syrup/ointment/powder/granules)",
@@ -381,97 +300,26 @@ export class MedicationRecognitionService {
"dosageUnit": "剂量单位",
"timesPerDay": 建议每日服用次数(数字),
"medicationTimes": ["建议的服药时间格式HH:mm"],
"confidence": 识别置信度(0-1的小数)
}
**关键规则(必须遵守)**
1. isReadable 是最重要的字段,如果为 false其他识别结果将被忽略
2. 当图片模糊、反光、文字不清晰时,必须设置 isReadable 为 false
3. 只有在确实能看清并理解图片内容时,才能设置 isReadable 为 true
4. confidence 必须反映真实的识别把握程度,不能虚高
5. 如果 isReadable 为 falsename 必须返回"无法识别"confidence 设为 0
6. dosageValue 和 timesPerDay 必须是数字类型,不要加引号
7. medicationTimes 必须是 HH:mm 格式的时间数组
8. form 必须是枚举值之一
**宁可识别失败,也不要提供不准确的药品信息。用药安全高于一切!**`;
}
/**
* 构建适宜人群分析提示词
*/
private buildSuitabilityAnalysisPrompt(
productInfo: Partial<RecognitionResultDto>,
): string {
return `作为资深药剂师,请分析以下药品的适宜人群和禁忌人群:
**药品信息**
- 名称:${productInfo.name}
- 剂型:${productInfo.form}
- 剂量:${productInfo.dosageValue}${productInfo.dosageUnit}
请以严格的JSON格式返回不要包含任何markdown标记
{
"confidence": 识别置信度(0-1),
"suitableFor": ["适合人群1", "适合人群2", "适合人群3"],
"unsuitableFor": ["不适合人群1", "不适合人群2", "不适合人群3"],
"mainUsage": "药品的主要用途和适应症描述"
}
**要求**
- suitableFor 和 unsuitableFor 必须是字符串数组至少包含3项
- mainUsage 是字符串,描述药品的主要治疗用途
- 如果无法识别药品所有数组返回空数组mainUsage返回"无法识别药品"`;
}
/**
* 构建成分分析提示词
*/
private buildIngredientsAnalysisPrompt(
productInfo: Partial<RecognitionResultDto>,
): string {
return `作为资深药剂师,请分析以下药品的主要成分:
**药品信息**
- 名称:${productInfo.name}
- 用途:${productInfo.mainUsage}
请以严格的JSON格式返回不要包含任何markdown标记
{
"mainIngredients": ["主要成分1", "主要成分2", "主要成分3"]
}
**要求**
- mainIngredients 必须是字符串数组,列出药品的主要活性成分
- 至少包含1-3个主要成分
- 如果无法确定,返回空数组`;
}
/**
* 构建副作用分析提示词
*/
private buildEffectsAnalysisPrompt(
productInfo: Partial<RecognitionResultDto>,
): string {
return `作为资深药剂师,请分析以下药品的副作用、储存建议和健康建议:
**药品信息**
- 名称:${productInfo.name}
- 用途:${productInfo.mainUsage}
- 成分:${productInfo.mainIngredients?.join('、')}
请以严格的JSON格式返回不要包含任何markdown标记
{
"mainUsage": "药品的主要用途和适应症描述",
"mainIngredients": ["主要成分1", "主要成分2", "主要成分3"],
"sideEffects": ["副作用1", "副作用2", "副作用3"],
"storageAdvice": ["储存建议1", "储存建议2", "储存建议3"],
"healthAdvice": ["健康建议1", "健康建议2", "健康建议3"]
}
**要求**
- 所有字段都是字符串数组
- sideEffects: 列出常见和严重的副作用至少3项
- storageAdvice: 提供正确的储存方法至少2项
- healthAdvice: 给出配合用药的生活建议至少3项
- 如果无法确定,返回空数组`;
**关键规则(必须遵守)**
1. isReadable 是最重要的字段,如果为 false其他识别结果将被忽略name返回"无法识别"confidence 为 0
2. dosageValue 和 timesPerDay 必须是数字类型,不要加引号
3. medicationTimes 必须是 HH:mm 格式的时间数组
4. form 必须是枚举值之一
5. suitableFor/unsuitableFor/mainIngredients/sideEffects/storageAdvice/healthAdvice 必须是字符串数组
6. 数组字段至少包含 1-3 项,如无信息返回空数组
7. 必须使用 ${language} 语言回答所有文本描述性内容
**宁可识别失败,也不要提供不准确的药品信息。用药安全高于一切!**`;
}
/**
@@ -529,11 +377,15 @@ export class MedicationRecognitionService {
private async completeTask(
taskId: string,
result: RecognitionResultDto,
language: string = 'zh-CN',
): Promise<void> {
await this.taskModel.update(
{
status: RecognitionStatusEnum.COMPLETED,
currentStep: RECOGNITION_STATUS_DESCRIPTIONS[RecognitionStatusEnum.COMPLETED],
currentStep: this.getStatusMessage(
RecognitionStatusEnum.COMPLETED,
language,
),
progress: 100,
recognitionResult: JSON.stringify(result),
completedAt: new Date(),
@@ -547,11 +399,18 @@ export class MedicationRecognitionService {
/**
* 任务失败
*/
private async failTask(taskId: string, errorMessage: string): Promise<void> {
private async failTask(
taskId: string,
errorMessage: string,
language: string = 'zh-CN',
): Promise<void> {
await this.taskModel.update(
{
status: RecognitionStatusEnum.FAILED,
currentStep: RECOGNITION_STATUS_DESCRIPTIONS[RecognitionStatusEnum.FAILED],
currentStep: this.getStatusMessage(
RecognitionStatusEnum.FAILED,
language,
),
progress: 0,
errorMessage,
completedAt: new Date(),
@@ -561,4 +420,18 @@ export class MedicationRecognitionService {
},
);
}
/**
* 获取多语言状态描述
*/
private getStatusMessage(key: string, language: string): string {
// 简化语言代码,如 'zh-TW' -> 'zh-CN', 'en-GB' -> 'en-US'
let langCode = 'zh-CN'; // 默认中文
if (language.toLowerCase().startsWith('en')) {
langCode = 'en-US';
}
const messages = STATUS_MESSAGES[langCode] || STATUS_MESSAGES['zh-CN'];
return messages[key] || key;
}
}

View File

@@ -12,12 +12,16 @@ import * as dayjs from 'dayjs';
/**
* 药物提醒推送服务
* 在服药时间前15分钟发送推送提醒并在超过服药时间1小时后发送鼓励提醒
* 同时检查药品过期时间,分别在提前一个月、一周、一天进行预警
*/
@Injectable()
export class MedicationReminderService {
private readonly logger = new Logger(MedicationReminderService.name);
private readonly REMINDER_MINUTES_BEFORE = 5; // 提前5分钟提醒
private readonly OVERDUE_HOURS_THRESHOLD = 1; // 超过1小时后发送超时提醒
private readonly EXPIRY_ONE_MONTH_DAYS = 30; // 提前一个月预警
private readonly EXPIRY_ONE_WEEK_DAYS = 7; // 提前一周预警
private readonly EXPIRY_ONE_DAY_DAYS = 1; // 提前一天预警
constructor(
@InjectModel(Medication)
@@ -408,4 +412,274 @@ export class MedicationReminderService {
return count;
}
/**
* 每天早上9点检查药品过期预警
* 分别在提前一个月、一周、一天进行预警
* 只有主进程NODE_APP_INSTANCE=0执行避免多进程重复发送
*/
@Cron('0 9 * * *')
async checkAndSendExpiryWarnings(): Promise<void> {
this.logger.log('开始检查药品过期预警');
try {
// 检查是否为主进程NODE_APP_INSTANCE 为 0
const nodeAppInstance = this.configService.get<number>('NODE_APP_INSTANCE', 0);
if (Number(nodeAppInstance) !== 0) {
this.logger.debug(`不是主进程 (instance: ${nodeAppInstance}),跳过药品过期预警检查`);
return;
}
this.logger.log('主进程检测到,执行药品过期预警检查...');
const now = dayjs();
const today = now.startOf('day');
// 计算各个预警时间点
const oneMonthLater = today.add(this.EXPIRY_ONE_MONTH_DAYS, 'day').toDate();
const oneWeekLater = today.add(this.EXPIRY_ONE_WEEK_DAYS, 'day').toDate();
const oneDayLater = today.add(this.EXPIRY_ONE_DAY_DAYS, 'day').toDate();
// 查找需要发送一个月预警的药品过期时间在30天内且未发送过一个月预警
const oneMonthWarningMeds = await this.medicationModel.findAll({
where: {
isActive: true,
deleted: false,
expiryDate: {
[Op.not]: null,
[Op.lte]: oneMonthLater,
[Op.gt]: oneWeekLater, // 排除已经进入一周预警范围的
},
expiryOneMonthWarned: false,
},
});
// 查找需要发送一周预警的药品过期时间在7天内且未发送过一周预警
const oneWeekWarningMeds = await this.medicationModel.findAll({
where: {
isActive: true,
deleted: false,
expiryDate: {
[Op.not]: null,
[Op.lte]: oneWeekLater,
[Op.gt]: oneDayLater, // 排除已经进入一天预警范围的
},
expiryOneWeekWarned: false,
},
});
// 查找需要发送一天预警的药品过期时间在1天内且未发送过一天预警
const oneDayWarningMeds = await this.medicationModel.findAll({
where: {
isActive: true,
deleted: false,
expiryDate: {
[Op.not]: null,
[Op.lte]: oneDayLater,
[Op.gte]: today.toDate(), // 还未过期
},
expiryOneDayWarned: false,
},
});
// 查找已过期的药品(用于发送已过期提醒)
const expiredMeds = await this.medicationModel.findAll({
where: {
isActive: true,
deleted: false,
expiryDate: {
[Op.not]: null,
[Op.lt]: today.toDate(),
},
expiryOneDayWarned: false, // 复用一天预警标记
},
});
this.logger.log(
`找到需要预警的药品 - 一个月: ${oneMonthWarningMeds.length}, 一周: ${oneWeekWarningMeds.length}, 一天: ${oneDayWarningMeds.length}, 已过期: ${expiredMeds.length}`,
);
// 发送一个月预警
await this.sendExpiryWarnings(oneMonthWarningMeds, 'one_month');
// 发送一周预警
await this.sendExpiryWarnings(oneWeekWarningMeds, 'one_week');
// 发送一天预警
await this.sendExpiryWarnings(oneDayWarningMeds, 'one_day');
// 发送已过期提醒
await this.sendExpiryWarnings(expiredMeds, 'expired');
this.logger.log('药品过期预警检查完成');
} catch (error) {
this.logger.error('检查药品过期预警失败', error.stack);
}
}
/**
* 发送药品过期预警
*/
private async sendExpiryWarnings(
medications: Medication[],
warningType: 'one_month' | 'one_week' | 'one_day' | 'expired',
): Promise<void> {
if (medications.length === 0) {
return;
}
// 按用户分组
const userMedsMap = new Map<string, Medication[]>();
for (const med of medications) {
const userId = med.userId;
if (!userMedsMap.has(userId)) {
userMedsMap.set(userId, []);
}
userMedsMap.get(userId)!.push(med);
}
// 为每个用户发送预警
for (const [userId, meds] of userMedsMap.entries()) {
const success = await this.sendExpiryWarningToUser(userId, meds, warningType);
if (success) {
// 标记已发送预警
await this.markMedicationsAsExpiryWarned(
meds.map((m) => m.id),
warningType,
);
}
}
}
/**
* 为单个用户发送药品过期预警
*/
private async sendExpiryWarningToUser(
userId: string,
medications: Medication[],
warningType: 'one_month' | 'one_week' | 'one_day' | 'expired',
): Promise<boolean> {
try {
const medicationNames = medications.map((m) => m.name).join('、');
let title: string;
let body: string;
switch (warningType) {
case 'one_month':
title = '药品即将过期提醒';
body =
medications.length === 1
? `您的药品「${medicationNames}」将在一个月内过期,请注意及时更换。`
: `您有 ${medications.length} 种药品将在一个月内过期:${medicationNames},请注意及时更换。`;
break;
case 'one_week':
title = '药品过期预警';
body =
medications.length === 1
? `您的药品「${medicationNames}」将在一周内过期,请尽快更换!`
: `您有 ${medications.length} 种药品将在一周内过期:${medicationNames},请尽快更换!`;
break;
case 'one_day':
title = '药品过期紧急提醒';
body =
medications.length === 1
? `您的药品「${medicationNames}」明天即将过期,请立即检查并更换!`
: `您有 ${medications.length} 种药品明天即将过期:${medicationNames},请立即检查并更换!`;
break;
case 'expired':
title = '药品已过期警告';
body =
medications.length === 1
? `您的药品「${medicationNames}」已过期,请勿继续服用,并及时更换新药!`
: `您有 ${medications.length} 种药品已过期:${medicationNames},请勿继续服用,并及时更换新药!`;
break;
}
await this.pushService.sendNotification({
userIds: [userId],
title,
body,
payload: {
type: 'medication_expiry_warning',
warningType,
medicationIds: medications.map((m) => m.id),
},
sound: 'default',
badge: 1,
});
this.logger.log(`成功向用户 ${userId} 发送药品过期预警 (${warningType})`);
return true;
} catch (error) {
this.logger.error(
`向用户 ${userId} 发送药品过期预警失败`,
error.stack,
);
return false;
}
}
/**
* 标记药品已发送过期预警
*/
private async markMedicationsAsExpiryWarned(
medicationIds: string[],
warningType: 'one_month' | 'one_week' | 'one_day' | 'expired',
): Promise<void> {
try {
let updateField: string;
switch (warningType) {
case 'one_month':
updateField = 'expiryOneMonthWarned';
break;
case 'one_week':
updateField = 'expiryOneWeekWarned';
break;
case 'one_day':
case 'expired':
updateField = 'expiryOneDayWarned';
break;
}
await this.medicationModel.update(
{ [updateField]: true },
{
where: {
id: {
[Op.in]: medicationIds,
},
},
},
);
this.logger.debug(
`已标记 ${medicationIds.length} 种药品为已发送 ${warningType} 过期预警`,
);
} catch (error) {
this.logger.error('标记药品过期预警失败', error.stack);
}
}
/**
* 重置药品的过期预警标记(当用户更新药品有效期时调用)
*/
async resetExpiryWarnings(medicationId: string): Promise<void> {
try {
await this.medicationModel.update(
{
expiryOneMonthWarned: false,
expiryOneWeekWarned: false,
expiryOneDayWarned: false,
},
{
where: {
id: medicationId,
},
},
);
this.logger.log(`已重置药品 ${medicationId} 的过期预警标记`);
} catch (error) {
this.logger.error('重置药品过期预警标记失败', error.stack);
}
}
}

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { MoodCheckinsService } from './mood-checkins.service';
import { MoodCheckinsController } from './mood-checkins.controller';
@@ -7,7 +7,7 @@ import { UsersModule } from '../users/users.module';
import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
@Module({
imports: [SequelizeModule.forFeature([MoodCheckin]), UsersModule, ActivityLogsModule],
imports: [SequelizeModule.forFeature([MoodCheckin]), forwardRef(() => UsersModule), forwardRef(() => ActivityLogsModule)],
providers: [MoodCheckinsService],
controllers: [MoodCheckinsController],
exports: [MoodCheckinsService],

View File

@@ -216,7 +216,7 @@ export class ChallengeReminderService {
}
// 获取鼓励文案
const template = getEncouragementTemplate(participant.challenge.type);
const template = getEncouragementTemplate(participant.challenge.type, participant.challenge.title);
// 发送推送
const result = await this.pushNotificationsService.sendBatchNotificationToDevices({
@@ -281,7 +281,7 @@ export class ChallengeReminderService {
// 获取邀请文案
const template = reminderType === ReminderType.GENERAL_INVITATION
? getGeneralInvitationTemplate()
: getInvitationTemplate(randomChallenge.type);
: getInvitationTemplate(randomChallenge.type, randomChallenge.title);
// 发送推送
const result = await this.pushNotificationsService.sendBatchNotificationToDevices({

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { PushNotificationsController } from './push-notifications.controller';
import { PushTemplateController } from './push-template.controller';
@@ -22,8 +22,8 @@ import { ChallengeParticipant } from '../challenges/models/challenge-participant
imports: [
ConfigModule,
DatabaseModule,
UsersModule,
ChallengesModule,
forwardRef(() => UsersModule),
forwardRef(() => ChallengesModule),
SequelizeModule.forFeature([
UserPushToken,
PushMessage,

View File

@@ -46,6 +46,9 @@ export const ENCOURAGEMENT_TEMPLATES = {
{ title: '团队体重挑战', body: '今天体重挑战群里又有很多人完成了目标!一起健康生活!' },
{ title: '健康生活记录', body: '挑战者们都在坚持健康饮食+适量运动,你也是其中一员!' },
],
[ChallengeType.CUSTOM]: [
// 自定义挑战使用动态模板,不在此处定义
],
};
/**
@@ -82,6 +85,9 @@ export const INVITATION_TEMPLATES = {
{ title: '健康体重挑战', body: '21天体重管理见证自己的变化' },
{ title: '体重目标挑战', body: '科学管理体重,享受健康生活!' },
],
[ChallengeType.CUSTOM]: [
// 自定义挑战使用动态模板,不在此处定义
],
};
/**
@@ -106,7 +112,12 @@ export function getRandomTemplate<T>(templates: T[]): T {
/**
* 根据挑战类型获取鼓励文案
*/
export function getEncouragementTemplate(challengeType: ChallengeType) {
export function getEncouragementTemplate(challengeType: ChallengeType, challengeTitle?: string) {
// 自定义挑战使用动态模板
if (challengeType === ChallengeType.CUSTOM && challengeTitle) {
return getCustomEncouragementTemplate(challengeTitle);
}
const templates = ENCOURAGEMENT_TEMPLATES[challengeType] || ENCOURAGEMENT_TEMPLATES[ChallengeType.EXERCISE];
return getRandomTemplate(templates);
}
@@ -114,7 +125,12 @@ export function getEncouragementTemplate(challengeType: ChallengeType) {
/**
* 根据挑战类型获取邀请文案
*/
export function getInvitationTemplate(challengeType: ChallengeType) {
export function getInvitationTemplate(challengeType: ChallengeType, challengeTitle?: string) {
// 自定义挑战使用动态模板
if (challengeType === ChallengeType.CUSTOM && challengeTitle) {
return getCustomInvitationTemplate(challengeTitle);
}
const templates = INVITATION_TEMPLATES[challengeType] || INVITATION_TEMPLATES[ChallengeType.EXERCISE];
return getRandomTemplate(templates);
}
@@ -124,4 +140,30 @@ export function getInvitationTemplate(challengeType: ChallengeType) {
*/
export function getGeneralInvitationTemplate() {
return getRandomTemplate(GENERAL_INVITATION_TEMPLATES);
}
/**
* 生成自定义挑战的鼓励文案模板
*/
export function getCustomEncouragementTemplate(challengeTitle: string) {
const templates = [
{ title: `${challengeTitle}进行中`, body: `今天已有多人参与「${challengeTitle}」!你也要加油哦!` },
{ title: `${challengeTitle}提醒`, body: `挑战伙伴们都在坚持「${challengeTitle}」,每一步努力都值得!` },
{ title: `${challengeTitle}打卡`, body: `看到很多挑战者都在参与「${challengeTitle}」,你也是其中一员!` },
{ title: `团队挑战`, body: `${challengeTitle}」挑战群里又有很多人完成了目标!别掉队,一起加油!` },
{ title: `挑战分享`, body: `挑战者们都在分享「${challengeTitle}」的心得,你今天打卡了吗?` },
];
return getRandomTemplate(templates);
}
/**
* 生成自定义挑战的邀请文案模板
*/
export function getCustomInvitationTemplate(challengeTitle: string) {
const templates = [
{ title: `${challengeTitle}邀请`, body: `加入「${challengeTitle}」,一起挑战自我,收获成长!` },
{ title: `挑战邀请`, body: `${challengeTitle}」正在进行中,快来加入我们吧!` },
{ title: `一起挑战`, body: `开启「${challengeTitle}」之旅,遇见更好的自己!` },
];
return getRandomTemplate(templates);
}

3
src/redis/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './redis.module';
export * from './redis.service';
export * from './throttler-storage-redis.service';

46
src/redis/redis.module.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RedisService } from './redis.service';
import { ThrottlerStorageRedisService } from './throttler-storage-redis.service';
@Global()
@Module({
imports: [ConfigModule],
providers: [
{
provide: 'REDIS_CLIENT',
useFactory: async (configService: ConfigService) => {
const Redis = (await import('ioredis')).default;
const client = new Redis({
host: configService.get<string>('REDIS_HOST', '127.0.0.1'),
port: configService.get<number>('REDIS_PORT', 6379),
password: configService.get<string>('REDIS_PASSWORD', ''),
db: configService.get<number>('REDIS_DB', 0),
keyPrefix: configService.get<string>('REDIS_PREFIX', 'pilates:'),
retryStrategy: (times: number) => {
if (times > 3) {
return null; // 停止重试
}
return Math.min(times * 200, 2000);
},
maxRetriesPerRequest: 3,
});
client.on('connect', () => {
console.log('Redis client connected');
});
client.on('error', (err) => {
console.error('Redis client error:', err);
});
return client;
},
inject: [ConfigService],
},
RedisService,
ThrottlerStorageRedisService,
],
exports: ['REDIS_CLIENT', RedisService, ThrottlerStorageRedisService],
})
export class RedisModule {}

315
src/redis/redis.service.ts Normal file
View File

@@ -0,0 +1,315 @@
import { Injectable, Inject, OnModuleDestroy, Logger } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
constructor(
@Inject('REDIS_CLIENT')
private readonly redis: Redis,
) {}
async onModuleDestroy() {
await this.redis.quit();
this.logger.log('Redis connection closed');
}
/**
* 获取原始 Redis 客户端(用于高级操作)
*/
getClient(): Redis {
return this.redis;
}
// ==================== 基础操作 ====================
/**
* 设置键值
* @param key 键
* @param value 值
* @param ttlSeconds 过期时间(秒),可选
*/
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.redis.setex(key, ttlSeconds, value);
} else {
await this.redis.set(key, value);
}
}
/**
* 获取键值
*/
async get(key: string): Promise<string | null> {
return this.redis.get(key);
}
/**
* 删除键
*/
async del(...keys: string[]): Promise<number> {
return this.redis.del(...keys);
}
/**
* 检查键是否存在
*/
async exists(key: string): Promise<boolean> {
const result = await this.redis.exists(key);
return result === 1;
}
/**
* 设置过期时间
*/
async expire(key: string, seconds: number): Promise<boolean> {
const result = await this.redis.expire(key, seconds);
return result === 1;
}
/**
* 获取剩余过期时间
*/
async ttl(key: string): Promise<number> {
return this.redis.ttl(key);
}
// ==================== JSON 操作 ====================
/**
* 设置 JSON 对象
*/
async setJson<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
const jsonString = JSON.stringify(value);
await this.set(key, jsonString, ttlSeconds);
}
/**
* 获取 JSON 对象
*/
async getJson<T>(key: string): Promise<T | null> {
const value = await this.get(key);
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
// ==================== Hash 操作 ====================
/**
* 设置 Hash 字段
*/
async hset(key: string, field: string, value: string): Promise<number> {
return this.redis.hset(key, field, value);
}
/**
* 获取 Hash 字段
*/
async hget(key: string, field: string): Promise<string | null> {
return this.redis.hget(key, field);
}
/**
* 获取所有 Hash 字段
*/
async hgetall(key: string): Promise<Record<string, string>> {
return this.redis.hgetall(key);
}
/**
* 删除 Hash 字段
*/
async hdel(key: string, ...fields: string[]): Promise<number> {
return this.redis.hdel(key, ...fields);
}
// ==================== List 操作 ====================
/**
* 从左侧推入列表
*/
async lpush(key: string, ...values: string[]): Promise<number> {
return this.redis.lpush(key, ...values);
}
/**
* 从右侧推入列表
*/
async rpush(key: string, ...values: string[]): Promise<number> {
return this.redis.rpush(key, ...values);
}
/**
* 获取列表范围
*/
async lrange(key: string, start: number, stop: number): Promise<string[]> {
return this.redis.lrange(key, start, stop);
}
/**
* 获取列表长度
*/
async llen(key: string): Promise<number> {
return this.redis.llen(key);
}
// ==================== Set 操作 ====================
/**
* 添加 Set 成员
*/
async sadd(key: string, ...members: string[]): Promise<number> {
return this.redis.sadd(key, ...members);
}
/**
* 获取所有 Set 成员
*/
async smembers(key: string): Promise<string[]> {
return this.redis.smembers(key);
}
/**
* 检查是否是 Set 成员
*/
async sismember(key: string, member: string): Promise<boolean> {
const result = await this.redis.sismember(key, member);
return result === 1;
}
/**
* 移除 Set 成员
*/
async srem(key: string, ...members: string[]): Promise<number> {
return this.redis.srem(key, ...members);
}
// ==================== 计数器操作 ====================
/**
* 自增
*/
async incr(key: string): Promise<number> {
return this.redis.incr(key);
}
/**
* 自增指定值
*/
async incrby(key: string, increment: number): Promise<number> {
return this.redis.incrby(key, increment);
}
/**
* 自减
*/
async decr(key: string): Promise<number> {
return this.redis.decr(key);
}
// ==================== 分布式锁 ====================
/**
* 获取分布式锁
* @param lockKey 锁的键名
* @param ttlSeconds 锁的过期时间(秒)
* @param retryTimes 重试次数
* @param retryDelay 重试间隔(毫秒)
* @returns 锁的唯一标识,获取失败返回 null
*/
async acquireLock(
lockKey: string,
ttlSeconds: number = 10,
retryTimes: number = 3,
retryDelay: number = 100,
): Promise<string | null> {
const lockValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
for (let i = 0; i < retryTimes; i++) {
const result = await this.redis.set(
`lock:${lockKey}`,
lockValue,
'EX',
ttlSeconds,
'NX',
);
if (result === 'OK') {
return lockValue;
}
if (i < retryTimes - 1) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
}
return null;
}
/**
* 释放分布式锁
* @param lockKey 锁的键名
* @param lockValue 锁的唯一标识
*/
async releaseLock(lockKey: string, lockValue: string): Promise<boolean> {
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await this.redis.eval(script, 1, `lock:${lockKey}`, lockValue);
return result === 1;
}
// ==================== 缓存辅助方法 ====================
/**
* 带缓存的数据获取
* 如果缓存存在则返回缓存,否则执行 factory 函数获取数据并缓存
*/
async getOrSet<T>(
key: string,
factory: () => Promise<T>,
ttlSeconds: number = 300,
): Promise<T> {
const cached = await this.getJson<T>(key);
if (cached !== null) {
return cached;
}
const value = await factory();
await this.setJson(key, value, ttlSeconds);
return value;
}
/**
* 批量删除匹配模式的键
* @param pattern 匹配模式,如 "user:*"
*/
async delByPattern(pattern: string): Promise<number> {
const keys = await this.redis.keys(pattern);
if (keys.length === 0) return 0;
return this.redis.del(...keys);
}
// ==================== 健康检查 ====================
/**
* 检查 Redis 连接状态
*/
async ping(): Promise<boolean> {
try {
const result = await this.redis.ping();
return result === 'PONG';
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,93 @@
import { Injectable, Inject, OnModuleDestroy } from '@nestjs/common';
import { ThrottlerStorage } from '@nestjs/throttler';
import Redis from 'ioredis';
export interface ThrottlerStorageRecord {
totalHits: number;
timeToExpire: number;
isBlocked: boolean;
timeToBlockExpire: number;
}
@Injectable()
export class ThrottlerStorageRedisService
implements ThrottlerStorage, OnModuleDestroy
{
private readonly prefix = 'throttler:';
constructor(
@Inject('REDIS_CLIENT')
private readonly redis: Redis,
) {}
async onModuleDestroy() {
// Redis 连接由 RedisModule 管理,这里不需要关闭
}
/**
* 增加指定 key 的请求计数
* @param key 限流 key通常是 IP 或用户标识)
* @param ttl 过期时间(毫秒)
* @param limit 限制次数
* @param blockDuration 封禁时长(毫秒)
* @param throttlerName 限流器名称
*/
async increment(
key: string,
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,
): Promise<ThrottlerStorageRecord> {
const redisKey = `${this.prefix}${throttlerName}:${key}`;
const blockKey = `${this.prefix}${throttlerName}:block:${key}`;
// 检查是否被封禁
const blockTtl = await this.redis.pttl(blockKey);
if (blockTtl > 0) {
return {
totalHits: limit + 1,
timeToExpire: ttl,
isBlocked: true,
timeToBlockExpire: blockTtl,
};
}
// 使用 Lua 脚本保证原子性操作
const luaScript = `
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
local pttl = redis.call('PTTL', KEYS[1])
return {current, pttl}
`;
const result = (await this.redis.eval(
luaScript,
1,
redisKey,
ttl.toString(),
)) as [number, number];
const totalHits = result[0];
const timeToExpire = result[1] > 0 ? result[1] : ttl;
// 如果超过限制且设置了封禁时长,则设置封禁
let isBlocked = false;
let timeToBlockExpire = 0;
if (totalHits > limit && blockDuration > 0) {
await this.redis.set(blockKey, '1', 'PX', blockDuration);
isBlocked = true;
timeToBlockExpire = blockDuration;
}
return {
totalHits,
timeToExpire,
isBlocked,
timeToBlockExpire,
};
}
}

View File

@@ -297,6 +297,4 @@ export class ScheduleExerciseService {
}
// 注意:训练计划是模板,不应该有完成状态
// 训练完成状态应该在 WorkoutSession 和 WorkoutExercise 中管理
// 如需标记完成状态,请使用 WorkoutsService
}

View File

@@ -153,8 +153,6 @@ export class TrainingPlansController {
}
// 注意:训练计划是模板,不应该有完成状态
// 实际的训练完成状态应该在 WorkoutSession 中管理
// 如需完成训练,请使用 /workouts/sessions 相关接口
}

View File

@@ -297,4 +297,59 @@ export class CosService {
});
});
}
}
/**
* 上传 Buffer 到 COS
*/
async uploadBuffer(
userId: string,
buffer: Buffer,
fileName: string,
mimeType: string = 'image/png'
): Promise<{ fileUrl: string; fileKey: string }> {
try {
this.logger.log(`开始上传Buffer用户ID: ${userId}, 文件名: ${fileName}`);
// 验证文件类型
const fileExtension = this.getFileExtension(fileName);
if (!this.validateFileType('image', fileExtension)) {
throw new BadRequestException('不支持的图片格式');
}
// 验证文件大小
const sizeLimit = this.getFileSizeLimit('image');
if (buffer.length > sizeLimit) {
throw new BadRequestException(`图片文件大小超过限制 (${sizeLimit / 1024 / 1024}MB)`);
}
// 生成唯一文件名
const uniqueFileName = this.generateUniqueFileName(fileName, fileExtension);
const fileKey = `uploads/images/${uniqueFileName}`;
// 上传到COS
const uploadResult = await this.uploadToCos(fileKey, buffer, mimeType);
// 生成文件访问URL
const fileUrl = this.generateFileUrl(uploadResult);
const response = {
fileUrl,
fileKey,
originalName: fileName,
fileSize: buffer.length,
fileType: 'image',
mimeType: mimeType,
uploadTime: new Date(),
etag: uploadResult.ETag,
};
this.logger.log(`Buffer上传成功用户ID: ${userId}, 文件URL: ${fileUrl}`);
return { fileUrl, fileKey };
} catch (error) {
this.logger.error(`上传Buffer失败: ${error.message}`, error.stack);
throw new BadRequestException(`上传Buffer失败: ${error.message}`);
}
}
}

View File

@@ -0,0 +1,123 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsNumber, IsString, Min, Max } from 'class-validator';
import { ApiResponseDto } from 'src/base.dto';
/**
* 更新每日健康数据 DTO
*/
export class UpdateDailyHealthDto {
@ApiPropertyOptional({ description: '记录日期 (YYYY-MM-DD),不传则默认为今天' })
@IsOptional()
@IsString()
date?: string;
@ApiPropertyOptional({ description: '饮水量 (毫升 ml)' })
@IsOptional()
@IsNumber()
@Min(0)
waterIntake?: number;
@ApiPropertyOptional({ description: '锻炼分钟数' })
@IsOptional()
@IsNumber()
@Min(0)
exerciseMinutes?: number;
@ApiPropertyOptional({ description: '消耗卡路里 (千卡 kcal)' })
@IsOptional()
@IsNumber()
@Min(0)
caloriesBurned?: number;
@ApiPropertyOptional({ description: '站立时间 (分钟)' })
@IsOptional()
@IsNumber()
@Min(0)
standingMinutes?: number;
@ApiPropertyOptional({ description: '基础代谢 (千卡 kcal)' })
@IsOptional()
@IsNumber()
@Min(0)
basalMetabolism?: number;
@ApiPropertyOptional({ description: '睡眠分钟数' })
@IsOptional()
@IsNumber()
@Min(0)
sleepMinutes?: number;
@ApiPropertyOptional({ description: '血氧饱和度 (百分比 %)' })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
bloodOxygen?: number;
@ApiPropertyOptional({ description: '压力 (ms保留一位小数)' })
@IsOptional()
@IsNumber()
@Min(0)
stressLevel?: number;
@ApiPropertyOptional({ description: '步数' })
@IsOptional()
@IsNumber()
@Min(0)
steps?: number;
}
/**
* 每日健康数据响应
*/
export class DailyHealthDataDto {
@ApiProperty({ description: '记录ID' })
id: number;
@ApiProperty({ description: '用户ID' })
userId: string;
@ApiProperty({ description: '记录日期 (YYYY-MM-DD)' })
recordDate: string;
@ApiPropertyOptional({ description: '饮水量 (毫升 ml)' })
waterIntake: number | null;
@ApiPropertyOptional({ description: '锻炼分钟数' })
exerciseMinutes: number | null;
@ApiPropertyOptional({ description: '消耗卡路里 (千卡 kcal)' })
caloriesBurned: number | null;
@ApiPropertyOptional({ description: '站立时间 (分钟)' })
standingMinutes: number | null;
@ApiPropertyOptional({ description: '基础代谢 (千卡 kcal)' })
basalMetabolism: number | null;
@ApiPropertyOptional({ description: '睡眠分钟数' })
sleepMinutes: number | null;
@ApiPropertyOptional({ description: '血氧饱和度 (百分比 %)' })
bloodOxygen: number | null;
@ApiPropertyOptional({ description: '压力 (ms保留一位小数)' })
stressLevel: number | null;
@ApiPropertyOptional({ description: '步数' })
steps: number | null;
@ApiProperty({ description: '创建时间' })
createdAt: Date;
@ApiProperty({ description: '更新时间' })
updatedAt: Date;
}
/**
* 更新每日健康数据响应 DTO
*/
export class UpdateDailyHealthResponseDto extends ApiResponseDto<DailyHealthDataDto> {
@ApiProperty({ type: DailyHealthDataDto })
declare data: DailyHealthDataDto;
}

View File

@@ -59,6 +59,11 @@ export class UpdateUserDto {
@ApiProperty({ description: '活动水平1-5的枚举值', example: 3, minimum: 1, maximum: 5 })
activityLevel?: number;
@IsString({ message: '语言偏好必须是字符串' })
@IsOptional()
@ApiProperty({ description: '用户语言偏好', example: 'zh-CN', required: false })
language?: string;
}
export class UpdateUserResponseDto {

View File

@@ -25,6 +25,7 @@ export interface UserWithPurchaseStatus {
maxUsageCount: number;
favoriteTopicCount: number;
isVip: boolean;
appVersion?: string;
profile?: Pick<UserProfile, 'dailyStepsGoal' | 'dailyCaloriesGoal' | 'pilatesPurposes' | 'weight' | 'initialWeight' | 'targetWeight' | 'height' | 'activityLevel'>;
}

View File

@@ -0,0 +1,68 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsOptional, IsEnum } from 'class-validator';
import { ResponseCode } from 'src/base.dto';
/**
* 版本检查请求DTO
*/
export class VersionCheckDto {
@ApiProperty({
description: '当前应用版本号(从请求头 x-App-Version 获取)',
example: '1.0.0',
required: false,
})
@IsString()
@IsOptional()
currentVersion?: string;
@ApiProperty({
description: '设备平台',
example: 'ios',
enum: ['ios', 'android'],
required: false,
})
@IsString()
@IsOptional()
@IsEnum(['ios', 'android'])
platform?: string;
}
/**
* 版本信息接口
*/
export interface VersionInfo {
latestVersion: string;
appStoreUrl: string;
needsUpdate: boolean;
updateMessage?: string;
releaseNotes?: string;
}
/**
* 版本检查响应DTO
*/
export class VersionCheckResponseDto {
@ApiProperty({
description: '响应代码',
example: ResponseCode.SUCCESS,
})
code: ResponseCode;
@ApiProperty({
description: '响应消息',
example: '版本检查成功',
})
message: string;
@ApiProperty({
description: '版本信息',
example: {
latestVersion: '1.2.0',
appStoreUrl: 'https://apps.apple.com/app/your-app-id',
needsUpdate: true,
updateMessage: '发现新版本,建议更新到最新版本以获得更好的体验',
releaseNotes: '1. 新增AI健康教练功能\n2. 优化用户体验\n3. 修复已知问题'
},
})
data: VersionInfo;
}

View File

@@ -0,0 +1,122 @@
import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript';
/**
* 用户每日健康记录表
* 每日每个用户只会生成一条数据,通过 userId + recordDate 唯一确定
*/
@Table({
tableName: 't_user_daily_health',
underscored: true,
indexes: [
{
unique: true,
fields: ['user_id', 'record_date'],
name: 'uk_user_record_date',
},
{
fields: ['user_id'],
name: 'idx_user_id',
},
{
fields: ['record_date'],
name: 'idx_record_date',
},
],
})
export class UserDailyHealth extends Model {
@PrimaryKey
@Column({
type: DataType.BIGINT,
autoIncrement: true,
})
declare id: number;
@Column({
type: DataType.STRING(64),
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.DATEONLY,
allowNull: false,
comment: '记录日期 (YYYY-MM-DD)',
})
declare recordDate: string;
@Column({
type: DataType.INTEGER,
allowNull: true,
comment: '饮水量 (毫升 ml)',
})
declare waterIntake: number | null;
@Column({
type: DataType.INTEGER,
allowNull: true,
comment: '锻炼分钟数',
})
declare exerciseMinutes: number | null;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '消耗卡路里 (千卡 kcal)',
})
declare caloriesBurned: number | null;
@Column({
type: DataType.INTEGER,
allowNull: true,
comment: '站立时间 (分钟)',
})
declare standingMinutes: number | null;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '基础代谢 (千卡 kcal)',
})
declare basalMetabolism: number | null;
@Column({
type: DataType.INTEGER,
allowNull: true,
comment: '睡眠分钟数',
})
declare sleepMinutes: number | null;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '血氧饱和度 (百分比 %)',
})
declare bloodOxygen: number | null;
@Column({
type: DataType.DECIMAL(5, 1),
allowNull: true,
comment: '压力 (ms保留一位小数)',
})
declare stressLevel: number | null;
@Column({
type: DataType.INTEGER,
allowNull: true,
comment: '步数',
})
declare steps: number | null;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
}

View File

@@ -118,6 +118,28 @@ export class User extends Model {
})
declare deviceName: string;
@Column({
type: DataType.STRING,
allowNull: true,
defaultValue: 'zh-CN',
comment: '用户语言偏好zh-CN、en-US',
})
declare language: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '用户当前使用的App版本号',
})
declare appVersion: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '健康邀请码',
})
declare healthInviteCode: string;
get isVip(): boolean {
return this.membershipExpiration ? dayjs(this.membershipExpiration).isAfter(dayjs()) : false;
}

View File

@@ -16,6 +16,7 @@ import {
NotFoundException,
UseInterceptors,
UploadedFile,
forwardRef,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Request } from 'express';
@@ -32,17 +33,22 @@ import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account
import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGuestTokenResponseDto } from './dto/guest-login.dto';
import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from './dto/app-store-notification.dto';
import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto';
import { VersionCheckDto, VersionCheckResponseDto } from './dto/version-check.dto';
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto';
import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.dto';
import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.dto';
import { GetUserBadgesResponseDto, GetAvailableBadgesResponseDto, MarkBadgeShownDto, MarkBadgeShownResponseDto } from './dto/badge.dto';
import { UpdateDailyHealthDto, UpdateDailyHealthResponseDto } from './dto/daily-health.dto';
import { Public } from '../common/decorators/public.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AppVersion } from '../common/decorators/app-version.decorator';
import { AccessTokenPayload } from './services/apple-auth.service';
import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard';
import { ResponseCode } from 'src/base.dto';
import { CosService } from './cos.service';
import { AiReportService } from '../ai-coach/services/ai-report.service';
import { GetAiReportHistoryQueryDto, GetAiReportHistoryResponseDto } from '../ai-coach/dto/ai-report-history.dto';
@ApiTags('users')
@@ -53,6 +59,8 @@ export class UsersController {
private readonly usersService: UsersService,
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger,
private readonly cosService: CosService,
@Inject(forwardRef(() => AiReportService))
private readonly aiReportService: AiReportService,
) { }
@UseGuards(JwtAuthGuard)
@@ -61,9 +69,12 @@ export class UsersController {
@ApiOperation({ summary: '获取用户信息' })
@ApiBody({ type: CreateUserDto })
@ApiResponse({ type: UserResponseDto })
async getProfile(@CurrentUser() user: AccessTokenPayload): Promise<UserResponseDto> {
this.logger.log(`get profile: ${JSON.stringify(user)}`);
return this.usersService.getProfile(user);
async getProfile(
@CurrentUser() user: AccessTokenPayload,
@AppVersion() appVersion: string | undefined,
): Promise<UserResponseDto> {
this.logger.log(`get profile: ${JSON.stringify(user)}, appVersion: ${appVersion}`);
return this.usersService.getProfile(user, appVersion);
}
// 获取历史体重记录
@@ -444,4 +455,213 @@ export class UsersController {
return this.usersService.markBadgeAsShown(user.sub, markBadgeShownDto.badgeCode);
}
// ==================== 版本检查相关接口 ====================
/**
* 检查应用版本更新
*/
@Public()
@Get('version-check')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '检查应用版本更新' })
@ApiQuery({ name: 'platform', required: false, description: '设备平台', enum: ['ios', 'android'] })
@ApiResponse({ status: 200, description: '成功获取版本信息', type: VersionCheckResponseDto })
async checkVersion(
@AppVersion() appVersion: string | undefined,
@Query('platform') platform?: string,
): Promise<VersionCheckResponseDto> {
this.logger.log(`版本检查请求 - 当前版本: ${appVersion}, 平台: ${platform}`);
// 构造查询对象,保持与原有服务的兼容性
const query: VersionCheckDto = {
currentVersion: appVersion,
platform: platform,
};
return this.usersService.checkVersion(query);
}
// ==================== AI 健康报告 ====================
/**
* 生成用户的 AI 健康报告图片
*/
@UseGuards(JwtAuthGuard)
@Post('ai-report')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '生成用户的 AI 健康报告图片' })
@ApiBody({ type: Object, required: false, description: '请求体,可以传入 date 指定日期,格式 YYYY-MM-DD' })
@ApiResponse({
status: 200,
description: '成功生成 AI 报告图片',
schema: {
type: 'object',
properties: {
code: { type: 'number', example: 0 },
message: { type: 'string', example: 'success' },
data: {
type: 'object',
properties: {
id: { type: 'string', example: '550e8400-e29b-41d4-a716-446655440000' },
imageUrl: { type: 'string', example: 'https://example.com/generated-image.png' }
}
}
}
}
})
async generateAiHealthReport(
@CurrentUser() user: AccessTokenPayload,
@Body() body: { date?: string },
): Promise<{ code: ResponseCode; message: string; data: { id: string; imageUrl: string } }> {
try {
this.logger.log(`生成AI健康报告请求 - 用户ID: ${user.sub}, 日期: ${body.date || '今天'}`);
const result = await this.aiReportService.generateHealthReportImage(user.sub, body.date);
return {
code: ResponseCode.SUCCESS,
message: 'AI健康报告生成成功',
data: {
id: result.id,
imageUrl: result.imageUrl,
},
};
} 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: {
id: '',
imageUrl: '',
},
};
}
}
/**
* 获取用户的 AI 健康报告历史列表
*/
@UseGuards(JwtAuthGuard)
@Get('ai-report/history')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取用户的 AI 健康报告历史列表(分页)' })
@ApiQuery({ name: 'page', required: false, description: '页码从1开始默认1' })
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数默认10最大50' })
@ApiQuery({ name: 'startDate', required: false, description: '开始日期,格式 YYYY-MM-DD' })
@ApiQuery({ name: 'endDate', required: false, description: '结束日期,格式 YYYY-MM-DD' })
@ApiQuery({ name: 'status', required: false, description: '状态筛选: pending | processing | success | failed不传则返回所有状态' })
@ApiResponse({ status: 200, description: '成功获取报告历史列表', type: GetAiReportHistoryResponseDto })
async getAiReportHistory(
@CurrentUser() user: AccessTokenPayload,
@Query() query: GetAiReportHistoryQueryDto,
): Promise<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,
},
};
}
}
// ==================== 每日健康数据相关接口 ====================
/**
* 更新用户每日健康数据
*/
@UseGuards(JwtAuthGuard)
@Put('daily-health')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新用户每日健康数据(每日每用户一条记录,存在则更新)' })
@ApiBody({ type: UpdateDailyHealthDto })
@ApiResponse({ status: 200, description: '成功更新每日健康数据', type: UpdateDailyHealthResponseDto })
async updateDailyHealth(
@Body() updateDto: UpdateDailyHealthDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<UpdateDailyHealthResponseDto> {
this.logger.log(`更新每日健康数据 - 用户ID: ${user.sub}, 数据: ${JSON.stringify(updateDto)}`);
return this.usersService.updateDailyHealth(user.sub, updateDto);
}
// ==================== 健康邀请码相关接口 ====================
/**
* 获取用户健康邀请码
*/
@UseGuards(JwtAuthGuard)
@Get('health-invite-code')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取用户健康邀请码(如果没有则自动生成)' })
@ApiResponse({
status: 200,
description: '成功获取健康邀请码',
schema: {
type: 'object',
properties: {
code: { type: 'number', example: 0 },
message: { type: 'string', example: 'success' },
data: {
type: 'object',
properties: {
healthInviteCode: { type: 'string', example: 'ABC12345' },
},
},
},
},
})
async getHealthInviteCode(
@CurrentUser() user: AccessTokenPayload,
): Promise<{ code: ResponseCode; message: string; data: { healthInviteCode: string } }> {
this.logger.log(`获取健康邀请码 - 用户ID: ${user.sub}`);
return this.usersService.getHealthInviteCode(user.sub);
}
}

View File

@@ -10,6 +10,7 @@ import { BadgeConfig } from "./models/badge-config.model";
import { UserBadge } from "./models/user-badge.model";
import { UserDietHistory } from "./models/user-diet-history.model";
import { UserDailyHealth } from "./models/user-daily-health.model";
import { ApplePurchaseService } from "./services/apple-purchase.service";
import { UserActivity } from "./models/user-activity.model";
import { UserActivityService } from "./services/user-activity.service";
@@ -24,6 +25,7 @@ import { RevenueCatEvent } from "./models/revenue-cat-event.model";
import { CosService } from './cos.service';
import { BadgeService } from './services/badge.service';
import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
import { AiCoachModule } from '../ai-coach/ai-coach.module';
@Module({
imports: [
@@ -40,9 +42,11 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
UserBadge,
UserDietHistory,
UserDailyHealth,
UserActivity,
]),
forwardRef(() => ActivityLogsModule),
forwardRef(() => AiCoachModule),
JwtModule.register({
secret: process.env.JWT_ACCESS_SECRET || 'your-access-token-secret-key',
signOptions: { expiresIn: '30d' },
@@ -50,6 +54,6 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
],
controllers: [UsersController],
providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService, UserActivityService, BadgeService],
exports: [UsersService, AppleAuthService, UserActivityService, BadgeService],
exports: [UsersService, AppleAuthService, UserActivityService, BadgeService, CosService],
})
export class UsersModule { }

View File

@@ -15,6 +15,8 @@ import { ResponseCode } from 'src/base.dto';
import { Transaction, Op } from 'sequelize';
import { Sequelize } from 'sequelize-typescript';
import { UpdateUserDto, UpdateUserResponseDto } from './dto/update-user.dto';
import { ConfigService } from '@nestjs/config';
import { VersionCheckDto, VersionCheckResponseDto, VersionInfo } from './dto/version-check.dto';
import { UserPurchase, PurchaseType, PurchaseStatus, PurchasePlatform } from './models/user-purchase.model';
import { ApplePurchaseService } from './services/apple-purchase.service';
@@ -35,6 +37,8 @@ import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purch
import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model';
import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model';
import { UserBodyMeasurementHistory, BodyMeasurementType, MeasurementUpdateSource } from './models/user-body-measurement-history.model';
import { UserDailyHealth } from './models/user-daily-health.model';
import { UpdateDailyHealthDto, UpdateDailyHealthResponseDto } from './dto/daily-health.dto';
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
import { UserActivityService } from './services/user-activity.service';
@@ -76,18 +80,21 @@ export class UsersService {
private userWeightHistoryModel: typeof UserWeightHistory,
@InjectModel(UserBodyMeasurementHistory)
private userBodyMeasurementHistoryModel: typeof UserBodyMeasurementHistory,
@InjectModel(UserDailyHealth)
private userDailyHealthModel: typeof UserDailyHealth,
@InjectConnection()
private sequelize: Sequelize,
private readonly activityLogsService: ActivityLogsService,
private readonly userActivityService: UserActivityService,
private readonly badgeService: BadgeService,
private readonly configService: ConfigService,
) { }
async getProfile(user: AccessTokenPayload): Promise<UserResponseDto> {
async getProfile(user: AccessTokenPayload, appVersion?: string): Promise<UserResponseDto> {
try {
// 使用NestJS Logger (会通过winston输出)
this.logger.log(`getProfile: ${JSON.stringify(user)}`);
this.logger.log(`getProfile: ${JSON.stringify(user)}, appVersion: ${appVersion}`);
// 也可以直接使用winston logger
this.winstonLogger.info('getProfile method called', {
@@ -108,8 +115,13 @@ export class UsersService {
};
}
// 更新用户最后登录时间
// 更新用户最后登录时间和版本信息
existingUser.lastLogin = new Date();
if (appVersion && existingUser.appVersion !== appVersion) {
const oldVersion = existingUser.appVersion;
existingUser.appVersion = appVersion;
this.logger.log(`用户 ${existingUser.id} 版本更新: ${oldVersion || '无'} -> ${appVersion}`);
}
await existingUser.save();
const [profile] = await this.userProfileModel.findOrCreate({
@@ -132,6 +144,8 @@ export class UsersService {
...existingUser.toJSON(),
maxUsageCount: DEFAULT_FREE_USAGE_COUNT,
isVip: existingUser.isVip,
gender: existingUser.gender,
appVersion: existingUser.appVersion,
dailyStepsGoal: profile?.dailyStepsGoal,
dailyCaloriesGoal: profile?.dailyCaloriesGoal,
pilatesPurposes: profile?.pilatesPurposes,
@@ -197,6 +211,22 @@ export class UsersService {
}
}
async getUserLanguage(userId: string): Promise<string> {
try {
const user = await this.userModel.findOne({ where: { id: userId } });
if (!user) {
this.logger.warn(`getUserLanguage: ${userId} not found, default zh-CN`);
return 'zh-CN';
}
return user.language || 'zh-CN';
} catch (error) {
this.logger.error(`getUserLanguage error: ${error instanceof Error ? error.message : String(error)}`);
return 'zh-CN';
}
}
// 扣减用户免费次数
async deductUserUsageCount(userId: string, count: number = 1): Promise<void> {
try {
@@ -222,7 +252,7 @@ export class UsersService {
// 更新用户昵称、头像
async updateUser(updateUserDto: UpdateUserDto, userId: string): Promise<UpdateUserResponseDto> {
const { name, avatar, gender, birthDate, dailyStepsGoal, dailyCaloriesGoal, pilatesPurposes, weight, initialWeight, targetWeight, height, activityLevel } = updateUserDto;
const { name, avatar, gender, birthDate, language, dailyStepsGoal, dailyCaloriesGoal, pilatesPurposes, weight, initialWeight, targetWeight, height, activityLevel } = updateUserDto;
this.logger.log(`updateUser: ${JSON.stringify(updateUserDto, null, 2)}`);
@@ -252,6 +282,10 @@ export class UsersService {
user.birthDate = dayjs(birthDate * 1000).startOf('day').toDate();
userChanges.birthDate = birthDate;
}
if (language) {
user.language = language;
userChanges.language = language;
}
this.logger.log(`updateUser user: ${JSON.stringify(user, null, 2)}`);
@@ -2848,6 +2882,98 @@ export class UsersService {
}
}
/**
* 检查应用版本更新
*/
async checkVersion(query: VersionCheckDto): Promise<VersionCheckResponseDto> {
try {
this.logger.log(`版本检查请求 - 当前版本: ${query.currentVersion}, 平台: ${query.platform}`);
const currentVersion = query.currentVersion
if (!currentVersion) {
this.logger.log('当前版本号为空,返回默认版本信息');
return {
code: ResponseCode.SUCCESS,
message: '当前版本号为空',
data: null as any,
};
}
// 从环境变量获取配置
const latestVersion = this.configService.get<string>('APP_VERSION', '1.0.0');
const appStoreUrl = this.configService.get<string>('APP_STORE_URL', '');
// 版本比较
const needsUpdate = this.compareVersions(latestVersion, currentVersion) > 0;
// 构建响应数据
const versionInfo: VersionInfo = {
latestVersion,
appStoreUrl,
needsUpdate: needsUpdate,
updateMessage: this.getUpdateMessage(needsUpdate),
releaseNotes: this.getReleaseNotes(latestVersion),
};
this.logger.log(`版本检查结果: ${JSON.stringify(versionInfo)}`);
return {
code: ResponseCode.SUCCESS,
message: '版本检查成功',
data: versionInfo,
};
} catch (error) {
this.logger.error(`版本检查失败: ${error instanceof Error ? error.message : '未知错误'}`);
return {
code: ResponseCode.ERROR,
message: `版本检查失败: ${error instanceof Error ? error.message : '未知错误'}`,
data: null as any,
};
}
}
/**
* 比较两个语义化版本号
* @param version1 版本1
* @param version2 版本2
* @returns 1: version1 > version2, 0: version1 = version2, -1: version1 < version2
*/
private compareVersions(version1: string, version2: string): number {
const v1Parts = version1.split('.').map(Number);
const v2Parts = version2.split('.').map(Number);
for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) {
const v1Part = v1Parts[i] || 0;
const v2Part = v2Parts[i] || 0;
if (v1Part > v2Part) return 1;
if (v1Part < v2Part) return -1;
}
return 0;
}
/**
* 获取更新消息
*/
private getUpdateMessage(needsUpdate: boolean): string {
if (needsUpdate) {
return '发现新版本,建议更新到最新版本以获得更好的体验';
}
return '当前已是最新版本';
}
/**
* 获取版本发布说明
*/
private getReleaseNotes(version: string): string {
// 这里可以从数据库或配置文件中获取版本发布说明
// 暂时返回示例数据
return '1. 优化多语言配置\n2. 锻炼通知点击直接查看锻炼详情\n3. 修复已知问题';
}
/**
* 标记勋章已展示
*/
@@ -2877,4 +3003,172 @@ export class UsersService {
};
}
}
// ==================== 每日健康数据相关方法 ====================
/**
* 获取用户指定日期的健康数据
* @param userId 用户ID
* @param date 日期,格式 YYYY-MM-DD默认为今天
* @returns 健康数据记录,如果不存在则返回 null
*/
async getDailyHealth(userId: string, date?: string): Promise<UserDailyHealth | null> {
const recordDate = date || dayjs().format('YYYY-MM-DD');
this.logger.log(`获取每日健康数据 - 用户ID: ${userId}, 日期: ${recordDate}`);
const record = await this.userDailyHealthModel.findOne({
where: { userId, recordDate },
});
return record;
}
/**
* 更新用户每日健康数据
* 每日每个用户只会生成一条数据,如果已存在则更新
*/
async updateDailyHealth(userId: string, updateDto: UpdateDailyHealthDto): Promise<UpdateDailyHealthResponseDto> {
try {
// 确定记录日期,默认为今天
const recordDate = updateDto.date || dayjs().format('YYYY-MM-DD');
this.logger.log(`更新每日健康数据 - 用户ID: ${userId}, 日期: ${recordDate}`);
// 准备更新字段
const updateFields: Partial<UserDailyHealth> = {};
if (updateDto.waterIntake !== undefined) updateFields.waterIntake = updateDto.waterIntake;
if (updateDto.exerciseMinutes !== undefined) updateFields.exerciseMinutes = updateDto.exerciseMinutes;
if (updateDto.caloriesBurned !== undefined) updateFields.caloriesBurned = updateDto.caloriesBurned;
if (updateDto.standingMinutes !== undefined) updateFields.standingMinutes = updateDto.standingMinutes;
if (updateDto.basalMetabolism !== undefined) updateFields.basalMetabolism = updateDto.basalMetabolism;
if (updateDto.sleepMinutes !== undefined) updateFields.sleepMinutes = updateDto.sleepMinutes;
if (updateDto.bloodOxygen !== undefined) updateFields.bloodOxygen = updateDto.bloodOxygen;
if (updateDto.stressLevel !== undefined) updateFields.stressLevel = Math.round(updateDto.stressLevel * 10) / 10; // 保留一位小数
if (updateDto.steps !== undefined) updateFields.steps = updateDto.steps;
// 使用 upsert 实现创建或更新
const [record, created] = await this.userDailyHealthModel.findOrCreate({
where: { userId, recordDate },
defaults: {
userId,
recordDate,
...updateFields,
},
});
// 如果记录已存在,则更新
if (!created && Object.keys(updateFields).length > 0) {
await record.update(updateFields);
}
this.logger.log(`每日健康数据${created ? '创建' : '更新'}成功 - 记录ID: ${record.id}`);
return {
code: ResponseCode.SUCCESS,
message: 'success',
data: {
id: record.id,
userId: record.userId,
recordDate: record.recordDate,
waterIntake: record.waterIntake,
exerciseMinutes: record.exerciseMinutes,
caloriesBurned: record.caloriesBurned,
standingMinutes: record.standingMinutes,
basalMetabolism: record.basalMetabolism,
sleepMinutes: record.sleepMinutes,
bloodOxygen: record.bloodOxygen,
stressLevel: record.stressLevel,
steps: record.steps,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
},
};
} catch (error) {
this.logger.error(`更新每日健康数据失败: ${error instanceof Error ? error.message : '未知错误'}`);
return {
code: ResponseCode.ERROR,
message: `更新每日健康数据失败: ${error instanceof Error ? error.message : '未知错误'}`,
data: null as any,
};
}
}
/**
* 获取用户健康邀请码
* 如果用户没有邀请码,则生成一个新的
*/
async getHealthInviteCode(userId: string): Promise<{ code: ResponseCode; message: string; data: { healthInviteCode: string } }> {
try {
const user = await this.userModel.findByPk(userId);
if (!user) {
return {
code: ResponseCode.ERROR,
message: '用户不存在',
data: { healthInviteCode: '' },
};
}
// 如果用户已有邀请码,直接返回
if (user.healthInviteCode) {
return {
code: ResponseCode.SUCCESS,
message: 'success',
data: { healthInviteCode: user.healthInviteCode },
};
}
// 生成唯一的邀请码8位随机字母数字组合
const healthInviteCode = await this.generateUniqueInviteCode(8);
// 保存到数据库
user.healthInviteCode = healthInviteCode;
await user.save();
this.logger.log(`为用户 ${userId} 生成健康邀请码: ${healthInviteCode}`);
return {
code: ResponseCode.SUCCESS,
message: 'success',
data: { healthInviteCode },
};
} catch (error) {
this.logger.error(`获取健康邀请码失败: ${error instanceof Error ? error.message : '未知错误'}`);
return {
code: ResponseCode.ERROR,
message: `获取健康邀请码失败: ${error instanceof Error ? error.message : '未知错误'}`,
data: { healthInviteCode: '' },
};
}
}
/**
* 生成唯一的邀请码,确保不与数据库中已有的重复
*/
private async generateUniqueInviteCode(length: number, maxAttempts = 10): Promise<string> {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// 生成随机邀请码
let code = '';
for (let i = 0; i < length; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
// 检查是否已存在
const existing = await this.userModel.findOne({
where: { healthInviteCode: code },
});
if (!existing) {
return code;
}
this.logger.warn(`邀请码 ${code} 已存在,重新生成(第 ${attempt + 1} 次尝试)`);
}
// 如果多次尝试都失败,使用时间戳+随机数确保唯一性
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `${timestamp}${random}`.substring(0, length);
}
}

View File

@@ -1,170 +0,0 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsDateString, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min, Max } from 'class-validator';
import { WorkoutItemType, WorkoutExerciseStatus } from '../models/workout-exercise.model';
export class CreateWorkoutExerciseDto {
@ApiProperty({ description: '关联的动作key仅exercise类型需要', required: false })
@IsString()
@IsOptional()
exerciseKey?: string;
@ApiProperty({ description: '项目名称' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ description: '计划组数', required: false })
@IsInt()
@Min(0)
@IsOptional()
plannedSets?: number;
@ApiProperty({ description: '计划重复次数', required: false })
@IsInt()
@Min(0)
@IsOptional()
plannedReps?: number;
@ApiProperty({ description: '计划持续时长(秒)', required: false })
@IsInt()
@Min(0)
@IsOptional()
plannedDurationSec?: number;
@ApiProperty({ description: '休息时长(秒)', required: false })
@IsInt()
@Min(0)
@IsOptional()
restSec?: number;
@ApiProperty({ description: '备注', required: false })
@IsString()
@IsOptional()
note?: string;
@ApiProperty({
enum: ['exercise', 'rest', 'note'],
description: '项目类型',
default: 'exercise',
required: false
})
@IsEnum(['exercise', 'rest', 'note'])
@IsOptional()
itemType?: WorkoutItemType;
}
export class UpdateWorkoutExerciseDto extends PartialType(CreateWorkoutExerciseDto) {
@ApiProperty({ description: '实际完成组数', required: false })
@IsInt()
@Min(0)
@IsOptional()
completedSets?: number;
@ApiProperty({ description: '实际完成重复次数', required: false })
@IsInt()
@Min(0)
@IsOptional()
completedReps?: number;
@ApiProperty({ description: '实际持续时长(秒)', required: false })
@IsInt()
@Min(0)
@IsOptional()
actualDurationSec?: number;
@ApiProperty({ enum: ['pending', 'in_progress', 'completed', 'skipped'], required: false })
@IsEnum(['pending', 'in_progress', 'completed', 'skipped'])
@IsOptional()
status?: WorkoutExerciseStatus;
}
export class StartWorkoutExerciseDto {
@ApiProperty({ description: '开始时间', required: false })
@IsDateString()
@IsOptional()
startedAt?: string;
}
export class CompleteWorkoutExerciseDto {
@ApiProperty({ description: '实际完成组数', required: false })
@IsInt()
@Min(0)
@IsOptional()
completedSets?: number;
@ApiProperty({ description: '实际完成重复次数', required: false })
@IsInt()
@Min(0)
@IsOptional()
completedReps?: number;
@ApiProperty({ description: '实际持续时长(秒)', required: false })
@IsInt()
@Min(0)
@IsOptional()
actualDurationSec?: number;
@ApiProperty({ description: '完成时间', required: false })
@IsDateString()
@IsOptional()
completedAt?: string;
@ApiProperty({ description: '详细执行数据', required: false })
@IsOptional()
performanceData?: {
sets?: Array<{
reps?: number;
weight?: number;
duration?: number;
restTime?: number;
difficulty?: number;
notes?: string;
}>;
heartRate?: {
avg?: number;
max?: number;
};
perceivedExertion?: number;
};
}
export class UpdateWorkoutExerciseOrderDto {
@ApiProperty({ description: '动作ID列表按新的顺序排列' })
@IsArray()
@IsString({ each: true })
exerciseIds: string[];
}
export class WorkoutExerciseResponseDto {
@ApiProperty() id: string;
@ApiProperty() workoutSessionId: string;
@ApiProperty() userId: string;
@ApiProperty({ required: false }) exerciseKey?: string;
@ApiProperty() name: string;
@ApiProperty({ required: false }) plannedSets?: number;
@ApiProperty({ required: false }) completedSets?: number;
@ApiProperty({ required: false }) plannedReps?: number;
@ApiProperty({ required: false }) completedReps?: number;
@ApiProperty({ required: false }) plannedDurationSec?: number;
@ApiProperty({ required: false }) actualDurationSec?: number;
@ApiProperty({ required: false }) restSec?: number;
@ApiProperty({ required: false }) note?: string;
@ApiProperty({ enum: ['exercise', 'rest', 'note'] }) itemType: WorkoutItemType;
@ApiProperty({ enum: ['pending', 'in_progress', 'completed', 'skipped'] }) status: WorkoutExerciseStatus;
@ApiProperty() sortOrder: number;
@ApiProperty({ required: false }) startedAt?: Date;
@ApiProperty({ required: false }) completedAt?: Date;
@ApiProperty({ required: false }) performanceData?: any;
@ApiProperty() createdAt: Date;
@ApiProperty() updatedAt: Date;
// 关联的动作信息仅exercise类型时存在
@ApiProperty({ required: false })
exercise?: {
key: string;
name: string;
description: string;
categoryKey: string;
categoryName: string;
};
}

View File

@@ -1,93 +0,0 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsDateString, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { WorkoutStatus } from '../models/workout-session.model';
export class CreateWorkoutSessionDto {
@ApiProperty({ description: '训练计划ID基于训练计划创建时必填', required: false })
@IsUUID()
@IsOptional()
trainingPlanId?: string;
@ApiProperty({ description: '训练会话名称' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ description: '计划训练日期', required: false })
@IsDateString()
@IsOptional()
scheduledDate?: string;
@ApiProperty({ description: '自定义训练动作列表(自定义训练时使用)', required: false, type: 'array' })
@IsArray()
@IsOptional()
customExercises?: Array<{
exerciseKey?: string;
name: string;
plannedSets?: number;
plannedReps?: number;
plannedDurationSec?: number;
restSec?: number;
note?: string;
itemType?: 'exercise' | 'rest' | 'note';
sortOrder: number;
}>;
}
export class StartWorkoutDto {
@ApiProperty({ description: '实际开始时间', required: false })
@IsDateString()
@IsOptional()
startedAt?: string;
}
// 注意训练会话自动完成不需要手动完成DTO
export class UpdateWorkoutSessionDto {
@ApiProperty({ description: '训练总结', required: false })
@IsString()
@IsOptional()
summary?: string;
@ApiProperty({ description: '消耗卡路里', required: false })
@IsInt()
@Min(0)
@IsOptional()
caloriesBurned?: number;
}
export class WorkoutSessionResponseDto {
@ApiProperty() id: string;
@ApiProperty() userId: string;
@ApiProperty() trainingPlanId: string;
@ApiProperty() name: string;
@ApiProperty() scheduledDate: Date;
@ApiProperty({ required: false }) startedAt?: Date;
@ApiProperty({ required: false }) completedAt?: Date;
@ApiProperty({ enum: ['planned', 'in_progress', 'completed', 'skipped'] }) status: WorkoutStatus;
@ApiProperty({ required: false }) totalDurationSec?: number;
@ApiProperty({ required: false }) summary?: string;
@ApiProperty({ required: false }) caloriesBurned?: number;
@ApiProperty({ required: false }) stats?: {
totalExercises?: number;
completedExercises?: number;
totalSets?: number;
completedSets?: number;
totalReps?: number;
completedReps?: number;
};
@ApiProperty() createdAt: Date;
@ApiProperty() updatedAt: Date;
// 关联的训练计划信息
@ApiProperty({ required: false })
trainingPlan?: {
id: string;
name: string;
goal: string;
};
// 训练动作列表
@ApiProperty({ required: false, type: 'array' })
exercises?: any[];
}

View File

@@ -1,115 +0,0 @@
import { Column, DataType, ForeignKey, Model, PrimaryKey, Table, BelongsTo } from 'sequelize-typescript';
import { WorkoutSession } from './workout-session.model';
import { Exercise } from '../../exercises/models/exercise.model';
export type WorkoutItemType = 'exercise' | 'rest' | 'note';
export type WorkoutExerciseStatus = 'pending' | 'in_progress' | 'completed' | 'skipped';
@Table({
tableName: 't_workout_exercises',
underscored: true,
})
export class WorkoutExercise extends Model {
@PrimaryKey
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
})
declare id: string;
@ForeignKey(() => WorkoutSession)
@Column({ type: DataType.UUID, allowNull: false })
declare workoutSessionId: string;
@BelongsTo(() => WorkoutSession)
declare workoutSession: WorkoutSession;
@Column({ type: DataType.STRING, allowNull: false })
declare userId: string;
// 关联到动作库仅exercise类型需要
@ForeignKey(() => Exercise)
@Column({ type: DataType.STRING, allowNull: true, comment: '关联的动作key仅exercise类型' })
declare exerciseKey: string;
@BelongsTo(() => Exercise, { foreignKey: 'exerciseKey', targetKey: 'key' })
declare exercise: Exercise;
@Column({ type: DataType.STRING, allowNull: false, comment: '项目名称' })
declare name: string;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '计划组数' })
declare plannedSets: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '实际完成组数' })
declare completedSets: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '计划重复次数' })
declare plannedReps: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '实际完成重复次数' })
declare completedReps: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '计划持续时长(秒)' })
declare plannedDurationSec: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '实际持续时长(秒)' })
declare actualDurationSec: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '休息时长(秒)' })
declare restSec: number;
@Column({ type: DataType.TEXT, allowNull: true, comment: '备注' })
declare note: string;
@Column({
type: DataType.ENUM('exercise', 'rest', 'note'),
allowNull: false,
defaultValue: 'exercise',
comment: '项目类型'
})
declare itemType: WorkoutItemType;
@Column({
type: DataType.ENUM('pending', 'in_progress', 'completed', 'skipped'),
allowNull: false,
defaultValue: 'pending',
comment: '动作状态'
})
declare status: WorkoutExerciseStatus;
@Column({ type: DataType.INTEGER, allowNull: false, comment: '排序顺序' })
declare sortOrder: number;
@Column({ type: DataType.DATE, allowNull: true, comment: '开始时间' })
declare startedAt: Date;
@Column({ type: DataType.DATE, allowNull: true, comment: '完成时间' })
declare completedAt: Date;
@Column({ type: DataType.JSON, allowNull: true, comment: '详细执行数据' })
declare performanceData: {
sets?: Array<{
reps?: number;
weight?: number;
duration?: number;
restTime?: number;
difficulty?: number; // 1-10
notes?: string;
}>;
heartRate?: {
avg?: number;
max?: number;
};
perceivedExertion?: number; // 1-10 RPE scale
};
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare createdAt: Date;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare updatedAt: Date;
@Column({ type: DataType.BOOLEAN, defaultValue: false, comment: '是否已删除' })
declare deleted: boolean;
}

View File

@@ -1,79 +0,0 @@
import { Column, DataType, ForeignKey, Model, PrimaryKey, Table, BelongsTo, HasMany } from 'sequelize-typescript';
import { TrainingPlan } from '../../training-plans/models/training-plan.model';
import { WorkoutExercise } from './workout-exercise.model';
export type WorkoutStatus = 'planned' | 'in_progress' | 'completed' | 'skipped';
@Table({
tableName: 't_workout_sessions',
underscored: true,
})
export class WorkoutSession extends Model {
@PrimaryKey
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
})
declare id: string;
@Column({ type: DataType.STRING, allowNull: false })
declare userId: string;
@ForeignKey(() => TrainingPlan)
@Column({ type: DataType.UUID, allowNull: true, comment: '关联的训练计划模板' })
declare trainingPlanId: string;
@BelongsTo(() => TrainingPlan)
declare trainingPlan: TrainingPlan;
@HasMany(() => WorkoutExercise)
declare exercises: WorkoutExercise[];
@Column({ type: DataType.STRING, allowNull: false, comment: '训练会话名称' })
declare name: string;
@Column({ type: DataType.DATE, allowNull: false, comment: '计划训练日期' })
declare scheduledDate: Date;
@Column({ type: DataType.DATE, allowNull: true, comment: '实际开始时间' })
declare startedAt: Date;
@Column({ type: DataType.DATE, allowNull: true, comment: '实际结束时间' })
declare completedAt: Date;
@Column({
type: DataType.ENUM('planned', 'in_progress', 'completed', 'skipped'),
allowNull: false,
defaultValue: 'planned',
comment: '训练状态'
})
declare status: WorkoutStatus;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '总时长(秒)' })
declare totalDurationSec: number;
@Column({ type: DataType.TEXT, allowNull: true, comment: '训练总结/备注' })
declare summary: string;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '消耗卡路里(估算)' })
declare caloriesBurned: number;
@Column({ type: DataType.JSON, allowNull: true, comment: '训练统计数据' })
declare stats: {
totalExercises?: number;
completedExercises?: number;
totalSets?: number;
completedSets?: number;
totalReps?: number;
completedReps?: number;
};
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare createdAt: Date;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare updatedAt: Date;
@Column({ type: DataType.BOOLEAN, defaultValue: false, comment: '是否已删除' })
declare deleted: boolean;
}

View File

@@ -1,210 +0,0 @@
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards, Put } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { WorkoutsService } from './workouts.service';
import {
CreateWorkoutSessionDto,
StartWorkoutDto,
UpdateWorkoutSessionDto,
WorkoutSessionResponseDto
} from './dto/workout-session.dto';
import {
CreateWorkoutExerciseDto,
UpdateWorkoutExerciseDto,
StartWorkoutExerciseDto,
CompleteWorkoutExerciseDto,
UpdateWorkoutExerciseOrderDto,
WorkoutExerciseResponseDto
} from './dto/workout-exercise.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
@ApiTags('workouts')
@Controller('workouts')
@UseGuards(JwtAuthGuard)
export class WorkoutsController {
constructor(private readonly workoutsService: WorkoutsService) { }
// ==================== 训练会话管理 ====================
@Post('sessions')
@ApiOperation({ summary: '创建训练会话(支持基于训练计划或自定义动作)' })
@ApiBody({ type: CreateWorkoutSessionDto })
async createSession(@CurrentUser() user: AccessTokenPayload, @Body() dto: CreateWorkoutSessionDto) {
return this.workoutsService.createWorkoutSession(user.sub, dto);
}
@Get('sessions')
@ApiOperation({ summary: '获取训练会话列表' })
async getSessions(
@CurrentUser() user: AccessTokenPayload,
@Query('page') page: number = 1,
@Query('limit') limit: number = 10,
) {
return this.workoutsService.getWorkoutSessions(user.sub, page, limit);
}
@Get('sessions/:id')
@ApiOperation({ summary: '获取训练会话详情' })
@ApiParam({ name: 'id', description: '训练会话ID' })
async getSessionDetail(@CurrentUser() user: AccessTokenPayload, @Param('id') sessionId: string) {
return this.workoutsService.getWorkoutSessionDetail(user.sub, sessionId);
}
@Post('sessions/:id/start')
@ApiOperation({ summary: '开始训练会话' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiBody({ type: StartWorkoutDto, required: false })
async startSession(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Body() dto: StartWorkoutDto = {},
) {
return this.workoutsService.startWorkoutSession(user.sub, sessionId, dto);
}
// 注意:训练会话自动完成,无需手动标记
// 当所有动作完成时,会话自动标记为完成
@Delete('sessions/:id')
@ApiOperation({ summary: '删除训练会话' })
@ApiParam({ name: 'id', description: '训练会话ID' })
async deleteSession(@CurrentUser() user: AccessTokenPayload, @Param('id') sessionId: string) {
return this.workoutsService.deleteWorkoutSession(user.sub, sessionId);
}
// ==================== 训练动作管理 ====================
@Post('sessions/:id/exercises')
@ApiOperation({ summary: '向训练会话添加自定义动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiBody({ type: CreateWorkoutExerciseDto })
async addExerciseToSession(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Body() dto: CreateWorkoutExerciseDto,
) {
return this.workoutsService.addExerciseToSession(user.sub, sessionId, dto);
}
@Get('sessions/:id/exercises')
@ApiOperation({ summary: '获取训练会话的所有动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
async getSessionExercises(@CurrentUser() user: AccessTokenPayload, @Param('id') sessionId: string) {
return this.workoutsService.getWorkoutExercises(user.sub, sessionId);
}
@Get('sessions/:id/exercises/:exerciseId')
@ApiOperation({ summary: '获取训练动作详情' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
async getExerciseDetail(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
) {
return this.workoutsService.getWorkoutExerciseDetail(user.sub, sessionId, exerciseId);
}
@Post('sessions/:id/exercises/:exerciseId/start')
@ApiOperation({ summary: '开始训练动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
@ApiBody({ type: StartWorkoutExerciseDto, required: false })
async startExercise(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
@Body() dto: StartWorkoutExerciseDto = {},
) {
return this.workoutsService.startWorkoutExercise(user.sub, sessionId, exerciseId, dto);
}
@Post('sessions/:id/exercises/:exerciseId/complete')
@ApiOperation({ summary: '完成训练动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
@ApiBody({ type: CompleteWorkoutExerciseDto })
async completeExercise(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
@Body() dto: CompleteWorkoutExerciseDto,
) {
return this.workoutsService.completeWorkoutExercise(user.sub, sessionId, exerciseId, dto);
}
@Post('sessions/:id/exercises/:exerciseId/skip')
@ApiOperation({ summary: '跳过训练动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
async skipExercise(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
) {
return this.workoutsService.skipWorkoutExercise(user.sub, sessionId, exerciseId);
}
@Put('sessions/:id/exercises/:exerciseId')
@ApiOperation({ summary: '更新训练动作信息' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
@ApiBody({ type: UpdateWorkoutExerciseDto })
async updateExercise(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
@Body() dto: UpdateWorkoutExerciseDto,
) {
return this.workoutsService.updateWorkoutExercise(user.sub, sessionId, exerciseId, dto);
}
// ==================== 统计和分析 ====================
@Get('sessions/:id/stats')
@ApiOperation({ summary: '获取训练会话统计数据' })
@ApiParam({ name: 'id', description: '训练会话ID' })
async getSessionStats(@CurrentUser() user: AccessTokenPayload, @Param('id') sessionId: string) {
const session = await this.workoutsService.getWorkoutSessionDetail(user.sub, sessionId);
return {
status: session.status,
duration: session.totalDurationSec,
calories: session.caloriesBurned,
stats: session.stats,
exerciseCount: session.exercises?.length || 0,
completedExercises: session.exercises?.filter((e: any) => e.status === 'completed').length || 0,
};
}
// ==================== 快捷操作 ====================
@Get('today')
@ApiOperation({ summary: '获取/创建今日训练会话(基于激活的训练计划)' })
async getTodayWorkout(@CurrentUser() user: AccessTokenPayload) {
return this.workoutsService.getTodayWorkoutSession(user.sub);
}
@Get('recent')
@ApiOperation({ summary: '获取最近的训练会话' })
async getRecentWorkouts(
@CurrentUser() user: AccessTokenPayload,
@Query('days') days: number = 7,
@Query('limit') limit: number = 10,
) {
const sessions = await this.workoutsService.getWorkoutSessions(user.sub, 1, limit);
// 简化版本,实际应该在数据库层面过滤
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
const recentSessions = sessions.sessions.filter(session =>
new Date(session.scheduledDate) >= cutoffDate
);
return {
sessions: recentSessions,
period: `最近${days}`,
};
}
}

View File

@@ -1,29 +0,0 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { WorkoutsController } from './workouts.controller';
import { WorkoutsService } from './workouts.service';
import { WorkoutSession } from './models/workout-session.model';
import { WorkoutExercise } from './models/workout-exercise.model';
import { TrainingPlan } from '../training-plans/models/training-plan.model';
import { ScheduleExercise } from '../training-plans/models/schedule-exercise.model';
import { Exercise } from '../exercises/models/exercise.model';
import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
SequelizeModule.forFeature([
WorkoutSession,
WorkoutExercise,
TrainingPlan,
ScheduleExercise,
Exercise,
]),
ActivityLogsModule,
UsersModule,
],
controllers: [WorkoutsController],
providers: [WorkoutsService],
exports: [WorkoutsService],
})
export class WorkoutsModule { }

View File

@@ -1,712 +0,0 @@
import { Inject, Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { WorkoutSession } from './models/workout-session.model';
import { WorkoutExercise } from './models/workout-exercise.model';
import { TrainingPlan } from '../training-plans/models/training-plan.model';
import { ScheduleExercise } from '../training-plans/models/schedule-exercise.model';
import { Exercise } from '../exercises/models/exercise.model';
import {
CreateWorkoutSessionDto,
StartWorkoutDto,
} from './dto/workout-session.dto';
import {
CreateWorkoutExerciseDto,
UpdateWorkoutExerciseDto,
StartWorkoutExerciseDto,
CompleteWorkoutExerciseDto,
} from './dto/workout-exercise.dto';
import { Logger as WinstonLogger } from 'winston';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Op } from 'sequelize';
@Injectable()
export class WorkoutsService {
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger;
constructor(
@InjectModel(WorkoutSession)
private workoutSessionModel: typeof WorkoutSession,
@InjectModel(WorkoutExercise)
private workoutExerciseModel: typeof WorkoutExercise,
@InjectModel(TrainingPlan)
private trainingPlanModel: typeof TrainingPlan,
@InjectModel(ScheduleExercise)
private scheduleExerciseModel: typeof ScheduleExercise,
@InjectModel(Exercise)
private exerciseModel: typeof Exercise,
) { }
// ==================== 训练会话管理 ====================
/**
* 创建训练会话(支持基于训练计划或自定义动作)
*/
async createWorkoutSession(userId: string, dto: CreateWorkoutSessionDto) {
if (dto.trainingPlanId) {
// 基于训练计划创建
return this.createWorkoutSessionFromPlan(
userId,
dto.trainingPlanId,
dto.scheduledDate ? new Date(dto.scheduledDate) : new Date(),
dto.name
);
} else {
// 基于自定义动作创建
return this.createCustomWorkoutSession(userId, dto);
}
}
/**
* 获取今日训练会话,如果不存在则自动创建
*/
async getTodayWorkoutSession(userId: string) {
const today = new Date();
today.setHours(0, 0, 0, 0); // 设置为今日0点
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// 查找今日是否已有训练会话
let session = await this.workoutSessionModel.findOne({
where: {
userId,
deleted: false,
scheduledDate: {
[Op.gte]: today,
[Op.lt]: tomorrow
}
},
include: [
{
model: TrainingPlan,
required: false,
attributes: ['id', 'name', 'goal']
},
{
model: WorkoutExercise,
required: false,
include: [
{
model: Exercise,
required: false,
attributes: ['key', 'name', 'description', 'categoryKey', 'categoryName']
}
],
order: [['sortOrder', 'ASC']]
}
],
});
if (session) {
return session.toJSON();
}
// 如果没有训练会话,查找激活的训练计划
const activeTrainingPlan = await this.trainingPlanModel.findOne({
where: { userId, isActive: true, deleted: false }
});
if (!activeTrainingPlan) {
this.winstonLogger.info(`今日没有激活的训练计划`, {
context: 'WorkoutsService',
userId,
});
return null;
}
// 创建今日训练会话
return this.createWorkoutSessionFromPlan(userId, activeTrainingPlan.id, today);
}
/**
* 创建自定义训练会话
*/
private async createCustomWorkoutSession(userId: string, dto: CreateWorkoutSessionDto) {
const transaction = await this.workoutSessionModel.sequelize?.transaction();
if (!transaction) throw new Error('Failed to start transaction');
this.winstonLogger.info(`创建自定义训练会话`, {
context: 'WorkoutsService',
userId,
dto,
});
try {
// 1. 创建训练会话
const workoutSession = await this.workoutSessionModel.create({
userId,
name: dto.name,
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : new Date(),
status: 'planned',
}, { transaction });
// 2. 创建自定义动作
if (dto.customExercises && dto.customExercises.length > 0) {
for (const customExercise of dto.customExercises!) {
// 如果有exerciseKey验证动作是否存在
if (customExercise.exerciseKey) {
const exercise = await this.exerciseModel.findByPk(customExercise.exerciseKey);
if (!exercise) {
throw new NotFoundException(`动作 "${customExercise.exerciseKey}" 不存在`);
}
}
await this.workoutExerciseModel.create({
workoutSessionId: workoutSession.id,
userId,
exerciseKey: customExercise.exerciseKey,
name: customExercise.name,
plannedSets: customExercise.plannedSets,
plannedReps: customExercise.plannedReps,
plannedDurationSec: customExercise.plannedDurationSec,
restSec: customExercise.restSec,
note: customExercise.note || '',
itemType: customExercise.itemType || 'exercise',
status: 'pending',
sortOrder: customExercise.sortOrder,
}, { transaction });
}
}
this.winstonLogger.info(`创建自定义训练会话 ${workoutSession.id}`, {
context: 'WorkoutsService',
userId,
workoutSessionId: workoutSession.id,
exerciseCount: dto.customExercises?.length,
});
await transaction.commit();
return workoutSession.toJSON();
} catch (error) {
this.winstonLogger.error(`创建自定义训练会话失败`, {
context: 'WorkoutsService',
userId,
dto,
error,
});
await transaction.rollback();
throw error;
}
}
/**
* 从训练计划创建训练会话(内部方法)
*/
private async createWorkoutSessionFromPlan(userId: string, trainingPlanId: string, scheduledDate: Date, name?: string) {
const trainingPlan = await this.trainingPlanModel.findOne({
where: { id: trainingPlanId, userId, deleted: false }
});
if (!trainingPlan) {
throw new NotFoundException('训练计划不存在或不属于当前用户');
}
const transaction = await this.workoutSessionModel.sequelize?.transaction();
if (!transaction) throw new Error('Failed to start transaction');
try {
// 1. 创建训练会话
const workoutSession = await this.workoutSessionModel.create({
userId,
trainingPlanId,
name: name || trainingPlan.name || '今日训练',
scheduledDate,
status: 'planned',
}, { transaction });
// 2. 复制训练计划中的动作到训练会话
const scheduleExercises = await this.scheduleExerciseModel.findAll({
where: { trainingPlanId, userId, deleted: false },
order: [['sortOrder', 'ASC']],
transaction
});
for (const scheduleExercise of scheduleExercises) {
await this.workoutExerciseModel.create({
workoutSessionId: workoutSession.id,
userId,
exerciseKey: scheduleExercise.exerciseKey,
name: scheduleExercise.name,
plannedSets: scheduleExercise.sets,
plannedReps: scheduleExercise.reps,
plannedDurationSec: scheduleExercise.durationSec,
restSec: scheduleExercise.restSec,
note: scheduleExercise.note,
itemType: scheduleExercise.itemType,
status: 'pending',
sortOrder: scheduleExercise.sortOrder,
}, { transaction });
}
await transaction.commit();
this.winstonLogger.info(`自动创建训练会话 ${workoutSession.id}`, {
context: 'WorkoutsService',
userId,
trainingPlanId,
workoutSessionId: workoutSession.id,
});
return this.getWorkoutSessionDetail(userId, workoutSession.id);
} catch (error) {
await transaction.rollback();
throw error;
}
}
/**
* 开始训练会话
*/
async startWorkoutSession(userId: string, sessionId: string, dto: StartWorkoutDto = {}) {
try {
const session = await this.workoutSessionModel.findOne({
where: { id: sessionId, userId, deleted: false }
});
if (!session) {
throw new NotFoundException('训练会话不存在');
}
if (session.status !== 'planned') {
throw new BadRequestException('只能开始计划中的训练会话');
}
// 是否有训练动作,没有的话提示添加
const exercises = await this.workoutExerciseModel.findAll({
where: { workoutSessionId: sessionId, deleted: false }
});
if (exercises.length === 0) {
throw new BadRequestException('请先添加训练动作');
}
const startTime = dto.startedAt ? new Date(dto.startedAt) : new Date();
session.startedAt = startTime;
session.status = 'in_progress';
await session.save();
this.winstonLogger.info(`开始训练会话 ${sessionId}`, {
context: 'WorkoutsService',
userId,
sessionId,
});
return session.toJSON();
} catch (error) {
this.winstonLogger.error(`开始训练会话失败 ${sessionId}`, {
context: 'WorkoutsService',
userId,
sessionId,
error,
});
throw error;
}
}
// 注意:训练会话现在自动完成,不需要手动完成方法
/**
* 获取训练会话列表
*/
async getWorkoutSessions(userId: string, page: number = 1, limit: number = 10) {
const offset = (page - 1) * limit;
const { rows: sessions, count } = await this.workoutSessionModel.findAndCountAll({
where: { userId, deleted: false },
include: [
{
model: TrainingPlan,
attributes: ['id', 'name', 'goal']
}
],
order: [['createdAt', 'DESC']],
limit,
offset,
});
return {
sessions,
pagination: {
page,
limit,
total: count,
totalPages: Math.ceil(count / limit),
}
};
}
/**
* 获取训练会话详情
*/
async getWorkoutSessionDetail(userId: string, sessionId: string) {
const session = await this.workoutSessionModel.findOne({
where: { id: sessionId, userId, deleted: false },
include: [
{
model: TrainingPlan,
required: false,
attributes: ['id', 'name', 'goal']
},
{
model: WorkoutExercise,
required: false,
include: [
{
model: Exercise,
required: false,
attributes: ['key', 'name', 'description', 'categoryKey', 'categoryName']
}
],
order: [['sortOrder', 'ASC']]
}
],
});
if (!session) {
throw new NotFoundException('训练会话不存在');
}
return session.toJSON();
}
/**
* 删除训练会话
*/
async deleteWorkoutSession(userId: string, sessionId: string) {
const transaction = await this.workoutSessionModel.sequelize?.transaction();
if (!transaction) throw new Error('Failed to start transaction');
try {
const [count] = await this.workoutSessionModel.update(
{ deleted: true },
{ where: { id: sessionId, userId, deleted: false }, transaction }
);
if (count === 0) {
throw new NotFoundException('训练会话不存在');
}
// 同时删除关联的训练动作
await this.workoutExerciseModel.update(
{ deleted: true },
{ where: { workoutSessionId: sessionId, userId, deleted: false }, transaction }
);
await transaction.commit();
this.winstonLogger.info(`删除训练会话 ${sessionId}`, {
context: 'WorkoutsService',
userId,
sessionId,
});
return { success: true };
} catch (error) {
await transaction.rollback();
this.winstonLogger.error(`删除训练会话失败 ${sessionId}`, {
context: 'WorkoutsService',
userId,
sessionId,
error,
});
throw error;
}
}
// ==================== 训练动作管理 ====================
/**
* 向训练会话添加自定义动作
*/
async addExerciseToSession(userId: string, sessionId: string, dto: CreateWorkoutExerciseDto) {
const session = await this.validateWorkoutSession(userId, sessionId);
if (session.status === 'completed') {
throw new BadRequestException('已完成的训练会话无法添加动作');
}
// 如果有exerciseKey验证动作是否存在
if (dto.exerciseKey) {
const exercise = await this.exerciseModel.findByPk(dto.exerciseKey);
if (!exercise) {
throw new NotFoundException(`动作 "${dto.exerciseKey}" 不存在`);
}
}
// 获取下一个排序顺序
const lastExercise = await this.workoutExerciseModel.findOne({
where: { workoutSessionId: sessionId, deleted: false },
order: [['sortOrder', 'DESC']],
});
const sortOrder = lastExercise ? lastExercise.sortOrder + 1 : 1;
const exercise = await this.workoutExerciseModel.create({
workoutSessionId: sessionId,
userId,
exerciseKey: dto.exerciseKey,
name: dto.name,
plannedSets: dto.plannedSets,
plannedReps: dto.plannedReps,
plannedDurationSec: dto.plannedDurationSec,
restSec: dto.restSec,
note: dto.note || '',
itemType: dto.itemType || 'exercise',
status: 'pending',
sortOrder,
});
this.winstonLogger.info(`向训练会话添加动作 ${exercise.id}`, {
context: 'WorkoutsService',
userId,
sessionId,
exerciseId: exercise.id,
});
return exercise.toJSON();
}
/**
* 开始训练动作
*/
async startWorkoutExercise(userId: string, sessionId: string, exerciseId: string, dto: StartWorkoutExerciseDto = {}) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
if (exercise.status !== 'pending') {
throw new BadRequestException('只能开始待执行的训练动作');
}
const startTime = dto.startedAt ? new Date(dto.startedAt) : new Date();
exercise.startedAt = startTime;
exercise.status = 'in_progress';
await exercise.save();
this.winstonLogger.info(`开始训练动作 ${exerciseId}`, {
context: 'WorkoutsService',
userId,
sessionId,
exerciseId,
});
return exercise.toJSON();
}
/**
* 完成训练动作
*/
async completeWorkoutExercise(userId: string, sessionId: string, exerciseId: string, dto: CompleteWorkoutExerciseDto) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
if (exercise.status === 'completed') {
throw new BadRequestException('训练动作已经完成');
}
const completedTime = dto.completedAt ? new Date(dto.completedAt) : new Date();
exercise.completedAt = completedTime;
exercise.status = 'completed';
exercise.completedSets = dto.completedSets || exercise.completedSets;
exercise.completedReps = dto.completedReps || exercise.completedReps;
exercise.actualDurationSec = dto.actualDurationSec || exercise.actualDurationSec;
exercise.performanceData = dto.performanceData || exercise.performanceData;
// 计算实际时长(如果没有传入)
if (!dto.actualDurationSec && exercise.startedAt) {
exercise.actualDurationSec = Math.floor((completedTime.getTime() - exercise.startedAt.getTime()) / 1000);
}
await exercise.save();
this.winstonLogger.info(`完成训练动作 ${exerciseId}`, {
context: 'WorkoutsService',
userId,
sessionId,
exerciseId,
});
// 检查是否所有动作都完成,如果是则自动完成训练会话
await this.checkAndAutoCompleteSession(userId, sessionId);
return exercise.toJSON();
}
/**
* 跳过训练动作
*/
async skipWorkoutExercise(userId: string, sessionId: string, exerciseId: string) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
if (exercise.status === 'completed') {
throw new BadRequestException('已完成的训练动作不能跳过');
}
exercise.status = 'skipped';
await exercise.save();
this.winstonLogger.info(`跳过训练动作 ${exerciseId}`, {
context: 'WorkoutsService',
userId,
sessionId,
exerciseId,
});
// 检查是否所有动作都完成,如果是则自动完成训练会话
await this.checkAndAutoCompleteSession(userId, sessionId);
return exercise.toJSON();
}
/**
* 更新训练动作
*/
async updateWorkoutExercise(userId: string, sessionId: string, exerciseId: string, dto: UpdateWorkoutExerciseDto) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
// 更新字段
Object.assign(exercise, dto);
await exercise.save();
this.winstonLogger.info(`更新训练动作 ${exerciseId}`, {
context: 'WorkoutsService',
userId,
sessionId,
exerciseId,
});
return exercise.toJSON();
}
/**
* 获取训练会话的所有动作
*/
async getWorkoutExercises(userId: string, sessionId: string) {
await this.validateWorkoutSession(userId, sessionId);
const exercises = await this.workoutExerciseModel.findAll({
where: { workoutSessionId: sessionId, userId, deleted: false },
include: [
{
model: Exercise,
required: false,
attributes: ['key', 'name', 'description', 'categoryKey', 'categoryName']
}
],
order: [['sortOrder', 'ASC']],
});
return exercises.map(e => e.toJSON());
}
/**
* 获取训练动作详情
*/
async getWorkoutExerciseDetail(userId: string, sessionId: string, exerciseId: string) {
const exercise = await this.workoutExerciseModel.findOne({
where: { id: exerciseId, workoutSessionId: sessionId, userId, deleted: false },
include: [
{
model: Exercise,
required: false,
attributes: ['key', 'name', 'description', 'categoryKey', 'categoryName']
}
],
});
if (!exercise) {
throw new NotFoundException('训练动作不存在');
}
return exercise.toJSON();
}
// ==================== 工具方法 ====================
private async validateWorkoutSession(userId: string, sessionId: string): Promise<WorkoutSession> {
const session = await this.workoutSessionModel.findOne({
where: { id: sessionId, userId, deleted: false }
});
if (!session) {
throw new NotFoundException('训练会话不存在');
}
return session;
}
private async validateWorkoutExercise(userId: string, sessionId: string, exerciseId: string): Promise<WorkoutExercise> {
const exercise = await this.workoutExerciseModel.findOne({
where: { id: exerciseId, workoutSessionId: sessionId, userId, deleted: false }
});
if (!exercise) {
throw new NotFoundException('训练动作不存在');
}
return exercise;
}
private async calculateWorkoutStats(sessionId: string) {
const exercises = await this.workoutExerciseModel.findAll({
where: { workoutSessionId: sessionId, deleted: false, itemType: 'exercise' }
});
const stats = {
totalExercises: exercises.length,
completedExercises: exercises.filter(e => e.status === 'completed').length,
totalSets: exercises.reduce((sum, e) => sum + (e.plannedSets || 0), 0),
completedSets: exercises.reduce((sum, e) => sum + (e.completedSets || 0), 0),
totalReps: exercises.reduce((sum, e) => sum + (e.plannedReps || 0), 0),
completedReps: exercises.reduce((sum, e) => sum + (e.completedReps || 0), 0),
};
return stats;
}
/**
* 检查并自动完成训练会话
*/
private async checkAndAutoCompleteSession(userId: string, sessionId: string) {
const session = await this.workoutSessionModel.findOne({
where: { id: sessionId, userId, deleted: false }
});
if (!session || session.status === 'completed') {
return;
}
// 检查所有exercise类型的动作是否都完成
const exerciseActions = await this.workoutExerciseModel.findAll({
where: {
workoutSessionId: sessionId,
userId,
deleted: false,
itemType: 'exercise'
}
});
const allCompleted = exerciseActions.every(exercise =>
exercise.status === 'completed' || exercise.status === 'skipped'
);
if (allCompleted && exerciseActions.length > 0) {
// 自动完成训练会话
const completedTime = new Date();
session.completedAt = completedTime;
session.status = 'completed';
// 计算总时长
if (session.startedAt) {
session.totalDurationSec = Math.floor((completedTime.getTime() - session.startedAt.getTime()) / 1000);
}
// 计算统计数据
const stats = await this.calculateWorkoutStats(sessionId);
session.stats = stats;
await session.save();
this.winstonLogger.info(`自动完成训练会话 ${sessionId}`, {
context: 'WorkoutsService',
userId,
sessionId,
duration: session.totalDurationSec,
});
}
}
}

View File

@@ -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=== 测试完成 ==="

133
yarn.lock
View File

@@ -599,6 +599,11 @@
resolved "https://registry.npmjs.org/@inquirer/type/-/type-3.0.5.tgz"
integrity sha512-ZJpeIYYueOz/i/ONzrfof8g89kNdO2hjGuvULROo3O8rlB2CRtSseE5KeirnyE4t/thAn/EwvS/vuQeJCn+NZg==
"@ioredis/commands@1.4.0":
version "1.4.0"
resolved "https://mirrors.tencent.com/npm/@ioredis/commands/-/commands-1.4.0.tgz"
integrity sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==
"@isaacs/cliui@^8.0.2":
version "8.0.2"
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
@@ -884,12 +889,12 @@
"@napi-rs/nice-android-arm-eabi@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz#9a0cba12706ff56500df127d6f4caf28ddb94936"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz#9a0cba12706ff56500df127d6f4caf28ddb94936"
integrity sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==
"@napi-rs/nice-android-arm64@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz#32fc32e9649bd759d2a39ad745e95766f6759d2f"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz#32fc32e9649bd759d2a39ad745e95766f6759d2f"
integrity sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA==
"@napi-rs/nice-darwin-arm64@1.0.1":
@@ -899,67 +904,67 @@
"@napi-rs/nice-darwin-x64@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz#f1b1365a8370c6a6957e90085a9b4873d0e6a957"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz#f1b1365a8370c6a6957e90085a9b4873d0e6a957"
integrity sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ==
"@napi-rs/nice-freebsd-x64@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz#4280f081efbe0b46c5165fdaea8b286e55a8f89e"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz#4280f081efbe0b46c5165fdaea8b286e55a8f89e"
integrity sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw==
"@napi-rs/nice-linux-arm-gnueabihf@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz#07aec23a9467ed35eb7602af5e63d42c5d7bd473"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz#07aec23a9467ed35eb7602af5e63d42c5d7bd473"
integrity sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q==
"@napi-rs/nice-linux-arm64-gnu@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz#038a77134cc6df3c48059d5a5e199d6f50fb9a90"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz#038a77134cc6df3c48059d5a5e199d6f50fb9a90"
integrity sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA==
"@napi-rs/nice-linux-arm64-musl@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz#715d0906582ba0cff025109f42e5b84ea68c2bcc"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz#715d0906582ba0cff025109f42e5b84ea68c2bcc"
integrity sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw==
"@napi-rs/nice-linux-ppc64-gnu@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz#ac1c8f781c67b0559fa7a1cd4ae3ca2299dc3d06"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz#ac1c8f781c67b0559fa7a1cd4ae3ca2299dc3d06"
integrity sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q==
"@napi-rs/nice-linux-riscv64-gnu@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz#b0a430549acfd3920ffd28ce544e2fe17833d263"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz#b0a430549acfd3920ffd28ce544e2fe17833d263"
integrity sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig==
"@napi-rs/nice-linux-s390x-gnu@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz#5b95caf411ad72a965885217db378c4d09733e97"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz#5b95caf411ad72a965885217db378c4d09733e97"
integrity sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg==
"@napi-rs/nice-linux-x64-gnu@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz#a98cdef517549f8c17a83f0236a69418a90e77b7"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz#a98cdef517549f8c17a83f0236a69418a90e77b7"
integrity sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA==
"@napi-rs/nice-linux-x64-musl@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz#5e26843eafa940138aed437c870cca751c8a8957"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz#5e26843eafa940138aed437c870cca751c8a8957"
integrity sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ==
"@napi-rs/nice-win32-arm64-msvc@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz#bd62617d02f04aa30ab1e9081363856715f84cd8"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz#bd62617d02f04aa30ab1e9081363856715f84cd8"
integrity sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg==
"@napi-rs/nice-win32-ia32-msvc@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz#b8b7aad552a24836027473d9b9f16edaeabecf18"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz#b8b7aad552a24836027473d9b9f16edaeabecf18"
integrity sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw==
"@napi-rs/nice-win32-x64-msvc@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz#37d8718b8f722f49067713e9f1e85540c9a3dd09"
resolved "https://mirrors.tencent.com/npm/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz#37d8718b8f722f49067713e9f1e85540c9a3dd09"
integrity sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg==
"@napi-rs/nice@^1.0.1":
@@ -1105,6 +1110,11 @@
dependencies:
tslib "2.8.1"
"@nestjs/throttler@^6.4.0":
version "6.4.0"
resolved "https://mirrors.tencent.com/npm/@nestjs/throttler/-/throttler-6.4.0.tgz"
integrity sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@@ -1133,6 +1143,13 @@
dependencies:
consola "^3.2.3"
"@openrouter/sdk@^0.1.27":
version "0.1.27"
resolved "https://mirrors.tencent.com/npm/@openrouter/sdk/-/sdk-0.1.27.tgz"
integrity sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ==
dependencies:
zod "^3.25.0 || ^4.0.0"
"@parse/node-apn@^5.0.0":
version "5.2.3"
resolved "https://mirrors.tencent.com/npm/@parse/node-apn/-/node-apn-5.2.3.tgz"
@@ -1254,47 +1271,47 @@
"@swc/core-darwin-x64@1.11.13":
version "1.11.13"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.11.13.tgz#9cad870d48ebff805e8946ddcbe3d8312182f70b"
resolved "https://mirrors.tencent.com/npm/@swc/core-darwin-x64/-/core-darwin-x64-1.11.13.tgz#9cad870d48ebff805e8946ddcbe3d8312182f70b"
integrity sha512-uSA4UwgsDCIysUPfPS8OrQTH2h9spO7IYFd+1NB6dJlVGUuR6jLKuMBOP1IeLeax4cGHayvkcwSJ3OvxHwgcZQ==
"@swc/core-linux-arm-gnueabihf@1.11.13":
version "1.11.13"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.13.tgz#51839e5a850bfa300e2c838fee8379e4dba1de78"
resolved "https://mirrors.tencent.com/npm/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.13.tgz#51839e5a850bfa300e2c838fee8379e4dba1de78"
integrity sha512-boVtyJzS8g30iQfe8Q46W5QE/cmhKRln/7NMz/5sBP/am2Lce9NL0d05NnFwEWJp1e2AMGHFOdRr3Xg1cDiPKw==
"@swc/core-linux-arm64-gnu@1.11.13":
version "1.11.13"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.13.tgz#4145f1e504bdfa92604aee883d777bc8c4fba5d7"
resolved "https://mirrors.tencent.com/npm/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.13.tgz#4145f1e504bdfa92604aee883d777bc8c4fba5d7"
integrity sha512-+IK0jZ84zHUaKtwpV+T+wT0qIUBnK9v2xXD03vARubKF+eUqCsIvcVHXmLpFuap62dClMrhCiwW10X3RbXNlHw==
"@swc/core-linux-arm64-musl@1.11.13":
version "1.11.13"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.13.tgz#b1813ae2e99e386ca16fff5af6601ac45ef57c5b"
resolved "https://mirrors.tencent.com/npm/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.13.tgz#b1813ae2e99e386ca16fff5af6601ac45ef57c5b"
integrity sha512-+ukuB8RHD5BHPCUjQwuLP98z+VRfu+NkKQVBcLJGgp0/+w7y0IkaxLY/aKmrAS5ofCNEGqKL+AOVyRpX1aw+XA==
"@swc/core-linux-x64-gnu@1.11.13":
version "1.11.13"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.13.tgz#13b89a0194c4033c01400e9c65d9c21c56a4a6cd"
resolved "https://mirrors.tencent.com/npm/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.13.tgz#13b89a0194c4033c01400e9c65d9c21c56a4a6cd"
integrity sha512-q9H3WI3U3dfJ34tdv60zc8oTuWvSd5fOxytyAO9Pc5M82Hic3jjWaf2xBekUg07ubnMZpyfnv+MlD+EbUI3Llw==
"@swc/core-linux-x64-musl@1.11.13":
version "1.11.13"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.13.tgz#0d0e5aa889dd4da69723e2287c3c1714d9bfd8aa"
resolved "https://mirrors.tencent.com/npm/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.13.tgz#0d0e5aa889dd4da69723e2287c3c1714d9bfd8aa"
integrity sha512-9aaZnnq2pLdTbAzTSzy/q8dr7Woy3aYIcQISmw1+Q2/xHJg5y80ZzbWSWKYca/hKonDMjIbGR6dp299I5J0aeA==
"@swc/core-win32-arm64-msvc@1.11.13":
version "1.11.13"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.13.tgz#ad7281f9467e3de09f52615afe2276a8ef738a9d"
resolved "https://mirrors.tencent.com/npm/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.13.tgz#ad7281f9467e3de09f52615afe2276a8ef738a9d"
integrity sha512-n3QZmDewkHANcoHvtwvA6yJbmS4XJf0MBMmwLZoKDZ2dOnC9D/jHiXw7JOohEuzYcpLoL5tgbqmjxa3XNo9Oow==
"@swc/core-win32-ia32-msvc@1.11.13":
version "1.11.13"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.13.tgz#046f6dbddb5b69a29bbaa98de104090a46088b74"
resolved "https://mirrors.tencent.com/npm/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.13.tgz#046f6dbddb5b69a29bbaa98de104090a46088b74"
integrity sha512-wM+Nt4lc6YSJFthCx3W2dz0EwFNf++j0/2TQ0Js9QLJuIxUQAgukhNDVCDdq8TNcT0zuA399ALYbvj5lfIqG6g==
"@swc/core-win32-x64-msvc@1.11.13":
version "1.11.13"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.13.tgz#0412620d8594a7d3e482d3e79d9e89d80f9a14c0"
resolved "https://mirrors.tencent.com/npm/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.13.tgz#0412620d8594a7d3e482d3e79d9e89d80f9a14c0"
integrity sha512-+X5/uW3s1L5gK7wAo0E27YaAoidJDo51dnfKSfU7gF3mlEUuWH8H1bAy5OTt2mU4eXtfsdUMEVXSwhDlLtQkuA==
"@swc/core@^1.10.7":
@@ -1514,6 +1531,13 @@
resolved "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz"
integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==
"@types/ioredis@^4.28.10":
version "4.28.10"
resolved "https://mirrors.tencent.com/npm/@types/ioredis/-/ioredis-4.28.10.tgz"
integrity sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==
dependencies:
"@types/node" "*"
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
version "2.0.6"
resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz"
@@ -2728,6 +2752,11 @@ clone@^1.0.2:
resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz"
integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
cluster-key-slot@^1.1.0:
version "1.1.2"
resolved "https://mirrors.tencent.com/npm/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz"
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
co@^4.6.0:
version "4.6.0"
resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz"
@@ -3844,14 +3873,15 @@ form-data-encoder@^2.1.2:
resolved "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz"
integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==
form-data@^4.0.0:
version "4.0.2"
resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz"
integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==
form-data@^4.0.0, form-data@^4.0.5:
version "4.0.5"
resolved "https://mirrors.tencent.com/npm/form-data/-/form-data-4.0.5.tgz"
integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
es-set-tostringtag "^2.1.0"
hasown "^2.0.2"
mime-types "^2.1.12"
form-data@~2.3.2:
@@ -4304,6 +4334,21 @@ inspect-with-kind@^1.0.5:
dependencies:
kind-of "^6.0.2"
ioredis@^5.8.2:
version "5.8.2"
resolved "https://mirrors.tencent.com/npm/ioredis/-/ioredis-5.8.2.tgz"
integrity sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==
dependencies:
"@ioredis/commands" "1.4.0"
cluster-key-slot "^1.1.0"
debug "^4.3.4"
denque "^2.1.0"
lodash.defaults "^4.2.0"
lodash.isarguments "^3.1.0"
redis-errors "^1.2.0"
redis-parser "^3.0.0"
standard-as-callback "^2.1.0"
ip-address@^9.0.5:
version "9.0.5"
resolved "https://mirrors.tencent.com/npm/ip-address/-/ip-address-9.0.5.tgz"
@@ -5126,11 +5171,21 @@ lodash.clonedeep@^4.5.0:
resolved "https://mirrors.tencent.com/npm/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz"
integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==
lodash.defaults@^4.2.0:
version "4.2.0"
resolved "https://mirrors.tencent.com/npm/lodash.defaults/-/lodash.defaults-4.2.0.tgz"
integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://mirrors.tencent.com/npm/lodash.includes/-/lodash.includes-4.3.0.tgz"
integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==
lodash.isarguments@^3.1.0:
version "3.1.0"
resolved "https://mirrors.tencent.com/npm/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz"
integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==
lodash.isboolean@^3.0.3:
version "3.0.3"
resolved "https://mirrors.tencent.com/npm/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz"
@@ -6201,6 +6256,18 @@ readdirp@~3.6.0:
dependencies:
picomatch "^2.2.1"
redis-errors@^1.0.0, redis-errors@^1.2.0:
version "1.2.0"
resolved "https://mirrors.tencent.com/npm/redis-errors/-/redis-errors-1.2.0.tgz"
integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==
redis-parser@^3.0.0:
version "3.0.0"
resolved "https://mirrors.tencent.com/npm/redis-parser/-/redis-parser-3.0.0.tgz"
integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==
dependencies:
redis-errors "^1.0.0"
reflect-metadata@^0.2.2:
version "0.2.2"
resolved "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz"
@@ -6704,6 +6771,11 @@ stack-utils@^2.0.3:
dependencies:
escape-string-regexp "^2.0.0"
standard-as-callback@^2.1.0:
version "2.1.0"
resolved "https://mirrors.tencent.com/npm/standard-as-callback/-/standard-as-callback-2.1.0.tgz"
integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==
statuses@2.0.1, statuses@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz"
@@ -7575,3 +7647,8 @@ yoctocolors-cjs@^2.1.2:
version "2.1.2"
resolved "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz"
integrity sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==
"zod@^3.25.0 || ^4.0.0":
version "3.25.76"
resolved "https://mirrors.tencent.com/npm/zod/-/zod-3.25.76.tgz"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==