feat(admin): 存储管理 - 新增资源只读查询、统计与预览
Adapted from ddcat-ai/open-ai-canvas#339 Original commit: fcdbe2aa833f31f683f709b7f409a0534ed78e20 Refs #339 Co-authored-by: 宇熙 <101980758+yuxiwl@users.noreply.github.com>
This commit is contained in:
1 parent
0f274bb71f
commit
b0d77dba09
10 files changed
+820
No files matched your search
@@ -0,0 +1,188 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AdminResourceQuery struct {
|
||||
Keyword string
|
||||
Kind string
|
||||
Status string
|
||||
Provider string
|
||||
UserID string
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
type AdminStorageResourceView struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
Kind string `json:"kind"`
|
||||
Status model.ResourceStatus `json:"status"`
|
||||
Provider string `json:"provider"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
ObjectKey string `json:"objectKey"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Size int64 `json:"size"`
|
||||
PhysicalBytes int64 `json:"physicalBytes"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
FileURL string `json:"fileUrl"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AdminResourcePage struct {
|
||||
Items []AdminStorageResourceView `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
type AdminStorageStats struct {
|
||||
repository.ResourceStorageSummary
|
||||
ByKind []repository.ResourceKindStat `json:"byKind"`
|
||||
ByProvider []repository.ResourceProviderStat `json:"byProvider"`
|
||||
}
|
||||
|
||||
func (s *Service) AdminResourcePage(actor *model.User, query AdminResourceQuery) (*AdminResourcePage, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter, page, limit, err := normalizeAdminResourceQuery(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources, total, err := s.repo.AdminResources(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs := make([]string, 0, len(resources))
|
||||
seen := make(map[string]struct{}, len(resources))
|
||||
for _, resource := range resources {
|
||||
if _, exists := seen[resource.UserID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[resource.UserID] = struct{}{}
|
||||
userIDs = append(userIDs, resource.UserID)
|
||||
}
|
||||
users, err := s.repo.UsersByIDs(userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]AdminStorageResourceView, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
provider := normalizedResourceProvider(resource.Provider)
|
||||
items = append(items, AdminStorageResourceView{
|
||||
ID: resource.ID, UserID: resource.UserID, UserName: adminResourceUserName(users[resource.UserID]),
|
||||
Kind: resource.Kind, Status: resource.Status, Provider: provider, Bucket: resource.Bucket,
|
||||
ObjectKey: resource.ObjectKey, MimeType: resource.MimeType, Size: resource.Size,
|
||||
PhysicalBytes: readyResourceBytes(resource), Width: resource.Width, Height: resource.Height, DurationMs: resource.DurationMs,
|
||||
FileURL: "/api/admin/resources/" + resource.ID + "/file", CreatedAt: resource.CreatedAt, UpdatedAt: resource.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return &AdminResourcePage{Items: items, Total: total, Page: page, Limit: limit}, nil
|
||||
}
|
||||
|
||||
func (s *Service) AdminStorageStats(actor *model.User) (*AdminStorageStats, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary, err := s.repo.ResourceStorageSummary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byKind, err := s.repo.ResourceKindStats()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byProvider, err := s.repo.ResourceProviderStats()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminStorageStats{ResourceStorageSummary: summary, ByKind: byKind, ByProvider: byProvider}, nil
|
||||
}
|
||||
|
||||
func (s *Service) OpenResourceRangeAsAdmin(actor *model.User, id string, rangeHeader string) (*ResourceStream, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resource, err := s.repo.Resource(strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, NotFound("资源不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
resource.Provider = normalizedResourceProvider(resource.Provider)
|
||||
return s.openResourceRange(resource.UserID, resource, rangeHeader)
|
||||
}
|
||||
|
||||
func normalizeAdminResourceQuery(query AdminResourceQuery) (repository.AdminResourceFilter, int, int, error) {
|
||||
page, limit := normalizeAdminPage(query.Page, query.Limit)
|
||||
filter := repository.AdminResourceFilter{
|
||||
Keyword: strings.TrimSpace(query.Keyword),
|
||||
Kind: strings.ToLower(strings.TrimSpace(query.Kind)),
|
||||
Status: strings.ToLower(strings.TrimSpace(query.Status)),
|
||||
Provider: strings.ToLower(strings.TrimSpace(query.Provider)),
|
||||
UserID: strings.TrimSpace(query.UserID),
|
||||
Limit: limit,
|
||||
Offset: (page - 1) * limit,
|
||||
}
|
||||
if filter.Kind != "" && !oneOf(filter.Kind, "image", "video", "audio", "file") {
|
||||
return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源类型筛选无效")
|
||||
}
|
||||
if filter.Status != "" && !oneOf(filter.Status, string(model.ResourceStatusPending), string(model.ResourceStatusReady), string(model.ResourceStatusFailed), string(model.ResourceStatusDeleted)) {
|
||||
return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源状态筛选无效")
|
||||
}
|
||||
if filter.Provider != "" && !oneOf(filter.Provider, "local", aliyunOSSProvider, tencentCOSProvider, qiniuKodoProvider, s3Provider) {
|
||||
return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源存储位置筛选无效")
|
||||
}
|
||||
if len(filter.Keyword) > 200 || len(filter.UserID) > 64 {
|
||||
return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源筛选条件过长")
|
||||
}
|
||||
return filter, page, limit, nil
|
||||
}
|
||||
|
||||
func normalizedResourceProvider(provider string) string {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
if provider == "" {
|
||||
return "local"
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
func readyResourceBytes(resource model.Resource) int64 {
|
||||
if resource.Status != model.ResourceStatusReady {
|
||||
return 0
|
||||
}
|
||||
return resource.Size
|
||||
}
|
||||
|
||||
func adminResourceUserName(user model.User) string {
|
||||
if value := strings.TrimSpace(user.DisplayName); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimSpace(user.Username); value != "" {
|
||||
return value
|
||||
}
|
||||
return user.ID
|
||||
}
|
||||
|
||||
func oneOf(value string, allowed ...string) bool {
|
||||
for _, item := range allowed {
|
||||
if value == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAdminStorageListStatsAndPreview(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Resource{}, &model.UserOSSSetting{}, &model.StorageLocation{}, &model.SystemSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users := []model.User{
|
||||
{ID: "admin", Username: "admin", DisplayName: "管理员", Role: model.UserRoleAdmin, Status: model.UserStatusActive},
|
||||
{ID: "user-1", Username: "creator", DisplayName: "创作者", Role: model.UserRoleUser, Status: model.UserStatusActive},
|
||||
}
|
||||
if err := db.Create(&users).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
resources := []model.Resource{
|
||||
{ID: "image-ready", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, Provider: "", ObjectKey: "users/user-1/image/ready.png", MimeType: "image/png", Size: 128, CreatedAt: now.Add(-time.Minute), UpdatedAt: now},
|
||||
{ID: "image-failed", UserID: "user-1", Kind: "image", Status: model.ResourceStatusFailed, Provider: "local", ObjectKey: "users/user-1/image/failed.png", MimeType: "image/png", Size: 64, CreatedAt: now.Add(-2 * time.Minute), UpdatedAt: now},
|
||||
{ID: "video-ready", UserID: "admin", Kind: "video", Status: model.ResourceStatusReady, Provider: "s3", Bucket: "media", ObjectKey: "video/ready.mp4", MimeType: "video/mp4", Size: 256, CreatedAt: now.Add(-3 * time.Minute), UpdatedAt: now},
|
||||
{ID: "image-shared", UserID: "admin", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "users/user-1/image/ready.png", MimeType: "image/png", Size: 128, CreatedAt: now.Add(-4 * time.Minute), UpdatedAt: now},
|
||||
}
|
||||
if err := db.Create(&resources).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dataDir := t.TempDir()
|
||||
filePath := filepath.Join(dataDir, "resources", filepath.FromSlash(resources[0].ObjectKey))
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filePath, []byte("image-data"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(repository.New(db), dataDir)
|
||||
admin := &users[0]
|
||||
|
||||
page, err := svc.AdminResourcePage(admin, AdminResourceQuery{Kind: "image", Provider: "local", Page: 1, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 3 || len(page.Items) != 3 || page.Items[0].UserName != "创作者" {
|
||||
t.Fatalf("page = %+v", page)
|
||||
}
|
||||
if page.Items[0].PhysicalBytes != 128 || page.Items[1].PhysicalBytes != 0 {
|
||||
t.Fatalf("physical bytes = %+v", page.Items)
|
||||
}
|
||||
|
||||
stats, err := svc.AdminStorageStats(admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.ResourceCount != 4 || stats.ReadyCount != 3 || stats.LogicalBytes != 576 || stats.PhysicalBytes != 384 || stats.LocalBytes != 128 || stats.RemoteBytes != 256 {
|
||||
t.Fatalf("stats = %+v", stats)
|
||||
}
|
||||
if len(stats.ByKind) != 2 || stats.ByKind[0].Kind != "image" || stats.ByKind[0].PhysicalBytes != 128 {
|
||||
t.Fatalf("kind stats = %+v", stats.ByKind)
|
||||
}
|
||||
|
||||
stream, err := svc.OpenResourceRangeAsAdmin(admin, resources[0].ID, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stream.Body.Close()
|
||||
data, err := io.ReadAll(stream.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "image-data" {
|
||||
t.Fatalf("preview data = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageRejectsNonAdminAndInvalidFilters(t *testing.T) {
|
||||
svc := &Service{}
|
||||
user := &model.User{ID: "user", Role: model.UserRoleUser, Status: model.UserStatusActive}
|
||||
if _, err := svc.AdminResourcePage(user, AdminResourceQuery{}); err == nil {
|
||||
t.Fatal("expected non-admin resource list to be rejected")
|
||||
}
|
||||
if _, _, _, err := normalizeAdminResourceQuery(AdminResourceQuery{Provider: "unknown"}); err == nil {
|
||||
t.Fatal("expected invalid provider filter to be rejected")
|
||||
}
|
||||
if _, _, _, err := normalizeAdminResourceQuery(AdminResourceQuery{Status: "unknown"}); err == nil {
|
||||
t.Fatal("expected invalid status filter to be rejected")
|
||||
}
|
||||
if _, err := svc.AdminStorageStats(user); err == nil {
|
||||
t.Fatal("expected non-admin stats to be rejected")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user