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

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

No files matched your search

+4 -2
View File
@@ -750,7 +750,7 @@ func (s *Service) LogAPICall(log model.ApiCallLog) error {
stdlog.Printf("provider billing request id update failed: billing_order_id=%s provider_request_id=%s error=%v", log.BillingOrderID, log.ProviderRequestID, err) stdlog.Printf("provider billing request id update failed: billing_order_id=%s provider_request_id=%s error=%v", log.BillingOrderID, log.ProviderRequestID, err)
} }
} }
if log.TaskID != "" { if log.TaskID != "" && (log.RequestKind == "create" || log.RequestKind == "poll" || log.RequestKind == "cancel") {
stage := log.RequestKind stage := log.RequestKind
var nextPollAt *time.Time var nextPollAt *time.Time
if stage == "create" && log.Status == model.ApiCallStatusSucceeded && log.ProviderRequestID != "" { if stage == "create" && log.Status == model.ApiCallStatusSucceeded && log.ProviderRequestID != "" {
@@ -798,7 +798,9 @@ func (s *Service) LogAPICall(log model.ApiCallLog) error {
} }
func (s *Service) mergeVideoAPICallLog(log model.ApiCallLog) (bool, error) { func (s *Service) mergeVideoAPICallLog(log model.ApiCallLog) (bool, error) {
if log.Capability != "video" || (log.RequestKind != "poll" && log.RequestKind != "download") { // Delivery is a separate outcome: a failed download must not rewrite a
// successful generation request as failed.
if log.Capability != "video" || log.RequestKind != "poll" {
return false, nil return false, nil
} }
if log.TaskID == "" && log.ProviderRequestID == "" { if log.TaskID == "" && log.ProviderRequestID == "" {
+2 -1
View File
@@ -246,7 +246,7 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error {
billingOrderIDs = append(billingOrderIDs, log.BillingOrderID) billingOrderIDs = append(billingOrderIDs, log.BillingOrderID)
} }
} }
if (log.Capability != "image" && log.Capability != "video") || log.TaskID == "" { if (log.Capability != "image" && log.Capability != "video" && log.Capability != "audio") || log.TaskID == "" {
continue continue
} }
if _, exists := seenTaskIDs[log.TaskID]; exists { if _, exists := seenTaskIDs[log.TaskID]; exists {
@@ -303,6 +303,7 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error {
} }
if task, exists := taskByID[logs[index].TaskID]; exists && task.UserID == logs[index].UserID { if task, exists := taskByID[logs[index].TaskID]; exists && task.UserID == logs[index].UserID {
logs[index].TaskStatus = task.Status logs[index].TaskStatus = task.Status
logs[index].MediaStage = task.MediaStage
previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type) previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type)
if canvasResourceID(previewURL) != "" { if canvasResourceID(previewURL) != "" {
logs[index].MediaPreviewURL = "/api/admin/api-logs/" + logs[index].ID + "/media" logs[index].MediaPreviewURL = "/api/admin/api-logs/" + logs[index].ID + "/media"
@@ -789,6 +789,9 @@ func finishProtocolResult(ctx context.Context, config providerConfig, mode strin
if len(references) == 0 { if len(references) == 0 {
return nil, errors.New("声明式协议已完成但没有返回媒体地址") return nil, errors.New("声明式协议已完成但没有返回媒体地址")
} }
if output, handled, err := recoverProtocolMedia(ctx, config, mode, references); handled {
return output, err
}
items := make([]interface{}, 0, len(references)) items := make([]interface{}, 0, len(references))
for _, reference := range references { for _, reference := range references {
var data []byte var data []byte
@@ -71,6 +71,9 @@ func (s *Service) queryFailedVideoTask(ctx context.Context, task *model.Task, cl
if task.Status != model.TaskStatusFailed { if task.Status != model.TaskStatusFailed {
return nil, BadAuthRequest("只能人工查询状态为失败的任务") return nil, BadAuthRequest("只能人工查询状态为失败的任务")
} }
if task.MediaRecoveryJSON != "" {
return nil, BadAuthRequest("该任务已生成作品,请在任务中心重试保存,无需重新查询生成")
}
if !strings.HasPrefix(task.Type, "canvas_video") && !strings.HasPrefix(task.Type, "video_") { if !strings.HasPrefix(task.Type, "canvas_video") && !strings.HasPrefix(task.Type, "video_") {
return nil, BadAuthRequest("该任务不是视频生成任务") return nil, BadAuthRequest("该任务不是视频生成任务")
} }
+5 -1
View File
@@ -491,6 +491,10 @@ func (s *Service) openResourceRange(userID string, resource *model.Resource, ran
} }
func (s *Service) storeResource(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string, forceLocal bool) (*model.Resource, bool, error) { func (s *Service) storeResource(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string, forceLocal bool) (*model.Resource, bool, error) {
return s.storeResourceWithWriter(userID, kind, fileName, mimeType, size, width, height, durationMs, body, uploadKey, forceLocal, s.storeResourceObject)
}
func (s *Service) storeResourceWithWriter(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string, forceLocal bool, writeObject func(*model.Resource, string, io.Reader) (string, error)) (*model.Resource, bool, error) {
if existing, err := s.resourceForUploadKey(userID, uploadKey); err != nil { if existing, err := s.resourceForUploadKey(userID, uploadKey); err != nil {
return nil, false, err return nil, false, err
} else if existing != nil { } else if existing != nil {
@@ -536,7 +540,7 @@ func (s *Service) storeResource(userID string, kind string, fileName string, mim
return nil, false, err return nil, false, err
} }
var etag string var etag string
etag, err = s.storeResourceObject(&resource, fileName, body) etag, err = writeObject(&resource, fileName, body)
resource.UpdatedAt = time.Now() resource.UpdatedAt = time.Now()
if err != nil { if err != nil {
resource.Status = model.ResourceStatusFailed resource.Status = model.ResourceStatusFailed
+12 -1
View File
@@ -150,7 +150,18 @@ func (s *Service) saveTaskCompletionWithinStorageQuota(task *model.Task, resultJ
completed.ResultJSON = string(resultJSON) completed.ResultJSON = string(resultJSON)
completed.InputJSON = publicInputJSON completed.InputJSON = publicInputJSON
completed.CompletedAt = ptr(time.Now()) completed.CompletedAt = ptr(time.Now())
if err := s.repo.SaveTaskCompletion(&completed, expectedStatus, results); err != nil { var register func(*repository.Repository) error
if task.MediaRecoveryJSON != "" {
completed.MediaStage = "completed"
register = func(repo *repository.Repository) error {
writer := &Service{repo: repo, dataDir: s.dataDir}
if err := writer.RegisterTaskOutputFromTask(completed); err != nil {
return err
}
return writer.registerRecoveredMediaAssets(completed)
}
}
if err := s.repo.SaveTaskCompletionWithRegistration(&completed, expectedStatus, results, register); err != nil {
return err return err
} }
*task = completed *task = completed
+1
View File
@@ -22,6 +22,7 @@ func (s *Service) processTask(ctx context.Context, task model.Task) (map[string]
task.InputJSON = decryptedInput task.InputJSON = decryptedInput
ctx = withTaskExecutionID(ctx, task.ID) ctx = withTaskExecutionID(ctx, task.ID)
ctx = withProviderAnalytics(ctx, s, task) ctx = withProviderAnalytics(ctx, s, task)
ctx = context.WithValue(ctx, mediaExecutionTaskKey{}, task)
if task.Type == "canvas_text" || task.Type == "canvas_image" || task.Type == "canvas_video" || task.Type == "canvas_audio" { if task.Type == "canvas_text" || task.Type == "canvas_image" || task.Type == "canvas_video" || task.Type == "canvas_audio" {
result, err := s.processCanvasGenerationTask(ctx, task.UserID, task.ProjectID, task.Type, task.Prompt, task.InputJSON) result, err := s.processCanvasGenerationTask(ctx, task.UserID, task.ProjectID, task.Type, task.Prompt, task.InputJSON)
+3
View File
@@ -39,6 +39,9 @@ func (w *taskLifecycleCoordinator) retryTask(userID string, id string) (*model.T
if err != nil { if err != nil {
return nil, err return nil, err
} }
if task.MediaRecoveryJSON != "" {
return nil, BadAuthRequest("作品已生成,请使用重试保存,不要重复生成")
}
if task.CreationSubmissionID != nil { if task.CreationSubmissionID != nil {
return nil, creationConflict("智能创作重做需要新的报价批准,请回到创作会话继续") return nil, creationConflict("智能创作重做需要新的报价批准,请回到创作会话继续")
} }
+129
View File
@@ -0,0 +1,129 @@
package app
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
)
// A deleted canvas/project must not be resurrected by an old download worker.
func (s *Service) mediaTaskProject(task *model.Task) (string, error) {
id := task.ProjectID
if id == "" {
return "", nil
}
canvas, err := s.repo.CanvasProjectForUser(task.UserID, id)
if err == nil {
id = canvas.ProjectID
if id == "" {
return "", nil
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
project, err := s.repo.ProjectForUser(task.UserID, id)
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", NotFound("任务所属画布或项目已不存在")
}
if err != nil {
return "", err
}
if project.Status == model.ProjectStatusArchived {
return "", BadAuthRequest("项目已归档,无法恢复作品")
}
return project.ID, nil
}
// Uses the same effect key and ID as browser materialization, so reconnecting
// the canvas cannot create a second asset for a server-delivered output.
func (s *Service) registerRecoveredMediaAssets(task model.Task) error {
projectID, err := s.mediaTaskProject(&task)
if err != nil {
return err
}
checkpoint, err := s.decodeMediaCheckpoint(&task)
if err != nil {
return err
}
representations, err := s.repo.AssetRepresentationsForTask(task.ID)
if err != nil {
return err
}
policy, err := s.RuntimePolicy()
if err != nil {
return err
}
for index, item := range checkpoint.Items {
registered := false
for _, representation := range representations {
if representation.ResourceID == item.ResourceID && representation.AssetVersionID != "" {
registered = true
break
}
}
if registered {
continue
}
resource, err := s.repo.ResourceForUser(task.UserID, item.ResourceID)
if err != nil {
return err
}
if resource.Status != model.ResourceStatusReady {
return errors.New("作品文件尚未保存完成")
}
effectKey := fmt.Sprintf("materialize:%s:%d", task.ID, index)
sum := sha256.Sum256([]byte(effectKey))
assetID := "generation_" + hex.EncodeToString(sum[:])
// Preserve existing user edits on replay; the deterministic key is unique.
if _, err := s.repo.AssetForUser(task.UserID, assetID); err == nil {
continue
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
now := time.Now()
url := resourceFileURL(resource.ID)
data := map[string]any{"storageKey": "resource:" + resource.ID, "mimeType": resource.MimeType, "bytes": resource.Size, "width": resource.Width, "height": resource.Height, "durationMs": resource.DurationMs}
if checkpoint.Mode == "image" {
data["dataUrl"] = url
} else {
data["url"] = url
}
title := "生成作品"
metadata := map[string]any{"source": "generation-task", "generationEffectKey": effectKey, "taskId": task.ID, "outputIndex": index}
if projectID != "" {
metadata["projectIds"] = []string{projectID}
}
payload, err := json.Marshal(map[string]any{"id": assetID, "kind": checkpoint.Mode, "category": model.AssetCategoryMaterial, "status": model.AssetVersionStatusConfirmed, "title": title, "coverUrl": url, "tags": []string{"生成"}, "createdAt": now.UTC().Format(time.RFC3339Nano), "updatedAt": now.UTC().Format(time.RFC3339Nano), "data": data, "metadata": metadata})
if err != nil {
return err
}
usage, err := s.repo.UserStorageUsage(task.UserID)
if err != nil {
return err
}
if err := validateStructuredStorageQuotaWithPolicy(usage, "asset", true, int64(len(payload)), policy.Resource); err != nil {
return err
}
asset := &model.Asset{ID: assetID, UserID: task.UserID, Kind: checkpoint.Mode, Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: title, PayloadJSON: string(payload), CreatedAt: now, UpdatedAt: now}
if err := s.repo.UpsertAsset(asset); err != nil {
return err
}
if projectID != "" {
position, err := s.repo.NextProjectAssetPosition(projectID, "")
if err != nil {
return err
}
_, err = s.repo.LinkProjectAsset(asset, nil, &model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, Position: position, CreatedAt: now})
if err != nil {
return err
}
}
}
return nil
}
+428
View File
@@ -0,0 +1,428 @@
package app
import (
"context"
"encoding/json"
"errors"
"math/rand/v2"
"os"
"path/filepath"
"strings"
"time"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/protocol"
"infinite-canvas/backend/internal/repository"
)
// Recovery is keyed by the task, not a second generation or billing order.
type mediaCheckpoint struct {
Mode string `json:"mode"`
Items []mediaCheckpointItem `json:"items"`
StartedAt time.Time `json:"startedAt"`
Attempts int `json:"attempts"`
ManualAttempts int `json:"manualAttempts"`
LastManualAt time.Time `json:"lastManualAt"`
}
type mediaCheckpointItem struct {
Reference protocol.MediaReference `json:"reference"`
TempName string `json:"tempName,omitempty"`
MIMEType string `json:"mimeType,omitempty"`
ResourceID string `json:"resourceId,omitempty"`
}
type mediaRecoveryError struct {
stage string
retryable bool
cause error
}
type mediaExecutionTaskKey struct{}
func (e *mediaRecoveryError) Error() string {
return "作品已生成,但保存未完成:" + mediaStageLabel(e.stage)
}
func (e *mediaRecoveryError) Unwrap() error { return e.cause }
var mediaRecoverySlots = make(chan struct{}, 2)
var mediaRecoveryDelays = []time.Duration{15 * time.Second, time.Minute, 3 * time.Minute, 10 * time.Minute}
func mediaStageLabel(stage string) string {
switch stage {
case "download":
return "下载结果失败"
case "upload":
return "上传 OSS 失败"
case "local_save":
return "保存本地文件失败"
case "register":
return "登记作品失败"
default:
return "记录恢复信息失败"
}
}
func (s *Service) decodeMediaCheckpoint(task *model.Task) (*mediaCheckpoint, error) {
plain, err := s.decryptSettingSecret(task.MediaRecoveryJSON)
if err != nil {
return nil, err
}
var checkpoint mediaCheckpoint
if err := json.Unmarshal([]byte(plain), &checkpoint); err != nil {
return nil, err
}
if len(checkpoint.Items) == 0 || len(checkpoint.Items) > 32 || (checkpoint.Mode != "image" && checkpoint.Mode != "video" && checkpoint.Mode != "audio") {
return nil, errors.New("作品恢复信息无效")
}
return &checkpoint, nil
}
func (s *Service) saveMediaCheckpoint(task *model.Task, checkpoint *mediaCheckpoint, stage string) error {
data, err := json.Marshal(checkpoint)
if err != nil {
return err
}
encoded, err := s.encryptSettingSecret(string(data))
if err != nil {
return err
}
return s.repo.SaveTaskMediaCheckpoint(task, encoded, stage)
}
func recoverProtocolMedia(ctx context.Context, config providerConfig, mode string, references []protocol.MediaReference) (map[string]interface{}, bool, error) {
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
if !ok || metadata.Service == nil || metadata.TaskID == "" {
return nil, false, nil
}
s := metadata.Service
executing, ok := ctx.Value(mediaExecutionTaskKey{}).(model.Task)
if !ok {
return nil, false, nil
}
task := &executing
var err error
// Manual video queries retain their existing recovery lease and completion path.
if task.Status != model.TaskStatusRunning {
return nil, false, nil
}
if len(references) == 0 || len(references) > 32 {
return nil, true, &mediaRecoveryError{stage: "checkpoint", cause: errors.New("生成结果数量超过恢复限制")}
}
if mode != "image" {
references = references[:1]
}
if task.MediaRecoveryJSON != "" {
result, err := s.resumeTaskMedia(ctx, task)
return result, true, err
}
checkpoint := &mediaCheckpoint{Mode: mode, StartedAt: time.Now()}
for _, reference := range references {
if len(reference.URL) > 16_384 {
return nil, true, &mediaRecoveryError{stage: "checkpoint", cause: errors.New("上游媒体地址过长")}
}
item := mediaCheckpointItem{Reference: protocol.MediaReference{URL: reference.URL, MIMEType: reference.MIMEType}}
// Inline bytes are staged, never stored as a large base64 DB checkpoint.
if reference.DataURL != "" {
mimeType, data, decodeErr := decodeProviderDataURL(reference.DataURL)
if decodeErr != nil {
return nil, true, &mediaRecoveryError{stage: "checkpoint", cause: decodeErr}
}
policy, policyErr := s.RuntimePolicy()
if policyErr != nil {
return nil, true, &mediaRecoveryError{stage: "checkpoint", cause: policyErr}
}
if int64(len(data)) > min(megabytes(policy.Resource.GeneratedFileMB), mediaStagingBudget) || !strings.HasPrefix(mimeType, mode+"/") {
return nil, true, &mediaRecoveryError{stage: "checkpoint", cause: errors.New("生成文件类型或大小不符合限制")}
}
item.TempName, err = s.stageInlineMedia(data)
if err != nil {
return nil, true, &mediaRecoveryError{stage: "checkpoint", cause: err}
}
item.MIMEType = mimeType
item.Reference.DataURL = ""
}
checkpoint.Items = append(checkpoint.Items, item)
}
if err := s.saveMediaCheckpoint(task, checkpoint, "download"); err != nil {
return nil, true, &mediaRecoveryError{stage: "checkpoint", cause: err}
}
result, err := s.materializeTaskMedia(ctx, task, config, checkpoint)
return result, true, err
}
func (s *Service) resumeTaskMedia(ctx context.Context, task *model.Task) (map[string]interface{}, error) {
checkpoint, err := s.decodeMediaCheckpoint(task)
if err != nil {
return nil, &mediaRecoveryError{stage: "checkpoint", cause: err}
}
raw, err := s.decryptTaskInputJSON(task.InputJSON)
if err != nil {
return nil, &mediaRecoveryError{stage: "checkpoint", cause: err}
}
var input canvasGenerationInput
if err := json.Unmarshal([]byte(raw), &input); err != nil {
return nil, &mediaRecoveryError{stage: "checkpoint", cause: err}
}
return s.materializeTaskMedia(withProviderAnalytics(ctx, s, *task), task, input.Config, checkpoint)
}
func (s *Service) materializeTaskMedia(ctx context.Context, task *model.Task, config providerConfig, checkpoint *mediaCheckpoint) (result map[string]interface{}, err error) {
defer func() {
var failure *mediaRecoveryError
if err != nil && !errors.As(err, &failure) {
err = &mediaRecoveryError{stage: task.MediaStage, cause: err}
}
}()
// Do not occupy a worker waiting for a download slot.
select {
case mediaRecoverySlots <- struct{}{}:
defer func() { <-mediaRecoverySlots }()
default:
return nil, &mediaRecoveryError{stage: task.MediaStage, retryable: true, cause: errMediaRecoveryBusy}
}
if _, err := s.mediaTaskProject(task); err != nil {
return nil, &mediaRecoveryError{stage: "register", cause: err}
}
items := make([]interface{}, 0, len(checkpoint.Items))
for index := range checkpoint.Items {
item := &checkpoint.Items[index]
if err := ctx.Err(); err != nil {
return nil, &mediaRecoveryError{stage: task.MediaStage, retryable: true, cause: err}
}
resource, err := s.resourceForUploadKey(task.UserID, mediaUploadKey(task.ID, index))
if err != nil {
return nil, &mediaRecoveryError{stage: "register", retryable: true, cause: err}
}
if resource == nil || resource.Status != model.ResourceStatusReady {
path := s.mediaTempPath(item.TempName)
info, statErr := os.Lstat(path)
if path == "" || statErr != nil || !info.Mode().IsRegular() {
if item.Reference.URL == "" {
return nil, &mediaRecoveryError{stage: "download", cause: errors.New("暂存结果已过期且上游没有可重新下载的地址")}
}
if err := s.saveMediaCheckpoint(task, checkpoint, "download"); err != nil {
return nil, err
}
config, err = s.resolveProviderConfig(config)
if err != nil {
return nil, &mediaRecoveryError{stage: "download", cause: err}
}
item.TempName, item.MIMEType, err = s.downloadTaskMedia(ctx, config, item.Reference.URL, checkpoint.Mode)
if err != nil {
return nil, &mediaRecoveryError{stage: "download", retryable: retryableMediaRecovery(err), cause: err}
}
path = s.mediaTempPath(item.TempName)
if err := s.saveMediaCheckpoint(task, checkpoint, "download"); err != nil {
return nil, err
}
}
stage := "local_save"
_, _, oss, settingErr := s.activeResourceOSSSetting(task.UserID)
if settingErr != nil {
return nil, &mediaRecoveryError{stage: "upload", cause: settingErr}
}
if oss || (resource != nil && resource.Provider != "local") {
stage = "upload"
}
if err := s.saveMediaCheckpoint(task, checkpoint, stage); err != nil {
return nil, err
}
started := time.Now()
resource, err = s.storeTaskMediaFile(task, index, path, item.MIMEType, checkpoint.Mode, resource)
s.logMediaStage(*task, stage, started, err)
if err != nil {
return nil, &mediaRecoveryError{stage: stage, retryable: retryableMediaRecovery(err), cause: err}
}
}
item.ResourceID = resource.ID
if err := s.saveMediaCheckpoint(task, checkpoint, "register"); err != nil {
return nil, err
}
url := resourceFileURL(resource.ID)
items = append(items, map[string]interface{}{"dataUrl": url, "url": url, "resourceId": resource.ID, "storageKey": "resource:" + resource.ID, "mimeType": resource.MimeType, "bytes": resource.Size, "width": resource.Width, "height": resource.Height, "durationMs": resource.DurationMs})
}
result = map[string]interface{}{"mode": checkpoint.Mode}
if checkpoint.Mode == "image" {
result["images"] = items
} else {
result[checkpoint.Mode] = items[0]
}
return result, nil
}
var errMediaRecoveryBusy = errors.New("作品保存并发已满")
func retryableMediaRecovery(err error) bool {
if retryableProtocolMediaDownload(err) || errors.Is(err, context.DeadlineExceeded) {
return true
}
var upstream providerHTTPError
return errors.As(err, &upstream) && (upstream.StatusCode == 429 || upstream.StatusCode == 408 || upstream.StatusCode >= 500)
}
// Called before the generic execution failure path, so result delivery cannot be
// mistaken for model failure or trigger a second generation.
func (s *Service) handleMediaRecoveryFailure(task *model.Task, cause error) error {
latest, err := s.repo.Task(task.ID)
if err != nil {
return err
}
if latest.Status != model.TaskStatusRunning || latest.LeaseOwner != task.LeaseOwner {
return repository.ErrTaskStateConflict
}
task.MediaRecoveryJSON, task.MediaStage = latest.MediaRecoveryJSON, latest.MediaStage
var failure *mediaRecoveryError
if !errors.As(cause, &failure) {
failure = &mediaRecoveryError{stage: task.MediaStage, retryable: true, cause: cause}
}
if task.MediaRecoveryJSON != "" {
checkpoint, decodeErr := s.decodeMediaCheckpoint(task)
if decodeErr != nil {
return decodeErr
}
if errors.Is(cause, errMediaRecoveryBusy) && time.Since(checkpoint.StartedAt) < 20*time.Minute {
return s.repo.DeferRunningTaskForProviderPoll(task.ID, task.LeaseOwner, "作品已生成,等待保存", 5*time.Second)
}
if failure.retryable && checkpoint.Attempts < len(mediaRecoveryDelays) && time.Since(checkpoint.StartedAt) < 20*time.Minute {
delay := mediaRecoveryDelays[checkpoint.Attempts] + time.Duration(rand.IntN(5_000))*time.Millisecond
var upstream providerHTTPError
if errors.As(cause, &upstream) {
delay = max(delay, min(upstream.RetryAfter, 10*time.Minute))
}
checkpoint.Attempts++
if err := s.saveMediaCheckpoint(task, checkpoint, failure.stage); err != nil {
return err
}
return s.repo.DeferRunningTaskForProviderPoll(task.ID, task.LeaseOwner, "作品已生成,保存遇到问题,正在自动恢复", delay)
}
if err := s.saveMediaCheckpoint(task, checkpoint, failure.stage); err != nil {
return err
}
}
// Billing belongs to the original generation. A delivery failure cannot refund
// or submit it again implicitly; the existing reconciliation path is retained.
return s.terminalCoordinator().handleResultPersistenceFailureError(task, failure)
}
func (s *Service) RecoverTaskMedia(userID, id string) (*model.Task, error) {
if s.IsDraining() {
return nil, BadAuthRequest("服务正在维护,请稍后重试保存")
}
task, err := s.repo.TaskForUser(userID, strings.TrimSpace(id))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, NotFound("任务不存在")
}
if err != nil {
return nil, err
}
if task.MediaRecoveryJSON == "" {
return nil, BadAuthRequest("该任务没有可恢复的作品,请先核对上游结果")
}
if task.Status == model.TaskStatusRunning || task.Status == model.TaskStatusQueued || task.Status == model.TaskStatusSucceeded {
return taskForOutput(*task), nil
}
if task.Status != model.TaskStatusFailed {
return nil, BadAuthRequest("仅保存失败的作品可以恢复")
}
if _, err := s.mediaTaskProject(task); err != nil {
return nil, err
}
if err := s.taskBilling().CheckRetryEligibility(task.BillingOrderID); err != nil && !errors.Is(err, errTaskBillingReview) {
return nil, err
}
// Never silently restore a refunded order (the old video recovery does that).
if task.BillingOrderID != "" {
order, err := s.repo.BillingOrder(task.BillingOrderID)
if err != nil {
return nil, err
}
if order.UserID != task.UserID || order.TaskID != task.ID {
return nil, BadAuthRequest("任务与计费订单归属不一致")
}
if order.Status == model.BillingStatusRefunded {
return nil, BadAuthRequest("原任务已退款,请联系管理员处理作品,不会自动重新扣费")
}
}
checkpoint, err := s.decodeMediaCheckpoint(task)
if err != nil {
return nil, err
}
if checkpoint.ManualAttempts >= 3 {
return nil, BadAuthRequest("已达到重试保存上限,请联系管理员核对渠道结果")
}
if time.Since(checkpoint.LastManualAt) < time.Minute {
return nil, BadAuthRequest("请稍后再试,重试保存间隔至少一分钟")
}
checkpoint.ManualAttempts++
checkpoint.LastManualAt = time.Now()
// Manual recovery is one attempt, not another full automatic retry budget.
checkpoint.Attempts = len(mediaRecoveryDelays)
data, err := json.Marshal(checkpoint)
if err != nil {
return nil, err
}
encrypted, err := s.encryptSettingSecret(string(data))
if err != nil {
return nil, err
}
if err := s.repo.RequeueTaskMediaRecovery(task, encrypted); err != nil {
if errors.Is(err, repository.ErrTaskStateConflict) {
if latest, readErr := s.repo.TaskForUser(userID, task.ID); readErr == nil && (latest.Status == model.TaskStatusQueued || latest.Status == model.TaskStatusRunning || latest.Status == model.TaskStatusSucceeded) {
return taskForOutput(*latest), nil
}
}
return nil, err
}
next, err := s.repo.TaskForUser(userID, task.ID)
if err != nil {
return nil, err
}
return taskForOutput(*next), nil
}
func (s *Service) logMediaStage(task model.Task, stage string, started time.Time, err error) {
status := model.ApiCallStatusSucceeded
code, message := "", ""
if err != nil {
status = model.ApiCallStatusFailed
code = "media_" + stage + "_failed"
message = mediaStageLabel(stage)
}
if logErr := s.LogAPICall(model.ApiCallLog{UserID: task.UserID, TaskID: task.ID, BillingOrderID: task.BillingOrderID, ProviderRequestID: task.ProviderRequestID, Source: "backend-task", Capability: capabilityFromTaskType(task.Type), Model: task.Model, RequestKind: stage, Method: "INTERNAL", Path: "task/media/" + stage, Status: status, ErrorCode: code, Error: message, DurationMs: time.Since(started).Milliseconds()}); logErr != nil {
_ = s.log(task.UserID, task.ID, "error", "记录作品保存阶段失败", logErr.Error())
}
}
func (s *Service) mediaTempPath(name string) string {
if !strings.HasPrefix(name, "result-") || filepath.Base(name) != name || strings.ContainsAny(name, `/\\`) {
return ""
}
return filepath.Join(s.dataDir, "media-staging", name)
}
func (c *taskTerminalCoordinator) handleResultPersistenceFailureError(task *model.Task, err error) error {
_, failure := c.handleResultPersistenceFailure(task, err)
return failure
}
func (s *Service) finishTaskMediaRecovery(task *model.Task, result map[string]interface{}, err error) error {
if err != nil {
return s.handleMediaRecoveryFailure(task, err)
}
started := time.Now()
if _, err = s.finalizeCharacterTurnaroundTask(*task, result); err == nil {
var encoded []byte
encoded, err = json.Marshal(result)
if err == nil {
err = s.saveTaskCompletionWithinStorageQuota(task, encoded, nil, false)
}
}
s.logMediaStage(*task, "register", started, err)
if err != nil {
return s.handleMediaRecoveryFailure(task, &mediaRecoveryError{stage: "register", cause: err})
}
s.cleanupMediaCheckpointFiles(task)
return s.terminalCoordinator().handleSuccess(task)
}
@@ -0,0 +1,411 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"image"
"image/png"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"gorm.io/gorm"
"infinite-canvas/backend/internal/database"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/protocol"
"infinite-canvas/backend/internal/repository"
)
func newMediaRecoveryTestService(t *testing.T) (*Service, *gorm.DB) {
t.Helper()
db := newSQLiteTestDB(t)
if err := database.MigrateSchema(db); err != nil {
t.Fatal(err)
}
return &Service{repo: repository.New(db), dataDir: t.TempDir(), activeCancels: make(map[string]context.CancelFunc)}, db
}
func mediaTestPNG(t *testing.T) []byte {
t.Helper()
var buffer bytes.Buffer
if err := png.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, 2, 3))); err != nil {
t.Fatal(err)
}
return buffer.Bytes()
}
func seedMediaTask(t *testing.T, db *gorm.DB, config providerConfig) *model.Task {
t.Helper()
input, err := json.Marshal(canvasGenerationInput{Mode: "image", Config: config, Prompt: "test"})
if err != nil {
t.Fatal(err)
}
expires := time.Now().Add(time.Hour)
task := &model.Task{ID: newID(), UserID: "media-user", Type: "canvas_image", Status: model.TaskStatusRunning, InputJSON: string(input), LeaseOwner: "worker-a", LeaseExpiresAt: &expires, RouteRun: 1, CreatedAt: time.Now()}
if err := db.Create(task).Error; err != nil {
t.Fatal(err)
}
return task
}
func TestMediaRecoveryDownloadFailureResumesSameTaskAfterRestart(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
s, db := newMediaRecoveryTestService(t)
picture := mediaTestPNG(t)
var downloads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("recovery submitted generation: %s", r.Method)
}
if downloads.Add(1) == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(picture)
}))
defer server.Close()
config := providerConfig{BaseURL: server.URL}
task := seedMediaTask(t, db, config)
ctx := context.WithValue(withProviderAnalytics(context.Background(), s, *task), mediaExecutionTaskKey{}, *task)
_, handled, failure := recoverProtocolMedia(ctx, config, "image", []protocol.MediaReference{{URL: server.URL + "/result?token=private-test-token"}})
if !handled || failure == nil {
t.Fatalf("handled=%v failure=%v", handled, failure)
}
if err := s.handleMediaRecoveryFailure(task, failure); err != nil {
t.Fatal(err)
}
saved, err := s.repo.Task(task.ID)
if err != nil {
t.Fatal(err)
}
if saved.Status != model.TaskStatusRunning || saved.NextPollAt == nil || saved.LeaseOwner != "" {
t.Fatalf("not deferred: %+v", saved)
}
if saved.MediaRecoveryJSON == "" || strings.Contains(saved.MediaRecoveryJSON, "private-test-token") {
t.Fatal("missing/encrypted checkpoint")
}
public, _ := json.Marshal(taskForOutput(*saved))
if bytes.Contains(public, []byte("mediaRecovery")) || bytes.Contains(public, []byte("private-test-token")) {
t.Fatal("checkpoint leaked")
}
if err := db.Model(&model.Task{}).Where("id = ?", task.ID).Update("next_poll_at", time.Now().Add(-time.Second)).Error; err != nil {
t.Fatal(err)
}
// A fresh service has no in-memory state or downloaded bytes from generation.
restarted := &Service{repo: s.repo, dataDir: s.dataDir, activeCancels: make(map[string]context.CancelFunc)}
claimed, err := restarted.repo.ClaimNextTask("worker-b", time.Minute)
if err != nil || claimed == nil {
t.Fatalf("claim: %v", err)
}
if err := restarted.taskWorker().processClaimedTask(claimed, nil); err != nil {
t.Fatal(err)
}
completed, _ := s.repo.Task(task.ID)
if completed.Status != model.TaskStatusSucceeded || completed.MediaStage != "completed" {
t.Fatalf("not delivered: %s %s", completed.Status, completed.MediaStage)
}
if downloads.Load() != 2 {
t.Fatalf("downloads=%d", downloads.Load())
}
var assets []model.Asset
if err := db.Find(&assets, "user_id = ?", task.UserID).Error; err != nil {
t.Fatal(err)
}
if len(assets) != 1 || !strings.HasPrefix(assets[0].ID, "generation_") {
t.Fatalf("assets: %+v", assets)
}
if err := restarted.registerRecoveredMediaAssets(*completed); err != nil {
t.Fatal(err)
}
var count int64
db.Model(&model.Asset{}).Where("user_id = ?", task.UserID).Count(&count)
if count != 1 {
t.Fatalf("duplicate assets: %d", count)
}
db.Model(&model.BillingOrder{}).Count(&count)
if count != 0 {
t.Fatal("recovery created a billing order")
}
files, err := os.ReadDir(filepath.Join(s.dataDir, "media-staging"))
if err != nil || len(files) != 0 {
t.Fatalf("staging not cleaned: %v %v", files, err)
}
}
func TestMediaRecoveryUploadFailureReusesStagedFileAndResource(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
s, db := newMediaRecoveryTestService(t)
seedOSSEnabled(t, db, "media-user", "http://127.0.0.1:1")
task := seedMediaTask(t, db, providerConfig{})
temp, err := s.stageInlineMedia(mediaTestPNG(t))
if err != nil {
t.Fatal(err)
}
checkpoint := &mediaCheckpoint{Mode: "image", StartedAt: time.Now(), Items: []mediaCheckpointItem{{TempName: temp, MIMEType: "image/png"}}}
if err := s.saveMediaCheckpoint(task, checkpoint, "upload"); err != nil {
t.Fatal(err)
}
_, failure := s.materializeTaskMedia(context.Background(), task, providerConfig{}, checkpoint)
var delivery *mediaRecoveryError
if !errors.As(failure, &delivery) || delivery.stage != "upload" {
t.Fatalf("expected OSS failure, got %v", failure)
}
resource, err := s.resourceForUploadKey(task.UserID, mediaUploadKey(task.ID, 0))
if err != nil || resource == nil || resource.Status == model.ResourceStatusReady || resource.Provider == "local" {
t.Fatalf("silently degraded upload: %+v %v", resource, err)
}
if _, err := os.Stat(s.mediaTempPath(temp)); err != nil {
t.Fatal("upload failure discarded download", err)
}
// Simulate fixing storage configuration on the same existing object identity.
resource.Provider, resource.ObjectKey = "local", "recovered/picture.png"
if err := s.repo.SaveResource(resource); err != nil {
t.Fatal(err)
}
output, err := s.resumeTaskMedia(context.Background(), task)
if err != nil {
t.Fatal(err)
}
if err := s.finishTaskMediaRecovery(task, output, nil); err != nil {
t.Fatal(err)
}
ready, _ := s.resourceForUploadKey(task.UserID, mediaUploadKey(task.ID, 0))
if ready.ID != resource.ID || ready.Status != model.ResourceStatusReady {
t.Fatal("resource identity changed")
}
var count int64
db.Model(&model.Resource{}).Where("user_id = ?", task.UserID).Count(&count)
if count != 1 {
t.Fatalf("duplicate resource count=%d", count)
}
}
func TestMediaRecoveryRegistrationRollbackAndRetryWithoutDownload(t *testing.T) {
s, db := newMediaRecoveryTestService(t)
task := seedMediaTask(t, db, providerConfig{})
temp, err := s.stageInlineMedia(mediaTestPNG(t))
if err != nil {
t.Fatal(err)
}
checkpoint := &mediaCheckpoint{Mode: "image", StartedAt: time.Now(), Items: []mediaCheckpointItem{{TempName: temp, MIMEType: "image/png"}}}
if err := s.saveMediaCheckpoint(task, checkpoint, "local_save"); err != nil {
t.Fatal(err)
}
output, err := s.materializeTaskMedia(context.Background(), task, providerConfig{}, checkpoint)
if err != nil {
t.Fatal(err)
}
encoded, _ := json.Marshal(output)
if err := db.Exec(`CREATE TRIGGER reject_media_asset BEFORE INSERT ON assets BEGIN SELECT RAISE(ABORT, 'injected asset failure'); END`).Error; err != nil {
t.Fatal(err)
}
if err := s.saveTaskCompletionWithinStorageQuota(task, encoded, nil, false); err == nil {
t.Fatal("registration failure was ignored")
}
saved, _ := s.repo.Task(task.ID)
if saved.Status != model.TaskStatusRunning || saved.ResultJSON != "" {
t.Fatal("false succeeded task escaped transaction")
}
if err := db.Exec(`DROP TRIGGER reject_media_asset`).Error; err != nil {
t.Fatal(err)
}
if err := os.Remove(s.mediaTempPath(temp)); err != nil {
t.Fatal(err)
}
output, err = s.resumeTaskMedia(context.Background(), saved)
if err != nil {
t.Fatal("ready resource must not need staged file or URL", err)
}
if err := s.finishTaskMediaRecovery(saved, output, nil); err != nil {
t.Fatal(err)
}
}
func TestMediaRecoveryFencesLeasesCancellationAndManualRetries(t *testing.T) {
s, db := newMediaRecoveryTestService(t)
task := seedMediaTask(t, db, providerConfig{})
checkpoint := &mediaCheckpoint{Mode: "image", StartedAt: time.Now(), Items: []mediaCheckpointItem{{Reference: protocol.MediaReference{URL: "https://example.test/result"}}}}
if err := s.saveMediaCheckpoint(task, checkpoint, "download"); err != nil {
t.Fatal(err)
}
stale := *task
stale.LeaseOwner = "obsolete-worker"
if err := s.saveMediaCheckpoint(&stale, checkpoint, "upload"); !errors.Is(err, repository.ErrTaskStateConflict) {
t.Fatalf("stale write: %v", err)
}
if err := db.Model(task).Updates(map[string]any{"status": model.TaskStatusFailed, "lease_owner": "", "lease_expires_at": nil}).Error; err != nil {
t.Fatal(err)
}
if _, err := s.RecoverTaskMedia("other-user", task.ID); err == nil {
t.Fatal("cross-user recovery")
}
queued, err := s.RecoverTaskMedia(task.UserID, task.ID)
if err != nil || queued.ID != task.ID {
t.Fatalf("manual recovery: %v", err)
}
duplicate, err := s.RecoverTaskMedia(task.UserID, task.ID)
if err != nil || duplicate.ID != task.ID {
t.Fatalf("duplicate click: %v", err)
}
if err := db.Model(task).Update("status", model.TaskStatusCancelled).Error; err != nil {
t.Fatal(err)
}
if _, err := s.RecoverTaskMedia(task.UserID, task.ID); err == nil {
t.Fatal("cancelled task revived")
}
if err := s.saveMediaCheckpoint(task, checkpoint, "register"); !errors.Is(err, repository.ErrTaskStateConflict) {
t.Fatalf("cancelled write: %v", err)
}
}
func TestMediaRecoveryDoesNotFailOverGenerationRoute(t *testing.T) {
p := &taskRouteExecutionPortStub{nextAttempts: map[string]*model.RouteAttempt{"first": {RouteID: "second"}}, processResults: map[string]taskRouteExecutionStubResult{"first": {err: &mediaRecoveryError{stage: "download", retryable: true, cause: errors.New("connection reset")}}}}
result, err := (&taskRouteExecutor{port: p}).execute(context.Background(), &model.Task{RouteID: "first"}, &model.RouteAttempt{RouteID: "first"})
if err != nil || !result.providerSucceeded || len(p.processCalls) != 1 || p.finished[0] != "first:succeeded" {
t.Fatalf("delivery entered model failover: %+v %v", result, err)
}
}
func TestMediaRecoveryTerminalStagesLimitsAndPublicList(t *testing.T) {
s, db := newMediaRecoveryTestService(t)
for _, tc := range []struct {
name string
attempts int
age time.Duration
failure *mediaRecoveryError
}{
{"permanent", 0, 0, &mediaRecoveryError{stage: "upload", cause: providerHTTPError{StatusCode: 403}}},
{"budget", 4, 0, &mediaRecoveryError{stage: "download", retryable: true, cause: errors.New("reset")}},
{"expired", 0, time.Hour, &mediaRecoveryError{stage: "register", retryable: true, cause: errors.New("database unavailable")}},
} {
t.Run(tc.name, func(t *testing.T) {
task := seedMediaTask(t, db, providerConfig{})
checkpoint := &mediaCheckpoint{Mode: "image", StartedAt: time.Now().Add(-tc.age), Attempts: tc.attempts, Items: []mediaCheckpointItem{{Reference: protocol.MediaReference{URL: "https://example.test/private"}}}}
if err := s.saveMediaCheckpoint(task, checkpoint, "download"); err != nil {
t.Fatal(err)
}
if err := s.handleMediaRecoveryFailure(task, tc.failure); err == nil {
t.Fatal("terminal failure swallowed")
}
saved, err := s.repo.Task(task.ID)
if err != nil || saved.Status != model.TaskStatusFailed || saved.MediaStage != tc.failure.stage {
t.Fatalf("terminal stage: %+v %v", saved, err)
}
if !strings.Contains(saved.Error, "作品已生成") {
t.Fatalf("wrong user message: %s", saved.Error)
}
listed, err := s.repo.Tasks(task.UserID, 100, "", false)
if err != nil {
t.Fatal(err)
}
found := false
for _, item := range taskSummariesForOutput(listed) {
if item.ID == task.ID {
found = item.CanRecoverMedia && item.MediaStage == tc.failure.stage
}
}
if !found {
t.Fatal("list lost recovery capability")
}
})
}
}
func TestMediaRecoveryManualGuardsDoNotChangeBilling(t *testing.T) {
for _, name := range []string{"refunded", "wrong-owner", "limit", "cooldown", "deleted-project"} {
t.Run(name, func(t *testing.T) {
s, db := newMediaRecoveryTestService(t)
task := seedMediaTask(t, db, providerConfig{})
checkpoint := &mediaCheckpoint{Mode: "image", StartedAt: time.Now(), Items: []mediaCheckpointItem{{Reference: protocol.MediaReference{URL: "https://example.test/result"}}}}
order := model.BillingOrder{ID: newID(), TaskID: task.ID, UserID: task.UserID, IdempotencyKey: newID(), Status: model.BillingStatusUncertain, ReservedAmountMicrocredits: 100}
switch name {
case "refunded":
order.Status = model.BillingStatusRefunded
case "wrong-owner":
order.UserID = "another-user"
case "limit":
checkpoint.ManualAttempts = 3
case "cooldown":
checkpoint.LastManualAt = time.Now()
case "deleted-project":
task.ProjectID = "deleted-project"
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := s.saveMediaCheckpoint(task, checkpoint, "upload"); err != nil {
t.Fatal(err)
}
if err := db.Model(task).Updates(map[string]any{"status": model.TaskStatusFailed, "lease_owner": "", "lease_expires_at": nil, "billing_order_id": order.ID, "project_id": task.ProjectID}).Error; err != nil {
t.Fatal(err)
}
if _, err := s.RecoverTaskMedia(task.UserID, task.ID); err == nil {
t.Fatal("guard bypassed")
}
saved, _ := s.repo.Task(task.ID)
if saved.Status != model.TaskStatusFailed {
t.Fatal("guard changed task")
}
unchanged, err := s.repo.BillingOrder(order.ID)
if err != nil || unchanged.Status != order.Status || unchanged.ReservedAmountMicrocredits != 100 {
t.Fatalf("billing changed: %+v %v", unchanged, err)
}
})
}
}
func TestMediaRecoveryVideoDownloadDoesNotRewriteGenerationLog(t *testing.T) {
s, db := newMediaRecoveryTestService(t)
task := seedMediaTask(t, db, providerConfig{})
for _, item := range []model.ApiCallLog{
{ID: "media-create", UserID: task.UserID, TaskID: task.ID, Capability: "video", RequestKind: "create", Method: "POST", Billable: true, Status: model.ApiCallStatusSucceeded, StatusCode: 200},
{ID: "media-download", UserID: task.UserID, TaskID: task.ID, Capability: "video", RequestKind: "download", Method: "GET", Status: model.ApiCallStatusFailed, StatusCode: 503},
} {
if err := s.LogAPICall(item); err != nil {
t.Fatal(err)
}
}
var logs []model.ApiCallLog
if err := db.Where("task_id = ?", task.ID).Order("id").Find(&logs).Error; err != nil {
t.Fatal(err)
}
if len(logs) != 2 || logs[0].Status != model.ApiCallStatusSucceeded || logs[1].Status != model.ApiCallStatusFailed || logs[1].Billable {
t.Fatalf("merged delivery into generation: %+v", logs)
}
}
func TestMediaRecoveryExpiredLeaseAndStagingLimits(t *testing.T) {
s, db := newMediaRecoveryTestService(t)
task := seedMediaTask(t, db, providerConfig{})
checkpoint := &mediaCheckpoint{Mode: "image", StartedAt: time.Now(), Items: []mediaCheckpointItem{{}}}
if err := db.Model(task).Update("lease_expires_at", time.Now().Add(-time.Second)).Error; err != nil {
t.Fatal(err)
}
if err := s.saveMediaCheckpoint(task, checkpoint, "download"); !errors.Is(err, repository.ErrTaskStateConflict) {
t.Fatalf("expired lease wrote: %v", err)
}
for _, name := range []string{"../result-secret", "other", "", "result-dir/file"} {
if s.mediaTempPath(name) != "" {
t.Fatalf("unsafe staging name %s", name)
}
}
if _, err := s.newMediaTemp(mediaStagingBudget + 1); err == nil {
t.Fatal("oversized reservation accepted")
}
file, err := s.newMediaTemp(mediaStagingBudget)
if err != nil {
t.Fatal(err)
}
defer file.Close()
if _, err := s.newMediaTemp(1); err == nil {
t.Fatal("staging budget exceeded")
}
}
+301
View File
@@ -0,0 +1,301 @@
package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"infinite-canvas/backend/internal/model"
)
const mediaStagingBudget int64 = 512 << 20
const mediaStagingTTL = 24 * time.Hour
var mediaStagingMu sync.Mutex
// Reserve the maximum response size before opening a file. Temporary files are
// sparse reservations, so concurrent downloads cannot oversubscribe this budget.
func (s *Service) newMediaTemp(limit int64) (*os.File, error) {
mediaStagingMu.Lock()
defer mediaStagingMu.Unlock()
dir := filepath.Join(s.dataDir, "media-staging")
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var used int64
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "result-") || !entry.Type().IsRegular() {
continue
}
info, err := entry.Info()
if err != nil {
return nil, err
}
if time.Since(info.ModTime()) > mediaStagingTTL {
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil {
return nil, err
}
continue
}
used += info.Size()
}
if limit <= 0 || limit > mediaStagingBudget || used > mediaStagingBudget-limit {
return nil, errors.New("作品暂存空间不足,请稍后重试保存")
}
file, err := os.CreateTemp(dir, "result-*")
if err != nil {
return nil, err
}
if err := file.Truncate(limit); err != nil {
file.Close()
os.Remove(file.Name())
return nil, err
}
return file, nil
}
func (s *Service) stageInlineMedia(data []byte) (string, error) {
if len(data) == 0 {
return "", errors.New("上游返回空作品")
}
file, err := s.newMediaTemp(int64(len(data)))
if err != nil {
return "", err
}
_, err = file.Write(data)
if err == nil {
err = file.Sync()
}
closeErr := file.Close()
if err == nil {
err = closeErr
}
if err != nil {
os.Remove(file.Name())
return "", err
}
return filepath.Base(file.Name()), nil
}
func (s *Service) downloadTaskMedia(ctx context.Context, config providerConfig, url, mode string) (name, mimeType string, err error) {
policy, err := s.RuntimePolicy()
if err != nil {
return "", "", err
}
limit := min(megabytes(policy.Resource.GeneratedFileMB), mediaStagingBudget)
downloadCtx, cancel := context.WithTimeout(withProviderRequestKind(ctx, "download"), 3*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(downloadCtx, http.MethodGet, url, nil)
if err != nil {
return "", "", err
}
started := time.Now()
statusCode := 0
defer func() {
// Signed paths and net/url errors can contain bearer credentials. Keep
// the real error for retry classification, never in request logs.
logged := req.Clone(req.Context())
redactedURL := *req.URL
redactedURL.Path, redactedURL.RawPath, redactedURL.RawQuery, redactedURL.Fragment = "/task-media", "", "", ""
redactedURL.User = nil
logged.URL = &redactedURL
var safeErr error
if err != nil {
safeErr = errors.New("结果下载失败")
}
recordProviderRequest(logged, started, statusCode, nil, safeErr)
}()
if _, err = ValidateOutboundURL(url); err != nil {
return "", "", err
}
if sameProviderOrigin(config.BaseURL, url) {
applyProviderAuth(req, config)
ApplyOutboundHeaders(req, config.Headers)
}
ApplyDefaultOutboundHeaders(req)
response, err := OutboundHTTPClient(3 * time.Minute).Do(req)
if err != nil {
return "", "", err
}
defer response.Body.Close()
statusCode = response.StatusCode
if statusCode < 200 || statusCode >= 300 {
return "", "", providerHTTPError{StatusCode: statusCode, Status: response.Status, RetryAfter: parseRetryAfter(response.Header.Get("Retry-After"), time.Now())}
}
if response.ContentLength > limit {
return "", "", errors.New("生成文件超过大小限制")
}
if response.ContentLength > 0 {
limit = response.ContentLength
}
file, err := s.newMediaTemp(limit)
if err != nil {
return "", "", err
}
defer func() {
_ = file.Close()
if err != nil {
_ = os.Remove(file.Name())
}
}()
size, err := io.Copy(file, io.LimitReader(response.Body, limit+1))
if err != nil {
return "", "", err
}
if size == 0 || size > limit {
return "", "", errors.New("生成文件为空或超过大小限制")
}
if response.ContentLength >= 0 && size != response.ContentLength {
return "", "", io.ErrUnexpectedEOF
}
if err = file.Truncate(size); err != nil {
return "", "", err
}
if _, err = file.Seek(0, io.SeekStart); err != nil {
return "", "", err
}
head := make([]byte, min(size, 512))
if _, err = io.ReadFull(file, head); err != nil {
return "", "", err
}
mimeType = normalizedMediaMimeType(response.Header.Get("Content-Type"), head)
if mode == "audio" && mimeType == "application/ogg" {
mimeType = "audio/ogg"
}
if !strings.HasPrefix(mimeType, mode+"/") {
return "", "", errors.New("上游返回的内容不是有效的" + mode + "媒体")
}
if err = file.Sync(); err != nil {
return "", "", err
}
if err = file.Close(); err != nil {
return "", "", err
}
return filepath.Base(file.Name()), mimeType, nil
}
func mediaUploadKey(taskID string, index int) *string {
sum := sha256.Sum256([]byte(fmt.Sprintf("task-media:%s:%d", taskID, index)))
key := hex.EncodeToString(sum[:])
return &key
}
func (s *Service) storeTaskMediaFile(task *model.Task, index int, path, mimeType, kind string, existing *model.Resource) (*model.Resource, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, errors.New("暂存作品不是普通文件")
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
info, err = file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, errors.New("暂存作品不是普通文件")
}
width, height := 0, 0
if kind == "image" {
config, _, err := image.DecodeConfig(file)
if err != nil {
// The standard library does not decode WebP/AVIF. Verify their
// container signatures instead of trusting a response MIME header.
head := make([]byte, 64)
n, _ := file.ReadAt(head, 0)
head = head[:n]
webp := mimeType == "image/webp" && n >= 12 && string(head[:4]) == "RIFF" && string(head[8:12]) == "WEBP"
avif := mimeType == "image/avif" && n >= 16 && string(head[4:8]) == "ftyp" && (strings.Contains(string(head[8:]), "avif") || strings.Contains(string(head[8:]), "avis"))
if !webp && !avif {
return nil, fmt.Errorf("图片内容校验失败:%w", err)
}
}
width, height = config.Width, config.Height
if _, err := file.Seek(0, io.SeekStart); err != nil {
return nil, err
}
}
day, err := s.reserveGeneratedResourceQuota(task.UserID, info.Size())
if err != nil {
return nil, err
}
committed := false
defer func() {
if !committed {
s.releaseUserUploadQuota(task.UserID, day, info.Size())
}
}()
var resource *model.Resource
if existing == nil {
resource, _, err = s.storeResourceWithWriter(task.UserID, kind, "generated."+extensionFromMimeType(mimeType), mimeType, info.Size(), width, height, 0, file, mediaUploadKey(task.ID, index), false, s.storeTaskMediaObject)
} else {
// Reuse the same object key after a failed upload; no orphan per retry.
resource = existing
if resource.UserID != task.UserID || resource.Size != info.Size() || resource.MimeType != mimeType {
return nil, errors.New("恢复文件与原始资源不一致")
}
resource.ETag, err = s.storeTaskMediaObject(resource, "generated."+extensionFromMimeType(mimeType), file)
if err == nil {
resource.Status, resource.Error, resource.UpdatedAt = model.ResourceStatusReady, "", time.Now()
err = s.repo.SaveResource(resource)
}
}
if err != nil {
return nil, err
}
s.commitUserUploadQuota(task.UserID, info.Size())
committed = true
if existing != nil {
s.recordActivity(task.UserID, "resource", 1)
s.maybeStartPlaybackTranscode(resource)
}
return resource, nil
}
// Unlike ordinary uploads, generated output preserves an OSS failure for
// explicit recovery instead of silently changing the storage destination.
func (s *Service) storeTaskMediaObject(resource *model.Resource, _ string, body io.Reader) (string, error) {
if resource.Provider == "local" {
return "", writeLocalResourceObject(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey)), body)
}
setting, err := s.ossSettingForResource(resource.UserID, resource)
if err != nil {
return "", err
}
return putOSSObject(setting, resource.ObjectKey, resource.MimeType, resource.Size, body)
}
// Cleanup is best effort only after durable completion. Expired leftovers are
// also collected on staging allocation, never by deleting resource objects.
func (s *Service) cleanupMediaCheckpointFiles(task *model.Task) {
checkpoint, err := s.decodeMediaCheckpoint(task)
if err != nil {
return
}
for _, item := range checkpoint.Items {
if path := s.mediaTempPath(item.TempName); path != "" {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
_ = s.log(task.UserID, task.ID, "warn", "清理作品暂存文件失败", "")
}
}
}
}
+5
View File
@@ -17,6 +17,8 @@ type TaskSummary struct {
Type string `json:"type"` Type string `json:"type"`
Status model.TaskStatus `json:"status"` Status model.TaskStatus `json:"status"`
Stage string `json:"stage"` Stage string `json:"stage"`
MediaStage string `json:"mediaStage,omitempty"`
CanRecoverMedia bool `json:"canRecoverMedia,omitempty"`
Progress int `json:"progress"` Progress int `json:"progress"`
Prompt string `json:"prompt"` Prompt string `json:"prompt"`
Operation string `json:"operation,omitempty"` Operation string `json:"operation,omitempty"`
@@ -107,6 +109,8 @@ func taskSummaryForOutput(task model.Task) TaskSummary {
Type: task.Type, Type: task.Type,
Status: task.Status, Status: task.Status,
Stage: task.Stage, Stage: task.Stage,
MediaStage: task.MediaStage,
CanRecoverMedia: task.MediaRecoveryJSON != "" && task.Status == model.TaskStatusFailed,
Progress: task.Progress, Progress: task.Progress,
Prompt: truncateRunes(task.Prompt, 500), Prompt: truncateRunes(task.Prompt, 500),
Operation: task.Operation, Operation: task.Operation,
@@ -285,6 +289,7 @@ func truncateRunes(value string, limit int) string {
} }
func taskForOutput(task model.Task) *model.Task { func taskForOutput(task model.Task) *model.Task {
task.CanRecoverMedia = task.MediaRecoveryJSON != "" && task.Status == model.TaskStatusFailed
task.Diagnostic = taskExecutionDiagnostic(&task) task.Diagnostic = taskExecutionDiagnostic(&task)
task.InputJSON = publicTaskInputJSON(task.InputJSON) task.InputJSON = publicTaskInputJSON(task.InputJSON)
// 普通任务接口只暴露前台模型身份;渠道模型和供应线路属于管理员内部信息。 // 普通任务接口只暴露前台模型身份;渠道模型和供应线路属于管理员内部信息。
@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"errors"
"infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/model"
) )
@@ -51,6 +52,13 @@ func (e *taskRouteExecutor) execute(ctx context.Context, task *model.Task, attem
if stateErr := e.port.refreshTaskProviderState(task); stateErr != nil { if stateErr := e.port.refreshTaskProviderState(task); stateErr != nil {
return taskRouteExecutionResult{}, stateErr return taskRouteExecutionResult{}, stateErr
} }
var deliveryFailure *mediaRecoveryError
if errors.As(execution.err, &deliveryFailure) {
// The provider succeeded. Delivery must never enter route failover.
e.port.finishTaskRouteAttempt(attempt, task, nil)
execution.providerSucceeded = true
return execution, nil
}
e.port.finishTaskRouteAttempt(attempt, task, execution.err) e.port.finishTaskRouteAttempt(attempt, task, execution.err)
if execution.err == nil { if execution.err == nil {
break break
+1 -1
View File
@@ -218,7 +218,7 @@ func (c *taskTerminalCoordinator) handleSuccess(task *model.Task) error {
if fetchErr != nil { if fetchErr != nil {
completionErr = fmt.Errorf("任务成功后读取任务产物失败:%w", fetchErr) completionErr = fmt.Errorf("任务成功后读取任务产物失败:%w", fetchErr)
_ = c.logger.log(task.UserID, task.ID, "error", "任务成功但读取任务产物失败", fetchErr.Error()) _ = c.logger.log(task.UserID, task.ID, "error", "任务成功但读取任务产物失败", fetchErr.Error())
} else { } else if completedTask.MediaRecoveryJSON == "" {
if registerErr := c.outputs.RegisterTaskOutputFromTask(*completedTask); registerErr != nil { if registerErr := c.outputs.RegisterTaskOutputFromTask(*completedTask); registerErr != nil {
// 任务成功与产物登记分开记账;登记失败保持步骤异常,允许项目页幂等补登记。 // 任务成功与产物登记分开记账;登记失败保持步骤异常,允许项目页幂等补登记。
_ = c.logger.log(task.UserID, task.ID, "error", "任务成功但项目产物登记失败", registerErr.Error()) _ = c.logger.log(task.UserID, task.ID, "error", "任务成功但项目产物登记失败", registerErr.Error())
+13
View File
@@ -185,6 +185,10 @@ func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task, globalSlot
if task.Type == model.TaskTypeTimelineRender { if task.Type == model.TaskTypeTimelineRender {
return w.processTimelineRender(task, ctx) return w.processTimelineRender(task, ctx)
} }
if task.MediaRecoveryJSON != "" {
result, recoveryErr := s.resumeTaskMedia(ctx, task)
return s.finishTaskMediaRecovery(task, result, recoveryErr)
}
s.markAgentMemoryCompactRunning(*task) s.markAgentMemoryCompactRunning(*task)
task.Stage = "调用生成模型" task.Stage = "调用生成模型"
@@ -215,6 +219,15 @@ func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task, globalSlot
default: default:
} }
result, canvasOps, err := routeResult.result, routeResult.canvasOps, routeResult.err result, canvasOps, err := routeResult.result, routeResult.canvasOps, routeResult.err
latestMedia, readErr := s.repo.Task(task.ID)
if readErr != nil {
return readErr
}
task.MediaRecoveryJSON, task.MediaStage = latestMedia.MediaRecoveryJSON, latestMedia.MediaStage
var deliveryFailure *mediaRecoveryError
if task.MediaRecoveryJSON != "" || errors.As(err, &deliveryFailure) {
return s.finishTaskMediaRecovery(task, result, err)
}
providerSucceeded := routeResult.providerSucceeded providerSucceeded := routeResult.providerSucceeded
if err == nil { if err == nil {
result, err = s.persistGeneratedMediaResult(task.UserID, result) result, err = s.persistGeneratedMediaResult(task.UserID, result)
+11 -1
View File
@@ -11,7 +11,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const CurrentSchemaVersion int64 = 33 const CurrentSchemaVersion int64 = 34
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
@@ -107,6 +107,16 @@ var schemaMigrations = []migration{
}}, }},
{version: 32, name: "channel_model_tags", checksum: "sha256:channel-model-tags-v32", apply: migrateChannelModelTags}, {version: 32, name: "channel_model_tags", checksum: "sha256:channel-model-tags-v32", apply: migrateChannelModelTags},
{version: 33, name: "oauth_state_accepted_terms", checksum: "sha256:oauth-state-accepted-terms-v33", apply: migrateOAuthStateAcceptedTerms}, {version: 33, name: "oauth_state_accepted_terms", checksum: "sha256:oauth-state-accepted-terms-v33", apply: migrateOAuthStateAcceptedTerms},
{version: 34, name: "task_media_recovery", checksum: "sha256:task-media-recovery-v34", apply: func(tx *gorm.DB) error {
for _, field := range []string{"MediaRecoveryJSON", "MediaStage"} {
if !tx.Migrator().HasColumn(&model.Task{}, field) {
if err := tx.Migrator().AddColumn(&model.Task{}, field); err != nil {
return err
}
}
}
return nil
}},
} }
func migrateChannelModelTags(tx *gorm.DB) error { func migrateChannelModelTags(tx *gorm.DB) error {
@@ -0,0 +1,38 @@
package database
import (
"infinite-canvas/backend/internal/model"
"testing"
)
func TestTaskMediaRecoveryMigrationPreservesHistoricalTasks(t *testing.T) {
db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, status TEXT, error TEXT)`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO tasks VALUES ('old-task', 'failed', 'download failed')`).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := schemaMigrations[33].apply(db); err != nil {
t.Fatal(err)
}
}
var task model.Task
if err := db.First(&task, "id = ?", "old-task").Error; err != nil {
t.Fatal(err)
}
if task.Status != model.TaskStatusFailed || task.Error != "download failed" || task.MediaRecoveryJSON != "" || task.MediaStage != "" {
t.Fatalf("migration rewrote historical task: %+v", task)
}
for _, field := range []string{"media_stage", "media_recovery_json"} {
if !db.Migrator().HasColumn(&model.Task{}, field) {
t.Fatalf("missing %s", field)
}
}
}
+5 -4
View File
@@ -17,10 +17,11 @@ func TestRegisterCanvasAPIExposesOpenAPIAndProjects(t *testing.T) {
RegisterCanvasAPI(router.Group("/api"), &service.Service{}) RegisterCanvasAPI(router.Group("/api"), &service.Service{})
wanted := map[string]bool{ wanted := map[string]bool{
"GET /api/openapi.yaml": false, "GET /api/openapi.yaml": false,
"GET /api/projects": false, "GET /api/projects": false,
"POST /api/tasks": false, "POST /api/tasks": false,
"GET /api/resources": false, "POST /api/tasks/:id/recover-media": false,
"GET /api/resources": false,
} }
for _, route := range router.Routes() { for _, route := range router.Routes() {
key := route.Method + " " + route.Path key := route.Method + " " + route.Path
+19
View File
@@ -1067,6 +1067,25 @@ paths:
$ref: "#/components/responses/Envelope" $ref: "#/components/responses/Envelope"
"400": "400":
$ref: "#/components/responses/Error" $ref: "#/components/responses/Error"
/tasks/{id}/recover-media:
post:
tags: [tasks]
summary: 按原任务 ID 重试作品保存,不重新生成或创建计费订单
description: 仅当前用户有恢复记录的保存失败任务可重入队;已在队列或运行中时幂等返回。每任务最多手动恢复三次,间隔至少一分钟。已取消、已退款或归属项目不可用时拒绝。
parameters:
- in: path
name: id
required: true
schema: { type: string }
responses:
"200":
$ref: "#/components/responses/Envelope"
"400":
$ref: "#/components/responses/Error"
"401":
$ref: "#/components/responses/Error"
"404":
$ref: "#/components/responses/Error"
/tasks/{id}/text-events: /tasks/{id}/text-events:
get: get:
tags: [tasks] tags: [tasks]
+17
View File
@@ -202,6 +202,23 @@ func RegisterTaskRoutes(r *gin.RouterGroup, svc *service.Service) {
} }
ok(c, task) ok(c, task)
}) })
r.POST("/tasks/:id/recover-media", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
policy, available := loadRuntimePolicy(c, svc)
if !available || !enforceRateLimit(c, "task-media-recovery:"+user.ID, policy.Request.TaskCreatePerMinute, time.Minute) {
return
}
task, err := svc.RecoverTaskMedia(user.ID, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, task)
})
r.POST("/tasks/:id/query-provider", func(c *gin.Context) { r.POST("/tasks/:id/query-provider", func(c *gin.Context) {
user, err := currentUser(c, svc) user, err := currentUser(c, svc)
if err != nil { if err != nil {
@@ -0,0 +1,64 @@
package handler
import (
"github.com/gin-gonic/gin"
"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"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestTaskMediaRecoveryHTTPAuthenticationAndOwnership(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := database.Open(database.Config{Driver: "sqlite", DSN: ":memory:"})
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 _, item := range []any{
&model.User{ID: "media-user", Username: "media-user", Email: "media@example.invalid", Role: model.UserRoleUser, Status: model.UserStatusActive},
&model.AuthSession{ID: "media-session", UserID: "media-user", TokenHash: auth.HashToken("test-token"), ExpiresAt: time.Now().Add(time.Hour)},
&model.Task{ID: "legacy-task", UserID: "media-user", Status: model.TaskStatusFailed},
&model.Task{ID: "foreign-task", UserID: "other-user", Status: model.TaskStatusFailed},
} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
router := gin.New()
svc := service.New(repository.New(db), t.TempDir())
previous := runtimeService
ConfigureRuntime(svc)
t.Cleanup(func() { ConfigureRuntime(previous) })
RegisterTaskRoutes(router.Group("/api"), svc)
for _, tc := range []struct {
id string
auth bool
want int
}{
{"legacy-task", false, http.StatusUnauthorized},
{"legacy-task", true, http.StatusBadRequest},
{"foreign-task", true, http.StatusNotFound},
{"missing-task", true, http.StatusNotFound},
} {
r := httptest.NewRequest(http.MethodPost, "/api/tasks/"+tc.id+"/recover-media", nil)
if tc.auth {
r.AddCookie(&http.Cookie{Name: service.SessionCookieName, Value: "media-session.test-token"})
}
w := httptest.NewRecorder()
router.ServeHTTP(w, r)
if w.Code != tc.want {
t.Fatalf("%s auth=%v: %d %s", tc.id, tc.auth, w.Code, w.Body.String())
}
}
}
+1
View File
@@ -105,6 +105,7 @@ type ApiCallLog struct {
ChannelName string `json:"channelName" gorm:"-"` ChannelName string `json:"channelName" gorm:"-"`
TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` TaskID string `json:"taskId,omitempty" gorm:"index;size:36"`
TaskStatus TaskStatus `json:"taskStatus,omitempty" gorm:"-"` TaskStatus TaskStatus `json:"taskStatus,omitempty" gorm:"-"`
MediaStage string `json:"mediaStage,omitempty" gorm:"-"`
BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"`
BillingStatus BillingStatus `json:"billingStatus,omitempty" gorm:"-"` BillingStatus BillingStatus `json:"billingStatus,omitempty" gorm:"-"`
BillingAmount int64 `json:"billingAmountMicrocredits" gorm:"-"` BillingAmount int64 `json:"billingAmountMicrocredits" gorm:"-"`
+11 -7
View File
@@ -46,13 +46,17 @@ type Task struct {
LeaseExpiresAt *time.Time `json:"-" gorm:"index;index:idx_tasks_claim,priority:2"` LeaseExpiresAt *time.Time `json:"-" gorm:"index;index:idx_tasks_claim,priority:2"`
InputJSON string `json:"inputJson" gorm:"type:text"` InputJSON string `json:"inputJson" gorm:"type:text"`
ResultJSON string `json:"resultJson" gorm:"type:text"` ResultJSON string `json:"resultJson" gorm:"type:text"`
TextDraft string `json:"textDraft,omitempty" gorm:"type:text"` // MediaRecoveryJSON is an encrypted server-only checkpoint, never a public result.
Error string `json:"error"` MediaRecoveryJSON string `json:"-" gorm:"type:text"`
Attempts int `json:"attempts"` MediaStage string `json:"mediaStage,omitempty" gorm:"size:24"`
StartedAt *time.Time `json:"startedAt"` CanRecoverMedia bool `json:"canRecoverMedia,omitempty" gorm:"-"`
CompletedAt *time.Time `json:"completedAt"` TextDraft string `json:"textDraft,omitempty" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt" gorm:"index:idx_tasks_user_created,priority:2;index:idx_tasks_status_created,priority:2;index:idx_tasks_claim,priority:3;index:idx_tasks_user_project_created,priority:3"` Error string `json:"error"`
UpdatedAt time.Time `json:"updatedAt"` Attempts int `json:"attempts"`
StartedAt *time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt"`
CreatedAt time.Time `json:"createdAt" gorm:"index:idx_tasks_user_created,priority:2;index:idx_tasks_status_created,priority:2;index:idx_tasks_claim,priority:3;index:idx_tasks_user_project_created,priority:3"`
UpdatedAt time.Time `json:"updatedAt"`
} }
// TaskTextDelta 只保存可回放窗口内的文本增量;最终正文和失败草稿分别归并到 Task.ResultJSON 与 Task.TextDraft。 // TaskTextDelta 只保存可回放窗口内的文本增量;最终正文和失败草稿分别归并到 Task.ResultJSON 与 Task.TextDraft。
+2 -2
View File
@@ -144,7 +144,7 @@ func (r *Repository) filteredAPICallLogQuery(filter APICallLogFilter) *gorm.DB {
query = query.Where("api_call_logs.request_kind = ?", "download") query = query.Where("api_call_logs.request_kind = ?", "download")
case "all": case "all":
default: 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 != "" { if value := strings.TrimSpace(filter.Keyword); value != "" {
pattern := "%" + strings.ToLower(value) + "%" pattern := "%" + strings.ToLower(value) + "%"
@@ -167,7 +167,7 @@ func (r *Repository) APICallLogTasks(ids []string) ([]model.Task, error) {
return []model.Task{}, nil return []model.Task{}, nil
} }
var tasks []model.Task 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 return tasks, err
} }
@@ -32,6 +32,9 @@ func TestAPICallLogRecordTypeFiltersListAndExport(t *testing.T) {
{ID: "poll", Capability: "video", RequestKind: "poll", CreatedAt: now}, {ID: "poll", Capability: "video", RequestKind: "poll", CreatedAt: now},
{ID: "video-download", Capability: "video", RequestKind: "download", CreatedAt: now}, {ID: "video-download", Capability: "video", RequestKind: "download", CreatedAt: now},
{ID: "image-download", Capability: "image", 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 { if err := db.Create(&item).Error; err != nil {
t.Fatal(err) t.Fatal(err)
@@ -41,7 +44,7 @@ func TestAPICallLogRecordTypeFiltersListAndExport(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
kind string kind string
count int 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} filter := APICallLogFilter{AnalyticsFilter: AnalyticsFilter{From: now.Add(-time.Hour), To: now.Add(time.Hour)}, RecordType: tc.kind}
logs, total, err := repo.QueryAPICallLogs(filter) logs, total, err := repo.QueryAPICallLogs(filter)
if err != nil { if err != nil {
+11 -2
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 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 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 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(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(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 = ?) + (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 { 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 { return r.db.Transaction(func(tx *gorm.DB) error {
updated := taskLeaseWriter(tx.Model(&model.Task{}), task.LeaseOwner). updated := taskLeaseWriter(tx.Model(&model.Task{}), task.LeaseOwner).
Where("id = ? AND status = ?", task.ID, expected). Where("id = ? AND status = ?", task.ID, expected).
@@ -551,6 +557,9 @@ func (r *Repository) SaveTaskCompletion(task *model.Task, expected model.TaskSta
return err return err
} }
} }
if register != nil {
return register(New(tx))
}
return nil return nil
}) })
} }
@@ -696,7 +705,7 @@ func (r *Repository) Tasks(userID string, limit int, projectID string, activeOnl
if limit <= 0 || limit > 100 { if limit <= 0 || limit > 100 {
limit = 50 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) Where("user_id = ?", userID)
if strings.TrimSpace(projectID) != "" { if strings.TrimSpace(projectID) != "" {
query = query.Where("project_id = ?", strings.TrimSpace(projectID)) query = query.Where("project_id = ?", strings.TrimSpace(projectID))
@@ -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
}
@@ -15,7 +15,14 @@ description: 影策后端核心持久化表与状态边界
## 结构迁移与版本 ## 结构迁移与版本
当前结构版本为 33。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`;保留旧 state 的身份、PKCE、跳转目标及有效期,不替历史流程补造同意记录。升级前备份,按既有流程执行 `migrate-schema up` 和 `verify` 后再启动新后端。旧版本二进制不支持版本 33,回退需恢复匹配的数据库备份。 当前结构版本为 34。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。升级前备份,按既有流程执行 `migrate-schema up` 和 `verify` 后再启动新后端。旧版本二进制不支持版本 34,回退需恢复匹配的数据库备份。
### 生成成功后的作品保存恢复
- 使用原任务主键、计费订单及现有 worker 租约,不新增执行批次或第二套队列。`media_stage` 为 `download | upload | local_save | register | checkpoint | completed`,表示当前结果的保存阶段,不改变上游生成日志的成功状态。
- `media_recovery_json` 使用已有 settings-key 加密,记录媒体地址、暂存文件名、资源 ID 和有界重试计数;不保存媒体 base64,不向任务 JSON/摘要返回该字段。数据库与 `.settings-key` 必须一起备份;该字段计入任务存储用量。
- 下载前写入恢复记录,下载后记录暂存文件,上传后记录资源。通过任务 ID 和输出索引生成稳定的资源上传键、素材 ID;任务成功、结果记录及素材登记在同一数据库事务提交,登记失败则不发布任务成功。
- 记录保留在终态任务中,成功后清理对应暂存文件;任务的既有保留/删除策略不变。结构迁移只增加列,不扫描日志,不修改历史任务、结果或账单。没有恢复记录的历史失败任务不会自动变为可恢复。
### 云端 Agent 运行时 ### 云端 Agent 运行时
+12
View File
@@ -13,6 +13,18 @@ description: 信封、错误码与 OpenAPI
模型 HTTP 失败及经过 Provider 错误转换的业务失败,在通用提示后追加经过过滤的 `;上游:具体原因`,供任务错误和画布卡片展示内容安全、提示词、尺寸等可操作信息。只读取上游 `error` / `message` / `msg` / `detail` 消息字段,不回传整个 JSON;消息中的 HTTP(S) URL 隐藏,包含余额、账务、凭据、请求诊断或 HTML 的消息不追加原文,保留通用分类提示。后台响应拦截规则仍对最终用户文案生效;不会修改历史任务错误或改变重试、退款策略。 模型 HTTP 失败及经过 Provider 错误转换的业务失败,在通用提示后追加经过过滤的 `;上游:具体原因`,供任务错误和画布卡片展示内容安全、提示词、尺寸等可操作信息。只读取上游 `error` / `message` / `msg` / `detail` 消息字段,不回传整个 JSON;消息中的 HTTP(S) URL 隐藏,包含余额、账务、凭据、请求诊断或 HTML 的消息不追加原文,保留通用分类提示。后台响应拦截规则仍对最终用户文案生效;不会修改历史任务错误或改变重试、退款策略。
## 生成成功后的重试保存
`POST /tasks/:id/recover-media` 无需请求体。当前用户只能恢复自己有保存恢复记录的失败任务;重入队仍返回原任务 ID,不请求重新生成、不新建计费订单。已排队、运行或完成时幂等返回当前任务。取消任务、已退款订单、任务/账单归属不一致、项目已删除或归档时拒绝恢复。
任务详情和摘要新增 `mediaStage`、`canRecoverMedia`。后者表示失败任务存在保存恢复记录,实际操作仍校验权限、账单、项目及重试额度。每任务最多手动恢复三次,两次之间至少一分钟,每次手动操作只执行一次保存尝试;接口另受用户维度任务请求限流约束。普通 `/tasks/:id/retry` 不接受带保存恢复记录的任务,避免误走重新生成。
保存网络故障在原 worker 中最多自动重试四次(15 秒、1 分钟、3 分钟、10 分钟并加抖动;超过 20 分钟不再安排自动重试),使用 `next_poll_at` 释放 worker,而不是在请求内睡眠。不可重试错误直接保留为保存失败。画布和任务中心显示“作品已生成,正在保存”或“作品保存未完成”,失败按钮为“重试保存”。完成后客户端消费原任务的结果。
请求明细保留上游生成与结果下载的独立状态,新增 `upload`、`local_save`、`register` 内部非计费明细;列表和详情通过 `mediaStage` 展示交付摘要。`recordType=request` 排除这些保存明细和下载,`all` 查看全部阶段,`download` 仍仅查看下载。视频下载失败不会再覆盖生成请求的成功记录。
此恢复能力接入声明式媒体协议返回 URL/data URL 的任务路径;旧协议自行下载、协议二进制下载端点以及无恢复记录的历史失败任务不自动补录。历史任务需先核实上游结果可用性、资源归属及原账单,再单独制定修复动作。
## 素材彻底删除 ## 素材彻底删除
`DELETE /assets/:id` 表示用户确认后的彻底删除,不依赖服务端当前记录是否已经同步为 `archived`。接口仍校验登录用户和素材归属,但不会因画布、任务、项目或历史快照仍有引用而拒绝;事务会删除素材及对应历史资源索引,并通过删除 Outbox 清理未被其他素材复用的物理对象。其他素材共享的同一物理对象继续保留。移入回收站仍通过素材写入接口把状态更新为 `archived`,不能用 DELETE 代替可恢复归档。 `DELETE /assets/:id` 表示用户确认后的彻底删除,不依赖服务端当前记录是否已经同步为 `archived`。接口仍校验登录用户和素材归属,但不会因画布、任务、项目或历史快照仍有引用而拒绝;事务会删除素材及对应历史资源索引,并通过删除 Outbox 清理未被其他素材复用的物理对象。其他素材共享的同一物理对象继续保留。移入回收站仍通过素材写入接口把状态更新为 `archived`,不能用 DELETE 代替可恢复归档。
@@ -9,7 +9,18 @@ description: 热点回源保护、并发租约、故障边界和上线验收
任务中心打开详情只读取任务和日志,不在该路径调用画布结果同步。后台同步遇到短剧领域项目任务(`domainProjectId` 等于任务 `projectId`)或没有生成节点关联时跳过画布写回,避免把短剧项目 ID 当作画布 ID 查询。 任务中心打开详情只读取任务和日志,不在该路径调用画布结果同步。后台同步遇到短剧领域项目任务(`domainProjectId` 等于任务 `projectId`)或没有生成节点关联时跳过画布写回,避免把短剧项目 ID 当作画布 ID 查询。
管理员请求明细列表及 CSV 导出支持 `recordType`:省略或 `request` 默认仅请求(继续隐藏视频轮询并排除下载);`download` 只包含 `request_kind=download`;`all` 包含请求、轮询与下载。非法值返回参数错误,管理员权限校验保持不变;该筛选不改变统计聚合接口。 管理员请求明细列表及 CSV 导出支持 `recordType`:省略或 `request` 默认仅请求(隐藏视频轮询,并排除下载、上传、本地保存、素材登记明细);`download` 只包含 `request_kind=download`;`all` 包含全部阶段。非法值返回参数错误,管理员权限校验保持不变;该筛选不改变统计聚合接口。
## 媒体交付恢复的资源边界
声明式协议已经返回作品地址后,先加密保存任务恢复记录,再执行下载、存储、素材登记。交付错误不进入模型路由切换,不创建新生成任务。已下载文件优先复用;资源已就绪时跳过下载和上传,只补登记。配置为 OSS 的生成结果上传失败会明确保留错误,不像普通文件上传那样自动降级到本地存储;普通上传行为不变。
- 单进程同时最多两个媒体交付操作,满载时通过原任务 `next_poll_at` 延迟领取,不启动无界 goroutine 或额外轮询器。此限制不是跨实例全局限制,集群仍依赖既有任务租约与调度限制。
- 下载流式写入数据目录的 `media-staging`,单次下载最多三分钟,文件上限取运行时生成文件限制与 512 MiB 中较小者;暂存目录逻辑占用上限为 512 MiB,先预留空间再写入。
- 成功后立即清理对应暂存文件;超过 24 小时的暂存文件在下次分配空间时清理,不为闲置服务常驻一个清理循环。暂存文件不可用时只尝试原地址,不重新生成;只有内联结果且文件丢失时明确失败。
- 多实例切换时,暂存文件只有位于共享数据卷才可直接复用;没有共享暂存文件但有有效源地址时可以重下载。数据目录和 settings-key 需要稳定保留,不能以临时容器目录代替持久化配置。
- 出站下载沿用 SSRF 防护;同源才附带渠道鉴权。恢复地址仅加密留存,下载明细隐藏路径和查询参数,错误日志不写入原始带签名的 URL。
- 自动重试有次数和时间窗口上限,手动重试也有限额。源地址过期、权限问题、配额不足等不会无限循环;不承诺已经过期且无暂存文件的作品一定可恢复。
章节提取采用独立提示词操作 `chapter_assets_extract`,受保护输出契约为 `chapter-assets/v1`,要求 `characters`、`scenes`、`props` 三个数组。场景和道具要求非空的 `name`、`description`、`prompt`;角色继续执行原角色契约校验。写入候选资产时场景映射为 `environment`,继续使用现有服务的项目归属、去重与写入校验,不新增数据库字段。 章节提取采用独立提示词操作 `chapter_assets_extract`,受保护输出契约为 `chapter-assets/v1`,要求 `characters`、`scenes`、`props` 三个数组。场景和道具要求非空的 `name`、`description`、`prompt`;角色继续执行原角色契约校验。写入候选资产时场景映射为 `environment`,继续使用现有服务的项目归属、去重与写入校验,不新增数据库字段。
@@ -5,6 +5,17 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试 # 待测试
## 原任务 ID 媒体保存恢复
- [x] 故障注入:下载 503 后服务实例重建继续原任务、OSS 上传失败保留暂存文件及资源 ID、素材登记异常使任务成功事务回滚,登记恢复无需再次下载。
- [x] 防重复与权限:不触发模型路由切换、不新增账单、重复点击幂等、跨用户/已取消/已退款/项目删除拒绝恢复、过期租约写入拒绝、自动/手动重试预算与暂存容量校验。
- [x] 后端 `app`、`repository`、`database`、`handler` 四包回归通过;最后补验媒体恢复、路由与请求日志专项测试,以及 schema 34 幂等迁移和恢复接口鉴权/归属冒烟。测试使用隔离数据库。
- [x] 前端类型检查与 31 项生成显示、画布结果同步、生成提交及重新生成相关测试通过。
- [ ] 在真实画布和任务中心触发下载失败、OSS 上传失败、登记失败,确认各阶段提示、刷新后“重试保存”、结果回写及资产库仅一条;真实浏览器验收尚未执行。
- [ ] 使用真实 OSS 配置进行网络中断/凭证修复后的恢复验收;当前上传故障测试使用不可达端点,不代表真实 OSS 成功链路已验证。
- [ ] PostgreSQL 部署执行 schema 34 升级及多实例租约/共享暂存卷验收。未修改当前本地业务数据库、历史失败任务或账单。
- [ ] 无恢复记录的历史失败任务单独核实源地址有效期与计费状态;本功能不从旧日志自动回填,不承诺恢复已过期的源文件。
## Live2D 运行库随部署交付 ## Live2D 运行库随部署交付
- Cubism Core 已纳入版本控制,保留原始版权声明并附第三方许可说明;前端构建和静态镜像直接包含运行文件,不再要求部署者手工安装。 - Cubism Core 已纳入版本控制,保留原始版权声明并附第三方许可说明;前端构建和静态镜像直接包含运行文件,不再要求部署者手工安装。
@@ -184,6 +184,7 @@ function LoadingContent({ node, theme, onOpenTaskDetails }: Pick<CanvasNodeConte
provider: node.metadata?.taskProvider, provider: node.metadata?.taskProvider,
status: (node.metadata?.taskStatus || "running") as GenerationTask["status"], status: (node.metadata?.taskStatus || "running") as GenerationTask["status"],
stage: node.metadata?.taskStage, stage: node.metadata?.taskStage,
mediaStage: node.metadata?.taskMediaStage,
officialStatus: node.metadata?.taskOfficialStatus, officialStatus: node.metadata?.taskOfficialStatus,
errorCode: node.metadata?.taskErrorCode, errorCode: node.metadata?.taskErrorCode,
}; };
@@ -245,6 +246,7 @@ function ErrorContent({ node, theme, onRetry, onReloadResource }: Pick<CanvasNod
provider: node.metadata?.taskProvider, provider: node.metadata?.taskProvider,
status: (node.metadata?.taskStatus || "failed") as GenerationTask["status"], status: (node.metadata?.taskStatus || "failed") as GenerationTask["status"],
stage: node.metadata?.taskStage, stage: node.metadata?.taskStage,
mediaStage: node.metadata?.taskMediaStage,
officialStatus: node.metadata?.taskOfficialStatus, officialStatus: node.metadata?.taskOfficialStatus,
errorCode: node.metadata?.taskErrorCode, errorCode: node.metadata?.taskErrorCode,
}; };
@@ -295,7 +297,7 @@ function ErrorContent({ node, theme, onRetry, onReloadResource }: Pick<CanvasNod
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
> >
<RefreshCw className="size-3.5" /> <RefreshCw className="size-3.5" />
{node.metadata?.isBatchRoot ? "重新生成失败项" : "重新生成"} {node.metadata?.taskCanRecoverMedia ? "重试保存(不重新生成)" : node.metadata?.isBatchRoot ? "重新生成失败项" : "重新生成"}
</button> </button>
)} )}
</div> </div>
+1 -1
View File
@@ -453,7 +453,7 @@ export const CanvasNode = React.memo(function CanvasNode({
<BatchChildActionButton theme={theme} label={batchPrimary ? "当前主图" : "设为主图"} icon={<Star className={`size-3.5 ${batchPrimary ? "fill-current" : ""}`} style={{ color: theme.accent.primary }} />} onClick={() => onSetBatchPrimary?.(data)} /> <BatchChildActionButton theme={theme} label={batchPrimary ? "当前主图" : "设为主图"} icon={<Star className={`size-3.5 ${batchPrimary ? "fill-current" : ""}`} style={{ color: theme.accent.primary }} />} onClick={() => onSetBatchPrimary?.(data)} />
) : null} ) : null}
{data.metadata?.status === "error" && data.metadata.resourceReloadAvailable ? <BatchChildActionButton theme={theme} label="重新加载资源" icon={<Download className="size-3.5" />} onClick={() => onReloadResource?.(data)} /> : null} {data.metadata?.status === "error" && data.metadata.resourceReloadAvailable ? <BatchChildActionButton theme={theme} label="重新加载资源" icon={<Download className="size-3.5" />} onClick={() => onReloadResource?.(data)} /> : null}
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="重新生成" icon={<RefreshCw className="size-3.5" />} onClick={() => onRetry?.(data)} /> : null} {data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label={data.metadata?.taskCanRecoverMedia ? "重试保存" : "重新生成"} icon={<RefreshCw className="size-3.5" />} onClick={() => onRetry?.(data)} /> : null}
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="删除" icon={<Trash2 className="size-3.5" />} danger onClick={() => deleteNode?.(data)} /> : null} {data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="删除" icon={<Trash2 className="size-3.5" />} danger onClick={() => deleteNode?.(data)} /> : null}
</div> </div>
</div> </div>
@@ -321,6 +321,8 @@ function completedTaskMetadata(task: GenerationTask): CanvasNodeMetadata {
taskStatus: task.status, taskStatus: task.status,
taskProgress: typeof task.progress === "number" && Number.isFinite(task.progress) ? Math.max(0, Math.min(100, Math.round(task.progress))) : 100, taskProgress: typeof task.progress === "number" && Number.isFinite(task.progress) ? Math.max(0, Math.min(100, Math.round(task.progress))) : 100,
taskStage: task.stage, taskStage: task.stage,
taskMediaStage: task.mediaStage,
taskCanRecoverMedia: task.canRecoverMedia,
taskStartedAt: task.startedAt, taskStartedAt: task.startedAt,
taskCompletedAt: task.completedAt, taskCompletedAt: task.completedAt,
taskDurationMs: task.startedAt && task.completedAt ? Math.max(0, Date.parse(task.completedAt) - Date.parse(task.startedAt)) : undefined, taskDurationMs: task.startedAt && task.completedAt ? Math.max(0, Date.parse(task.completedAt) - Date.parse(task.startedAt)) : undefined,
@@ -158,6 +158,8 @@ export function generationTaskMetadata(task: GenerationTask): CanvasNodeMetadata
taskStatus: task.status, taskStatus: task.status,
taskProgress: progress, taskProgress: progress,
taskStage: task.stage, taskStage: task.stage,
taskMediaStage: task.mediaStage,
taskCanRecoverMedia: task.canRecoverMedia,
taskProvider: task.provider, taskProvider: task.provider,
taskStartedAt: task.startedAt, taskStartedAt: task.startedAt,
taskCompletedAt: task.completedAt, taskCompletedAt: task.completedAt,
+2
View File
@@ -23,6 +23,8 @@ export function resetGenerationTaskMetadata(metadata: CanvasNodeMetadata | undef
delete next.taskStatus; delete next.taskStatus;
delete next.taskProgress; delete next.taskProgress;
delete next.taskStage; delete next.taskStage;
delete next.taskMediaStage;
delete next.taskCanRecoverMedia;
delete next.taskProvider; delete next.taskProvider;
delete next.taskStartedAt; delete next.taskStartedAt;
delete next.taskCompletedAt; delete next.taskCompletedAt;
+12 -1
View File
@@ -8,13 +8,23 @@ export const statusLabel: Record<TaskStatus, string> = {
cancelled: "已取消", cancelled: "已取消",
}; };
type GenerationTaskDisplayTarget = Pick<GenerationTask, "status" | "stage">; type GenerationTaskDisplayTarget = Pick<GenerationTask, "status" | "stage" | "mediaStage">;
export function mediaDeliverySummary(status: TaskStatus | undefined, stage: GenerationTask["mediaStage"]) {
if (!stage) return "";
if (stage === "completed") return "生成成功 · 文件保存成功 · 素材登记成功";
const prefix = stage === "download" || stage === "checkpoint" ? "生成成功" : stage === "register" ? "生成成功 · 文件保存成功" : "生成成功 · 下载成功";
const action = { download: "下载", upload: "上传 OSS", local_save: "保存文件", register: "登记素材", checkpoint: "记录恢复信息" }[stage];
return `${prefix} · ${action}${status === "failed" ? "失败" : status === "cancelled" ? "已停止" : "待完成"}`;
}
export function isGenerationTaskSubmissionUncertain(task: GenerationTaskDisplayTarget) { export function isGenerationTaskSubmissionUncertain(task: GenerationTaskDisplayTarget) {
return task.stage === "submission_unknown"; return task.stage === "submission_unknown";
} }
export function generationTaskStatusLabel(task: GenerationTaskDisplayTarget) { export function generationTaskStatusLabel(task: GenerationTaskDisplayTarget) {
if (task.mediaStage && task.status === "failed") return "作品保存未完成";
if (task.mediaStage && (task.status === "queued" || task.status === "running")) return "作品已生成,正在保存";
if (isGenerationTaskSubmissionUncertain(task)) return "提交结果待确认"; if (isGenerationTaskSubmissionUncertain(task)) return "提交结果待确认";
return statusLabel[task.status]; return statusLabel[task.status];
} }
@@ -27,6 +37,7 @@ export function generationTaskStageLabel(task: GenerationTaskDisplayTarget) {
} }
export function generationTaskShowsProgress(task: GenerationTaskDisplayTarget) { export function generationTaskShowsProgress(task: GenerationTaskDisplayTarget) {
if (task.mediaStage && task.mediaStage !== "completed") return false;
if (isGenerationTaskSubmissionUncertain(task)) return false; if (isGenerationTaskSubmissionUncertain(task)) return false;
// 排队、后端接管和连接供应商都没有真实百分比。只有上游状态响应 // 排队、后端接管和连接供应商都没有真实百分比。只有上游状态响应
// 已经写回任务后才显示进度,避免所有图片/视频长期停在同一个假数值。 // 已经写回任务后才显示进度,避免所有图片/视频长期停在同一个假数值。
@@ -4,6 +4,7 @@ import { AdminEmpty } from "@/pages/admin/components/admin-ui";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { formatCredits } from "@/constant/credits"; import { formatCredits } from "@/constant/credits";
import { mediaDeliverySummary } from "@/lib/generation-task-display";
import { getAdminApiLog, queryAdminApiLogTask, type ApiCallLog } from "@/services/api/auth"; import { getAdminApiLog, queryAdminApiLogTask, type ApiCallLog } from "@/services/api/auth";
import { AdminStatusBadge } from "./admin-ui"; import { AdminStatusBadge } from "./admin-ui";
@@ -80,6 +81,7 @@ function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; qu
], ],
["能力", capabilityText(log.capability)], ["能力", capabilityText(log.capability)],
["请求阶段", requestKindText(log.requestKind)], ["请求阶段", requestKindText(log.requestKind)],
["作品交付", mediaDeliverySummary(log.taskStatus, log.mediaStage) || "未记录保存阶段"],
["计费属性", log.billable ? "计费调用" : "不计费"], ["计费属性", log.billable ? "计费调用" : "不计费"],
["总耗时", <span className="tabular-nums">{formatDuration(log.durationMs)}</span>], ["总耗时", <span className="tabular-nums">{formatDuration(log.durationMs)}</span>],
["视频轮询", log.capability === "video" ? <span className="tabular-nums">{log.pollCount || 0} 次</span> : <span className="text-foreground/35">--</span>], ["视频轮询", log.capability === "video" ? <span className="tabular-nums">{log.pollCount || 0} 次</span> : <span className="text-foreground/35">--</span>],
@@ -125,7 +127,7 @@ function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; qu
["上游地址", log.upstreamUrl ? <code className="break-all text-sm text-foreground/70">{log.upstreamUrl}</code> : <span className="text-foreground/35">--</span>], ["上游地址", log.upstreamUrl ? <code className="break-all text-sm text-foreground/70">{log.upstreamUrl}</code> : <span className="text-foreground/35">--</span>],
].map(([label, children], index) => ({ key: String(index), label, children })); ].map(([label, children], index) => ({ key: String(index), label, children }));
const canQueryProviderTask = log.capability === "video" && log.taskStatus === "failed" && Boolean(log.taskId && log.providerRequestId); const canQueryProviderTask = !log.mediaStage && log.capability === "video" && log.taskStatus === "failed" && Boolean(log.taskId && log.providerRequestId);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -159,7 +161,7 @@ function billingText(log: ApiCallLog) {
} }
function requestKindText(value: ApiCallLog["requestKind"]) { function requestKindText(value: ApiCallLog["requestKind"]) {
const labels: Partial<Record<ApiCallLog["requestKind"], string>> = { create: "模型生成", poll: "状态查询", download: "结果下载", repair: "结果修复" }; const labels: Partial<Record<ApiCallLog["requestKind"], string>> = { create: "模型生成", poll: "状态查询", download: "结果下载", upload: "上传 OSS", local_save: "保存文件", register: "登记素材", repair: "结果修复" };
return labels[value] || "上游请求"; return labels[value] || "上游请求";
} }
+3 -1
View File
@@ -10,6 +10,7 @@ import { PaginationBar } from "@/pages/admin/components/admin-ui";
import { MediaPreview } from "@/components/media-preview"; import { MediaPreview } from "@/components/media-preview";
import { formatCredits } from "@/constant/credits"; import { formatCredits } from "@/constant/credits";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { mediaDeliverySummary } from "@/lib/generation-task-display";
import { exportAdminApiLogs, listAdminApiLogs, type ApiCallLog } from "@/services/api/auth"; import { exportAdminApiLogs, listAdminApiLogs, type ApiCallLog } from "@/services/api/auth";
import { ApiLogDetailDrawer } from "../components/api-log-detail-drawer"; import { ApiLogDetailDrawer } from "../components/api-log-detail-drawer";
import { AdminPageFrame } from "../components/admin-shell"; import { AdminPageFrame } from "../components/admin-shell";
@@ -361,12 +362,13 @@ function CallStatus({ log }: { log: ApiCallLog }) {
<div> <div>
<div className="mb-1 text-xs font-medium text-foreground/70">{requestKindText(log.requestKind)}</div> <div className="mb-1 text-xs font-medium text-foreground/70">{requestKindText(log.requestKind)}</div>
<AdminStatusBadge {...logStatus(log)} /> <AdminStatusBadge {...logStatus(log)} />
{log.mediaStage ? <div className="mt-1 text-xs text-foreground/60">{mediaDeliverySummary(log.taskStatus, log.mediaStage)}</div> : null}
{log.capability === "video" ? <div className="mt-1 text-xs tabular-nums text-foreground/45">已轮询 {log.pollCount || 0} 次</div> : null} {log.capability === "video" ? <div className="mt-1 text-xs tabular-nums text-foreground/45">已轮询 {log.pollCount || 0} 次</div> : null}
</div> </div>
); );
} }
function requestKindText(value: ApiCallLog["requestKind"]) { function requestKindText(value: ApiCallLog["requestKind"]) {
const labels: Partial<Record<ApiCallLog["requestKind"], string>> = { create: "模型生成", poll: "状态查询", download: "结果下载", repair: "结果修复" }; const labels: Partial<Record<ApiCallLog["requestKind"], string>> = { create: "模型生成", poll: "状态查询", download: "结果下载", upload: "上传 OSS", local_save: "保存文件", register: "登记素材", repair: "结果修复" };
return labels[value] || "上游请求"; return labels[value] || "上游请求";
} }
@@ -30,7 +30,7 @@ import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { navigateToSettings } from "@/lib/settings-navigation"; import { navigateToSettings } from "@/lib/settings-navigation";
import type { Skill } from "@/services/api/skills"; import type { Skill } from "@/services/api/skills";
import { skillRuntime, type SkillRuntimeMetadata } from "@/services/skill-runtime"; import { skillRuntime, type SkillRuntimeMetadata } from "@/services/skill-runtime";
import type { GenerationTask } from "@/services/api/task-center"; import { queryGenerationTask, recoverGenerationTaskMedia, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center";
import { resolveImageUrl } from "@/services/image-storage"; import { resolveImageUrl } from "@/services/image-storage";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import type { Asset } from "@/stores/use-asset-store"; import type { Asset } from "@/stores/use-asset-store";
@@ -75,6 +75,35 @@ export function useCanvasGenerationRetry({
return useCallback( return useCallback(
async (node: CanvasNodeData) => { async (node: CanvasNodeData) => {
// Canvas caches may predate the failure. Consult the original task
// before deciding to submit another paid generation.
let sourceTask: GenerationTask | undefined;
if (node.metadata?.taskId) {
try {
sourceTask = await queryGenerationTask(node.metadata.taskId);
} catch (error) {
message.error(error instanceof Error ? error.message : "无法核对原任务,请稍后重试");
return;
}
}
if (sourceTask?.mediaStage && sourceTask.status !== "cancelled") {
const controller = startGenerationRequest(node.id, node.id, node.id);
try {
const initialTask = sourceTask.status === "failed" ? await recoverGenerationTaskMedia(sourceTask.id) : sourceTask;
if (controller.signal.aborted) return;
bindGenerationTask(node.id, initialTask);
const completed = await waitForGenerationTask(initialTask.id, {
initialTask, signal: controller.signal, timeoutMs: 25 * 60_000,
onTaskUpdate: (task) => bindGenerationTask(node.id, task),
});
await applyGenerationTaskResult(node.id, completed);
} catch (error) {
if (!controller.signal.aborted) message.error(error instanceof Error ? error.message : "恢复作品保存失败");
} finally {
finishGenerationRequest(node.id, controller);
}
return;
}
const retryMode = retryModeForNode(node.type); const retryMode = retryModeForNode(node.type);
if (!retryMode) { if (!retryMode) {
message.warning("当前节点不能使用通用生成重试"); message.warning("当前节点不能使用通用生成重试");
+12 -7
View File
@@ -10,11 +10,11 @@ import { MediaPreview } from "@/components/media-preview";
import { PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page"; import { PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceState } from "@/components/layout/workspace-state"; import { WorkspaceState } from "@/components/layout/workspace-state";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { formatTaskKind, operationOptions, statusLabel } from "@/lib/generation-task-display"; import { formatTaskKind, generationTaskStatusLabel, mediaDeliverySummary, operationOptions, statusLabel } from "@/lib/generation-task-display";
import { buildVideoOperationPrompt } from "@/lib/prompts"; import { buildVideoOperationPrompt } from "@/lib/prompts";
import { backendProviderConfig, logicalModelIDForConfig } from "@/services/api/generation-task"; import { backendProviderConfig, logicalModelIDForConfig } from "@/services/api/generation-task";
import { createGenerationTask, formatTaskLog, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center"; import { createGenerationTask, formatTaskLog, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, recoverGenerationTaskMedia, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync"; import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
@@ -299,15 +299,18 @@ export default function TasksPage() {
}, [loadTasks]); }, [loadTasks]);
const runAction = async (id: string) => { const runAction = async (id: string) => {
const currentTask = tasksRef.current.find((task) => task.id === id);
setActingId(id); setActingId(id);
try { try {
const next = await retryGenerationTask(id); const currentTask = await queryGenerationTask(id);
const savingMedia = Boolean(currentTask.mediaStage);
const next = currentTask.status === "queued" || currentTask.status === "running" || currentTask.status === "succeeded"
? currentTask
: savingMedia ? await recoverGenerationTaskMedia(id) : await retryGenerationTask(id);
setTasks((items) => items.map((item) => (item.id === id ? next : item))); setTasks((items) => items.map((item) => (item.id === id ? next : item)));
setDetailTask((current) => (current?.id === id ? { ...current, ...next } : current)); setDetailTask((current) => (current?.id === id ? { ...current, ...next } : current));
setStatusFilter("active"); setStatusFilter("active");
setPage(1); setPage(1);
message.success("任务已重新入队"); message.success(next.status === "succeeded" ? "任务已完成" : savingMedia ? "正在恢复作品保存,不会重新生成" : "任务已在队列中");
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : "操作失败"); message.error(error instanceof Error ? error.message : "操作失败");
} finally { } finally {
@@ -482,7 +485,8 @@ export default function TasksPage() {
{detailTask ? ( {detailTask ? (
<div className="space-y-5"> <div className="space-y-5">
<div className="task-detail-facts grid text-sm sm:grid-cols-2"> <div className="task-detail-facts grid text-sm sm:grid-cols-2">
<InfoItem label="状态" value={statusLabel[detailTask.status]} /> <InfoItem label="状态" value={generationTaskStatusLabel(detailTask)} />
{detailTask.mediaStage ? <InfoItem label="作品交付" value={mediaDeliverySummary(detailTask.status, detailTask.mediaStage)} /> : null}
<InfoItem label="画布名称" value={getTaskCanvasContext(detailTask, canvasById, domainProjectNameById).canvasName} /> <InfoItem label="画布名称" value={getTaskCanvasContext(detailTask, canvasById, domainProjectNameById).canvasName} />
<InfoItem label="任务类型" value={formatTaskKind(detailTask)} /> <InfoItem label="任务类型" value={formatTaskKind(detailTask)} />
<InfoItem label="模型" value={formatModelName(effectiveConfig, detailTask)} /> <InfoItem label="模型" value={formatModelName(effectiveConfig, detailTask)} />
@@ -495,6 +499,7 @@ export default function TasksPage() {
{detailTask.providerCancelRequestedAt ? <InfoItem label="请求取消时间" value={formatDate(detailTask.providerCancelRequestedAt)} /> : null} {detailTask.providerCancelRequestedAt ? <InfoItem label="请求取消时间" value={formatDate(detailTask.providerCancelRequestedAt)} /> : null}
</div> </div>
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
{detailTask.canRecoverMedia ? <Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void runAction(detailTask.id)}>重试保存</Button> : null}
{canQueryProviderTask(detailTask) ? <Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void queryProviderTask(detailTask)}>手动查询任务</Button> : null} {canQueryProviderTask(detailTask) ? <Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void queryProviderTask(detailTask)}>手动查询任务</Button> : null}
{isTaskFailed(detailTask) ? <Button icon={<Bug className="size-4" />} onClick={() => navigate(`/settings?section=diagnostics&taskId=${encodeURIComponent(detailTask.id)}${detailTask.projectId ? `&projectId=${encodeURIComponent(detailTask.projectId)}` : ""}`)}>导出诊断包</Button> : null} {isTaskFailed(detailTask) ? <Button icon={<Bug className="size-4" />} onClick={() => navigate(`/settings?section=diagnostics&taskId=${encodeURIComponent(detailTask.id)}${detailTask.projectId ? `&projectId=${encodeURIComponent(detailTask.projectId)}` : ""}`)}>导出诊断包</Button> : null}
</div> </div>
@@ -538,7 +543,7 @@ export default function TasksPage() {
} }
function canQueryProviderTask(task: GenerationTask) { function canQueryProviderTask(task: GenerationTask) {
return task.status === "failed" && (task.type.startsWith("canvas_video") || task.type.startsWith("video_")) && Boolean(task.providerRequestId); return task.status === "failed" && !task.mediaStage && (task.type.startsWith("canvas_video") || task.type.startsWith("video_")) && Boolean(task.providerRequestId);
} }
function reconcileTaskSummaries(current: GenerationTask[], next: GenerationTask[]) { function reconcileTaskSummaries(current: GenerationTask[], next: GenerationTask[]) {
+5 -5
View File
@@ -5,7 +5,7 @@ import { Eye, FileText, Image as ImageIcon, RotateCcw, Video } from "lucide-reac
import { MediaPreview } from "@/components/media-preview"; import { MediaPreview } from "@/components/media-preview";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error"; import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { statusLabel } from "@/lib/generation-task-display"; import { generationTaskStatusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center"; import type { GenerationTask } from "@/services/api/task-center";
import { isTaskFailed, statusDotClassName, taskAttentionReason, TaskDate } from "./task-shared"; import { isTaskFailed, statusDotClassName, taskAttentionReason, TaskDate } from "./task-shared";
import { TaskVideoThumbnail } from "./task-video-thumbnail"; import { TaskVideoThumbnail } from "./task-video-thumbnail";
@@ -33,12 +33,12 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: Genera
<IconButton size="sm" variant="ghost" icon={Eye} aria-label="查看详情" onClick={onOpen} /> <IconButton size="sm" variant="ghost" icon={Eye} aria-label="查看详情" onClick={onOpen} />
</Tooltip> </Tooltip>
{isFailed ? ( {isFailed ? (
<Tooltip title={retryDisabled ? "内容审核失败,无法自动重试" : "重试任务"}> <Tooltip title={retryDisabled ? "内容审核失败,无法自动重试" : task.canRecoverMedia ? "重试保存,不重新生成" : "重试任务"}>
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<RotateCcw className="size-3.5" />} icon={<RotateCcw className="size-3.5" />}
aria-label="重试任务" aria-label={task.canRecoverMedia ? "重试保存" : "重试任务"}
loading={actingId === task.id} loading={actingId === task.id}
disabled={retryDisabled} disabled={retryDisabled}
onClick={onRetry} onClick={onRetry}
@@ -54,13 +54,13 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: Genera
<div className="task-grid-meta"> <div className="task-grid-meta">
<span className={`task-grid-status ${isFailed ? "is-failed" : isActive ? "is-active" : task.status === "succeeded" ? "is-success" : ""}`}> <span className={`task-grid-status ${isFailed ? "is-failed" : isActive ? "is-active" : task.status === "succeeded" ? "is-success" : ""}`}>
<i className={statusDotClassName(task.status)} /> <i className={statusDotClassName(task.status)} />
{statusLabel[task.status]} {generationTaskStatusLabel(task)}
</span> </span>
<span className="task-grid-date"> <span className="task-grid-date">
<TaskDate value={task.createdAt} /> <TaskDate value={task.createdAt} />
</span> </span>
</div> </div>
{isActive ? ( {isActive && task.mediaStage ? <p>{task.stage || "作品已生成,正在保存"}</p> : isActive ? (
<div className="task-grid-progress" role="progressbar" aria-label={task.stage || "任务生成进度"} aria-valuemin={0} aria-valuemax={100} aria-valuenow={task.progress || 0}> <div className="task-grid-progress" role="progressbar" aria-label={task.stage || "任务生成进度"} aria-valuemin={0} aria-valuemax={100} aria-valuenow={task.progress || 0}>
<span>{task.stage || "正在生成"}</span> <span>{task.stage || "正在生成"}</span>
<strong>{task.progress || 0}%</strong> <strong>{task.progress || 0}%</strong>
+5 -5
View File
@@ -6,7 +6,7 @@ import { useState } from "react";
import { MediaPreview } from "@/components/media-preview"; import { MediaPreview } from "@/components/media-preview";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { formatTaskKind, statusLabel } from "@/lib/generation-task-display"; import { formatTaskKind, generationTaskStatusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center"; import type { GenerationTask } from "@/services/api/task-center";
import type { AiConfig } from "@/stores/use-config-store"; import type { AiConfig } from "@/stores/use-config-store";
import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared"; import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
@@ -44,7 +44,7 @@ export function TaskListRow({
<div className="task-record-heading"> <div className="task-record-heading">
<span className={`task-record-status ${isFailed ? "is-failed" : isActive ? "is-active" : "is-success"}`}> <span className={`task-record-status ${isFailed ? "is-failed" : isActive ? "is-active" : "is-success"}`}>
<i className={statusDotClassName(task.status)} /> <i className={statusDotClassName(task.status)} />
{statusLabel[task.status]} {generationTaskStatusLabel(task)}
</span> </span>
<button type="button" className="task-record-title" title={task.prompt} onClick={onOpen}> <button type="button" className="task-record-title" title={task.prompt} onClick={onOpen}>
{task.prompt || "未命名任务"} {task.prompt || "未命名任务"}
@@ -60,7 +60,7 @@ export function TaskListRow({
{context.projectName ? ` · ${context.projectName}` : ""} {context.projectName ? ` · ${context.projectName}` : ""}
</span> </span>
</div> </div>
{isActive ? ( {isActive && task.mediaStage ? <p>{task.stage || "作品已生成,正在保存"}</p> : isActive ? (
<div className="task-record-progress" role="progressbar" aria-label={task.stage || "任务生成进度"} aria-valuemin={0} aria-valuemax={100} aria-valuenow={task.progress || 0}> <div className="task-record-progress" role="progressbar" aria-label={task.stage || "任务生成进度"} aria-valuemin={0} aria-valuemax={100} aria-valuenow={task.progress || 0}>
<span>{task.stage || "正在生成"}</span> <span>{task.stage || "正在生成"}</span>
<span>{task.progress || 0}%</span> <span>{task.progress || 0}%</span>
@@ -84,12 +84,12 @@ export function TaskListRow({
<IconButton size="sm" variant="ghost" icon={Eye} aria-label="查看详情" onClick={onOpen} /> <IconButton size="sm" variant="ghost" icon={Eye} aria-label="查看详情" onClick={onOpen} />
</Tooltip> </Tooltip>
{isFailed ? ( {isFailed ? (
<Tooltip title={retryDisabled ? "内容审核失败,无法自动重试" : "重试任务"}> <Tooltip title={retryDisabled ? "内容审核失败,无法自动重试" : task.canRecoverMedia ? "重试保存,不重新生成" : "重试任务"}>
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<RotateCcw className="size-3.5" />} icon={<RotateCcw className="size-3.5" />}
aria-label="重试任务" aria-label={task.canRecoverMedia ? "重试保存" : "重试任务"}
loading={actingId === task.id} loading={actingId === task.id}
disabled={retryDisabled} disabled={retryDisabled}
onClick={onRetry} onClick={onRetry}
+2
View File
@@ -2,6 +2,7 @@ import { Coins } from "lucide-react";
import { formatCredits } from "@/constant/credits"; import { formatCredits } from "@/constant/credits";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { mediaDeliverySummary } from "@/lib/generation-task-display";
import type { GenerationTask, TaskStatus } from "@/services/api/task-center"; import type { GenerationTask, TaskStatus } from "@/services/api/task-center";
import { modelDisplayName, type AiConfig } from "@/stores/use-config-store"; import { modelDisplayName, type AiConfig } from "@/stores/use-config-store";
@@ -19,6 +20,7 @@ export function isTaskFailed(task: GenerationTask) {
export function taskAttentionReason(task: GenerationTask) { export function taskAttentionReason(task: GenerationTask) {
if (task.status === "cancelled") return providerCancelStatusLabel(task); if (task.status === "cancelled") return providerCancelStatusLabel(task);
if (task.mediaStage) return mediaDeliverySummary(task.status, task.mediaStage);
if (task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)) return "内容审核未通过,请修改输入后新建任务"; if (task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)) return "内容审核未通过,请修改输入后新建任务";
if (task.error) return generationErrorMessage(task.error); if (task.error) return generationErrorMessage(task.error);
return task.stage || "生成失败,打开详情查看原因"; return task.stage || "生成失败,打开详情查看原因";
+2 -1
View File
@@ -59,6 +59,7 @@ export type ApiCallLog = {
channelName: string; channelName: string;
taskId?: string; taskId?: string;
taskStatus?: TaskStatus; taskStatus?: TaskStatus;
mediaStage?: GenerationTask["mediaStage"];
billingOrderId?: string; billingOrderId?: string;
billingStatus?: BillingOrder["status"]; billingStatus?: BillingOrder["status"];
billingAmountMicrocredits: number; billingAmountMicrocredits: number;
@@ -66,7 +67,7 @@ export type ApiCallLog = {
source: string; source: string;
capability: "text" | "image" | "video" | "audio" | ""; capability: "text" | "image" | "video" | "audio" | "";
operation?: string; operation?: string;
requestKind: "create" | "poll" | "download" | "repair" | ""; requestKind: "create" | "poll" | "download" | "upload" | "local_save" | "register" | "repair" | "";
billable: boolean; billable: boolean;
apiFormat: string; apiFormat: string;
method: string; method: string;
+6
View File
@@ -27,6 +27,8 @@ export type GenerationTask = {
status: TaskStatus; status: TaskStatus;
progress?: number; progress?: number;
stage?: string; stage?: string;
mediaStage?: "download" | "upload" | "local_save" | "register" | "completed" | "checkpoint";
canRecoverMedia?: boolean;
prompt: string; prompt: string;
operation?: string; operation?: string;
provider?: string; provider?: string;
@@ -282,6 +284,10 @@ export function retryGenerationTask(id: string) {
return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/retry`); return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/retry`);
} }
export function recoverGenerationTaskMedia(id: string) {
return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/recover-media`);
}
export function cancelGenerationTask(id: string) { export function cancelGenerationTask(id: string) {
return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/cancel`).then((task) => { return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/cancel`).then((task) => {
window.dispatchEvent(new CustomEvent("canvas:task-cancelled", { detail: { task } })); window.dispatchEvent(new CustomEvent("canvas:task-cancelled", { detail: { task } }));
+3
View File
@@ -7,6 +7,7 @@ import type { ArtCritiqueNodeState } from "@/lib/art-critique/contracts";
import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library"; import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
import type { SrtEntry, SubtitleHighlight, SubtitleStyle } from "@/types/timeline"; import type { SrtEntry, SubtitleHighlight, SubtitleStyle } from "@/types/timeline";
import type { GenerationSpec } from "@/lib/canvas/generation-contract.generated"; import type { GenerationSpec } from "@/lib/canvas/generation-contract.generated";
import type { GenerationTask } from "@/services/api/task-center";
export type Position = { export type Position = {
x: number; x: number;
@@ -360,6 +361,8 @@ export type CanvasNodeMetadata = {
taskStatus?: "queued" | "running" | "succeeded" | "failed" | "cancelled" | string; taskStatus?: "queued" | "running" | "succeeded" | "failed" | "cancelled" | string;
taskProgress?: number; taskProgress?: number;
taskStage?: string; taskStage?: string;
taskMediaStage?: GenerationTask["mediaStage"];
taskCanRecoverMedia?: boolean;
taskProvider?: string; taskProvider?: string;
taskStartedAt?: string; taskStartedAt?: string;
taskCompletedAt?: string; taskCompletedAt?: string;
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { generationTaskShowsProgress, generationTaskStatusLabel, mediaDeliverySummary } from "@/lib/generation-task-display";
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state";
describe("作品保存阶段", () => {
test("分别表达生成、下载、上传和登记,不把保存失败当成生成失败", () => {
expect(mediaDeliverySummary("failed", "download")).toBe("生成成功 · 下载失败");
expect(mediaDeliverySummary("failed", "upload")).toBe("生成成功 · 下载成功 · 上传 OSS失败");
expect(mediaDeliverySummary("failed", "register")).toBe("生成成功 · 文件保存成功 · 登记素材失败");
expect(mediaDeliverySummary("failed", "checkpoint")).not.toContain("下载成功");
expect(mediaDeliverySummary("succeeded", "completed")).toContain("素材登记成功");
});
test("恢复中不显示虚假生成进度", () => {
expect(generationTaskStatusLabel({ status: "running", mediaStage: "upload" })).toBe("作品已生成,正在保存");
expect(generationTaskShowsProgress({ status: "running", mediaStage: "upload" })).toBe(false);
expect(generationTaskStatusLabel({ status: "failed", mediaStage: "download" })).toBe("作品保存未完成");
});
test("重新生成清除旧恢复信息", () => {
const metadata = resetGenerationTaskMetadata({ taskId: "old", taskMediaStage: "upload", taskCanRecoverMedia: true });
expect(metadata.taskId).toBeUndefined();
expect(metadata.taskMediaStage).toBeUndefined();
expect(metadata.taskCanRecoverMedia).toBeUndefined();
});
});