diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ed5469ca3..278c96f2d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -84,6 +84,7 @@ func main() { handler.RegisterResponseInterceptionRoutes(api, svc) handler.RegisterAdminRoutes(api, svc) handler.RegisterAdminAnalyticsRoutes(api, svc) + handler.RegisterAdminStorageRoutes(api, svc) handler.RegisterAnnouncementRoutes(api, svc) handler.RegisterFinanceRoutes(api, svc) handler.RegisterLibTVRoutes(api, svc) diff --git a/backend/internal/handler/admin_storage.go b/backend/internal/handler/admin_storage.go new file mode 100644 index 000000000..79f3cfdf3 --- /dev/null +++ b/backend/internal/handler/admin_storage.go @@ -0,0 +1,72 @@ +package handler + +import ( + "strconv" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func RegisterAdminStorageRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/admin/storage/stats", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + stats, err := svc.AdminStorageStats(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"stats": stats}) + }) + + r.GET("/admin/resources", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + result, err := svc.AdminResourcePage(user, service.AdminResourceQuery{ + Keyword: c.Query("keyword"), Kind: c.Query("kind"), Status: c.Query("status"), + Provider: c.Query("provider"), UserID: c.Query("userId"), Page: page, Limit: limit, + }) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + + r.GET("/admin/resources/:id/file", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + stream, err := svc.OpenResourceRangeAsAdmin(user, c.Param("id"), c.GetHeader("Range")) + if err != nil { + failService(c, err) + return + } + defer stream.Body.Close() + mimeType := stream.Resource.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + c.Header("Cache-Control", "private, no-cache") + c.Header("Accept-Ranges", stream.AcceptRanges) + c.Header("X-Content-Type-Options", "nosniff") + if stream.ContentRange != "" { + c.Header("Content-Range", stream.ContentRange) + } + if c.Query("download") == "1" { + c.Header("Content-Disposition", "attachment") + } + c.DataFromReader(stream.StatusCode, stream.ContentLength, mimeType, stream.Body, nil) + }) +} diff --git a/backend/internal/repository/admin_storage.go b/backend/internal/repository/admin_storage.go new file mode 100644 index 000000000..d2f9d094d --- /dev/null +++ b/backend/internal/repository/admin_storage.go @@ -0,0 +1,190 @@ +package repository + +import ( + "strings" + + "infinite-canvas/backend/internal/model" +) + +type AdminResourceFilter struct { + Kind string + Status string + Provider string + UserID string + Keyword string + Limit int + Offset int +} + +type ResourceKindStat struct { + Kind string `json:"kind" gorm:"column:kind"` + Count int64 `json:"count" gorm:"column:count"` + LogicalBytes int64 `json:"logicalBytes" gorm:"column:logical_bytes"` + PhysicalBytes int64 `json:"physicalBytes" gorm:"column:physical_bytes"` +} + +type ResourceProviderStat struct { + Provider string `json:"provider" gorm:"column:provider"` + Count int64 `json:"count" gorm:"column:count"` + LogicalBytes int64 `json:"logicalBytes" gorm:"column:logical_bytes"` + PhysicalBytes int64 `json:"physicalBytes" gorm:"column:physical_bytes"` +} + +type ResourceStorageSummary struct { + ResourceCount int64 `json:"resourceCount" gorm:"column:resource_count"` + ReadyCount int64 `json:"readyCount" gorm:"column:ready_count"` + LogicalBytes int64 `json:"logicalBytes" gorm:"column:logical_bytes"` + PhysicalBytes int64 `json:"physicalBytes" gorm:"column:physical_bytes"` + LocalBytes int64 `json:"localBytes" gorm:"column:local_bytes"` + RemoteBytes int64 `json:"remoteBytes" gorm:"column:remote_bytes"` +} + +func (r *Repository) AdminResources(filter AdminResourceFilter) ([]model.Resource, int64, error) { + var resources []model.Resource + var total int64 + query := r.db.Model(&model.Resource{}) + if filter.Kind != "" { + query = query.Where("kind = ?", filter.Kind) + } + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + if filter.Provider != "" { + query = query.Where(resourceProviderExpression()+" = ?", filter.Provider) + } + if filter.UserID != "" { + query = query.Where("user_id = ?", filter.UserID) + } + if filter.Keyword != "" { + pattern := "%" + strings.ToLower(filter.Keyword) + "%" + query = query.Where("lower(id) LIKE ? OR lower(object_key) LIKE ?", pattern, pattern) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Order("created_at desc, id desc").Limit(filter.Limit).Offset(filter.Offset).Find(&resources).Error; err != nil { + return nil, 0, err + } + return resources, total, nil +} + +func (r *Repository) UsersByIDs(ids []string) (map[string]model.User, error) { + if len(ids) == 0 { + return map[string]model.User{}, nil + } + var users []model.User + if err := r.db.Select("id", "username", "display_name").Where("id IN ?", ids).Find(&users).Error; err != nil { + return nil, err + } + result := make(map[string]model.User, len(users)) + for _, user := range users { + result[user.ID] = user + } + return result, nil +} + +func (r *Repository) ResourceStorageSummary() (ResourceStorageSummary, error) { + var summary ResourceStorageSummary + err := r.db.Model(&model.Resource{}).Select(` + COUNT(*) AS resource_count, + COALESCE(SUM(CASE WHEN status = 'ready' THEN 1 ELSE 0 END), 0) AS ready_count, + COALESCE(SUM(size), 0) AS logical_bytes + `).Scan(&summary).Error + if err != nil { + return summary, err + } + physicalBytes, err := r.resourcePhysicalBytes("") + if err != nil { + return summary, err + } + localBytes, err := r.resourcePhysicalBytes(resourceProviderExpression()+" = ?", "local") + if err != nil { + return summary, err + } + summary.PhysicalBytes = physicalBytes + summary.LocalBytes = localBytes + summary.RemoteBytes = physicalBytes - localBytes + return summary, nil +} + +func (r *Repository) resourcePhysicalBytes(where string, args ...any) (int64, error) { + query := r.db.Model(&model.Resource{}). + Select("MAX(size) AS size"). + Where("status = ?", model.ResourceStatusReady) + if where != "" { + query = query.Where(where, args...) + } + query = query.Group(resourceProviderExpression() + ", endpoint, bucket, object_key") + var total struct { + Bytes int64 `gorm:"column:bytes"` + } + err := r.db.Table("(?) AS physical_resources", query). + Select("COALESCE(SUM(size), 0) AS bytes"). + Scan(&total).Error + return total.Bytes, err +} + +func (r *Repository) ResourceKindStats() ([]ResourceKindStat, error) { + var stats []ResourceKindStat + err := r.db.Model(&model.Resource{}). + Select("kind, COUNT(*) AS count, COALESCE(SUM(size), 0) AS logical_bytes"). + Group("kind"). + Order("logical_bytes desc, kind asc"). + Scan(&stats).Error + if err != nil { + return nil, err + } + physical, err := r.resourcePhysicalStats("kind") + if err != nil { + return nil, err + } + for index := range stats { + stats[index].PhysicalBytes = physical[stats[index].Kind] + } + return stats, err +} + +func (r *Repository) ResourceProviderStats() ([]ResourceProviderStat, error) { + var stats []ResourceProviderStat + provider := resourceProviderExpression() + err := r.db.Model(&model.Resource{}). + Select(provider + " AS provider, COUNT(*) AS count, COALESCE(SUM(size), 0) AS logical_bytes"). + Group(provider). + Order("logical_bytes desc, provider asc"). + Scan(&stats).Error + if err != nil { + return nil, err + } + physical, err := r.resourcePhysicalStats(provider) + if err != nil { + return nil, err + } + for index := range stats { + stats[index].PhysicalBytes = physical[stats[index].Provider] + } + return stats, err +} + +func (r *Repository) resourcePhysicalStats(dimensionExpression string) (map[string]int64, error) { + query := r.db.Model(&model.Resource{}). + Select(dimensionExpression+" AS dimension, MAX(size) AS size"). + Where("status = ?", model.ResourceStatusReady). + Group(dimensionExpression + ", " + resourceProviderExpression() + ", endpoint, bucket, object_key") + var rows []struct { + Dimension string `gorm:"column:dimension"` + Bytes int64 `gorm:"column:bytes"` + } + err := r.db.Table("(?) AS physical_resources", query). + Select("dimension, COALESCE(SUM(size), 0) AS bytes"). + Group("dimension"). + Scan(&rows).Error + result := make(map[string]int64, len(rows)) + for _, row := range rows { + result[row.Dimension] = row.Bytes + } + return result, err +} + +func resourceProviderExpression() string { + return "COALESCE(NULLIF(provider, ''), 'local')" +} diff --git a/backend/internal/service/admin_storage.go b/backend/internal/service/admin_storage.go new file mode 100644 index 000000000..49a743ed2 --- /dev/null +++ b/backend/internal/service/admin_storage.go @@ -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 +} diff --git a/backend/internal/service/admin_storage_test.go b/backend/internal/service/admin_storage_test.go new file mode 100644 index 000000000..6495e2630 --- /dev/null +++ b/backend/internal/service/admin_storage_test.go @@ -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") + } +} diff --git a/web/src/pages/admin/admin-route-pages.tsx b/web/src/pages/admin/admin-route-pages.tsx index b787c3c9c..9b8c6cfa1 100644 --- a/web/src/pages/admin/admin-route-pages.tsx +++ b/web/src/pages/admin/admin-route-pages.tsx @@ -9,6 +9,7 @@ const CreditOperationsPanel = lazy(() => import("./components/credit-operations- const AccessSettingsPanel = lazy(() => import("./components/access-settings-panel")); const EmailSettingsPanel = lazy(() => import("./components/email-settings-panel")); const FeatureAvailabilityPanel = lazy(() => import("./components/feature-availability-panel")); +const StorageResourcesPanel = lazy(() => import("./components/storage-resources-panel")); function PageFallback({ label }: { label: string }) { return