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

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

View File

@@ -17,10 +17,11 @@ func TestRegisterCanvasAPIExposesOpenAPIAndProjects(t *testing.T) {
RegisterCanvasAPI(router.Group("/api"), &service.Service{})
wanted := map[string]bool{
"GET /api/openapi.yaml": false,
"GET /api/projects": false,
"POST /api/tasks": false,
"GET /api/resources": false,
"GET /api/openapi.yaml": false,
"GET /api/projects": false,
"POST /api/tasks": false,
"POST /api/tasks/:id/recover-media": false,
"GET /api/resources": false,
}
for _, route := range router.Routes() {
key := route.Method + " " + route.Path

View File

@@ -1067,6 +1067,25 @@ paths:
$ref: "#/components/responses/Envelope"
"400":
$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:
get:
tags: [tasks]

View File

@@ -202,6 +202,23 @@ func RegisterTaskRoutes(r *gin.RouterGroup, svc *service.Service) {
}
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) {
user, err := currentUser(c, svc)
if err != nil {

View File

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