Преглед изворни кода

feat(backend): add local/minio storage and public upload API

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu пре 2 месеци
родитељ
комит
561dfba49d

+ 2 - 0
.gitignore

@@ -5,3 +5,5 @@ node_modules/
 *.log
 .env
 .env.*
+!.env.example
+!**/.env.example

+ 22 - 0
backend/.env.example

@@ -0,0 +1,22 @@
+# ajyy backend environment example
+APP_NAME=ajyy
+API_V1_PREFIX=/api
+DATABASE_URL=sqlite+pysqlite:///./ajyy.db
+SECRET_KEY=change-me-in-production
+ACCESS_TOKEN_EXPIRE_MINUTES=720
+ALGORITHM=HS256
+INIT_ADMIN_USERNAME=admin
+INIT_ADMIN_PASSWORD=Admin@123
+
+# local | minio
+STORAGE_TYPE=local
+LOCAL_STORAGE_DIR=./uploads
+
+MINIO_ENDPOINT=127.0.0.1:9000
+MINIO_ACCESS_KEY=
+MINIO_SECRET_KEY=
+MINIO_BUCKET=ajyy
+MINIO_SECURE=false
+MINIO_BASE_URL=
+
+CORS_ORIGINS=*

+ 39 - 0
backend/app/api/public.py

@@ -0,0 +1,39 @@
+"""公开 API:附件上传等(无需登录)。"""
+from typing import Annotated, Optional
+
+from fastapi import APIRouter, Depends, File, Form, UploadFile
+from sqlalchemy.orm import Session
+
+from app.core import storage
+from app.core.database import get_db
+from app.core.response import AppHTTPError, success_response
+from app.crud import attachment as attachment_crud
+
+router = APIRouter(prefix="/public", tags=["public"])
+
+
+@router.post("/attachment/upload")
+async def upload_attachment(
+    db: Annotated[Session, Depends(get_db)],
+    file: UploadFile = File(...),
+    folder: Optional[str] = Form(""),
+):
+    if not file or not file.filename:
+        raise AppHTTPError(400, "请选择文件")
+    content = await file.read()
+    stored = storage.upload_file(content, file.filename, folder or "")
+    row = attachment_crud.create_attachment(
+        db,
+        file_name=file.filename,
+        object_key=stored["objectKey"],
+        url=stored["url"],
+        size=len(content),
+    )
+    return success_response(
+        {
+            "id": row.id,
+            "url": row.url,
+            "fileName": row.file_name,
+            "size": row.size,
+        }
+    )

+ 85 - 0
backend/app/core/storage.py

