feat(media): 作品保存恢复 - 按原任务重试交付并避免重复生成计费
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
64
backend/internal/handler/task_media_recovery_test.go
Normal file
64
backend/internal/handler/task_media_recovery_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user