feat(m8): P1 MinIO 对象存储与文件管理

- 迁移 000023 gateway.file_objects(personal/system 归属隔离 + 部分索引)
- internal/platform/storage:minio-go 适配(端点 scheme 剥离、流式 PutObject/Open/Delete)
- internal/workbench/files.go:FileService(sha256 校验、PutObject-then-insert 回滚、delete 先删行再删对象)
- admin /api/v1/admin/files + portal /api/v1/portal/files 处理器(流式上传下载、Content-Disposition)
- RBAC file:read/file:manage;菜单加文件管理 + 门户文件仓库
- compose 增 minio 服务(S3_* anchor、不暴露端口);nginx client_max_body_size 32m→256m
- 管理端文件管理页 + 门户个人文件仓;集成测试 TestFileObjectLifecycle 连真 MinIO 通过
- healthz object_storage:true;README/PRODUCTION/进展文档同步

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ben
2026-08-12 14:08:38 +08:00
parent 5759c1862e
commit 6708c226a5
26 changed files with 1180 additions and 40 deletions
+194
View File
@@ -0,0 +1,194 @@
package workbench
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"strings"
"time"
"aigateway.local/core/internal/platform/storage"
"github.com/jackc/pgx/v5"
)
// FileObject is the metadata row for one object stored in MinIO. The object
// body lives in the bucket; this table is the searchable index plus the access
// control (personal scope is bound to a portal user, system scope to admins).
type FileObject struct {
ID string `json:"id"`
ObjectKey string `json:"-"`
OriginalName string `json:"original_name"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
ContentSHA256 string `json:"content_sha256"`
Scope string `json:"scope"`
OwnerUserID *string `json:"owner_user_id,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// FileService stores object metadata in PostgreSQL and the body in MinIO. Both
// writes are kept consistent: the object is uploaded first and only then is the
// metadata row inserted; any failure rolls the object back.
type FileService struct {
assets *Service
store *storage.Client
}
func NewFileService(assets *Service, store *storage.Client) *FileService {
return &FileService{assets: assets, store: store}
}
func (s *FileService) MaxBytes() int64 { return s.store.MaxBytes() }
// countingReader tallies how many bytes flow through it so uploads can enforce
// the configured size ceiling without buffering the whole file in memory.
type countingReader struct {
r io.Reader
n *int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
*c.n += int64(n)
return n, err
}
// Upload streams the body into the object store and records its metadata. The
// owner must be set for the personal scope and nil for the system scope.
func (s *FileService) Upload(ctx context.Context, scope string, ownerUserID *string, createdBy, originalName, contentType string, body io.Reader) (FileObject, error) {
originalName = strings.TrimSpace(originalName)
if originalName == "" || len(originalName) > 255 {
return FileObject{}, errors.New("文件名无效")
}
if scope != "personal" && scope != "system" {
return FileObject{}, errors.New("无效的文件范围")
}
if contentType == "" {
contentType = "application/octet-stream"
}
objectKey, err := newUUID()
if err != nil {
return FileObject{}, err
}
objectKey = scope + "/" + objectKey
var size int64
hasher := sha256.New()
limited := io.LimitReader(body, s.store.MaxBytes()+1)
counted := &countingReader{r: limited, n: &size}
rollbackObject := func() { _ = s.store.DeleteObject(ctx, objectKey) }
if _, err = s.store.PutObject(ctx, objectKey, io.TeeReader(counted, hasher), -1, contentType); err != nil {
return FileObject{}, err
}
if size > s.store.MaxBytes() {
rollbackObject()
return FileObject{}, errors.New("文件超过大小上限")
}
id, err := newUUID()
if err != nil {
rollbackObject()
return FileObject{}, err
}
var owner any
if ownerUserID != nil && strings.TrimSpace(*ownerUserID) != "" {
owner = *ownerUserID
}
var obj FileObject
err = s.assets.pool.QueryRow(ctx, `INSERT INTO gateway.file_objects(id,object_key,original_name,content_type,size_bytes,content_sha256,scope,owner_user_id,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text,object_key,original_name,content_type,size_bytes,content_sha256,scope,owner_user_id::text,created_by,created_at,updated_at`, id, objectKey, originalName, contentType, size, hex.EncodeToString(hasher.Sum(nil)), scope, owner, createdBy).Scan(&obj.ID, &obj.ObjectKey, &obj.OriginalName, &obj.ContentType, &obj.SizeBytes, &obj.ContentSHA256, &obj.Scope, &obj.OwnerUserID, &obj.CreatedBy, &obj.CreatedAt, &obj.UpdatedAt)
if err != nil {
rollbackObject()
return FileObject{}, err
}
return obj, nil
}
const fileObjectSelect = `SELECT id::text,object_key,original_name,content_type,size_bytes,content_sha256,scope,owner_user_id::text,created_by,created_at,updated_at FROM gateway.file_objects`
func scanFileObject(row pgx.Row) (FileObject, error) {
var obj FileObject
err := row.Scan(&obj.ID, &obj.ObjectKey, &obj.OriginalName, &obj.ContentType, &obj.SizeBytes, &obj.ContentSHA256, &obj.Scope, &obj.OwnerUserID, &obj.CreatedBy, &obj.CreatedAt, &obj.UpdatedAt)
return obj, mapNotFound(err)
}
func (s *FileService) ListPersonal(ctx context.Context, ownerUserID string) ([]FileObject, error) {
rows, err := s.assets.pool.Query(ctx, fileObjectSelect+` WHERE scope='personal' AND owner_user_id=$1 ORDER BY created_at DESC`, ownerUserID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []FileObject{}
for rows.Next() {
obj, err := scanFileObject(rows)
if err != nil {
return nil, err
}
items = append(items, obj)
}
return items, rows.Err()
}
func (s *FileService) ListSystem(ctx context.Context) ([]FileObject, error) {
rows, err := s.assets.pool.Query(ctx, fileObjectSelect+` WHERE scope='system' ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []FileObject{}
for rows.Next() {
obj, err := scanFileObject(rows)
if err != nil {
return nil, err
}
items = append(items, obj)
}
return items, rows.Err()
}
func (s *FileService) GetPersonal(ctx context.Context, ownerUserID, id string) (FileObject, error) {
return scanFileObject(s.assets.pool.QueryRow(ctx, fileObjectSelect+` WHERE id=$1 AND scope='personal' AND owner_user_id=$2`, id, ownerUserID))
}
func (s *FileService) GetSystem(ctx context.Context, id string) (FileObject, error) {
return scanFileObject(s.assets.pool.QueryRow(ctx, fileObjectSelect+` WHERE id=$1 AND scope='system'`, id))
}
// Open streams the object body for download.
func (s *FileService) Open(ctx context.Context, obj FileObject) (io.ReadCloser, int64, error) {
return s.store.OpenObject(ctx, obj.ObjectKey)
}
func (s *FileService) DeletePersonal(ctx context.Context, ownerUserID, id string) error {
return s.deleteFile(ctx, id, `scope='personal' AND owner_user_id=$2`, ownerUserID)
}
func (s *FileService) DeleteSystem(ctx context.Context, id string) error {
return s.deleteFile(ctx, id, `scope='system'`, nil)
}
// deleteFile removes the metadata row and, best-effort, the object body. The
// object removal is best-effort because an orphan in the bucket is recoverable
// and must never block the delete that the user asked for.
func (s *FileService) deleteFile(ctx context.Context, id, where string, owner any) error {
// 只传 SQL 中真实出现的占位符参数:system 范围没有 $2,多传 nil 会让
// Postgres 报 "bind message supplies 2 parameters" 错误。
args := []any{id}
if owner != nil {
args = append(args, owner)
}
var key string
if err := s.assets.pool.QueryRow(ctx, `SELECT object_key FROM gateway.file_objects WHERE id=$1 AND `+where, args...).Scan(&key); err != nil {
return mapNotFound(err)
}
tag, err := s.assets.pool.Exec(ctx, `DELETE FROM gateway.file_objects WHERE id=$1 AND `+where, args...)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
_ = s.store.DeleteObject(ctx, key)
return nil
}
+165
View File
@@ -0,0 +1,165 @@
package workbench
import (
"errors"
"io"
"mime/multipart"
"net/http"
"net/url"
"strconv"
"strings"
"aigateway.local/core/internal/identity"
"aigateway.local/core/internal/platform/apiresponse"
)
// FilesAdminHTTPHandler exposes the system-scoped file store to the admin
// console. Objects are uploaded through the gateway (never exposing MinIO), so
// the S3 credentials stay inside the API container.
type FilesAdminHTTPHandler struct {
files *FileService
identity *identity.Service
mux *http.ServeMux
}
func NewFilesAdminHTTPHandler(files *FileService, identityService *identity.Service) *FilesAdminHTTPHandler {
h := &FilesAdminHTTPHandler{files: files, identity: identityService, mux: http.NewServeMux()}
h.mux.HandleFunc("POST /api/v1/admin/files", h.upload)
h.mux.HandleFunc("GET /api/v1/admin/files", h.list)
h.mux.HandleFunc("GET /api/v1/admin/files/{id}/download", h.download)
h.mux.HandleFunc("DELETE /api/v1/admin/files/{id}", h.delete)
return h
}
func (h *FilesAdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) }
func (h *FilesAdminHTTPHandler) require(w http.ResponseWriter, r *http.Request, permission string) (identity.Account, bool) {
account, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization"))
if err != nil {
apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效")
return identity.Account{}, false
}
if !identity.HasPermission(account, permission) {
apiresponse.Error(w, http.StatusForbidden, "缺少文件管理权限")
return identity.Account{}, false
}
return account, true
}
// firstFilePart pulls the multipart "file" part out of a streaming request so
// uploads never need to buffer the whole body in memory.
func firstFilePart(r *http.Request) (*multipart.Part, error) {
reader, err := r.MultipartReader()
if err != nil {
return nil, err
}
for {
part, err := reader.NextPart()
if err == io.EOF {
return nil, errors.New("no file part")
}
if err != nil {
return nil, err
}
if part.FormName() == "file" {
return part, nil
}
}
}
// upload accepts either multipart/form-data (browser) or a raw body with a
// ?filename= query parameter (scripting). The size ceiling is enforced by the
// service's LimitReader, not by buffering.
func (h *FilesAdminHTTPHandler) upload(w http.ResponseWriter, r *http.Request) {
a, ok := h.require(w, r, identity.PermissionFileManage)
if !ok {
return
}
var (
body io.Reader
originalName = strings.TrimSpace(r.URL.Query().Get("filename"))
contentType = r.Header.Get("Content-Type")
)
if strings.HasPrefix(contentType, "multipart/form-data") {
part, err := firstFilePart(r)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, "缺少上传文件")
return
}
defer part.Close()
body = part
originalName = part.FileName()
if ct := part.Header.Get("Content-Type"); ct != "" {
contentType = ct
}
}
if originalName == "" {
apiresponse.Error(w, http.StatusBadRequest, "缺少文件名")
return
}
obj, err := h.files.Upload(r.Context(), "system", nil, a.ID, originalName, contentType, body)
if err != nil {
fileError(w, err)
return
}
apiresponse.OK(w, obj)
}
func (h *FilesAdminHTTPHandler) list(w http.ResponseWriter, r *http.Request) {
if _, ok := h.require(w, r, identity.PermissionFileRead); !ok {
return
}
items, err := h.files.ListSystem(r.Context())
if err != nil {
fileError(w, err)
return
}
apiresponse.OK(w, items)
}
func (h *FilesAdminHTTPHandler) download(w http.ResponseWriter, r *http.Request) {
if _, ok := h.require(w, r, identity.PermissionFileRead); !ok {
return
}
obj, err := h.files.GetSystem(r.Context(), r.PathValue("id"))
if err != nil {
fileError(w, err)
return
}
serveFileContent(w, r, h.files, obj)
}
func (h *FilesAdminHTTPHandler) delete(w http.ResponseWriter, r *http.Request) {
if _, ok := h.require(w, r, identity.PermissionFileManage); !ok {
return
}
if err := h.files.DeleteSystem(r.Context(), r.PathValue("id")); err != nil {
fileError(w, err)
return
}
apiresponse.OK(w, map[string]bool{"deleted": true})
}
// serveFileContent streams the object body straight to the client with a
// Content-Disposition so the original filename is preserved on download.
func serveFileContent(w http.ResponseWriter, r *http.Request, files *FileService, obj FileObject) {
reader, size, err := files.Open(r.Context(), obj)
if err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "文件内容暂不可用")
return
}
defer reader.Close()
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(obj.OriginalName))
w.Header().Set("Content-Type", obj.ContentType)
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
_, _ = io.Copy(w, reader)
}
func fileError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
apiresponse.Error(w, http.StatusNotFound, "文件不存在")
default:
apiresponse.Error(w, http.StatusBadRequest, err.Error())
}
}
@@ -0,0 +1,142 @@
package workbench
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"testing"
"aigateway.local/core/internal/platform/config"
"aigateway.local/core/internal/platform/database"
"aigateway.local/core/internal/platform/storage"
)
// TestFileObjectLifecycle runs against real PostgreSQL + MinIO. Set
// WORKBENCH_TEST_DATABASE_URL and WORKBENCH_TEST_S3_ENDPOINT to enable it; with
// the deploy compose up these are reachable from the build container via the
// deploy_default network.
func TestFileObjectLifecycle(t *testing.T) {
databaseURL := os.Getenv("WORKBENCH_TEST_DATABASE_URL")
s3Endpoint := os.Getenv("WORKBENCH_TEST_S3_ENDPOINT")
if databaseURL == "" || s3Endpoint == "" {
t.Skip("WORKBENCH_TEST_DATABASE_URL and WORKBENCH_TEST_S3_ENDPOINT are not set")
}
ctx := context.Background()
pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8, MinConns: 0})
if err != nil {
t.Fatal(err)
}
defer pool.Close()
store, err := storage.NewClient(storage.Config{
Endpoint: s3Endpoint,
AccessKeyID: os.Getenv("WORKBENCH_TEST_S3_ACCESS_KEY"),
SecretAccessKey: os.Getenv("WORKBENCH_TEST_S3_SECRET_KEY"),
Bucket: os.Getenv("WORKBENCH_TEST_S3_BUCKET"),
Region: "us-east-1",
MaxFileBytes: 8 << 20,
})
if err != nil {
t.Fatal(err)
}
if err := store.EnsureBucket(ctx); err != nil {
t.Fatal(err)
}
adminID := "22222222-2222-4222-8222-222222222222"
portalID := "33333333-3333-4333-8333-333333333333"
if _, err := pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role) VALUES($1,'m8-files-admin','test','superadmin') ON CONFLICT(id) DO NOTHING`, adminID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO gateway.portal_users(id,account,password_hash,name,active) VALUES($1,'m8-files-user','test','m8',true) ON CONFLICT(id) DO NOTHING`, portalID); err != nil {
t.Fatal(err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM gateway.file_objects`)
}
cleanup()
defer cleanup()
files := NewFileService(NewService(pool), store)
content := []byte("M8 对象存储文件管理集成测试 payload\nline two\n")
owner := portalID
sysObj, err := files.Upload(ctx, "system", nil, adminID, "报告.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", bytes.NewReader(content))
if err != nil {
t.Fatal(err)
}
personalObj, err := files.Upload(ctx, "personal", &owner, portalID, "notes.txt", "text/plain", bytes.NewReader(content))
if err != nil {
t.Fatal(err)
}
// sha256 往返一致
hasher := sha256.Sum256(content)
if personalObj.ContentSHA256 != hex.EncodeToString(hasher[:]) || personalObj.SizeBytes != int64(len(content)) {
t.Fatalf("integrity mismatch: sha=%s size=%d", personalObj.ContentSHA256, personalObj.SizeBytes)
}
// 流式读回
reader, size, err := files.Open(ctx, personalObj)
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(reader)
_ = reader.Close()
if err != nil || int64(len(got)) != size || !bytes.Equal(got, content) {
t.Fatalf("roundtrip mismatch: len=%d size=%d err=%v", len(got), size, err)
}
// 列表按范围隔离
sysList, err := files.ListSystem(ctx)
if err != nil || len(sysList) != 1 || sysList[0].ID != sysObj.ID {
t.Fatalf("system list mismatch: %#v err=%v", sysList, err)
}
myList, err := files.ListPersonal(ctx, portalID)
if err != nil || len(myList) != 1 || myList[0].ID != personalObj.ID {
t.Fatalf("personal list mismatch: %#v err=%v", myList, err)
}
otherList, err := files.ListPersonal(ctx, "44444444-4444-4444-8444-444444444444")
if err != nil || len(otherList) != 0 {
t.Fatalf("other personal list should be empty: %#v err=%v", otherList, err)
}
// 归属隔离:A 不能读 B 的文件
if _, err := files.GetPersonal(ctx, adminID, personalObj.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound for cross-owner read, got %v", err)
}
if _, err := files.GetSystem(ctx, personalObj.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound for system read of personal file, got %v", err)
}
// 超限上传被拒(8MiB 上限,用无 EOF 占位 reader 凑 9MiB)
if _, err := files.Upload(ctx, "system", nil, adminID, "big.bin", "application/octet-stream", io.LimitReader(zeroReader{}, 9<<20)); err == nil {
t.Fatal("expected oversized upload to fail")
}
// 删除后元数据与对象都消失
if err := files.DeleteSystem(ctx, sysObj.ID); err != nil {
t.Fatal(err)
}
if _, err := files.GetSystem(ctx, sysObj.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound after delete, got %v", err)
}
if err := files.DeletePersonal(ctx, portalID, personalObj.ID); err != nil {
t.Fatal(err)
}
if _, err := files.GetPersonal(ctx, portalID, personalObj.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound after personal delete, got %v", err)
}
}
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 0
}
return len(p), nil
}
+133
View File
@@ -0,0 +1,133 @@
package workbench
import (
"errors"
"io"
"mime/multipart"
"net/http"
"strings"
"aigateway.local/core/internal/identity"
"aigateway.local/core/internal/platform/apiresponse"
)
// FilesPortalHTTPHandler serves each portal user's personal file store. Unlike
// the admin handler every object is scoped to the authenticated account, so a
// user can only ever see and open their own files.
type FilesPortalHTTPHandler struct {
files *FileService
identity *identity.Service
mux *http.ServeMux
}
func NewFilesPortalHTTPHandler(files *FileService, identityService *identity.Service) *FilesPortalHTTPHandler {
h := &FilesPortalHTTPHandler{files: files, identity: identityService, mux: http.NewServeMux()}
h.mux.HandleFunc("POST /api/v1/portal/files", h.upload)
h.mux.HandleFunc("GET /api/v1/portal/files", h.list)
h.mux.HandleFunc("GET /api/v1/portal/files/{id}/download", h.download)
h.mux.HandleFunc("DELETE /api/v1/portal/files/{id}", h.delete)
return h
}
func (h *FilesPortalHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) }
func (h *FilesPortalHTTPHandler) account(w http.ResponseWriter, r *http.Request) (identity.Account, bool) {
account, err := h.identity.Authenticate(r.Context(), identity.KindPortal, r.Header.Get("Authorization"))
if err != nil {
apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效或已过期")
return identity.Account{}, false
}
return account, true
}
func (h *FilesPortalHTTPHandler) upload(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
var (
body io.Reader
originalName = strings.TrimSpace(r.URL.Query().Get("filename"))
contentType = r.Header.Get("Content-Type")
)
if strings.HasPrefix(contentType, "multipart/form-data") {
part, err := h.firstFilePart(r)
if err != nil {
apiresponse.Error(w, http.StatusBadRequest, "缺少上传文件")
return
}
defer part.Close()
body = part
originalName = part.FileName()
if ct := part.Header.Get("Content-Type"); ct != "" {
contentType = ct
}
}
if originalName == "" {
apiresponse.Error(w, http.StatusBadRequest, "缺少文件名")
return
}
obj, err := h.files.Upload(r.Context(), "personal", &a.ID, a.ID, originalName, contentType, body)
if err != nil {
fileError(w, err)
return
}
apiresponse.OK(w, obj)
}
func (h *FilesPortalHTTPHandler) firstFilePart(r *http.Request) (*multipart.Part, error) {
reader, err := r.MultipartReader()
if err != nil {
return nil, err
}
for {
part, err := reader.NextPart()
if err == io.EOF {
return nil, errors.New("no file part")
}
if err != nil {
return nil, err
}
if part.FormName() == "file" {
return part, nil
}
}
}
func (h *FilesPortalHTTPHandler) list(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
items, err := h.files.ListPersonal(r.Context(), a.ID)
if err != nil {
fileError(w, err)
return
}
apiresponse.OK(w, items)
}
func (h *FilesPortalHTTPHandler) download(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
obj, err := h.files.GetPersonal(r.Context(), a.ID, r.PathValue("id"))
if err != nil {
fileError(w, err)
return
}
serveFileContent(w, r, h.files, obj)
}
func (h *FilesPortalHTTPHandler) delete(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
if err := h.files.DeletePersonal(r.Context(), a.ID, r.PathValue("id")); err != nil {
fileError(w, err)
return
}
apiresponse.OK(w, map[string]bool{"deleted": true})
}