@@ -0,0 +1,85 @@
+"""文件存储:local | minio。upload_file 返回 {url, objectKey}。"""
+from __future__ import annotations
+
+import os
+import uuid
+from datetime import datetime, timezone
+from io import BytesIO
+from pathlib import Path
+from typing import Any, Dict
+
+from app.core.config import get_settings
+
+
+def _unique_name(filename: str) -> str:
+    ext = os.path.splitext(filename)[1] or ""
+    return f"{uuid.uuid4().hex}{ext}"
+
+
+def _object_key(folder: str, filename: str) -> str:
+    unique = _unique_name(filename)
+    date_part = datetime.now(timezone.utc).strftime("%Y%m/%d")
+    folder = (folder or "").strip().strip("/")
+    if folder:
+        return f"{folder}/{date_part}/{unique}"
+    return f"{date_part}/{unique}"
+
+
+class LocalStorage:
+    def upload_file(self, content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
+        settings = get_settings()
+        base_dir = Path(settings.LOCAL_STORAGE_DIR)
+        object_key = _object_key(folder, filename)
+        dest = base_dir / object_key
+        dest.parent.mkdir(parents=True, exist_ok=True)
+        dest.write_bytes(content)
+        url = f"/uploads/{object_key}"
+        return {"url": url, "objectKey": object_key}
+
+
+class MinIOStorage:
+    def __init__(self) -> None:
+        from minio import Minio
+
+        settings = get_settings()
+        self._settings = settings
+        self.client = Minio(
+            settings.MINIO_ENDPOINT,
+            access_key=settings.MINIO_ACCESS_KEY,
+            secret_key=settings.MINIO_SECRET_KEY,
+            secure=settings.MINIO_SECURE,
+        )
+        self.bucket_name = settings.MINIO_BUCKET
+        if settings.MINIO_BASE_URL:
+            self.base_url = settings.MINIO_BASE_URL.rstrip("/")
+        else:
+            scheme = "https" if settings.MINIO_SECURE else "http"
+            self.base_url = f"{scheme}://{settings.MINIO_ENDPOINT}/{self.bucket_name}"
+        self._ensure_bucket()
+
+    def _ensure_bucket(self) -> None:
+        if not self.client.bucket_exists(self.bucket_name):
+            self.client.make_bucket(self.bucket_name)
+
+    def upload_file(self, content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
+        object_key = _object_key(folder, filename)
+        self.client.put_object(
+            bucket_name=self.bucket_name,
+            object_name=object_key,
+            data=BytesIO(content),
+            length=len(content),
+        )
+        url = f"{self.base_url}/{object_key}"
+        return {"url": url, "objectKey": object_key}
+
+
+def get_storage() -> Any:
+    settings = get_settings()
+    if settings.STORAGE_TYPE.lower() == "minio":
+        return MinIOStorage()
+    return LocalStorage()
+
+
+def upload_file(content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
+    """上传文件,返回 {url, objectKey}。"""
+    return get_storage().upload_file(content, filename, folder)

+ 30 - 0
backend/app/crud/attachment.py

@@ -0,0 +1,30 @@
+"""附件元数据 CRUD"""
+from typing import Optional
+
+from sqlalchemy.orm import Session
+
+from app.models.business import Attachment
+
+
+def create_attachment(
+    db: Session,
+    *,
+    file_name: str,
+    object_key: str,
+    url: str,
+    size: int = 0,
+    biz_type: Optional[str] = None,
+    biz_id: Optional[str] = None,
+) -> Attachment:
+    row = Attachment(
+        file_name=file_name,
+        object_key=object_key,
+        url=url,
+        size=size,
+        biz_type=biz_type,
+        biz_id=biz_id,
+    )
+    db.add(row)
+    db.commit()
+    db.refresh(row)
+    return row

+ 8 - 0
backend/app/main.py

@@ -1,10 +1,13 @@
 from contextlib import asynccontextmanager
+from pathlib import Path
 
 from fastapi import FastAPI, Request
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import JSONResponse
+from fastapi.staticfiles import StaticFiles
 
 from app.api.auth import router as auth_router
+from app.api.public import router as public_router
 from app.core.config import get_settings
 from app.core.database import SessionLocal, init_db
 from app.core.response import AppHTTPError, error_response, success_response
@@ -39,6 +42,11 @@ app.add_middleware(
 )
 
 app.include_router(auth_router, prefix=settings.API_V1_PREFIX)
+app.include_router(public_router, prefix=settings.API_V1_PREFIX)
+
+_upload_dir = Path(settings.LOCAL_STORAGE_DIR)
+_upload_dir.mkdir(parents=True, exist_ok=True)
+app.mount("/uploads", StaticFiles(directory=str(_upload_dir)), name="uploads")
 
 
 @app.exception_handler(AppHTTPError)

+ 18 - 0
backend/tests/test_upload.py

@@ -0,0 +1,18 @@
+"""Public attachment upload (local storage)."""
+from app.core.config import get_settings
+
+
+def test_public_upload(client, tmp_path, monkeypatch):
+    monkeypatch.setenv("STORAGE_TYPE", "local")
+    monkeypatch.setenv("LOCAL_STORAGE_DIR", str(tmp_path))
+    get_settings.cache_clear()
+
+    files = {"file": ("a.png", b"fake-png-bytes", "image/png")}
+    r = client.post("/api/public/attachment/upload", files=files)
+    assert r.status_code == 200
+    data = r.json()["data"]
+    assert data["url"]
+    assert data["id"]
+    assert data["fileName"] == "a.png"
+    assert data["size"] == len(b"fake-png-bytes")
+    assert data["url"].startswith("/uploads/")