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

@@ -6,6 +6,7 @@ from pydantic import BaseModel
class MinioConfig(BaseModel): class MinioConfig(BaseModel):
endpoint: str endpoint: str
public_endpoint: str = ""
bucket: str = "snapledger" bucket: str = "snapledger"
access_key: str access_key: str
secret_key: str secret_key: str

View File

@@ -1,7 +1,18 @@
from pydantic import BaseModel from pydantic import BaseModel
class UploadResponse(BaseModel): class PresignRequest(BaseModel):
success: bool original_filename: str
data: dict | None = None
error: str | None = None
class PresignData(BaseModel):
upload_url: str
object_key: str
ref_id: str
class ConfirmRequest(BaseModel):
ref_id: str
object_key: str
original_filename: str
user_note: str | None = None

View File

@@ -1,8 +1,10 @@
import logging import logging
import uuid
from fastapi import APIRouter, File, Form, UploadFile from fastapi import APIRouter, File, Form, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.models.transaction import ConfirmRequest, PresignRequest
from app.services import database, minio from app.services import database, minio
router = APIRouter(tags=["upload"]) router = APIRouter(tags=["upload"])
@@ -10,6 +12,66 @@ logger = logging.getLogger(__name__)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB 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") @router.post("/transactions/upload")
async def upload_transaction( async def upload_transaction(
@@ -17,7 +79,7 @@ async def upload_transaction(
original_filename: str | None = Form(None), original_filename: str | None = Form(None),
user_note: 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() content = await file.read()
if len(content) == 0: if len(content) == 0:
@@ -34,7 +96,6 @@ async def upload_transaction(
filename = original_filename or file.filename or "upload.jpg" filename = original_filename or file.filename or "upload.jpg"
# Upload to MinIO
try: try:
object_key = minio.upload_image(content, filename) object_key = minio.upload_image(content, filename)
except Exception: except Exception:
@@ -44,7 +105,6 @@ async def upload_transaction(
content={"success": False, "error": "Storage service unavailable"}, content={"success": False, "error": "Storage service unavailable"},
) )
# Insert metadata into PostgreSQL
try: try:
result = database.insert_transaction( result = database.insert_transaction(
image_object_key=object_key, image_object_key=object_key,

View File

@@ -39,3 +39,23 @@ def upload_image(file_bytes: bytes, original_filename: str) -> str:
ContentType=content_type, ContentType=content_type,
) )
return object_key return object_key
def generate_presigned_put_url(object_key: str) -> str:
client = _get_client()
url: str = client.generate_presigned_url(
"put_object",
Params={
"Bucket": settings.minio.bucket,
"Key": object_key,
},
ExpiresIn=300,
)
# Replace internal endpoint with public endpoint
if settings.minio.public_endpoint:
from urllib.parse import urlparse, urlunparse
internal = urlparse(settings.minio.endpoint)
public = urlparse(settings.minio.public_endpoint)
url = url.replace(f"{internal.scheme}://{internal.netloc}", f"{public.scheme}://{public.netloc}", 1)
return url

View File

@@ -3,6 +3,7 @@
minio: minio:
endpoint: "https://minio.example.com:9000" endpoint: "https://minio.example.com:9000"
public_endpoint: "https://minio-public.example.com"
bucket: "snapledger" bucket: "snapledger"
access_key: "YOUR_MINIO_ACCESS_KEY" access_key: "YOUR_MINIO_ACCESS_KEY"
secret_key: "YOUR_MINIO_SECRET_KEY" secret_key: "YOUR_MINIO_SECRET_KEY"