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>
This commit is contained in:
Misaka_Company
2026-05-28 12:51:10 +08:00
parent e3c84540eb
commit fe81153491
5 changed files with 100 additions and 7 deletions

View File

@@ -1,8 +1,10 @@
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"])
@@ -10,6 +12,66 @@ 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(
@@ -17,7 +79,7 @@ async def upload_transaction(
original_filename: str | None = Form(None),
user_note: str | None = Form(None),
):
# Read file content
# Legacy fallback endpoint — receives image via backend relay
content = await file.read()
if len(content) == 0:
@@ -34,7 +96,6 @@ async def upload_transaction(
filename = original_filename or file.filename or "upload.jpg"
# Upload to MinIO
try:
object_key = minio.upload_image(content, filename)
except Exception:
@@ -44,7 +105,6 @@ async def upload_transaction(
content={"success": False, "error": "Storage service unavailable"},
)
# Insert metadata into PostgreSQL
try:
result = database.insert_transaction(
image_object_key=object_key,