refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"net/http"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
@@ -48,8 +48,11 @@ func RegisterAdminStorageRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
page, limit, err := parsePaginationQuery(c, 20)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.AdminResourcePage(user, service.AdminResourceQuery{
|
||||
Keyword: c.Query("keyword"), Kind: c.Query("kind"), Status: c.Query("status"),
|
||||
Provider: c.Query("provider"), UserID: c.Query("userId"), Page: page, Limit: limit,
|
||||
|
||||
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
@@ -77,8 +76,11 @@ func RegisterAnnouncementRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
page, limit, err := parsePaginationQuery(c, 20)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
announcements, err := svc.AdminAnnouncementPage(user, service.AdminListQuery{Keyword: c.Query("keyword"), Status: c.Query("status"), Page: page, Limit: limit})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
|
||||
52
backend/internal/handler/api.go
Normal file
52
backend/internal/handler/api.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterCanvasAPI 注册当前公开 HTTP API。main 把它挂在 /api。前后端一起发布,不做路径版本前缀。
|
||||
func RegisterCanvasAPI(api *gin.RouterGroup, svc *service.Service) {
|
||||
RegisterOpenAPIRoutes(api)
|
||||
RegisterAuthRoutes(api, svc)
|
||||
RegisterAppearanceRoutes(api, svc)
|
||||
RegisterFeatureAvailabilityRoutes(api, svc)
|
||||
RegisterResponseInterceptionRoutes(api, svc)
|
||||
RegisterAdminRoutes(api, svc)
|
||||
RegisterAdminAnalyticsRoutes(api, svc)
|
||||
RegisterAdminStorageRoutes(api, svc)
|
||||
RegisterAdminUpdateRoutes(api, svc)
|
||||
RegisterAdminSystemPerformanceRoutes(api, svc)
|
||||
RegisterAnnouncementRoutes(api, svc)
|
||||
RegisterFinanceRoutes(api, svc)
|
||||
RegisterPaymentRoutes(api, svc)
|
||||
RegisterLibTVRoutes(api, svc)
|
||||
RegisterTapNowRoutes(api, svc)
|
||||
RegisterChannelModelRoutes(api, svc)
|
||||
RegisterLogicalModelRoutes(api, svc)
|
||||
RegisterModelCatalogRoutes(api, svc)
|
||||
RegisterSystemProxyRoutes(api, svc)
|
||||
RegisterCustomRelayRoutes(api, svc)
|
||||
RegisterTaskRoutes(api, svc)
|
||||
RegisterComfyBridgeRoutes(api, svc)
|
||||
RegisterRunningHubRoutes(api, svc)
|
||||
RegisterSessionRoutes(api, svc)
|
||||
RegisterSkillRoutes(api, svc)
|
||||
RegisterUserDataRoutes(api, svc)
|
||||
RegisterChunkedUploadRoutes(api, svc)
|
||||
RegisterDiagnosticsRoutes(api, svc)
|
||||
RegisterPluginRoutes(api, svc)
|
||||
projectAPI := api.Group("")
|
||||
projectAPI.Use(RequireFeature(svc, service.FeatureShortDrama))
|
||||
RegisterProjectRoutes(projectAPI, svc)
|
||||
RegisterCanvasShareRoutes(api, svc)
|
||||
}
|
||||
|
||||
func RegisterOpenAPIRoutes(api *gin.RouterGroup) {
|
||||
api.GET("/openapi.yaml", func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/yaml; charset=utf-8", openAPISpec)
|
||||
})
|
||||
}
|
||||
42
backend/internal/handler/api_test.go
Normal file
42
backend/internal/handler/api_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRegisterCanvasAPIExposesOpenAPIAndProjects(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
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,
|
||||
}
|
||||
for _, route := range router.Routes() {
|
||||
key := route.Method + " " + route.Path
|
||||
if _, exists := wanted[key]; exists {
|
||||
wanted[key] = true
|
||||
}
|
||||
}
|
||||
for route, found := range wanted {
|
||||
if !found {
|
||||
t.Errorf("route %s is not registered", route)
|
||||
}
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/openapi.yaml", nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "openapi: 3.0.3") || !strings.Contains(recorder.Body.String(), "url: /api") {
|
||||
t.Fatalf("openapi.yaml status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -231,8 +231,11 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
page, limit, err := parsePaginationQuery(c, 20)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
users, err := svc.AdminUsers(user, service.AdminListQuery{Keyword: c.Query("keyword"), Type: c.Query("role"), Status: c.Query("status"), Page: page, Limit: limit})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -310,8 +313,11 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
page, limit, err := parsePaginationQuery(c, 20)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.AdminUserLedger(user, c.Param("id"), c.Query("type"), page, limit)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -325,8 +331,11 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
page, limit, err := parsePaginationQuery(c, 20)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.AdminUserTasks(user, c.Param("id"), page, limit)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -340,8 +349,11 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
page, limit, err := parsePaginationQuery(c, 20)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.AdminUserAuditEvents(user, c.Param("id"), page, limit)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -385,8 +397,11 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
page, limit, err := parsePaginationQuery(c, 20)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
channels, err := svc.AdminSystemChannelPage(user, service.AdminListQuery{Keyword: c.Query("keyword"), Status: c.Query("status"), Page: page, Limit: limit})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -685,8 +700,11 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
page, limit, err := parsePaginationQuery(c, 50)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
logs, err := svc.AdminAPICallLogs(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), RecordType: c.Query("recordType"), Keyword: c.Query("keyword"), Status: c.Query("status"), Page: page, Limit: limit})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -933,7 +951,6 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us
|
||||
}
|
||||
validatedTarget, err := svc.ValidateChannelOutboundURL(target, channel.AllowLocalChannel, false)
|
||||
if err != nil {
|
||||
_ = svc.RefundBilling(billingOrderID, "系统渠道地址校验失败")
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
@@ -961,7 +978,7 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us
|
||||
if order != nil {
|
||||
billingOrderID = order.ID
|
||||
if err := svc.MarkBillingRunning(billingOrderID); err != nil {
|
||||
_ = svc.RefundBilling(billingOrderID, "系统渠道请求尚未发出")
|
||||
refundSystemProxyBilling(svc, billingOrderID, "系统渠道请求尚未发出")
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
@@ -969,7 +986,7 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us
|
||||
}
|
||||
upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, validatedTarget.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
_ = svc.RefundBilling(billingOrderID, "系统渠道请求构造失败")
|
||||
refundSystemProxyBilling(svc, billingOrderID, "系统渠道请求构造失败")
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
@@ -997,7 +1014,7 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us
|
||||
if err != nil {
|
||||
status = model.ApiCallStatusFailed
|
||||
errorText = err.Error()
|
||||
_ = svc.MarkBillingUncertain(billingOrderID, "系统渠道连接中断,费用状态待核对")
|
||||
markSystemProxyBillingUncertain(svc, billingOrderID, "系统渠道连接中断,费用状态待核对")
|
||||
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), nil)
|
||||
fail(c, http.StatusBadGateway, errors.New("系统渠道连接失败"))
|
||||
return
|
||||
@@ -1023,7 +1040,7 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us
|
||||
if errors.Is(readErr, errSystemProxyResponseTooLarge) {
|
||||
billingNote = "上游已响应但流式响应体超过限制,费用状态待核对"
|
||||
}
|
||||
_ = svc.MarkBillingUncertain(billingOrderID, billingNote)
|
||||
markSystemProxyBillingUncertain(svc, billingOrderID, billingNote)
|
||||
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), responseBody)
|
||||
// SSE 响应头已经发送,流中断后只能关闭连接;非流式响应仍返回结构化错误。
|
||||
if !streamed {
|
||||
@@ -1032,21 +1049,21 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us
|
||||
return
|
||||
}
|
||||
if int64(len(responseBody)) > responseLimit {
|
||||
_ = svc.MarkBillingUncertain(billingOrderID, "上游已响应但响应体超过限制,费用状态待核对")
|
||||
markSystemProxyBillingUncertain(svc, billingOrderID, "上游已响应但响应体超过限制,费用状态待核对")
|
||||
fail(c, http.StatusBadGateway, fmt.Errorf("系统渠道响应超过 %dMB 限制", policy.Request.SystemRelayResponseMB))
|
||||
return
|
||||
}
|
||||
logErr := logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), responseBody)
|
||||
if status == model.ApiCallStatusSucceeded {
|
||||
if logErr != nil {
|
||||
_ = svc.MarkBillingUncertain(billingOrderID, "上游成功但调用日志写入失败,费用状态待核对")
|
||||
markSystemProxyBillingUncertain(svc, billingOrderID, "上游成功但调用日志写入失败,费用状态待核对")
|
||||
} else if err := svc.SettleBilling(billingOrderID, ""); err != nil {
|
||||
_ = svc.MarkBillingUncertain(billingOrderID, "上游成功但积分结算失败:"+err.Error())
|
||||
markSystemProxyBillingUncertain(svc, billingOrderID, "上游成功但积分结算失败:"+err.Error())
|
||||
}
|
||||
} else if statusCode == 524 {
|
||||
_ = svc.MarkBillingUncertain(billingOrderID, "上游返回 524,费用状态待核对")
|
||||
markSystemProxyBillingUncertain(svc, billingOrderID, "上游返回 524,费用状态待核对")
|
||||
} else {
|
||||
_ = svc.RefundBilling(billingOrderID, "上游明确返回失败")
|
||||
refundSystemProxyBilling(svc, billingOrderID, "上游明确返回失败")
|
||||
}
|
||||
if streamed {
|
||||
return
|
||||
@@ -1055,6 +1072,26 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us
|
||||
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), responseBody)
|
||||
}
|
||||
|
||||
func refundSystemProxyBilling(svc *service.Service, billingOrderID string, reason string) {
|
||||
if billingOrderID == "" {
|
||||
return
|
||||
}
|
||||
if err := svc.RefundBilling(billingOrderID, reason); err != nil {
|
||||
log.Printf("system proxy billing refund failed: billing_order_id=%s error=%v", billingOrderID, err)
|
||||
// 退款写失败不能当没发生:转待核对,避免订单停在 running 被当成已扣费。
|
||||
markSystemProxyBillingUncertain(svc, billingOrderID, "退款失败,费用状态待核对:"+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func markSystemProxyBillingUncertain(svc *service.Service, billingOrderID string, reason string) {
|
||||
if billingOrderID == "" {
|
||||
return
|
||||
}
|
||||
if err := svc.MarkBillingUncertain(billingOrderID, reason); err != nil {
|
||||
log.Printf("system proxy billing uncertainty update failed: billing_order_id=%s error=%v", billingOrderID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func apiCallLog(user *model.User, channel *model.ModelChannel, billingOrderID string, capability string, protocol model.ChannelInterfaceType, method string, path string, target string, body []byte, contentType string, status model.ApiCallStatus, statusCode int, duration time.Duration, errorText string, concurrencyLimit int) model.ApiCallLog {
|
||||
requestKind := "create"
|
||||
apiFormat := "openai"
|
||||
|
||||
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
@@ -17,8 +16,11 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
||||
page, limit, err := parsePaginationQuery(c, 30)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
wallet, err := svc.Wallet(user, c.Query("type"), page, limit)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -329,8 +331,11 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
page, limit, err := parsePaginationQuery(c, 20)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
items, err := svc.AdminRedeemBatchPage(user, service.AdminListQuery{Keyword: c.Query("keyword"), Status: c.Query("validity"), Page: page, Limit: limit})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -367,8 +372,11 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
page, limit, err := parsePaginationQuery(c, 50)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.AdminRedeemCodePage(user, c.Param("id"), c.Query("status"), page, limit)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -426,8 +434,11 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
page, limit, err := parsePaginationQuery(c, 20)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
items, err := svc.AdminBillingOrderPage(user, service.AdminListQuery{Keyword: c.Query("keyword"), Status: c.DefaultQuery("status", "review"), Page: page, Limit: limit})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
|
||||
@@ -42,39 +42,4 @@ func RegisterModelCatalogRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
ok(c, catalog)
|
||||
})
|
||||
|
||||
r.POST("/model-catalog/quote", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ModelID string `json:"modelId"`
|
||||
Intent service.ModelRequestIntent `json:"intent"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, errors.New("模型报价请求格式错误"))
|
||||
return
|
||||
}
|
||||
|
||||
// 根据 frontendModelsEnabled 决定使用哪种报价方式
|
||||
frontendEnabled, err := svc.FeatureEnabled(service.FeatureFrontendModels)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if frontendEnabled {
|
||||
// 使用前台模型报价
|
||||
quote, err := svc.QuoteLogicalModel(req.ModelID, req.Intent)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"quote": quote})
|
||||
} else {
|
||||
// 使用系统渠道模型报价
|
||||
// TODO: 实现系统渠道模型报价逻辑
|
||||
fail(c, http.StatusNotImplemented, errors.New("系统渠道模型报价功能尚未实现"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
215
backend/internal/handler/openapi.yaml
Normal file
215
backend/internal/handler/openapi.yaml
Normal file
@@ -0,0 +1,215 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 影策 HTTP API
|
||||
version: "1.0.0"
|
||||
description: |
|
||||
路径前缀是 `/api`。前后端一起发布,不按路径做 API 版本。
|
||||
|
||||
业务 JSON 信封为 `{ code, data, msg, reason }`。`code === 0` 表示成功;失败时 HTTP status 与
|
||||
`code` 都表达失败。`code` 默认等于 HTTP 状态;同一状态下的细分原因使用 `status*100+n`
|
||||
(例如 40301 配额、42901 限流)。机器判断请用 `reason`,不要解析 `msg`。
|
||||
|
||||
TypeScript 调用面仍是 `web/src/services/api` 的 `http` 模块,不从本规范生成客户端。
|
||||
servers:
|
||||
- url: /api
|
||||
security:
|
||||
- sessionCookie: []
|
||||
tags:
|
||||
- name: meta
|
||||
- name: auth
|
||||
- name: projects
|
||||
- name: resources
|
||||
- name: tasks
|
||||
- name: assets
|
||||
paths:
|
||||
/openapi.yaml:
|
||||
get:
|
||||
tags: [meta]
|
||||
security: []
|
||||
summary: 本 OpenAPI 文档
|
||||
responses:
|
||||
"200":
|
||||
description: OpenAPI 3.0 YAML
|
||||
content:
|
||||
application/yaml:
|
||||
schema:
|
||||
type: string
|
||||
/auth/session:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: 当前登录会话
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
/auth/login:
|
||||
post:
|
||||
tags: [auth]
|
||||
security: []
|
||||
summary: 登录并设置会话 Cookie
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [username, password]
|
||||
properties:
|
||||
username: { type: string }
|
||||
password: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
/projects:
|
||||
get:
|
||||
tags: [projects]
|
||||
summary: 短剧项目列表
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
"403":
|
||||
$ref: "#/components/responses/Error"
|
||||
post:
|
||||
tags: [projects]
|
||||
summary: 创建短剧项目
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"400":
|
||||
$ref: "#/components/responses/Error"
|
||||
/resources:
|
||||
get:
|
||||
tags: [resources]
|
||||
summary: 当前用户资源列表
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
post:
|
||||
tags: [resources]
|
||||
summary: 上传资源文件
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"403":
|
||||
description: 配额用尽(code=40301, reason=quota_exceeded)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/resources/{id}/file:
|
||||
get:
|
||||
tags: [resources]
|
||||
summary: 读取资源二进制(非 JSON 信封)
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: 文件流
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
"404":
|
||||
$ref: "#/components/responses/Error"
|
||||
/tasks:
|
||||
post:
|
||||
tags: [tasks]
|
||||
summary: 创建生成任务
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"400":
|
||||
$ref: "#/components/responses/Error"
|
||||
/tasks/{id}/text-events:
|
||||
get:
|
||||
tags: [tasks]
|
||||
summary: 文本任务 SSE
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: after
|
||||
schema: { type: integer }
|
||||
description: 递增事件游标,不要用任务 ID
|
||||
responses:
|
||||
"200":
|
||||
description: text/event-stream
|
||||
/assets:
|
||||
get:
|
||||
tags: [assets]
|
||||
summary: 用户素材库
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
components:
|
||||
securitySchemes:
|
||||
sessionCookie:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: open_ai_canvas_session
|
||||
schemas:
|
||||
Envelope:
|
||||
type: object
|
||||
required: [code, msg]
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
description: 0 成功;失败时默认等于 HTTP 状态,或 status*100+n 的细分码
|
||||
data: {}
|
||||
msg:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
description: 仅失败时出现。稳定机器可读原因。
|
||||
ErrorEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Envelope"
|
||||
- type: object
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
enum: [400, 401, 403, 404, 409, 429, 500, 502, 503, 504, 40301, 40901, 42901]
|
||||
data:
|
||||
nullable: true
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- invalid_argument
|
||||
- unauthorized
|
||||
- forbidden
|
||||
- not_found
|
||||
- conflict
|
||||
- failed_precondition
|
||||
- quota_exceeded
|
||||
- rate_limited
|
||||
- unavailable
|
||||
- timeout
|
||||
- internal
|
||||
- bad_gateway
|
||||
- model_capability_not_supported
|
||||
- model_price_not_configured
|
||||
- model_route_unavailable
|
||||
- provider_request_failed
|
||||
- model_catalog_mismatch
|
||||
- invalid_model_selection
|
||||
responses:
|
||||
Envelope:
|
||||
description: 业务成功信封
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
Error:
|
||||
description: 业务失败信封
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
6
backend/internal/handler/openapi_embed.go
Normal file
6
backend/internal/handler/openapi_embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package handler
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed openapi.yaml
|
||||
var openAPISpec []byte
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -265,8 +264,11 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
||||
page, limit, err := parsePaginationQuery(c, 30)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.AdminPaymentOrderPage(user, c.Query("status"), c.Query("keyword"), page, limit)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -324,8 +326,11 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
||||
page, limit, err := parsePaginationQuery(c, 30)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.AdminPaymentReconciliationPage(user, c.Query("providerId"), c.Query("status"), page, limit)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -339,8 +344,11 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
page, limit, err := parsePaginationQuery(c, 50)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.AdminPaymentReconciliationItems(user, c.Param("id"), c.Query("result"), page, limit)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
|
||||
@@ -32,7 +32,7 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
return
|
||||
}
|
||||
pageParam, hasPage := c.GetQuery("page")
|
||||
pageSizeParam, hasPageSize := c.GetQuery("page_size")
|
||||
pageSizeParam, hasPageSize := c.GetQuery("pageSize")
|
||||
if !hasPage && !hasPageSize {
|
||||
projects, err := svc.ListProjects(user.ID)
|
||||
if err != nil {
|
||||
@@ -385,7 +385,7 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
pageSize, err := parsePositiveQueryInt(c.Query("page_size"), 40)
|
||||
pageSize, err := parsePositiveQueryInt(c.Query("pageSize"), 40)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
@@ -404,7 +404,7 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
return
|
||||
}
|
||||
pageParam, hasPage := c.GetQuery("page")
|
||||
pageSizeParam, hasPageSize := c.GetQuery("page_size")
|
||||
pageSizeParam, hasPageSize := c.GetQuery("pageSize")
|
||||
if hasPage || hasPageSize {
|
||||
page, pageErr := parsePositiveQueryInt(pageParam, 1)
|
||||
if pageErr != nil {
|
||||
@@ -417,10 +417,10 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
return
|
||||
}
|
||||
var folderID *string
|
||||
if value, present := c.GetQuery("folder_id"); present {
|
||||
if value, present := c.GetQuery("folderId"); present {
|
||||
folderID = &value
|
||||
}
|
||||
assets, pageErr := svc.ProjectAssetsPage(user.ID, c.Param("id"), page, pageSize, c.Query("category"), c.Query("media_type"), c.Query("status"), folderID, c.Query("q"))
|
||||
assets, pageErr := svc.ProjectAssetsPage(user.ID, c.Param("id"), page, pageSize, c.Query("category"), c.Query("mediaType"), c.Query("status"), folderID, c.Query("q"))
|
||||
if pageErr != nil {
|
||||
failService(c, pageErr)
|
||||
return
|
||||
@@ -874,12 +874,12 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
pageSize, err := parsePositiveQueryInt(c.Query("page_size"), 100)
|
||||
pageSize, err := parsePositiveQueryInt(c.Query("pageSize"), 100)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.ProjectAssetCandidatesPage(user.ID, c.Param("id"), page, pageSize, c.Query("unit_id"), c.Query("status"), c.Query("category"), c.Query("q"))
|
||||
result, err := svc.ProjectAssetCandidatesPage(user.ID, c.Param("id"), page, pageSize, c.Query("unitId"), c.Query("status"), c.Query("category"), c.Query("q"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
@@ -907,6 +907,7 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
})
|
||||
}
|
||||
|
||||
// parsePositiveQueryInt 解析列表查询里的正整数。分页和筛选查询名统一用 camelCase:page、pageSize、projectId、folderId、mediaType、unitId。
|
||||
func parsePositiveQueryInt(value string, fallback int) (int, error) {
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
@@ -917,3 +918,18 @@ func parsePositiveQueryInt(value string, fallback int) (int, error) {
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// parsePaginationQuery 统一解析列表分页参数。
|
||||
// 缺省值由具体接口声明;显式传入非正整数属于请求错误,不能悄悄变成服务层默认值,
|
||||
// 否则客户端的协议问题会被掩盖,分页行为也会随服务层实现变化。
|
||||
func parsePaginationQuery(c *gin.Context, fallbackPageSize int) (int, int, error) {
|
||||
page, err := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("page: %w", err)
|
||||
}
|
||||
pageSize, err := parsePositiveQueryInt(c.Query("pageSize"), fallbackPageSize)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("pageSize: %w", err)
|
||||
}
|
||||
return page, pageSize, nil
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
const internalErrorMessage = "系统处理失败,请稍后重试"
|
||||
|
||||
func ok(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": data, "msg": "ok"})
|
||||
c.JSON(http.StatusOK, gin.H{"code": service.CodeOK, "data": data, "msg": "ok"})
|
||||
}
|
||||
|
||||
// fail 只接受调用方已经确认可公开的错误;service 返回值统一交给 failService 投影。
|
||||
@@ -24,50 +24,67 @@ func fail(c *gin.Context, status int, err error) {
|
||||
if err != nil && strings.TrimSpace(err.Error()) != "" {
|
||||
message = err.Error()
|
||||
}
|
||||
writeFailure(c, status, status, message)
|
||||
writeFailure(c, status, status, service.ReasonForStatus(status), message)
|
||||
}
|
||||
|
||||
func failService(c *gin.Context, err error) {
|
||||
var cooldown *service.EmailCodeCooldownError
|
||||
if errors.As(err, &cooldown) {
|
||||
c.Header("Retry-After", strconv.Itoa(cooldown.Seconds))
|
||||
fail(c, http.StatusTooManyRequests, cooldown)
|
||||
writeFailure(c, http.StatusTooManyRequests, service.CodeRateLimited, service.ReasonRateLimited, cooldown.Error())
|
||||
return
|
||||
}
|
||||
var modelErr *service.ModelError
|
||||
if errors.As(err, &modelErr) && modelErr.AppError != nil && validErrorStatus(modelErr.Status) {
|
||||
writeAppError(c, modelErr.AppError)
|
||||
return
|
||||
}
|
||||
var appErr *service.AppError
|
||||
if errors.As(err, &appErr) && validErrorStatus(appErr.Status) {
|
||||
code := appErr.Code
|
||||
if code == 0 {
|
||||
code = appErr.Status
|
||||
}
|
||||
message := strings.TrimSpace(appErr.Message)
|
||||
if message == "" {
|
||||
message = safeInternalErrorMessage(appErr.Status)
|
||||
}
|
||||
if appErr.Status >= http.StatusInternalServerError {
|
||||
diagnosticErr := appErr.Cause
|
||||
if diagnosticErr == nil {
|
||||
diagnosticErr = appErr
|
||||
}
|
||||
logHandlerError(c, appErr.Status, diagnosticErr)
|
||||
}
|
||||
writeFailure(c, appErr.Status, code, message)
|
||||
writeAppError(c, appErr)
|
||||
return
|
||||
}
|
||||
failInternal(c, http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
func writeAppError(c *gin.Context, appErr *service.AppError) {
|
||||
code := appErr.Code
|
||||
if code == 0 {
|
||||
code = appErr.Status
|
||||
}
|
||||
reason := appErr.Reason
|
||||
if reason == "" {
|
||||
reason = service.ReasonForStatus(appErr.Status)
|
||||
}
|
||||
message := strings.TrimSpace(appErr.Message)
|
||||
if message == "" {
|
||||
message = safeInternalErrorMessage(appErr.Status)
|
||||
}
|
||||
if appErr.Status >= http.StatusInternalServerError {
|
||||
diagnosticErr := appErr.Cause
|
||||
if diagnosticErr == nil {
|
||||
diagnosticErr = appErr
|
||||
}
|
||||
logHandlerError(c, appErr.Status, diagnosticErr)
|
||||
}
|
||||
writeFailure(c, appErr.Status, code, reason, message)
|
||||
}
|
||||
|
||||
// failInternal 保留真实 HTTP 状态,但绝不把未分类错误原文写入响应。
|
||||
func failInternal(c *gin.Context, status int, err error) {
|
||||
if !validErrorStatus(status) {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
logHandlerError(c, status, err)
|
||||
writeFailure(c, status, status, safeInternalErrorMessage(status))
|
||||
writeFailure(c, status, status, service.ReasonForStatus(status), safeInternalErrorMessage(status))
|
||||
}
|
||||
|
||||
func writeFailure(c *gin.Context, status int, code int, message string) {
|
||||
c.JSON(status, gin.H{"code": code, "data": nil, "msg": message})
|
||||
func writeFailure(c *gin.Context, status int, code int, reason service.ErrorReason, message string) {
|
||||
body := gin.H{"code": code, "data": nil, "msg": message}
|
||||
if reason != "" {
|
||||
body["reason"] = reason
|
||||
}
|
||||
c.JSON(status, body)
|
||||
}
|
||||
|
||||
func validErrorStatus(status int) bool {
|
||||
|
||||
@@ -14,32 +14,42 @@ import (
|
||||
)
|
||||
|
||||
type failureEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func TestFailServiceRegistrationCooldown(t *testing.T) {
|
||||
recorder, context := responseTestContext()
|
||||
failService(context, &service.EmailCodeCooldownError{Seconds: 47})
|
||||
response := decodeFailureEnvelope(t, recorder)
|
||||
if recorder.Code != http.StatusTooManyRequests || response.Code != 429 || recorder.Header().Get("Retry-After") != "47" || !strings.Contains(response.Msg, "47") {
|
||||
if recorder.Code != http.StatusTooManyRequests || response.Code != service.CodeRateLimited || response.Reason != string(service.ReasonRateLimited) || recorder.Header().Get("Retry-After") != "47" || !strings.Contains(response.Msg, "47") {
|
||||
t.Fatalf("cooldown response: status=%d header=%s body=%#v", recorder.Code, recorder.Header().Get("Retry-After"), response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailServiceProjectsAppError(t *testing.T) {
|
||||
recorder, context := responseTestContext()
|
||||
err := service.NewAppError(http.StatusTooManyRequests, "请求过于频繁,请稍后重试")
|
||||
err.Code = 42901
|
||||
err := service.RateLimited("请求过于频繁,请稍后重试")
|
||||
|
||||
failService(context, err)
|
||||
|
||||
response := decodeFailureEnvelope(t, recorder)
|
||||
if recorder.Code != http.StatusTooManyRequests || response.Code != 42901 || response.Msg != err.Message {
|
||||
if recorder.Code != http.StatusTooManyRequests || response.Code != service.CodeRateLimited || response.Reason != string(service.ReasonRateLimited) || response.Msg != err.Message {
|
||||
t.Fatalf("response = status %d, body %#v", recorder.Code, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailServiceQuotaExceeded(t *testing.T) {
|
||||
recorder, context := responseTestContext()
|
||||
failService(context, service.QuotaExceeded("账号素材数量已达到 100 个上限"))
|
||||
|
||||
response := decodeFailureEnvelope(t, recorder)
|
||||
if recorder.Code != http.StatusForbidden || response.Code != service.CodeQuotaExceeded || response.Reason != string(service.ReasonQuotaExceeded) {
|
||||
t.Fatalf("quota response = status %d, body %#v", recorder.Code, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailServiceHidesUnclassifiedInternalError(t *testing.T) {
|
||||
recorder, context := responseTestContext()
|
||||
failService(context, errors.New("database password=secret"))
|
||||
@@ -66,6 +76,40 @@ func TestFailInternalKeepsStatusWithoutLeakingCause(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePaginationQueryUsesDefaultsAndRejectsInvalidValues(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
page int
|
||||
pageSize int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "defaults", query: "", page: 1, pageSize: 40},
|
||||
{name: "explicit values", query: "page=3&pageSize=25", page: 3, pageSize: 25},
|
||||
{name: "invalid page", query: "page=abc", wantError: true},
|
||||
{name: "zero page", query: "page=0", wantError: true},
|
||||
{name: "negative page size", query: "pageSize=-1", wantError: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/test?"+tt.query, nil)
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = request
|
||||
page, pageSize, err := parsePaginationQuery(context, 40)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Fatalf("error = %v, wantError = %t", err, tt.wantError)
|
||||
}
|
||||
if tt.wantError {
|
||||
return
|
||||
}
|
||||
if page != tt.page || pageSize != tt.pageSize {
|
||||
t.Fatalf("pagination = (%d, %d), want (%d, %d)", page, pageSize, tt.page, tt.pageSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func responseTestContext() (*httptest.ResponseRecorder, *gin.Context) {
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
@@ -104,9 +104,13 @@ func RegisterTaskRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
pageSize, err := parsePositiveQueryInt(c.Query("pageSize"), 50)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
tasks, err := svc.TasksWithOptions(user.ID, service.TaskListOptions{
|
||||
Limit: limit,
|
||||
Limit: pageSize,
|
||||
ProjectID: c.Query("projectId"),
|
||||
ActiveOnly: c.Query("activeOnly") == "true",
|
||||
})
|
||||
@@ -155,7 +159,11 @@ func RegisterTaskRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
after, _ := strconv.ParseInt(c.DefaultQuery("after", "0"), 10, 64)
|
||||
after, err := taskTextEventCursor(c)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.TaskTextReplay(user.ID, c.Param("id"), after)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
|
||||
@@ -155,7 +155,7 @@ func enforceRateLimit(c *gin.Context, key string, limit int, window time.Duratio
|
||||
wait := runtimeService.RequestRetryAfter(c.Request.Context(), key, window)
|
||||
seconds := max(1, int((wait+time.Second-1)/time.Second))
|
||||
c.Header("Retry-After", strconv.Itoa(seconds))
|
||||
fail(c, http.StatusTooManyRequests, fmt.Errorf("请求次数已达上限,请在 %d 秒后重试", seconds))
|
||||
failService(c, service.RateLimited(fmt.Sprintf("请求次数已达上限,请在 %d 秒后重试", seconds)))
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,12 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, service.BadAuthRequest("请选择 Markdown 或 ZIP 技能文件"))
|
||||
return
|
||||
}
|
||||
isPrivate, err := parseOptionalBool(c.PostForm("is_private"))
|
||||
isPrivate, err := parseOptionalBool(c.PostForm("isPrivate"))
|
||||
if err != nil {
|
||||
failService(c, service.BadAuthRequest("is_private 必须是布尔值"))
|
||||
failService(c, service.BadAuthRequest("isPrivate 必须是布尔值"))
|
||||
return
|
||||
}
|
||||
sourceType := strings.ToLower(strings.TrimSpace(c.PostForm("source_type")))
|
||||
sourceType := strings.ToLower(strings.TrimSpace(c.PostForm("sourceType")))
|
||||
if sourceType == "" {
|
||||
switch strings.ToLower(filepath.Ext(file.Filename)) {
|
||||
case ".md", ".markdown":
|
||||
@@ -75,8 +75,11 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
page, pageSize, err := parsePaginationQuery(c, 20)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.Skills(user.ID, service.SkillListRequest{
|
||||
Page: page, PageSize: pageSize, Scope: c.DefaultQuery("scope", "public"),
|
||||
Search: c.Query("search"), Tag: c.Query("tag"), Sort: c.DefaultQuery("sort", "popular"),
|
||||
|
||||
@@ -37,6 +37,20 @@ func TestTaskTextEventCursorPrefersQueryAndSupportsLastEventID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskTextEventCursorRejectsInvalidValues(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, query := range []string{"after=abc", "after=-1"} {
|
||||
t.Run(query, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/api/tasks/task-1/text-events?"+query, nil)
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = request
|
||||
if after, err := taskTextEventCursor(context); err == nil || after != 0 {
|
||||
t.Fatalf("cursor = %d, err = %v; want a rejected cursor", after, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTaskTextEventsCachedReplayReachesTerminalWithoutDuplicateDelta(t *testing.T) {
|
||||
t.Setenv("REDIS_URL", "")
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
@@ -142,8 +142,12 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200"))
|
||||
resources, err := svc.Resources(user.ID, limit)
|
||||
pageSize, err := parsePositiveQueryInt(c.Query("pageSize"), 200)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
resources, err := svc.Resources(user.ID, pageSize)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
@@ -406,18 +410,13 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
return
|
||||
}
|
||||
if _, paged := c.GetQuery("page"); paged || hasUserAssetPageFilters(c) {
|
||||
page, pageErr := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if pageErr != nil || page < 1 {
|
||||
fail(c, http.StatusBadRequest, service.BadAuthRequest("页码必须是正整数"))
|
||||
return
|
||||
}
|
||||
pageSize, pageSizeErr := strconv.Atoi(c.DefaultQuery("page_size", "40"))
|
||||
if pageSizeErr != nil || pageSize < 1 {
|
||||
fail(c, http.StatusBadRequest, service.BadAuthRequest("每页数量必须是正整数"))
|
||||
page, pageSize, pageErr := parsePaginationQuery(c, 40)
|
||||
if pageErr != nil {
|
||||
fail(c, http.StatusBadRequest, pageErr)
|
||||
return
|
||||
}
|
||||
var folderID *string
|
||||
if value, present := c.GetQuery("folder_id"); present {
|
||||
if value, present := c.GetQuery("folderId"); present {
|
||||
folderID = &value
|
||||
}
|
||||
assets, pageErr := svc.UserAssetsPage(user.ID, page, pageSize, service.UserAssetPageFilter{
|
||||
@@ -593,9 +592,12 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
return
|
||||
}
|
||||
if c.Query("page") != "" {
|
||||
page, _ := strconv.Atoi(c.Query("page"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
result, pageErr := svc.UserCanvasProjectsPage(user.ID, page, pageSize, c.Query("project_id"), c.Query("q"), c.Query("sort"))
|
||||
page, pageSize, pageErr := parsePaginationQuery(c, 40)
|
||||
if pageErr != nil {
|
||||
fail(c, http.StatusBadRequest, pageErr)
|
||||
return
|
||||
}
|
||||
result, pageErr := svc.UserCanvasProjectsPage(user.ID, page, pageSize, c.Query("projectId"), c.Query("q"), c.Query("sort"))
|
||||
if pageErr != nil {
|
||||
failService(c, pageErr)
|
||||
return
|
||||
@@ -670,7 +672,7 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
|
||||
func hasUserAssetPageFilters(c *gin.Context) bool {
|
||||
for _, key := range []string{"page_size", "kind", "category", "folder_id", "uncategorized", "status", "q"} {
|
||||
for _, key := range []string{"pageSize", "kind", "category", "folderId", "uncategorized", "status", "q"} {
|
||||
if _, present := c.GetQuery(key); present {
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user