refactor(runner): 收敛上游失败决策与轮转策略

将同平台重试、跨平台动作、健康副作用和冷却排队统一到单一失败决策,避免旧降级策略与 failover 重复执行。\n\n增加事务级单源保护、候选实时刷新、冷却错误契约、管理接口严格校验及兼容策略只读展示。\n\n验证:真实 Gateway HTTP/PostgreSQL 接受测试 10 项通过;go test ./...、pnpm openapi、pnpm lint、pnpm test、pnpm build 均通过。
This commit is contained in:
2026-07-27 23:50:17 +08:00
parent 046c16fc69
commit 31565af07a
19 changed files with 1988 additions and 307 deletions
@@ -3,6 +3,7 @@ package httpapi
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -71,6 +72,10 @@ func (s *Server) updateRunnerPolicy(w http.ResponseWriter, r *http.Request) {
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)
@@ -80,6 +85,89 @@ func (s *Server) updateRunnerPolicy(w http.ResponseWriter, r *http.Request) {
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"`