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:
richarjiang
2025-11-20 17:55:05 +08:00
parent 07fae9bdc0
commit afe6ae1c6a
6 changed files with 308 additions and 9 deletions

View 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[];
}

View File

@@ -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 : '药品分析失败',
);
}
}
} }

View File

@@ -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 剂型枚举

View File

@@ -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;

View File

@@ -50,7 +50,6 @@ import { ChallengeParticipant } from '../challenges/models/challenge-participant
PushTokenService, PushTokenService,
PushTemplateService, PushTemplateService,
PushMessageService, PushMessageService,
PushTestService,
ChallengeReminderService, ChallengeReminderService,
], ],
}) })

View File

@@ -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();