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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user