feat(media): 作品保存恢复 - 按原任务重试交付并避免重复生成计费

This commit is contained in:
ddCat
2026-09-22 15:47:24 +08:00
parent acbb354e97
commit 48a82ded74
49 changed files with 1710 additions and 50 deletions

View File

@@ -144,7 +144,7 @@ func (r *Repository) filteredAPICallLogQuery(filter APICallLogFilter) *gorm.DB {
query = query.Where("api_call_logs.request_kind = ?", "download")
case "all":
default:
query = visibleAPICallLogQuery(query).Where("COALESCE(api_call_logs.request_kind, '') <> ?", "download")
query = visibleAPICallLogQuery(query).Where("COALESCE(api_call_logs.request_kind, '') NOT IN ?", []string{"download", "upload", "local_save", "register"})
}
if value := strings.TrimSpace(filter.Keyword); value != "" {
pattern := "%" + strings.ToLower(value) + "%"
@@ -167,7 +167,7 @@ func (r *Repository) APICallLogTasks(ids []string) ([]model.Task, error) {
return []model.Task{}, nil
}
var tasks []model.Task
err := r.db.Select("id", "user_id", "type", "status", "result_json").Where("id IN ?", ids).Find(&tasks).Error
err := r.db.Select("id", "user_id", "type", "status", "result_json", "media_stage").Where("id IN ?", ids).Find(&tasks).Error
return tasks, err
}

View File

