"""食谱服务 - 包含热量识别与营养分析""" import base64 import io from typing import Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from ...models import Recipe from ...schemas import RecipeCreate, RecipeUpdate, RecipeResponse from ..ai_service_core import AIService from ...config.ai_prompts import PROMPT_ANALYZE_FOOD_CALORIES class RecipeService: """食谱服务""" def __init__(self, ai_service: AIService): self.ai_service = ai_service async def create_recipe( self, db: AsyncSession, recipe_data: RecipeCreate, tenant_id: int, ) -> Recipe: """创建食谱""" recipe = Recipe( **recipe_data.model_dump(), tenant_id=tenant_id, status="draft", ) db.add(recipe) await db.commit() await db.refresh(recipe) return recipe async def get_recipe( self, db: AsyncSession, recipe_id: int, tenant_id: int, ) -> Optional[Recipe]: """获取单个食谱""" result = await db.execute( select(Recipe).where( Recipe.id == recipe_id, Recipe.tenant_id == tenant_id, ) ) return result.scalar_one_or_none() async def list_recipes( self, db: AsyncSession, tenant_id: int, skip: int = 0, limit: int = 20, category: Optional[str] = None, cuisine: Optional[str] = None, status: Optional[str] = None, is_favorite: Optional[bool] = None, ) -> list[Recipe]: """获取食谱列表""" query = select(Recipe).where(Recipe.tenant_id == tenant_id) if category: query = query.where(Recipe.category == category) if cuisine: query = query.where(Recipe.cuisine == cuisine) if status: query = query.where(Recipe.status == status) if is_favorite is not None: query = query.where(Recipe.is_favorite == is_favorite) query = query.order_by(Recipe.created_at.desc()).offset(skip).limit(limit) result = await db.execute(query) return list(result.scalars().all()) async def update_recipe( self, db: AsyncSession, recipe_id: int, recipe_data: RecipeUpdate, tenant_id: int, ) -> Optional[Recipe]: """更新食谱""" recipe = await self.get_recipe(db, recipe_id, tenant_id) if not recipe: return None update_data = recipe_data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(recipe, field, value) await db.commit() await db.refresh(recipe) return recipe async def delete_recipe( self, db: AsyncSession, recipe_id: int, tenant_id: int, ) -> bool: """删除食谱""" recipe = await self.get_recipe(db, recipe_id, tenant_id) if not recipe: return False await db.delete(recipe) await db.commit() return True async def analyze_food_from_description( self, db: AsyncSession, description: str, tenant_id: int, ) -> dict: """ 基于食物描述分析热量和营养成分 注意:此方法使用纯文本分析。对于图片识别, 建议先使用图像识别服务将图片转为描述文本,再调用此方法。 """ tid = str(tenant_id) prompt = PROMPT_ANALYZE_FOOD_CALORIES.format(food_description=description) try: response_text = await self.ai_service._invoke_with_retry( prompt, db=db, tenant_id=tid, function_name="analyze_food_calories", use_cache=True, ) result = self.ai_service._parse_json(response_text) return { "success": True, "data": result, "message": "分析成功", } except Exception as e: return { "success": False, "data": None, "message": f"分析失败: {str(e)}", } async def create_recipe_with_ai_analysis( self, db: AsyncSession, recipe_data: RecipeCreate, food_description: str, tenant_id: int, ) -> tuple[Recipe, dict]: """ 创建食谱并进行 AI 热量分析 Args: db: 数据库会话 recipe_data: 食谱基础数据 food_description: 食物描述(可以是图片识别后的描述) tenant_id: 租户ID Returns: (Recipe, analysis_result): 创建的食谱对象和分析结果 """ # 先创建食谱 recipe = await self.create_recipe(db, recipe_data, tenant_id) # 进行 AI 分析 analysis = await self.analyze_food_from_description( db, food_description, tenant_id ) if analysis["success"] and analysis["data"]: # 更新食谱的营养信息 data = analysis["data"] recipe.calories = data.get("calories") recipe.protein = data.get("protein") recipe.fat = data.get("fat") recipe.carbs = data.get("carbs") recipe.fiber = data.get("fiber") recipe.ai_recognition_confidence = data.get("confidence") recipe.ai_analysis_result = data recipe.category = data.get("category") or recipe_data.category recipe.cuisine = data.get("cuisine") or recipe_data.cuisine await db.commit() await db.refresh(recipe) return recipe, analysis def decode_base64_image(self, image_base64: str) -> tuple[bytes, str]: """ 解码 Base64 图片数据 Args: image_base64: Base64 编码的图片数据(可能包含 data:image/xxx;base64, 前缀) Returns: (image_bytes, mime_type): 图片字节数据和 MIME 类型 """ # 移除可能的数据 URI 前缀 if "," in image_base64: header, image_base64 = image_base64.split(",", 1) # 提取 MIME 类型 mime_type = header.split(";")[0].split(":")[-1] if ":" in header else "image/jpeg" else: mime_type = "image/jpeg" # 解码 Base64 image_bytes = base64.b64decode(image_base64) return image_bytes, mime_type async def upload_image( self, image_base64: str, filename: Optional[str] = None, ) -> str: """ 上传图片并返回 URL 注意:这是一个示例实现,实际项目中应该集成: - AWS S3 - 阿里云 OSS - 腾讯云 COS - MinIO 等对象存储服务 Args: image_base64: Base64 编码的图片数据 filename: 原始文件名 Returns: 图片 URL """ # TODO: 集成实际的文件存储服务 # 这里只是示例,实际应该上传到对象存储 image_bytes, mime_type = self.decode_base64_image(image_base64) # 示例:生成一个模拟 URL # 实际应该上传后返回真实的 URL import hashlib import time file_hash = hashlib.md5(image_bytes).hexdigest() timestamp = int(time.time()) mock_url = f"https://example.com/recipe-images/{file_hash}_{timestamp}.jpg" return mock_url # 单例 _recipe_service: Optional[RecipeService] = None def get_recipe_service() -> RecipeService: """获取食谱服务单例""" global _recipe_service if _recipe_service is None: from ..ai_service_core import get_ai_service _recipe_service = RecipeService(ai_service=get_ai_service()) return _recipe_service