Files
ttstd-infinite-canvas/backend/internal/canvas/repair_asset_bytes.go

117 lines
4.0 KiB
Go

package canvas
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"yingce/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
}