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
+127
View File
@@ -0,0 +1,127 @@
package sub
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
)
type reportSummaryResp struct {
Success bool `json:"success"`
Data struct {
Total int `json:"total"`
Currency []struct {
Currency string `json:"currency"`
Cnt int `json:"cnt"`
Total float64 `json:"total"`
} `json:"currency"`
Due struct {
Overdue int `json:"overdue"`
Due7 int `json:"due7"`
Due30 int `json:"due30"`
} `json:"due"`
} `json:"data"`
Error string `json:"error"`
}
type reportListResp struct {
Success bool `json:"success"`
Data []map[string]any `json:"data"`
Error string `json:"error"`
}
func (m *Module) report(userID int64, args []string) (string, error) {
cfg, err := m.loadSettings()
if err != nil {
return "❌ " + err.Error(), nil
}
typeName := "summary"
days := ""
if len(args) >= 1 {
typeName = strings.ToLower(strings.TrimSpace(args[0]))
}
if len(args) >= 2 {
days = strings.TrimSpace(args[1])
}
base := strings.TrimRight(cfg.APIBase, "/") + "/api/report?user_id=1&channel=qq&type=" + typeName
if days != "" {
base += "&days=" + days
}
body, status, err := m.doRequest(http.MethodGet, base, nil, cfg)
if err != nil {
return "❌ 报表请求失败: " + err.Error(), nil
}
if status >= 400 {
return fmt.Sprintf("❌ 报表失败: http %d %s", status, body), nil
}
switch typeName {
case "summary":
var out reportSummaryResp
if err := json.Unmarshal([]byte(body), &out); err != nil {
return "❌ 报表解析失败", nil
}
if !out.Success {
return "❌ " + out.Error, nil
}
lines := []string{fmt.Sprintf("📊 订阅总览:%d 项", out.Data.Total)}
if len(out.Data.Currency) > 0 {
lines = append(lines, "💰 按币种汇总:")
for _, c := range out.Data.Currency {
lines = append(lines, fmt.Sprintf("- %s: %.2f(%d项)", c.Currency, c.Total, c.Cnt))
}
}
lines = append(lines, fmt.Sprintf("⏰ 到期:7天内=%d,30天内=%d,已逾期=%d", out.Data.Due.Due7, out.Data.Due.Due30, out.Data.Due.Overdue))
return strings.Join(lines, "\n"), nil
case "category", "cycle", "due":
var out reportListResp
if err := json.Unmarshal([]byte(body), &out); err != nil {
return "❌ 报表解析失败", nil
}
if !out.Success {
return "❌ " + out.Error, nil
}
if len(out.Data) == 0 {
return "📭 无数据", nil
}
lines := []string{fmt.Sprintf("📊 报表(%s):%d 条", typeName, len(out.Data))}
max := len(out.Data)
if max > 30 {
max = 30
}
for i := 0; i < max; i++ {
r := out.Data[i]
switch typeName {
case "category":
lines = append(lines, fmt.Sprintf("- %v | %v | 数量:%v | 金额:%.2f", r["category"], r["currency"], r["cnt"], toF64(r["total"])))
case "cycle":
lines = append(lines, fmt.Sprintf("- %v x%v | 数量:%v", r["cycle_unit"], r["cycle_value"], r["cnt"]))
case "due":
lines = append(lines, fmt.Sprintf("- %v | %v | 分类:%v | 到期:%v | 周期:%v x%v | %.2f %v", r["id"], r["name"], r["category"], r["due_date"], r["cycle_unit"], r["cycle_value"], toF64(r["amount"]), r["currency"]))
}
}
if len(out.Data) > max {
lines = append(lines, fmt.Sprintf("... 共%d条,仅显示前%d条", len(out.Data), max))
}
return strings.Join(lines, "\n"), nil
default:
return "❌ 用法:/sub report | /sub report category | /sub report cycle | /sub report due <7|30|overdue>", nil
}
}
func toF64(v any) float64 {
switch t := v.(type) {
case float64:
return t
case string:
f, _ := strconv.ParseFloat(t, 64)
return f
case int:
return float64(t)
default:
return 0
}
}