Files
superidou 6708c226a5 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>
2026-08-12 14:08:38 +08:00

143 lines
4.9 KiB
Go

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
}