fix(*): 素材库与画布 - 修复批量删除刷新、选择布局及工具引用
This commit is contained in:
126
backend/internal/handler/asset_delete_test.go
Normal file
126
backend/internal/handler/asset_delete_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/auth"
|
||||
"infinite-canvas/backend/internal/database"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAssetBatchDeleteHTTP(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := database.MigrateSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, record := range []any{
|
||||
&model.User{ID: "batch-user", Username: "batch-user", Email: "batch@example.invalid", Role: model.UserRoleUser, Status: model.UserStatusActive},
|
||||
&model.AuthSession{ID: "batch-session", UserID: "batch-user", TokenHash: auth.HashToken("test-token"), ExpiresAt: time.Now().Add(time.Hour)},
|
||||
&model.Asset{ID: "foreign", UserID: "other-user", PayloadJSON: `{}`},
|
||||
} {
|
||||
if err := db.Create(record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ids := make([]string, 100)
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("batch-%03d", i)
|
||||
if err := db.Create(&model.Asset{ID: ids[i], UserID: "batch-user", Status: model.AssetVersionStatusConfirmed, PayloadJSON: `{}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
router := gin.New()
|
||||
RegisterUserDataRoutes(router.Group("/api"), service.New(repository.New(db), t.TempDir()))
|
||||
call := func(method, path, body string, authenticated bool) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if authenticated {
|
||||
r.AddCookie(&http.Cookie{Name: service.SessionCookieName, Value: "batch-session.test-token"})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
assertAssets := func(want int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Model(&model.Asset{}).Count(&count).Error; err != nil || count != want {
|
||||
t.Fatalf("asset count=%d want=%d err=%v", count, want, err)
|
||||
}
|
||||
}
|
||||
path := "/api/assets/batch-delete"
|
||||
if w := call(http.MethodPost, path, `["batch-000"]`, false); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
for _, body := range []string{`[]`, `null`, `{ "ids": ["batch-000"] }`, `["batch-000", ""]`, `[1]`} {
|
||||
if w := call(http.MethodPost, path, body, true); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid body %s: %d %s", body, w.Code, w.Body.String())
|
||||
}
|
||||
assertAssets(101)
|
||||
}
|
||||
for _, badID := range []string{"foreign", "missing"} {
|
||||
w := call(http.MethodPost, path, `["batch-000","`+badID+`"]`, true)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("ownership/missing %s: %d %s", badID, w.Code, w.Body.String())
|
||||
}
|
||||
assertAssets(101)
|
||||
}
|
||||
body, _ := json.Marshal(ids)
|
||||
if err := db.Exec("CREATE TRIGGER fail_batch_delete BEFORE DELETE ON assets WHEN OLD.id = 'batch-099' BEGIN SELECT RAISE(ABORT, 'forced failure'); END;").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w := call(http.MethodPost, path, string(body), true); w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("database failure: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
assertAssets(101)
|
||||
if err := db.Exec("DROP TRIGGER fail_batch_delete").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := call(http.MethodPost, path, string(body), true)
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
IDs []string `json:"ids"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || w.Code != http.StatusOK || response.Code != 0 || len(response.Data.IDs) != 100 {
|
||||
t.Fatalf("batch response: %d %s err=%v", w.Code, w.Body.String(), err)
|
||||
}
|
||||
assertAssets(1)
|
||||
|
||||
// The existing DELETE endpoint uses the same purge policy for non-archived assets.
|
||||
resource := model.Resource{ID: "single-resource", UserID: "batch-user", Provider: "unsupported-test-provider", ObjectKey: "single.png"}
|
||||
payload := `{"url":"/api/resources/single-resource/file"}`
|
||||
for _, record := range []any{
|
||||
&resource,
|
||||
&model.Asset{ID: "single", UserID: "batch-user", Status: model.AssetVersionStatusConfirmed, PayloadJSON: payload},
|
||||
&model.Task{ID: "running-task", UserID: "batch-user", Status: model.TaskStatusRunning, InputJSON: payload},
|
||||
} {
|
||||
if err := db.Create(record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if w := call(http.MethodDelete, "/api/assets/single", "", true); w.Code != http.StatusOK {
|
||||
t.Fatalf("single referenced delete: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
assertAssets(1)
|
||||
}
|
||||
@@ -1090,6 +1090,65 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
/assets/batch-delete:
|
||||
post:
|
||||
tags: [assets]
|
||||
summary: 批量彻底删除当前用户素材
|
||||
description: 顶层 JSON 数组,一次请求、一个数据库事务。所有 ID 必须属于当前用户,不受任务、画布或历史引用阻塞;共享文件保留,其余通过 Outbox 异步清理,失败重试。任一素材不存在、越权或数据库写入失败时整批不删除。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 1000
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
example: ["asset-1", "asset-2"]
|
||||
responses:
|
||||
"200":
|
||||
description: 成功信封,data.ids 返回本次请求的素材 ID 数组;物理文件清理由后台执行。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Envelope"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required: [ids]
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
items: { type: string }
|
||||
"400":
|
||||
$ref: "#/components/responses/Error"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
"404":
|
||||
$ref: "#/components/responses/Error"
|
||||
"500":
|
||||
$ref: "#/components/responses/Error"
|
||||
/assets/{id}:
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
delete:
|
||||
tags: [assets]
|
||||
summary: 彻底删除当前用户素材
|
||||
description: 不依赖素材 archived 状态,也不受画布、任务、项目或历史快照引用阻塞;其他素材共享的物理对象仍保留。
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
"404":
|
||||
$ref: "#/components/responses/Error"
|
||||
components:
|
||||
parameters:
|
||||
AgentRunID:
|
||||
|
||||
@@ -18,6 +18,24 @@ import (
|
||||
)
|
||||
|
||||
func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.POST("/assets/batch-delete", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10)
|
||||
var ids []string
|
||||
if err := c.ShouldBindJSON(&ids); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := svc.PurgeUserAssets(user.ID, ids); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"ids": ids})
|
||||
})
|
||||
r.POST("/assets/batch", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
@@ -586,7 +604,7 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.DeleteUserAsset(user.ID, c.Param("id")); err != nil {
|
||||
if err := svc.PurgeUserAsset(user.ID, c.Param("id")); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ func TestUserDataIncrementalRoutesAreRegistered(t *testing.T) {
|
||||
router := gin.New()
|
||||
RegisterUserDataRoutes(router.Group("/api"), &service.Service{})
|
||||
wanted := map[string]bool{
|
||||
"GET /api/canvas-projects": false,
|
||||
"GET /api/canvas-projects/:id": false,
|
||||
"POST /api/assets/batch": false,
|
||||
"GET /api/canvas-projects": false,
|
||||
"GET /api/canvas-projects/:id": false,
|
||||
"POST /api/assets/batch": false,
|
||||
"POST /api/assets/batch-delete": false,
|
||||
}
|
||||
for _, route := range router.Routes() {
|
||||
key := route.Method + " " + route.Path
|
||||
|
||||
Reference in New Issue
Block a user