feat: 模块化命令框架 + runbook 严格断言与查询结果回传

- core/module: 命令模板、门控(feature flag/confirm/dry-run)、ecode 错误标签、ResultFormatter
- runbook executor: http.get/http.post/assert.json(expect_success 严格断言)、目标主机管理、任务落库、最终 stdout 直接回传
- cf 模块: /cf dns list 支持域名自动解析 zone_id;dns list/zones/workers list 格式化回传;API success:false 判真失败
- 全部 CF/CPA runbook 接入严格断言,杜绝假阳性 success
- 新增 cpa/cfdav/mail/sub 模块与 runbooks;QQ 订阅到期提醒调度
- Web 后台: 模块设置(CF/CPA/cfdav/AI)、OPS 任务、渠道管理、审计页
This commit is contained in:
2026-09-13 07:42:17 +08:00
parent 9085c490f3
commit 81939825d1
86 changed files with 6885 additions and 467 deletions
+253
View File
@@ -0,0 +1,253 @@
package cfdav
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"ops-assistant/internal/core/command"
"ops-assistant/internal/core/ecode"
"ops-assistant/models"
"gorm.io/gorm"
)
type Module struct {
db *gorm.DB
}
type settings struct {
APIBase string
AdminUser string
AdminPass string
}
type userItem struct {
ID string `json:"id"`
Email string `json:"email"`
IsAdmin int `json:"is_admin"`
CreatedAt string `json:"created_at"`
}
type listResp struct {
Success bool `json:"success"`
Data []userItem `json:"data"`
Error string `json:"error"`
}
type simpleResp struct {
Success bool `json:"success"`
Error string `json:"error"`
}
func New(db *gorm.DB) *Module {
return &Module{db: db}
}
func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) {
text := strings.TrimSpace(cmd.Raw)
if text == "/cfdav" || strings.HasPrefix(text, "/cfdav help") {
return "cfdav 模块\n- /cfdav users\n- /cfdav add <email> <password> [admin]\n- /cfdav del <user_id>", nil
}
parts := strings.Fields(text)
if len(parts) == 0 {
return ecode.Tag(ecode.ErrStepFailed, "空命令"), nil
}
// 支持别名:/cfdav list
if len(parts) >= 2 && (parts[1] == "users" || parts[1] == "list") {
return m.listUsers()
}
if len(parts) >= 2 && (parts[1] == "add" || (len(parts) >= 3 && parts[1] == "user" && parts[2] == "add")) {
return m.addUser(parts)
}
if len(parts) >= 2 && (parts[1] == "del" || parts[1] == "delete" || (len(parts) >= 3 && parts[1] == "user" && (parts[2] == "del" || parts[2] == "delete"))) {
return m.deleteUser(parts)
}
return ecode.Tag(ecode.ErrStepFailed, "cfdav 模块已接入,当前支持:/cfdav users, /cfdav add, /cfdav del"), nil
}
func (m *Module) listUsers() (string, error) {
cfg, err := m.loadSettings()
if err != nil {
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
}
url := strings.TrimRight(cfg.APIBase, "/") + "/api/admin/users"
resp, status, err := m.doRequest(http.MethodGet, url, nil, cfg)
if err != nil {
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
}
if status >= 400 {
return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, resp)), nil
}
var out listResp
if err := json.Unmarshal([]byte(resp), &out); err != nil {
return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil
}
if !out.Success {
return ecode.Tag(ecode.ErrStepFailed, out.Error), nil
}
if len(out.Data) == 0 {
return "📭 cfdav 用户列表为空", nil
}
limit := len(out.Data)
if limit > 50 {
limit = 50
}
lines := make([]string, 0, limit+2)
lines = append(lines, fmt.Sprintf("✅ cfdav 用户列表(%d)", len(out.Data)))
for i := 0; i < limit; i++ {
u := out.Data[i]
admin := "user"
if u.IsAdmin == 1 {
admin = "admin"
}
lines = append(lines, fmt.Sprintf("- %s | %s | %s", u.ID, u.Email, admin))
}
if len(out.Data) > limit {
lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(out.Data), limit))
}
return strings.Join(lines, "\n"), nil
}
func (m *Module) addUser(parts []string) (string, error) {
cfg, err := m.loadSettings()
if err != nil {
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
}
// 支持: /cfdav add <email> <pass> [admin]
// 或: /cfdav user add <email> <pass> [admin]
idx := 2
if len(parts) >= 3 && parts[1] == "user" {
idx = 3
}
if len(parts) < idx+2 {
return ecode.Tag(ecode.ErrStepFailed, "缺少参数,示例:/cfdav add <email> <password> [admin]"), nil
}
email := strings.TrimSpace(parts[idx])
pass := strings.TrimSpace(parts[idx+1])
if email == "" || pass == "" {
return ecode.Tag(ecode.ErrStepFailed, "email/password 不能为空"), nil
}
isAdmin := false
if len(parts) >= idx+3 {
flag := strings.ToLower(strings.TrimSpace(parts[idx+2]))
isAdmin = flag == "admin" || flag == "1" || flag == "true" || flag == "yes"
}
payload := map[string]any{"email": email, "password": pass, "isAdmin": isAdmin}
body, _ := json.Marshal(payload)
url := strings.TrimRight(cfg.APIBase, "/") + "/api/admin/users"
resp, status, err := m.doRequest(http.MethodPost, url, body, cfg)
if err != nil {
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
}
if status >= 400 {
return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, resp)), nil
}
var out simpleResp
if err := json.Unmarshal([]byte(resp), &out); err != nil {
return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil
}
if !out.Success {
return ecode.Tag(ecode.ErrStepFailed, out.Error), nil
}
return fmt.Sprintf("✅ cfdav 用户已创建:%s", email), nil
}
func (m *Module) deleteUser(parts []string) (string, error) {
cfg, err := m.loadSettings()
if err != nil {
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
}
// 支持: /cfdav del <id> 或 /cfdav user del <id>
idx := 2
if len(parts) >= 3 && parts[1] == "user" {
idx = 3
}
if len(parts) < idx+1 {
return ecode.Tag(ecode.ErrStepFailed, "缺少参数,示例:/cfdav del <user_id>"), nil
}
id := strings.TrimSpace(parts[idx])
if id == "" {
return ecode.Tag(ecode.ErrStepFailed, "user_id 不能为空"), nil
}
url := strings.TrimRight(cfg.APIBase, "/") + "/api/admin/users/" + id
resp, status, err := m.doRequest(http.MethodDelete, url, nil, cfg)
if err != nil {
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
}
if status >= 400 {
return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, resp)), nil
}
var out simpleResp
if err := json.Unmarshal([]byte(resp), &out); err != nil {
return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil
}
if !out.Success {
return ecode.Tag(ecode.ErrStepFailed, out.Error), nil
}
return fmt.Sprintf("✅ cfdav 用户已删除:%s", id), nil
}
func (m *Module) loadSettings() (*settings, error) {
if m.db == nil {
return nil, errors.New("db 未初始化")
}
keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"}
cfg := &settings{}
for _, k := range keys {
var sset models.AppSetting
val := ""
if err := m.db.Where("key = ?", k).First(&sset).Error; err == nil {
val = sset.Value
}
switch k {
case "cfdav_api_base":
cfg.APIBase = strings.TrimSpace(val)
case "cfdav_admin_user":
cfg.AdminUser = strings.TrimSpace(val)
case "cfdav_admin_pass":
cfg.AdminPass = val
}
}
if cfg.APIBase == "" {
return nil, errors.New("cfdav api_base 未配置")
}
if cfg.AdminUser == "" || cfg.AdminPass == "" {
return nil, errors.New("cfdav admin 账号未配置")
}
return cfg, nil
}
func (m *Module) doRequest(method, url string, body []byte, cfg *settings) (string, int, error) {
var reader io.Reader
if body != nil {
reader = strings.NewReader(string(body))
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
return "", 0, err
}
auth := cfg.AdminUser + ":" + cfg.AdminPass
b64 := base64.StdEncoding.EncodeToString([]byte(auth))
req.Header.Set("Authorization", "Basic "+b64)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", resp.StatusCode, err
}
return string(data), resp.StatusCode, nil
}