From afe6ae1c6ab1ef05caa107462c222bf494a8f474 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 20 Nov 2025 17:55:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(medications):=20=E5=AE=9E=E7=8E=B0V2?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=8D=AF=E5=93=81AI=E5=88=86=E6=9E=90?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=BB=93=E6=9E=84=E5=8C=96=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 V2 版药品分析服务,通过 AI 生成包含适用人群、副作用等字段的结构化 JSON 数据 - 添加 `POST :id/ai-analysis/v2` 接口,集成用户免费次数校验与自动扣费逻辑 - 在药品创建流程中增加异步触发自动 AI 分析的机制 - fix(users): 修复 Apple 登录未获取到邮箱时的报错问题,改为自动生成随机唯一邮箱 - perf(medications): 将服药提醒定时任务的检查频率调整为每 5 分钟一次 - refactor(push-notifications): 移除不再使用的 PushTestService --- src/medications/dto/ai-analysis-result.dto.ts | 27 +++ src/medications/medications.controller.ts | 88 +++++++++ .../services/medication-analysis.service.ts | 183 ++++++++++++++++++ .../services/medication-reminder.service.ts | 8 +- .../push-notifications.module.ts | 1 - src/users/users.service.ts | 10 +- 6 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 src/medications/dto/ai-analysis-result.dto.ts diff --git a/src/medications/dto/ai-analysis-result.dto.ts b/src/medications/dto/ai-analysis-result.dto.ts new file mode 100644 index 0000000..2e5109d --- /dev/null +++ b/src/medications/dto/ai-analysis-result.dto.ts @@ -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[]; +} \ No newline at end of file diff --git a/src/medications/medications.controller.ts b/src/medications/medications.controller.ts index 78edf85..17b9f0b 100644 --- a/src/medications/medications.controller.ts +++ b/src/medications/medications.controller.ts @@ -17,6 +17,7 @@ import { MedicationsService } from './medications.service'; import { CreateMedicationDto } from './dto/create-medication.dto'; import { UpdateMedicationDto } from './dto/update-medication.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 { CurrentUser } from '../common/decorators/current-user.decorator'; import { ApiResponseDto } from '../base.dto'; @@ -55,9 +56,48 @@ export class MedicationsController { // 设置提醒(实际由定时任务触发) 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, '创建成功'); } + /** + * 触发自动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() @ApiOperation({ summary: '获取药物列表' }) @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 : '药品分析失败', + ); + } + } } \ No newline at end of file diff --git a/src/medications/services/medication-analysis.service.ts b/src/medications/services/medication-analysis.service.ts index 154ccd7..7469f93 100644 --- a/src/medications/services/medication-analysis.service.ts +++ b/src/medications/services/medication-analysis.service.ts @@ -5,6 +5,7 @@ import { OpenAI } from 'openai'; import { Readable } from 'stream'; import { MedicationsService } from '../medications.service'; import { Medication } from '../models/medication.model'; +import { AiAnalysisResultDto } from '../dto/ai-analysis-result.dto'; /** * 药品AI分析服务 @@ -66,6 +67,43 @@ export class MedicationAnalysisService { } } + /** + * 分析药品信息并返回结构化JSON (V2) + * @param medicationId 药品ID + * @param userId 用户ID + * @returns 结构化分析结果 + */ + async analyzeMedicationV2(medicationId: string, userId: string): Promise { + 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 分析提示 @@ -101,6 +139,44 @@ export class MedicationAnalysisService { } } + /** + * 使用视觉模型分析药品并返回结构化数据 (V2) + * @param prompt 分析提示 + * @param imageUrl 药品图片URL + * @returns 结构化数据 + */ + private async analyzeWithVisionV2(prompt: string, imageUrl: string): Promise { + 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 分析提示 @@ -129,6 +205,66 @@ export class MedicationAnalysisService { } } + /** + * 使用文本模型分析药品并返回结构化数据 (V2) + * @param prompt 分析提示 + * @returns 结构化数据 + */ + private async analyzeWithTextV2(prompt: string): Promise { + 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响应创建可读流 * @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 剂型枚举 diff --git a/src/medications/services/medication-reminder.service.ts b/src/medications/services/medication-reminder.service.ts index 38b4eeb..2f9cab1 100644 --- a/src/medications/services/medication-reminder.service.ts +++ b/src/medications/services/medication-reminder.service.ts @@ -29,10 +29,10 @@ export class MedicationReminderService { ) {} /** - * 每1分钟检查一次需要发送的提前提醒 + * 每5分钟检查一次需要发送的提前提醒 * 只有主进程(NODE_APP_INSTANCE=0)执行,避免多进程重复发送 */ - @Cron('*/1 * * * *') + @Cron('*/5 * * * *') async checkAndSendReminders(): Promise { this.logger.log('开始检查服药提醒'); @@ -48,9 +48,7 @@ export class MedicationReminderService { // 计算时间范围:当前时间 + 15分钟 const now = new Date(); - const reminderTime = dayjs(now) - .add(this.REMINDER_MINUTES_BEFORE, 'minute') - .toDate(); + // 查找在接下来1分钟内需要提醒的记录 const startRange = now; diff --git a/src/push-notifications/push-notifications.module.ts b/src/push-notifications/push-notifications.module.ts index 4820f14..23e489f 100644 --- a/src/push-notifications/push-notifications.module.ts +++ b/src/push-notifications/push-notifications.module.ts @@ -50,7 +50,6 @@ import { ChallengeParticipant } from '../challenges/models/challenge-participant PushTokenService, PushTemplateService, PushMessageService, - PushTestService, ChallengeReminderService, ], }) diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 391ed59..d829c63 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -538,10 +538,14 @@ export class UsersService { if (!user) { // 创建新用户 const userName = appleLoginDto.name || applePayload.email?.split('@')[0] || '用户'; - const userEmail = appleLoginDto.email || applePayload.email || ''; - + + // 如果无法获取用户邮箱,生成一个随机邮箱 + let userEmail = appleLoginDto.email || applePayload.email || ''; 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();