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:
@@ -33,6 +33,7 @@ func commandSpecs() []coremodule.CommandSpec {
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cf zones(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf zones 已执行,job=%d", jobID) },
|
||||
ResultFormatter: func(out string) (string, bool) { return FormatZones(out) },
|
||||
},
|
||||
ErrPrefix: "/cf zones 执行失败: ",
|
||||
},
|
||||
@@ -44,7 +45,19 @@ func commandSpecs() []coremodule.CommandSpec {
|
||||
if len(parts) < 4 {
|
||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dns list <zone_id>"))
|
||||
}
|
||||
return map[string]string{"zone_id": parts[3]}, nil
|
||||
zoneID := parts[3]
|
||||
if !isHex32(zoneID) {
|
||||
acct, tok := cfSettings()
|
||||
if acct == "" || tok == "" {
|
||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "cf 凭据未配置,无法解析域名;请直接传 zone_id"))
|
||||
}
|
||||
resolved, _, err := ResolveZoneIDIfDomain(zoneID, acct, tok)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, err.Error()))
|
||||
}
|
||||
zoneID = resolved
|
||||
}
|
||||
return map[string]string{"zone_id": zoneID}, nil
|
||||
},
|
||||
Gate: coremodule.Gate{
|
||||
NeedFlag: "enable_module_cf",
|
||||
@@ -52,6 +65,7 @@ func commandSpecs() []coremodule.CommandSpec {
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cf dns list <zone_id>(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dns list 已执行,job=%d", jobID) },
|
||||
ResultFormatter: func(out string) (string, bool) { return FormatDNSList(out) },
|
||||
},
|
||||
ErrPrefix: "/cf dns list 执行失败: ",
|
||||
ErrHint: "/cf dns list <zone_id>",
|
||||
@@ -247,6 +261,7 @@ func commandSpecs() []coremodule.CommandSpec {
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cf workers list(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf workers list 已执行,job=%d", jobID) },
|
||||
ResultFormatter: func(out string) (string, bool) { return FormatWorkers(out) },
|
||||
},
|
||||
ErrPrefix: "/cf workers list 执行失败: ",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package cf
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"ops-assistant/internal/core/runbook"
|
||||
)
|
||||
|
||||
// ModuleRef 由 module.go 注入 db 句柄,避免循环依赖
|
||||
var ModuleRef struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// cfSettings 从 app_settings 读取 CF 凭据(与 executor 相同存储约定)
|
||||
func cfSettings() (accountID, apiToken string) {
|
||||
if ModuleRef.DB == nil {
|
||||
return "", ""
|
||||
}
|
||||
type setting struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
var rows []setting
|
||||
if err := ModuleRef.DB.Table("app_settings").Where("key IN ?", []string{"cf_account_id", "cf_api_token"}).Find(&rows).Error; err != nil {
|
||||
return "", ""
|
||||
}
|
||||
for _, r := range rows {
|
||||
switch r.Key {
|
||||
case "cf_account_id":
|
||||
accountID = r.Value
|
||||
case "cf_api_token":
|
||||
apiToken = r.Value
|
||||
}
|
||||
}
|
||||
return accountID, apiToken
|
||||
}
|
||||
|
||||
// ResolveZoneIDIfDomain 判断 zoneIDOrName 是否为域名(非 32 位十六进制),
|
||||
// 若是则调用 zones runbook 的同款 API 逻辑解析为 zone_id。
|
||||
// 返回 (解析后的 zone_id, 是否发生了替换, error)。
|
||||
func ResolveZoneIDIfDomain(zoneIDOrName, accountID, apiToken string) (string, bool, error) {
|
||||
s := strings.TrimSpace(zoneIDOrName)
|
||||
if s == "" || isHex32(s) {
|
||||
return s, false, nil
|
||||
}
|
||||
zones, err := fetchZones(accountID, apiToken)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
for _, z := range zones {
|
||||
if strings.EqualFold(z.Name, s) {
|
||||
return z.ID, true, nil
|
||||
}
|
||||
}
|
||||
return "", false, fmt.Errorf("找不到域名 %s 对应的 zone(账号下无此域名或无权限)", s)
|
||||
}
|
||||
|
||||
func isHex32(s string) bool {
|
||||
if len(s) != 32 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type cfZone struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func fetchZones(accountID, apiToken string) ([]cfZone, error) {
|
||||
// 与 runbooks/cf_zones.yaml 相同的 API 调用方式
|
||||
url := "https://api.cloudflare.com/client/v4/zones?account.id=" + accountID + "&per_page=200"
|
||||
body, err := cfHTTPGet(url, apiToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data struct {
|
||||
Success bool `json:"success"`
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"result"`
|
||||
Errors []json.RawMessage `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &data); err != nil {
|
||||
return nil, fmt.Errorf("zones 响应解析失败: %v", err)
|
||||
}
|
||||
if !data.Success {
|
||||
return nil, fmt.Errorf("zones API 返回 success=false")
|
||||
}
|
||||
out := make([]cfZone, 0, len(data.Result))
|
||||
for _, z := range data.Result {
|
||||
out = append(out, cfZone{ID: z.ID, Name: z.Name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cfHTTPGet(url, apiToken string) (string, error) {
|
||||
// 通过 SSH 在 hwsg 上用 python3+requests 发请求(与 runbook 网络环境一致)
|
||||
script := fmt.Sprintf(
|
||||
"python3 -c \"import os,requests; token=os.environ['CF_TOKEN']; resp=requests.get(os.environ['CF_URL'], headers={'Authorization':'Bearer '+token}, timeout=15); print(resp.text)\"",
|
||||
)
|
||||
out, err := sshExec(script, map[string]string{"CF_TOKEN": apiToken, "CF_URL": url})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sshExec(script string, env map[string]string) (string, error) {
|
||||
if ModuleRef.DB == nil {
|
||||
return "", fmt.Errorf("db 未初始化,无法解析目标主机")
|
||||
}
|
||||
// 解析 hwsg 目标(与 runbook targets 相同约定)
|
||||
resolved := runbook.ResolveTargetForModule(ModuleRef.DB, "hwsg")
|
||||
if !resolved.Found {
|
||||
return "", fmt.Errorf("未找到目标主机 hwsg")
|
||||
}
|
||||
envStr := ""
|
||||
for k, v := range env {
|
||||
envStr += fmt.Sprintf("%s=%q ", k, v)
|
||||
}
|
||||
args := []string{"-p", strconv.Itoa(resolved.Port), resolved.User + "@" + resolved.Host, envStr + script}
|
||||
cmd := exec.Command("ssh", args...)
|
||||
out, err := cmd.Output()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// 以下为格式化函数(ResultFormatter 用)
|
||||
|
||||
type dnsRecord struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Proxied bool `json:"proxied"`
|
||||
TTL int `json:"ttl"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// FormatDNSList 把 cf_dns_list 的 stdout(CF API 原始 JSON)格式化为可读消息
|
||||
func FormatDNSList(out string) (string, bool) {
|
||||
var data struct {
|
||||
Success bool `json:"success"`
|
||||
Result []dnsRecord
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !data.Success {
|
||||
return "", false
|
||||
}
|
||||
recs := data.Result
|
||||
// 排序:类型 -> 名称
|
||||
sort.Slice(recs, func(i, j int) bool {
|
||||
if recs[i].Type != recs[j].Type {
|
||||
return recs[i].Type < recs[j].Type
|
||||
}
|
||||
return recs[i].Name < recs[j].Name
|
||||
})
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("📋 DNS 记录(共 %d 条):\n", len(recs)))
|
||||
for _, r := range recs {
|
||||
proxyMark := ""
|
||||
if r.Proxied {
|
||||
proxyMark = " 🟠"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("• %s %s → %s%s\n", r.Type, r.Name, r.Content, proxyMark))
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n"), true
|
||||
}
|
||||
|
||||
// FormatZones 把 cf_zones 的 stdout 格式化为可读消息
|
||||
func FormatZones(out string) (string, bool) {
|
||||
var data struct {
|
||||
Success bool `json:"success"`
|
||||
Zones []struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
} `json:"zones"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !data.Success {
|
||||
return "", false
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🌐 Zones(共 %d 个):\n", len(data.Zones)))
|
||||
for _, z := range data.Zones {
|
||||
sb.WriteString(fmt.Sprintf("• %s\n %s\n", z.Name, z.ID))
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n"), true
|
||||
}
|
||||
|
||||
// FormatWorkers 把 cf_workers_list 的 stdout 格式化为可读消息
|
||||
func FormatWorkers(out string) (string, bool) {
|
||||
var data struct {
|
||||
Workers []string `json:"workers"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if data.Workers == nil {
|
||||
return "", false
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("⚙️ Workers(共 %d 个):\n", len(data.Workers)))
|
||||
for _, w := range data.Workers {
|
||||
sb.WriteString("• " + w + "\n")
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n"), true
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type Module struct {
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, exec *runbook.Executor) *Module {
|
||||
ModuleRef.DB = db
|
||||
return &Module{db: db, exec: exec, runner: coremodule.NewRunner(db, exec)}
|
||||
}
|
||||
|
||||
@@ -34,6 +35,10 @@ func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error
|
||||
if out == "dry-run" {
|
||||
return ecode.Tag("OK", coremodule.FormatDryRunMessage(sp.Template)), nil
|
||||
}
|
||||
// 查询类命令:结果已由 ResultFormatter 格式化;写操作回退到回执消息
|
||||
if out != "" && out != "dry-run" && len(out) > 2 {
|
||||
return ecode.Tag("OK", out), nil
|
||||
}
|
||||
return ecode.Tag("OK", coremodule.FormatSuccessMessage(sp.Template, jobID)), nil
|
||||
}
|
||||
return ecode.Tag(ecode.ErrStepFailed, "CF 模块已接入,当前支持:/cf status, /cf help"), nil
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cpa
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
coremodule "ops-assistant/internal/core/module"
|
||||
"ops-assistant/internal/core/runbook"
|
||||
)
|
||||
|
||||
func commandSpecs() []coremodule.CommandSpec {
|
||||
return []coremodule.CommandSpec{
|
||||
{
|
||||
Prefixes: []string{"/cpa status"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cpa_status",
|
||||
Gate: coremodule.Gate{AllowDryRun: true},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cpa status(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cpa status 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cpa status 执行失败: ",
|
||||
},
|
||||
{
|
||||
Prefixes: []string{"/cpa usage backup"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cpa_usage_backup",
|
||||
Gate: coremodule.Gate{AllowDryRun: true},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cpa usage backup(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cpa usage backup 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cpa usage backup 执行失败: ",
|
||||
},
|
||||
{
|
||||
Prefixes: []string{"/cpa usage restore "},
|
||||
ErrHint: "--confirm YES_RESTORE",
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cpa_usage_restore",
|
||||
Gate: coremodule.Gate{NeedFlag: "allow_ops_restore", RequireConfirm: true, ExpectedToken: "YES_RESTORE", AllowDryRun: true},
|
||||
InputsFn: func(_ string, parts []string) (map[string]string, error) {
|
||||
if len(parts) < 4 {
|
||||
return nil, fmt.Errorf("❌ 用法:/cpa usage restore <backup_id>")
|
||||
}
|
||||
backupID := strings.TrimSpace(parts[3])
|
||||
if backupID == "" {
|
||||
return nil, fmt.Errorf("❌ backup_id 不能为空")
|
||||
}
|
||||
return map[string]string{"backup_id": backupID}, nil
|
||||
},
|
||||
MetaFn: func(userID int64, confirmToken string, inputs map[string]string) runbook.RunMeta {
|
||||
meta := runbook.NewMeta()
|
||||
meta.Target = "hwsg"
|
||||
meta.RiskLevel = "high"
|
||||
meta.ConfirmHash = hashConfirmToken(confirmToken)
|
||||
return meta
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 restore(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cpa usage restore 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cpa usage restore 执行失败: ",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package cpa
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func hashConfirmToken(token string) string {
|
||||
t := strings.TrimSpace(token)
|
||||
if t == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(t))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package cpa
|
||||
|
||||
import (
|
||||
"ops-assistant/internal/core/ecode"
|
||||
)
|
||||
|
||||
func formatErr(code, msg string) string {
|
||||
return ecode.Tag(code, msg)
|
||||
}
|
||||
|
||||
func formatOK(msg string) string {
|
||||
return ecode.Tag("OK", msg)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cpa
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ops-assistant/internal/core/command"
|
||||
"ops-assistant/internal/core/ecode"
|
||||
coremodule "ops-assistant/internal/core/module"
|
||||
"ops-assistant/internal/core/runbook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
db *gorm.DB
|
||||
exec *runbook.Executor
|
||||
runner *coremodule.Runner
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, exec *runbook.Executor) *Module {
|
||||
return &Module{db: db, exec: exec, runner: coremodule.NewRunner(db, exec)}
|
||||
}
|
||||
|
||||
func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) {
|
||||
text := strings.TrimSpace(cmd.Raw)
|
||||
if text == "/cpa" || strings.HasPrefix(text, "/cpa help") {
|
||||
return "CPA 模块\n- /cpa status\n- /cpa usage backup\n- /cpa usage restore <backup_id> [--confirm YES_RESTORE] [--dry-run]", nil
|
||||
}
|
||||
specs := commandSpecs()
|
||||
if sp, ok := coremodule.MatchCommand(text, specs); ok {
|
||||
jobID, out, err := coremodule.ExecTemplate(m.runner, userID, cmd.Raw, sp.Template)
|
||||
if err != nil {
|
||||
return formatErr(ecode.ErrStepFailed, coremodule.FormatExecError(sp, err)), nil
|
||||
}
|
||||
if out == "dry-run" {
|
||||
return formatOK(coremodule.FormatDryRunMessage(sp.Template)), nil
|
||||
}
|
||||
return formatOK(coremodule.FormatSuccessMessage(sp.Template, jobID)), nil
|
||||
}
|
||||
return "❓ 暂不支持该 CPA 命令。当前支持:/cpa status, /cpa usage backup, /cpa usage restore <backup_id>", nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
coremodule "ops-assistant/internal/core/module"
|
||||
)
|
||||
|
||||
func commandSpecs() []coremodule.CommandSpec {
|
||||
return []coremodule.CommandSpec{
|
||||
{
|
||||
Prefixes: []string{"/mail status"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "mail_status",
|
||||
Gate: coremodule.Gate{
|
||||
NeedFlag: "enable_module_mail",
|
||||
AllowDryRun: true,
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /mail status(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /mail status 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/mail status 执行失败: ",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ops-assistant/internal/core/command"
|
||||
"ops-assistant/internal/core/ecode"
|
||||
coremodule "ops-assistant/internal/core/module"
|
||||
"ops-assistant/internal/core/runbook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
db *gorm.DB
|
||||
exec *runbook.Executor
|
||||
runner *coremodule.Runner
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, exec *runbook.Executor) *Module {
|
||||
return &Module{db: db, exec: exec, runner: coremodule.NewRunner(db, exec)}
|
||||
}
|
||||
|
||||
func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) {
|
||||
text := strings.TrimSpace(cmd.Raw)
|
||||
if text == "/mail" || strings.HasPrefix(text, "/mail help") {
|
||||
return "Mail 模块\n- /mail status [--dry-run]", nil
|
||||
}
|
||||
specs := commandSpecs()
|
||||
if sp, ok := coremodule.MatchCommand(text, specs); ok {
|
||||
jobID, out, err := coremodule.ExecTemplate(m.runner, userID, cmd.Raw, sp.Template)
|
||||
if err != nil {
|
||||
return ecode.Tag(ecode.ErrStepFailed, coremodule.FormatExecError(sp, err)), nil
|
||||
}
|
||||
if out == "dry-run" {
|
||||
return ecode.Tag("OK", coremodule.FormatDryRunMessage(sp.Template)), nil
|
||||
}
|
||||
return ecode.Tag("OK", coremodule.FormatSuccessMessage(sp.Template, jobID)), nil
|
||||
}
|
||||
return ecode.Tag(ecode.ErrStepFailed, "Mail 模块已接入,当前支持:/mail status, /mail help"), nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"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
|
||||
Token string
|
||||
}
|
||||
|
||||
type subItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Account string `json:"account"`
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
DueDate string `json:"due_date"`
|
||||
Cycle string `json:"cycle"`
|
||||
CycleUnit string `json:"cycle_unit"`
|
||||
CycleValue int `json:"cycle_value"`
|
||||
Status string `json:"status"`
|
||||
UserID string `json:"user_id"`
|
||||
Channel string `json:"channel"`
|
||||
Remind []int `json:"remind_days"`
|
||||
}
|
||||
|
||||
type listResp struct {
|
||||
Success bool `json:"success"`
|
||||
Data []subItem `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 == "/sub" || strings.HasPrefix(text, "/sub help") {
|
||||
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)取消新增流程", nil
|
||||
}
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) < 2 {
|
||||
return "❌ 用法:/sub add | /sub list | /sub del <id>", nil
|
||||
}
|
||||
switch parts[1] {
|
||||
case "add":
|
||||
return "🧩 进入订阅新增流程:名称→分类→账号(可选)→金额→币种→到期日→周期单位→周期数量", nil
|
||||
case "list":
|
||||
return m.list(userID)
|
||||
case "report":
|
||||
return m.report(userID, parts[2:])
|
||||
case "del", "delete":
|
||||
if len(parts) < 3 {
|
||||
return "❌ 用法:/sub del <id>", nil
|
||||
}
|
||||
return m.del(userID, strings.TrimSpace(parts[2]))
|
||||
default:
|
||||
return "❌ 用法:/sub add | /sub list | /sub del <id>", nil
|
||||
}
|
||||
}
|
||||
|
||||
func formatCycleText(unit string, value int, legacy string) string {
|
||||
u := strings.TrimSpace(unit)
|
||||
if u == "" {
|
||||
if strings.TrimSpace(legacy) != "" {
|
||||
return legacy
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
if value <= 0 {
|
||||
value = 1
|
||||
}
|
||||
return fmt.Sprintf("%s x%d", u, value)
|
||||
}
|
||||
|
||||
func (m *Module) list(userID int64) (string, error) {
|
||||
cfg, err := m.loadSettings()
|
||||
if err != nil {
|
||||
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
|
||||
}
|
||||
url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions?user_id=1&channel=qq"
|
||||
body, 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, body)), nil
|
||||
}
|
||||
var out listResp
|
||||
if err := json.Unmarshal([]byte(body), &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 "📭 订阅列表为空", nil
|
||||
}
|
||||
lines := make([]string, 0, len(out.Data)+2)
|
||||
lines = append(lines, fmt.Sprintf("✅ 订阅列表(%d)", len(out.Data)))
|
||||
limit := len(out.Data)
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
it := out.Data[i]
|
||||
cycleText := formatCycleText(it.CycleUnit, it.CycleValue, it.Cycle)
|
||||
line := fmt.Sprintf("- ID=%s | %s | 分类:%s | 到期:%s | 周期:%s | 金额:%.2f %s", it.ID, it.Name, it.Category, it.DueDate, cycleText, it.Amount, it.Currency)
|
||||
if strings.TrimSpace(it.Account) != "" {
|
||||
line += fmt.Sprintf(" | 账号:%s", it.Account)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
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) del(userID int64, id string) (string, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return "❌ id 不能为空", nil
|
||||
}
|
||||
cfg, err := m.loadSettings()
|
||||
if err != nil {
|
||||
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
|
||||
}
|
||||
url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions/" + id + "?user_id=1&channel=qq"
|
||||
body, 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, body)), nil
|
||||
}
|
||||
var out simpleResp
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil
|
||||
}
|
||||
if !out.Success {
|
||||
return ecode.Tag(ecode.ErrStepFailed, out.Error), nil
|
||||
}
|
||||
return fmt.Sprintf("✅ 订阅已删除:%s", id), nil
|
||||
}
|
||||
|
||||
func (m *Module) loadSettings() (*settings, error) {
|
||||
if m.db == nil {
|
||||
return nil, errors.New("db 未初始化")
|
||||
}
|
||||
keys := []string{"sub_api_base", "sub_api_token"}
|
||||
cfg := &settings{}
|
||||
for _, k := range keys {
|
||||
var s models.AppSetting
|
||||
val := ""
|
||||
if err := m.db.Where("key = ?", k).First(&s).Error; err == nil {
|
||||
val = strings.TrimSpace(s.Value)
|
||||
}
|
||||
switch k {
|
||||
case "sub_api_base":
|
||||
cfg.APIBase = strings.TrimSpace(val)
|
||||
case "sub_api_token":
|
||||
cfg.Token = val
|
||||
}
|
||||
}
|
||||
if cfg.APIBase == "" {
|
||||
return nil, errors.New("sub_api_base 未配置")
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
return nil, errors.New("sub_api_token 未配置")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (m *Module) doRequest(method, url string, body []byte, cfg *settings) (string, int, error) {
|
||||
req, err := http.NewRequest(method, url, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if cfg.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user