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:
ddCat
2026-08-29 15:29:04 +08:00
parent 0f274bb71f
commit b0d77dba09
10 changed files with 820 additions and 0 deletions

View File

@@ -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)

View File

@@ -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)
})
}

View File

@@ -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')"
}

View File

@@ -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
}

View File

@@ -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")
}
}