fix(canvas): 素材库 - 拒绝空字节数并提供历史数据修复工具
This commit is contained in:
116
backend/internal/canvas/repair_asset_bytes.go
Normal file
116
backend/internal/canvas/repair_asset_bytes.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type AssetBytesRepairIssue struct {
|
||||
AssetID string `json:"assetId"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type AssetBytesRepairReport struct {
|
||||
Apply bool `json:"apply"`
|
||||
Scanned int `json:"scanned"`
|
||||
Repairable int `json:"repairable"`
|
||||
Repaired int `json:"repaired"`
|
||||
Unresolved []AssetBytesRepairIssue `json:"unresolved"`
|
||||
}
|
||||
|
||||
// RepairAssetBytes is an explicit offline maintenance operation, not a read fallback.
|
||||
// Only an owned, ready resource with a known positive size can repair a record.
|
||||
func RepairAssetBytes(db *gorm.DB, apply bool) (AssetBytesRepairReport, error) {
|
||||
report := AssetBytesRepairReport{Apply: apply, Unresolved: []AssetBytesRepairIssue{}}
|
||||
// Payloads may contain private media URLs; never include SQL parameters in logs.
|
||||
err := db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Transaction(func(tx *gorm.DB) error {
|
||||
after := ""
|
||||
for {
|
||||
var rows []model.Asset
|
||||
if err := tx.Where("id > ? AND kind IN ?", after, []string{"image", "video", "audio", "model"}).Order("id ASC").Limit(100).Find(&rows).Error; err != nil {
|
||||
return errors.New("读取素材失败,未提交修复")
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, asset := range rows {
|
||||
after = asset.ID
|
||||
report.Scanned++
|
||||
issue := func(reason string) {
|
||||
report.Unresolved = append(report.Unresolved, AssetBytesRepairIssue{AssetID: asset.ID, Reason: reason})
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
var data map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(asset.PayloadJSON), &payload) != nil || json.Unmarshal(payload["data"], &data) != nil || data == nil {
|
||||
issue("素材 JSON 或 data 无效,需人工核查")
|
||||
continue
|
||||
}
|
||||
if _, err := requiredJSONNumberField(data, "bytes"); err == nil {
|
||||
continue
|
||||
}
|
||||
var kind, key string
|
||||
_ = json.Unmarshal(payload["kind"], &kind)
|
||||
_ = json.Unmarshal(data["storageKey"], &key)
|
||||
key = strings.TrimSpace(key)
|
||||
if strings.TrimSpace(asset.UserID) == "" || kind != asset.Kind || !strings.HasPrefix(key, "resource:") || strings.TrimPrefix(key, "resource:") == "" {
|
||||
issue("类型不一致或缺少明确的 resource: 引用,不能推测文件大小")
|
||||
continue
|
||||
}
|
||||
var resource model.Resource
|
||||
err := tx.Where("id = ? AND user_id = ? AND kind = ? AND status = ? AND size > 0", strings.TrimPrefix(key, "resource:"), asset.UserID, kind, model.ResourceStatusReady).Take(&resource).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
issue("未找到同用户、同类型、就绪且大小已知的资源")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("读取资源失败,未提交修复")
|
||||
}
|
||||
data["bytes"], err = json.Marshal(resource.Size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload["data"], err = json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
payload["updatedAt"], err = json.Marshal(now.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateUserAssetDocument(encoded); err != nil {
|
||||
issue("素材仍有其他合同错误,未修改,请另行核查")
|
||||
continue
|
||||
}
|
||||
report.Repairable++
|
||||
if !apply {
|
||||
continue
|
||||
}
|
||||
result := tx.Model(&model.Asset{}).Where("id = ? AND user_id = ? AND payload_json = ? AND updated_at = ?", asset.ID, asset.UserID, asset.PayloadJSON, asset.UpdatedAt).Updates(map[string]any{"payload_json": string(encoded), "updated_at": now})
|
||||
if result.Error != nil {
|
||||
return errors.New("更新素材失败,未提交修复")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return fmt.Errorf("素材 %s 已变化,整批修复回滚,请停止写入后重试", asset.ID)
|
||||
}
|
||||
report.Repaired++
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
report.Repaired = 0
|
||||
}
|
||||
return report, err
|
||||
}
|
||||
181
backend/internal/canvas/repair_asset_bytes_test.go
Normal file
181
backend/internal/canvas/repair_asset_bytes_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestRepairAssetBytes(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
testRepairAssetBytes(t, db)
|
||||
}
|
||||
|
||||
func TestRepairAssetBytesPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("CANVAS_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("CANVAS_TEST_POSTGRES_DSN is not configured")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
rollback := errors.New("rollback isolated test schema")
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
schema := fmt.Sprintf("repair_asset_bytes_%d", time.Now().UnixNano())
|
||||
if err := tx.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("SET LOCAL search_path TO " + schema).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
testRepairAssetBytes(t, tx)
|
||||
return rollback
|
||||
})
|
||||
if !errors.Is(err, rollback) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func testRepairAssetBytes(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.AutoMigrate(&model.Asset{}, &model.Resource{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource := model.Resource{ID: "resource-1", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, Size: 12345}
|
||||
if err := db.Create(&resource).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originals := map[string]string{}
|
||||
for i := 0; i < 106; i++ {
|
||||
id := fmt.Sprintf("asset-%03d", i)
|
||||
payload := testUserAssetPayload("image", nil)
|
||||
payload["id"] = id
|
||||
payload["customExactInteger"] = json.RawMessage("9007199254740993")
|
||||
data := payload["data"].(map[string]any)
|
||||
data["storageKey"] = "resource:resource-1"
|
||||
data["bytes"] = nil
|
||||
userID := "user-1"
|
||||
switch i {
|
||||
case 0:
|
||||
data["bytes"] = 55 // Valid values must remain untouched.
|
||||
case 1:
|
||||
userID = "another-user"
|
||||
case 2:
|
||||
delete(data, "storageKey")
|
||||
case 3:
|
||||
data["bytes"] = "123"
|
||||
case 4:
|
||||
delete(data, "bytes")
|
||||
case 5:
|
||||
data["bytes"] = -1
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originals[id] = string(raw)
|
||||
asset := model.Asset{ID: id, UserID: userID, Kind: "image", PayloadJSON: string(raw)}
|
||||
if err := db.Create(&asset).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
report, err := RepairAssetBytes(db, false)
|
||||
if err != nil || report.Scanned != 106 || report.Repairable != 103 || report.Repaired != 0 || len(report.Unresolved) != 2 {
|
||||
t.Fatalf("dry run: %+v, %v", report, err)
|
||||
}
|
||||
var before []model.Asset
|
||||
if err := db.Find(&before).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, asset := range before {
|
||||
if asset.PayloadJSON != originals[asset.ID] {
|
||||
t.Fatal("dry run modified data")
|
||||
}
|
||||
}
|
||||
report, err = RepairAssetBytes(db, true)
|
||||
if err != nil || report.Repaired != 103 || len(report.Unresolved) != 2 {
|
||||
t.Fatalf("apply: %+v, %v", report, err)
|
||||
}
|
||||
var after []model.Asset
|
||||
if err := db.Find(&after).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, asset := range after {
|
||||
if asset.ID <= "asset-002" {
|
||||
if asset.PayloadJSON != originals[asset.ID] {
|
||||
t.Fatal("valid or unresolved asset modified")
|
||||
}
|
||||
continue
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
var data map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(asset.PayloadJSON), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(payload["data"], &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data["bytes"]) != "12345" || string(payload["customExactInteger"]) != "9007199254740993" {
|
||||
t.Fatal("repair corrupted payload")
|
||||
}
|
||||
var stamp string
|
||||
_ = json.Unmarshal(payload["updatedAt"], &stamp)
|
||||
parsed, err := time.Parse(time.RFC3339Nano, stamp)
|
||||
// PostgreSQL stores microseconds; compare at that precision.
|
||||
if err != nil || parsed.Sub(asset.UpdatedAt).Abs() >= time.Microsecond {
|
||||
t.Fatal("timestamps disagree")
|
||||
}
|
||||
}
|
||||
report, err = RepairAssetBytes(db, true)
|
||||
if err != nil || report.Repairable != 0 || report.Repaired != 0 {
|
||||
t.Fatalf("not idempotent: %+v, %v", report, err)
|
||||
}
|
||||
for _, id := range []string{"asset-003", "asset-004"} {
|
||||
if err := db.Model(&model.Asset{}).Where("id = ?", id).Update("payload_json", originals[id]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
updates := 0
|
||||
const callback = "test:fail-second-asset-repair"
|
||||
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
|
||||
updates++
|
||||
if updates == 2 {
|
||||
tx.AddError(errors.New("injected update failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err = RepairAssetBytes(db, true)
|
||||
if removeErr := db.Callback().Update().Remove(callback); removeErr != nil {
|
||||
t.Fatal(removeErr)
|
||||
}
|
||||
if err == nil || report.Repaired != 0 {
|
||||
t.Fatalf("failed repair reported success: %+v, %v", report, err)
|
||||
}
|
||||
for _, id := range []string{"asset-003", "asset-004"} {
|
||||
var asset model.Asset
|
||||
if err := db.First(&asset, "id = ?", id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asset.PayloadJSON != originals[id] {
|
||||
t.Fatal("failed batch was not rolled back")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,11 @@ func requiredJSONNumberField(object map[string]json.RawMessage, key string) (flo
|
||||
if !ok {
|
||||
return 0, kernel.BadAuthRequest("素材缺少 " + key + " 字段")
|
||||
}
|
||||
// json.Unmarshal(null, &float64) succeeds without assigning a value.
|
||||
// Reject null explicitly so the persisted JSON obeys the client contract.
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return 0, kernel.BadAuthRequest("素材字段 " + key + " 必须是数字")
|
||||
}
|
||||
var value float64
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return 0, kernel.BadAuthRequest("素材字段 " + key + " 必须是数字")
|
||||
|
||||
32
backend/internal/canvas/user_asset_document_test.go
Normal file
32
backend/internal/canvas/user_asset_document_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserAssetBytesRequiresNonNegativeNumber(t *testing.T) {
|
||||
for _, kind := range []string{"image", "video", "audio", "model"} {
|
||||
for _, raw := range []string{"null", " null ", `"12"`, "-1", "true", "{}", "[]", "1e400", "0", "12"} {
|
||||
t.Run(kind+"/"+raw, func(t *testing.T) {
|
||||
data := map[string]json.RawMessage{
|
||||
"dataUrl": json.RawMessage(`"https://example.com/file"`),
|
||||
"url": json.RawMessage(`"https://example.com/file"`),
|
||||
"width": json.RawMessage("1"), "height": json.RawMessage("1"),
|
||||
"mimeType": json.RawMessage(`"application/octet-stream"`),
|
||||
"fileName": json.RawMessage(`"file.glb"`),
|
||||
"bytes": json.RawMessage(raw),
|
||||
}
|
||||
err := validateUserAssetData(kind, data)
|
||||
valid := raw == "0" || raw == "12"
|
||||
if (err == nil) != valid {
|
||||
t.Fatalf("bytes=%s: error=%v, want valid=%v", raw, err, valid)
|
||||
}
|
||||
delete(data, "bytes")
|
||||
if err := validateUserAssetData(kind, data); err == nil {
|
||||
t.Fatal("missing bytes accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user