"""POST /api/v1/parse-image —— iOS 端的截图识别接口。 multipart/form-data: - image: file (JPEG/PNG/HEIC,客户端已压到 < 5MB) - clusters: str (JSON 序列化的 [{"id":"","title":"..."}, ...]) - package_name: str (可选,iOS 通常不传或传 null;Mock 实现不使用) 占坑期实现接 mock_extractor;后续接真模型时只改这里调 extractor_vision。 """ from __future__ import annotations import json import logging from typing import Optional from fastapi import APIRouter, File, Form, HTTPException, UploadFile from pydantic import ValidationError from app.mock_extractor import mock_extract_image from app.schemas import ClusterDtoStr, ParseStrResponse logger = logging.getLogger("shagua.parse_image") router = APIRouter(prefix="/api/v1") # 单张图片上限 5MB。客户端正常压完短边 1024 的 JPEG 通常 < 500KB, # 超过 5MB 是异常情况(原图 / 客户端压缩 bug),直接拒掉省 LLM 钱。 MAX_IMAGE_BYTES = 5 * 1024 * 1024 @router.post("/parse-image", response_model=ParseStrResponse) async def parse_image( image: UploadFile = File(...), clusters: str = Form("[]"), package_name: Optional[str] = Form(None), ) -> ParseStrResponse: image_bytes = await image.read() if not image_bytes: logger.info("parse_image rejected: no_image bytes=0") raise HTTPException(status_code=422, detail="no_image") if len(image_bytes) > MAX_IMAGE_BYTES: logger.info("parse_image rejected: image_too_large bytes=%d", len(image_bytes)) raise HTTPException(status_code=422, detail="image_too_large") try: clusters_raw = json.loads(clusters) if clusters else [] if not isinstance(clusters_raw, list): raise ValueError("clusters must be a JSON array") cluster_dtos = [ClusterDtoStr(**c) for c in clusters_raw] except (json.JSONDecodeError, ValueError, TypeError, ValidationError) as e: # ValidationError 不是 ValueError 子类(pydantic v2),必须显式 catch, # 否则客户端发字段不全的 cluster 会让接口冒 500 logger.info("parse_image rejected: invalid_clusters err=%s", e) raise HTTPException(status_code=422, detail="invalid_clusters") logger.info( "parse_image request bytes=%d package=%s clusters=%d", len(image_bytes), package_name, len(cluster_dtos), ) result = await mock_extract_image(image_bytes, cluster_dtos) if not result.get("success"): raise HTTPException(status_code=422, detail=result.get("reason", "extract_failed")) return ParseStrResponse( title=result["title"], price=result["price"], source_app=result["source_app"], cluster_id=result.get("cluster_id"), typical_price=result["typical_price"], )