@@ -32,6 +32,9 @@ func TestAPICallLogRecordTypeFiltersListAndExport(t *testing.T) {
{ID: "poll", Capability: "video", RequestKind: "poll", CreatedAt: now},
{ID: "video-download", Capability: "video", RequestKind: "download", CreatedAt: now},
{ID: "image-download", Capability: "image", RequestKind: "download", CreatedAt: now},
{ID: "upload", Capability: "image", RequestKind: "upload", CreatedAt: now},
{ID: "local-save", Capability: "image", RequestKind: "local_save", CreatedAt: now},
{ID: "register", Capability: "image", RequestKind: "register", CreatedAt: now},
} {
if err := db.Create(&item).Error; err != nil {
t.Fatal(err)
@@ -41,7 +44,7 @@ func TestAPICallLogRecordTypeFiltersListAndExport(t *testing.T) {
for _, tc := range []struct {
kind string
count int
}{{"", 1}, {"request", 1}, {"download", 2}, {"all", 4}} {
}{{"", 1}, {"request", 1}, {"download", 2}, {"all", 7}} {
filter := APICallLogFilter{AnalyticsFilter: AnalyticsFilter{From: now.Add(-time.Hour), To: now.Add(time.Hour)}, RecordType: tc.kind}
logs, total, err := repo.QueryAPICallLogs(filter)
if err != nil {

View File

@@ -110,7 +110,7 @@ func (r *Repository) UserStorageUsage(userID string) (UserStorageUsage, error) {
(SELECT COUNT(*) FROM canvas_projects WHERE user_id = ?) AS canvas_count,
(SELECT COALESCE(SUM(length(CAST(COALESCE(payload_json, '') AS BLOB))), 0) FROM canvas_projects WHERE user_id = ?) AS canvas_bytes,
(SELECT COUNT(*) FROM tasks WHERE user_id = ?) AS task_count,
(SELECT COALESCE(SUM(length(CAST(COALESCE(prompt, '') AS BLOB)) + length(CAST(COALESCE(input_json, '') AS BLOB)) + length(CAST(COALESCE(result_json, '') AS BLOB)) + length(CAST(COALESCE(text_draft, '') AS BLOB)) + length(CAST(COALESCE(error, '') AS BLOB))), 0) FROM tasks WHERE user_id = ?)
(SELECT COALESCE(SUM(length(CAST(COALESCE(prompt, '') AS BLOB)) + length(CAST(COALESCE(input_json, '') AS BLOB)) + length(CAST(COALESCE(result_json, '') AS BLOB)) + length(CAST(COALESCE(media_recovery_json, '') AS BLOB)) + length(CAST(COALESCE(text_draft, '') AS BLOB)) + length(CAST(COALESCE(error, '') AS BLOB))), 0) FROM tasks WHERE user_id = ?)
+ (SELECT COALESCE(SUM(length(CAST(COALESCE(message, '') AS BLOB)) + length(CAST(COALESCE(payload, '') AS BLOB))), 0) FROM task_logs WHERE user_id = ?)
+ (SELECT COALESCE(SUM(length(CAST(COALESCE(url, '') AS BLOB)) + length(CAST(COALESCE(payload, '') AS BLOB))), 0) FROM results WHERE user_id = ?)
+ (SELECT COALESCE(SUM(byte_count), 0) FROM task_text_delta WHERE user_id = ?)
@@ -536,6 +536,12 @@ func (r *Repository) UpdateTaskProviderProgress(id string, progress int) error {
}
func (r *Repository) SaveTaskCompletion(task *model.Task, expected model.TaskStatus, results []model.Result) error {
return r.SaveTaskCompletionWithRegistration(task, expected, results, nil)
}
// Registration and terminal success commit together; a failed asset write leaves
// the task recoverable and never exposes a false success to the canvas.
func (r *Repository) SaveTaskCompletionWithRegistration(task *model.Task, expected model.TaskStatus, results []model.Result, register func(*Repository) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
updated := taskLeaseWriter(tx.Model(&model.Task{}), task.LeaseOwner).
Where("id = ? AND status = ?", task.ID, expected).
@@ -551,6 +557,9 @@ func (r *Repository) SaveTaskCompletion(task *model.Task, expected model.TaskSta
return err
}
}
if register != nil {
return register(New(tx))
}
return nil
})
}
@@ -696,7 +705,7 @@ func (r *Repository) Tasks(userID string, limit int, projectID string, activeOnl
if limit <= 0 || limit > 100 {
limit = 50
}
query := r.db.Select("id", "project_id", "type", "status", "stage", "progress", "prompt", "operation", "provider", "model", "input_json", "result_json", "billing_order_id", "provider_request_id", "provider_cancel_status", "provider_cancel_error", "provider_cancel_attempts", "provider_cancel_requested_at", "provider_cancelled_at", "provider_cancel_next_check_at", "attempts", "started_at", "completed_at", "created_at", "updated_at", "agent_run_id", "generation_id", "approval_id", "authorized_charge_microcredits", "execution_diagnostic_json", "cancellation_source", "cancellation_actor_id", "cancellation_requested_at").
query := r.db.Select("id", "project_id", "type", "status", "stage", "media_stage", "media_recovery_json", "progress", "prompt", "operation", "provider", "model", "input_json", "result_json", "billing_order_id", "provider_request_id", "provider_cancel_status", "provider_cancel_error", "provider_cancel_attempts", "provider_cancel_requested_at", "provider_cancelled_at", "provider_cancel_next_check_at", "attempts", "started_at", "completed_at", "created_at", "updated_at", "agent_run_id", "generation_id", "approval_id", "authorized_charge_microcredits", "execution_diagnostic_json", "cancellation_source", "cancellation_actor_id", "cancellation_requested_at").
Where("user_id = ?", userID)
if strings.TrimSpace(projectID) != "" {
query = query.Where("project_id = ?", strings.TrimSpace(projectID))

View File

@@ -0,0 +1,37 @@
package repository
import (
"time"
"infinite-canvas/backend/internal/model"
)
// The existing task lease fences every checkpoint write, including after cancellation.
func (r *Repository) SaveTaskMediaCheckpoint(task *model.Task, encrypted, stage string) error {
result := taskLeaseWriter(r.db.Model(&model.Task{}), task.LeaseOwner).
Where("id = ? AND user_id = ? AND status = ? AND route_run = ?", task.ID, task.UserID, model.TaskStatusRunning, task.RouteRun).
Updates(map[string]any{"media_recovery_json": encrypted, "media_stage": stage, "stage": "作品已生成,正在保存", "updated_at": time.Now()})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrTaskStateConflict
}
task.MediaRecoveryJSON, task.MediaStage = encrypted, stage
return nil
}
// Manual recovery requeues the same task and order, never a new generation.
func (r *Repository) RequeueTaskMediaRecovery(task *model.Task, encrypted string) error {
now := time.Now()
result := r.db.Model(&model.Task{}).
Where("id = ? AND user_id = ? AND status = ? AND media_recovery_json = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", task.ID, task.UserID, model.TaskStatusFailed, task.MediaRecoveryJSON, now).
Updates(map[string]any{"status": model.TaskStatusQueued, "stage": "等待恢复作品保存", "error": "", "completed_at": nil, "next_poll_at": nil, "lease_owner": "", "lease_expires_at": nil, "media_recovery_json": encrypted, "updated_at": now})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrTaskStateConflict
}
return nil
}