Implement Phase 1 backend: FastAPI upload API with MinIO and PostgreSQL

- Add FastAPI app with CORS and auth middleware (Bearer token)
- Add POST /api/v1/transactions/upload endpoint (multipart/form-data)
- Add MinIO storage service with date-partitioned object keys
- Add PostgreSQL database service for transaction metadata
- Add Dockerfile and docker-compose.yml for deployment
- Add config.example.yaml template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-05-21 16:42:08 +08:00
parent a7ae5cba5d
commit e3c84540eb
16 changed files with 306 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
config.yaml
__pycache__/
*.pyc
.venv/
*.egg-info/
dist/
build/
.env

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir .
COPY app/ app/
COPY config.example.yaml config.example.yaml
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
app/__init__.py Normal file
View File

33
app/auth.py Normal file
View File

@@ -0,0 +1,33 @@
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import settings
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if not request.url.path.startswith("/api/v1"):
return await call_next(request)
token = settings.api.token
if not token:
return JSONResponse(
status_code=500,
content={"success": False, "error": "Server configuration error"},
)
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return JSONResponse(
status_code=401,
content={"success": False, "error": "Missing authentication token"},
)
if auth_header.removeprefix("Bearer ") != token:
return JSONResponse(
status_code=403,
content={"success": False, "error": "Invalid authentication token"},
)
return await call_next(request)

48
app/config.py Normal file
View File

@@ -0,0 +1,48 @@
from pathlib import Path
import yaml
from pydantic import BaseModel
class MinioConfig(BaseModel):
endpoint: str
bucket: str = "snapledger"
access_key: str
secret_key: str
class PostgresConfig(BaseModel):
host: str
port: int = 5432
database: str = "snapledger"
user: str
password: str
@property
def dsn(self) -> str:
return (
f"postgresql://{self.user}:{self.password}"
f"@{self.host}:{self.port}/{self.database}"
)
class ApiConfig(BaseModel):
token: str
class AppConfig(BaseModel):
minio: MinioConfig
postgresql: PostgresConfig
api: ApiConfig
def load_config(path: str = "config.yaml") -> AppConfig:
config_path = Path(path)
if not config_path.exists():
raise FileNotFoundError(f"Configuration file not found: {path}")
with open(config_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
return AppConfig(**data)
settings = load_config()

20
app/main.py Normal file
View File

@@ -0,0 +1,20 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.auth import AuthMiddleware
from app.routers import upload
app = FastAPI(
title="SnapLedger API",
version="0.1.0",
)
app.add_middleware(AuthMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(upload.router, prefix="/api/v1")

0
app/models/__init__.py Normal file
View File

View File

@@ -0,0 +1,7 @@
from pydantic import BaseModel
class UploadResponse(BaseModel):
success: bool
data: dict | None = None
error: str | None = None

0
app/routers/__init__.py Normal file
View File

61
app/routers/upload.py Normal file
View File

@@ -0,0 +1,61 @@
import logging
from fastapi import APIRouter, File, Form, UploadFile
from fastapi.responses import JSONResponse
from app.services import database, minio
router = APIRouter(tags=["upload"])
logger = logging.getLogger(__name__)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
@router.post("/transactions/upload")
async def upload_transaction(
file: UploadFile = File(...),
original_filename: str | None = Form(None),
user_note: str | None = Form(None),
):
# Read file content
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"
# Upload to MinIO
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"},
)
# Insert metadata into PostgreSQL
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}

0
app/services/__init__.py Normal file
View File

29
app/services/database.py Normal file
View File

@@ -0,0 +1,29 @@
import psycopg
from psycopg.rows import dict_row
from app.config import settings
def _get_conn():
return psycopg.connect(settings.postgresql.dsn, row_factory=dict_row)
def insert_transaction(
image_object_key: str,
original_filename: str | None,
user_note: str | None,
) -> dict:
with _get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO transactions
(image_object_key, original_filename, user_note, process_status)
VALUES (%s, %s, %s, 'PENDING')
RETURNING id, upload_time
""",
(image_object_key, original_filename, user_note),
)
row = cur.fetchone()
conn.commit()
return {"id": str(row["id"]), "upload_time": row["upload_time"].isoformat()}

41
app/services/minio.py Normal file
View File

@@ -0,0 +1,41 @@
import uuid
from datetime import datetime, timezone
from pathlib import PurePosixPath
import boto3
from botocore.config import Config as BotoConfig
from app.config import settings
def _get_client():
return boto3.client(
"s3",
endpoint_url=settings.minio.endpoint,
aws_access_key_id=settings.minio.access_key,
aws_secret_access_key=settings.minio.secret_key,
config=BotoConfig(signature_version="s3v4"),
)
def _generate_object_key(filename: str) -> str:
now = datetime.now(timezone.utc)
date_prefix = now.strftime("%Y/%m/%d")
ext = PurePosixPath(filename).suffix or ".jpg"
return f"{date_prefix}/{uuid.uuid4().hex}{ext}"
def upload_image(file_bytes: bytes, original_filename: str) -> str:
client = _get_client()
object_key = _generate_object_key(original_filename)
content_type = "image/jpeg"
if original_filename.lower().endswith(".png"):
content_type = "image/png"
client.put_object(
Bucket=settings.minio.bucket,
Key=object_key,
Body=file_bytes,
ContentType=content_type,
)
return object_key

18
config.example.yaml Normal file
View File

@@ -0,0 +1,18 @@
# SnapLedger Backend Configuration
# Copy this file to config.yaml and fill in your credentials.
minio:
endpoint: "https://minio.example.com:9000"
bucket: "snapledger"
access_key: "YOUR_MINIO_ACCESS_KEY"
secret_key: "YOUR_MINIO_SECRET_KEY"
postgresql:
host: "localhost"
port: 5432
database: "snapledger"
user: "snapledger_user"
password: "YOUR_POSTGRES_PASSWORD"
api:
token: "YOUR_API_TOKEN"

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
backend:
build: .
ports:
- "8000:8000"
volumes:
- ./config.yaml:/app/config.yaml:ro
restart: unless-stopped

20
pyproject.toml Normal file
View File

@@ -0,0 +1,20 @@
[project]
name = "snapledger-backend"
version = "0.1.0"
description = "SnapLedger backend API server"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.34.0",
"boto3>=1.35.0",
"psycopg[binary]>=3.2.0",
"python-multipart>=0.0.12",
"pydantic>=2.9.0",
"pyyaml>=6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"httpx>=0.27",
]