feat(medications): 实现V2版本药品AI分析功能及结构化数据支持
- 新增 V2 版药品分析服务,通过 AI 生成包含适用人群、副作用等字段的结构化 JSON 数据 - 添加 `POST :id/ai-analysis/v2` 接口,集成用户免费次数校验与自动扣费逻辑 - 在药品创建流程中增加异步触发自动 AI 分析的机制 - fix(users): 修复 Apple 登录未获取到邮箱时的报错问题,改为自动生成随机唯一邮箱 - perf(medications): 将服药提醒定时任务的检查频率调整为每 5 分钟一次 - refactor(push-notifications): 移除不再使用的 PushTestService
This commit is contained in:
27
src/medications/dto/ai-analysis-result.dto.ts
Normal file
27
src/medications/dto/ai-analysis-result.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 药品分析结果 DTO (V2)
|
||||||
|
*/
|
||||||
|
export class AiAnalysisResultDto {
|
||||||
|
@ApiProperty({ description: '适合人群', type: [String] })
|
||||||
|
suitableFor: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '不适合人群', type: [String] })
|
||||||
|
unsuitableFor: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '主要成分', type: [String] })
|
||||||
|
mainIngredients: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '主要用途' })
|
||||||
|
mainUsage: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '可能的副作用', type: [String] })
|
||||||
|
sideEffects: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '储存和保管建议', type: [String] })
|
||||||
|
storageAdvice: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '健康关怀建议', type: [String] })
|
||||||
|
healthAdvice: string[];
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { MedicationsService } from './medications.service';
|
|||||||
import { CreateMedicationDto } from './dto/create-medication.dto';
|
import { CreateMedicationDto } from './dto/create-medication.dto';
|
||||||
import { UpdateMedicationDto } from './dto/update-medication.dto';
|
import { UpdateMedicationDto } from './dto/update-medication.dto';
|
||||||
import { MedicationQueryDto } from './dto/medication-query.dto';
|
import { MedicationQueryDto } from './dto/medication-query.dto';
|
||||||
|
import { AiAnalysisResultDto } from './dto/ai-analysis-result.dto';
|
||||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
import { ApiResponseDto } from '../base.dto';
|
import { ApiResponseDto } from '../base.dto';
|
||||||
@@ -55,9 +56,48 @@ export class MedicationsController {
|
|||||||
// 设置提醒(实际由定时任务触发)
|
// 设置提醒(实际由定时任务触发)
|
||||||
await this.reminderService.setupRemindersForMedication(medication);
|
await this.reminderService.setupRemindersForMedication(medication);
|
||||||
|
|
||||||
|
// 异步触发 AI 分析(不阻塞当前请求)
|
||||||
|
this.triggerAutoAnalysis(user.sub, medication.id).catch(err => {
|
||||||
|
this.logger.error(`触发自动AI分析异常: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
});
|
||||||
|
|
||||||
return ApiResponseDto.success(medication, '创建成功');
|
return ApiResponseDto.success(medication, '创建成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发自动AI分析
|
||||||
|
* 这是一个后台异步任务,不会阻塞主请求
|
||||||
|
*/
|
||||||
|
private async triggerAutoAnalysis(userId: string, medicationId: string) {
|
||||||
|
try {
|
||||||
|
// 1. 检查用户免费使用次数或会员状态
|
||||||
|
const userUsageCount = await this.usersService.getUserUsageCount(userId);
|
||||||
|
|
||||||
|
// 如果用户不是VIP且免费次数不足,直接返回
|
||||||
|
if (userUsageCount <= 0) {
|
||||||
|
this.logger.log(`新药自动分析跳过 - 用户ID: ${userId}, 免费次数不足`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`开始新药自动AI分析 - 用户ID: ${userId}, 药物ID: ${medicationId}`);
|
||||||
|
|
||||||
|
// 2. 执行分析 (V2版本,返回结构化数据并自动保存到数据库)
|
||||||
|
await this.analysisService.analyzeMedicationV2(medicationId, userId);
|
||||||
|
|
||||||
|
// 3. 分析成功后扣减次数
|
||||||
|
// 注意:VIP用户 getUserUsageCount 返回 999,即使这里扣减了 freeUsageCount 也不影响 VIP 权益
|
||||||
|
try {
|
||||||
|
await this.usersService.deductUserUsageCount(userId, 1);
|
||||||
|
this.logger.log(`自动AI分析成功,已扣减用户免费次数 - 用户ID: ${userId}`);
|
||||||
|
} catch (deductError) {
|
||||||
|
this.logger.error(`自动AI分析扣费失败 - 用户ID: ${userId}, 错误: ${deductError instanceof Error ? deductError.message : String(deductError)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`新药自动AI分析失败 - 用户ID: ${userId}, 药物ID: ${medicationId}, 错误: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取药物列表' })
|
@ApiOperation({ summary: '获取药物列表' })
|
||||||
@ApiResponse({ status: 200, description: '查询成功' })
|
@ApiResponse({ status: 200, description: '查询成功' })
|
||||||
@@ -227,4 +267,52 @@ export class MedicationsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/ai-analysis/v2')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '获取药品AI分析 (V2)',
|
||||||
|
description: '使用大模型分析药品信息,返回结构化的JSON数据。包含适合人群、主要成分、副作用等详细信息。'
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '返回结构化分析结果',
|
||||||
|
type: AiAnalysisResultDto
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 403,
|
||||||
|
description: '免费使用次数已用完'
|
||||||
|
})
|
||||||
|
async getAiAnalysisV2(
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
// 检查用户免费使用次数
|
||||||
|
const userUsageCount = await this.usersService.getUserUsageCount(user.sub);
|
||||||
|
|
||||||
|
// 如果用户不是VIP且免费次数不足,返回错误
|
||||||
|
if (userUsageCount <= 0) {
|
||||||
|
this.logger.warn(`药品AI分析(V2)失败 - 用户ID: ${user.sub}, 免费次数不足`);
|
||||||
|
return ApiResponseDto.error('免费使用次数已用完,请开通会员获取更多使用次数', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取结构化分析结果
|
||||||
|
const result = await this.analysisService.analyzeMedicationV2(id, user.sub);
|
||||||
|
|
||||||
|
// 分析成功后扣减用户免费使用次数
|
||||||
|
try {
|
||||||
|
await this.usersService.deductUserUsageCount(user.sub, 1);
|
||||||
|
this.logger.log(`药品AI分析(V2)成功,已扣减用户免费次数 - 用户ID: ${user.sub}, 剩余次数: ${userUsageCount - 1}`);
|
||||||
|
} catch (deductError) {
|
||||||
|
this.logger.error(`扣减用户免费次数失败 - 用户ID: ${user.sub}, 错误: ${deductError instanceof Error ? deductError.message : String(deductError)}`);
|
||||||
|
// 不影响主流程
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponseDto.success(result, '分析成功');
|
||||||
|
} catch (error) {
|
||||||
|
return ApiResponseDto.error(
|
||||||
|
error instanceof Error ? error.message : '药品分析失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { OpenAI } from 'openai';
|
|||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { MedicationsService } from '../medications.service';
|
import { MedicationsService } from '../medications.service';
|
||||||
import { Medication } from '../models/medication.model';
|
import { Medication } from '../models/medication.model';
|
||||||
|
import { AiAnalysisResultDto } from '../dto/ai-analysis-result.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 药品AI分析服务
|
* 药品AI分析服务
|
||||||
@@ -66,6 +67,43 @@ export class MedicationAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分析药品信息并返回结构化JSON (V2)
|
||||||
|
* @param medicationId 药品ID
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @returns 结构化分析结果
|
||||||
|
*/
|
||||||
|
async analyzeMedicationV2(medicationId: string, userId: string): Promise<AiAnalysisResultDto> {
|
||||||
|
this.logger.log(`开始分析药品(V2): ${medicationId}`);
|
||||||
|
try {
|
||||||
|
// 1. 获取药品信息
|
||||||
|
const medication = await this.medicationsService.findOne(medicationId, userId);
|
||||||
|
|
||||||
|
this.logger.log(`获取到药品信息: ${JSON.stringify(medication, null, 2)}`);
|
||||||
|
// 2. 构建专业医药分析提示
|
||||||
|
const prompt = this.buildMedicationAnalysisPromptV2(medication);
|
||||||
|
|
||||||
|
let result: AiAnalysisResultDto;
|
||||||
|
|
||||||
|
// 3. 调用AI模型进行分析
|
||||||
|
if (medication.photoUrl) {
|
||||||
|
// 有图片:使用视觉模型
|
||||||
|
result = await this.analyzeWithVisionV2(prompt, medication.photoUrl);
|
||||||
|
} else {
|
||||||
|
// 无图片:使用文本模型
|
||||||
|
result = await this.analyzeWithTextV2(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 保存结果到数据库 (覆盖 aiAnalysis 字段)
|
||||||
|
await this.saveAnalysisResult(medicationId, userId, JSON.stringify(result));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`药品分析(V2)失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用视觉模型分析药品(带图片)
|
* 使用视觉模型分析药品(带图片)
|
||||||
* @param prompt 分析提示
|
* @param prompt 分析提示
|
||||||
@@ -101,6 +139,44 @@ export class MedicationAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用视觉模型分析药品并返回结构化数据 (V2)
|
||||||
|
* @param prompt 分析提示
|
||||||
|
* @param imageUrl 药品图片URL
|
||||||
|
* @returns 结构化数据
|
||||||
|
*/
|
||||||
|
private async analyzeWithVisionV2(prompt: string, imageUrl: string): Promise<AiAnalysisResultDto> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.chat.completions.create({
|
||||||
|
model: this.visionModel,
|
||||||
|
temperature: 0.7,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: prompt },
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: imageUrl }
|
||||||
|
} as any,
|
||||||
|
] as any,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const content = response.choices[0]?.message?.content;
|
||||||
|
if (!content) {
|
||||||
|
throw new Error('AI模型返回内容为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.parseAiResponse(content);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`视觉模型(V2)调用失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
throw new Error('视觉分析失败,请稍后重试。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用文本模型分析药品(无图片)
|
* 使用文本模型分析药品(无图片)
|
||||||
* @param prompt 分析提示
|
* @param prompt 分析提示
|
||||||
@@ -129,6 +205,66 @@ export class MedicationAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用文本模型分析药品并返回结构化数据 (V2)
|
||||||
|
* @param prompt 分析提示
|
||||||
|
* @returns 结构化数据
|
||||||
|
*/
|
||||||
|
private async analyzeWithTextV2(prompt: string): Promise<AiAnalysisResultDto> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.chat.completions.create({
|
||||||
|
model: this.model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt
|
||||||
|
}
|
||||||
|
],
|
||||||
|
temperature: 0.7,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = response.choices[0]?.message?.content;
|
||||||
|
if (!content) {
|
||||||
|
throw new Error('AI模型返回内容为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.parseAiResponse(content);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`文本模型(V2)调用失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
throw new Error('文本分析失败,请稍后重试。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析AI返回的JSON内容
|
||||||
|
* @param content AI返回的文本内容
|
||||||
|
* @returns 解析后的DTO对象
|
||||||
|
*/
|
||||||
|
private parseAiResponse(content: string): AiAnalysisResultDto {
|
||||||
|
try {
|
||||||
|
// 尝试查找JSON块
|
||||||
|
let jsonString = content;
|
||||||
|
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
jsonString = jsonMatch[1];
|
||||||
|
} else {
|
||||||
|
// 尝试查找第一个 { 和最后一个 }
|
||||||
|
const firstBrace = content.indexOf('{');
|
||||||
|
const lastBrace = content.lastIndexOf('}');
|
||||||
|
if (firstBrace !== -1 && lastBrace !== -1) {
|
||||||
|
jsonString = content.substring(firstBrace, lastBrace + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonString);
|
||||||
|
return parsed as AiAnalysisResultDto;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`解析AI响应JSON失败: ${error instanceof Error ? error.message : String(error)}, Content: ${content}`);
|
||||||
|
throw new Error('AI响应格式错误,无法解析');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从AI响应创建可读流
|
* 从AI响应创建可读流
|
||||||
* @param aiStream AI模型流式响应
|
* @param aiStream AI模型流式响应
|
||||||
@@ -350,6 +486,53 @@ ${medication.note ? `- 用户备注:${medication.note}` : ''}
|
|||||||
祝您早日康复,保持健康!💪`;
|
祝您早日康复,保持健康!💪`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建专业医药分析提示 (V2 - JSON格式)
|
||||||
|
* @param medication 药品信息
|
||||||
|
* @returns 分析提示文本
|
||||||
|
*/
|
||||||
|
private buildMedicationAnalysisPromptV2(medication: Medication): string {
|
||||||
|
const formName = this.getMedicationFormName(medication.form);
|
||||||
|
const dosageInfo = `${medication.dosageValue}${medication.dosageUnit}`;
|
||||||
|
|
||||||
|
return `你是一位拥有20年从业经验的资深药剂师和临床医学专家。
|
||||||
|
请基于以下药品信息,为用户提供专业的药品分析报告。
|
||||||
|
|
||||||
|
**药品信息**:
|
||||||
|
- 药品名称:${medication.name}
|
||||||
|
- 剂型:${formName}
|
||||||
|
- 规格剂量:${dosageInfo}
|
||||||
|
- 每日服用次数:${medication.timesPerDay}次
|
||||||
|
- 服药时间:${medication.medicationTimes.join('、')}
|
||||||
|
${medication.photoUrl ? '- 药品图片:已提供(请结合图片中的药品外观、包装、说明书等信息进行分析)' : ''}
|
||||||
|
${medication.note ? `- 用户备注:${medication.note}` : ''}
|
||||||
|
|
||||||
|
**重要指示**:
|
||||||
|
请以严格的 JSON 格式返回分析结果,不要包含任何 Markdown 标记或其他文本。JSON 结构如下:
|
||||||
|
|
||||||
|
{
|
||||||
|
"suitableFor": ["适合人群1", "适合人群2"],
|
||||||
|
"unsuitableFor": ["不适合人群1", "不适合人群2"],
|
||||||
|
"mainIngredients": ["主要成分1", "主要成分2"],
|
||||||
|
"mainUsage": "主要用途说明",
|
||||||
|
"sideEffects": ["副作用1", "副作用2"],
|
||||||
|
"storageAdvice": ["建议1", "建议2"],
|
||||||
|
"healthAdvice": ["建议1", "建议2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
**字段说明**:
|
||||||
|
1. suitableFor: 适合使用该药品的人群,字符串数组
|
||||||
|
2. unsuitableFor: 不适合使用该药品的人群(包括禁忌症),字符串数组
|
||||||
|
3. mainIngredients: 药品的主要成分,字符串数组
|
||||||
|
4. mainUsage: 药品的主要用途和适应症,字符串
|
||||||
|
5. sideEffects: 可能的副作用,字符串数组
|
||||||
|
6. storageAdvice: 储存和保管建议,字符串数组
|
||||||
|
7. healthAdvice: 健康关怀建议(生活方式、饮食等),字符串数组
|
||||||
|
|
||||||
|
如果无法识别药品,请在所有数组字段返回空数组,mainUsage 返回 "无法识别药品,请提供更准确的名称或图片"。
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取药品剂型的中文名称
|
* 获取药品剂型的中文名称
|
||||||
* @param form 剂型枚举
|
* @param form 剂型枚举
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ export class MedicationReminderService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每1分钟检查一次需要发送的提前提醒
|
* 每5分钟检查一次需要发送的提前提醒
|
||||||
* 只有主进程(NODE_APP_INSTANCE=0)执行,避免多进程重复发送
|
* 只有主进程(NODE_APP_INSTANCE=0)执行,避免多进程重复发送
|
||||||
*/
|
*/
|
||||||
@Cron('*/1 * * * *')
|
@Cron('*/5 * * * *')
|
||||||
async checkAndSendReminders(): Promise<void> {
|
async checkAndSendReminders(): Promise<void> {
|
||||||
this.logger.log('开始检查服药提醒');
|
this.logger.log('开始检查服药提醒');
|
||||||
|
|
||||||
@@ -48,9 +48,7 @@ export class MedicationReminderService {
|
|||||||
|
|
||||||
// 计算时间范围:当前时间 + 15分钟
|
// 计算时间范围:当前时间 + 15分钟
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const reminderTime = dayjs(now)
|
|
||||||
.add(this.REMINDER_MINUTES_BEFORE, 'minute')
|
|
||||||
.toDate();
|
|
||||||
|
|
||||||
// 查找在接下来1分钟内需要提醒的记录
|
// 查找在接下来1分钟内需要提醒的记录
|
||||||
const startRange = now;
|
const startRange = now;
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ import { ChallengeParticipant } from '../challenges/models/challenge-participant
|
|||||||
PushTokenService,
|
PushTokenService,
|
||||||
PushTemplateService,
|
PushTemplateService,
|
||||||
PushMessageService,
|
PushMessageService,
|
||||||
PushTestService,
|
|
||||||
ChallengeReminderService,
|
ChallengeReminderService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -538,10 +538,14 @@ export class UsersService {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
// 创建新用户
|
// 创建新用户
|
||||||
const userName = appleLoginDto.name || applePayload.email?.split('@')[0] || '用户';
|
const userName = appleLoginDto.name || applePayload.email?.split('@')[0] || '用户';
|
||||||
const userEmail = appleLoginDto.email || applePayload.email || '';
|
|
||||||
|
// 如果无法获取用户邮箱,生成一个随机邮箱
|
||||||
|
let userEmail = appleLoginDto.email || applePayload.email || '';
|
||||||
if (!userEmail) {
|
if (!userEmail) {
|
||||||
throw new BadRequestException('无法获取用户邮箱信息');
|
// 使用用户ID生成唯一的随机邮箱
|
||||||
|
const randomString = Math.random().toString(36).substring(2, 10);
|
||||||
|
userEmail = `${userId.substring(0, 8)}_${randomString}@outlive.com`;
|
||||||
|
this.logger.log(`为用户 ${userId} 生成随机邮箱: ${userEmail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberNumber = await this.assignMemberNumber();
|
const memberNumber = await this.assignMemberNumber();
|
||||||
|
|||||||
Reference in New Issue
Block a user