本地 K3s 节点此前在 Kubelet 中登记为宿主机资源,负载可挤压 etcd 并在 server 重启后继续污染同一 Run。现在为三节点设置真实可调度预算、独立 etcd/数据卷和锁定依赖镜像,并持续核对 server 与 API/etcd 健康。\n\n每次验收生成独立 Run ID,报告使用独占或原子写入,负载错误记录具体阶段;数据库鉴权不可用返回带 Retry-After 的 503,避免基础设施故障被误报为 401。\n\n验证:Go 全量测试、go vet、gofmt、OpenAPI、bash -n、ShellCheck、报告测试和 k3d 配置解析均通过。
196 lines
6.0 KiB
Go
196 lines
6.0 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"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
|
|
if requestErr.RetryAfterSeconds > 0 {
|
|
w.Header().Set("Retry-After", strconv.Itoa(requestErr.RetryAfterSeconds))
|
|
}
|
|
}
|
|
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,
|
|
}})
|
|
}
|