Files
backend/app/routers/upload.py
Misaka_Company fe81153491 Add S3 presigned URL direct upload (presign → PUT → confirm)
- Add presign endpoint: POST /api/v1/transactions/presign
- Add confirm endpoint: POST /api/v1/transactions/confirm
- Add generate_presigned_put_url() to MinIO service with public endpoint support
- Add public_endpoint field to MinioConfig
- Keep legacy /upload endpoint as fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-28 12:51:10 +08:00

122 lines
3.6 KiB
Python

import logging
import uuid
from fastapi import APIRouter, File, Form, UploadFile
from fastapi.responses import JSONResponse
from app.models.transaction import ConfirmRequest, PresignRequest
from app.services import database, minio
router = APIRouter(tags=["upload"])
logger = logging.getLogger(__name__)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
# In-memory pending references (ref_id → object_key)
_pending_refs: dict[str, str] = {}
@router.post("/transactions/presign")
async def presign_upload(req: PresignRequest):
object_key = minio._generate_object_key(req.original_filename)
ref_id = uuid.uuid4().hex
try:
upload_url = minio.generate_presigned_put_url(object_key)
except Exception:
logger.exception("Failed to generate presigned URL")
return JSONResponse(
status_code=503,
content={"success": False, "error": "Storage service unavailable"},
)
_pending_refs[ref_id] = object_key
return {
"success": True,
"data": {
"upload_url": upload_url,
"object_key": object_key,
"ref_id": ref_id,
},
}
@router.post("/transactions/confirm")
async def confirm_upload(req: ConfirmRequest):
expected_key = _pending_refs.pop(req.ref_id, None)
if expected_key is None:
return JSONResponse(
status_code=404,
content={"success": False, "error": "Invalid or expired reference ID"},
)
if req.object_key != expected_key:
return JSONResponse(
status_code=400,
content={"success": False, "error": "Object key mismatch"},
)
try:
result = database.insert_transaction(
image_object_key=req.object_key,
original_filename=req.original_filename,
user_note=req.user_note if req.user_note else None,
)
except Exception:
logger.exception("Failed to save transaction metadata")
return JSONResponse(
status_code=500,
content={"success": False, "error": "Failed to save transaction metadata"},
)
return {"success": True, "data": result}
@router.post("/transactions/upload")
async def upload_transaction(
file: UploadFile = File(...),
original_filename: str | None = Form(None),
user_note: str | None = Form(None),
):
# Legacy fallback endpoint — receives image via backend relay
content = await file.read()
if len(content) == 0:
return JSONResponse(
status_code=422,
content={"success": False, "error": "Missing required field: file"},
)
if len(content) > MAX_FILE_SIZE:
return JSONResponse(
status_code=413,
content={"success": False, "error": "File size exceeds 10MB limit"},
)
filename = original_filename or file.filename or "upload.jpg"
try:
object_key = minio.upload_image(content, filename)
except Exception:
logger.exception("Failed to upload image to storage")
return JSONResponse(
status_code=503,
content={"success": False, "error": "Storage service unavailable"},
)
try:
result = database.insert_transaction(
image_object_key=object_key,
original_filename=filename,
user_note=user_note if user_note else None,
)
except Exception:
logger.exception("Failed to save transaction metadata")
return JSONResponse(
status_code=500,
content={"success": False, "error": "Failed to save transaction metadata"},
)
return {"success": True, "data": result}