From 36f11fa84694c6190faf3f54d005bc1ca34f3384 Mon Sep 17 00:00:00 2001 From: openclaw Date: Sun, 15 Mar 2026 11:09:26 +0800 Subject: [PATCH] fix: harden ops runbooks and execution --- cmd/main.go | 42 +- config/config.go | 34 + internal/channel/channel.go | 86 ++- internal/core/ops/retry.go | 60 ++ internal/core/runbook/cancel.go | 32 + internal/core/runbook/executor.go | 387 ++++++++++ internal/core/runbook/targets.go | 37 + internal/web/server.go | 1185 +++++++++++++++++++++++++++-- runbooks/cpa_usage_backup.yaml | 38 + runbooks/cpa_usage_restore.yaml | 112 +++ 10 files changed, 1912 insertions(+), 101 deletions(-) create mode 100644 internal/core/ops/retry.go create mode 100644 internal/core/runbook/cancel.go create mode 100644 internal/core/runbook/executor.go create mode 100644 internal/core/runbook/targets.go create mode 100644 runbooks/cpa_usage_backup.yaml create mode 100644 runbooks/cpa_usage_restore.yaml diff --git a/cmd/main.go b/cmd/main.go index 404c7c3..6bde4b2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -9,15 +9,17 @@ import ( "syscall" "time" - "xiaji-go/config" - "xiaji-go/internal/bot" - "xiaji-go/internal/channel" - "xiaji-go/internal/feishu" - "xiaji-go/internal/qq" - "xiaji-go/internal/service" - "xiaji-go/internal/web" - "xiaji-go/models" - "xiaji-go/version" + "ops-assistant/config" + "ops-assistant/internal/bot" + "ops-assistant/internal/channel" + "ops-assistant/internal/core/ops" + "ops-assistant/internal/core/runbook" + "ops-assistant/internal/feishu" + "ops-assistant/internal/qq" + "ops-assistant/internal/service" + "ops-assistant/internal/web" + "ops-assistant/models" + "ops-assistant/version" "github.com/gin-gonic/gin" "gorm.io/driver/sqlite" @@ -55,6 +57,10 @@ func main() { log.Fatalf("初始化渠道密钥加密失败: %v", err) } + if config.IsWeakPassword(cfg.Admin.Password) { + log.Printf("⚠️ admin 密码过弱或为默认值,请尽快修改") + } + // DB 渠道配置覆盖 YAML 配置 if err := channel.ApplyChannelConfig(db, cfg); err != nil { log.Printf("⚠️ 渠道配置加载失败,继续使用 YAML: %v", err) @@ -63,11 +69,17 @@ func main() { finance := service.NewFinanceService(db) defer finance.Close() + if err := runbook.SeedDefaultTargets(db); err != nil { + log.Printf("⚠️ 初始化ops targets失败: %v", err) + } + + opsSvc := ops.BuildDefault(db, cfg.Database.Path, ".") + ctx, cancel := context.WithCancel(context.Background()) defer cancel() if cfg.Telegram.Enabled { - tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, finance) + tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, finance, opsSvc) if err != nil { log.Printf("⚠️ TG Bot 启动失败: %v", err) } else { @@ -76,7 +88,7 @@ func main() { } if cfg.QQBot.Enabled { - qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, finance) + qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, finance, opsSvc) go qqBot.Start(ctx) } @@ -91,11 +103,11 @@ func main() { return fmt.Sprintf("reload ok: tg=%v qq=%v feishu=%v", cfg.Telegram.Enabled, cfg.QQBot.Enabled, cfg.Feishu.Enabled), nil } - webServer := web.NewWebServer(db, finance, cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password, cfg.Server.Key, reloadFn) + webServer := web.NewWebServer(db, cfg.Database.Path, ".", finance, cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password, cfg.Server.Key, reloadFn) webServer.RegisterRoutes(engine) if cfg.Feishu.Enabled { - fsBot := feishu.NewBot(db, finance, cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.VerificationToken, cfg.Feishu.EncryptKey) + fsBot := feishu.NewBot(db, finance, opsSvc, cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.VerificationToken, cfg.Feishu.EncryptKey) fsBot.RegisterRoutes(engine) go fsBot.Start(ctx) } @@ -108,7 +120,7 @@ func main() { } }() - log.Println("🦞 Xiaji-Go 已全面启动") + log.Println("🛠️ Ops-Assistant 已全面启动") sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig @@ -122,5 +134,5 @@ func main() { sqlDB.Close() } - log.Println("👋 Xiaji-Go 已关闭") + log.Println("👋 Ops-Assistant 已关闭") } diff --git a/config/config.go b/config/config.go index a1a20b9..c66dabe 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "strings" "gopkg.in/yaml.v3" ) @@ -31,6 +32,13 @@ type Config struct { VerificationToken string `yaml:"verification_token"` EncryptKey string `yaml:"encrypt_key"` } `yaml:"feishu"` + AI struct { + Enabled bool `yaml:"enabled"` + BaseURL string `yaml:"base_url"` + APIKey string `yaml:"api_key"` + Model string `yaml:"model"` + TimeoutSeconds int `yaml:"timeout_seconds"` + } `yaml:"ai"` Admin struct { Username string `yaml:"username"` Password string `yaml:"password"` @@ -57,6 +65,24 @@ func LoadConfig(path string) (*Config, error) { return cfg, nil } +func IsWeakPassword(pw string) bool { + p := strings.TrimSpace(pw) + if p == "" { + return true + } + weak := map[string]bool{ + "admin123": true, + "your_password": true, + "CHANGE_ME": true, + "change_me": true, + "password": true, + "123456": true, + "12345678": true, + "qwerty": true, + } + return weak[p] +} + func (c *Config) Validate() error { if c.Database.Path == "" { return fmt.Errorf("database.path 不能为空") @@ -83,5 +109,13 @@ func (c *Config) Validate() error { return fmt.Errorf("feishu 已启用但 app_id 或 app_secret 为空") } } + if c.AI.Enabled { + if c.AI.BaseURL == "" || c.AI.APIKey == "" || c.AI.Model == "" { + return fmt.Errorf("ai 已启用但 base_url/api_key/model 为空") + } + if c.AI.TimeoutSeconds <= 0 { + c.AI.TimeoutSeconds = 15 + } + } return nil } diff --git a/internal/channel/channel.go b/internal/channel/channel.go index 83435f5..dc0f3f6 100644 --- a/internal/channel/channel.go +++ b/internal/channel/channel.go @@ -6,6 +6,7 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "crypto/sha256" "encoding/base64" "encoding/json" "errors" @@ -15,9 +16,10 @@ import ( "strings" "time" - "xiaji-go/config" - "xiaji-go/models" + "ops-assistant/config" + "ops-assistant/models" + "golang.org/x/crypto/pbkdf2" "gorm.io/gorm" ) @@ -29,27 +31,44 @@ type UnifiedMessage struct { Text string `json:"text"` } -var secretCipher *cipherContext +const ( + encPrefixV1 = "enc:v1:" + encPrefixV2 = "enc:v2:" +) + +var secretCipherV1 *cipherContext +var secretCipherV2 *cipherContext type cipherContext struct { aead cipher.AEAD } func InitSecretCipher(key string) error { - k := deriveKey32(key) - block, err := aes.NewCipher(k) + k1 := deriveKey32Legacy(key) + block1, err := aes.NewCipher(k1) if err != nil { return err } - aead, err := cipher.NewGCM(block) + aead1, err := cipher.NewGCM(block1) if err != nil { return err } - secretCipher = &cipherContext{aead: aead} + secretCipherV1 = &cipherContext{aead: aead1} + + k2 := deriveKey32V2(key) + block2, err := aes.NewCipher(k2) + if err != nil { + return err + } + aead2, err := cipher.NewGCM(block2) + if err != nil { + return err + } + secretCipherV2 = &cipherContext{aead: aead2} return nil } -func deriveKey32(s string) []byte { +func deriveKey32Legacy(s string) []byte { b := []byte(s) out := make([]byte, 32) if len(b) >= 32 { @@ -63,37 +82,66 @@ func deriveKey32(s string) []byte { return out } +func deriveKey32V2(s string) []byte { + if strings.TrimSpace(s) == "" { + return make([]byte, 32) + } + // PBKDF2 for deterministic 32-byte key derivation + return pbkdf2.Key([]byte(s), []byte("ops-assistant-v1"), 200000, 32, sha256.New) +} + func encryptString(plain string) (string, error) { - if secretCipher == nil { + if secretCipherV2 == nil { return plain, errors.New("cipher not initialized") } - nonce := make([]byte, secretCipher.aead.NonceSize()) + nonce := make([]byte, secretCipherV2.aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", err } - ciphertext := secretCipher.aead.Seal(nil, nonce, []byte(plain), nil) + ciphertext := secretCipherV2.aead.Seal(nil, nonce, []byte(plain), nil) buf := append(nonce, ciphertext...) - return "enc:v1:" + base64.StdEncoding.EncodeToString(buf), nil + return encPrefixV2 + base64.StdEncoding.EncodeToString(buf), nil } func decryptString(raw string) (string, error) { - if !strings.HasPrefix(raw, "enc:v1:") { + if !strings.HasPrefix(raw, encPrefixV1) && !strings.HasPrefix(raw, encPrefixV2) { return raw, nil } - if secretCipher == nil { + if strings.HasPrefix(raw, encPrefixV2) { + if secretCipherV2 == nil { + return "", errors.New("cipher not initialized") + } + data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, encPrefixV2)) + if err != nil { + return "", err + } + ns := secretCipherV2.aead.NonceSize() + if len(data) <= ns { + return "", errors.New("invalid ciphertext") + } + nonce := data[:ns] + ct := data[ns:] + pt, err := secretCipherV2.aead.Open(nil, nonce, ct, nil) + if err != nil { + return "", err + } + return string(pt), nil + } + + if secretCipherV1 == nil { return "", errors.New("cipher not initialized") } - data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, "enc:v1:")) + data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, encPrefixV1)) if err != nil { return "", err } - ns := secretCipher.aead.NonceSize() + ns := secretCipherV1.aead.NonceSize() if len(data) <= ns { return "", errors.New("invalid ciphertext") } nonce := data[:ns] ct := data[ns:] - pt, err := secretCipher.aead.Open(nil, nonce, ct, nil) + pt, err := secretCipherV1.aead.Open(nil, nonce, ct, nil) if err != nil { return "", err } @@ -119,10 +167,10 @@ func EncryptSecretJSON(raw string) string { if strings.TrimSpace(raw) == "" { return raw } - if strings.HasPrefix(raw, "enc:v1:") { + if strings.HasPrefix(raw, encPrefixV1) || strings.HasPrefix(raw, encPrefixV2) { return raw } - if secretCipher == nil { + if secretCipherV2 == nil { return raw } enc, err := encryptString(raw) diff --git a/internal/core/ops/retry.go b/internal/core/ops/retry.go new file mode 100644 index 0000000..d2b2784 --- /dev/null +++ b/internal/core/ops/retry.go @@ -0,0 +1,60 @@ +package ops + +import ( + "encoding/json" + "errors" + "path/filepath" + "strings" + + "ops-assistant/internal/core/runbook" + "ops-assistant/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func decodeInputJSON(raw string, out *map[string]string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + return json.Unmarshal([]byte(raw), out) +} + +func RetryJobWithDB(db *gorm.DB, baseDir string, jobID uint) (uint, error) { + if db == nil { + return 0, errors.New("db is nil") + } + var old models.OpsJob + if err := db.First(&old, jobID).Error; err != nil { + return 0, err + } + if strings.TrimSpace(old.Status) != "failed" { + return 0, errors.New("only failed jobs can retry") + } + + inputs := map[string]string{} + if strings.TrimSpace(old.InputJSON) != "" { + _ = decodeInputJSON(old.InputJSON, &inputs) + } + + meta := runbook.NewMeta() + meta.Target = old.Target + meta.RiskLevel = old.RiskLevel + meta.RequestID = old.RequestID + "-retry" + meta.ConfirmHash = old.ConfirmHash + + exec := runbook.NewExecutor(db, filepath.Join(baseDir, "runbooks")) + newID, _, err := exec.RunWithInputsAndMeta(old.Command, old.Runbook, old.Operator, inputs, meta) + if err != nil { + return newID, err + } + return newID, nil +} + +func RetryJob(dbPath, baseDir string, jobID uint) (uint, error) { + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + return 0, err + } + return RetryJobWithDB(db, baseDir, jobID) +} diff --git a/internal/core/runbook/cancel.go b/internal/core/runbook/cancel.go new file mode 100644 index 0000000..7fd4320 --- /dev/null +++ b/internal/core/runbook/cancel.go @@ -0,0 +1,32 @@ +package runbook + +import ( + "context" + "sync" +) + +var jobCancelMap sync.Map + +func registerJobCancel(jobID uint, cancel context.CancelFunc) { + jobCancelMap.Store(jobID, cancel) +} + +func clearJobCancel(jobID uint) { + if v, ok := jobCancelMap.Load(jobID); ok { + if cancel, ok2 := v.(context.CancelFunc); ok2 { + cancel() + } + jobCancelMap.Delete(jobID) + } +} + +func CancelJob(jobID uint) bool { + if v, ok := jobCancelMap.Load(jobID); ok { + if cancel, ok2 := v.(context.CancelFunc); ok2 { + cancel() + } + jobCancelMap.Delete(jobID) + return true + } + return false +} diff --git a/internal/core/runbook/executor.go b/internal/core/runbook/executor.go new file mode 100644 index 0000000..b47742b --- /dev/null +++ b/internal/core/runbook/executor.go @@ -0,0 +1,387 @@ +package runbook + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "ops-assistant/internal/core/ecode" + "ops-assistant/models" + + "gorm.io/gorm" +) + +type Executor struct { + db *gorm.DB + runbookDir string +} + +func NewExecutor(db *gorm.DB, runbookDir string) *Executor { + return &Executor{db: db, runbookDir: runbookDir} +} + +func (e *Executor) Run(commandText, runbookName string, operator int64) (uint, string, error) { + return e.RunWithInputsAndMeta(commandText, runbookName, operator, map[string]string{}, NewMeta()) +} + +func (e *Executor) RunWithInputs(commandText, runbookName string, operator int64, inputs map[string]string) (uint, string, error) { + return e.RunWithInputsAndMeta(commandText, runbookName, operator, inputs, NewMeta()) +} + +func (e *Executor) RunWithInputsAndMeta(commandText, runbookName string, operator int64, inputs map[string]string, meta RunMeta) (uint, string, error) { + started := time.Now() + inputJSON := "{}" + if b, err := json.Marshal(inputs); err == nil { + inputJSON = string(b) + } + job := models.OpsJob{ + Command: commandText, + Runbook: runbookName, + Operator: operator, + Target: strings.TrimSpace(meta.Target), + RiskLevel: strings.TrimSpace(meta.RiskLevel), + RequestID: strings.TrimSpace(meta.RequestID), + ConfirmHash: strings.TrimSpace(meta.ConfirmHash), + InputJSON: inputJSON, + Status: "pending", + StartedAt: started, + } + if job.RiskLevel == "" { + job.RiskLevel = "low" + } + if err := e.db.Create(&job).Error; err != nil { + return 0, "", err + } + + release := acquireTargetLock(job.Target) + defer release() + + job.Status = "running" + _ = e.db.Save(&job).Error + + specPath := filepath.Join(e.runbookDir, runbookName+".yaml") + data, err := os.ReadFile(specPath) + if err != nil { + e.finishJob(&job, "failed", "runbook not found") + return job.ID, "", err + } + spec, err := Parse(data) + if err != nil { + e.finishJob(&job, "failed", "runbook parse failed") + return job.ID, "", err + } + + outputs := map[string]string{} + ctx := map[string]string{} + + jobCtx, jobCancel := context.WithCancel(context.Background()) + registerJobCancel(job.ID, jobCancel) + defer clearJobCancel(job.ID) + for k, v := range inputs { + ctx["inputs."+k] = v + } + if t := strings.TrimSpace(os.Getenv("CPA_MANAGEMENT_BASE")); t != "" { + ctx["env.cpa_management_base"] = t + } else { + var sset models.AppSetting + if err := e.db.Where("key = ?", "cpa_management_base").First(&sset).Error; err == nil { + if strings.TrimSpace(sset.Value) != "" { + ctx["env.cpa_management_base"] = strings.TrimSpace(sset.Value) + } + } + } + if t := strings.TrimSpace(os.Getenv("CPA_MANAGEMENT_TOKEN")); t != "" { + ctx["env.cpa_management_token"] = t + } else { + var sset models.AppSetting + if err := e.db.Where("key = ?", "cpa_management_token").First(&sset).Error; err == nil { + if strings.TrimSpace(sset.Value) != "" { + ctx["env.cpa_management_token"] = strings.TrimSpace(sset.Value) + } + } + } + // Cloudflare settings + if t := strings.TrimSpace(os.Getenv("CF_ACCOUNT_ID")); t != "" { + ctx["env_cf_account_id"] = t + } else { + var sset models.AppSetting + if err := e.db.Where("key = ?", "cf_account_id").First(&sset).Error; err == nil { + if strings.TrimSpace(sset.Value) != "" { + ctx["env_cf_account_id"] = strings.TrimSpace(sset.Value) + } + } + } + if t := strings.TrimSpace(os.Getenv("CF_API_EMAIL")); t != "" { + ctx["env_cf_api_email"] = t + } else { + var sset models.AppSetting + if err := e.db.Where("key = ?", "cf_api_email").First(&sset).Error; err == nil { + if strings.TrimSpace(sset.Value) != "" { + ctx["env_cf_api_email"] = strings.TrimSpace(sset.Value) + } + } + } + if t := strings.TrimSpace(os.Getenv("CF_API_TOKEN")); t != "" { + ctx["env_cf_api_token"] = t + } else { + var sset models.AppSetting + if err := e.db.Where("key = ?", "cf_api_token").First(&sset).Error; err == nil { + if strings.TrimSpace(sset.Value) != "" { + ctx["env_cf_api_token"] = strings.TrimSpace(sset.Value) + } + } + } + + // inject input env vars for runbook steps + for k, v := range inputs { + if strings.TrimSpace(v) != "" { + ctx["env.INPUT_"+strings.ToUpper(k)] = v + } + } + for _, st := range spec.Steps { + if isJobCancelled(e.db, job.ID) { + e.finishJob(&job, "cancelled", ecode.Tag(ecode.ErrJobCancelled, "cancelled by user")) + return job.ID, "", fmt.Errorf(ecode.Tag(ecode.ErrJobCancelled, "cancelled by user")) + } + + rendered := renderStep(st, ctx) + step := models.OpsJobStep{JobID: job.ID, StepID: rendered.ID, Action: rendered.Action, Status: "running", StartedAt: time.Now()} + _ = e.db.Create(&step).Error + + timeout := meta.timeoutOrDefault() + rc, stdout, stderr, runErr := e.execStep(jobCtx, rendered, outputs, timeout) + step.RC = rc + step.StdoutTail = tail(stdout, 1200) + step.StderrTail = tail(stderr, 1200) + step.EndedAt = time.Now() + if runErr != nil || rc != 0 { + step.Status = "failed" + _ = e.db.Save(&step).Error + e.finishJob(&job, "failed", fmt.Sprintf("%s: step=%s failed", ecode.ErrStepFailed, rendered.ID)) + if runErr == nil { + runErr = fmt.Errorf("rc=%d", rc) + } + return job.ID, "", fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("step %s failed: %v", rendered.ID, runErr))) + } + step.Status = "success" + _ = e.db.Save(&step).Error + outputs[rendered.ID] = stdout + ctx["steps."+rendered.ID+".output"] = stdout + } + + e.finishJob(&job, "success", "ok") + return job.ID, "ok", nil +} + +func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]string, timeout time.Duration) (int, string, string, error) { + switch st.Action { + case "ssh.exec": + target := strings.TrimSpace(fmt.Sprintf("%v", st.With["target"])) + cmdText := strings.TrimSpace(fmt.Sprintf("%v", st.With["command"])) + if target == "" || cmdText == "" { + return 1, "", "missing target/command", fmt.Errorf("missing target/command") + } + resolved := resolveTarget(e.db, target) + if !resolved.Found { + return 1, "", "invalid target", fmt.Errorf("invalid target: %s", target) + } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + args := []string{"-p", strconv.Itoa(resolved.Port), resolved.User + "@" + resolved.Host, cmdText} + cmd := exec.CommandContext(ctx, "ssh", args...) + var outb, errb bytes.Buffer + cmd.Stdout = &outb + cmd.Stderr = &errb + err := cmd.Run() + rc := 0 + if err != nil { + rc = 1 + if ctx.Err() == context.DeadlineExceeded { + return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "ssh step timeout")) + } + } + return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err + + case "shell.exec": + cmdText := strings.TrimSpace(fmt.Sprintf("%v", st.With["command"])) + if cmdText == "" { + return 1, "", "missing command", fmt.Errorf("missing command") + } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "-lc", cmdText) + var outb, errb bytes.Buffer + cmd.Stdout = &outb + cmd.Stderr = &errb + err := cmd.Run() + rc := 0 + if err != nil { + rc = 1 + if ctx.Err() == context.DeadlineExceeded { + return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "shell step timeout")) + } + } + return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err + + case "assert.json": + sourceStep := strings.TrimSpace(fmt.Sprintf("%v", st.With["source_step"])) + if sourceStep == "" { + return 1, "", "missing source_step", fmt.Errorf("missing source_step") + } + raw, ok := outputs[sourceStep] + if !ok { + return 1, "", "source step output not found", fmt.Errorf("source step output not found: %s", sourceStep) + } + + var payload any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return 1, "", "invalid json", err + } + + rules := parseRequiredPaths(st.With["required_paths"]) + if len(rules) == 0 { + return 1, "", "required_paths empty", fmt.Errorf("required_paths empty") + } + for _, p := range rules { + if _, ok := lookupPath(payload, p); !ok { + return 1, "", "json path not found: " + p, fmt.Errorf("json path not found: %s", p) + } + } + return 0, "assert ok", "", nil + + case "sleep": + ms := 1000 + if v, ok := st.With["ms"]; ok { + switch t := v.(type) { + case int: + ms = t + case int64: + ms = int(t) + case float64: + ms = int(t) + case string: + if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil { + ms = n + } + } + } + if ms < 0 { + ms = 0 + } + time.Sleep(time.Duration(ms) * time.Millisecond) + return 0, fmt.Sprintf("slept %dms", ms), "", nil + + default: + return 1, "", "unsupported action", fmt.Errorf("unsupported action: %s", st.Action) + } +} + +func renderStep(st Step, ctx map[string]string) Step { + out := st + out.ID = renderString(out.ID, ctx) + out.Action = renderString(out.Action, ctx) + if out.With == nil { + return out + } + m := make(map[string]any, len(out.With)) + for k, v := range out.With { + switch t := v.(type) { + case string: + m[k] = renderString(t, ctx) + case []any: + arr := make([]any, 0, len(t)) + for _, it := range t { + if s, ok := it.(string); ok { + arr = append(arr, renderString(s, ctx)) + } else { + arr = append(arr, it) + } + } + m[k] = arr + default: + m[k] = v + } + } + out.With = m + return out +} + +func renderString(s string, ctx map[string]string) string { + res := s + for k, v := range ctx { + res = strings.ReplaceAll(res, "${"+k+"}", v) + } + return res +} + +func parseRequiredPaths(v any) []string { + res := []string{} + switch t := v.(type) { + case []any: + for _, it := range t { + res = append(res, strings.TrimSpace(fmt.Sprintf("%v", it))) + } + case []string: + for _, it := range t { + res = append(res, strings.TrimSpace(it)) + } + } + out := make([]string, 0, len(res)) + for _, p := range res { + if p != "" { + out = append(out, p) + } + } + return out +} + +func lookupPath(root any, path string) (any, bool) { + parts := strings.Split(path, ".") + cur := root + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + return nil, false + } + m, ok := cur.(map[string]any) + if !ok { + return nil, false + } + next, exists := m[part] + if !exists { + return nil, false + } + cur = next + } + return cur, true +} + +func (e *Executor) finishJob(job *models.OpsJob, status, summary string) { + job.Status = status + job.Summary = summary + job.EndedAt = time.Now() + _ = e.db.Save(job).Error +} + +func isJobCancelled(db *gorm.DB, jobID uint) bool { + var j models.OpsJob + if err := db.Select("status").First(&j, jobID).Error; err != nil { + return false + } + return strings.EqualFold(strings.TrimSpace(j.Status), "cancelled") +} + +func tail(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) <= max { + return s + } + return s[len(s)-max:] +} diff --git a/internal/core/runbook/targets.go b/internal/core/runbook/targets.go new file mode 100644 index 0000000..ac114e7 --- /dev/null +++ b/internal/core/runbook/targets.go @@ -0,0 +1,37 @@ +package runbook + +import ( + "strings" + + "ops-assistant/models" + + "gorm.io/gorm" +) + +type ResolvedTarget struct { + Found bool + User string + Host string + Port int +} + +func resolveTarget(db *gorm.DB, name string) ResolvedTarget { + trim := strings.TrimSpace(name) + if trim == "" { + return ResolvedTarget{} + } + var t models.OpsTarget + if err := db.Where("name = ? AND enabled = ?", trim, true).First(&t).Error; err != nil { + return ResolvedTarget{} + } + user := strings.TrimSpace(t.User) + host := strings.TrimSpace(t.Host) + port := t.Port + if user == "" || host == "" { + return ResolvedTarget{} + } + if port <= 0 { + port = 22 + } + return ResolvedTarget{Found: true, User: user, Host: host, Port: port} +} diff --git a/internal/web/server.go b/internal/web/server.go index e8358fa..96bf63a 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -7,22 +7,37 @@ import ( "encoding/hex" "encoding/json" "fmt" + "net" "net/http" + "path/filepath" + "regexp" "strconv" "strings" "time" - "xiaji-go/internal/channel" - "xiaji-go/internal/service" - "xiaji-go/models" - "xiaji-go/version" + "ops-assistant/internal/channel" + "ops-assistant/internal/core/ecode" + "ops-assistant/internal/core/ops" + "ops-assistant/internal/core/runbook" + "ops-assistant/internal/service" + "ops-assistant/models" + "ops-assistant/version" "github.com/gin-gonic/gin" "gorm.io/gorm" ) +const ( + cookieUserNew = "ops_user" + cookieTokenNew = "ops_token" + cookieUserOld = "xiaji_user" + cookieTokenOld = "xiaji_token" +) + type WebServer struct { db *gorm.DB + dbPath string + baseDir string finance *service.FinanceService port int username string @@ -46,6 +61,15 @@ type flagPatchReq struct { Reason string `json:"reason"` } +type moduleToggleReq struct { + Enabled bool `json:"enabled"` + Reason string `json:"reason"` +} + +type opsJobActionReq struct { + Reason string `json:"reason"` +} + type channelConfigPatchReq struct { Name *string `json:"name"` Enabled *bool `json:"enabled"` @@ -53,6 +77,58 @@ type channelConfigPatchReq struct { Secrets json.RawMessage `json:"secrets"` } +type cpaSettingsReq struct { + ManagementToken string `json:"management_token"` + ManagementBase string `json:"management_base"` +} + +type cfSettingsReq struct { + AccountID string `json:"account_id"` + APIEmail string `json:"api_email"` + APIToken string `json:"api_token"` +} + +type aiSettingsReq struct { + Enabled *bool `json:"enabled"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + TimeoutSeconds int `json:"timeout_seconds"` +} + +type opsTargetReq struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Enabled bool `json:"enabled"` +} + +var ( + validHostRe = regexp.MustCompile(`^(?:[a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$`) + validUserRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) +) + +func validateTargetFields(host, user string, port int) error { + host = strings.TrimSpace(host) + user = strings.TrimSpace(user) + if host == "" || user == "" { + return fmt.Errorf("host/user 不能为空") + } + if ip := net.ParseIP(host); ip == nil { + if !validHostRe.MatchString(host) { + return fmt.Errorf("host 非法") + } + } + if !validUserRe.MatchString(user) { + return fmt.Errorf("user 非法") + } + if port <= 0 || port > 65535 { + return fmt.Errorf("port 无效") + } + return nil +} + var rolePermissions = map[string][]string{ "owner": { "records.read.self", "records.read.all", @@ -60,38 +136,71 @@ var rolePermissions = map[string][]string{ "records.export.self", "records.export.all", "settings.flags.read", "settings.flags.write", "channels.read", "channels.write", "channels.test", - "audit.read", + "audit.read", "ops.read", "ops.cancel", "ops.retry", }, "admin": { "records.read.self", "records.delete.self", "records.export.self", - "settings.flags.read", "channels.read", "audit.read", + "settings.flags.read", "channels.read", "audit.read", "ops.read", "ops.cancel", "ops.retry", }, "viewer": { "records.read.self", }, } -func NewWebServer(db *gorm.DB, finance *service.FinanceService, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer { +func NewWebServer(db *gorm.DB, dbPath, baseDir string, finance *service.FinanceService, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer { return &WebServer{ db: db, + dbPath: dbPath, + baseDir: baseDir, finance: finance, port: port, username: username, password: password, - secretKey: "xiaji-go-session-" + sessionKey, + secretKey: "ops-assistant-session-" + sessionKey, reloadFn: reloadFn, } } func (s *WebServer) generateToken(username string) string { + exp := time.Now().Add(7 * 24 * time.Hour).Unix() + payload := fmt.Sprintf("%s|%d", username, exp) mac := hmac.New(sha256.New, []byte(s.secretKey)) - mac.Write([]byte(username)) - return hex.EncodeToString(mac.Sum(nil)) + mac.Write([]byte(payload)) + sig := hex.EncodeToString(mac.Sum(nil)) + return fmt.Sprintf("%s|%s", payload, sig) } func (s *WebServer) validateToken(username, token string) bool { - expected := s.generateToken(username) - return hmac.Equal([]byte(expected), []byte(token)) + parts := strings.Split(token, "|") + if len(parts) == 1 { + // legacy token: HMAC(username) + expected := s.generateLegacyToken(username) + return hmac.Equal([]byte(expected), []byte(token)) + } + if len(parts) != 3 { + return false + } + if parts[0] != username { + return false + } + exp, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return false + } + if time.Now().Unix() > exp { + return false + } + payload := fmt.Sprintf("%s|%s", parts[0], parts[1]) + mac := hmac.New(sha256.New, []byte(s.secretKey)) + mac.Write([]byte(payload)) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(parts[2])) +} + +func (s *WebServer) generateLegacyToken(username string) string { + mac := hmac.New(sha256.New, []byte(s.secretKey)) + mac.Write([]byte(username)) + return hex.EncodeToString(mac.Sum(nil)) } func (s *WebServer) buildCurrentUser(username string) *CurrentUser { @@ -154,7 +263,7 @@ func (s *WebServer) renderPage(c *gin.Context, tpl string, u *CurrentUser, extra } func deny(c *gin.Context, msg string) { - c.JSON(http.StatusForbidden, gin.H{"error": msg}) + respondErr(c, http.StatusForbidden, ecode.ErrPermissionDenied, msg) } func currentUser(c *gin.Context) *CurrentUser { @@ -168,13 +277,20 @@ func currentUser(c *gin.Context) *CurrentUser { func (s *WebServer) authRequired() gin.HandlerFunc { return func(c *gin.Context) { - username, _ := c.Cookie("xiaji_user") - token, _ := c.Cookie("xiaji_token") + username, _ := c.Cookie(cookieUserNew) + token, _ := c.Cookie(cookieTokenNew) + + legacy := false + if username == "" || token == "" { + username, _ = c.Cookie(cookieUserOld) + token, _ = c.Cookie(cookieTokenOld) + legacy = username != "" && token != "" + } if username == "" || token == "" || !s.validateToken(username, token) { path := c.Request.URL.Path if strings.HasPrefix(path, "/api") || c.Request.Method == "POST" || c.Request.Method == "PATCH" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + respondErr(c, http.StatusUnauthorized, ecode.ErrPermissionDenied, "未登录") } else { c.Redirect(http.StatusFound, "/login") } @@ -182,6 +298,18 @@ func (s *WebServer) authRequired() gin.HandlerFunc { return } + if legacy { + maxAge := 7 * 24 * 3600 + c.SetCookie(cookieUserNew, username, maxAge, "/", "", false, true) + c.SetCookie(cookieTokenNew, token, maxAge, "/", "", false, true) + } else if strings.Contains(token, "|") { + // refresh exp for new token format + maxAge := 7 * 24 * 3600 + fresh := s.generateToken(username) + c.SetCookie(cookieUserNew, username, maxAge, "/", "", false, true) + c.SetCookie(cookieTokenNew, fresh, maxAge, "/", "", false, true) + } + c.Set("currentUser", s.buildCurrentUser(username)) c.Next() } @@ -218,6 +346,19 @@ func (s *WebServer) registerAPIV1Routes(auth *gin.RouterGroup) { auth.GET("/api/v1/export", s.handleExportV1) auth.GET("/api/v1/admin/settings/flags", s.handleFlagsList) auth.PATCH("/api/v1/admin/settings/flags/:key", s.handleFlagPatch) + // CPA settings + auth.GET("/api/v1/admin/cpa/settings", s.handleCPASettingsGet) + auth.PATCH("/api/v1/admin/cpa/settings", s.handleCPASettingsPatch) + // Cloudflare settings + auth.GET("/api/v1/admin/cf/settings", s.handleCFSettingsGet) + auth.PATCH("/api/v1/admin/cf/settings", s.handleCFSettingsPatch) + // AI settings + auth.GET("/api/v1/admin/ai/settings", s.handleAISettingsGet) + auth.PATCH("/api/v1/admin/ai/settings", s.handleAISettingsPatch) + // Ops targets + auth.GET("/api/v1/admin/ops/targets", s.handleOpsTargetsList) + auth.POST("/api/v1/admin/ops/targets", s.handleOpsTargetsCreate) + auth.PATCH("/api/v1/admin/ops/targets/:id", s.handleOpsTargetsPatch) auth.GET("/api/v1/admin/channels", s.handleChannelsList) auth.PATCH("/api/v1/admin/channels/:platform", s.handleChannelPatch) @@ -229,6 +370,18 @@ func (s *WebServer) registerAPIV1Routes(auth *gin.RouterGroup) { auth.POST("/api/v1/admin/channels/:platform/test", s.handleChannelTest) auth.POST("/api/v1/admin/channels/:platform/apply", s.handleChannelApply) auth.GET("/api/v1/admin/audit", s.handleAuditList) + auth.GET("/api/v1/admin/legacy/usage", s.handleLegacyUsage) + auth.GET("/api/v1/admin/legacy/trend", s.handleLegacyTrend) + auth.GET("/api/v1/admin/legacy/readiness", s.handleLegacyReadiness) + auth.GET("/api/v1/modules", s.handleModulesList) + auth.POST("/api/v1/modules/:module/toggle", s.handleModuleToggle) + auth.GET("/api/v1/dashboard/overview", s.handleDashboardOverview) + auth.GET("/api/v1/dashboard/summary", s.handleDashboardSummary) + auth.GET("/api/v1/ops/jobs", s.handleOpsJobs) + auth.GET("/api/v1/ops/jobs/request/:requestID", s.handleOpsJobsByRequestID) + auth.GET("/api/v1/ops/jobs/:id", s.handleOpsJobDetail) + auth.POST("/api/v1/ops/jobs/:id/cancel", s.handleOpsJobCancel) + auth.POST("/api/v1/ops/jobs/:id/retry", s.handleOpsJobRetry) } func (s *WebServer) registerLegacyCompatRoutes(auth *gin.RouterGroup) { @@ -238,9 +391,43 @@ func (s *WebServer) registerLegacyCompatRoutes(auth *gin.RouterGroup) { // 1) 新功能与新页面只允许使用 /api/v1/* // 2) 当确认无旧调用后,再移除以下旧路由映射 // 3) 每次版本发布前,优先检查是否仍存在对旧路由的引用 - auth.GET("/api/records", s.handleRecordsV1) - auth.POST("/delete/:id", s.handleDeleteV1) - auth.GET("/export", s.handleExportV1) + auth.GET("/api/records", s.handleLegacyRecords) + auth.POST("/delete/:id", s.handleLegacyDelete) + auth.GET("/export", s.handleLegacyExport) +} + +func (s *WebServer) writeLegacyAccess(c *gin.Context, route string) { + u := currentUser(c) + uid := int64(0) + if u != nil { + uid = u.UserID + } + note := fmt.Sprintf("legacy route=%s method=%s path=%s ua=%s", route, c.Request.Method, c.Request.URL.Path, c.Request.UserAgent()) + s.writeAuditResult(uid, "legacy.route.access", "route", route, "", "", note, "success") +} + +func (s *WebServer) markLegacyDeprecated(c *gin.Context, replacement string) { + c.Header("X-API-Deprecated", "true") + c.Header("X-API-Replacement", replacement) + c.Header("Warning", fmt.Sprintf(`299 - "legacy API deprecated, use %s"`, replacement)) +} + +func (s *WebServer) handleLegacyRecords(c *gin.Context) { + s.writeLegacyAccess(c, "/api/records") + s.markLegacyDeprecated(c, "/api/v1/records") + s.handleRecordsV1(c) +} + +func (s *WebServer) handleLegacyDelete(c *gin.Context) { + s.writeLegacyAccess(c, "/delete/:id") + s.markLegacyDeprecated(c, "/api/v1/records/:id/delete") + s.handleDeleteV1(c) +} + +func (s *WebServer) handleLegacyExport(c *gin.Context) { + s.writeLegacyAccess(c, "/export") + s.markLegacyDeprecated(c, "/api/v1/export") + s.handleExportV1(c) } func (s *WebServer) RegisterRoutes(r *gin.Engine) { @@ -257,6 +444,10 @@ func (s *WebServer) RegisterRoutes(r *gin.Engine) { auth.GET("/", s.handleIndex) auth.GET("/channels", s.handleChannelsPage) auth.GET("/audit", s.handleAuditPage) + auth.GET("/ops", s.handleOpsPage) + auth.GET("/cpa", s.handleCPASettingsPage) + auth.GET("/cf", s.handleCFSettingsPage) + // AI 配置页临时隐藏(保留后端接口) s.registerAPIV1Routes(auth) s.registerLegacyCompatRoutes(auth) @@ -278,8 +469,12 @@ func (s *WebServer) Start() { } func (s *WebServer) handleLoginPage(c *gin.Context) { - username, _ := c.Cookie("xiaji_user") - token, _ := c.Cookie("xiaji_token") + username, _ := c.Cookie(cookieUserNew) + token, _ := c.Cookie(cookieTokenNew) + if username == "" || token == "" { + username, _ = c.Cookie(cookieUserOld) + token, _ = c.Cookie(cookieTokenOld) + } if username != "" && token != "" && s.validateToken(username, token) { c.Redirect(http.StatusFound, "/") return @@ -294,8 +489,10 @@ func (s *WebServer) handleLogin(c *gin.Context) { if username == s.username && password == s.password { token := s.generateToken(username) maxAge := 7 * 24 * 3600 - c.SetCookie("xiaji_user", username, maxAge, "/", "", false, true) - c.SetCookie("xiaji_token", token, maxAge, "/", "", false, true) + c.SetCookie(cookieUserNew, username, maxAge, "/", "", false, true) + c.SetCookie(cookieTokenNew, token, maxAge, "/", "", false, true) + c.SetCookie(cookieUserOld, "", -1, "/", "", false, true) + c.SetCookie(cookieTokenOld, "", -1, "/", "", false, true) u := s.buildCurrentUser(username) s.writeAuditResult(u.UserID, "auth.login.success", "user", username, "", "", "", "success") c.Redirect(http.StatusFound, "/") @@ -311,8 +508,10 @@ func (s *WebServer) handleLogout(c *gin.Context) { if u != nil { s.writeAuditResult(u.UserID, "auth.logout", "user", u.Username, "", "", "", "success") } - c.SetCookie("xiaji_user", "", -1, "/", "", false, true) - c.SetCookie("xiaji_token", "", -1, "/", "", false, true) + c.SetCookie(cookieUserNew, "", -1, "/", "", false, true) + c.SetCookie(cookieTokenNew, "", -1, "/", "", false, true) + c.SetCookie(cookieUserOld, "", -1, "/", "", false, true) + c.SetCookie(cookieTokenOld, "", -1, "/", "", false, true) c.Redirect(http.StatusFound, "/login") } @@ -339,10 +538,339 @@ func (s *WebServer) handleAuditPage(c *gin.Context) { s.renderPage(c, "audit.html", u, nil) } +func (s *WebServer) handleOpsPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "ops.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "ops.html", u, nil) +} + +func (s *WebServer) handleCPASettingsPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "settings.flags.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "cpa_settings.html", u, nil) +} + +func (s *WebServer) handleCFSettingsPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "settings.flags.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "cf_settings.html", u, nil) +} + +func (s *WebServer) handleAISettingsPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "settings.flags.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "ai_settings.html", u, nil) +} + +func (s *WebServer) handleCPASettingsGet(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + keys := []string{"cpa_management_token", "cpa_management_base"} + out := map[string]string{} + for _, k := range keys { + var sset models.AppSetting + if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil { + out[k] = sset.Value + } else { + out[k] = "" + } + } + // do not expose secret token + if v := strings.TrimSpace(out["cpa_management_token"]); v != "" { + out["cpa_management_token"] = "***" + } + respondOK(c, "ok", gin.H{"settings": out}) +} + +func (s *WebServer) handleCPASettingsPatch(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + var req cpaSettingsReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + // management_token + key := "cpa_management_token" + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: req.ManagementToken, UpdatedBy: u.UserID} + if err := s.db.Create(&sset).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") + return + } + } else { + old := sset.Value + sset.Value = req.ManagementToken + sset.UpdatedBy = u.UserID + if err := s.db.Save(&sset).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") + return + } + _ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementToken != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error + } + + // management_base + key = "cpa_management_base" + var ssetBase models.AppSetting + if err := s.db.Where("key = ?", key).First(&ssetBase).Error; err != nil { + ssetBase = models.AppSetting{Key: key, Value: req.ManagementBase, UpdatedBy: u.UserID} + if err := s.db.Create(&ssetBase).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") + return + } + } else { + old := ssetBase.Value + ssetBase.Value = req.ManagementBase + ssetBase.UpdatedBy = u.UserID + if err := s.db.Save(&ssetBase).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") + return + } + _ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementBase != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error + } + + s.writeAuditResult(u.UserID, "cpa.settings.update", "settings", "cpa_management", "", "", "", "success") + respondOK(c, "success", gin.H{"keys": []string{"cpa_management_token", "cpa_management_base"}}) +} + +func (s *WebServer) handleCFSettingsGet(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + keys := []string{"cf_account_id", "cf_api_email", "cf_api_token"} + out := map[string]string{} + for _, k := range keys { + var sset models.AppSetting + if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil { + out[k] = sset.Value + } else { + out[k] = "" + } + } + respondOK(c, "ok", gin.H{"settings": out}) +} + +func (s *WebServer) handleCFSettingsPatch(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + var req cfSettingsReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + // account id + if req.AccountID != "" { + key := "cf_account_id" + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: req.AccountID, UpdatedBy: u.UserID} + _ = s.db.Create(&sset).Error + } else { + sset.Value = req.AccountID + sset.UpdatedBy = u.UserID + _ = s.db.Save(&sset).Error + } + } + // api email + if req.APIEmail != "" { + key := "cf_api_email" + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: req.APIEmail, UpdatedBy: u.UserID} + _ = s.db.Create(&sset).Error + } else { + sset.Value = req.APIEmail + sset.UpdatedBy = u.UserID + _ = s.db.Save(&sset).Error + } + } + // api token + if req.APIToken != "" { + key := "cf_api_token" + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: req.APIToken, UpdatedBy: u.UserID} + _ = s.db.Create(&sset).Error + } else { + sset.Value = req.APIToken + sset.UpdatedBy = u.UserID + _ = s.db.Save(&sset).Error + } + } + respondOK(c, "success", gin.H{"ok": true}) +} + +func (s *WebServer) handleAISettingsGet(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + keys := []string{"ai_enabled", "ai_base_url", "ai_api_key", "ai_model", "ai_timeout_seconds"} + out := map[string]string{} + for _, k := range keys { + var sset models.AppSetting + if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil { + out[k] = sset.Value + } else { + out[k] = "" + } + } + respondOK(c, "ok", gin.H{"settings": out}) +} + +func (s *WebServer) handleAISettingsPatch(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + var req aiSettingsReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + setKV := func(key, val string) { + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: val, UpdatedBy: u.UserID} + _ = s.db.Create(&sset).Error + return + } + sset.Value = val + sset.UpdatedBy = u.UserID + _ = s.db.Save(&sset).Error + } + if req.Enabled != nil { + if *req.Enabled { + setKV("ai_enabled", "true") + } else { + setKV("ai_enabled", "false") + } + } + if strings.TrimSpace(req.BaseURL) != "" { + setKV("ai_base_url", strings.TrimSpace(req.BaseURL)) + } + if strings.TrimSpace(req.APIKey) != "" { + setKV("ai_api_key", strings.TrimSpace(req.APIKey)) + } + if strings.TrimSpace(req.Model) != "" { + setKV("ai_model", strings.TrimSpace(req.Model)) + } + if req.TimeoutSeconds > 0 { + setKV("ai_timeout_seconds", strconv.Itoa(req.TimeoutSeconds)) + } + s.writeAuditResult(u.UserID, "ai.settings.update", "settings", "ai", "", "", "", "success") + respondOK(c, "success", gin.H{"ok": true}) +} + +func (s *WebServer) handleOpsTargetsList(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + var items []models.OpsTarget + s.db.Order("name asc").Find(&items) + respondOK(c, "ok", gin.H{"targets": items}) +} + +func (s *WebServer) handleOpsTargetsCreate(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + var req opsTargetReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Host) == "" || strings.TrimSpace(req.User) == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "name/host/user 不能为空") + return + } + if req.Port == 0 { + req.Port = 22 + } + if err := validateTargetFields(req.Host, req.User, req.Port); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error()) + return + } + item := models.OpsTarget{Name: strings.TrimSpace(req.Name), Host: strings.TrimSpace(req.Host), Port: req.Port, User: strings.TrimSpace(req.User), Enabled: req.Enabled} + if err := s.db.Create(&item).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "创建失败") + return + } + s.writeAuditResult(u.UserID, "ops_target.create", "ops_target", item.Name, "", "", "", "success") + respondOK(c, "success", gin.H{"target": item}) +} + +func (s *WebServer) handleOpsTargetsPatch(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + id, _ := strconv.Atoi(strings.TrimSpace(c.Param("id"))) + if id <= 0 { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "id 无效") + return + } + var req opsTargetReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + var item models.OpsTarget + if err := s.db.First(&item, id).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "目标不存在") + return + } + if strings.TrimSpace(req.Name) != "" { + item.Name = strings.TrimSpace(req.Name) + } + if strings.TrimSpace(req.Host) != "" { + item.Host = strings.TrimSpace(req.Host) + } + if strings.TrimSpace(req.User) != "" { + item.User = strings.TrimSpace(req.User) + } + if req.Port != 0 { + item.Port = req.Port + } + item.Enabled = req.Enabled + if err := validateTargetFields(item.Host, item.User, item.Port); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error()) + return + } + if err := s.db.Save(&item).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "更新失败") + return + } + s.writeAuditResult(u.UserID, "ops_target.update", "ops_target", item.Name, "", "", "", "success") + respondOK(c, "success", gin.H{"target": item}) +} + func (s *WebServer) handleMe(c *gin.Context) { u := currentUser(c) if u == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + respondErr(c, http.StatusUnauthorized, ecode.ErrPermissionDenied, "未登录") return } @@ -360,11 +888,14 @@ func (s *WebServer) handleMe(c *gin.Context) { "can_edit_channels": s.hasPermission(u, "channels.write"), "can_test_channels": s.hasPermission(u, "channels.test"), "can_view_audit": s.hasPermission(u, "audit.read"), + "can_view_ops": s.hasPermission(u, "ops.read"), + "can_cancel_ops": s.hasPermission(u, "ops.cancel"), + "can_retry_ops": s.hasPermission(u, "ops.retry"), } u.Flags = flags u.Caps = caps - c.JSON(http.StatusOK, u) + respondOK(c, "ok", u) } func (s *WebServer) handleRecordsV1(c *gin.Context) { @@ -414,7 +945,7 @@ func (s *WebServer) handleRecordsV1(c *gin.Context) { } note = fmt.Sprintf("scope=%s,count=%d", scope, len(resp)) s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", note, "success") - c.JSON(http.StatusOK, resp) + respondOK(c, "ok", gin.H{"records": resp}) } func (s *WebServer) handleDeleteV1(c *gin.Context) { @@ -422,13 +953,13 @@ func (s *WebServer) handleDeleteV1(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"}) + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "无效的ID") return } var tx models.Transaction if err := s.db.Where("id = ? AND is_deleted = ?", id, false).First(&tx).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在或已删除"}) + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "记录不存在或已删除") return } @@ -456,11 +987,11 @@ func (s *WebServer) handleDeleteV1(c *gin.Context) { result := s.db.Model(&models.Transaction{}).Where("id = ? AND is_deleted = ?", id, false).Update("is_deleted", true) if result.Error != nil { s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", result.Error.Error(), "failed") - c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "删除失败") return } s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", `{"is_deleted":true}`, "", "success") - c.JSON(http.StatusOK, gin.H{"status": "success"}) + respondOK(c, "success", gin.H{"id": id}) } func (s *WebServer) handleExportV1(c *gin.Context) { @@ -494,7 +1025,7 @@ func (s *WebServer) handleExportV1(c *gin.Context) { q.Order("date asc, id asc").Find(&items) now := time.Now().Format("20060102") - filename := fmt.Sprintf("xiaji_%s.csv", now) + filename := fmt.Sprintf("ops_assistant_%s.csv", now) c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) @@ -514,7 +1045,7 @@ func (s *WebServer) handleFlagsList(c *gin.Context) { } var flags []models.FeatureFlag s.db.Order("key asc").Find(&flags) - c.JSON(http.StatusOK, flags) + respondOK(c, "ok", gin.H{"flags": flags}) } func (s *WebServer) handleFlagPatch(c *gin.Context) { @@ -528,17 +1059,17 @@ func (s *WebServer) handleFlagPatch(c *gin.Context) { key := c.Param("key") var req flagPatchReq if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误"}) + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") return } var ff models.FeatureFlag if err := s.db.Where("key = ?", key).First(&ff).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "开关不存在"}) + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "开关不存在") return } if ff.RequireReason && strings.TrimSpace(req.Reason) == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "该开关修改必须提供 reason"}) + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "该开关修改必须提供 reason") return } @@ -548,7 +1079,7 @@ func (s *WebServer) handleFlagPatch(c *gin.Context) { ff.UpdatedBy = u.UserID if err := s.db.Save(&ff).Error; err != nil { s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", key, before, "", err.Error(), "failed") - c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "更新失败") return } @@ -557,7 +1088,7 @@ func (s *WebServer) handleFlagPatch(c *gin.Context) { _ = s.db.Create(&h).Error s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", key, before, after, req.Reason, "success") - c.JSON(http.StatusOK, gin.H{"status": "success", "key": key, "old": old, "new": req.Enabled}) + respondOK(c, "success", gin.H{"key": key, "old": old, "new": req.Enabled}) } func sanitizeJSON(raw string) string { @@ -665,7 +1196,7 @@ func (s *WebServer) handleChannelsList(c *gin.Context) { UpdatedAt: it.UpdatedAt, }) } - c.JSON(http.StatusOK, resp) + respondOK(c, "ok", gin.H{"channels": resp}) } func (s *WebServer) handleChannelPatch(c *gin.Context) { @@ -677,13 +1208,13 @@ func (s *WebServer) handleChannelPatch(c *gin.Context) { platform := c.Param("platform") var req channelConfigPatchReq if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误"}) + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") return } var row models.ChannelConfig if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"}) + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在") return } @@ -708,7 +1239,7 @@ func (s *WebServer) handleChannelPatch(c *gin.Context) { } if err := s.db.Save(&row).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") return } @@ -716,7 +1247,7 @@ func (s *WebServer) handleChannelPatch(c *gin.Context) { sanitizeJSON(row.DraftConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON))) s.writeAudit(u.UserID, "channel_draft_update", "channel", row.Platform, before, after, "") - c.JSON(http.StatusOK, gin.H{"status": "success", "mode": "draft"}) + respondOK(c, "success", gin.H{"mode": "draft"}) } func (s *WebServer) handleChannelPublish(c *gin.Context) { @@ -728,7 +1259,7 @@ func (s *WebServer) handleChannelPublish(c *gin.Context) { platform := c.Param("platform") var row models.ChannelConfig if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"}) + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在") return } @@ -745,7 +1276,7 @@ func (s *WebServer) handleChannelPublish(c *gin.Context) { row.PublishedAt = &now row.UpdatedBy = u.UserID if err := s.db.Save(&row).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败"}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "发布失败") return } @@ -753,7 +1284,7 @@ func (s *WebServer) handleChannelPublish(c *gin.Context) { sanitizeJSON(row.ConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON))) s.writeAudit(u.UserID, "channel_publish", "channel", row.Platform, before, after, "") - c.JSON(http.StatusOK, gin.H{"status": "success", "published_at": now}) + respondOK(c, "success", gin.H{"published_at": now}) } func (s *WebServer) handleChannelReload(c *gin.Context) { @@ -763,18 +1294,18 @@ func (s *WebServer) handleChannelReload(c *gin.Context) { } if s.reloadFn == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "reload 未配置"}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "reload 未配置") return } detail, err := s.reloadFn() if err != nil { s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", "failed: "+err.Error()) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, err.Error()) return } s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", detail) - c.JSON(http.StatusOK, gin.H{"status": "success", "detail": detail}) + respondOK(c, "success", gin.H{"detail": detail}) } func (s *WebServer) handleChannelEnable(c *gin.Context) { @@ -797,11 +1328,11 @@ func (s *WebServer) handleChannelDisableAll(c *gin.Context) { "updated_by": u.UserID, }) if res.Error != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "批量关闭失败"}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "批量关闭失败") return } s.writeAudit(u.UserID, "channel_disable_all", "channel", "*", "", fmt.Sprintf(`{"affected":%d}`, res.RowsAffected), "") - c.JSON(http.StatusOK, gin.H{"status": "success", "affected": res.RowsAffected}) + respondOK(c, "success", gin.H{"affected": res.RowsAffected}) } func (s *WebServer) handleChannelToggle(c *gin.Context, enable bool) { @@ -813,7 +1344,7 @@ func (s *WebServer) handleChannelToggle(c *gin.Context, enable bool) { platform := c.Param("platform") var row models.ChannelConfig if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"}) + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在") return } @@ -824,7 +1355,7 @@ func (s *WebServer) handleChannelToggle(c *gin.Context, enable bool) { } row.UpdatedBy = u.UserID if err := s.db.Save(&row).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") return } after := fmt.Sprintf(`{"enabled":%v}`, row.Enabled) @@ -833,7 +1364,7 @@ func (s *WebServer) handleChannelToggle(c *gin.Context, enable bool) { action = "channel_enable" } s.writeAudit(u.UserID, action, "channel", row.Platform, before, after, "") - c.JSON(http.StatusOK, gin.H{"status": "success", "enabled": row.Enabled, "platform": row.Platform}) + respondOK(c, "success", gin.H{"enabled": row.Enabled, "platform": row.Platform}) } func (s *WebServer) handleChannelTest(c *gin.Context) { @@ -845,7 +1376,7 @@ func (s *WebServer) handleChannelTest(c *gin.Context) { platform := c.Param("platform") var row models.ChannelConfig if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"}) + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在") return } @@ -861,12 +1392,12 @@ func (s *WebServer) handleChannelTest(c *gin.Context) { row.LastCheck = &now row.Status = status if err := s.db.Save(&row).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "测试写入失败"}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "测试写入失败") return } s.writeAudit(u.UserID, "channel_test", "channel", row.Platform, "", fmt.Sprintf(`{"status":%q,"detail":%q}`, row.Status, detail), "manual test") - c.JSON(http.StatusOK, gin.H{"status": row.Status, "detail": detail, "platform": row.Platform, "checked_at": now}) + respondOK(c, "ok", gin.H{"status": row.Status, "detail": detail, "platform": row.Platform, "checked_at": now}) } func (s *WebServer) handleChannelApply(c *gin.Context) { @@ -878,13 +1409,13 @@ func (s *WebServer) handleChannelApply(c *gin.Context) { platform := c.Param("platform") var req channelConfigPatchReq if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误", "stage": "patch", "committed": false}) + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误(stage=patch,committed=false)") return } var row models.ChannelConfig if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在", "stage": "patch", "committed": false}) + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在(stage=patch,committed=false)") return } @@ -925,7 +1456,7 @@ func (s *WebServer) handleChannelApply(c *gin.Context) { if err := s.db.Save(&row).Error; err != nil { s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, "", "", "failed stage=publish: "+err.Error()) - c.JSON(http.StatusInternalServerError, gin.H{"error": "保存并发布失败", "stage": "publish", "committed": false}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存并发布失败(stage=publish,committed=false)") return } @@ -946,21 +1477,20 @@ func (s *WebServer) handleChannelApply(c *gin.Context) { if s.reloadFn == nil { s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, "failed stage=reload: reload 未配置") - c.JSON(http.StatusInternalServerError, gin.H{"error": "reload 未配置", "stage": "reload", "committed": true}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "reload 未配置(stage=reload,committed=true)") return } detail, err := s.reloadFn() if err != nil { s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, "failed stage=reload: "+err.Error()) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "stage": "reload", "committed": true}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "reload失败(stage=reload,committed=true): "+err.Error()) return } note := fmt.Sprintf("apply(patch+publish+reload) detail=%s", detail) s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, note) - c.JSON(http.StatusOK, gin.H{ - "status": "success", + respondOK(c, "success", gin.H{ "platform": row.Platform, "published_at": publishAt, "detail": detail, @@ -1016,8 +1546,529 @@ func (s *WebServer) handleAuditList(c *gin.Context) { var logs []models.AuditLog if err := q.Order("id desc").Limit(limit).Offset(offset).Find(&logs).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"}) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询失败") return } - c.JSON(http.StatusOK, logs) + respondOK(c, "ok", gin.H{"logs": logs}) +} + +func (s *WebServer) handleLegacyUsage(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") { + return + } + + type row struct { + Route string `json:"route"` + Count int64 `json:"count"` + } + routes := []string{"/api/records", "/delete/:id", "/export"} + usage := make([]row, 0, len(routes)) + for _, rt := range routes { + var cnt int64 + err := s.db.Model(&models.AuditLog{}). + Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt). + Count(&cnt).Error + if err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy usage失败") + return + } + usage = append(usage, row{Route: rt, Count: cnt}) + } + + var recent []models.AuditLog + if err := s.db.Where("action = ? AND target_type = ?", "legacy.route.access", "route").Order("id desc").Limit(50).Find(&recent).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy recent失败") + return + } + respondOK(c, "ok", gin.H{"summary": usage, "recent": recent}) +} + +func (s *WebServer) handleLegacyTrend(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") { + return + } + days, _ := strconv.Atoi(c.DefaultQuery("days", "7")) + if days <= 0 || days > 90 { + days = 7 + } + start := time.Now().AddDate(0, 0, -days+1) + + type point struct { + Day string `json:"day"` + Count int64 `json:"count"` + } + type routeTrend struct { + Route string `json:"route"` + Points []point `json:"points"` + } + routes := []string{"/api/records", "/delete/:id", "/export"} + trends := make([]routeTrend, 0, len(routes)) + + for _, rt := range routes { + pts := make([]point, 0, days) + for i := 0; i < days; i++ { + dayStart := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()).AddDate(0, 0, i) + dayEnd := dayStart.Add(24 * time.Hour) + var cnt int64 + err := s.db.Model(&models.AuditLog{}). + Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Count(&cnt).Error + if err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy trend失败") + return + } + pts = append(pts, point{Day: dayStart.Format("2006-01-02"), Count: cnt}) + } + trends = append(trends, routeTrend{Route: rt, Points: pts}) + } + respondOK(c, "ok", gin.H{"days": days, "from": start.Format(time.RFC3339), "trends": trends}) +} + +func (s *WebServer) handleLegacyReadiness(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") { + return + } + days, _ := strconv.Atoi(c.DefaultQuery("days", "7")) + if days <= 0 || days > 90 { + days = 7 + } + zeroDays, _ := strconv.Atoi(c.DefaultQuery("zero_days", "3")) + if zeroDays <= 0 || zeroDays > 30 { + zeroDays = 3 + } + + start := time.Now().AddDate(0, 0, -days+1) + routes := []string{"/api/records", "/delete/:id", "/export"} + routeTotals := map[string]int64{} + zeroStreak := map[string]int{} + windowTotal := int64(0) + ready := true + + for _, rt := range routes { + var total int64 + err := s.db.Model(&models.AuditLog{}). + Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt). + Where("created_at >= ?", start). + Count(&total).Error + if err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy readiness失败") + return + } + routeTotals[rt] = total + windowTotal += total + + streak := 0 + for i := days - 1; i >= 0; i-- { + dayStart := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()).AddDate(0, 0, i) + dayEnd := dayStart.Add(24 * time.Hour) + var cnt int64 + err := s.db.Model(&models.AuditLog{}). + Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Count(&cnt).Error + if err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy readiness失败") + return + } + if cnt == 0 { + streak++ + } else { + break + } + } + zeroStreak[rt] = streak + if streak < zeroDays { + ready = false + } + } + + recommendation := "暂不建议下线 legacy 路由(未满足连续0调用阈值)" + if ready { + recommendation = "可考虑下线 legacy 路由(已满足连续0调用阈值)" + } + respondOK(c, "ok", gin.H{ + "days": days, + "zero_days": zeroDays, + "window_total": windowTotal, + "route_totals": routeTotals, + "consecutive_zero_days": zeroStreak, + "ready": ready, + "recommendation": recommendation, + }) +} + +func (s *WebServer) handleModulesList(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + type moduleItem struct { + Module string `json:"module"` + DisplayName string `json:"display_name"` + FlagKey string `json:"flag_key"` + Enabled bool `json:"enabled"` + } + items := []moduleItem{ + {Module: "cpa", DisplayName: "CPA 管理", FlagKey: "enable_module_cpa"}, + {Module: "cf", DisplayName: "CF 管理", FlagKey: "enable_module_cf"}, + {Module: "mail", DisplayName: "邮箱管理", FlagKey: "enable_module_mail"}, + } + for i := range items { + var ff models.FeatureFlag + if err := s.db.Where("key = ?", items[i].FlagKey).First(&ff).Error; err == nil { + items[i].Enabled = ff.Enabled + } + } + respondOK(c, "ok", gin.H{"modules": items}) +} + +func (s *WebServer) handleModuleToggle(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + module := strings.TrimSpace(strings.ToLower(c.Param("module"))) + flagKey := "" + switch module { + case "cpa": + flagKey = "enable_module_cpa" + case "cf": + flagKey = "enable_module_cf" + case "mail": + flagKey = "enable_module_mail" + default: + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "unknown module") + return + } + + var req moduleToggleReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + if strings.TrimSpace(req.Reason) == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "修改模块开关必须提供 reason") + return + } + if module == "cpa" && !req.Enabled { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "禁止禁用关键模块 cpa") + return + } + + var ff models.FeatureFlag + if err := s.db.Where("key = ?", flagKey).First(&ff).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "模块开关不存在") + return + } + old := ff.Enabled + if old == req.Enabled { + respondOK(c, "noop", gin.H{"module": module, "flag_key": flagKey, "old": old, "new": req.Enabled}) + return + } + ff.Enabled = req.Enabled + ff.UpdatedBy = u.UserID + if err := s.db.Save(&ff).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "更新失败") + return + } + _ = s.db.Create(&models.FeatureFlagHistory{FlagKey: flagKey, OldValue: old, NewValue: req.Enabled, ChangedBy: u.UserID, Reason: req.Reason, RequestID: c.GetHeader("X-Request-ID")}).Error + s.writeAuditResult(u.UserID, "module.toggle", "module", module, fmt.Sprintf(`{"enabled":%v}`, old), fmt.Sprintf(`{"enabled":%v}`, req.Enabled), req.Reason, "success") + respondOK(c, "success", gin.H{"module": module, "flag_key": flagKey, "old": old, "new": req.Enabled}) +} + +func (s *WebServer) handleDashboardOverview(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { + return + } + + var jobs []models.OpsJob + s.db.Order("id desc").Limit(30).Find(&jobs) + statusCount := map[string]int{"pending": 0, "running": 0, "success": 0, "failed": 0, "cancelled": 0} + for _, j := range jobs { + statusCount[j.Status]++ + } + + type moduleItem struct { + Module string `json:"module"` + Enabled bool `json:"enabled"` + } + mods := []moduleItem{{Module: "cpa"}, {Module: "cf"}, {Module: "mail"}} + for i := range mods { + flagKey := "enable_module_" + mods[i].Module + var ff models.FeatureFlag + if err := s.db.Where("key = ?", flagKey).First(&ff).Error; err == nil { + mods[i].Enabled = ff.Enabled + } + } + + var channels []models.ChannelConfig + s.db.Order("platform asc").Find(&channels) + channelOut := make([]gin.H, 0, len(channels)) + for _, ch := range channels { + channelOut = append(channelOut, gin.H{"platform": ch.Platform, "enabled": ch.Enabled, "status": ch.Status}) + } + + respondOK(c, "ok", gin.H{"jobs": gin.H{"recent": jobs, "status_count": statusCount}, "modules": mods, "channels": channelOut}) +} + +func (s *WebServer) handleOpsJobs(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { + return + } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 || limit > 200 { + limit = 50 + } + status := strings.TrimSpace(c.Query("status")) + target := strings.TrimSpace(c.Query("target")) + runbook := strings.TrimSpace(c.Query("runbook")) + requestID := strings.TrimSpace(c.Query("request_id")) + operator := strings.TrimSpace(c.Query("operator")) + riskLevel := strings.TrimSpace(c.Query("risk_level")) + qtext := strings.TrimSpace(c.Query("q")) + from := strings.TrimSpace(c.Query("from")) + to := strings.TrimSpace(c.Query("to")) + + q := s.db.Model(&models.OpsJob{}) + if status != "" { + q = q.Where("status = ?", status) + } + if target != "" { + q = q.Where("target = ?", target) + } + if runbook != "" { + q = q.Where("runbook = ?", runbook) + } + if requestID != "" { + q = q.Where("request_id = ?", requestID) + } + if operator != "" { + opid, err := strconv.ParseInt(operator, 10, 64) + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid operator") + return + } + q = q.Where("operator = ?", opid) + } + if riskLevel != "" { + q = q.Where("risk_level = ?", riskLevel) + } + if qtext != "" { + like := "%" + qtext + "%" + q = q.Where("command LIKE ? OR runbook LIKE ? OR target LIKE ? OR request_id LIKE ?", like, like, like, like) + } + if from != "" { + t, err := time.Parse(time.RFC3339, from) + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid from, must be RFC3339") + return + } + q = q.Where("created_at >= ?", t) + } + if to != "" { + t, err := time.Parse(time.RFC3339, to) + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid to, must be RFC3339") + return + } + q = q.Where("created_at <= ?", t) + } + + var jobs []models.OpsJob + if err := q.Order("id desc").Limit(limit).Find(&jobs).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询ops jobs失败") + return + } + respondOK(c, "ok", gin.H{"jobs": jobs, "filters": gin.H{"limit": limit, "status": status, "target": target, "runbook": runbook, "request_id": requestID, "operator": operator, "risk_level": riskLevel, "q": qtext, "from": from, "to": to}}) +} + +func (s *WebServer) handleDashboardSummary(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { + return + } + + var total, running, failed, success int64 + s.db.Model(&models.OpsJob{}).Count(&total) + s.db.Model(&models.OpsJob{}).Where("status = ?", "running").Count(&running) + s.db.Model(&models.OpsJob{}).Where("status = ?", "failed").Count(&failed) + s.db.Model(&models.OpsJob{}).Where("status = ?", "success").Count(&success) + + mods := map[string]bool{"cpa": false, "cf": false, "mail": false} + for k := range mods { + var ff models.FeatureFlag + if err := s.db.Where("key = ?", "enable_module_"+k).First(&ff).Error; err == nil { + mods[k] = ff.Enabled + } + } + + respondOK(c, "ok", gin.H{ + "jobs": gin.H{"total": total, "running": running, "failed": failed, "success": success}, + "modules": mods, + }) +} + +func (s *WebServer) handleOpsJobsByRequestID(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { + return + } + requestID := strings.TrimSpace(c.Param("requestID")) + if requestID == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "request id required") + return + } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 || limit > 200 { + limit = 50 + } + var jobs []models.OpsJob + if err := s.db.Where("request_id = ?", requestID).Order("id desc").Limit(limit).Find(&jobs).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询ops jobs失败") + return + } + var total int64 + if err := s.db.Model(&models.OpsJob{}).Where("request_id = ?", requestID).Count(&total).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询ops jobs总数失败") + return + } + respondOK(c, "ok", gin.H{"request_id": requestID, "total": total, "jobs": jobs}) +} + +func (s *WebServer) handleOpsJobDetail(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { + return + } + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid job id") + return + } + + var job models.OpsJob + if err := s.db.First(&job, id).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "job not found") + return + } + + var steps []models.OpsJobStep + if err := s.db.Where("job_id = ?", job.ID).Order("id asc").Find(&steps).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "query steps failed") + return + } + + stepStats := map[string]int{"running": 0, "success": 0, "failed": 0, "skipped": 0} + var stepDurationMs int64 + for _, st := range steps { + stepStats[st.Status]++ + if !st.StartedAt.IsZero() && !st.EndedAt.IsZero() { + d := st.EndedAt.Sub(st.StartedAt).Milliseconds() + if d > 0 { + stepDurationMs += d + } + } + } + var jobDurationMs int64 + if !job.StartedAt.IsZero() && !job.EndedAt.IsZero() { + d := job.EndedAt.Sub(job.StartedAt).Milliseconds() + if d > 0 { + jobDurationMs = d + } + } + respondOK(c, "ok", gin.H{"job": job, "steps": steps, "step_stats": stepStats, "step_total": len(steps), "duration": gin.H{"job_ms": jobDurationMs, "steps_ms_sum": stepDurationMs}}) +} + +func (s *WebServer) handleOpsJobCancel(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.cancel", "无 ops.cancel 权限") { + return + } + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid job id") + return + } + + var req opsJobActionReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + reason := strings.TrimSpace(req.Reason) + if reason == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cancel 必须提供 reason") + return + } + + var job models.OpsJob + if err := s.db.First(&job, id).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "job not found") + return + } + if job.Status == "success" || job.Status == "failed" || job.Status == "cancelled" { + respondOK(c, "noop", gin.H{"id": job.ID, "job_status": job.Status, "reason": "job already finished"}) + return + } + if job.Status != "pending" && job.Status != "running" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "job status not cancellable") + return + } + cancelled := runbook.CancelJob(job.ID) + job.Status = "cancelled" + job.CancelNote = reason + job.EndedAt = time.Now() + if err := s.db.Save(&job).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "cancel failed") + return + } + s.writeAuditResult(u.UserID, "ops.job.cancel", "ops_job", strconv.Itoa(int(job.ID)), "", "", reason, "success") + respondOK(c, "cancelled", gin.H{"id": job.ID, "reason": reason, "signal_sent": cancelled}) +} + +func (s *WebServer) handleOpsJobRetry(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.retry", "无 ops.retry 权限") { + return + } + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid job id") + return + } + + var req opsJobActionReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + reason := strings.TrimSpace(req.Reason) + if reason == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "retry 必须提供 reason") + return + } + + var old models.OpsJob + if err := s.db.First(&old, id).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "job not found") + return + } + if strings.TrimSpace(old.Status) != "failed" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "only failed jobs can retry") + return + } + + newID, err := ops.RetryJobWithDB(s.db, filepath.Clean(s.baseDir), uint(id)) + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error()) + return + } + s.writeAuditResult(u.UserID, "ops.job.retry", "ops_job", strconv.Itoa(id), "", "", reason, "success") + respondOK(c, "retried", gin.H{"old_job_id": id, "new_job_id": newID, "reason": reason}) } diff --git a/runbooks/cpa_usage_backup.yaml b/runbooks/cpa_usage_backup.yaml new file mode 100644 index 0000000..d36361c --- /dev/null +++ b/runbooks/cpa_usage_backup.yaml @@ -0,0 +1,38 @@ +version: 1 +name: cpa_usage_backup +description: 实时导出 usage 并打包备份(公网管理接口) +inputs: [] +steps: + - id: export_and_package + action: shell.exec + on_fail: stop + with: + command: | + CPA_TOKEN=${env.cpa_management_token} + CPA_BASE=https://cpa.pao.xx.kg/v0/management + ts=$(date +%F_%H%M%S) + out=/root/cliproxyapi/usage_export_${ts}.json + curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" ${CPA_BASE}/usage/export -o ${out} + + echo ${out} + + latest=$(ls -1t /root/cliproxyapi/usage_export_*.json | head -n 1) + ts=$(date +%Y-%m-%d_%H%M%S) + out=/root/backups/cpa-runtime-daily/hwsg_usage_realtime_${ts}.tar.gz + meta=/root/backups/cpa-runtime-daily/hwsg_usage_realtime_${ts}.meta.txt + mkdir -p /root/backups/cpa-runtime-daily + tar -czf ${out} ${latest} + sha=$(sha256sum ${out} | awk '{print $1}') + size=$(du -h ${out} | awk '{print $1}') + req=$(python3 -c "import json; data=json.load(open('${latest}','r',encoding='utf-8')); u=data.get('usage',{}); print(u.get('total_requests', data.get('total_requests','unknown')))" ) + tok=$(python3 -c "import json; data=json.load(open('${latest}','r',encoding='utf-8')); u=data.get('usage',{}); print(u.get('total_tokens', data.get('total_tokens','unknown')))" ) + { + echo "time=$(date '+%F %T %z')" + echo "source=${latest}" + echo "backup=${out}" + echo "sha256=${sha}" + echo "size=${size}" + echo "total_requests=${req}" + echo "total_tokens=${tok}" + } > ${meta} + cat ${meta} diff --git a/runbooks/cpa_usage_restore.yaml b/runbooks/cpa_usage_restore.yaml new file mode 100644 index 0000000..0f91478 --- /dev/null +++ b/runbooks/cpa_usage_restore.yaml @@ -0,0 +1,112 @@ +version: 1 +name: cpa_usage_restore +description: 从备份包恢复 usage(公网管理接口,双重校验) +inputs: + - backup_id +steps: + - id: pre_backup + action: shell.exec + on_fail: stop + with: + command: | + CPA_TOKEN=${env.cpa_management_token} + CPA_BASE=https://cpa.pao.xx.kg/v0/management + ts=$(date +%F_%H%M%S) + out=/root/cliproxyapi/usage_export_${ts}.json + curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" ${CPA_BASE}/usage/export -o ${out} + + echo ${out} + + latest=$(ls -1t /root/cliproxyapi/usage_export_*.json | head -n 1) + ts=$(date +%Y-%m-%d_%H%M%S) + out=/root/backups/cpa-runtime-daily/hwsg_usage_realtime_${ts}.tar.gz + meta=/root/backups/cpa-runtime-daily/hwsg_usage_realtime_${ts}.meta.txt + mkdir -p /root/backups/cpa-runtime-daily + tar -czf ${out} ${latest} + sha=$(sha256sum ${out} | awk '{print $1}') + size=$(du -h ${out} | awk '{print $1}') + req=$(python3 -c "import json; data=json.load(open('${latest}','r',encoding='utf-8')); u=data.get('usage',{}); print(u.get('total_requests', data.get('total_requests','unknown')))" ) + tok=$(python3 -c "import json; data=json.load(open('${latest}','r',encoding='utf-8')); u=data.get('usage',{}); print(u.get('total_tokens', data.get('total_tokens','unknown')))" ) + { + echo "time=$(date '+%F %T %z')" + echo "source=${latest}" + echo "backup=${out}" + echo "sha256=${sha}" + echo "size=${size}" + echo "total_requests=${req}" + echo "total_tokens=${tok}" + } > ${meta} + cat ${meta} + + - id: find_backup + action: shell.exec + on_fail: stop + with: + command: "ls -1 /root/backups/cpa-runtime-daily/${inputs.backup_id}.tar.gz" + + - id: extract_backup + action: shell.exec + on_fail: stop + with: + command: "mkdir -p /tmp/cpa-restore && tar -xzf /root/backups/cpa-runtime-daily/${inputs.backup_id}.tar.gz -C /tmp/cpa-restore" + + - id: import_usage + action: shell.exec + on_fail: stop + with: + command: | + CPA_TOKEN=${env.cpa_management_token} + CPA_BASE=https://cpa.pao.xx.kg/v0/management + latest=$(ls -1 /tmp/cpa-restore/root/cliproxyapi/usage_export_*.json 2>/dev/null | head -n 1) + if [ -z "$latest" ]; then + latest=$(ls -1 /tmp/cpa-restore/root/cliproxyapi/stats_persistence-*.json 2>/dev/null | head -n 1) + fi + if [ -z "$latest" ]; then + echo "no usage file found"; exit 1 + fi + python3 -c "import json; json.load(open('${latest}','r',encoding='utf-8')); print('json_ok')" + resp=$(curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" -H "Content-Type: application/json" --data @${latest} ${CPA_BASE}/usage/import) + echo "$resp" + python3 -c "import json,sys; r=json.loads(sys.argv[1]); import sys as _s; _s.exit(r.get('error')) if isinstance(r,dict) and r.get('error') else print('import_ok')" "$resp" + + - id: verify_now + action: shell.exec + on_fail: stop + with: + command: | + CPA_TOKEN=${env.cpa_management_token} + CPA_BASE=https://cpa.pao.xx.kg/v0/management + curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" ${CPA_BASE}/usage + + - id: verify_now_assert + action: assert.json + on_fail: stop + with: + source_step: verify_now + required_paths: + - "usage.total_requests" + - "usage.total_tokens" + + - id: wait_10s + action: sleep + on_fail: continue + with: + ms: 10000 + + - id: verify_later + action: shell.exec + on_fail: stop + with: + command: | + CPA_TOKEN=${env.cpa_management_token} + CPA_BASE=https://cpa.pao.xx.kg/v0/management + curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" ${CPA_BASE}/usage + + - id: verify_later_assert + action: assert.json + on_fail: stop + with: + source_step: verify_later + required_paths: + - "usage.total_requests" + - "usage.total_tokens"