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
+18 -6
View File
@@ -7,9 +7,10 @@ import (
"strings"
"time"
xchart "xiaji-go/internal/chart"
"xiaji-go/internal/service"
"xiaji-go/models"
xchart "ops-assistant/internal/chart"
"ops-assistant/internal/core/ops"
"ops-assistant/internal/service"
"ops-assistant/models"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"gorm.io/gorm"
@@ -22,14 +23,15 @@ type TGBot struct {
api *tgbotapi.BotAPI
finance *service.FinanceService
db *gorm.DB
opsSvc *ops.Service
}
func NewTGBot(db *gorm.DB, token string, finance *service.FinanceService) (*TGBot, error) {
func NewTGBot(db *gorm.DB, token string, finance *service.FinanceService, opsSvc *ops.Service) (*TGBot, error) {
bot, err := tgbotapi.NewBotAPI(token)
if err != nil {
return nil, err
}
return &TGBot{api: bot, finance: finance, db: db}, nil
return &TGBot{api: bot, finance: finance, db: db, opsSvc: opsSvc}, nil
}
func (b *TGBot) Start(ctx context.Context) {
@@ -79,11 +81,21 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
text := msg.Text
chatID := msg.Chat.ID
if b.opsSvc != nil {
if handled, out := b.opsSvc.Handle(DefaultUserID, text); handled {
m := tgbotapi.NewMessage(chatID, out)
if _, err := b.api.Send(m); err != nil {
log.Printf("发送OPS消息失败 chat=%d: %v", chatID, err)
}
return
}
}
var reply string
switch {
case text == "/start":
reply = "🦞 欢迎使用虾记记账!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\n/today - 今日汇总\n/chart - 本月图表\n/help - 帮助"
reply = "🛠️ 欢迎使用 Ops-Assistant!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\n/today - 今日汇总\n/chart - 本月图表\n/help - 帮助"
case text == "/help":
reply = "📖 使用说明:\n\n直接发送带金额的文本即可自动记账。\n系统会自动识别金额和消费分类。\n\n支持格式:\n• 午饭 25元\n• ¥30 打车\n• 买水果15块\n\n命令:\n/list - 最近10条记录\n/today - 今日汇总\n/chart - 本月消费图表\n/week - 近7天每日趋势\n/start - 欢迎信息"
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"math"
"xiaji-go/internal/service"
"ops-assistant/internal/service"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
+24
View File
@@ -0,0 +1,24 @@
package ai
type Mode string
const (
ModeOff Mode = "off"
ModeSuggest Mode = "suggest"
ModeExplain Mode = "explain"
)
type Advisor interface {
Suggest(userInput string) (string, error)
Explain(result string) (string, error)
}
type NoopAdvisor struct{}
func (NoopAdvisor) Suggest(userInput string) (string, error) {
return "", nil
}
func (NoopAdvisor) Explain(result string) (string, error) {
return "", nil
}
+103
View File
@@ -0,0 +1,103 @@
package ai
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
type Client struct {
BaseURL string
APIKey string
Model string
Timeout time.Duration
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Temperature float64 `json:"temperature"`
}
type chatResponse struct {
Choices []struct {
Message chatMessage `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func (c *Client) Suggest(userInput string) (string, error) {
return c.chat(userInput)
}
func (c *Client) Explain(result string) (string, error) {
return "", nil
}
func commandGuide() string {
b, err := os.ReadFile("docs/ai_command_guide.md")
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func (c *Client) chat(userInput string) (string, error) {
if strings.TrimSpace(c.BaseURL) == "" || strings.TrimSpace(c.APIKey) == "" || strings.TrimSpace(c.Model) == "" {
return "", errors.New("ai config missing")
}
base := strings.TrimRight(c.BaseURL, "/")
url := base + "/chat/completions"
sys := "你是命令翻译器。把用户的自然语言转换成系统支持的标准命令。只输出一行命令,不要解释。若无法确定,输出 FAIL。\n\n可用命令知识库:\n" + commandGuide() + "\n\n规则:严格按命令格式输出。缺少关键参数时输出 FAIL。不要猜测 zone_id/record_id/backup_id。"
req := chatRequest{
Model: c.Model,
Messages: []chatMessage{
{Role: "system", Content: sys},
{Role: "user", Content: userInput},
},
Temperature: 0,
}
body, _ := json.Marshal(req)
client := &http.Client{Timeout: c.Timeout}
httpReq, _ := http.NewRequest("POST", url, bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := client.Do(httpReq)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if resp.StatusCode == 429 {
return "", fmt.Errorf("ai rate limited")
}
return "", fmt.Errorf("ai http %d", resp.StatusCode)
}
var out chatResponse
if err := json.Unmarshal(b, &out); err != nil {
return "", err
}
if out.Error != nil && out.Error.Message != "" {
return "", errors.New(out.Error.Message)
}
if len(out.Choices) == 0 {
return "", errors.New("empty ai response")
}
return strings.TrimSpace(out.Choices[0].Message.Content), nil
}
+40
View File
@@ -0,0 +1,40 @@
package ai
import (
"strconv"
"strings"
"time"
"ops-assistant/models"
"gorm.io/gorm"
)
func LoadClient(db *gorm.DB) *Client {
if db == nil {
return nil
}
get := func(key string) string {
var sset models.AppSetting
if err := db.Where("key = ?", key).First(&sset).Error; err == nil {
return strings.TrimSpace(sset.Value)
}
return ""
}
if strings.ToLower(get("ai_enabled")) != "true" {
return nil
}
baseURL := get("ai_base_url")
apiKey := get("ai_api_key")
model := get("ai_model")
to := 15
if v := get("ai_timeout_seconds"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
to = n
}
}
if baseURL == "" || apiKey == "" || model == "" {
return nil
}
return &Client{BaseURL: baseURL, APIKey: apiKey, Model: model, Timeout: time.Duration(to) * time.Second}
}
+67
View File
@@ -0,0 +1,67 @@
package command
import (
"fmt"
"strings"
)
type ParsedCommand struct {
Raw string
Name string
Args []string
Module string
}
func Parse(raw string) (*ParsedCommand, error) {
text := strings.TrimSpace(raw)
if text == "" || !strings.HasPrefix(text, "/") {
return nil, fmt.Errorf("not a command")
}
parts := strings.Fields(text)
if len(parts) == 0 {
return nil, fmt.Errorf("empty command")
}
cmd := &ParsedCommand{Raw: text, Name: parts[0]}
if len(parts) > 1 {
cmd.Args = parts[1:]
}
mod := strings.TrimPrefix(cmd.Name, "/")
if i := strings.Index(mod, "@"); i > 0 {
mod = mod[:i]
}
if mod != "" {
cmd.Module = mod
}
return cmd, nil
}
// ParseWithInputs: 支持 /cf dns list <zone_id> 这种输入参数写入 runbook inputs
func ParseWithInputs(raw string) (*ParsedCommand, map[string]string, error) {
cmd, err := Parse(raw)
if err != nil {
return nil, nil, err
}
inputs := map[string]string{}
if cmd.Module == "cf" {
// /cf dns list <zone_id>
if len(cmd.Args) >= 2 && cmd.Args[0] == "dns" && cmd.Args[1] == "list" && len(cmd.Args) >= 3 {
inputs["zone_id"] = cmd.Args[2]
}
// /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied]
if len(cmd.Args) >= 2 && cmd.Args[0] == "dns" && cmd.Args[1] == "update" && len(cmd.Args) >= 7 {
inputs["zone_id"] = cmd.Args[2]
inputs["record_id"] = cmd.Args[3]
inputs["type"] = cmd.Args[4]
inputs["name"] = cmd.Args[5]
inputs["content"] = cmd.Args[6]
if len(cmd.Args) >= 8 {
inputs["ttl"] = cmd.Args[7]
}
if len(cmd.Args) >= 9 {
inputs["proxied"] = cmd.Args[8]
}
}
}
return cmd, inputs, nil
}
+17
View File
@@ -0,0 +1,17 @@
package ecode
const (
ErrPermissionDenied = "ERR_PERMISSION_DENIED"
ErrConfirmRequired = "ERR_CONFIRM_REQUIRED"
ErrFeatureDisabled = "ERR_FEATURE_DISABLED"
ErrStepFailed = "ERR_STEP_FAILED"
ErrJobCancelled = "ERR_JOB_CANCELLED"
ErrStepTimeout = "ERR_STEP_TIMEOUT"
)
func Tag(code, msg string) string {
if code == "" {
return msg
}
return "[" + code + "] " + msg
}
+19
View File
@@ -0,0 +1,19 @@
package module
import "ops-assistant/internal/core/runbook"
type Gate struct {
NeedFlag string
RequireConfirm bool
ExpectedToken string
AllowDryRun bool
}
type Request struct {
RunbookName string
Inputs map[string]string
Meta runbook.RunMeta
Gate Gate
DryRun bool
ConfirmToken string
}
+48
View File
@@ -0,0 +1,48 @@
package module
import (
"fmt"
"strings"
"gorm.io/gorm"
"ops-assistant/internal/core/ecode"
"ops-assistant/internal/core/policy"
"ops-assistant/internal/core/runbook"
)
type Runner struct {
db *gorm.DB
exec *runbook.Executor
}
func NewRunner(db *gorm.DB, exec *runbook.Executor) *Runner {
return &Runner{db: db, exec: exec}
}
func (r *Runner) Run(commandText string, operator int64, req Request) (uint, string, error) {
if strings.TrimSpace(req.RunbookName) == "" {
return 0, "", fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "runbook 不能为空"))
}
if req.DryRun {
if !req.Gate.AllowDryRun {
return 0, "", fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "当前命令不允许 dry-run"))
}
return 0, "dry-run", nil
}
if err := policy.CheckGate(r.db, policy.GateRequest{
NeedFlag: req.Gate.NeedFlag,
RequireConfirm: req.Gate.RequireConfirm,
ConfirmToken: req.ConfirmToken,
ExpectedToken: req.Gate.ExpectedToken,
AllowDryRun: req.Gate.AllowDryRun,
DryRun: req.DryRun,
}); err != nil {
code := ecode.ErrFeatureDisabled
if strings.Contains(err.Error(), "confirm") || strings.Contains(err.Error(), "确认") {
code = ecode.ErrConfirmRequired
}
return 0, "", fmt.Errorf(ecode.Tag(code, err.Error()))
}
return r.exec.RunWithInputsAndMeta(commandText, req.RunbookName, operator, req.Inputs, req.Meta)
}
+26
View File
@@ -0,0 +1,26 @@
package module
import (
"fmt"
"strings"
"gorm.io/gorm"
"ops-assistant/internal/core/policy"
)
func switchFlag(module string) string {
module = strings.TrimSpace(strings.ToLower(module))
if module == "" {
return ""
}
return fmt.Sprintf("enable_module_%s", module)
}
func IsEnabled(db *gorm.DB, module string) bool {
k := switchFlag(module)
if k == "" {
return false
}
return policy.FlagEnabled(db, k)
}
+108
View File
@@ -0,0 +1,108 @@
package module
import (
"fmt"
"strings"
"time"
"ops-assistant/internal/core/policy"
"ops-assistant/internal/core/runbook"
)
type CommandTemplate struct {
RunbookName string
Gate Gate
InputsFn func(text string, parts []string) (map[string]string, error)
MetaFn func(userID int64, confirmToken string, inputs map[string]string) runbook.RunMeta
DryRunMsg string
SuccessMsg func(jobID uint) string
// ResultFormatter 可选:查询类命令用最终 stdout 格式化回传内容。
// 返回 (消息, true) 表示使用格式化结果;返回 (anything, false) 回退到回执式消息。
ResultFormatter func(out string) (string, bool)
}
type CommandSpec struct {
Prefixes []string
Template CommandTemplate
ErrPrefix string
ErrHint string
}
func ExecTemplate(runner *Runner, userID int64, raw string, tpl CommandTemplate) (uint, string, error) {
dryRun, confirmToken := policy.ParseCommonFlags(raw)
parts := strings.Fields(strings.TrimSpace(raw))
inputs := map[string]string{}
if tpl.InputsFn != nil {
out, err := tpl.InputsFn(raw, parts)
if err != nil {
return 0, "", err
}
inputs = out
}
meta := runbook.NewMeta()
if tpl.MetaFn != nil {
meta = tpl.MetaFn(userID, confirmToken, inputs)
}
if meta.RequestID == "" {
meta.RequestID = fmt.Sprintf("ops-u%d-%d", userID, time.Now().Unix())
}
req := Request{
RunbookName: tpl.RunbookName,
Inputs: inputs,
Meta: meta,
Gate: tpl.Gate,
DryRun: dryRun,
ConfirmToken: confirmToken,
}
jobID, out, err := runner.Run(raw, userID, req)
if err != nil {
return jobID, out, err
}
if tpl.ResultFormatter != nil && out != "" && out != "dry-run" {
if msg, ok := tpl.ResultFormatter(out); ok {
return jobID, msg, nil
}
}
return jobID, out, err
}
func FormatDryRunMessage(tpl CommandTemplate) string {
if tpl.DryRunMsg != "" {
return tpl.DryRunMsg
}
return fmt.Sprintf("🧪 dry-run: 将执行 %s(未实际执行)", tpl.RunbookName)
}
func FormatSuccessMessage(tpl CommandTemplate, jobID uint) string {
if tpl.SuccessMsg != nil {
return tpl.SuccessMsg(jobID)
}
return fmt.Sprintf("✅ %s 已执行,job=%d", tpl.RunbookName, jobID)
}
func MatchAnyPrefix(text string, prefixes []string) bool {
text = strings.TrimSpace(text)
for _, p := range prefixes {
if strings.HasPrefix(text, p) {
return true
}
}
return false
}
func MatchCommand(text string, specs []CommandSpec) (CommandSpec, bool) {
for _, sp := range specs {
if MatchAnyPrefix(text, sp.Prefixes) {
return sp, true
}
}
return CommandSpec{}, false
}
func FormatExecError(sp CommandSpec, err error) string {
msg := sp.ErrPrefix + err.Error()
if sp.ErrHint != "" {
msg += "(示例:" + sp.ErrHint + ")"
}
return msg
}
+32
View File
@@ -0,0 +1,32 @@
package ops
import (
"path/filepath"
"ops-assistant/internal/core/registry"
"ops-assistant/internal/core/runbook"
"ops-assistant/internal/module/cf"
"ops-assistant/internal/module/cfdav"
"ops-assistant/internal/module/cpa"
"ops-assistant/internal/module/mail"
"ops-assistant/internal/module/sub"
"gorm.io/gorm"
)
func BuildDefault(db *gorm.DB, dbPath, baseDir string) *Service {
r := registry.New()
exec := runbook.NewExecutor(db, filepath.Join(baseDir, "runbooks"))
cpaModule := cpa.New(db, exec)
cfModule := cf.New(db, exec)
mailModule := mail.New(db, exec)
cfdavModule := cfdav.New(db)
subModule := sub.New(db)
r.RegisterModule("cpa", cpaModule.Handle)
r.RegisterModule("cf", cfModule.Handle)
r.RegisterModule("mail", mailModule.Handle)
r.RegisterModule("cfdav", cfdavModule.Handle)
r.RegisterModule("sub", subModule.Handle)
return NewService(dbPath, baseDir, r)
}
+20
View File
@@ -0,0 +1,20 @@
package ops
import (
"path/filepath"
"ops-assistant/internal/core/runbook"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// RunOnce executes a runbook directly without bot/channel.
func RunOnce(dbPath, baseDir, commandText, runbookName string, operator int64, inputs map[string]string) (uint, string, error) {
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return 0, "", err
}
exec := runbook.NewExecutor(db, filepath.Join(baseDir, "runbooks"))
return exec.RunWithInputsAndMeta(commandText, runbookName, operator, inputs, runbook.NewMeta())
}
+7 -15
View File
@@ -58,22 +58,14 @@ func (s *Service) Handle(userID int64, text string) (bool, string) {
func (s *Service) helpText() string {
lines := []string{
"🛠️ OPS 交互命令:",
"🛠️ OPS 帮助(模块入口):",
"- /ops modules (查看模块启用状态)",
"- /cpa help",
"- /cpa status",
"- /cpa usage backup",
"- /cpa usage restore <backup_id> [--confirm YES_RESTORE] [--dry-run]",
"- /cf status (需要 enable_module_cf)",
"- /cf zones (需要 enable_module_cf)",
"- /cf dns list <zone_id> (需要 enable_module_cf)",
"- /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied:true|false] (需要 enable_module_cf)",
"- /cf dnsadd <name> <content> [on|off] [type] (需要 enable_module_cf)",
"- /cf dnsset <record_id> <content> [true] (需要 enable_module_cf)",
"- /cf dnsdel <record_id> YES (需要 enable_module_cf)",
"- /cf dnsproxy <record_id|name> on|off (需要 enable_module_cf)",
"- /cf workers list (需要 enable_module_cf)",
"- /mail status (需要 enable_module_mail)",
"- /cpa (CPA 使用统计/备份/恢复)",
"- /cf (Cloudflare 管理)",
"- /mail (邮箱状态)",
"- /cfdav (CFDAV 用户管理)",
"- /sub (订阅管理)",
"提示:发送 /cpa /cf /mail /cfdav /sub 获取各模块完整指令清单。",
}
return strings.Join(lines, "\n")
}
+62
View File
@@ -0,0 +1,62 @@
package policy
import (
"errors"
"strings"
"gorm.io/gorm"
"ops-assistant/models"
)
type GateRequest struct {
NeedFlag string
RequireConfirm bool
ConfirmToken string
ExpectedToken string
AllowDryRun bool
DryRun bool
}
func ParseCommonFlags(text string) (dryRun bool, confirmToken string) {
parts := strings.Fields(strings.TrimSpace(text))
for i := 0; i < len(parts); i++ {
if parts[i] == "--dry-run" {
dryRun = true
}
if parts[i] == "--confirm" && i+1 < len(parts) {
confirmToken = strings.TrimSpace(parts[i+1])
i++
}
}
return
}
func FlagEnabled(db *gorm.DB, key string) bool {
if strings.TrimSpace(key) == "" {
return true
}
var ff models.FeatureFlag
if err := db.Where("key = ?", key).First(&ff).Error; err != nil {
return false
}
return ff.Enabled
}
func CheckGate(db *gorm.DB, req GateRequest) error {
if strings.TrimSpace(req.NeedFlag) != "" && !FlagEnabled(db, req.NeedFlag) {
return errors.New("feature flag 未启用: " + req.NeedFlag)
}
if req.RequireConfirm {
if strings.TrimSpace(req.ConfirmToken) == "" {
return errors.New("缺少 --confirm <token>")
}
if strings.TrimSpace(req.ExpectedToken) != "" && strings.TrimSpace(req.ConfirmToken) != strings.TrimSpace(req.ExpectedToken) {
return errors.New("确认 token 无效")
}
}
if req.DryRun && !req.AllowDryRun {
return errors.New("当前命令不允许 dry-run")
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
package ports
type UnifiedMessage struct {
Channel string
OperatorID int64
Text string
RawID string
}
type ChannelAdapter interface {
Name() string
Normalize(any) (*UnifiedMessage, error)
Reply(targetID string, text string) error
}
+6
View File
@@ -0,0 +1,6 @@
package ports
type Module interface {
Name() string
CommandPrefix() string
}
+47
View File
@@ -0,0 +1,47 @@
package registry
import (
"sort"
"ops-assistant/internal/core/command"
)
type Handler func(userID int64, cmd *command.ParsedCommand) (string, error)
type Registry struct {
handlers map[string]Handler
moduleHandlers map[string]Handler
}
func New() *Registry {
return &Registry{handlers: map[string]Handler{}, moduleHandlers: map[string]Handler{}}
}
func (r *Registry) Register(name string, h Handler) {
r.handlers[name] = h
}
func (r *Registry) RegisterModule(module string, h Handler) {
r.moduleHandlers[module] = h
}
func (r *Registry) Handle(userID int64, cmd *command.ParsedCommand) (string, bool, error) {
if h, ok := r.handlers[cmd.Name]; ok {
out, err := h(userID, cmd)
return out, true, err
}
if h, ok := r.moduleHandlers[cmd.Module]; ok {
out, err := h(userID, cmd)
return out, true, err
}
return "", false, nil
}
func (r *Registry) ListModules() []string {
mods := make([]string, 0, len(r.moduleHandlers))
for m := range r.moduleHandlers {
mods = append(mods, m)
}
sort.Strings(mods)
return mods
}
+363 -20
View File
@@ -1,10 +1,15 @@
package runbook
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
@@ -80,6 +85,7 @@ func (e *Executor) RunWithInputsAndMeta(commandText, runbookName string, operato
outputs := map[string]string{}
ctx := map[string]string{}
dataOut := "ok"
jobCtx, jobCancel := context.WithCancel(context.Background())
registerJobCancel(job.ID, jobCancel)
@@ -174,10 +180,13 @@ func (e *Executor) RunWithInputsAndMeta(commandText, runbookName string, operato
_ = e.db.Save(&step).Error
outputs[rendered.ID] = stdout
ctx["steps."+rendered.ID+".output"] = stdout
if strings.TrimSpace(stdout) != "" && rendered.Action != "assert.json" {
dataOut = stdout
}
}
e.finishJob(&job, "success", "ok")
return job.ID, "ok", nil
return job.ID, dataOut, nil
}
func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]string, timeout time.Duration) (int, string, string, error) {
@@ -230,6 +239,125 @@ func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]
}
return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err
case "http.get":
url := strings.TrimSpace(fmt.Sprintf("%v", st.With["url"]))
if url == "" {
return 1, "", "missing url", fmt.Errorf("missing url")
}
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 1, "", err.Error(), err
}
applyHTTPHeaders(req, st.With["headers"])
resp, err := http.DefaultClient.Do(req)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return 1, "", "http step timeout", fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "http step timeout"))
}
return 1, "", err.Error(), err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return 1, strings.TrimSpace(string(body)), resp.Status, fmt.Errorf("http status: %d", resp.StatusCode)
}
return 0, strings.TrimSpace(string(body)), "", nil
case "http.post":
url := strings.TrimSpace(fmt.Sprintf("%v", st.With["url"]))
if url == "" {
return 1, "", "missing url", fmt.Errorf("missing url")
}
bodyStr := ""
if b, ok := st.With["body"]; ok {
switch t := b.(type) {
case string:
bodyStr = t
default:
if jb, err := json.Marshal(t); err == nil {
bodyStr = string(jb)
}
}
}
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(bodyStr))
if err != nil {
return 1, "", err.Error(), err
}
hasCT := applyHTTPHeaders(req, st.With["headers"])
if bodyStr != "" && !hasCT {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return 1, "", "http step timeout", fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "http step timeout"))
}
return 1, "", err.Error(), err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return 1, strings.TrimSpace(string(respBody)), resp.Status, fmt.Errorf("http status: %d", resp.StatusCode)
}
return 0, strings.TrimSpace(string(respBody)), "", nil
case "file.upload":
source := strings.TrimSpace(fmt.Sprintf("%v", st.With["source"]))
stagingDir := strings.TrimSpace(fmt.Sprintf("%v", st.With["staging_dir"]))
filename := strings.TrimSpace(fmt.Sprintf("%v", st.With["filename"]))
if source == "" {
return 1, "", "missing source", fmt.Errorf("missing source")
}
if stagingDir == "" {
stagingDir = "/tmp/ops-staging"
}
if filename == "" {
filename = filepath.Base(source)
}
if err := os.MkdirAll(stagingDir, 0o755); err != nil {
return 1, "", err.Error(), err
}
dest := filepath.Join(stagingDir, filepath.Base(filename))
if err := copyFile(source, dest); err != nil {
return 1, "", err.Error(), err
}
return 0, dest, "", nil
case "file.extract":
archivePath := strings.TrimSpace(fmt.Sprintf("%v", st.With["archive"]))
toDir := strings.TrimSpace(fmt.Sprintf("%v", st.With["to_dir"]))
if archivePath == "" {
return 1, "", "missing archive", fmt.Errorf("missing archive")
}
if toDir == "" {
toDir = "/tmp/ops-staging/extracted"
}
if err := os.MkdirAll(toDir, 0o755); err != nil {
return 1, "", err.Error(), err
}
lower := strings.ToLower(archivePath)
switch {
case strings.HasSuffix(lower, ".zip"):
if err := extractZip(archivePath, toDir); err != nil {
return 1, "", err.Error(), err
}
case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"):
if err := extractTarGz(archivePath, toDir); err != nil {
return 1, "", err.Error(), err
}
case strings.HasSuffix(lower, ".tar"):
if err := extractTar(archivePath, toDir); err != nil {
return 1, "", err.Error(), err
}
default:
return 1, "", "unsupported archive format", fmt.Errorf("unsupported archive format: %s", archivePath)
}
return 0, toDir, "", nil
case "assert.json":
sourceStep := strings.TrimSpace(fmt.Sprintf("%v", st.With["source_step"]))
if sourceStep == "" {
@@ -245,15 +373,27 @@ func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]
return 1, "", "invalid json", err
}
rules := parseRequiredPaths(st.With["required_paths"])
if len(rules) == 0 {
return 1, "", "required_paths empty", fmt.Errorf("required_paths empty")
expectSuccess := parseExpectSuccess(st.With["expect_success"])
if expectSuccess {
v, ok := lookupPath(payload, "success")
if !ok {
return 1, "", "json path not found: success", fmt.Errorf("json path not found: success")
}
if !truthyValue(v) {
errSummary := summarizeJSONErrors(payload)
return 1, "", "api success=false: " + errSummary, fmt.Errorf("api success=false: %s", errSummary)
}
}
rules := parseRequiredPaths(st.With["required_paths"])
for _, p := range rules {
if _, ok := lookupPath(payload, p); !ok {
return 1, "", "json path not found: " + p, fmt.Errorf("json path not found: %s", p)
}
}
if len(rules) == 0 && !expectSuccess {
return 1, "", "required_paths empty", fmt.Errorf("required_paths empty")
}
return 0, "assert ok", "", nil
case "sleep":
@@ -292,27 +432,33 @@ func renderStep(st Step, ctx map[string]string) Step {
}
m := make(map[string]any, len(out.With))
for k, v := range out.With {
switch t := v.(type) {
case string:
m[k] = renderString(t, ctx)
case []any:
arr := make([]any, 0, len(t))
for _, it := range t {
if s, ok := it.(string); ok {
arr = append(arr, renderString(s, ctx))
} else {
arr = append(arr, it)
}
}
m[k] = arr
default:
m[k] = v
}
m[k] = renderAny(v, ctx)
}
out.With = m
return out
}
func renderAny(v any, ctx map[string]string) any {
switch t := v.(type) {
case string:
return renderString(t, ctx)
case []any:
arr := make([]any, 0, len(t))
for _, it := range t {
arr = append(arr, renderAny(it, ctx))
}
return arr
case map[string]any:
m := make(map[string]any, len(t))
for k, v := range t {
m[k] = renderAny(v, ctx)
}
return m
default:
return v
}
}
func renderString(s string, ctx map[string]string) string {
res := s
for k, v := range ctx {
@@ -321,6 +467,158 @@ func renderString(s string, ctx map[string]string) string {
return res
}
func applyHTTPHeaders(req *http.Request, raw any) bool {
hasContentType := false
m, ok := raw.(map[string]any)
if !ok {
return hasContentType
}
for k, v := range m {
key := strings.TrimSpace(k)
if key == "" {
continue
}
val := strings.TrimSpace(fmt.Sprintf("%v", v))
if val == "" {
continue
}
req.Header.Set(key, val)
if strings.EqualFold(key, "Content-Type") {
hasContentType = true
}
}
return hasContentType
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
fi, err := in.Stat()
if err != nil {
return err
}
if !fi.Mode().IsRegular() {
return fmt.Errorf("source is not regular file")
}
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}
func extractZip(src, dst string) error {
zr, err := zip.OpenReader(src)
if err != nil {
return err
}
defer zr.Close()
for _, f := range zr.File {
clean := filepath.Clean(f.Name)
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return fmt.Errorf("invalid zip entry path: %s", f.Name)
}
target := filepath.Join(dst, clean)
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
out, err := os.Create(target)
if err != nil {
rc.Close()
return err
}
_, cpErr := io.Copy(out, rc)
rc.Close()
if err := out.Close(); err != nil {
return err
}
if cpErr != nil {
return cpErr
}
}
return nil
}
func extractTar(src, dst string) error {
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
return untar(f, dst)
}
func extractTarGz(src, dst string) error {
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
return untar(gz, dst)
}
func untar(r io.Reader, dst string) error {
tr := tar.NewReader(r)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
clean := filepath.Clean(hdr.Name)
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return fmt.Errorf("invalid tar entry path: %s", hdr.Name)
}
target := filepath.Join(dst, clean)
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
out, err := os.Create(target)
if err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
}
}
return nil
}
func parseRequiredPaths(v any) []string {
res := []string{}
switch t := v.(type) {
@@ -363,6 +661,51 @@ func lookupPath(root any, path string) (any, bool) {
return cur, true
}
func parseExpectSuccess(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
return strings.EqualFold(strings.TrimSpace(t), "true")
default:
return false
}
}
func truthyValue(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
return strings.EqualFold(strings.TrimSpace(t), "true")
default:
return false
}
}
// summarizeJSONErrors 从 Cloudflare 风格的 {"success":false,"errors":[...]} 中提取可读错误摘要
func summarizeJSONErrors(payload any) string {
msgs := []string{}
if m, ok := payload.(map[string]any); ok {
if errs, ok := m["errors"].([]any); ok {
for _, e := range errs {
switch ev := e.(type) {
case map[string]any:
code := fmt.Sprintf("%v", ev["code"])
msg := fmt.Sprintf("%v", ev["message"])
msgs = append(msgs, code+" "+msg)
default:
msgs = append(msgs, fmt.Sprintf("%v", ev))
}
}
}
}
if len(msgs) == 0 {
return "unknown error"
}
return strings.Join(msgs, "; ")
}
func (e *Executor) finishJob(job *models.OpsJob, status, summary string) {
job.Status = status
job.Summary = summary
+21
View File
@@ -0,0 +1,21 @@
package runbook
import (
"sync"
)
var globalTargetLocks sync.Map
type targetLock struct {
mu sync.Mutex
}
func acquireTargetLock(target string) func() {
if target == "" {
return func() {}
}
v, _ := globalTargetLocks.LoadOrStore(target, &targetLock{})
lk := v.(*targetLock)
lk.mu.Lock()
return func() { lk.mu.Unlock() }
}
+23
View File
@@ -0,0 +1,23 @@
package runbook
import "time"
type RunMeta struct {
Target string
RiskLevel string
RequestID string
ConfirmHash string
StepTimeoutMs int
}
func NewMeta() RunMeta {
return RunMeta{RiskLevel: "low"}
}
func (m RunMeta) timeoutOrDefault() time.Duration {
ms := m.StepTimeoutMs
if ms <= 0 {
ms = 45000
}
return time.Duration(ms) * time.Millisecond
}
+20
View File
@@ -0,0 +1,20 @@
package runbook
import (
"ops-assistant/models"
"gorm.io/gorm"
)
func SeedDefaultTargets(db *gorm.DB) error {
defaults := []models.OpsTarget{
{Name: "hwsg", Host: "10.2.3.11", Port: 22, User: "root", Enabled: true},
{Name: "wjynl", Host: "66.235.105.208", Port: 22, User: "root", Enabled: true},
}
for _, t := range defaults {
if err := db.Where("name = ?", t.Name).FirstOrCreate(&t).Error; err != nil {
return err
}
}
return nil
}
+5
View File
@@ -35,3 +35,8 @@ func resolveTarget(db *gorm.DB, name string) ResolvedTarget {
}
return ResolvedTarget{Found: true, User: user, Host: host, Port: port}
}
// ResolveTargetForModule 供其他包(如 cf 模块格式化器)复用目标解析
func ResolveTargetForModule(db *gorm.DB, name string) ResolvedTarget {
return resolveTarget(db, name)
}
+24
View File
@@ -0,0 +1,24 @@
package runbook
import "gopkg.in/yaml.v3"
type Spec struct {
Version int `yaml:"version"`
Name string `yaml:"name"`
Steps []Step `yaml:"steps"`
}
type Step struct {
ID string `yaml:"id"`
Action string `yaml:"action"`
OnFail string `yaml:"on_fail"`
With map[string]any `yaml:"with"`
}
func Parse(data []byte) (*Spec, error) {
var s Spec
if err := yaml.Unmarshal(data, &s); err != nil {
return nil, err
}
return &s, nil
}
+13 -5
View File
@@ -9,9 +9,10 @@ import (
"strings"
"time"
"xiaji-go/internal/channel"
"xiaji-go/internal/service"
"xiaji-go/models"
"ops-assistant/internal/channel"
"ops-assistant/internal/core/ops"
"ops-assistant/internal/service"
"ops-assistant/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -23,16 +24,18 @@ const DefaultUserID int64 = 1
type Bot struct {
db *gorm.DB
finance *service.FinanceService
opsSvc *ops.Service
appID string
appSecret string
verificationToken string
encryptKey string
}
func NewBot(db *gorm.DB, finance *service.FinanceService, appID, appSecret, verificationToken, encryptKey string) *Bot {
func NewBot(db *gorm.DB, finance *service.FinanceService, opsSvc *ops.Service, appID, appSecret, verificationToken, encryptKey string) *Bot {
return &Bot{
db: db,
finance: finance,
opsSvc: opsSvc,
appID: appID,
appSecret: appSecret,
verificationToken: verificationToken,
@@ -93,9 +96,14 @@ func (b *Bot) handleWebhook(c *gin.Context) {
func (b *Bot) handleText(text string) string {
trim := strings.TrimSpace(text)
if b.opsSvc != nil {
if handled, out := b.opsSvc.Handle(DefaultUserID, trim); handled {
return out
}
}
switch trim {
case "帮助", "help", "/help", "菜单", "功能", "/start":
return "🦞 虾记记账\n\n直接发送消费描述即可记账:\n• 午饭 25元\n• 打车 ¥30\n\n📋 命令:记录/查看、今日/今天、统计"
return "🛠️ Ops-Assistant\n\n直接发送消费描述即可记账:\n• 午饭 25元\n• 打车 ¥30\n\n📋 命令:记录/查看、今日/今天、统计"
case "查看", "记录", "列表", "最近":
items, err := b.finance.GetTransactions(DefaultUserID, 10)
if err != nil {
+16 -1
View File
@@ -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 执行失败: ",
},
+224
View File
@@ -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
}
+5
View File
@@ -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
+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
}
+62
View File
@@ -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 执行失败: ",
},
}
}
+16
View File
@@ -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[:])
}
+13
View File
@@ -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)
}
+40
View File
@@ -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
}
+25
View File
@@ -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 执行失败: ",
},
}
}
+40
View File
@@ -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
}
+217
View File
@@ -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
}
+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
}
}
+11
View File
@@ -0,0 +1,11 @@
package qq
import (
"ops-assistant/internal/core/ai"
"gorm.io/gorm"
)
func buildAIClient(db *gorm.DB) *ai.Client {
return ai.LoadClient(db)
}
+324 -83
View File
@@ -4,11 +4,17 @@ import (
"context"
"fmt"
"log"
"path"
"regexp"
"strconv"
"strings"
"time"
"xiaji-go/internal/service"
"xiaji-go/models"
"ops-assistant/internal/core/ops"
"ops-assistant/internal/service"
"ops-assistant/internal/core/ai"
"ops-assistant/models"
"github.com/tencent-connect/botgo"
"github.com/tencent-connect/botgo/dto"
@@ -16,6 +22,7 @@ import (
"github.com/tencent-connect/botgo/event"
"github.com/tencent-connect/botgo/openapi"
"github.com/tencent-connect/botgo/token"
"github.com/tidwall/gjson"
"gorm.io/gorm"
)
@@ -23,20 +30,26 @@ import (
const DefaultUserID int64 = 1
type QQBot struct {
api openapi.OpenAPI
finance *service.FinanceService
credentials *token.QQBotCredentials
db *gorm.DB
api openapi.OpenAPI
finance *service.FinanceService
credentials *token.QQBotCredentials
db *gorm.DB
opsSvc *ops.Service
aiClient *ai.Client
aiAutoReload time.Time
}
func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService) *QQBot {
func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService, opsSvc *ops.Service) *QQBot {
return &QQBot{
db: db,
finance: finance,
opsSvc: opsSvc,
credentials: &token.QQBotCredentials{
AppID: appID,
AppSecret: secret,
},
aiClient: buildAIClient(db),
aiAutoReload: time.Now(),
}
}
@@ -97,87 +110,315 @@ func (b *QQBot) processAndReply(userID string, content string) string {
return ""
}
today := time.Now().Format("2006-01-02")
switch {
case isCommand(text, "帮助", "help", "/help", "/start", "菜单", "功能"):
return "🦞 虾记记账\n\n" +
"直接发送消费描述即可记账:\n" +
"• 午饭 25元\n" +
"• 打车 ¥30\n" +
"• 买咖啡15块\n\n" +
"📋 命令列表:\n" +
"• 记录/查看 — 最近10条\n" +
"• 今日/今天 — 今日汇总\n" +
"• 统计/报表 — 本月分类统计\n" +
"• 帮助 — 本帮助信息"
case isCommand(text, "查看", "记录", "列表", "list", "/list", "最近"):
items, err := b.finance.GetTransactions(DefaultUserID, 10)
if err != nil {
return "❌ 查询失败"
}
if len(items) == 0 {
return "📭 暂无记录"
}
var sb strings.Builder
sb.WriteString("📋 最近记录:\n\n")
for _, item := range items {
sb.WriteString(fmt.Sprintf("• [%s] %s:%.2f元\n", item.Date, item.Category, item.AmountYuan()))
}
return sb.String()
case isCommand(text, "今日", "今天", "today"):
items, err := b.finance.GetTransactionsByDate(DefaultUserID, today)
if err != nil {
return "❌ 查询失败"
}
if len(items) == 0 {
return fmt.Sprintf("📭 %s 暂无消费记录", today)
}
var sb strings.Builder
var total int64
sb.WriteString(fmt.Sprintf("📊 今日(%s)消费:\n\n", today))
for _, item := range items {
sb.WriteString(fmt.Sprintf("• %s:%.2f元\n", item.Category, item.AmountYuan()))
total += item.Amount
}
sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", len(items), float64(total)/100.0))
return sb.String()
case isCommand(text, "统计", "报表", "图表", "chart", "/chart"):
now := time.Now()
dateFrom := now.Format("2006-01") + "-01"
dateTo := now.Format("2006-01-02")
stats, err := b.finance.GetCategoryStats(DefaultUserID, dateFrom, dateTo)
if err != nil || len(stats) == 0 {
return fmt.Sprintf("📭 %d年%d月暂无消费数据", now.Year(), now.Month())
}
var sb strings.Builder
var grandTotal int64
var grandCount int
sb.WriteString(fmt.Sprintf("📊 %d年%d月消费统计:\n\n", now.Year(), now.Month()))
for _, s := range stats {
yuan := float64(s.Total) / 100.0
sb.WriteString(fmt.Sprintf("• %s:%.2f元(%d笔)\n", s.Category, yuan, s.Count))
grandTotal += s.Total
grandCount += s.Count
}
sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", grandCount, float64(grandTotal)/100.0))
return sb.String()
// sub 引导式新增流程(优先级高于模块命令)
if reply, handled := b.handleSubFlow(userID, text); handled {
return reply
}
amount, category, err := b.finance.AddTransaction(DefaultUserID, text)
// 先交给 opsSvc 处理命令
if b.opsSvc != nil {
if handled, out := b.opsSvc.Handle(DefaultUserID, text); handled {
if strings.HasPrefix(text, "/cpa ") || text == "/cpa" || strings.HasPrefix(text, "/cf ") || strings.HasPrefix(text, "/mail ") || strings.HasPrefix(text, "/cfdav ") || text == "/cfdav" {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, text)
}
}
return out
}
}
// 规则兜底:常见自然语映射到标准命令
if b.opsSvc != nil {
norm := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(text), " ", ""), " ", "")
if (strings.Contains(norm, "域名") || strings.Contains(norm, "站点")) && !strings.Contains(norm, "解析") && !strings.Contains(strings.ToLower(norm), "dns") {
cmd := "/cf zones"
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, cmd)
}
return out
}
}
switch norm {
case "cpa状态", "CPA状态", "cpaStatus", "cpastatus":
cmd := "/cpa status"
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, cmd)
}
return out
}
case "功能", "菜单", "帮助", "help", "Help", "HELP", "你能做什么", "你会什么":
cmd := "/help"
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
return out
}
case "cf状态", "cf配置", "cf配置状态", "cloudflare状态", "cloudflare配置":
cmd := "/cf status"
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, cmd)
}
return out
}
case "cf域名", "cf账号域名", "cfzones", "cf zones", "cloudflare域名", "cloudflare站点", "站点列表", "域名列表", "我的域名", "域名清单":
cmd := "/cf zones"
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, cmd)
}
return out
}
case "cf解析", "cf记录", "解析记录", "dns记录", "dns列表", "列解析", "列记录":
return "❌ 缺少 zone_id,请用:/cf dns list <zone_id>"
case "cfworkers", "cf workers", "workers列表", "workers list", "列workers":
cmd := "/cf workers list"
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, cmd)
}
return out
}
}
}
// 非命令:尝试 AI 翻译 -> 标准命令
if time.Since(b.aiAutoReload) > 3*time.Second {
b.aiClient = buildAIClient(b.db)
b.aiAutoReload = time.Now()
}
if b.aiClient != nil {
if cmd, err := b.aiClient.Suggest(text); err == nil {
cmd = strings.TrimSpace(cmd)
if cmd != "" && cmd != "FAIL" && strings.HasPrefix(cmd, "/") {
// 仅翻译成命令,交给模块处理(不回译文)
if b.opsSvc != nil {
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
if strings.HasPrefix(cmd, "/cpa ") || cmd == "/cpa" || strings.HasPrefix(cmd, "/cf ") || strings.HasPrefix(cmd, "/mail ") {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, cmd)
}
}
return out
}
}
return "❌ 无法识别,请使用标准命令"
}
return "❌ 无法识别,请使用标准命令"
} else {
if strings.Contains(err.Error(), "rate limited") {
return "⚠️ AI 服务繁忙,请稍后再试或使用标准命令"
}
return "❌ 无法识别,请使用标准命令"
}
}
// 仍然无法处理
return "❌ 无法识别,请使用标准命令"
}
func parseJobID(out string) uint {
re := regexp.MustCompile(`job=(\d+)`)
m := re.FindStringSubmatch(out)
if len(m) < 2 {
return 0
}
n, _ := strconv.Atoi(m[1])
if n <= 0 {
return 0
}
return uint(n)
}
func (b *QQBot) waitAndPushJobResult(userID string, jobID uint, cmd string) {
if b.db == nil {
return
}
var job models.OpsJob
for i := 0; i < 15; i++ { // 最多等 ~30s
time.Sleep(2 * time.Second)
if err := b.db.First(&job, jobID).Error; err != nil {
continue
}
if job.Status == "pending" || job.Status == "running" {
continue
}
break
}
if job.ID == 0 || job.Status == "pending" || job.Status == "running" {
return
}
msg := formatJobResult(b.db, jobID, cmd)
if strings.TrimSpace(msg) == "" {
return
}
_, err := b.api.PostC2CMessage(context.Background(), userID, dto.MessageToCreate{Content: msg})
if err != nil {
log.Printf("QQ记账失败 user=%s: %v", userID, err)
return "❌ 记账失败,请稍后重试"
}
if amount == 0 {
return "📍 没看到金额,这笔花了多少钱?"
log.Printf("QQ 推送任务结果失败: %v", err)
}
}
amountYuan := float64(amount) / 100.0
return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, amountYuan, text)
func formatJobResult(db *gorm.DB, jobID uint, cmd string) string {
var job models.OpsJob
if err := db.First(&job, jobID).Error; err != nil {
return ""
}
if job.Runbook == "cpa_status" {
return formatCPAStatusResult(db, jobID, job.Status)
}
if job.Runbook == "cpa_usage_backup" {
return formatCPAUsageBackupResult(db, jobID)
}
if job.Runbook == "cf_zones" {
return formatCFZonesResult(db, jobID, cmd)
}
if job.Runbook == "cf_workers_list" {
return formatCFWorkersResult(db, jobID, cmd)
}
return fmt.Sprintf("📦 %s 结果:%s (job=%d)", strings.TrimSpace(cmd), job.Status, jobID)
}
func formatCFZonesResult(db *gorm.DB, jobID uint, cmd string) string {
var steps []models.OpsJobStep
_ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error
var raw string
for _, st := range steps {
if st.StepID == "list_zones" {
raw = strings.TrimSpace(st.StdoutTail)
break
}
}
if raw == "" {
return fmt.Sprintf("📦 %s 结果:success (job=%d)", strings.TrimSpace(cmd), jobID)
}
arr := gjson.Get(raw, "zones").Array()
if len(arr) == 0 {
arr = gjson.Get(raw, "result").Array()
}
if len(arr) == 0 {
return fmt.Sprintf("📦 %s 结果:success (job=%d)\n(no zones)", strings.TrimSpace(cmd), jobID)
}
lines := make([]string, 0, len(arr)+2)
lines = append(lines, fmt.Sprintf("✅ %s 完成 (job=%d)", strings.TrimSpace(cmd), jobID))
limit := len(arr)
if limit > 50 {
limit = 50
}
for i := 0; i < limit; i++ {
name := arr[i].Get("name").String()
id := arr[i].Get("id").String()
if name == "" && id == "" {
continue
}
if id != "" {
lines = append(lines, fmt.Sprintf("- %s (%s)", name, id))
} else {
lines = append(lines, fmt.Sprintf("- %s", name))
}
}
if len(arr) > limit {
lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(arr), limit))
}
return strings.Join(lines, "\n")
}
func formatCFWorkersResult(db *gorm.DB, jobID uint, cmd string) string {
var steps []models.OpsJobStep
_ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error
var raw string
for _, st := range steps {
if st.StepID == "list_workers" {
raw = strings.TrimSpace(st.StdoutTail)
break
}
}
if raw == "" {
return fmt.Sprintf("📦 %s 结果:success (job=%d)", strings.TrimSpace(cmd), jobID)
}
arr := gjson.Get(raw, "workers").Array()
if len(arr) == 0 {
arr = gjson.Get(raw, "result").Array()
}
lines := make([]string, 0, len(arr)+2)
lines = append(lines, fmt.Sprintf("✅ %s 完成 (job=%d)", strings.TrimSpace(cmd), jobID))
if len(arr) == 0 {
lines = append(lines, "(no workers)")
return strings.Join(lines, "\n")
}
limit := len(arr)
if limit > 50 {
limit = 50
}
for i := 0; i < limit; i++ {
name := arr[i].Get("id").String()
if name == "" {
name = arr[i].Get("name").String()
}
if name == "" {
continue
}
lines = append(lines, fmt.Sprintf("- %s", name))
}
if len(arr) > limit {
lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(arr), limit))
}
return strings.Join(lines, "\n")
}
func formatCPAUsageBackupResult(db *gorm.DB, jobID uint) string {
var steps []models.OpsJobStep
_ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error
var raw string
for _, st := range steps {
if st.StepID == "export_and_package" {
raw = strings.TrimSpace(st.StdoutTail)
break
}
}
if raw == "" {
return fmt.Sprintf("✅ /cpa usage backup 执行成功(job=%d)", jobID)
}
backup := ""
for _, line := range strings.Split(raw, "\n") {
if strings.HasPrefix(line, "backup=") {
backup = strings.TrimSpace(strings.TrimPrefix(line, "backup="))
break
}
}
if backup == "" {
return fmt.Sprintf("✅ /cpa usage backup 执行成功(job=%d)", jobID)
}
file := path.Base(backup)
return fmt.Sprintf("✅ /cpa usage backup 执行成功(job=%d)\n📦 备份文件:%s\n📁 路径:%s", jobID, file, path.Dir(backup)+"/")
}
func formatCPAStatusResult(db *gorm.DB, jobID uint, status string) string {
var steps []models.OpsJobStep
_ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error
var svc, usage string
for _, st := range steps {
if st.StepID == "service_status" {
svc = strings.TrimSpace(st.StdoutTail)
}
if st.StepID == "usage_snapshot" {
usage = strings.TrimSpace(st.StdoutTail)
}
}
tr := gjson.Get(usage, "usage.total_requests").String()
tt := gjson.Get(usage, "usage.total_tokens").String()
if tr == "" {
tr = "-"
}
if tt == "" {
tt = "-"
}
return fmt.Sprintf("✅ /cpa status 完成 (job=%d)\nservice=%s\nrequests=%s\ntokens=%s", jobID, svc, tr, tt)
}
func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler {
+308
View File
@@ -0,0 +1,308 @@
package qq
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"ops-assistant/models"
"github.com/tencent-connect/botgo/dto"
)
type subRemindItem 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"`
CycleUnit string `json:"cycle_unit"`
CycleValue int `json:"cycle_value"`
Remind []int `json:"remind_days"`
}
type subListResp struct {
Success bool `json:"success"`
Data []subRemindItem `json:"data"`
Error string `json:"error"`
}
type subNotifySettings struct {
APIBase string
Token string
NotifyQQUserID string
NotifyGroupID string
NotifyWeComWebhook string
NotifyTime string
}
func (b *QQBot) StartSubScheduler(ctx context.Context, hour, minute int) {
if b.db == nil {
return
}
go func() {
b.trySendTestOnce(ctx)
for {
next := nextRunTime(hour, minute)
t := time.NewTimer(time.Until(next))
select {
case <-ctx.Done():
t.Stop()
return
case <-t.C:
b.runSubNotifyOnce(ctx)
}
}
}()
}
func (b *QQBot) trySendTestOnce(ctx context.Context) {
if b.db == nil {
log.Printf("[sub_notify] test skip: db nil")
return
}
var s models.AppSetting
if err := b.db.Where("key = ?", "sub_notify_test_once").First(&s).Error; err != nil {
log.Printf("[sub_notify] test skip: setting not found")
return
}
if strings.TrimSpace(s.Value) != "1" {
log.Printf("[sub_notify] test skip: flag=%s", strings.TrimSpace(s.Value))
return
}
cfg, err := b.loadSubNotifySettings()
if err != nil {
log.Printf("[sub_notify] test skip: load settings err=%v", err)
return
}
if strings.TrimSpace(cfg.NotifyWeComWebhook) == "" && strings.TrimSpace(cfg.NotifyQQUserID) == "" && strings.TrimSpace(cfg.NotifyGroupID) == "" {
log.Printf("[sub_notify] test skip: notify target empty")
return
}
for i := 0; i < 10; i++ {
if b.api != nil {
break
}
select {
case <-ctx.Done():
log.Printf("[sub_notify] test skip: ctx done")
return
case <-time.After(1 * time.Second):
}
}
if b.api == nil {
log.Printf("[sub_notify] test skip: api nil")
return
}
msg := fmt.Sprintf("✅ 订阅提醒测试 %s", time.Now().Format("01-02 15:04"))
if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" {
if err := sendWeCom(cfg.NotifyWeComWebhook, msg); err == nil {
s.Value = "0"
_ = b.db.Save(&s).Error
log.Printf("[sub_notify] test send ok wecom")
} else {
log.Printf("[sub_notify] test send failed wecom err=%v", err)
}
return
}
if strings.TrimSpace(cfg.NotifyGroupID) != "" {
if err := b.sendGroup(cfg.NotifyGroupID, msg); err == nil {
s.Value = "0"
_ = b.db.Save(&s).Error
log.Printf("[sub_notify] test send ok group=%s", cfg.NotifyGroupID)
} else {
log.Printf("[sub_notify] test send failed group=%s err=%v", cfg.NotifyGroupID, err)
}
return
}
if err := b.sendC2C(cfg.NotifyQQUserID, msg); err == nil {
s.Value = "0"
_ = b.db.Save(&s).Error
log.Printf("[sub_notify] test send ok user=%s", cfg.NotifyQQUserID)
} else {
log.Printf("[sub_notify] test send failed user=%s err=%v", cfg.NotifyQQUserID, err)
}
}
func nextRunTime(hour, minute int) time.Time {
now := time.Now()
loc := now.Location()
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc)
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
return next
}
func (b *QQBot) runSubNotifyOnce(ctx context.Context) {
cfg, err := b.loadSubNotifySettings()
if err != nil {
return
}
if strings.TrimSpace(cfg.NotifyWeComWebhook) == "" && strings.TrimSpace(cfg.NotifyQQUserID) == "" && strings.TrimSpace(cfg.NotifyGroupID) == "" {
return
}
url := fmt.Sprintf("%s/api/subscriptions?user_id=1&channel=qq", strings.TrimRight(cfg.APIBase, "/"))
body, status, err := doSubRequest("GET", url, nil, cfg.Token)
if err != nil || status >= 400 {
return
}
var out subListResp
if err := json.Unmarshal([]byte(body), &out); err != nil || !out.Success {
return
}
if len(out.Data) == 0 {
return
}
now := time.Now()
today := now.Format("2006-01-02")
for _, it := range out.Data {
if it.DueDate == "" {
continue
}
due, err := time.ParseInLocation("2006-01-02", it.DueDate, now.Location())
if err != nil {
continue
}
daysLeft := int(due.Sub(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())).Hours() / 24)
if !shouldRemind(it.Remind, daysLeft) {
continue
}
targetID := cfg.NotifyQQUserID
if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" {
targetID = "wecom"
} else if strings.TrimSpace(cfg.NotifyGroupID) != "" {
targetID = cfg.NotifyGroupID
}
if b.hasNotified("qqbot_official", targetID, it.ID, today, daysLeft) {
continue
}
msg := formatSubReminder(it, daysLeft)
if msg == "" {
continue
}
if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" {
_ = sendWeCom(cfg.NotifyWeComWebhook, msg)
} else if strings.TrimSpace(cfg.NotifyGroupID) != "" {
_ = b.sendGroup(cfg.NotifyGroupID, msg)
} else {
_ = b.sendC2C(cfg.NotifyQQUserID, msg)
}
b.markNotified("qqbot_official", targetID, it.ID, today, daysLeft)
}
}
func (b *QQBot) loadSubNotifySettings() (*subNotifySettings, error) {
if b.db == nil {
return nil, fmt.Errorf("db 未初始化")
}
get := func(key string) string {
var s models.AppSetting
if err := b.db.Where("key = ?", key).First(&s).Error; err == nil {
return strings.TrimSpace(s.Value)
}
return ""
}
cfg := &subNotifySettings{
APIBase: get("sub_api_base"),
Token: get("sub_api_token"),
NotifyQQUserID: get("sub_notify_qq_user_id"),
NotifyGroupID: get("sub_notify_group_id"),
NotifyWeComWebhook: get("sub_notify_wecom_webhook"),
NotifyTime: get("sub_notify_time"),
}
if cfg.APIBase == "" {
return nil, fmt.Errorf("sub_api_base 未配置")
}
if cfg.Token == "" {
return nil, fmt.Errorf("sub_api_token 未配置")
}
return cfg, nil
}
func shouldRemind(remind []int, daysLeft int) bool {
for _, d := range remind {
if d == daysLeft {
return true
}
}
return false
}
func (b *QQBot) hasNotified(platform, userID, subID, date string, daysLeft int) bool {
var row models.SubNotifyLog
if err := b.db.Where("platform=? AND external_user_id=? AND sub_id=? AND notify_date=? AND days_left=?", platform, userID, subID, date, daysLeft).First(&row).Error; err == nil {
return true
}
return false
}
func (b *QQBot) markNotified(platform, userID, subID, date string, daysLeft int) {
_ = b.db.Create(&models.SubNotifyLog{Platform: platform, ExternalUserID: userID, SubID: subID, NotifyDate: date, DaysLeft: daysLeft, CreatedAt: time.Now()}).Error
}
func formatSubReminder(it subRemindItem, daysLeft int) string {
prefix := "⏰"
if daysLeft < 0 {
prefix = "⚠️"
}
return fmt.Sprintf("%s 订阅提醒:%s | 分类:%s | 到期:%s | 剩余:%d天 | 金额:%.2f %s", prefix, it.Name, it.Category, it.DueDate, daysLeft, it.Amount, it.Currency)
}
func (b *QQBot) sendC2C(userID, msg string) error {
if b.api == nil {
log.Printf("[sub_notify] skip send: api nil")
return nil
}
_, err := b.api.PostC2CMessage(context.Background(), userID, dto.MessageToCreate{Content: msg})
if err != nil {
log.Printf("[sub_notify] send failed user=%s err=%v", userID, err)
return err
}
log.Printf("[sub_notify] send ok user=%s", userID)
return nil
}
func (b *QQBot) sendGroup(groupID, msg string) error {
if b.api == nil {
log.Printf("[sub_notify] skip send group: api nil")
return nil
}
_, err := b.api.PostGroupMessage(context.Background(), groupID, dto.MessageToCreate{Content: msg})
if err != nil {
log.Printf("[sub_notify] send failed group=%s err=%v", groupID, err)
return err
}
log.Printf("[sub_notify] send ok group=%s", groupID)
return nil
}
func sendWeCom(webhook, msg string) error {
payload := map[string]any{
"msgtype": "text",
"text": map[string]any{"content": msg},
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, webhook, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("wecom status %d", resp.StatusCode)
}
return nil
}
+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
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strconv"
"time"
"xiaji-go/models"
"ops-assistant/models"
"github.com/yanyiwu/gojieba"
"gorm.io/gorm"
+17
View File
@@ -0,0 +1,17 @@
package web
import "github.com/gin-gonic/gin"
type apiResp struct {
Code string `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
func respondOK(c *gin.Context, message string, data any) {
c.JSON(200, apiResp{Code: "OK", Message: message, Data: data})
}
func respondErr(c *gin.Context, status int, code, message string) {
c.JSON(status, apiResp{Code: code, Message: message})
}
+276 -3
View File
@@ -4,9 +4,11 @@ import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"path/filepath"
@@ -88,6 +90,18 @@ type cfSettingsReq struct {
APIToken string `json:"api_token"`
}
type cfdavSettingsReq struct {
APIBase string `json:"api_base"`
AdminUser string `json:"admin_user"`
AdminPass string `json:"admin_pass"`
}
type cfdavSettings struct {
APIBase string
AdminUser string
AdminPass string
}
type aiSettingsReq struct {
Enabled *bool `json:"enabled"`
BaseURL string `json:"base_url"`
@@ -352,6 +366,12 @@ func (s *WebServer) registerAPIV1Routes(auth *gin.RouterGroup) {
// Cloudflare settings
auth.GET("/api/v1/admin/cf/settings", s.handleCFSettingsGet)
auth.PATCH("/api/v1/admin/cf/settings", s.handleCFSettingsPatch)
// CFDAV settings
auth.GET("/api/v1/admin/cfdav/settings", s.handleCFDAVSettingsGet)
auth.PATCH("/api/v1/admin/cfdav/settings", s.handleCFDAVSettingsPatch)
auth.GET("/api/v1/admin/cfdav/users", s.handleCFDAVUsersList)
auth.POST("/api/v1/admin/cfdav/users", s.handleCFDAVUsersCreate)
auth.DELETE("/api/v1/admin/cfdav/users/:id", s.handleCFDAVUsersDelete)
// AI settings
auth.GET("/api/v1/admin/ai/settings", s.handleAISettingsGet)
auth.PATCH("/api/v1/admin/ai/settings", s.handleAISettingsPatch)
@@ -447,6 +467,8 @@ func (s *WebServer) RegisterRoutes(r *gin.Engine) {
auth.GET("/ops", s.handleOpsPage)
auth.GET("/cpa", s.handleCPASettingsPage)
auth.GET("/cf", s.handleCFSettingsPage)
auth.GET("/cfdav", s.handleCFDAVSettingsPage)
auth.GET("/modules", s.handleModulesPage)
// AI 配置页临时隐藏(保留后端接口)
s.registerAPIV1Routes(auth)
@@ -565,6 +587,24 @@ func (s *WebServer) handleCFSettingsPage(c *gin.Context) {
s.renderPage(c, "cf_settings.html", u, nil)
}
func (s *WebServer) handleCFDAVSettingsPage(c *gin.Context) {
u := currentUser(c)
if u == nil || !s.hasPermission(u, "settings.flags.read") {
c.Redirect(http.StatusFound, "/")
return
}
s.renderPage(c, "cfdav_settings.html", u, nil)
}
func (s *WebServer) handleModulesPage(c *gin.Context) {
u := currentUser(c)
if u == nil || !s.hasPermission(u, "settings.flags.read") {
c.Redirect(http.StatusFound, "/")
return
}
s.renderPage(c, "modules.html", u, nil)
}
func (s *WebServer) handleAISettingsPage(c *gin.Context) {
u := currentUser(c)
if u == nil || !s.hasPermission(u, "settings.flags.read") {
@@ -645,7 +685,7 @@ func (s *WebServer) handleCPASettingsPatch(c *gin.Context) {
}
_ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementBase != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error
}
s.writeAuditResult(u.UserID, "cpa.settings.update", "settings", "cpa_management", "", "", "", "success")
respondOK(c, "success", gin.H{"keys": []string{"cpa_management_token", "cpa_management_base"}})
}
@@ -720,6 +760,233 @@ func (s *WebServer) handleCFSettingsPatch(c *gin.Context) {
respondOK(c, "success", gin.H{"ok": true})
}
func (s *WebServer) handleCFDAVSettingsGet(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
return
}
keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"}
out := map[string]string{}
for _, k := range keys {
var sset models.AppSetting
if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil {
out[k] = sset.Value
} else {
out[k] = ""
}
}
respondOK(c, "ok", gin.H{"settings": out})
}
func (s *WebServer) handleCFDAVSettingsPatch(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
return
}
var req cfdavSettingsReq
if err := c.ShouldBindJSON(&req); err != nil {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
return
}
if req.APIBase != "" {
key := "cfdav_api_base"
var sset models.AppSetting
if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
sset = models.AppSetting{Key: key, Value: req.APIBase, UpdatedBy: u.UserID}
_ = s.db.Create(&sset).Error
} else {
sset.Value = req.APIBase
sset.UpdatedBy = u.UserID
_ = s.db.Save(&sset).Error
}
}
if req.AdminUser != "" {
key := "cfdav_admin_user"
var sset models.AppSetting
if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
sset = models.AppSetting{Key: key, Value: req.AdminUser, UpdatedBy: u.UserID}
_ = s.db.Create(&sset).Error
} else {
sset.Value = req.AdminUser
sset.UpdatedBy = u.UserID
_ = s.db.Save(&sset).Error
}
}
if req.AdminPass != "" {
key := "cfdav_admin_pass"
var sset models.AppSetting
if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
sset = models.AppSetting{Key: key, Value: req.AdminPass, UpdatedBy: u.UserID}
_ = s.db.Create(&sset).Error
} else {
sset.Value = req.AdminPass
sset.UpdatedBy = u.UserID
_ = s.db.Save(&sset).Error
}
}
respondOK(c, "success", gin.H{"ok": true})
}
func (s *WebServer) loadCFDAVSettings() (*cfdavSettings, error) {
keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"}
out := &cfdavSettings{}
for _, k := range keys {
var sset models.AppSetting
val := ""
if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil {
val = sset.Value
}
switch k {
case "cfdav_api_base":
out.APIBase = val
case "cfdav_admin_user":
out.AdminUser = val
case "cfdav_admin_pass":
out.AdminPass = val
}
}
if strings.TrimSpace(out.AdminUser) == "" || strings.TrimSpace(out.AdminPass) == "" {
return nil, fmt.Errorf("cfdav admin 账号未配置")
}
return out, nil
}
func (s *WebServer) doCFDAVRequest(method, url string, body []byte, settings *cfdavSettings) (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 := settings.AdminUser + ":" + settings.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
}
func (s *WebServer) handleCFDAVUsersList(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
return
}
settings, err := s.loadCFDAVSettings()
if err != nil {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
return
}
if strings.TrimSpace(settings.APIBase) == "" {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置")
return
}
url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users"
resp, status, err := s.doCFDAVRequest(http.MethodGet, url, nil, settings)
if err != nil {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error())
return
}
if status >= 400 {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp)
return
}
var out map[string]any
if err := json.Unmarshal([]byte(resp), &out); err != nil {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败")
return
}
respondOK(c, "ok", out)
}
func (s *WebServer) handleCFDAVUsersCreate(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
return
}
settings, err := s.loadCFDAVSettings()
if err != nil {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
return
}
if strings.TrimSpace(settings.APIBase) == "" {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置")
return
}
payload, err := io.ReadAll(c.Request.Body)
if err != nil {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "读取请求失败")
return
}
url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users"
resp, status, err := s.doCFDAVRequest(http.MethodPost, url, payload, settings)
if err != nil {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error())
return
}
if status >= 400 {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp)
return
}
var out map[string]any
if err := json.Unmarshal([]byte(resp), &out); err != nil {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败")
return
}
s.writeAuditResult(u.UserID, "cfdav.user.create", "cfdav_user", "*", "", "", "", "success")
respondOK(c, "ok", out)
}
func (s *WebServer) handleCFDAVUsersDelete(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
return
}
settings, err := s.loadCFDAVSettings()
if err != nil {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
return
}
if strings.TrimSpace(settings.APIBase) == "" {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置")
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "id required")
return
}
url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users/" + id
resp, status, err := s.doCFDAVRequest(http.MethodDelete, url, nil, settings)
if err != nil {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error())
return
}
if status >= 400 {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp)
return
}
var out map[string]any
if err := json.Unmarshal([]byte(resp), &out); err != nil {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败")
return
}
s.writeAuditResult(u.UserID, "cfdav.user.delete", "cfdav_user", id, "", "", "", "success")
respondOK(c, "ok", out)
}
func (s *WebServer) handleAISettingsGet(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
@@ -1716,6 +1983,8 @@ func (s *WebServer) handleModulesList(c *gin.Context) {
{Module: "cpa", DisplayName: "CPA 管理", FlagKey: "enable_module_cpa"},
{Module: "cf", DisplayName: "CF 管理", FlagKey: "enable_module_cf"},
{Module: "mail", DisplayName: "邮箱管理", FlagKey: "enable_module_mail"},
{Module: "cfdav", DisplayName: "cfdav 管理", FlagKey: "enable_module_cfdav"},
{Module: "sub", DisplayName: "订阅管理", FlagKey: "enable_module_sub"},
}
for i := range items {
var ff models.FeatureFlag
@@ -1740,6 +2009,10 @@ func (s *WebServer) handleModuleToggle(c *gin.Context) {
flagKey = "enable_module_cf"
case "mail":
flagKey = "enable_module_mail"
case "cfdav":
flagKey = "enable_module_cfdav"
case "sub":
flagKey = "enable_module_sub"
default:
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "unknown module")
return
@@ -1797,7 +2070,7 @@ func (s *WebServer) handleDashboardOverview(c *gin.Context) {
Module string `json:"module"`
Enabled bool `json:"enabled"`
}
mods := []moduleItem{{Module: "cpa"}, {Module: "cf"}, {Module: "mail"}}
mods := []moduleItem{{Module: "cpa"}, {Module: "cf"}, {Module: "mail"}, {Module: "cfdav"}, {Module: "sub"}}
for i := range mods {
flagKey := "enable_module_" + mods[i].Module
var ff models.FeatureFlag
@@ -1900,7 +2173,7 @@ func (s *WebServer) handleDashboardSummary(c *gin.Context) {
s.db.Model(&models.OpsJob{}).Where("status = ?", "failed").Count(&failed)
s.db.Model(&models.OpsJob{}).Where("status = ?", "success").Count(&success)
mods := map[string]bool{"cpa": false, "cf": false, "mail": false}
mods := map[string]bool{"cpa": false, "cf": false, "mail": false, "cfdav": false, "sub": false}
for k := range mods {
var ff models.FeatureFlag
if err := s.db.Where("key = ?", "enable_module_"+k).First(&ff).Error; err == nil {