refactor(core): 任务与模型协议治理 - 拆分服务职责并禁止已发起任务取消
This commit is contained in:
@@ -786,7 +786,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
log := apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), model.ApiCallStatusFailed, 0, time.Since(startedAt), err.Error(), concurrencyLimit)
|
||||
log.ErrorCode, log.Error = service.ChannelSlotFailureDetails(err)
|
||||
logSystemProxyCall(svc, log, nil)
|
||||
fail(c, http.StatusServiceUnavailable, err)
|
||||
failInternal(c, http.StatusServiceUnavailable, err)
|
||||
return
|
||||
}
|
||||
defer releaseChannel()
|
||||
@@ -976,12 +976,3 @@ func clearSessionCookie(c *gin.Context) {
|
||||
Secure: secure,
|
||||
})
|
||||
}
|
||||
|
||||
func failService(c *gin.Context, err error) {
|
||||
var authErr *service.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
fail(c, authErr.Status, errors.New(authErr.Message))
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
projects, err := svc.ListProjects(user.ID)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err)
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"projects": projects})
|
||||
@@ -67,7 +67,7 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
fail(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusInternalServerError, err)
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, detail)
|
||||
|
||||
98
backend/internal/handler/response.go
Normal file
98
backend/internal/handler/response.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const internalErrorMessage = "系统处理失败,请稍后重试"
|
||||
|
||||
func ok(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": data, "msg": "ok"})
|
||||
}
|
||||
|
||||
// fail 只接受调用方已经确认可公开的错误;service 返回值统一交给 failService 投影。
|
||||
func fail(c *gin.Context, status int, err error) {
|
||||
message := http.StatusText(status)
|
||||
if err != nil && strings.TrimSpace(err.Error()) != "" {
|
||||
message = err.Error()
|
||||
}
|
||||
writeFailure(c, status, status, message)
|
||||
}
|
||||
|
||||
func failService(c *gin.Context, err error) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
failInternal(c, http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
func writeFailure(c *gin.Context, status int, code int, message string) {
|
||||
c.JSON(status, gin.H{"code": code, "data": nil, "msg": message})
|
||||
}
|
||||
|
||||
func validErrorStatus(status int) bool {
|
||||
return status >= http.StatusBadRequest && status <= 599
|
||||
}
|
||||
|
||||
func safeInternalErrorMessage(status int) string {
|
||||
switch status {
|
||||
case http.StatusBadGateway:
|
||||
return "上游服务暂时不可用,请稍后重试"
|
||||
case http.StatusServiceUnavailable:
|
||||
return "服务暂时不可用,请稍后重试"
|
||||
case http.StatusGatewayTimeout:
|
||||
return "上游服务响应超时,请稍后重试"
|
||||
default:
|
||||
return internalErrorMessage
|
||||
}
|
||||
}
|
||||
|
||||
func logHandlerError(c *gin.Context, status int, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
method := ""
|
||||
route := ""
|
||||
if c != nil && c.Request != nil {
|
||||
method = c.Request.Method
|
||||
route = c.FullPath()
|
||||
if route == "" {
|
||||
route = "<unmatched>"
|
||||
}
|
||||
}
|
||||
// 普通访问日志会输出 Gin error;这里只记录错误类型,避免密钥或上游响应体进入日志。
|
||||
log.Printf("handler request failed: method=%s route=%s status=%d error_type=%T", method, route, status, err)
|
||||
}
|
||||
74
backend/internal/handler/response_test.go
Normal file
74
backend/internal/handler/response_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type failureEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
func TestFailServiceProjectsAppError(t *testing.T) {
|
||||
recorder, context := responseTestContext()
|
||||
err := service.NewAppError(http.StatusTooManyRequests, "请求过于频繁,请稍后重试")
|
||||
err.Code = 42901
|
||||
|
||||
failService(context, err)
|
||||
|
||||
response := decodeFailureEnvelope(t, recorder)
|
||||
if recorder.Code != http.StatusTooManyRequests || response.Code != 42901 || response.Msg != err.Message {
|
||||
t.Fatalf("response = status %d, body %#v", recorder.Code, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailServiceHidesUnclassifiedInternalError(t *testing.T) {
|
||||
recorder, context := responseTestContext()
|
||||
failService(context, errors.New("database password=secret"))
|
||||
|
||||
response := decodeFailureEnvelope(t, recorder)
|
||||
if recorder.Code != http.StatusInternalServerError || response.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("response = status %d, body %#v", recorder.Code, response)
|
||||
}
|
||||
if response.Msg != internalErrorMessage || strings.Contains(recorder.Body.String(), "password=secret") {
|
||||
t.Fatalf("internal error leaked in response: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailInternalKeepsStatusWithoutLeakingCause(t *testing.T) {
|
||||
recorder, context := responseTestContext()
|
||||
failInternal(context, http.StatusServiceUnavailable, errors.New("redis://user:password@private-host"))
|
||||
|
||||
response := decodeFailureEnvelope(t, recorder)
|
||||
if recorder.Code != http.StatusServiceUnavailable || response.Msg != "服务暂时不可用,请稍后重试" {
|
||||
t.Fatalf("response = status %d, body %#v", recorder.Code, response)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "private-host") {
|
||||
t.Fatalf("internal cause leaked in response: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func responseTestContext() (*httptest.ResponseRecorder, *gin.Context) {
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
||||
return recorder, context
|
||||
}
|
||||
|
||||
func decodeFailureEnvelope(t *testing.T, recorder *httptest.ResponseRecorder) failureEnvelope {
|
||||
t.Helper()
|
||||
var response failureEnvelope
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func RegisterTaskRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
ActiveOnly: c.Query("activeOnly") == "true",
|
||||
})
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err)
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, tasks)
|
||||
@@ -200,7 +200,7 @@ func RegisterTaskRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
logs, err := svc.TaskLogs(user.ID, c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err)
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, logs)
|
||||
@@ -332,7 +332,7 @@ func RegisterSessionRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
item, err := svc.StoreUpload(user.ID, c.PostForm("sessionId"), file)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err)
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, item)
|
||||
@@ -354,17 +354,4 @@ func RegisterSessionRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.GET("/sessions/:id", querySession)
|
||||
r.POST("/files", uploadFile)
|
||||
r.GET("/sessions/:id/results", downloadResults)
|
||||
// 兼容旧客户端的废弃路由;新调用统一使用上方 REST 风格路径。
|
||||
r.POST("/create_session", createSession)
|
||||
r.GET("/query_session/:id", querySession)
|
||||
r.POST("/upload_file", uploadFile)
|
||||
r.GET("/download_results/:id", downloadResults)
|
||||
}
|
||||
|
||||
func ok(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": data, "msg": "ok"})
|
||||
}
|
||||
|
||||
func fail(c *gin.Context, status int, err error) {
|
||||
c.JSON(status, gin.H{"code": status, "data": nil, "msg": err.Error()})
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func enforceRateLimit(c *gin.Context, key string, limit int, window time.Duratio
|
||||
func loadRuntimePolicy(c *gin.Context, svc *service.Service) (service.RuntimePolicySetting, bool) {
|
||||
policy, err := svc.RuntimePolicy()
|
||||
if err != nil {
|
||||
fail(c, http.StatusServiceUnavailable, errors.New("读取运行时策略失败:"+err.Error()))
|
||||
failInternal(c, http.StatusServiceUnavailable, err)
|
||||
return service.RuntimePolicySetting{}, false
|
||||
}
|
||||
return policy, true
|
||||
|
||||
Reference in New Issue
Block a user