fix(*): 素材库与画布 - 修复批量删除刷新、选择布局及工具引用

This commit is contained in:
ddCat
2026-09-21 20:14:05 +08:00
parent c5b81f696c
commit dc1ad68015
27 changed files with 852 additions and 109 deletions

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

View File

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

View File

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

View File

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