将同平台重试、跨平台动作、健康副作用和冷却排队统一到单一失败决策,避免旧降级策略与 failover 重复执行。\n\n增加事务级单源保护、候选实时刷新、冷却错误契约、管理接口严格校验及兼容策略只读展示。\n\n验证:真实 Gateway HTTP/PostgreSQL 接受测试 10 项通过;go test ./...、pnpm openapi、pnpm lint、pnpm test、pnpm build 均通过。
192 lines
5.9 KiB
Go
192 lines
5.9 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
)
|
|
|
|
func (s *Server) requireProtocolUser(protocol string, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
user, err := s.auth.Authenticate(r)
|
|
if err != nil {
|
|
status, code, message := http.StatusUnauthorized, "unauthorized", "unauthorized"
|
|
var requestErr *auth.RequestAuthError
|
|
if errors.As(err, &requestErr) {
|
|
status, code, message = requestErr.Status, requestErr.Code, requestErr.Message
|
|
}
|
|
writeProtocolError(w, protocol, status, message, nil, code)
|
|
return
|
|
}
|
|
if auth.PermissionLevel(user.Roles) < 1 {
|
|
writeProtocolError(w, protocol, http.StatusForbidden, "forbidden", nil, "permission_denied")
|
|
return
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(user.Source), "oidc") {
|
|
result, resolveErr := s.resolveOIDCUserProjection(r.Context(), r, user)
|
|
if resolveErr != nil {
|
|
status, code, message := http.StatusServiceUnavailable, errorCodeGatewayProvisioningFailed, "Gateway 账号初始化失败,请稍后重试"
|
|
switch {
|
|
case errors.Is(resolveErr, store.ErrOIDCUserNotProvisioned):
|
|
status, code, message = http.StatusForbidden, errorCodeGatewayUserNotProvisioned, "该账号尚未开通 EasyAI Gateway"
|
|
case errors.Is(resolveErr, store.ErrOIDCUserDisabled):
|
|
status, code, message = http.StatusForbidden, errorCodeGatewayUserDisabled, "该 Gateway 账号已停用,请联系管理员"
|
|
case errors.Is(resolveErr, store.ErrOIDCTenantUnavailable):
|
|
status, code, message = http.StatusServiceUnavailable, errorCodeGatewayTenantUnavailable, "Gateway 租户尚未就绪,请联系管理员"
|
|
}
|
|
writeProtocolError(w, protocol, status, message, nil, code)
|
|
return
|
|
}
|
|
user = result.User
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(auth.WithUser(r.Context(), user)))
|
|
})
|
|
}
|
|
|
|
func targetProtocolForTaskRequest(kind string, r *http.Request) string {
|
|
switch kind {
|
|
case "chat.completions":
|
|
return clients.ProtocolOpenAIChatCompletions
|
|
case "responses":
|
|
return clients.ProtocolOpenAIResponses
|
|
case "embeddings":
|
|
return clients.ProtocolOpenAIEmbeddings
|
|
case "images.generations", "images.edits":
|
|
return clients.ProtocolOpenAIImages
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func wireResponseMatches(wire *clients.WireResponse, targetProtocol string) bool {
|
|
return wire != nil && !wire.Converted && strings.TrimSpace(wire.Protocol) == strings.TrimSpace(targetProtocol)
|
|
}
|
|
|
|
func writeWireResponse(w http.ResponseWriter, wire *clients.WireResponse) {
|
|
if wire == nil {
|
|
return
|
|
}
|
|
for name, values := range wire.Headers {
|
|
for _, value := range values {
|
|
if strings.EqualFold(name, "Content-Type") {
|
|
w.Header().Set(name, value)
|
|
} else {
|
|
w.Header().Add(name, value)
|
|
}
|
|
}
|
|
}
|
|
status := wire.StatusCode
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
w.WriteHeader(status)
|
|
if len(wire.RawJSON) > 0 {
|
|
_, _ = w.Write(wire.RawJSON)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(wire.Body)
|
|
}
|
|
|
|
func writeProtocolError(w http.ResponseWriter, protocol string, status int, message string, details map[string]any, code string) {
|
|
switch protocol {
|
|
case clients.ProtocolGeminiGenerateContent:
|
|
writeGeminiError(w, status, message, details, code)
|
|
case clients.ProtocolVolcesContents:
|
|
writeVolcesError(w, status, message, code)
|
|
case clients.ProtocolKlingV1Omni, clients.ProtocolKlingV2Omni:
|
|
writeKelingCompatError(w, "", newKelingCompatError(status, kelingCompatBusinessCode(code, message), message))
|
|
default:
|
|
writeOpenAIError(w, status, message, details, code)
|
|
}
|
|
}
|
|
|
|
func writeOpenAIError(w http.ResponseWriter, status int, message string, details map[string]any, code string) {
|
|
errorType := "server_error"
|
|
if status >= 400 && status < 500 {
|
|
errorType = "invalid_request_error"
|
|
}
|
|
var param any
|
|
if details != nil {
|
|
param = details["param"]
|
|
}
|
|
payload := map[string]any{
|
|
"message": message,
|
|
"type": errorType,
|
|
"param": param,
|
|
"code": nil,
|
|
}
|
|
if strings.TrimSpace(code) != "" {
|
|
payload["code"] = code
|
|
}
|
|
if len(details) > 0 {
|
|
publicDetails := map[string]any{}
|
|
for key, value := range details {
|
|
if key != "param" {
|
|
publicDetails[key] = value
|
|
}
|
|
}
|
|
if len(publicDetails) > 0 {
|
|
payload["details"] = publicDetails
|
|
}
|
|
}
|
|
writeJSON(w, status, map[string]any{"error": payload})
|
|
}
|
|
|
|
func writeGeminiError(w http.ResponseWriter, status int, message string, details map[string]any, code string) {
|
|
writeJSON(w, status, geminiErrorEnvelope(status, message, details, code))
|
|
}
|
|
|
|
func geminiErrorEnvelope(status int, message string, details map[string]any, code string) map[string]any {
|
|
detailList := []any{}
|
|
if len(details) > 0 {
|
|
detailList = append(detailList, details)
|
|
}
|
|
return map[string]any{"error": map[string]any{
|
|
"code": status,
|
|
"message": message,
|
|
"status": googleRPCStatus(status, code),
|
|
"details": detailList,
|
|
}}
|
|
}
|
|
|
|
func googleRPCStatus(status int, code string) string {
|
|
switch status {
|
|
case http.StatusBadRequest:
|
|
return "INVALID_ARGUMENT"
|
|
case http.StatusUnauthorized:
|
|
return "UNAUTHENTICATED"
|
|
case http.StatusForbidden:
|
|
return "PERMISSION_DENIED"
|
|
case http.StatusNotFound:
|
|
return "NOT_FOUND"
|
|
case http.StatusConflict:
|
|
return "ALREADY_EXISTS"
|
|
case http.StatusTooManyRequests:
|
|
return "RESOURCE_EXHAUSTED"
|
|
case http.StatusServiceUnavailable:
|
|
return "UNAVAILABLE"
|
|
case http.StatusGatewayTimeout:
|
|
return "DEADLINE_EXCEEDED"
|
|
default:
|
|
if strings.EqualFold(code, "cancelled") || strings.EqualFold(code, "canceled") {
|
|
return "CANCELLED"
|
|
}
|
|
return "INTERNAL"
|
|
}
|
|
}
|
|
|
|
func writeVolcesError(w http.ResponseWriter, status int, message string, code string) {
|
|
if strings.TrimSpace(code) == "" {
|
|
code = http.StatusText(status)
|
|
}
|
|
writeJSON(w, status, map[string]any{"error": map[string]any{
|
|
"code": code,
|
|
"message": message,
|
|
}})
|
|
}
|