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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user