stash
This commit is contained in:
@@ -4,22 +4,32 @@ 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 { UsersModule } from '../users/users.module';
|
||||
import { DietRecordsModule } from '../diet-records/diet-records.module';
|
||||
import { MedicationsModule } from '../medications/medications.module';
|
||||
import { WorkoutsModule } from '../workouts/workouts.module';
|
||||
import { MoodCheckinsModule } from '../mood-checkins/mood-checkins.module';
|
||||
import { WaterRecordsModule } from '../water-records/water-records.module';
|
||||
import { CosService } from '../users/cos.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
UsersModule,
|
||||
forwardRef(() => UsersModule),
|
||||
forwardRef(() => DietRecordsModule),
|
||||
forwardRef(() => MedicationsModule),
|
||||
forwardRef(() => WorkoutsModule),
|
||||
forwardRef(() => MoodCheckinsModule),
|
||||
forwardRef(() => WaterRecordsModule),
|
||||
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]),
|
||||
],
|
||||
controllers: [AiCoachController],
|
||||
providers: [AiCoachService, DietAnalysisService],
|
||||
exports: [DietAnalysisService],
|
||||
providers: [AiCoachService, DietAnalysisService, AiReportService, CosService],
|
||||
exports: [DietAnalysisService, AiReportService],
|
||||
})
|
||||
export class AiCoachModule { }
|
||||
|
||||
|
||||
429
src/ai-coach/services/ai-report.service.ts
Normal file
429
src/ai-coach/services/ai-report.service.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AxiosResponse, AxiosRequestConfig, AxiosResponseHeaders } from 'axios';
|
||||
import * as dayjs from 'dayjs';
|
||||
import { OpenRouter } from '@openrouter/sdk';
|
||||
import { CosService } from '../../users/cos.service';
|
||||
|
||||
// 假设各个模块的服务都已正确导出
|
||||
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 { WorkoutsService } from '../../workouts/workouts.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;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiReportService {
|
||||
private readonly logger = new Logger(AiReportService.name);
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@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(() => WorkoutsService))
|
||||
private readonly workoutsService: WorkoutsService,
|
||||
private readonly cosService: CosService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 主入口:生成用户的AI健康报告图片
|
||||
* @param userId 用户ID
|
||||
* @param date 目标日期,格式 YYYY-MM-DD,默认为今天
|
||||
* @returns 图片的 URL 或 Base64 数据
|
||||
*/
|
||||
async generateHealthReportImage(userId: string, date?: string): Promise<{ imageUrl: string }> {
|
||||
const targetDate = date || dayjs().format('YYYY-MM-DD');
|
||||
this.logger.log(`开始为用户 ${userId} 生成 ${targetDate} 的AI健康报告`);
|
||||
|
||||
// 1. 聚合数据
|
||||
const dailyData = await this.gatherDailyData(userId, targetDate);
|
||||
|
||||
// 2. 生成图像生成Prompt
|
||||
const imagePrompt = await this.generateImagePrompt(dailyData);
|
||||
this.logger.log(`为用户 ${userId} 生成了图像Prompt: ${imagePrompt}`);
|
||||
|
||||
// 3. 调用图像生成API
|
||||
const imageUrl = await this.callImageGenerationApi(imagePrompt);
|
||||
this.logger.log(`为用户 ${userId} 成功生成图像: ${imageUrl}`);
|
||||
|
||||
return { imageUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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] = 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),
|
||||
// 获取最近的训练会话,并在后续筛选
|
||||
]);
|
||||
|
||||
// 处理饮食数据聚合
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. 生成固定格式的 prompt,适用于 Nano Banana Pro 模型
|
||||
* 优化:不再使用 LLM 生成 prompt,而是使用固定模版,并确保包含准确的中文文本
|
||||
*/
|
||||
private async generateImagePrompt(data: DailyHealthData): Promise<string> {
|
||||
// 格式化日期为 "12月01日"
|
||||
const dateStr = dayjs(data.date).format('MM月DD日');
|
||||
|
||||
// 准备数据文本
|
||||
const moodText = this.translateMood(data.mood.primaryMood);
|
||||
const medRate = Math.round(data.medications.completionRate); // 取整
|
||||
const calories = Math.round(data.diet.totalCalories);
|
||||
const water = Math.round(data.water.totalAmount);
|
||||
|
||||
// 根据性别调整角色描述
|
||||
let characterDesc = 'A happy cute character or animal mascot';
|
||||
if (data.user?.gender === 'male') {
|
||||
characterDesc = 'A happy cute boy character in casual sportswear';
|
||||
} else if (data.user?.gender === 'female') {
|
||||
characterDesc = 'A happy cute girl character in yoga outfit';
|
||||
}
|
||||
|
||||
// 构建 Prompt
|
||||
// 格式:[风格描述] + [主体内容] + [文本渲染指令] + [细节描述]
|
||||
const prompt = `
|
||||
A cute, hand-drawn style health journal page illustration, kawaii aesthetic, soft pastel colors, warm lighting. Vertical 9:16 aspect ratio. High quality, 1k resolution.
|
||||
|
||||
The image features a cute layout with icons and text boxes.
|
||||
Please render the following specific text in Chinese correctly:
|
||||
- Title text: "${dateStr} 健康日报"
|
||||
- Medication section text: "用药: ${medRate}%"
|
||||
- Diet section text: "热量: ${calories}千卡"
|
||||
- Water section text: "饮水: ${water}ml"
|
||||
- Mood section text: "心情: ${moodText}"
|
||||
|
||||
Visual elements:
|
||||
- ${characterDesc} representing the user.
|
||||
- Icon for medication (pill bottle or pills).
|
||||
- Icon for diet (healthy food bowl or apple).
|
||||
- Icon for water (water glass or drop).
|
||||
- Icon for exercise (sneakers or dumbbell).
|
||||
- Icon for mood (a smiley face representing ${moodText}).
|
||||
|
||||
Composition: Clean, organized, magazine layout style, decorative stickers and washi tape effects.
|
||||
`.trim();
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将心情类型翻译成中文(为了Prompt生成)
|
||||
*/
|
||||
private translateMood(moodType?: string): string {
|
||||
const moodMap: Record<string, string> = {
|
||||
HAPPY: '开心',
|
||||
EXCITED: '兴奋',
|
||||
THRILLED: '激动',
|
||||
CALM: '平静',
|
||||
ANXIOUS: '焦虑',
|
||||
SAD: '难过',
|
||||
LONELY: '孤独',
|
||||
WRONGED: '委屈',
|
||||
ANGRY: '生气',
|
||||
TIRED: '心累',
|
||||
};
|
||||
// 如果心情未知,返回"开心"以保持积极的氛围,或者使用"平和"
|
||||
return moodMap[moodType || ''] || '开心';
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. 调用 OpenRouter SDK 生成图片并上传到 COS
|
||||
*/
|
||||
private async callImageGenerationApi(prompt: string): Promise<string> {
|
||||
this.logger.log(`准备调用 OpenRouter SDK 生成图像`);
|
||||
this.logger.log(`使用Prompt: ${prompt}`);
|
||||
|
||||
const openRouterApiKey = this.configService.get<string>('OPENROUTER_API_KEY');
|
||||
if (!openRouterApiKey) {
|
||||
this.logger.error('OpenRouter API Key 未配置');
|
||||
throw new Error('OpenRouter API Key 未配置');
|
||||
}
|
||||
|
||||
try {
|
||||
// 初始化 OpenRouter 客户端
|
||||
const openrouter = new OpenRouter({
|
||||
apiKey: openRouterApiKey,
|
||||
});
|
||||
|
||||
// 调用图像生成API
|
||||
const result = await openrouter.chat.send({
|
||||
model: "google/gemini-3-pro-image-preview",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const message = result.choices[0].message;
|
||||
|
||||
// 处理不同格式的响应内容
|
||||
let imageData: string | undefined;
|
||||
let isBase64 = false;
|
||||
|
||||
if (typeof message.content === 'string') {
|
||||
// 检查是否为 base64 数据
|
||||
// base64 图像通常以 data:image/ 开头,或者是纯 base64 字符串
|
||||
if (message.content.startsWith('data:image/')) {
|
||||
// 完整的 data URL 格式:data:image/png;base64,xxxxx
|
||||
imageData = message.content;
|
||||
isBase64 = true;
|
||||
this.logger.log('检测到 Data URL 格式的 base64 图像');
|
||||
} else if (/^[A-Za-z0-9+/=]+$/.test(message.content.substring(0, 100))) {
|
||||
// 纯 base64 字符串(检查前100个字符)
|
||||
imageData = message.content;
|
||||
isBase64 = true;
|
||||
this.logger.log('检测到纯 base64 格式的图像数据');
|
||||
} else {
|
||||
// 尝试提取 HTTP URL
|
||||
const urlMatch = message.content.match(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp)/i);
|
||||
if (urlMatch) {
|
||||
imageData = urlMatch[0];
|
||||
isBase64 = false;
|
||||
this.logger.log(`检测到 HTTP URL: ${imageData}`);
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// 检查内容数组中是否有图像项
|
||||
const imageItem = message.content.find(item => item.type === 'image_url');
|
||||
if (imageItem && imageItem.imageUrl) {
|
||||
imageData = imageItem.imageUrl.url;
|
||||
// 判断是 URL 还是 base64
|
||||
isBase64 = imageData.startsWith('data:image/') || /^[A-Za-z0-9+/=]+$/.test(imageData.substring(0, 100));
|
||||
}
|
||||
}
|
||||
|
||||
if (imageData) {
|
||||
if (isBase64) {
|
||||
// 处理 base64 数据并上传到 COS
|
||||
this.logger.log('开始处理 base64 图像数据');
|
||||
const cosImageUrl = await this.uploadBase64ToCos(imageData);
|
||||
this.logger.log(`Base64 图像上传到 COS 成功: ${cosImageUrl}`);
|
||||
return cosImageUrl;
|
||||
} else {
|
||||
// 下载 HTTP URL 图像并上传到 COS
|
||||
this.logger.log(`OpenRouter 返回图像 URL: ${imageData}`);
|
||||
const cosImageUrl = await this.downloadAndUploadToCos(imageData);
|
||||
this.logger.log(`图像上传到 COS 成功: ${cosImageUrl}`);
|
||||
return cosImageUrl;
|
||||
}
|
||||
} else {
|
||||
this.logger.error('OpenRouter 响应中未包含图像数据');
|
||||
this.logger.error(`实际响应内容类型: ${typeof message.content}`);
|
||||
this.logger.error(`实际响应内容: ${JSON.stringify(message.content).substring(0, 500)}`);
|
||||
throw new Error('图像生成失败:响应中未包含图像数据');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`调用 OpenRouter SDK 失败: ${error.message}`);
|
||||
if (error.response) {
|
||||
this.logger.error(`OpenRouter 错误详情: ${JSON.stringify(error.response.data)}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -119,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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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';
|
||||
@@ -42,8 +42,8 @@ import { UsersModule } from '../users/users.module';
|
||||
MedicationAiSummary,
|
||||
]),
|
||||
ScheduleModule.forRoot(), // 启用定时任务
|
||||
PushNotificationsModule, // 推送通知功能
|
||||
UsersModule, // 用户认证服务
|
||||
forwardRef(() => PushNotificationsModule), // 推送通知功能
|
||||
forwardRef(() => UsersModule), // 用户认证服务
|
||||
],
|
||||
controllers: [
|
||||
MedicationsController,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
NotFoundException,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Request } from 'express';
|
||||
@@ -45,6 +46,7 @@ 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';
|
||||
|
||||
|
||||
@ApiTags('users')
|
||||
@@ -55,6 +57,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)
|
||||
@@ -472,4 +476,65 @@ export class UsersController {
|
||||
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: {
|
||||
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: { 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: {
|
||||
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: {
|
||||
imageUrl: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -24,6 +24,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: [
|
||||
@@ -43,6 +44,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
||||
UserActivity,
|
||||
]),
|
||||
forwardRef(() => ActivityLogsModule),
|
||||
forwardRef(() => AiCoachModule),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_ACCESS_SECRET || 'your-access-token-secret-key',
|
||||
signOptions: { expiresIn: '30d' },
|
||||
|
||||
@@ -135,6 +135,7 @@ export class UsersService {
|
||||
...existingUser.toJSON(),
|
||||
maxUsageCount: DEFAULT_FREE_USAGE_COUNT,
|
||||
isVip: existingUser.isVip,
|
||||
gender: existingUser.gender,
|
||||
dailyStepsGoal: profile?.dailyStepsGoal,
|
||||
dailyCaloriesGoal: profile?.dailyCaloriesGoal,
|
||||
pilatesPurposes: profile?.pilatesPurposes,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { SequelizeModule } from '@nestjs/sequelize';
|
||||
import { WorkoutsController } from './workouts.controller';
|
||||
import { WorkoutsService } from './workouts.service';
|
||||
@@ -19,8 +19,8 @@ import { UsersModule } from '../users/users.module';
|
||||
ScheduleExercise,
|
||||
Exercise,
|
||||
]),
|
||||
ActivityLogsModule,
|
||||
UsersModule,
|
||||
forwardRef(() => ActivityLogsModule),
|
||||
forwardRef(() => UsersModule),
|
||||
],
|
||||
controllers: [WorkoutsController],
|
||||
providers: [WorkoutsService],
|
||||
|
||||
Reference in New Issue
Block a user