PMFlow/backend/app/services/recipe/service.py

267 lines
7.9 KiB
Python

"""食谱服务 - 包含热量识别与营养分析"""
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