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
+323
View File
@@ -0,0 +1,323 @@
package qq
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"ops-assistant/models"
)
type subSessionPayload struct {
Step string `json:"step"`
Temp map[string]string `json:"temp"`
}
type subAPISettings struct {
APIBase string
Token string
}
const (
subStepName = "WAIT_NAME"
subStepCategory = "WAIT_CATEGORY"
subStepAcct = "WAIT_ACCOUNT"
subStepAmount = "WAIT_AMOUNT"
subStepCurrency = "WAIT_CURRENCY"
subStepDate = "WAIT_DATE"
subStepCycleUnit = "WAIT_CYCLE_UNIT"
subStepCycleValue = "WAIT_CYCLE_VALUE"
)
var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
func (b *QQBot) handleSubFlow(userID string, text string) (string, bool) {
raw := strings.TrimSpace(text)
if !strings.HasPrefix(raw, "/sub") {
if raw == "/cancel" {
if b.hasSubSession(userID) {
b.clearSubSession(userID)
return "✅ 已取消订阅新增流程", true
}
return "", false
}
if !b.hasSubSession(userID) {
return "", false
}
} else {
if raw == "/sub" || strings.HasPrefix(raw, "/sub help") {
if b.opsSvc != nil {
if handled, out := b.opsSvc.Handle(DefaultUserID, raw); handled {
return out, true
}
}
return "订阅模块\n- /sub add\n- /sub list\n- /sub report\n- /sub report category\n- /sub report cycle\n- /sub report due <7|30|overdue>\n- /sub del <id>\n- /sub cancel(或 /cancel)取消新增流程", true
}
if raw == "/sub cancel" {
if b.hasSubSession(userID) {
b.clearSubSession(userID)
return "✅ 已取消订阅新增流程", true
}
return "", true
}
if raw == "/sub add" || strings.HasPrefix(raw, "/sub add ") {
b.setSubSession(userID, &subSessionPayload{Step: subStepName, Temp: map[string]string{}})
return "1️⃣ 输入订阅名称(例如:Netflix)", true
}
return "", false
}
sess := b.getSubSession(userID)
if sess == nil {
return "", false
}
msg := raw
if msg == "" {
return "", true
}
if sess.Temp == nil {
sess.Temp = map[string]string{}
}
switch sess.Step {
case subStepName:
sess.Temp["name"] = msg
sess.Step = subStepCategory
b.setSubSession(userID, sess)
return "2️⃣ 输入分类(例如:通信/视频/游戏/工具/其他,可 /skip=未分类)", true
case subStepCategory:
if msg == "/skip" {
sess.Temp["category"] = "未分类"
} else {
sess.Temp["category"] = msg
}
if strings.TrimSpace(sess.Temp["category"]) == "" {
return "❌ 分类不能为空,请重新输入(可 /skip)", true
}
sess.Step = subStepAcct
b.setSubSession(userID, sess)
return "3️⃣ 输入账号/备注(可选,回复 /skip 跳过)", true
case subStepAcct:
if msg == "/skip" {
sess.Temp["account"] = ""
} else {
sess.Temp["account"] = msg
}
sess.Step = subStepAmount
b.setSubSession(userID, sess)
return "4️⃣ 输入金额(数字,例如 9.99)", true
case subStepAmount:
amt, err := strconv.ParseFloat(msg, 64)
if err != nil || amt < 0 {
return "❌ 金额格式错误,请输入数字(例如 9.99)", true
}
sess.Temp["amount"] = fmt.Sprintf("%.2f", amt)
sess.Step = subStepCurrency
b.setSubSession(userID, sess)
return "5️⃣ 输入币种(CNY/USD/EUR/GBP/HKD/JPY/MYR/SGD,或 /skip 默认 CNY)", true
case subStepCurrency:
cur := strings.ToUpper(msg)
if msg == "/skip" {
cur = "CNY"
}
if !isAllowedCurrency(cur) {
return "❌ 币种不支持,请输入 CNY/USD/EUR/GBP/HKD/JPY/MYR/SGD", true
}
sess.Temp["currency"] = cur
sess.Step = subStepDate
b.setSubSession(userID, sess)
return "6️⃣ 输入到期日(YYYY-MM-DD)", true
case subStepDate:
if !dateRe.MatchString(msg) {
return "❌ 日期格式错误,请用 YYYY-MM-DD", true
}
sess.Temp["due_date"] = msg
sess.Step = subStepCycleUnit
b.setSubSession(userID, sess)
return "7️⃣ 输入周期单位(day/month/year)", true
case subStepCycleUnit:
unit := strings.ToLower(msg)
if !isAllowedCycleUnit(unit) {
return "❌ 周期单位不支持,请输入 day/month/year", true
}
sess.Temp["cycle_unit"] = unit
sess.Step = subStepCycleValue
b.setSubSession(userID, sess)
return "8️⃣ 输入周期数量(数字)", true
case subStepCycleValue:
cv, err := strconv.Atoi(msg)
if err != nil || cv <= 0 {
return "❌ 周期数量必须是正整数", true
}
sess.Temp["cycle_value"] = strconv.Itoa(cv)
b.clearSubSession(userID)
return b.subCreate(sess)
default:
b.clearSubSession(userID)
return "❌ 会话已重置,请重新发送 /sub add", true
}
}
func (b *QQBot) subCreate(sess *subSessionPayload) (string, bool) {
cfg, err := b.loadSubSettings()
if err != nil {
return "❌ 未配置 sub_api_base/sub_api_token", true
}
payload := map[string]any{
"user_id": "1",
"channel": "qq",
"name": sess.Temp["name"],
"category": sess.Temp["category"],
"account": sess.Temp["account"],
"amount": toFloat(sess.Temp["amount"]),
"currency": sess.Temp["currency"],
"due_date": sess.Temp["due_date"],
"cycle_unit": sess.Temp["cycle_unit"],
"cycle_value": toInt(sess.Temp["cycle_value"]),
}
body, _ := json.Marshal(payload)
url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions"
resp, status, err := doSubRequest(http.MethodPost, url, body, cfg.Token)
if err != nil {
return "❌ 创建失败: " + err.Error(), true
}
if status >= 400 {
return fmt.Sprintf("❌ 创建失败: http %d %s", status, strings.TrimSpace(resp)), true
}
return "✅ 添加成功", true
}
func (b *QQBot) loadSubSettings() (*subAPISettings, error) {
if b.db == nil {
return nil, fmt.Errorf("db nil")
}
var base models.AppSetting
if err := b.db.Where("key = ?", "sub_api_base").First(&base).Error; err != nil {
return nil, err
}
var token models.AppSetting
if err := b.db.Where("key = ?", "sub_api_token").First(&token).Error; err != nil {
return nil, err
}
apiBase := strings.TrimSpace(base.Value)
apiToken := strings.TrimSpace(token.Value)
if apiBase == "" || apiToken == "" {
return nil, fmt.Errorf("empty settings")
}
return &subAPISettings{APIBase: apiBase, Token: apiToken}, nil
}
func (b *QQBot) hasSubSession(userID string) bool {
if b.db == nil {
return false
}
var st models.SubSession
if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&st).Error; err != nil {
return false
}
return strings.TrimSpace(st.Step) != ""
}
func (b *QQBot) getSubSession(userID string) *subSessionPayload {
if b.db == nil {
return nil
}
var st models.SubSession
if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&st).Error; err != nil {
return nil
}
var payload subSessionPayload
if err := json.Unmarshal([]byte(st.TempJSON), &payload.Temp); err != nil {
payload.Temp = map[string]string{}
}
payload.Step = st.Step
return &payload
}
func (b *QQBot) setSubSession(userID string, sess *subSessionPayload) {
if b.db == nil {
return
}
tmp, _ := json.Marshal(sess.Temp)
var row models.SubSession
if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&row).Error; err == nil {
row.Step = sess.Step
row.TempJSON = string(tmp)
_ = b.db.Save(&row).Error
return
}
_ = b.db.Create(&models.SubSession{
UserID: userID,
Platform: "qqbot_official",
Step: sess.Step,
TempJSON: string(tmp),
}).Error
}
func (b *QQBot) clearSubSession(userID string) {
if b.db == nil {
return
}
_ = b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").Delete(&models.SubSession{}).Error
}
func doSubRequest(method, url string, body []byte, token string) (string, int, error) {
req, err := http.NewRequest(method, url, bytes.NewReader(body))
if err != nil {
return "", 0, err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
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, _ := io.ReadAll(resp.Body)
return string(data), resp.StatusCode, nil
}
func toInt(s string) int {
if s == "" {
return 0
}
v, _ := strconv.Atoi(s)
return v
}
func toFloat(s string) float64 {
if s == "" {
return 0
}
v, _ := strconv.ParseFloat(s, 64)
return v
}
func isAllowedCurrency(c string) bool {
switch strings.ToUpper(c) {
case "CNY", "USD", "EUR", "GBP", "HKD", "JPY", "MYR", "SGD":
return true
default:
return false
}
}
func isAllowedCycleUnit(u string) bool {
switch strings.ToLower(u) {
case "day", "month", "year":
return true
default:
return false
}
}