""" 图片处理 API ============ 提供图片上传(七牛云)和 AI 抠图(火山引擎 MediaKit)功能。 """ import io import logging import time import uuid from pathlib import Path import httpx from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db from app.config import get_settings from app.models.user import User from app.schemas.common import ApiResponse, success_response from app.services import point_service as ps from app.services.qiniu_service import get_qiniu_service from app.services.volcengine_mediakit_service import VolcengineMediakitService from app.utils.file_validation import check_upload_file router = APIRouter(tags=["Image"]) logger = logging.getLogger(__name__) settings = get_settings() # ── Dependencies ── async def get_mediakit_service(request: Request) -> VolcengineMediakitService: """FastAPI Depends:从 app.state 获取全局 VolcengineMediakitService 实例。""" service = getattr(request.app.state, "volcengine_mediakit_service", None) if service is None: raise HTTPException( status_code=503, detail="MediaKit 服务未初始化,请检查配置", ) return service # ── Schemas ── class ImageUploadResponse(BaseModel): """图片上传响应""" url: str = Field(..., description="七牛云图片 URL") key: str = Field(..., description="七牛云文件 key") size: int = Field(..., description="文件大小(字节)") class RemoveBackgroundResponse(BaseModel): """抠图响应""" url: str = Field(..., description="抠图结果图片 URL") class RemoveBackgroundRequest(BaseModel): """抠图请求""" image_url: str = Field(..., description="原始图片 URL") scene: str = Field(default="human", description="场景类型:general(通用)、human(人物,默认白色描边)或 product(商品)") # ── Endpoints ── @router.post("/upload/image", response_model=ApiResponse[ImageUploadResponse]) async def upload_image( file: UploadFile = File(..., description="图片文件"), current_user: User = Depends(get_current_user), ) -> ApiResponse[ImageUploadResponse]: """ 上传图片到七牛云 支持格式:jpg, jpeg, png, gif, webp 返回七牛云永久访问 URL。 """ try: allowed_types = { "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", } content_type = file.content_type or "" # 如果 content_type 为空,尝试从文件名推断 if not content_type: ext = Path(file.filename or "").suffix.lower() ext_to_mime = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", } content_type = ext_to_mime.get(ext, "") if content_type not in allowed_types: raise HTTPException( status_code=400, detail=f"不支持的图片格式: {content_type},请上传 jpg/png/gif/webp 图片", ) # 读取文件内容 content = await file.read() if not content: raise HTTPException(status_code=400, detail="文件内容为空") # 校验大小和魔数 check_upload_file( content, settings.UPLOAD_MAX_IMAGE_SIZE, content_type, "图片", ) # 生成唯一文件名 ext = Path(file.filename or "image.jpg").suffix or ".jpg" unique_name = f"{uuid.uuid4().hex[:16]}{ext}" # 上传到七牛云 qiniu = get_qiniu_service() bucket, domain = qiniu._get_bucket_and_domain("image") file_key = qiniu.generate_key("image", unique_name) stream = io.BytesIO(content) result = await qiniu.upload_stream_async( stream=stream, key=file_key, mime_type=content_type or "image/jpeg", bucket=bucket, domain=domain, ) url = result.get("url") returned_key = result.get("key") if not url: raise HTTPException(status_code=500, detail="上传到七牛云失败:未返回 URL") logger.info(f"[Upload] 图片上传成功: {url[:80]}..., size={len(content)}") return success_response( data=ImageUploadResponse( url=url, key=returned_key or file_key, size=len(content), ) ) except HTTPException: raise except Exception as e: logger.error(f"[Upload] 图片上传失败: {e}") raise HTTPException(status_code=500, detail=f"上传失败: {e}") @router.post("/image/remove-background", response_model=ApiResponse[RemoveBackgroundResponse]) async def remove_background( req: RemoveBackgroundRequest, current_user: User = Depends(get_current_user), mediakit_service: VolcengineMediakitService = Depends(get_mediakit_service), db: AsyncSession = Depends(get_db), ) -> ApiResponse[RemoveBackgroundResponse]: """ AI 抠图(火山引擎 MediaKit) 移除图片背景,返回透明背景图片 URL。 每次调用消耗 10 积分。 """ # 前置积分检查 required_points = ps._calculate_cost("cover_avatar") check = await ps.check_balance(db, current_user.id, required_points) if not check["sufficient"]: raise HTTPException( status_code=402, detail=f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}", ) try: logger.info( f"[RemoveBackground] 开始抠图: image_url={req.image_url[:80]}..., scene={req.scene}" ) result = await mediakit_service.remove_background( image_url=req.image_url, scene=req.scene, ) if not result.image_url: logger.error( f"[RemoveBackground] 抠图返回空 URL: raw={result.raw}" ) raise HTTPException(status_code=500, detail="抠图失败:未返回结果图片 URL") logger.info(f"[RemoveBackground] 抠图成功: {result.image_url[:80]}...") # 下载抠图结果并转存到七牛云(避免前端 CORS 问题) try: async with httpx.AsyncClient(timeout=60.0) as client: img_resp = await client.get(result.image_url, follow_redirects=True) img_resp.raise_for_status() img_content = img_resp.content if not img_content: raise HTTPException(status_code=500, detail="抠图结果下载失败:内容为空") # 上传到七牛云 image bucket qiniu = get_qiniu_service() bucket, domain = qiniu._get_bucket_and_domain("image") unique_name = f"{uuid.uuid4().hex[:16]}.png" file_key = qiniu.generate_key("image", unique_name) stream = io.BytesIO(img_content) upload_result = await qiniu.upload_stream_async( stream=stream, key=file_key, mime_type="image/png", bucket=bucket, domain=domain, ) qiniu_url = upload_result.get("url") if not qiniu_url: raise HTTPException(status_code=500, detail="抠图结果转存到七牛云失败") logger.info(f"[RemoveBackground] 结果已转存七牛云: {qiniu_url[:80]}...") # 后置扣费(服务已调用成功) await ps.consume( db, user_id=current_user.id, points=required_points, source_type="cover_avatar", source_id=f"cover_avatar_{current_user.id}_{int(time.time() * 1000)}", description="【封面形象抠图】", ) await db.commit() return success_response( data=RemoveBackgroundResponse(url=qiniu_url), message="抠图成功", ) except HTTPException: raise except Exception as e: logger.error(f"[RemoveBackground] 结果转存失败: {e}") raise HTTPException(status_code=500, detail=f"抠图结果转存失败: {e}") except HTTPException: raise except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error( f"[RemoveBackground] 抠图失败: image_url={req.image_url[:80]}..., error={e}" ) raise HTTPException(status_code=500, detail=f"抠图失败: {e}")