将同平台重试、跨平台动作、健康副作用和冷却排队统一到单一失败决策,避免旧降级策略与 failover 重复执行。\n\n增加事务级单源保护、候选实时刷新、冷却错误契约、管理接口严格校验及兼容策略只读展示。\n\n验证:真实 Gateway HTTP/PostgreSQL 接受测试 10 项通过;go test ./...、pnpm openapi、pnpm lint、pnpm test、pnpm build 均通过。
367 lines
12 KiB
Go
367 lines
12 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
)
|
|
|
|
// listRuntimePolicySets godoc
|
|
// @Summary 列出运行策略集
|
|
// @Description 管理端返回可分配给平台、模型或用户组的运行策略集。
|
|
// @Tags runtime
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Success 200 {object} RuntimePolicySetListResponse
|
|
// @Failure 401 {object} ErrorEnvelope
|
|
// @Failure 403 {object} ErrorEnvelope
|
|
// @Failure 500 {object} ErrorEnvelope
|
|
// @Router /api/admin/runtime/policy-sets [get]
|
|
func (s *Server) listRuntimePolicySets(w http.ResponseWriter, r *http.Request) {
|
|
items, err := s.store.ListRuntimePolicySets(r.Context())
|
|
if err != nil {
|
|
s.logger.Error("list runtime policy sets failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "list runtime policy sets failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
}
|
|
|
|
// getRunnerPolicy godoc
|
|
// @Summary 获取 Runner 策略
|
|
// @Description 管理端获取当前生效的默认 Runner 调度策略。
|
|
// @Tags runtime
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Success 200 {object} store.RunnerPolicy
|
|
// @Failure 401 {object} ErrorEnvelope
|
|
// @Failure 403 {object} ErrorEnvelope
|
|
// @Failure 500 {object} ErrorEnvelope
|
|
// @Router /api/admin/runtime/runner-policy [get]
|
|
func (s *Server) getRunnerPolicy(w http.ResponseWriter, r *http.Request) {
|
|
item, err := s.store.GetActiveRunnerPolicy(r.Context())
|
|
if err != nil {
|
|
s.logger.Error("get runner policy failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "get runner policy failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, item)
|
|
}
|
|
|
|
// updateRunnerPolicy godoc
|
|
// @Summary 更新 Runner 策略
|
|
// @Description 管理端写入默认 Runner 调度策略。
|
|
// @Tags runtime
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param input body store.RunnerPolicyInput true "Runner 策略请求"
|
|
// @Success 200 {object} store.RunnerPolicy
|
|
// @Failure 400 {object} ErrorEnvelope
|
|
// @Failure 401 {object} ErrorEnvelope
|
|
// @Failure 403 {object} ErrorEnvelope
|
|
// @Failure 500 {object} ErrorEnvelope
|
|
// @Router /api/admin/runtime/runner-policy [patch]
|
|
func (s *Server) updateRunnerPolicy(w http.ResponseWriter, r *http.Request) {
|
|
var input store.RunnerPolicyInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
|
return
|
|
}
|
|
if err := validateRunnerPolicyInput(input); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
|
|
return
|
|
}
|
|
item, err := s.store.UpsertDefaultRunnerPolicy(r.Context(), input)
|
|
if err != nil {
|
|
s.logger.Error("update runner policy failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "update runner policy failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, item)
|
|
}
|
|
|
|
func validateRunnerPolicyInput(input store.RunnerPolicyInput) error {
|
|
rawRules, exists := input.FailoverPolicy["actionRules"]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
rules, ok := rawRules.([]any)
|
|
if !ok {
|
|
return errors.New("failoverPolicy.actionRules must be an array")
|
|
}
|
|
validActions := map[string]bool{
|
|
"stop": true, "next": true, "cooldown_and_next": true, "demote_and_next": true, "disable_and_next": true,
|
|
}
|
|
for index, rawRule := range rules {
|
|
rule, ok := rawRule.(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("failoverPolicy.actionRules[%d] must be an object", index)
|
|
}
|
|
action := strings.TrimSpace(stringValue(rule["action"]))
|
|
if !validActions[action] {
|
|
return fmt.Errorf("failoverPolicy.actionRules[%d].action is invalid", index)
|
|
}
|
|
if target, present := rule["target"]; present {
|
|
targetValue := strings.TrimSpace(stringValue(target))
|
|
if targetValue != "model" && targetValue != "platform" {
|
|
return fmt.Errorf("failoverPolicy.actionRules[%d].target is invalid", index)
|
|
}
|
|
}
|
|
if !runnerActionRuleHasMatch(rule) {
|
|
return fmt.Errorf("failoverPolicy.actionRules[%d] must include at least one match condition", index)
|
|
}
|
|
if action == "cooldown_and_next" && positiveJSONInteger(rule["cooldownSeconds"]) <= 0 {
|
|
return fmt.Errorf("failoverPolicy.actionRules[%d].cooldownSeconds must be a positive integer", index)
|
|
}
|
|
if action == "demote_and_next" && positiveJSONInteger(rule["demoteSteps"]) <= 0 {
|
|
return fmt.Errorf("failoverPolicy.actionRules[%d].demoteSteps must be a positive integer", index)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runnerActionRuleHasMatch(rule map[string]any) bool {
|
|
for _, key := range []string{"categories", "codes", "errorCodes", "statusCodes", "keywords"} {
|
|
switch values := rule[key].(type) {
|
|
case []any:
|
|
for _, value := range values {
|
|
if strings.TrimSpace(fmt.Sprint(value)) != "" {
|
|
return true
|
|
}
|
|
}
|
|
case []string:
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func positiveJSONInteger(value any) int {
|
|
switch typed := value.(type) {
|
|
case int:
|
|
if typed > 0 {
|
|
return typed
|
|
}
|
|
case int64:
|
|
if typed > 0 {
|
|
return int(typed)
|
|
}
|
|
case float64:
|
|
if typed > 0 && typed == float64(int(typed)) {
|
|
return int(typed)
|
|
}
|
|
case json.Number:
|
|
parsed, err := typed.Int64()
|
|
if err == nil && parsed > 0 {
|
|
return int(parsed)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
type updatePlatformDynamicPriorityRequest struct {
|
|
DynamicPriority *int `json:"dynamicPriority" example:"10"`
|
|
Reset bool `json:"reset" example:"false"`
|
|
}
|
|
|
|
// updatePlatformDynamicPriority godoc
|
|
// @Summary 更新平台动态优先级
|
|
// @Description 管理端调整平台运行时动态优先级;reset 为 true 时清空动态值。
|
|
// @Tags runtime
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param platformID path string true "平台 ID"
|
|
// @Param input body updatePlatformDynamicPriorityRequest true "动态优先级请求"
|
|
// @Success 200 {object} store.Platform
|
|
// @Failure 400 {object} ErrorEnvelope
|
|
// @Failure 401 {object} ErrorEnvelope
|
|
// @Failure 403 {object} ErrorEnvelope
|
|
// @Failure 404 {object} ErrorEnvelope
|
|
// @Failure 500 {object} ErrorEnvelope
|
|
// @Router /api/admin/platforms/{platformID}/dynamic-priority [patch]
|
|
func (s *Server) updatePlatformDynamicPriority(w http.ResponseWriter, r *http.Request) {
|
|
var input updatePlatformDynamicPriorityRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
|
return
|
|
}
|
|
var dynamicPriority *int
|
|
if input.Reset {
|
|
dynamicPriority = nil
|
|
} else {
|
|
if input.DynamicPriority == nil {
|
|
writeError(w, http.StatusBadRequest, "dynamicPriority is required unless reset is true")
|
|
return
|
|
}
|
|
if *input.DynamicPriority < 0 {
|
|
writeError(w, http.StatusBadRequest, "dynamicPriority must be greater than or equal to 0")
|
|
return
|
|
}
|
|
dynamicPriority = input.DynamicPriority
|
|
}
|
|
item, err := s.store.UpdatePlatformDynamicPriority(r.Context(), r.PathValue("platformID"), dynamicPriority)
|
|
if err != nil {
|
|
if store.IsNotFound(err) {
|
|
writeError(w, http.StatusNotFound, "platform not found")
|
|
return
|
|
}
|
|
s.logger.Error("update platform dynamic priority failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "update platform dynamic priority failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, item)
|
|
}
|
|
|
|
// restorePlatformModelRuntimeStatus godoc
|
|
// @Summary 恢复平台模型运行状态
|
|
// @Description 管理端手动解除平台模型停用、模型冷却、平台冷却或平台禁用状态,使其重新参与路由。
|
|
// @Tags runtime
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param platformModelID path string true "平台模型 ID"
|
|
// @Success 200 {object} store.ModelRateLimitStatus
|
|
// @Failure 401 {object} ErrorEnvelope
|
|
// @Failure 403 {object} ErrorEnvelope
|
|
// @Failure 404 {object} ErrorEnvelope
|
|
// @Failure 500 {object} ErrorEnvelope
|
|
// @Router /api/admin/runtime/model-rate-limits/{platformModelID}/restore [post]
|
|
func (s *Server) restorePlatformModelRuntimeStatus(w http.ResponseWriter, r *http.Request) {
|
|
item, err := s.store.RestorePlatformModelRuntimeStatus(r.Context(), r.PathValue("platformModelID"))
|
|
if err != nil {
|
|
if store.IsNotFound(err) {
|
|
writeError(w, http.StatusNotFound, "platform model not found")
|
|
return
|
|
}
|
|
s.logger.Error("restore platform model runtime status failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "restore platform model runtime status failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, item)
|
|
}
|
|
|
|
// createRuntimePolicySet godoc
|
|
// @Summary 创建运行策略集
|
|
// @Description 管理端创建运行策略集,policyKey 和 name 必填。
|
|
// @Tags runtime
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param input body store.RuntimePolicySetInput true "运行策略集请求"
|
|
// @Success 201 {object} store.RuntimePolicySet
|
|
// @Failure 400 {object} ErrorEnvelope
|
|
// @Failure 401 {object} ErrorEnvelope
|
|
// @Failure 403 {object} ErrorEnvelope
|
|
// @Failure 409 {object} ErrorEnvelope
|
|
// @Failure 500 {object} ErrorEnvelope
|
|
// @Router /api/admin/runtime/policy-sets [post]
|
|
func (s *Server) createRuntimePolicySet(w http.ResponseWriter, r *http.Request) {
|
|
var input store.RuntimePolicySetInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
|
return
|
|
}
|
|
if !validRuntimePolicyInput(input) {
|
|
writeError(w, http.StatusBadRequest, "policyKey and name are required")
|
|
return
|
|
}
|
|
item, err := s.store.CreateRuntimePolicySet(r.Context(), input)
|
|
if err != nil {
|
|
if store.IsUniqueViolation(err) {
|
|
writeError(w, http.StatusConflict, "runtime policy key already exists")
|
|
return
|
|
}
|
|
s.logger.Error("create runtime policy set failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "create runtime policy set failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, item)
|
|
}
|
|
|
|
// updateRuntimePolicySet godoc
|
|
// @Summary 更新运行策略集
|
|
// @Description 管理端更新运行策略集及其限流、重试、超时等策略配置。
|
|
// @Tags runtime
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param policySetID path string true "运行策略集 ID"
|
|
// @Param input body store.RuntimePolicySetInput true "运行策略集请求"
|
|
// @Success 200 {object} store.RuntimePolicySet
|
|
// @Failure 400 {object} ErrorEnvelope
|
|
// @Failure 401 {object} ErrorEnvelope
|
|
// @Failure 403 {object} ErrorEnvelope
|
|
// @Failure 404 {object} ErrorEnvelope
|
|
// @Failure 409 {object} ErrorEnvelope
|
|
// @Failure 500 {object} ErrorEnvelope
|
|
// @Router /api/admin/runtime/policy-sets/{policySetID} [patch]
|
|
func (s *Server) updateRuntimePolicySet(w http.ResponseWriter, r *http.Request) {
|
|
var input store.RuntimePolicySetInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
|
return
|
|
}
|
|
if !validRuntimePolicyInput(input) {
|
|
writeError(w, http.StatusBadRequest, "policyKey and name are required")
|
|
return
|
|
}
|
|
item, err := s.store.UpdateRuntimePolicySet(r.Context(), r.PathValue("policySetID"), input)
|
|
if err != nil {
|
|
if store.IsNotFound(err) {
|
|
writeError(w, http.StatusNotFound, "runtime policy set not found")
|
|
return
|
|
}
|
|
if store.IsUniqueViolation(err) {
|
|
writeError(w, http.StatusConflict, "runtime policy key already exists")
|
|
return
|
|
}
|
|
s.logger.Error("update runtime policy set failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "update runtime policy set failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, item)
|
|
}
|
|
|
|
// deleteRuntimePolicySet godoc
|
|
// @Summary 删除运行策略集
|
|
// @Description 管理端删除非默认运行策略集;默认策略集受保护。
|
|
// @Tags runtime
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param policySetID path string true "运行策略集 ID"
|
|
// @Success 204 "No Content"
|
|
// @Failure 401 {object} ErrorEnvelope
|
|
// @Failure 403 {object} ErrorEnvelope
|
|
// @Failure 404 {object} ErrorEnvelope
|
|
// @Failure 500 {object} ErrorEnvelope
|
|
// @Router /api/admin/runtime/policy-sets/{policySetID} [delete]
|
|
func (s *Server) deleteRuntimePolicySet(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.store.DeleteRuntimePolicySet(r.Context(), r.PathValue("policySetID")); err != nil {
|
|
if store.IsNotFound(err) {
|
|
writeError(w, http.StatusNotFound, "runtime policy set not found")
|
|
return
|
|
}
|
|
if errors.Is(err, store.ErrProtectedDefault) {
|
|
writeError(w, http.StatusForbidden, "default runtime policy set cannot be deleted")
|
|
return
|
|
}
|
|
s.logger.Error("delete runtime policy set failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "delete runtime policy set failed")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func validRuntimePolicyInput(input store.RuntimePolicySetInput) bool {
|
|
return strings.TrimSpace(input.PolicyKey) != "" && strings.TrimSpace(input.Name) != ""
|
|
}
|