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
464bf6e352
commit
7e20041835
7 files changed
+649
-8
No files matched your search
@@ -9,6 +9,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func RegisterAdminStorageRoutes(r *gin.RouterGroup, svc *service.Service) {
|
func RegisterAdminStorageRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||||
|
r.POST("/admin/resources/delete", func(c *gin.Context) {
|
||||||
|
user, err := currentUser(c, svc)
|
||||||
|
if err != nil {
|
||||||
|
failService(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req service.AdminResourceDeleteRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
failService(c, service.BadAuthRequest("删除资源请求无效"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := svc.DeleteAdminResources(user, req)
|
||||||
|
if err != nil {
|
||||||
|
failService(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, result)
|
||||||
|
})
|
||||||
|
|
||||||
r.GET("/admin/storage/stats", func(c *gin.Context) {
|
r.GET("/admin/storage/stats", func(c *gin.Context) {
|
||||||
user, err := currentUser(c, svc)
|
user, err := currentUser(c, svc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"infinite-canvas/backend/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrAdminResourceDeleteChanged = errors.New("admin resource delete set changed")
|
||||||
|
var ErrAdminResourceStillReferenced = errors.New("admin resource is still directly referenced")
|
||||||
|
var ErrAdminResourceAuditMismatch = errors.New("admin resource delete audit mismatch")
|
||||||
|
|
||||||
|
func (r *Repository) AdminResourcesByIDs(ids []string) ([]model.Resource, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []model.Resource{}, nil
|
||||||
|
}
|
||||||
|
var resources []model.Resource
|
||||||
|
err := r.db.Where("id IN ?", ids).Find(&resources).Error
|
||||||
|
return resources, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) AnnouncementResourceReferences(resourceIDs []string) ([]ResourceDirectReference, error) {
|
||||||
|
if len(resourceIDs) == 0 {
|
||||||
|
return []ResourceDirectReference{}, nil
|
||||||
|
}
|
||||||
|
var announcements []model.Announcement
|
||||||
|
if err := r.db.Where("image_resource_id IN ?", resourceIDs).Find(&announcements).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]ResourceDirectReference, 0, len(announcements))
|
||||||
|
for _, announcement := range announcements {
|
||||||
|
result = append(result, ResourceDirectReference{Kind: "公告", ID: announcement.ID, Title: announcement.Title, ResourceID: announcement.ImageResourceID})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) DeleteAdminResources(resources []model.Resource, deletionJobs []model.ResourceDeletionJob, audits []model.AdminAuditEvent) error {
|
||||||
|
if len(resources) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(audits) != len(resources) {
|
||||||
|
return ErrAdminResourceAuditMismatch
|
||||||
|
}
|
||||||
|
resourceIDs := make([]string, 0, len(resources))
|
||||||
|
for _, resource := range resources {
|
||||||
|
resourceIDs = append(resourceIDs, resource.ID)
|
||||||
|
}
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var current []model.Resource
|
||||||
|
query := tx.Where("id IN ?", resourceIDs)
|
||||||
|
if r.Dialect() == "postgres" {
|
||||||
|
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||||
|
}
|
||||||
|
if err := query.Find(¤t).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(current) != len(resources) {
|
||||||
|
return ErrAdminResourceDeleteChanged
|
||||||
|
}
|
||||||
|
for _, check := range []struct {
|
||||||
|
model any
|
||||||
|
query string
|
||||||
|
}{
|
||||||
|
{&model.Announcement{}, "image_resource_id IN ?"},
|
||||||
|
{&model.AssetRepresentation{}, "resource_id IN ?"},
|
||||||
|
{&model.VoiceProfile{}, "sample_resource_id IN ?"},
|
||||||
|
{&model.ShotArtifact{}, "resource_id IN ?"},
|
||||||
|
} {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(check.model).Where(check.query, resourceIDs).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return ErrAdminResourceStillReferenced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Where("resource_id IN ?", resourceIDs).Delete(&model.ArkPrivateAssetBinding{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("resource_id IN ?", resourceIDs).Delete(&model.AnnouncementImageDraft{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(deletionJobs) > 0 {
|
||||||
|
if err := tx.Create(&deletionJobs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleted := tx.Where("id IN ?", resourceIDs).Delete(&model.Resource{})
|
||||||
|
if deleted.Error != nil {
|
||||||
|
return deleted.Error
|
||||||
|
}
|
||||||
|
if deleted.RowsAffected != int64(len(resourceIDs)) {
|
||||||
|
return ErrAdminResourceDeleteChanged
|
||||||
|
}
|
||||||
|
if len(audits) > 0 {
|
||||||
|
if err := tx.Create(&audits).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"infinite-canvas/backend/internal/model"
|
||||||
|
"infinite-canvas/backend/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxAdminResourceDeleteCount = 100
|
||||||
|
|
||||||
|
type AdminResourceDeleteRequest struct {
|
||||||
|
ResourceIDs []string `json:"resourceIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminResourceReferenceView struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminResourceDeleteBlocked struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
References []AdminResourceReferenceView `json:"references"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminResourceDeleteResult struct {
|
||||||
|
Deleted []string `json:"deleted"`
|
||||||
|
Blocked []AdminResourceDeleteBlocked `json:"blocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteAdminResources(actor *model.User, req AdminResourceDeleteRequest) (*AdminResourceDeleteResult, error) {
|
||||||
|
if err := s.RequireAdmin(actor); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resourceIDs, err := normalizeAdminResourceDeleteIDs(req.ResourceIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.storageMu.Lock()
|
||||||
|
defer s.storageMu.Unlock()
|
||||||
|
|
||||||
|
resources, err := s.repo.AdminResourcesByIDs(resourceIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resourcesByID := make(map[string]model.Resource, len(resources))
|
||||||
|
resourcesByUser := make(map[string][]model.Resource)
|
||||||
|
for _, resource := range resources {
|
||||||
|
resourcesByID[resource.ID] = resource
|
||||||
|
resourcesByUser[resource.UserID] = append(resourcesByUser[resource.UserID], resource)
|
||||||
|
}
|
||||||
|
blockedByID := make(map[string]AdminResourceDeleteBlocked)
|
||||||
|
for _, resourceID := range resourceIDs {
|
||||||
|
if _, exists := resourcesByID[resourceID]; !exists {
|
||||||
|
blockedByID[resourceID] = AdminResourceDeleteBlocked{ID: resourceID, Reason: "资源不存在", References: []AdminResourceReferenceView{}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for userID, userResources := range resourcesByUser {
|
||||||
|
userResourceIDs := make([]string, 0, len(userResources))
|
||||||
|
for _, resource := range userResources {
|
||||||
|
userResourceIDs = append(userResourceIDs, resource.ID)
|
||||||
|
}
|
||||||
|
snapshot, snapshotErr := s.repo.ResourceReferenceSnapshot(userID, "", userResourceIDs)
|
||||||
|
if snapshotErr != nil {
|
||||||
|
return nil, snapshotErr
|
||||||
|
}
|
||||||
|
for resourceID, references := range adminResourceReferences(snapshot, userResources) {
|
||||||
|
blockedByID[resourceID] = AdminResourceDeleteBlocked{ID: resourceID, Reason: "资源仍被业务数据引用", References: references}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
announcementReferences, err := s.repo.AnnouncementResourceReferences(resourceIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, reference := range announcementReferences {
|
||||||
|
blocked := blockedByID[reference.ResourceID]
|
||||||
|
blocked.ID = reference.ResourceID
|
||||||
|
blocked.Reason = "资源仍被业务数据引用"
|
||||||
|
blocked.References = appendUniqueAdminResourceReference(blocked.References, AdminResourceReferenceView{Kind: reference.Kind, ID: reference.ID, Title: reference.Title})
|
||||||
|
blockedByID[reference.ResourceID] = blocked
|
||||||
|
}
|
||||||
|
|
||||||
|
deletable := make([]model.Resource, 0, len(resources))
|
||||||
|
deletableIDs := make([]string, 0, len(resources))
|
||||||
|
for _, resourceID := range resourceIDs {
|
||||||
|
resource, exists := resourcesByID[resourceID]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, blocked := blockedByID[resourceID]; blocked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !supportedResourceDeleteProvider(resource.Provider) {
|
||||||
|
blockedByID[resourceID] = AdminResourceDeleteBlocked{ID: resourceID, Reason: "资源使用了不支持的存储类型,无法安全删除", References: []AdminResourceReferenceView{}}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deletable = append(deletable, resource)
|
||||||
|
deletableIDs = append(deletableIDs, resource.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
physicalObjects := map[string]*model.Resource{}
|
||||||
|
checkedPhysical := map[string]bool{}
|
||||||
|
for index := range deletable {
|
||||||
|
resource := &deletable[index]
|
||||||
|
if strings.TrimSpace(resource.ObjectKey) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
identity := resourceStorageIdentity(resource)
|
||||||
|
if checkedPhysical[identity] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
checkedPhysical[identity] = true
|
||||||
|
sharedCount, countErr := s.repo.ResourceStorageReferenceCount(resource, deletableIDs)
|
||||||
|
if countErr != nil {
|
||||||
|
return nil, countErr
|
||||||
|
}
|
||||||
|
if sharedCount == 0 {
|
||||||
|
physicalObjects[identity] = resource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deletionJobs := resourceDeletionJobsForResources(physicalObjects)
|
||||||
|
queuedResourceIDs := make(map[string]bool, len(deletionJobs))
|
||||||
|
for _, job := range deletionJobs {
|
||||||
|
queuedResourceIDs[job.ResourceID] = true
|
||||||
|
}
|
||||||
|
audits := make([]model.AdminAuditEvent, 0, len(deletable))
|
||||||
|
for _, resource := range deletable {
|
||||||
|
event, eventErr := newAdminAuditEvent(actor, "resource.delete", "resource", resource.ID, "管理员删除存储资源", map[string]any{
|
||||||
|
"userId": resource.UserID, "kind": resource.Kind, "provider": normalizedResourceProvider(resource.Provider),
|
||||||
|
"objectKey": resource.ObjectKey, "physicalDeleteQueued": queuedResourceIDs[resource.ID],
|
||||||
|
})
|
||||||
|
if eventErr != nil {
|
||||||
|
return nil, eventErr
|
||||||
|
}
|
||||||
|
audits = append(audits, *event)
|
||||||
|
}
|
||||||
|
if err := s.repo.DeleteAdminResources(deletable, deletionJobs, audits); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrAdminResourceDeleteChanged) || errors.Is(err, repository.ErrAdminResourceStillReferenced) {
|
||||||
|
return nil, BadAuthRequest("资源状态或引用已变化,请刷新后重试")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(deletionJobs) > 0 {
|
||||||
|
go s.drainResourceDeletionJobs(len(deletionJobs))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &AdminResourceDeleteResult{Deleted: []string{}, Blocked: []AdminResourceDeleteBlocked{}}
|
||||||
|
deletedSet := make(map[string]struct{}, len(deletable))
|
||||||
|
for _, resource := range deletable {
|
||||||
|
deletedSet[resource.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, resourceID := range resourceIDs {
|
||||||
|
if _, deleted := deletedSet[resourceID]; deleted {
|
||||||
|
result.Deleted = append(result.Deleted, resourceID)
|
||||||
|
} else if blocked, exists := blockedByID[resourceID]; exists {
|
||||||
|
result.Blocked = append(result.Blocked, blocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAdminResourceDeleteIDs(values []string) ([]string, error) {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, BadAuthRequest("请选择要删除的资源")
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || len(value) > 80 {
|
||||||
|
return nil, BadAuthRequest("资源 ID 无效")
|
||||||
|
}
|
||||||
|
if _, exists := seen[value]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
if len(result) == 0 || len(result) > maxAdminResourceDeleteCount {
|
||||||
|
return nil, BadAuthRequest("单次最多删除 100 个资源")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminResourceReferences(snapshot repository.ResourceReferenceSnapshot, resources []model.Resource) map[string][]AdminResourceReferenceView {
|
||||||
|
result := make(map[string][]AdminResourceReferenceView)
|
||||||
|
seen := make(map[string]map[string]struct{})
|
||||||
|
for _, reference := range snapshot.Direct {
|
||||||
|
appendAdminResourceReference(result, seen, reference.ResourceID, AdminResourceReferenceView{Kind: reference.Kind, ID: reference.ID, Title: reference.Title})
|
||||||
|
}
|
||||||
|
for _, resource := range resources {
|
||||||
|
candidate := map[string]struct{}{resource.ID: {}}
|
||||||
|
for _, document := range snapshot.Documents {
|
||||||
|
if documentReferencesResources(document.PrimaryJSON, candidate) || documentReferencesResources(document.SecondaryJSON, candidate) {
|
||||||
|
appendAdminResourceReference(result, seen, resource.ID, AdminResourceReferenceView{Kind: document.Kind, ID: document.ID, Title: document.Title})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for resourceID := range result {
|
||||||
|
sort.Slice(result[resourceID], func(i, j int) bool {
|
||||||
|
left, right := result[resourceID][i], result[resourceID][j]
|
||||||
|
if left.Kind != right.Kind {
|
||||||
|
return left.Kind < right.Kind
|
||||||
|
}
|
||||||
|
return left.ID < right.ID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendAdminResourceReference(result map[string][]AdminResourceReferenceView, seen map[string]map[string]struct{}, resourceID string, reference AdminResourceReferenceView) {
|
||||||
|
if resourceID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if seen[resourceID] == nil {
|
||||||
|
seen[resourceID] = map[string]struct{}{}
|
||||||
|
}
|
||||||
|
key := reference.Kind + "\x00" + reference.ID
|
||||||
|
if _, exists := seen[resourceID][key]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[resourceID][key] = struct{}{}
|
||||||
|
result[resourceID] = append(result[resourceID], reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniqueAdminResourceReference(references []AdminResourceReferenceView, reference AdminResourceReferenceView) []AdminResourceReferenceView {
|
||||||
|
for _, existing := range references {
|
||||||
|
if existing.Kind == reference.Kind && existing.ID == reference.ID {
|
||||||
|
return references
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(references, reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportedResourceDeleteProvider(provider string) bool {
|
||||||
|
provider = normalizedResourceProvider(provider)
|
||||||
|
return oneOf(provider, "local", aliyunOSSProvider, tencentCOSProvider, qiniuKodoProvider, s3Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceDeletionJobsForResources(physicalObjects map[string]*model.Resource) []model.ResourceDeletionJob {
|
||||||
|
resourcesByUser := make(map[string]map[string]*model.Resource)
|
||||||
|
for identity, resource := range physicalObjects {
|
||||||
|
if resourcesByUser[resource.UserID] == nil {
|
||||||
|
resourcesByUser[resource.UserID] = map[string]*model.Resource{}
|
||||||
|
}
|
||||||
|
resourcesByUser[resource.UserID][identity] = resource
|
||||||
|
}
|
||||||
|
userIDs := make([]string, 0, len(resourcesByUser))
|
||||||
|
for userID := range resourcesByUser {
|
||||||
|
userIDs = append(userIDs, userID)
|
||||||
|
}
|
||||||
|
sort.Strings(userIDs)
|
||||||
|
jobs := []model.ResourceDeletionJob{}
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
jobs = append(jobs, resourceDeletionJobs(userID, resourcesByUser[userID])...)
|
||||||
|
}
|
||||||
|
return jobs
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"infinite-canvas/backend/internal/database"
|
||||||
|
"infinite-canvas/backend/internal/model"
|
||||||
|
"infinite-canvas/backend/internal/repository"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeleteAdminResourcesSupportsPartialSuccessAndBatchedReferences(t *testing.T) {
|
||||||
|
svc, db, dataDir, admin := newAdminStorageDeleteTestService(t)
|
||||||
|
resources := []model.Resource{
|
||||||
|
{ID: "resource-free", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "users/user-1/image/delete-directory"},
|
||||||
|
{ID: "resource-blocked", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "users/user-1/image/blocked.png"},
|
||||||
|
}
|
||||||
|
if err := db.Create(&resources).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
canvas := model.CanvasProject{ID: "canvas-1", UserID: "user-1", Title: "广告分镜", PayloadJSON: `{"nodes":[{"data":{"storageKey":"resource:resource-blocked"}}]}`}
|
||||||
|
if err := db.Create(&canvas).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
directoryPath := filepath.Join(dataDir, "resources", filepath.FromSlash(resources[0].ObjectKey))
|
||||||
|
if err := os.MkdirAll(directoryPath, 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{"resource-free", "resource-blocked", "resource-free"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.Deleted) != 1 || result.Deleted[0] != "resource-free" {
|
||||||
|
t.Fatalf("deleted = %#v", result.Deleted)
|
||||||
|
}
|
||||||
|
if len(result.Blocked) != 1 || result.Blocked[0].ID != "resource-blocked" || len(result.Blocked[0].References) != 1 || result.Blocked[0].References[0].Kind != "画布" {
|
||||||
|
t.Fatalf("blocked = %#v", result.Blocked)
|
||||||
|
}
|
||||||
|
assertModelCount(t, db, &model.Resource{}, "id = ?", 0, "resource-free")
|
||||||
|
assertModelCount(t, db, &model.Resource{}, "id = ?", 1, "resource-blocked")
|
||||||
|
assertModelCount(t, db, &model.ResourceDeletionJob{}, "resource_id = ?", 1, "resource-free")
|
||||||
|
assertModelCount(t, db, &model.AdminAuditEvent{}, "action = ? AND target_id = ?", 1, "resource.delete", "resource-free")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAdminResourcesBlocksAnnouncementImage(t *testing.T) {
|
||||||
|
svc, db, _, admin := newAdminStorageDeleteTestService(t)
|
||||||
|
resource := model.Resource{ID: "announcement-image", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "announcements/cover.png"}
|
||||||
|
announcement := model.Announcement{ID: "announcement-1", Title: "维护通知", Content: "content", ImageResourceID: resource.ID, CreatedBy: admin.ID, Status: model.AnnouncementStatusActive}
|
||||||
|
if err := db.Create(&resource).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&announcement).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{resource.ID}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.Deleted) != 0 || len(result.Blocked) != 1 || result.Blocked[0].References[0].Kind != "公告" {
|
||||||
|
t.Fatalf("result = %#v", result)
|
||||||
|
}
|
||||||
|
assertModelCount(t, db, &model.Resource{}, "id = ?", 1, resource.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAdminResourcesKeepsSharedPhysicalObject(t *testing.T) {
|
||||||
|
svc, db, _, admin := newAdminStorageDeleteTestService(t)
|
||||||
|
resources := []model.Resource{
|
||||||
|
{ID: "resource-delete", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, Provider: "", ObjectKey: "shared/object.png"},
|
||||||
|
{ID: "resource-keep", UserID: "user-2", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "shared/object.png"},
|
||||||
|
}
|
||||||
|
if err := db.Create(&resources).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{"resource-delete"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.Deleted) != 1 {
|
||||||
|
t.Fatalf("result = %#v", result)
|
||||||
|
}
|
||||||
|
assertModelCount(t, db, &model.Resource{}, "id = ?", 0, "resource-delete")
|
||||||
|
assertModelCount(t, db, &model.Resource{}, "id = ?", 1, "resource-keep")
|
||||||
|
assertModelCount(t, db, &model.ResourceDeletionJob{}, "1 = 1", 0)
|
||||||
|
var audit model.AdminAuditEvent
|
||||||
|
if err := db.First(&audit, "target_id = ?", "resource-delete").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(audit.MetadataJSON, `"physicalDeleteQueued":false`) {
|
||||||
|
t.Fatalf("audit metadata = %s", audit.MetadataJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAdminResourcesRollsBackWhenAuditInsertFails(t *testing.T) {
|
||||||
|
svc, db, _, admin := newAdminStorageDeleteTestService(t)
|
||||||
|
resource := model.Resource{ID: "resource-rollback", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local"}
|
||||||
|
binding := model.ArkPrivateAssetBinding{ID: "binding-1", UserID: admin.ID, ResourceID: resource.ID, ProjectName: "project", Status: "active"}
|
||||||
|
draft := model.AnnouncementImageDraft{ResourceID: resource.ID, UserID: admin.ID}
|
||||||
|
if err := db.Create(&resource).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&binding).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&draft).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Exec("CREATE TRIGGER fail_resource_audit BEFORE INSERT ON admin_audit_events WHEN NEW.action = 'resource.delete' BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END;").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{resource.ID}}); err == nil {
|
||||||
|
t.Fatal("expected audit failure")
|
||||||
|
}
|
||||||
|
assertModelCount(t, db, &model.Resource{}, "id = ?", 1, resource.ID)
|
||||||
|
assertModelCount(t, db, &model.ArkPrivateAssetBinding{}, "resource_id = ?", 1, resource.ID)
|
||||||
|
assertModelCount(t, db, &model.AnnouncementImageDraft{}, "resource_id = ?", 1, resource.ID)
|
||||||
|
assertModelCount(t, db, &model.ResourceDeletionJob{}, "1 = 1", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAdminResourcesRejectsNonAdminAndOversizedBatch(t *testing.T) {
|
||||||
|
svc := &Service{}
|
||||||
|
user := &model.User{ID: "user", Role: model.UserRoleUser, Status: model.UserStatusActive}
|
||||||
|
if _, err := svc.DeleteAdminResources(user, AdminResourceDeleteRequest{ResourceIDs: []string{"resource-1"}}); err == nil {
|
||||||
|
t.Fatal("expected non-admin rejection")
|
||||||
|
}
|
||||||
|
ids := make([]string, maxAdminResourceDeleteCount+1)
|
||||||
|
for index := range ids {
|
||||||
|
ids[index] = newID()
|
||||||
|
}
|
||||||
|
if _, err := normalizeAdminResourceDeleteIDs(ids); err == nil {
|
||||||
|
t.Fatal("expected oversized batch rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdminStorageDeleteTestService(t *testing.T) (*Service, *gorm.DB, string, *model.User) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.MigrateSchema(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin := &model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||||
|
if err := db.Create(admin).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
return New(repository.New(db), dataDir), db, dataDir, admin
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertModelCount(t *testing.T, db *gorm.DB, value any, query string, expected int64, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
var count int64
|
||||||
|
dbQuery := db.Model(value).Where(query, args...)
|
||||||
|
if err := dbQuery.Count(&count).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != expected {
|
||||||
|
t.Fatalf("count = %d, want %d", count, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -440,6 +440,19 @@ description: 影策已实现功能的手工验证清单与未确认边界
|
|||||||
- [ ] 确认浅色/深色主题下标题、分类选中态、搜索焦点环、键帽和滚动区域清晰可见。
|
- [ ] 确认浅色/深色主题下标题、分类选中态、搜索焦点环、键帽和滚动区域清晰可见。
|
||||||
- [ ] 在窄窗口确认分类可横向滚动、快捷键列表改为单列,弹窗不超出视口且长内容仅在列表区滚动。
|
- [ ] 在窄窗口确认分类可横向滚动、快捷键列表改为单列,弹窗不超出视口且长内容仅在列表区滚动。
|
||||||
|
|
||||||
|
## 管理后台存储资源
|
||||||
|
|
||||||
|
管理后台已新增资源列表、筛选、去重容量统计、预览下载和安全批量删除;以下场景仍需使用真实资源数据确认:
|
||||||
|
|
||||||
|
- [x] 后端专项测试覆盖管理员权限、筛选校验、共享对象去重统计、历史本地资源预览、部分删除成功、公告/画布引用阻止、共享物理对象保留、审计失败事务回滚和 100 项上限。
|
||||||
|
- [x] `web` TypeScript 检查通过;只读阶段与删除阶段均复用同一资源页面和统一 API 客户端。
|
||||||
|
- [ ] 在 `/admin/resources` 分别按资源 ID、对象路径、用户 ID、类型、状态和存储类型筛选,确认分页与筛选 chip 一致。
|
||||||
|
- [ ] 预览并下载本地、阿里云 OSS、腾讯云 COS、七牛云 Kodo 和 S3 的图片、视频、音频与普通文件,确认 Range、登录 Cookie 和错误提示正确。
|
||||||
|
- [ ] 选择一个仍被公告、素材、画布、项目、风格、工作流或镜头产物引用的资源,确认删除结果显示真实引用来源且业务数据和物理对象均保留。
|
||||||
|
- [ ] 批量选择“可删除、仍被引用、不存在”三类资源,确认只删除安全项,列表和统计刷新,审计事件与 outbox 数量一致。
|
||||||
|
- [ ] 创建两条共享同一物理对象的资源记录,只删除其中一条,确认另一条仍能预览且物理对象未清理;两条同时删除时确认只产生一个物理删除任务。
|
||||||
|
- [ ] 模拟对象存储暂时失败,确认资源记录事务仍成功、删除任务保留并重试,恢复后物理对象最终清理。
|
||||||
|
|
||||||
## 短剧分镜工作流工作台
|
## 短剧分镜工作流工作台
|
||||||
|
|
||||||
短剧工作流已按镜头生产重构为单层项目工作台,并将资产绑定、镜头编辑、生成预览和整集镜头轨道放入同一视口;以下场景仍需用户在真实项目数据与模型渠道下确认:
|
短剧工作流已按镜头生产重构为单层项目工作台,并将资产绑定、镜头编辑、生成预览和整集镜头轨道放入同一视口;以下场景仍需用户在真实项目数据与模型渠道下确认:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { App, Button, Input, Modal, Select } from "antd";
|
import { App, Button, Input, Modal, Select } from "antd";
|
||||||
import type { ColumnsType } from "antd/es/table";
|
import type { ColumnsType } from "antd/es/table";
|
||||||
import { Download, Eye, Search } from "lucide-react";
|
import { Download, Eye, Search, Trash2 } from "lucide-react";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
@@ -9,18 +9,19 @@ import { PaginationBar } from "@/components/layout/workspace-page";
|
|||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import {
|
import {
|
||||||
adminResourceFileUrl,
|
adminResourceFileUrl,
|
||||||
|
deleteAdminResources,
|
||||||
downloadAdminResource,
|
downloadAdminResource,
|
||||||
getAdminStorageStats,
|
getAdminStorageStats,
|
||||||
listAdminResources,
|
listAdminResources,
|
||||||
type AdminStorageResource,
|
type AdminStorageResource,
|
||||||
type AdminStorageStats,
|
type AdminStorageStats,
|
||||||
} from "@/services/api/admin-storage";
|
} from "@/services/api/admin-storage";
|
||||||
import { AdminDataTable, AdminFilterChip, AdminStatTile, AdminStatusBadge, AdminTableEmpty } from "./admin-ui";
|
import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatTile, AdminStatusBadge, AdminTableEmpty } from "./admin-ui";
|
||||||
|
|
||||||
const pageSizes = [20, 50, 100];
|
const pageSizes = [20, 50, 100];
|
||||||
|
|
||||||
export default function StorageResourcesPanel() {
|
export default function StorageResourcesPanel() {
|
||||||
const { message } = App.useApp();
|
const { message, modal } = App.useApp();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const keyword = searchParams.get("filter") || "";
|
const keyword = searchParams.get("filter") || "";
|
||||||
const kind = normalizeOption(searchParams.get("kind"), ["image", "video", "audio", "file"]);
|
const kind = normalizeOption(searchParams.get("kind"), ["image", "video", "audio", "file"]);
|
||||||
@@ -37,6 +38,9 @@ export default function StorageResourcesPanel() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [previewing, setPreviewing] = useState<AdminStorageResource | null>(null);
|
const [previewing, setPreviewing] = useState<AdminStorageResource | null>(null);
|
||||||
const [downloadingId, setDownloadingId] = useState("");
|
const [downloadingId, setDownloadingId] = useState("");
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const requestSequence = useRef(0);
|
const requestSequence = useRef(0);
|
||||||
const hasFilters = Boolean(keyword || userId || kind !== "all" || status !== "all" || provider !== "all");
|
const hasFilters = Boolean(keyword || userId || kind !== "all" || status !== "all" || provider !== "all");
|
||||||
|
|
||||||
@@ -62,7 +66,7 @@ export default function StorageResourcesPanel() {
|
|||||||
message.error(error instanceof Error ? error.message : "读取存储统计失败");
|
message.error(error instanceof Error ? error.message : "读取存储统计失败");
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, [refreshKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sequence = ++requestSequence.current;
|
const sequence = ++requestSequence.current;
|
||||||
@@ -89,7 +93,7 @@ export default function StorageResourcesPanel() {
|
|||||||
})
|
})
|
||||||
.finally(() => sequence === requestSequence.current && setLoading(false));
|
.finally(() => sequence === requestSequence.current && setLoading(false));
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [debouncedKeyword, debouncedUserId, kind, status, provider, page, pageSize]);
|
}, [debouncedKeyword, debouncedUserId, kind, status, provider, page, pageSize, refreshKey]);
|
||||||
|
|
||||||
const columns = useMemo<ColumnsType<AdminStorageResource>>(() => [
|
const columns = useMemo<ColumnsType<AdminStorageResource>>(() => [
|
||||||
{ title: "资源", width: 260, render: (_, resource) => <div className="min-w-0"><div className="truncate font-medium text-foreground/85" title={resource.objectKey}>{fileName(resource.objectKey) || resource.id}</div><div className="admin-monospace truncate text-foreground/42" title={resource.id}>{resource.id}</div></div> },
|
{ title: "资源", width: 260, render: (_, resource) => <div className="min-w-0"><div className="truncate font-medium text-foreground/85" title={resource.objectKey}>{fileName(resource.objectKey) || resource.id}</div><div className="admin-monospace truncate text-foreground/42" title={resource.id}>{resource.id}</div></div> },
|
||||||
@@ -100,8 +104,8 @@ export default function StorageResourcesPanel() {
|
|||||||
{ title: "大小", dataIndex: "size", width: 110, render: (value) => <span className="tabular-nums">{formatBytes(value)}</span> },
|
{ title: "大小", dataIndex: "size", width: 110, render: (value) => <span className="tabular-nums">{formatBytes(value)}</span> },
|
||||||
{ title: "规格", width: 135, render: (_, resource) => resourceDimensions(resource) },
|
{ title: "规格", width: 135, render: (_, resource) => resourceDimensions(resource) },
|
||||||
{ title: "创建时间", dataIndex: "createdAt", width: 170, render: formatTime },
|
{ title: "创建时间", dataIndex: "createdAt", width: 170, render: formatTime },
|
||||||
{ title: "操作", width: 154, align: "right", fixed: "right", render: (_, resource) => <div className="flex justify-end gap-1"><Button type="text" size="small" icon={<Eye className="size-3.5" />} disabled={resource.status !== "ready"} onClick={() => setPreviewing(resource)}>预览</Button><Button type="text" size="small" icon={<Download className="size-3.5" />} loading={downloadingId === resource.id} disabled={resource.status !== "ready"} onClick={() => void download(resource)}>下载</Button></div> },
|
{ title: "操作", width: 220, align: "right", fixed: "right", render: (_, resource) => <div className="flex justify-end gap-1"><Button type="text" size="small" icon={<Eye className="size-3.5" />} disabled={resource.status !== "ready"} onClick={() => setPreviewing(resource)}>预览</Button><Button type="text" size="small" icon={<Download className="size-3.5" />} loading={downloadingId === resource.id} disabled={resource.status !== "ready"} onClick={() => void download(resource)}>下载</Button><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} disabled={deleting} onClick={() => confirmDelete([resource.id])}>删除</Button></div> },
|
||||||
], [downloadingId]);
|
], [downloadingId, deleting]);
|
||||||
|
|
||||||
const download = async (resource: AdminStorageResource) => {
|
const download = async (resource: AdminStorageResource) => {
|
||||||
setDownloadingId(resource.id);
|
setDownloadingId(resource.id);
|
||||||
@@ -115,6 +119,38 @@ export default function StorageResourcesPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (resourceIds: string[]) => {
|
||||||
|
const uniqueIds = Array.from(new Set(resourceIds));
|
||||||
|
modal.confirm({
|
||||||
|
title: uniqueIds.length > 1 ? `删除选中的 ${uniqueIds.length} 个资源?` : "删除这个资源?",
|
||||||
|
content: "系统会先批量检查公告、素材、画布、项目、工作流和镜头产物引用。仍被引用的资源会保留;无引用资源的记录、清理任务和审计事件会在同一事务提交。",
|
||||||
|
okText: uniqueIds.length > 1 ? "检查并删除" : "确认删除",
|
||||||
|
cancelText: "取消",
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const result = await deleteAdminResources(uniqueIds);
|
||||||
|
setSelectedIds([]);
|
||||||
|
setRefreshKey((value) => value + 1);
|
||||||
|
if (result.deleted.length > 0) message.success(`已删除 ${result.deleted.length} 个资源`);
|
||||||
|
if (result.blocked.length > 0) {
|
||||||
|
modal.warning({
|
||||||
|
title: result.deleted.length > 0 ? "部分资源未删除" : "资源未删除",
|
||||||
|
content: <DeleteBlockedSummary blocked={result.blocked} />,
|
||||||
|
okText: "知道了",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : "删除资源失败");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
<div className="grid overflow-hidden rounded-md border border-border sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid overflow-hidden rounded-md border border-border sm:grid-cols-2 xl:grid-cols-4">
|
||||||
@@ -129,8 +165,9 @@ export default function StorageResourcesPanel() {
|
|||||||
toolbarActiveFilters={<>{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => updateUrl({ filter: "", page: 1 })} /> : null}{userId ? <AdminFilterChip label={`用户:${userId}`} onRemove={() => updateUrl({ userId: "", page: 1 })} /> : null}{kind !== "all" ? <AdminFilterChip label={`类型:${kindLabel(kind)}`} onRemove={() => updateUrl({ kind: "all", page: 1 })} /> : null}{status !== "all" ? <AdminFilterChip label={`状态:${statusLabel(status)}`} onRemove={() => updateUrl({ status: "all", page: 1 })} /> : null}{provider !== "all" ? <AdminFilterChip label={`存储:${providerLabel(provider)}`} onRemove={() => updateUrl({ provider: "all", page: 1 })} /> : null}</>}
|
toolbarActiveFilters={<>{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => updateUrl({ filter: "", page: 1 })} /> : null}{userId ? <AdminFilterChip label={`用户:${userId}`} onRemove={() => updateUrl({ userId: "", page: 1 })} /> : null}{kind !== "all" ? <AdminFilterChip label={`类型:${kindLabel(kind)}`} onRemove={() => updateUrl({ kind: "all", page: 1 })} /> : null}{status !== "all" ? <AdminFilterChip label={`状态:${statusLabel(status)}`} onRemove={() => updateUrl({ status: "all", page: 1 })} /> : null}{provider !== "all" ? <AdminFilterChip label={`存储:${providerLabel(provider)}`} onRemove={() => updateUrl({ provider: "all", page: 1 })} /> : null}</>}
|
||||||
toolbarActive={hasFilters}
|
toolbarActive={hasFilters}
|
||||||
onReset={() => updateUrl({ filter: "", userId: "", kind: "all", status: "all", provider: "all", page: 1 })}
|
onReset={() => updateUrl({ filter: "", userId: "", kind: "all", status: "all", provider: "all", page: 1 })}
|
||||||
|
batchActions={<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}><Button danger size="small" icon={<Trash2 className="size-3.5" />} loading={deleting} onClick={() => confirmDelete(selectedIds)}>批量删除</Button></AdminBatchBar>}
|
||||||
skeletonColumns={9}
|
skeletonColumns={9}
|
||||||
table={{ className: "app-data-table", size: "small", rowKey: "id", loading, columns, dataSource: resources, pagination: false, scroll: { x: 1320 } }}
|
table={{ className: "app-data-table", size: "small", rowKey: "id", loading, columns, dataSource: resources, rowSelection: { selectedRowKeys: selectedIds, preserveSelectedRowKeys: true, onChange: (keys) => { const next = keys.map(String); if (next.length > 100) message.warning("单次最多选择 100 个资源"); setSelectedIds(next.slice(0, 100)); } }, pagination: false, scroll: { x: 1390 } }}
|
||||||
empty={<AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "暂无资源记录"} description={hasFilters ? undefined : "资源上传或生成后会显示在这里。"} />}
|
empty={<AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "暂无资源记录"} description={hasFilters ? undefined : "资源上传或生成后会显示在这里。"} />}
|
||||||
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />}
|
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />}
|
||||||
/>
|
/>
|
||||||
@@ -141,6 +178,20 @@ export default function StorageResourcesPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DeleteBlockedSummary({ blocked }: { blocked: Array<{ id: string; reason: string; references: Array<{ kind: string; id: string; title: string }> }> }) {
|
||||||
|
return (
|
||||||
|
<div className="max-h-72 space-y-3 overflow-y-auto pr-1 text-sm">
|
||||||
|
{blocked.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-md border border-border px-3 py-2">
|
||||||
|
<div className="admin-monospace break-all text-foreground/75">{item.id}</div>
|
||||||
|
<div className="mt-1 text-foreground/55">{item.reason}</div>
|
||||||
|
{item.references.length > 0 ? <div className="mt-1 text-xs text-foreground/45">{item.references.slice(0, 4).map((reference) => `${reference.kind}「${reference.title || reference.id}」`).join("、")}{item.references.length > 4 ? ` 等 ${item.references.length} 处` : ""}</div> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ResourcePreview({ resource }: { resource: AdminStorageResource }) {
|
function ResourcePreview({ resource }: { resource: AdminStorageResource }) {
|
||||||
const url = adminResourceFileUrl(resource.id);
|
const url = adminResourceFileUrl(resource.id);
|
||||||
if (resource.kind === "image" || resource.mimeType.startsWith("image/")) return <img className="mx-auto max-h-[65vh] max-w-full object-contain" src={url} alt={fileName(resource.objectKey) || "资源预览"} />;
|
if (resource.kind === "image" || resource.mimeType.startsWith("image/")) return <img className="mx-auto max-h-[65vh] max-w-full object-contain" src={url} alt={fileName(resource.objectKey) || "资源预览"} />;
|
||||||
|
|||||||
@@ -54,6 +54,23 @@ export type AdminResourceQuery = {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminResourceReference = {
|
||||||
|
kind: string;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminResourceDeleteBlocked = {
|
||||||
|
id: string;
|
||||||
|
reason: string;
|
||||||
|
references: AdminResourceReference[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminResourceDeleteResult = {
|
||||||
|
deleted: string[];
|
||||||
|
blocked: AdminResourceDeleteBlocked[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function listAdminResources(query: AdminResourceQuery, signal?: AbortSignal) {
|
export async function listAdminResources(query: AdminResourceQuery, signal?: AbortSignal) {
|
||||||
return request<AdminResourcePage>(apiClient.get("/admin/resources", { params: compactApiParams(query), signal }));
|
return request<AdminResourcePage>(apiClient.get("/admin/resources", { params: compactApiParams(query), signal }));
|
||||||
}
|
}
|
||||||
@@ -63,6 +80,10 @@ export async function getAdminStorageStats(signal?: AbortSignal) {
|
|||||||
return result.stats;
|
return result.stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteAdminResources(resourceIds: string[]) {
|
||||||
|
return request<AdminResourceDeleteResult>(apiClient.post("/admin/resources/delete", { resourceIds }));
|
||||||
|
}
|
||||||
|
|
||||||
export function adminResourceFileUrl(id: string, download = false) {
|
export function adminResourceFileUrl(id: string, download = false) {
|
||||||
const base = String(apiBaseURL).replace(/\/+$/, "");
|
const base = String(apiBaseURL).replace(/\/+$/, "");
|
||||||
return `${base}/admin/resources/${encodeURIComponent(id)}/file${download ? "?download=1" : ""}`;
|
return `${base}/admin/resources/${encodeURIComponent(id)}/file${download ? "?download=1" : ""}`;
|
||||||
|
|||||||
Reference in new issue
Block a user