init: ops-assistant codebase
This commit is contained in:
237
internal/bot/telegram.go
Normal file
237
internal/bot/telegram.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
||||
const DefaultUserID int64 = 1
|
||||
|
||||
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, opsSvc *ops.Service) (*TGBot, error) {
|
||||
bot, err := tgbotapi.NewBotAPI(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TGBot{api: bot, finance: finance, db: db, opsSvc: opsSvc}, nil
|
||||
}
|
||||
|
||||
func (b *TGBot) Start(ctx context.Context) {
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
updates := b.api.GetUpdatesChan(u)
|
||||
|
||||
log.Println("🚀 Telegram Bot 已启动")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("⏳ Telegram Bot 正在停止...")
|
||||
b.api.StopReceivingUpdates()
|
||||
return
|
||||
case update, ok := <-updates:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if update.Message == nil || update.Message.Text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
eventID := fmt.Sprintf("tg:%d", update.UpdateID)
|
||||
if b.isDuplicate(eventID) {
|
||||
continue
|
||||
}
|
||||
log.Printf("📩 inbound platform=telegram event=%s chat=%d user=%d text=%q", eventID, update.Message.Chat.ID, update.Message.From.ID, strings.TrimSpace(update.Message.Text))
|
||||
b.handleMessage(update.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *TGBot) isDuplicate(eventID string) bool {
|
||||
if b.db == nil || strings.TrimSpace(eventID) == "" {
|
||||
return false
|
||||
}
|
||||
var existed models.MessageDedup
|
||||
if err := b.db.Where("platform = ? AND event_id = ?", "telegram", eventID).First(&existed).Error; err == nil {
|
||||
return true
|
||||
}
|
||||
_ = b.db.Create(&models.MessageDedup{Platform: "telegram", EventID: eventID, ProcessedAt: time.Now()}).Error
|
||||
return false
|
||||
}
|
||||
|
||||
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 = "🛠️ 欢迎使用 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 - 欢迎信息"
|
||||
|
||||
case text == "/today":
|
||||
today := time.Now().Format("2006-01-02")
|
||||
items, err := b.finance.GetTransactionsByDate(DefaultUserID, today)
|
||||
if err != nil {
|
||||
reply = "❌ 查询失败"
|
||||
} else if len(items) == 0 {
|
||||
reply = fmt.Sprintf("📭 %s 暂无消费记录", today)
|
||||
} else {
|
||||
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))
|
||||
reply = sb.String()
|
||||
}
|
||||
|
||||
case text == "/chart":
|
||||
b.sendMonthlyChart(chatID)
|
||||
return
|
||||
|
||||
case text == "/week":
|
||||
b.sendWeeklyChart(chatID)
|
||||
return
|
||||
|
||||
case text == "/list":
|
||||
items, err := b.finance.GetTransactions(DefaultUserID, 10)
|
||||
if err != nil {
|
||||
reply = "❌ 查询失败"
|
||||
} else if len(items) == 0 {
|
||||
reply = "📭 暂无记录"
|
||||
} else {
|
||||
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()))
|
||||
}
|
||||
reply = sb.String()
|
||||
}
|
||||
|
||||
case strings.HasPrefix(text, "/"):
|
||||
reply = "❓ 未知命令,输入 /help 查看帮助"
|
||||
|
||||
default:
|
||||
amount, category, err := b.finance.AddTransaction(DefaultUserID, text)
|
||||
if err != nil {
|
||||
reply = "❌ 记账失败,请稍后重试"
|
||||
log.Printf("记账失败: %v", err)
|
||||
} else if amount == 0 {
|
||||
reply = "📍 没看到金额,这笔花了多少钱?"
|
||||
} else {
|
||||
amountYuan := float64(amount) / 100.0
|
||||
reply = fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, amountYuan, text)
|
||||
}
|
||||
}
|
||||
|
||||
m := tgbotapi.NewMessage(chatID, reply)
|
||||
if _, err := b.api.Send(m); err != nil {
|
||||
log.Printf("发送消息失败 chat=%d: %v", chatID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *TGBot) sendMonthlyChart(chatID int64) {
|
||||
now := time.Now()
|
||||
dateFrom := now.Format("2006-01") + "-01"
|
||||
dateTo := now.Format("2006-01-02")
|
||||
title := fmt.Sprintf("%d年%d月消费分类", now.Year(), now.Month())
|
||||
|
||||
stats, err := b.finance.GetCategoryStats(DefaultUserID, dateFrom, dateTo)
|
||||
if err != nil || len(stats) == 0 {
|
||||
m := tgbotapi.NewMessage(chatID, "📭 本月暂无消费数据")
|
||||
b.api.Send(m)
|
||||
return
|
||||
}
|
||||
|
||||
imgData, err := xchart.GeneratePieChart(stats, title)
|
||||
if err != nil {
|
||||
log.Printf("生成饼图失败: %v", err)
|
||||
m := tgbotapi.NewMessage(chatID, "❌ 图表生成失败")
|
||||
b.api.Send(m)
|
||||
return
|
||||
}
|
||||
|
||||
var total int64
|
||||
var totalCount int
|
||||
for _, s := range stats {
|
||||
total += s.Total
|
||||
totalCount += s.Count
|
||||
}
|
||||
caption := fmt.Sprintf("📊 %s\n💰 共 %d 笔,合计 %.2f 元", title, totalCount, float64(total)/100.0)
|
||||
|
||||
photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileBytes{Name: "chart.png", Bytes: imgData})
|
||||
photo.Caption = caption
|
||||
if _, err := b.api.Send(photo); err != nil {
|
||||
log.Printf("发送图表失败 chat=%d: %v", chatID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *TGBot) sendWeeklyChart(chatID int64) {
|
||||
now := time.Now()
|
||||
dateFrom := now.AddDate(0, 0, -6).Format("2006-01-02")
|
||||
dateTo := now.Format("2006-01-02")
|
||||
title := fmt.Sprintf("近7天消费趋势 (%s ~ %s)", dateFrom[5:], dateTo[5:])
|
||||
|
||||
stats, err := b.finance.GetDailyStats(DefaultUserID, dateFrom, dateTo)
|
||||
if err != nil || len(stats) == 0 {
|
||||
m := tgbotapi.NewMessage(chatID, "📭 近7天暂无消费数据")
|
||||
b.api.Send(m)
|
||||
return
|
||||
}
|
||||
|
||||
imgData, err := xchart.GenerateBarChart(stats, title)
|
||||
if err != nil {
|
||||
log.Printf("生成柱状图失败: %v", err)
|
||||
m := tgbotapi.NewMessage(chatID, "❌ 图表生成失败")
|
||||
b.api.Send(m)
|
||||
return
|
||||
}
|
||||
|
||||
var total int64
|
||||
var totalCount int
|
||||
for _, s := range stats {
|
||||
total += s.Total
|
||||
totalCount += s.Count
|
||||
}
|
||||
caption := fmt.Sprintf("📈 %s\n💰 共 %d 笔,合计 %.2f 元", title, totalCount, float64(total)/100.0)
|
||||
|
||||
photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileBytes{Name: "chart.png", Bytes: imgData})
|
||||
photo.Caption = caption
|
||||
if _, err := b.api.Send(photo); err != nil {
|
||||
log.Printf("发送图表失败 chat=%d: %v", chatID, err)
|
||||
}
|
||||
}
|
||||
442
internal/channel/channel.go
Normal file
442
internal/channel/channel.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ops-assistant/config"
|
||||
"ops-assistant/models"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UnifiedMessage struct {
|
||||
Platform string `json:"platform"`
|
||||
EventID string `json:"event_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
const (
|
||||
encPrefixV1 = "enc:v1:"
|
||||
encPrefixV2 = "enc:v2:"
|
||||
)
|
||||
|
||||
var secretCipherV1 *cipherContext
|
||||
var secretCipherV2 *cipherContext
|
||||
|
||||
type cipherContext struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
func InitSecretCipher(key string) error {
|
||||
k1 := deriveKey32Legacy(key)
|
||||
block1, err := aes.NewCipher(k1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aead1, err := cipher.NewGCM(block1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secretCipherV1 = &cipherContext{aead: aead1}
|
||||
|
||||
k2 := deriveKey32V2(key)
|
||||
block2, err := aes.NewCipher(k2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aead2, err := cipher.NewGCM(block2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secretCipherV2 = &cipherContext{aead: aead2}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deriveKey32Legacy(s string) []byte {
|
||||
b := []byte(s)
|
||||
out := make([]byte, 32)
|
||||
if len(b) >= 32 {
|
||||
copy(out, b[:32])
|
||||
return out
|
||||
}
|
||||
copy(out, b)
|
||||
for i := len(b); i < 32; i++ {
|
||||
out[i] = byte((i * 131) % 251)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deriveKey32V2(s string) []byte {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return make([]byte, 32)
|
||||
}
|
||||
// PBKDF2 for deterministic 32-byte key derivation
|
||||
return pbkdf2.Key([]byte(s), []byte("ops-assistant-v1"), 200000, 32, sha256.New)
|
||||
}
|
||||
|
||||
func encryptString(plain string) (string, error) {
|
||||
if secretCipherV2 == nil {
|
||||
return plain, errors.New("cipher not initialized")
|
||||
}
|
||||
nonce := make([]byte, secretCipherV2.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := secretCipherV2.aead.Seal(nil, nonce, []byte(plain), nil)
|
||||
buf := append(nonce, ciphertext...)
|
||||
return encPrefixV2 + base64.StdEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func decryptString(raw string) (string, error) {
|
||||
if !strings.HasPrefix(raw, encPrefixV1) && !strings.HasPrefix(raw, encPrefixV2) {
|
||||
return raw, nil
|
||||
}
|
||||
if strings.HasPrefix(raw, encPrefixV2) {
|
||||
if secretCipherV2 == nil {
|
||||
return "", errors.New("cipher not initialized")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, encPrefixV2))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ns := secretCipherV2.aead.NonceSize()
|
||||
if len(data) <= ns {
|
||||
return "", errors.New("invalid ciphertext")
|
||||
}
|
||||
nonce := data[:ns]
|
||||
ct := data[ns:]
|
||||
pt, err := secretCipherV2.aead.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(pt), nil
|
||||
}
|
||||
|
||||
if secretCipherV1 == nil {
|
||||
return "", errors.New("cipher not initialized")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, encPrefixV1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ns := secretCipherV1.aead.NonceSize()
|
||||
if len(data) <= ns {
|
||||
return "", errors.New("invalid ciphertext")
|
||||
}
|
||||
nonce := data[:ns]
|
||||
ct := data[ns:]
|
||||
pt, err := secretCipherV1.aead.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(pt), nil
|
||||
}
|
||||
|
||||
func maybeDecrypt(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
pt, err := decryptString(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return pt
|
||||
}
|
||||
|
||||
func MaybeDecryptPublic(raw string) string {
|
||||
return maybeDecrypt(raw)
|
||||
}
|
||||
|
||||
func EncryptSecretJSON(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
if strings.HasPrefix(raw, encPrefixV1) || strings.HasPrefix(raw, encPrefixV2) {
|
||||
return raw
|
||||
}
|
||||
if secretCipherV2 == nil {
|
||||
return raw
|
||||
}
|
||||
enc, err := encryptString(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
type telegramSecret struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type qqSecret struct {
|
||||
AppID string `json:"appid"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
type feishuSecret struct {
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
VerificationToken string `json:"verification_token"`
|
||||
EncryptKey string `json:"encrypt_key"`
|
||||
}
|
||||
|
||||
func parseJSON(raw string, out any) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal([]byte(raw), out)
|
||||
}
|
||||
|
||||
// ApplyChannelConfig 从数据库渠道配置覆盖运行时配置(优先级:DB > YAML)
|
||||
func ApplyChannelConfig(db *gorm.DB, cfg *config.Config) error {
|
||||
var rows []models.ChannelConfig
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
switch row.Platform {
|
||||
case "telegram":
|
||||
sec := telegramSecret{}
|
||||
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||
cfg.Telegram.Enabled = row.Enabled
|
||||
if strings.TrimSpace(sec.Token) != "" {
|
||||
cfg.Telegram.Token = strings.TrimSpace(sec.Token)
|
||||
}
|
||||
case "qqbot_official":
|
||||
sec := qqSecret{}
|
||||
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||
cfg.QQBot.Enabled = row.Enabled
|
||||
if strings.TrimSpace(sec.AppID) != "" {
|
||||
cfg.QQBot.AppID = strings.TrimSpace(sec.AppID)
|
||||
}
|
||||
if strings.TrimSpace(sec.Secret) != "" {
|
||||
cfg.QQBot.Secret = strings.TrimSpace(sec.Secret)
|
||||
}
|
||||
case "feishu":
|
||||
sec := feishuSecret{}
|
||||
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||
cfg.Feishu.Enabled = row.Enabled
|
||||
if strings.TrimSpace(sec.AppID) != "" {
|
||||
cfg.Feishu.AppID = strings.TrimSpace(sec.AppID)
|
||||
}
|
||||
if strings.TrimSpace(sec.AppSecret) != "" {
|
||||
cfg.Feishu.AppSecret = strings.TrimSpace(sec.AppSecret)
|
||||
}
|
||||
if strings.TrimSpace(sec.VerificationToken) != "" {
|
||||
cfg.Feishu.VerificationToken = strings.TrimSpace(sec.VerificationToken)
|
||||
}
|
||||
if strings.TrimSpace(sec.EncryptKey) != "" {
|
||||
cfg.Feishu.EncryptKey = strings.TrimSpace(sec.EncryptKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func httpClient() *http.Client {
|
||||
return &http.Client{Timeout: 8 * time.Second}
|
||||
}
|
||||
|
||||
func TestChannelConnectivity(ctx context.Context, row models.ChannelConfig) (status, detail string) {
|
||||
if !row.Enabled {
|
||||
return "disabled", "渠道未启用"
|
||||
}
|
||||
switch row.Platform {
|
||||
case "telegram":
|
||||
sec := telegramSecret{}
|
||||
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||
if strings.TrimSpace(sec.Token) == "" {
|
||||
return "error", "telegram token 为空"
|
||||
}
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/getMe", strings.TrimSpace(sec.Token))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "error", err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode != 200 || !strings.Contains(string(body), `"ok":true`) {
|
||||
return "error", fmt.Sprintf("telegram getMe失败: http=%d", resp.StatusCode)
|
||||
}
|
||||
return "ok", "telegram getMe 成功"
|
||||
|
||||
case "qqbot_official":
|
||||
sec := qqSecret{}
|
||||
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||
if strings.TrimSpace(sec.AppID) == "" || strings.TrimSpace(sec.Secret) == "" {
|
||||
return "error", "qq appid/secret 为空"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]string{"appId": strings.TrimSpace(sec.AppID), "clientSecret": strings.TrimSpace(sec.Secret)})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://bots.qq.com/app/getAppAccessToken", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "error", err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode != 200 || !strings.Contains(string(body), "access_token") {
|
||||
return "error", fmt.Sprintf("qq access token 获取失败: http=%d", resp.StatusCode)
|
||||
}
|
||||
return "ok", "qq access token 获取成功"
|
||||
|
||||
case "feishu":
|
||||
sec := feishuSecret{}
|
||||
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||
if strings.TrimSpace(sec.AppID) == "" || strings.TrimSpace(sec.AppSecret) == "" {
|
||||
return "error", "feishu app_id/app_secret 为空"
|
||||
}
|
||||
tk, err := GetFeishuTenantToken(ctx, strings.TrimSpace(sec.AppID), strings.TrimSpace(sec.AppSecret))
|
||||
if err != nil || strings.TrimSpace(tk) == "" {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("token 为空")
|
||||
}
|
||||
return "error", err.Error()
|
||||
}
|
||||
return "ok", "feishu tenant_access_token 获取成功"
|
||||
default:
|
||||
return "error", "未知平台"
|
||||
}
|
||||
}
|
||||
|
||||
func ParseFeishuInbound(body []byte, verificationToken string) (*UnifiedMessage, string, error) {
|
||||
// url_verification
|
||||
var verifyReq struct {
|
||||
Type string `json:"type"`
|
||||
Challenge string `json:"challenge"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &verifyReq); err == nil && verifyReq.Type == "url_verification" {
|
||||
if strings.TrimSpace(verificationToken) != "" && verifyReq.Token != verificationToken {
|
||||
return nil, "", fmt.Errorf("verification token mismatch")
|
||||
}
|
||||
return nil, verifyReq.Challenge, nil
|
||||
}
|
||||
|
||||
var event struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Sender struct {
|
||||
SenderID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"sender_id"`
|
||||
} `json:"sender"`
|
||||
Message struct {
|
||||
MessageID string `json:"message_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &event); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if event.Header.EventType != "im.message.receive_v1" {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
eventID := strings.TrimSpace(event.Header.EventID)
|
||||
if eventID == "" {
|
||||
eventID = strings.TrimSpace(event.Event.Message.MessageID)
|
||||
}
|
||||
if eventID == "" {
|
||||
return nil, "", fmt.Errorf("missing event id")
|
||||
}
|
||||
|
||||
var content struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(event.Event.Message.Content), &content)
|
||||
text := strings.TrimSpace(content.Text)
|
||||
if text == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
return &UnifiedMessage{
|
||||
Platform: "feishu",
|
||||
EventID: eventID,
|
||||
ChatID: strings.TrimSpace(event.Event.Message.ChatID),
|
||||
UserID: strings.TrimSpace(event.Event.Sender.SenderID.OpenID),
|
||||
Text: text,
|
||||
}, "", nil
|
||||
}
|
||||
|
||||
func GetFeishuTenantToken(ctx context.Context, appID, appSecret string) (string, error) {
|
||||
payload, _ := json.Marshal(map[string]string{"app_id": appID, "app_secret": appSecret})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("http=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.Code != 0 || strings.TrimSpace(out.TenantAccessToken) == "" {
|
||||
if out.Msg == "" {
|
||||
out.Msg = "获取token失败"
|
||||
}
|
||||
return "", fmt.Errorf(out.Msg)
|
||||
}
|
||||
return out.TenantAccessToken, nil
|
||||
}
|
||||
|
||||
func SendFeishuText(ctx context.Context, tenantToken, receiveID, text string) error {
|
||||
contentBytes, _ := json.Marshal(map[string]string{"text": text})
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"receive_id": receiveID,
|
||||
"msg_type": "text",
|
||||
"content": string(contentBytes),
|
||||
})
|
||||
url := "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id"
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tenantToken)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("http=%d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(body), `"code":0`) {
|
||||
return fmt.Errorf("feishu send failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
126
internal/chart/chart.go
Normal file
126
internal/chart/chart.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"ops-assistant/internal/service"
|
||||
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
// 分类对应的颜色
|
||||
var categoryColors = []drawing.Color{
|
||||
{R: 255, G: 99, B: 132, A: 255}, // 红
|
||||
{R: 54, G: 162, B: 235, A: 255}, // 蓝
|
||||
{R: 255, G: 206, B: 86, A: 255}, // 黄
|
||||
{R: 75, G: 192, B: 192, A: 255}, // 青
|
||||
{R: 153, G: 102, B: 255, A: 255}, // 紫
|
||||
{R: 255, G: 159, B: 64, A: 255}, // 橙
|
||||
{R: 46, G: 204, B: 113, A: 255}, // 绿
|
||||
{R: 231, G: 76, B: 60, A: 255}, // 深红
|
||||
{R: 52, G: 73, B: 94, A: 255}, // 深蓝灰
|
||||
{R: 241, G: 196, B: 15, A: 255}, // 金
|
||||
}
|
||||
|
||||
// GeneratePieChart 生成分类占比饼图
|
||||
func GeneratePieChart(stats []service.CategoryStat, title string) ([]byte, error) {
|
||||
if len(stats) == 0 {
|
||||
return nil, fmt.Errorf("no data")
|
||||
}
|
||||
|
||||
var total float64
|
||||
for _, s := range stats {
|
||||
total += float64(s.Total)
|
||||
}
|
||||
|
||||
var values []chart.Value
|
||||
for i, s := range stats {
|
||||
yuan := float64(s.Total) / 100.0
|
||||
pct := float64(s.Total) / total * 100
|
||||
label := fmt.Sprintf("%s %.0f元(%.0f%%)", s.Category, yuan, pct)
|
||||
values = append(values, chart.Value{
|
||||
Value: yuan,
|
||||
Label: label,
|
||||
Style: chart.Style{
|
||||
FillColor: categoryColors[i%len(categoryColors)],
|
||||
StrokeColor: drawing.ColorWhite,
|
||||
StrokeWidth: 2,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pie := chart.PieChart{
|
||||
Title: title,
|
||||
Width: 600,
|
||||
Height: 500,
|
||||
TitleStyle: chart.Style{
|
||||
FontSize: 16,
|
||||
},
|
||||
Values: values,
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
if err := pie.Render(chart.PNG, buf); err != nil {
|
||||
return nil, fmt.Errorf("render pie chart: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// GenerateBarChart 生成每日消费柱状图
|
||||
func GenerateBarChart(stats []service.DailyStat, title string) ([]byte, error) {
|
||||
if len(stats) == 0 {
|
||||
return nil, fmt.Errorf("no data")
|
||||
}
|
||||
|
||||
var values []chart.Value
|
||||
var maxVal float64
|
||||
for _, s := range stats {
|
||||
yuan := float64(s.Total) / 100.0
|
||||
if yuan > maxVal {
|
||||
maxVal = yuan
|
||||
}
|
||||
// 日期只取 MM-DD
|
||||
dateLabel := s.Date
|
||||
if len(s.Date) > 5 {
|
||||
dateLabel = s.Date[5:]
|
||||
}
|
||||
values = append(values, chart.Value{
|
||||
Value: yuan,
|
||||
Label: dateLabel,
|
||||
Style: chart.Style{
|
||||
FillColor: drawing.Color{R: 54, G: 162, B: 235, A: 255},
|
||||
StrokeColor: drawing.Color{R: 54, G: 162, B: 235, A: 255},
|
||||
StrokeWidth: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
bar := chart.BarChart{
|
||||
Title: title,
|
||||
Width: 600,
|
||||
Height: 400,
|
||||
TitleStyle: chart.Style{
|
||||
FontSize: 16,
|
||||
},
|
||||
YAxis: chart.YAxis{
|
||||
Range: &chart.ContinuousRange{
|
||||
Min: 0,
|
||||
Max: math.Ceil(maxVal*1.2/10) * 10,
|
||||
},
|
||||
ValueFormatter: func(v interface{}) string {
|
||||
return fmt.Sprintf("%.0f", v)
|
||||
},
|
||||
},
|
||||
BarWidth: 40,
|
||||
Bars: values,
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
if err := bar.Render(chart.PNG, buf); err != nil {
|
||||
return nil, fmt.Errorf("render bar chart: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
24
internal/core/ai/advisor.go
Normal file
24
internal/core/ai/advisor.go
Normal 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
internal/core/ai/client.go
Normal file
103
internal/core/ai/client.go
Normal 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
internal/core/ai/loader.go
Normal file
40
internal/core/ai/loader.go
Normal 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
internal/core/command/parser.go
Normal file
67
internal/core/command/parser.go
Normal 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
internal/core/ecode/codes.go
Normal file
17
internal/core/ecode/codes.go
Normal 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
internal/core/module/request.go
Normal file
19
internal/core/module/request.go
Normal 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
internal/core/module/runner.go
Normal file
48
internal/core/module/runner.go
Normal 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
internal/core/module/switches.go
Normal file
26
internal/core/module/switches.go
Normal 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)
|
||||
}
|
||||
97
internal/core/module/template.go
Normal file
97
internal/core/module/template.go
Normal file
@@ -0,0 +1,97 @@
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
26
internal/core/ops/bootstrap.go
Normal file
26
internal/core/ops/bootstrap.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package ops
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"ops-assistant/internal/core/registry"
|
||||
"ops-assistant/internal/core/runbook"
|
||||
"ops-assistant/internal/module/cf"
|
||||
"ops-assistant/internal/module/cpa"
|
||||
"ops-assistant/internal/module/mail"
|
||||
|
||||
"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)
|
||||
|
||||
r.RegisterModule("cpa", cpaModule.Handle)
|
||||
r.RegisterModule("cf", cfModule.Handle)
|
||||
r.RegisterModule("mail", mailModule.Handle)
|
||||
return NewService(dbPath, baseDir, r)
|
||||
}
|
||||
60
internal/core/ops/retry.go
Normal file
60
internal/core/ops/retry.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package ops
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"ops-assistant/internal/core/runbook"
|
||||
"ops-assistant/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func decodeInputJSON(raw string, out *map[string]string) error {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal([]byte(raw), out)
|
||||
}
|
||||
|
||||
func RetryJobWithDB(db *gorm.DB, baseDir string, jobID uint) (uint, error) {
|
||||
if db == nil {
|
||||
return 0, errors.New("db is nil")
|
||||
}
|
||||
var old models.OpsJob
|
||||
if err := db.First(&old, jobID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strings.TrimSpace(old.Status) != "failed" {
|
||||
return 0, errors.New("only failed jobs can retry")
|
||||
}
|
||||
|
||||
inputs := map[string]string{}
|
||||
if strings.TrimSpace(old.InputJSON) != "" {
|
||||
_ = decodeInputJSON(old.InputJSON, &inputs)
|
||||
}
|
||||
|
||||
meta := runbook.NewMeta()
|
||||
meta.Target = old.Target
|
||||
meta.RiskLevel = old.RiskLevel
|
||||
meta.RequestID = old.RequestID + "-retry"
|
||||
meta.ConfirmHash = old.ConfirmHash
|
||||
|
||||
exec := runbook.NewExecutor(db, filepath.Join(baseDir, "runbooks"))
|
||||
newID, _, err := exec.RunWithInputsAndMeta(old.Command, old.Runbook, old.Operator, inputs, meta)
|
||||
if err != nil {
|
||||
return newID, err
|
||||
}
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
func RetryJob(dbPath, baseDir string, jobID uint) (uint, error) {
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return RetryJobWithDB(db, baseDir, jobID)
|
||||
}
|
||||
20
internal/core/ops/run_once.go
Normal file
20
internal/core/ops/run_once.go
Normal 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())
|
||||
}
|
||||
100
internal/core/ops/service.go
Normal file
100
internal/core/ops/service.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package ops
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"ops-assistant/internal/core/command"
|
||||
coremodule "ops-assistant/internal/core/module"
|
||||
"ops-assistant/internal/core/registry"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
dbPath string
|
||||
baseDir string
|
||||
registry *registry.Registry
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewService(dbPath, baseDir string, reg *registry.Registry) *Service {
|
||||
db, _ := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
return &Service{dbPath: dbPath, baseDir: baseDir, registry: reg, db: db}
|
||||
}
|
||||
|
||||
func (s *Service) Handle(userID int64, text string) (bool, string) {
|
||||
if !strings.HasPrefix(strings.TrimSpace(text), "/") {
|
||||
return false, ""
|
||||
}
|
||||
cmd, _, err := command.ParseWithInputs(text)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
// 通用帮助
|
||||
if cmd.Module == "help" || cmd.Name == "/help" || cmd.Name == "/start" {
|
||||
return true, s.helpText()
|
||||
}
|
||||
if cmd.Module == "ops" && (len(cmd.Args) == 0 || cmd.Args[0] == "help") {
|
||||
return true, s.helpText()
|
||||
}
|
||||
if cmd.Module == "ops" && len(cmd.Args) > 0 && cmd.Args[0] == "modules" {
|
||||
return true, s.modulesStatusText()
|
||||
}
|
||||
if cmd.Module != "" && cmd.Module != "ops" && s.db != nil {
|
||||
if !coremodule.IsEnabled(s.db, cmd.Module) {
|
||||
return true, fmt.Sprintf("[ERR_FEATURE_DISABLED] 模块未启用: %s(开关: enable_module_%s)", cmd.Module, cmd.Module)
|
||||
}
|
||||
}
|
||||
out, handled, err := s.registry.Handle(userID, cmd)
|
||||
if !handled {
|
||||
return false, ""
|
||||
}
|
||||
if err != nil {
|
||||
return true, "❌ OPS 执行失败: " + err.Error()
|
||||
}
|
||||
return true, out
|
||||
}
|
||||
|
||||
func (s *Service) helpText() string {
|
||||
lines := []string{
|
||||
"🛠️ 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)",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (s *Service) modulesStatusText() string {
|
||||
mods := s.registry.ListModules()
|
||||
if len(mods) == 0 {
|
||||
return "暂无已注册模块"
|
||||
}
|
||||
lines := []string{"🧩 模块状态:"}
|
||||
for _, m := range mods {
|
||||
enabled := false
|
||||
if s.db != nil {
|
||||
enabled = coremodule.IsEnabled(s.db, m)
|
||||
}
|
||||
state := "disabled"
|
||||
if enabled {
|
||||
state = "enabled"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s: %s", m, state))
|
||||
}
|
||||
lines = append(lines, "\n可用命令:/ops modules")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
62
internal/core/policy/policy.go
Normal file
62
internal/core/policy/policy.go
Normal 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
internal/core/ports/channel.go
Normal file
14
internal/core/ports/channel.go
Normal 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
internal/core/ports/module.go
Normal file
6
internal/core/ports/module.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package ports
|
||||
|
||||
type Module interface {
|
||||
Name() string
|
||||
CommandPrefix() string
|
||||
}
|
||||
47
internal/core/registry/registry.go
Normal file
47
internal/core/registry/registry.go
Normal 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
|
||||
}
|
||||
32
internal/core/runbook/cancel.go
Normal file
32
internal/core/runbook/cancel.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package runbook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var jobCancelMap sync.Map
|
||||
|
||||
func registerJobCancel(jobID uint, cancel context.CancelFunc) {
|
||||
jobCancelMap.Store(jobID, cancel)
|
||||
}
|
||||
|
||||
func clearJobCancel(jobID uint) {
|
||||
if v, ok := jobCancelMap.Load(jobID); ok {
|
||||
if cancel, ok2 := v.(context.CancelFunc); ok2 {
|
||||
cancel()
|
||||
}
|
||||
jobCancelMap.Delete(jobID)
|
||||
}
|
||||
}
|
||||
|
||||
func CancelJob(jobID uint) bool {
|
||||
if v, ok := jobCancelMap.Load(jobID); ok {
|
||||
if cancel, ok2 := v.(context.CancelFunc); ok2 {
|
||||
cancel()
|
||||
}
|
||||
jobCancelMap.Delete(jobID)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
387
internal/core/runbook/executor.go
Normal file
387
internal/core/runbook/executor.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package runbook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ops-assistant/internal/core/ecode"
|
||||
"ops-assistant/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Executor struct {
|
||||
db *gorm.DB
|
||||
runbookDir string
|
||||
}
|
||||
|
||||
func NewExecutor(db *gorm.DB, runbookDir string) *Executor {
|
||||
return &Executor{db: db, runbookDir: runbookDir}
|
||||
}
|
||||
|
||||
func (e *Executor) Run(commandText, runbookName string, operator int64) (uint, string, error) {
|
||||
return e.RunWithInputsAndMeta(commandText, runbookName, operator, map[string]string{}, NewMeta())
|
||||
}
|
||||
|
||||
func (e *Executor) RunWithInputs(commandText, runbookName string, operator int64, inputs map[string]string) (uint, string, error) {
|
||||
return e.RunWithInputsAndMeta(commandText, runbookName, operator, inputs, NewMeta())
|
||||
}
|
||||
|
||||
func (e *Executor) RunWithInputsAndMeta(commandText, runbookName string, operator int64, inputs map[string]string, meta RunMeta) (uint, string, error) {
|
||||
started := time.Now()
|
||||
inputJSON := "{}"
|
||||
if b, err := json.Marshal(inputs); err == nil {
|
||||
inputJSON = string(b)
|
||||
}
|
||||
job := models.OpsJob{
|
||||
Command: commandText,
|
||||
Runbook: runbookName,
|
||||
Operator: operator,
|
||||
Target: strings.TrimSpace(meta.Target),
|
||||
RiskLevel: strings.TrimSpace(meta.RiskLevel),
|
||||
RequestID: strings.TrimSpace(meta.RequestID),
|
||||
ConfirmHash: strings.TrimSpace(meta.ConfirmHash),
|
||||
InputJSON: inputJSON,
|
||||
Status: "pending",
|
||||
StartedAt: started,
|
||||
}
|
||||
if job.RiskLevel == "" {
|
||||
job.RiskLevel = "low"
|
||||
}
|
||||
if err := e.db.Create(&job).Error; err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
release := acquireTargetLock(job.Target)
|
||||
defer release()
|
||||
|
||||
job.Status = "running"
|
||||
_ = e.db.Save(&job).Error
|
||||
|
||||
specPath := filepath.Join(e.runbookDir, runbookName+".yaml")
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
e.finishJob(&job, "failed", "runbook not found")
|
||||
return job.ID, "", err
|
||||
}
|
||||
spec, err := Parse(data)
|
||||
if err != nil {
|
||||
e.finishJob(&job, "failed", "runbook parse failed")
|
||||
return job.ID, "", err
|
||||
}
|
||||
|
||||
outputs := map[string]string{}
|
||||
ctx := map[string]string{}
|
||||
|
||||
jobCtx, jobCancel := context.WithCancel(context.Background())
|
||||
registerJobCancel(job.ID, jobCancel)
|
||||
defer clearJobCancel(job.ID)
|
||||
for k, v := range inputs {
|
||||
ctx["inputs."+k] = v
|
||||
}
|
||||
if t := strings.TrimSpace(os.Getenv("CPA_MANAGEMENT_BASE")); t != "" {
|
||||
ctx["env.cpa_management_base"] = t
|
||||
} else {
|
||||
var sset models.AppSetting
|
||||
if err := e.db.Where("key = ?", "cpa_management_base").First(&sset).Error; err == nil {
|
||||
if strings.TrimSpace(sset.Value) != "" {
|
||||
ctx["env.cpa_management_base"] = strings.TrimSpace(sset.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if t := strings.TrimSpace(os.Getenv("CPA_MANAGEMENT_TOKEN")); t != "" {
|
||||
ctx["env.cpa_management_token"] = t
|
||||
} else {
|
||||
var sset models.AppSetting
|
||||
if err := e.db.Where("key = ?", "cpa_management_token").First(&sset).Error; err == nil {
|
||||
if strings.TrimSpace(sset.Value) != "" {
|
||||
ctx["env.cpa_management_token"] = strings.TrimSpace(sset.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cloudflare settings
|
||||
if t := strings.TrimSpace(os.Getenv("CF_ACCOUNT_ID")); t != "" {
|
||||
ctx["env_cf_account_id"] = t
|
||||
} else {
|
||||
var sset models.AppSetting
|
||||
if err := e.db.Where("key = ?", "cf_account_id").First(&sset).Error; err == nil {
|
||||
if strings.TrimSpace(sset.Value) != "" {
|
||||
ctx["env_cf_account_id"] = strings.TrimSpace(sset.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if t := strings.TrimSpace(os.Getenv("CF_API_EMAIL")); t != "" {
|
||||
ctx["env_cf_api_email"] = t
|
||||
} else {
|
||||
var sset models.AppSetting
|
||||
if err := e.db.Where("key = ?", "cf_api_email").First(&sset).Error; err == nil {
|
||||
if strings.TrimSpace(sset.Value) != "" {
|
||||
ctx["env_cf_api_email"] = strings.TrimSpace(sset.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if t := strings.TrimSpace(os.Getenv("CF_API_TOKEN")); t != "" {
|
||||
ctx["env_cf_api_token"] = t
|
||||
} else {
|
||||
var sset models.AppSetting
|
||||
if err := e.db.Where("key = ?", "cf_api_token").First(&sset).Error; err == nil {
|
||||
if strings.TrimSpace(sset.Value) != "" {
|
||||
ctx["env_cf_api_token"] = strings.TrimSpace(sset.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inject input env vars for runbook steps
|
||||
for k, v := range inputs {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
ctx["env.INPUT_"+strings.ToUpper(k)] = v
|
||||
}
|
||||
}
|
||||
for _, st := range spec.Steps {
|
||||
if isJobCancelled(e.db, job.ID) {
|
||||
e.finishJob(&job, "cancelled", ecode.Tag(ecode.ErrJobCancelled, "cancelled by user"))
|
||||
return job.ID, "", fmt.Errorf(ecode.Tag(ecode.ErrJobCancelled, "cancelled by user"))
|
||||
}
|
||||
|
||||
rendered := renderStep(st, ctx)
|
||||
step := models.OpsJobStep{JobID: job.ID, StepID: rendered.ID, Action: rendered.Action, Status: "running", StartedAt: time.Now()}
|
||||
_ = e.db.Create(&step).Error
|
||||
|
||||
timeout := meta.timeoutOrDefault()
|
||||
rc, stdout, stderr, runErr := e.execStep(jobCtx, rendered, outputs, timeout)
|
||||
step.RC = rc
|
||||
step.StdoutTail = tail(stdout, 1200)
|
||||
step.StderrTail = tail(stderr, 1200)
|
||||
step.EndedAt = time.Now()
|
||||
if runErr != nil || rc != 0 {
|
||||
step.Status = "failed"
|
||||
_ = e.db.Save(&step).Error
|
||||
e.finishJob(&job, "failed", fmt.Sprintf("%s: step=%s failed", ecode.ErrStepFailed, rendered.ID))
|
||||
if runErr == nil {
|
||||
runErr = fmt.Errorf("rc=%d", rc)
|
||||
}
|
||||
return job.ID, "", fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("step %s failed: %v", rendered.ID, runErr)))
|
||||
}
|
||||
step.Status = "success"
|
||||
_ = e.db.Save(&step).Error
|
||||
outputs[rendered.ID] = stdout
|
||||
ctx["steps."+rendered.ID+".output"] = stdout
|
||||
}
|
||||
|
||||
e.finishJob(&job, "success", "ok")
|
||||
return job.ID, "ok", nil
|
||||
}
|
||||
|
||||
func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]string, timeout time.Duration) (int, string, string, error) {
|
||||
switch st.Action {
|
||||
case "ssh.exec":
|
||||
target := strings.TrimSpace(fmt.Sprintf("%v", st.With["target"]))
|
||||
cmdText := strings.TrimSpace(fmt.Sprintf("%v", st.With["command"]))
|
||||
if target == "" || cmdText == "" {
|
||||
return 1, "", "missing target/command", fmt.Errorf("missing target/command")
|
||||
}
|
||||
resolved := resolveTarget(e.db, target)
|
||||
if !resolved.Found {
|
||||
return 1, "", "invalid target", fmt.Errorf("invalid target: %s", target)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
args := []string{"-p", strconv.Itoa(resolved.Port), resolved.User + "@" + resolved.Host, cmdText}
|
||||
cmd := exec.CommandContext(ctx, "ssh", args...)
|
||||
var outb, errb bytes.Buffer
|
||||
cmd.Stdout = &outb
|
||||
cmd.Stderr = &errb
|
||||
err := cmd.Run()
|
||||
rc := 0
|
||||
if err != nil {
|
||||
rc = 1
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "ssh step timeout"))
|
||||
}
|
||||
}
|
||||
return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err
|
||||
|
||||
case "shell.exec":
|
||||
cmdText := strings.TrimSpace(fmt.Sprintf("%v", st.With["command"]))
|
||||
if cmdText == "" {
|
||||
return 1, "", "missing command", fmt.Errorf("missing command")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "bash", "-lc", cmdText)
|
||||
var outb, errb bytes.Buffer
|
||||
cmd.Stdout = &outb
|
||||
cmd.Stderr = &errb
|
||||
err := cmd.Run()
|
||||
rc := 0
|
||||
if err != nil {
|
||||
rc = 1
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "shell step timeout"))
|
||||
}
|
||||
}
|
||||
return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err
|
||||
|
||||
case "assert.json":
|
||||
sourceStep := strings.TrimSpace(fmt.Sprintf("%v", st.With["source_step"]))
|
||||
if sourceStep == "" {
|
||||
return 1, "", "missing source_step", fmt.Errorf("missing source_step")
|
||||
}
|
||||
raw, ok := outputs[sourceStep]
|
||||
if !ok {
|
||||
return 1, "", "source step output not found", fmt.Errorf("source step output not found: %s", sourceStep)
|
||||
}
|
||||
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
return 0, "assert ok", "", nil
|
||||
|
||||
case "sleep":
|
||||
ms := 1000
|
||||
if v, ok := st.With["ms"]; ok {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
ms = t
|
||||
case int64:
|
||||
ms = int(t)
|
||||
case float64:
|
||||
ms = int(t)
|
||||
case string:
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil {
|
||||
ms = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if ms < 0 {
|
||||
ms = 0
|
||||
}
|
||||
time.Sleep(time.Duration(ms) * time.Millisecond)
|
||||
return 0, fmt.Sprintf("slept %dms", ms), "", nil
|
||||
|
||||
default:
|
||||
return 1, "", "unsupported action", fmt.Errorf("unsupported action: %s", st.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func renderStep(st Step, ctx map[string]string) Step {
|
||||
out := st
|
||||
out.ID = renderString(out.ID, ctx)
|
||||
out.Action = renderString(out.Action, ctx)
|
||||
if out.With == nil {
|
||||
return out
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
out.With = m
|
||||
return out
|
||||
}
|
||||
|
||||
func renderString(s string, ctx map[string]string) string {
|
||||
res := s
|
||||
for k, v := range ctx {
|
||||
res = strings.ReplaceAll(res, "${"+k+"}", v)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func parseRequiredPaths(v any) []string {
|
||||
res := []string{}
|
||||
switch t := v.(type) {
|
||||
case []any:
|
||||
for _, it := range t {
|
||||
res = append(res, strings.TrimSpace(fmt.Sprintf("%v", it)))
|
||||
}
|
||||
case []string:
|
||||
for _, it := range t {
|
||||
res = append(res, strings.TrimSpace(it))
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(res))
|
||||
for _, p := range res {
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func lookupPath(root any, path string) (any, bool) {
|
||||
parts := strings.Split(path, ".")
|
||||
cur := root
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil, false
|
||||
}
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
next, exists := m[part]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur, true
|
||||
}
|
||||
|
||||
func (e *Executor) finishJob(job *models.OpsJob, status, summary string) {
|
||||
job.Status = status
|
||||
job.Summary = summary
|
||||
job.EndedAt = time.Now()
|
||||
_ = e.db.Save(job).Error
|
||||
}
|
||||
|
||||
func isJobCancelled(db *gorm.DB, jobID uint) bool {
|
||||
var j models.OpsJob
|
||||
if err := db.Select("status").First(&j, jobID).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(j.Status), "cancelled")
|
||||
}
|
||||
|
||||
func tail(s string, max int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[len(s)-max:]
|
||||
}
|
||||
21
internal/core/runbook/lock.go
Normal file
21
internal/core/runbook/lock.go
Normal 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
internal/core/runbook/meta.go
Normal file
23
internal/core/runbook/meta.go
Normal 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
internal/core/runbook/seed_targets.go
Normal file
20
internal/core/runbook/seed_targets.go
Normal 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
|
||||
}
|
||||
37
internal/core/runbook/targets.go
Normal file
37
internal/core/runbook/targets.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package runbook
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ops-assistant/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ResolvedTarget struct {
|
||||
Found bool
|
||||
User string
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
func resolveTarget(db *gorm.DB, name string) ResolvedTarget {
|
||||
trim := strings.TrimSpace(name)
|
||||
if trim == "" {
|
||||
return ResolvedTarget{}
|
||||
}
|
||||
var t models.OpsTarget
|
||||
if err := db.Where("name = ? AND enabled = ?", trim, true).First(&t).Error; err != nil {
|
||||
return ResolvedTarget{}
|
||||
}
|
||||
user := strings.TrimSpace(t.User)
|
||||
host := strings.TrimSpace(t.Host)
|
||||
port := t.Port
|
||||
if user == "" || host == "" {
|
||||
return ResolvedTarget{}
|
||||
}
|
||||
if port <= 0 {
|
||||
port = 22
|
||||
}
|
||||
return ResolvedTarget{Found: true, User: user, Host: host, Port: port}
|
||||
}
|
||||
24
internal/core/runbook/types.go
Normal file
24
internal/core/runbook/types.go
Normal 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
|
||||
}
|
||||
138
internal/feishu/feishu.go
Normal file
138
internal/feishu/feishu.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ops-assistant/internal/channel"
|
||||
"ops-assistant/internal/core/ops"
|
||||
"ops-assistant/internal/service"
|
||||
"ops-assistant/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
||||
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, opsSvc *ops.Service, appID, appSecret, verificationToken, encryptKey string) *Bot {
|
||||
return &Bot{
|
||||
db: db,
|
||||
finance: finance,
|
||||
opsSvc: opsSvc,
|
||||
appID: appID,
|
||||
appSecret: appSecret,
|
||||
verificationToken: verificationToken,
|
||||
encryptKey: encryptKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) Start(ctx context.Context) {
|
||||
log.Printf("🚀 Feishu Bot 已启用 app_id=%s", maskID(b.appID))
|
||||
<-ctx.Done()
|
||||
log.Printf("⏳ Feishu Bot 已停止")
|
||||
}
|
||||
|
||||
func (b *Bot) RegisterRoutes(r *gin.Engine) {
|
||||
r.POST("/webhook/feishu", b.handleWebhook)
|
||||
}
|
||||
|
||||
func (b *Bot) handleWebhook(c *gin.Context) {
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
|
||||
// 统一走 channel 包解析,便于后续扩展验签/解密
|
||||
msg, verifyChallenge, err := channel.ParseFeishuInbound(body, b.verificationToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if verifyChallenge != "" {
|
||||
c.JSON(http.StatusOK, gin.H{"challenge": verifyChallenge})
|
||||
return
|
||||
}
|
||||
if msg == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||
return
|
||||
}
|
||||
|
||||
// 幂等去重
|
||||
var existed models.MessageDedup
|
||||
if err := b.db.Where("platform = ? AND event_id = ?", "feishu", msg.EventID).First(&existed).Error; err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||
return
|
||||
}
|
||||
_ = b.db.Create(&models.MessageDedup{Platform: "feishu", EventID: msg.EventID, ProcessedAt: time.Now()}).Error
|
||||
|
||||
reply := b.handleText(msg.Text)
|
||||
if reply != "" && msg.UserID != "" {
|
||||
tk, err := channel.GetFeishuTenantToken(c.Request.Context(), b.appID, b.appSecret)
|
||||
if err == nil {
|
||||
_ = channel.SendFeishuText(c.Request.Context(), tk, msg.UserID, reply)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||
}
|
||||
|
||||
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 "🛠️ Ops-Assistant\n\n直接发送消费描述即可记账:\n• 午饭 25元\n• 打车 ¥30\n\n📋 命令:记录/查看、今日/今天、统计"
|
||||
case "查看", "记录", "列表", "最近":
|
||||
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()
|
||||
}
|
||||
|
||||
amount, category, err := b.finance.AddTransaction(DefaultUserID, trim)
|
||||
if err != nil {
|
||||
return "❌ 记账失败,请稍后重试"
|
||||
}
|
||||
if amount == 0 {
|
||||
return "📍 没看到金额,这笔花了多少钱?"
|
||||
}
|
||||
return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, float64(amount)/100.0, trim)
|
||||
}
|
||||
|
||||
func maskID(s string) string {
|
||||
if len(s) <= 6 {
|
||||
return "***"
|
||||
}
|
||||
return s[:3] + "***" + s[len(s)-3:]
|
||||
}
|
||||
254
internal/module/cf/commands.go
Normal file
254
internal/module/cf/commands.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package cf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ops-assistant/internal/core/ecode"
|
||||
coremodule "ops-assistant/internal/core/module"
|
||||
)
|
||||
|
||||
func commandSpecs() []coremodule.CommandSpec {
|
||||
return []coremodule.CommandSpec{
|
||||
{
|
||||
Prefixes: []string{"/cf status"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cf_status",
|
||||
Gate: coremodule.Gate{
|
||||
NeedFlag: "enable_module_cf",
|
||||
AllowDryRun: true,
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cf status(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf status 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cf status 执行失败: ",
|
||||
},
|
||||
{
|
||||
Prefixes: []string{"/cf zones"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cf_zones",
|
||||
Gate: coremodule.Gate{
|
||||
NeedFlag: "enable_module_cf",
|
||||
AllowDryRun: true,
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cf zones(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf zones 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cf zones 执行失败: ",
|
||||
},
|
||||
{
|
||||
Prefixes: []string{"/cf dns list"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cf_dns_list",
|
||||
InputsFn: func(_ string, parts []string) (map[string]string, error) {
|
||||
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
|
||||
},
|
||||
Gate: coremodule.Gate{
|
||||
NeedFlag: "enable_module_cf",
|
||||
AllowDryRun: true,
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cf dns list <zone_id>(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dns list 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cf dns list 执行失败: ",
|
||||
ErrHint: "/cf dns list <zone_id>",
|
||||
},
|
||||
{
|
||||
Prefixes: []string{"/cf dns update"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cf_dns_update",
|
||||
InputsFn: func(_ string, parts []string) (map[string]string, error) {
|
||||
if len(parts) < 8 {
|
||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied]"))
|
||||
}
|
||||
inputs := map[string]string{
|
||||
"zone_id": parts[3],
|
||||
"record_id": parts[4],
|
||||
"type": parts[5],
|
||||
"name": parts[6],
|
||||
"content": parts[7],
|
||||
}
|
||||
if len(parts) >= 9 {
|
||||
inputs["ttl"] = parts[8]
|
||||
}
|
||||
if len(parts) >= 10 {
|
||||
inputs["proxied"] = parts[9]
|
||||
}
|
||||
return inputs, nil
|
||||
},
|
||||
Gate: coremodule.Gate{
|
||||
NeedFlag: "enable_module_cf",
|
||||
AllowDryRun: true,
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cf dns update(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dns update 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cf dns update 执行失败: ",
|
||||
ErrHint: "/cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied:true|false]",
|
||||
},
|
||||
{
|
||||
Prefixes: []string{"/cf dnsadd"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cf_dns_add",
|
||||
InputsFn: func(_ string, parts []string) (map[string]string, error) {
|
||||
if len(parts) < 4 {
|
||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dnsadd <name> <content> [on|off] [type]"))
|
||||
}
|
||||
name := parts[2]
|
||||
content := parts[3]
|
||||
proxied := "false"
|
||||
recType := "A"
|
||||
if len(parts) >= 5 {
|
||||
switch strings.ToLower(parts[4]) {
|
||||
case "on":
|
||||
proxied = "true"
|
||||
if len(parts) >= 6 {
|
||||
recType = parts[5]
|
||||
}
|
||||
case "off":
|
||||
proxied = "false"
|
||||
if len(parts) >= 6 {
|
||||
recType = parts[5]
|
||||
}
|
||||
case "true":
|
||||
proxied = "true"
|
||||
if len(parts) >= 6 {
|
||||
recType = parts[5]
|
||||
}
|
||||
case "false":
|
||||
proxied = "false"
|
||||
if len(parts) >= 6 {
|
||||
recType = parts[5]
|
||||
}
|
||||
default:
|
||||
// treat as type when no on/off provided
|
||||
recType = parts[4]
|
||||
}
|
||||
}
|
||||
inputs := map[string]string{
|
||||
"name": name,
|
||||
"content": content,
|
||||
"type": strings.ToUpper(recType),
|
||||
"proxied": strings.ToLower(proxied),
|
||||
}
|
||||
return inputs, nil
|
||||
},
|
||||
Gate: coremodule.Gate{
|
||||
NeedFlag: "enable_module_cf",
|
||||
AllowDryRun: true,
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cf dnsadd(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dnsadd 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cf dnsadd 执行失败: ",
|
||||
ErrHint: "/cf dnsadd <name> <content> [on|off] [type]",
|
||||
},
|
||||
{
|
||||
Prefixes: []string{"/cf dnsset"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cf_dns_set",
|
||||
InputsFn: func(_ string, parts []string) (map[string]string, error) {
|
||||
if len(parts) < 4 {
|
||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dnsset <record_id> <content> [true]"))
|
||||
}
|
||||
proxied := "false"
|
||||
if len(parts) >= 5 && strings.EqualFold(parts[4], "true") {
|
||||
proxied = "true"
|
||||
}
|
||||
return map[string]string{
|
||||
"record_id": parts[2],
|
||||
"content": parts[3],
|
||||
"proxied": strings.ToLower(proxied),
|
||||
}, nil
|
||||
},
|
||||
Gate: coremodule.Gate{
|
||||
NeedFlag: "enable_module_cf",
|
||||
AllowDryRun: true,
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cf dnsset(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dnsset 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cf dnsset 执行失败: ",
|
||||
ErrHint: "/cf dnsset <record_id> <content> [true]",
|
||||
},
|
||||
{
|
||||
Prefixes: []string{"/cf dnsdel"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cf_dns_del",
|
||||
InputsFn: func(_ string, parts []string) (map[string]string, error) {
|
||||
if len(parts) < 4 {
|
||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dnsdel <record_id> YES"))
|
||||
}
|
||||
if len(parts) < 4 || !strings.EqualFold(parts[3], "YES") {
|
||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "缺少确认词 YES,示例:/cf dnsdel <record_id> YES"))
|
||||
}
|
||||
return map[string]string{
|
||||
"record_id": parts[2],
|
||||
}, nil
|
||||
},
|
||||
Gate: coremodule.Gate{
|
||||
NeedFlag: "enable_module_cf",
|
||||
AllowDryRun: false,
|
||||
},
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dnsdel 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cf dnsdel 执行失败: ",
|
||||
ErrHint: "/cf dnsdel <record_id> YES",
|
||||
},
|
||||
{
|
||||
Prefixes: []string{"/cf dnsproxy"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cf_dns_proxy",
|
||||
InputsFn: func(_ string, parts []string) (map[string]string, error) {
|
||||
if len(parts) < 4 {
|
||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dnsproxy <record_id|name> on|off"))
|
||||
}
|
||||
mode := strings.ToLower(parts[3])
|
||||
if mode != "on" && mode != "off" {
|
||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数无效,示例:/cf dnsproxy <record_id|name> on|off"))
|
||||
}
|
||||
proxied := "false"
|
||||
if mode == "on" {
|
||||
proxied = "true"
|
||||
}
|
||||
inputs := map[string]string{
|
||||
"proxied": proxied,
|
||||
"record_id": "__empty__",
|
||||
"name": "__empty__",
|
||||
}
|
||||
target := parts[2]
|
||||
if strings.Contains(target, ".") {
|
||||
inputs["name"] = target
|
||||
} else {
|
||||
inputs["record_id"] = target
|
||||
}
|
||||
return inputs, nil
|
||||
},
|
||||
Gate: coremodule.Gate{
|
||||
NeedFlag: "enable_module_cf",
|
||||
AllowDryRun: true,
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cf dnsproxy(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dnsproxy 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cf dnsproxy 执行失败: ",
|
||||
ErrHint: "/cf dnsproxy <record_id|name> on|off",
|
||||
},
|
||||
{
|
||||
Prefixes: []string{"/cf workers list"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cf_workers_list",
|
||||
Gate: coremodule.Gate{
|
||||
NeedFlag: "enable_module_cf",
|
||||
AllowDryRun: true,
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cf workers list(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf workers list 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cf workers list 执行失败: ",
|
||||
},
|
||||
}
|
||||
}
|
||||
40
internal/module/cf/module.go
Normal file
40
internal/module/cf/module.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package cf
|
||||
|
||||
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 == "/cf" || strings.HasPrefix(text, "/cf help") {
|
||||
return "CF 模块\n- /cf status [--dry-run]\n- /cf zones\n- /cf dns list <zone_id>\n- /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied:true|false]\n- /cf dnsadd <name> <content> [on|off] [type]\n- /cf dnsset <record_id> <content> [true]\n- /cf dnsdel <record_id> YES\n- /cf dnsproxy <record_id|name> on|off\n- /cf workers list", 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, "CF 模块已接入,当前支持:/cf status, /cf help"), nil
|
||||
}
|
||||
62
internal/module/cpa/commands.go
Normal file
62
internal/module/cpa/commands.go
Normal 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
internal/module/cpa/crypto.go
Normal file
16
internal/module/cpa/crypto.go
Normal 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
internal/module/cpa/guards.go
Normal file
13
internal/module/cpa/guards.go
Normal 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
internal/module/cpa/module.go
Normal file
40
internal/module/cpa/module.go
Normal 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
internal/module/mail/commands.go
Normal file
25
internal/module/mail/commands.go
Normal 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
internal/module/mail/module.go
Normal file
40
internal/module/mail/module.go
Normal 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
|
||||
}
|
||||
11
internal/qq/ai_loader.go
Normal file
11
internal/qq/ai_loader.go
Normal 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)
|
||||
}
|
||||
474
internal/qq/qq.go
Normal file
474
internal/qq/qq.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/tencent-connect/botgo/dto/message"
|
||||
"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"
|
||||
)
|
||||
|
||||
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
||||
const DefaultUserID int64 = 1
|
||||
|
||||
type QQBot struct {
|
||||
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, 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(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *QQBot) Start(ctx context.Context) {
|
||||
tokenSource := token.NewQQBotTokenSource(b.credentials)
|
||||
if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil {
|
||||
log.Printf("❌ QQ Bot Token 刷新失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.api = botgo.NewOpenAPI(b.credentials.AppID, tokenSource).WithTimeout(5 * time.Second)
|
||||
|
||||
_ = event.RegisterHandlers(
|
||||
b.groupATMessageHandler(),
|
||||
b.c2cMessageHandler(),
|
||||
b.channelATMessageHandler(),
|
||||
)
|
||||
|
||||
wsInfo, err := b.api.WS(ctx, nil, "")
|
||||
if err != nil {
|
||||
log.Printf("❌ QQ Bot 获取 WS 信息失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
intent := dto.Intent(1<<25 | 1<<30)
|
||||
|
||||
log.Printf("🚀 QQ Bot 已启动 (WebSocket, shards=%d)", wsInfo.Shards)
|
||||
|
||||
if err := botgo.NewSessionManager().Start(wsInfo, tokenSource, &intent); err != nil {
|
||||
log.Printf("❌ QQ Bot WebSocket 断开: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func isCommand(text string, keywords ...string) bool {
|
||||
for _, kw := range keywords {
|
||||
if text == kw {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *QQBot) isDuplicate(eventID string) bool {
|
||||
if b.db == nil || strings.TrimSpace(eventID) == "" {
|
||||
return false
|
||||
}
|
||||
var existed models.MessageDedup
|
||||
if err := b.db.Where("platform = ? AND event_id = ?", "qqbot_official", eventID).First(&existed).Error; err == nil {
|
||||
return true
|
||||
}
|
||||
_ = b.db.Create(&models.MessageDedup{Platform: "qqbot_official", EventID: eventID, ProcessedAt: time.Now()}).Error
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *QQBot) processAndReply(userID string, content string) string {
|
||||
text := strings.TrimSpace(message.ETLInput(content))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 先交给 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 ") {
|
||||
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 推送任务结果失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return func(ev *dto.WSPayload, data *dto.WSATMessageData) error {
|
||||
eventID := "qq:channel:" + strings.TrimSpace(data.ID)
|
||||
if b.isDuplicate(eventID) {
|
||||
return nil
|
||||
}
|
||||
log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.ChannelID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content)))
|
||||
reply := b.processAndReply(data.Author.ID, data.Content)
|
||||
if reply == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := b.api.PostMessage(context.Background(), data.ChannelID, &dto.MessageToCreate{MsgID: data.ID, Content: reply})
|
||||
if err != nil {
|
||||
log.Printf("QQ频道消息发送失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (b *QQBot) groupATMessageHandler() event.GroupATMessageEventHandler {
|
||||
return func(ev *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
||||
eventID := "qq:group:" + strings.TrimSpace(data.ID)
|
||||
if b.isDuplicate(eventID) {
|
||||
return nil
|
||||
}
|
||||
log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.GroupID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content)))
|
||||
reply := b.processAndReply(data.Author.ID, data.Content)
|
||||
if reply == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := b.api.PostGroupMessage(context.Background(), data.GroupID, dto.MessageToCreate{MsgID: data.ID, Content: reply})
|
||||
if err != nil {
|
||||
log.Printf("QQ群消息发送失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (b *QQBot) c2cMessageHandler() event.C2CMessageEventHandler {
|
||||
return func(ev *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
||||
eventID := "qq:c2c:" + strings.TrimSpace(data.ID)
|
||||
if b.isDuplicate(eventID) {
|
||||
return nil
|
||||
}
|
||||
log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.Author.ID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content)))
|
||||
reply := b.processAndReply(data.Author.ID, data.Content)
|
||||
if reply == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := b.api.PostC2CMessage(context.Background(), data.Author.ID, dto.MessageToCreate{MsgID: data.ID, Content: reply})
|
||||
if err != nil {
|
||||
log.Printf("QQ私聊消息发送失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
154
internal/service/finance.go
Normal file
154
internal/service/finance.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"ops-assistant/models"
|
||||
|
||||
"github.com/yanyiwu/gojieba"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type FinanceService struct {
|
||||
db *gorm.DB
|
||||
jieba *gojieba.Jieba
|
||||
}
|
||||
|
||||
func NewFinanceService(db *gorm.DB) *FinanceService {
|
||||
return &FinanceService{
|
||||
db: db,
|
||||
jieba: gojieba.NewJieba(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FinanceService) Close() {
|
||||
s.jieba.Free()
|
||||
}
|
||||
|
||||
// ParseText 从自然语言文本中提取金额(分)和分类
|
||||
func (s *FinanceService) ParseText(text string) (int64, string) {
|
||||
// 1. 提取金额 — 优先匹配带单位的,如 "15.5元"、"¥30"、"20块"
|
||||
amountPatterns := []*regexp.Regexp{
|
||||
regexp.MustCompile(`[¥¥]\s*(\d+\.?\d*)`),
|
||||
regexp.MustCompile(`(\d+\.?\d*)\s*[元块]`),
|
||||
}
|
||||
|
||||
var amountStr string
|
||||
for _, re := range amountPatterns {
|
||||
m := re.FindStringSubmatch(text)
|
||||
if len(m) > 1 {
|
||||
amountStr = m[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:取最后一个独立数字
|
||||
if amountStr == "" {
|
||||
re := regexp.MustCompile(`(\d+\.?\d*)`)
|
||||
matches := re.FindAllStringSubmatch(text, -1)
|
||||
if len(matches) > 0 {
|
||||
amountStr = matches[len(matches)-1][1]
|
||||
}
|
||||
}
|
||||
|
||||
if amountStr == "" {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
amountFloat, err := strconv.ParseFloat(amountStr, 64)
|
||||
if err != nil || amountFloat <= 0 {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// 转为分
|
||||
amountCents := int64(math.Round(amountFloat * 100))
|
||||
|
||||
// 2. 提取分类(Jieba 分词 + 数据库匹配)
|
||||
words := s.jieba.Cut(text, true)
|
||||
category := "其他"
|
||||
|
||||
for _, word := range words {
|
||||
var ck models.CategoryKeyword
|
||||
if err := s.db.Where("keyword = ?", word).First(&ck).Error; err == nil {
|
||||
category = ck.Category
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return amountCents, category
|
||||
}
|
||||
|
||||
// AddTransaction 解析文本并创建一条交易记录
|
||||
func (s *FinanceService) AddTransaction(userID int64, text string) (int64, string, error) {
|
||||
amount, category := s.ParseText(text)
|
||||
if amount == 0 {
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
tx := models.Transaction{
|
||||
UserID: userID,
|
||||
Amount: amount,
|
||||
Category: category,
|
||||
Note: text,
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
}
|
||||
|
||||
return amount, category, s.db.Create(&tx).Error
|
||||
}
|
||||
|
||||
// GetTransactions 获取用户的交易记录
|
||||
func (s *FinanceService) GetTransactions(userID int64, limit int) ([]models.Transaction, error) {
|
||||
var items []models.Transaction
|
||||
err := s.db.Where("user_id = ? AND is_deleted = ?", userID, false).
|
||||
Order("id desc").Limit(limit).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetTransactionsByDate 获取用户指定日期的交易记录
|
||||
func (s *FinanceService) GetTransactionsByDate(userID int64, date string) ([]models.Transaction, error) {
|
||||
var items []models.Transaction
|
||||
err := s.db.Where("user_id = ? AND date = ? AND is_deleted = ?", userID, date, false).
|
||||
Order("id desc").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// CategoryStat 分类统计结果
|
||||
type CategoryStat struct {
|
||||
Category string
|
||||
Total int64
|
||||
Count int
|
||||
}
|
||||
|
||||
// GetCategoryStats 获取用户指定日期范围的分类统计
|
||||
func (s *FinanceService) GetCategoryStats(userID int64, dateFrom, dateTo string) ([]CategoryStat, error) {
|
||||
var stats []CategoryStat
|
||||
err := s.db.Model(&models.Transaction{}).
|
||||
Select("category, SUM(amount) as total, COUNT(*) as count").
|
||||
Where("user_id = ? AND date >= ? AND date <= ? AND is_deleted = ?", userID, dateFrom, dateTo, false).
|
||||
Group("category").
|
||||
Order("total desc").
|
||||
Find(&stats).Error
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// DailyStat 每日统计结果
|
||||
type DailyStat struct {
|
||||
Date string
|
||||
Total int64
|
||||
Count int
|
||||
}
|
||||
|
||||
// GetDailyStats 获取用户指定日期范围的每日统计
|
||||
func (s *FinanceService) GetDailyStats(userID int64, dateFrom, dateTo string) ([]DailyStat, error) {
|
||||
var stats []DailyStat
|
||||
err := s.db.Model(&models.Transaction{}).
|
||||
Select("date, SUM(amount) as total, COUNT(*) as count").
|
||||
Where("user_id = ? AND date >= ? AND date <= ? AND is_deleted = ?", userID, dateFrom, dateTo, false).
|
||||
Group("date").
|
||||
Order("date asc").
|
||||
Find(&stats).Error
|
||||
return stats, err
|
||||
}
|
||||
17
internal/web/apiresp.go
Normal file
17
internal/web/apiresp.go
Normal 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})
|
||||
}
|
||||
2074
internal/web/server.go
Normal file
2074
internal/web/server.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user