refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态

This commit is contained in:
ddCat
2026-09-09 22:02:10 +08:00
parent bacd502f5c
commit b5f3413ded
298 changed files with 12681 additions and 26428 deletions

View File

@@ -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,

View File

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

View 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)
})
}

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

View File

@@ -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"

View File

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

View File

@@ -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("系统渠道模型报价功能尚未实现"))
}
})
}

View 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"

View File

@@ -0,0 +1,6 @@
package handler
import _ "embed"
//go:embed openapi.yaml
var openAPISpec []byte

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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"),

View File

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

View File

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