refactor: remove legacy finance module
This commit is contained in:
+4
-8
@@ -16,7 +16,6 @@ import (
|
|||||||
"ops-assistant/internal/core/runbook"
|
"ops-assistant/internal/core/runbook"
|
||||||
"ops-assistant/internal/feishu"
|
"ops-assistant/internal/feishu"
|
||||||
"ops-assistant/internal/qq"
|
"ops-assistant/internal/qq"
|
||||||
"ops-assistant/internal/service"
|
|
||||||
"ops-assistant/internal/web"
|
"ops-assistant/internal/web"
|
||||||
"ops-assistant/models"
|
"ops-assistant/models"
|
||||||
"ops-assistant/version"
|
"ops-assistant/version"
|
||||||
@@ -66,9 +65,6 @@ func main() {
|
|||||||
log.Printf("⚠️ 渠道配置加载失败,继续使用 YAML: %v", err)
|
log.Printf("⚠️ 渠道配置加载失败,继续使用 YAML: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
finance := service.NewFinanceService(db)
|
|
||||||
defer finance.Close()
|
|
||||||
|
|
||||||
if err := runbook.SeedDefaultTargets(db); err != nil {
|
if err := runbook.SeedDefaultTargets(db); err != nil {
|
||||||
log.Printf("⚠️ 初始化ops targets失败: %v", err)
|
log.Printf("⚠️ 初始化ops targets失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -79,7 +75,7 @@ func main() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if cfg.Telegram.Enabled {
|
if cfg.Telegram.Enabled {
|
||||||
tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, finance, opsSvc)
|
tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, opsSvc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ TG Bot 启动失败: %v", err)
|
log.Printf("⚠️ TG Bot 启动失败: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -88,7 +84,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.QQBot.Enabled {
|
if cfg.QQBot.Enabled {
|
||||||
qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, finance, opsSvc)
|
qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, opsSvc)
|
||||||
go qqBot.Start(ctx)
|
go qqBot.Start(ctx)
|
||||||
go qqBot.StartSubScheduler(ctx, 9, 0)
|
go qqBot.StartSubScheduler(ctx, 9, 0)
|
||||||
}
|
}
|
||||||
@@ -104,11 +100,11 @@ func main() {
|
|||||||
return fmt.Sprintf("reload ok: tg=%v qq=%v feishu=%v", cfg.Telegram.Enabled, cfg.QQBot.Enabled, cfg.Feishu.Enabled), nil
|
return fmt.Sprintf("reload ok: tg=%v qq=%v feishu=%v", cfg.Telegram.Enabled, cfg.QQBot.Enabled, cfg.Feishu.Enabled), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
webServer := web.NewWebServer(db, cfg.Database.Path, ".", finance, cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password, cfg.Server.Key, reloadFn)
|
webServer := web.NewWebServer(db, cfg.Database.Path, ".", cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password, cfg.Server.Key, reloadFn)
|
||||||
webServer.RegisterRoutes(engine)
|
webServer.RegisterRoutes(engine)
|
||||||
|
|
||||||
if cfg.Feishu.Enabled {
|
if cfg.Feishu.Enabled {
|
||||||
fsBot := feishu.NewBot(db, finance, opsSvc, cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.VerificationToken, cfg.Feishu.EncryptKey)
|
fsBot := feishu.NewBot(db, opsSvc, cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.VerificationToken, cfg.Feishu.EncryptKey)
|
||||||
fsBot.RegisterRoutes(engine)
|
fsBot.RegisterRoutes(engine)
|
||||||
go fsBot.Start(ctx)
|
go fsBot.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-142
@@ -7,31 +7,28 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
xchart "ops-assistant/internal/chart"
|
|
||||||
"ops-assistant/internal/core/ops"
|
"ops-assistant/internal/core/ops"
|
||||||
"ops-assistant/internal/service"
|
|
||||||
"ops-assistant/models"
|
"ops-assistant/models"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
// DefaultUserID 统一用户ID
|
||||||
const DefaultUserID int64 = 1
|
const DefaultUserID int64 = 1
|
||||||
|
|
||||||
type TGBot struct {
|
type TGBot struct {
|
||||||
api *tgbotapi.BotAPI
|
api *tgbotapi.BotAPI
|
||||||
finance *service.FinanceService
|
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
opsSvc *ops.Service
|
opsSvc *ops.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTGBot(db *gorm.DB, token string, finance *service.FinanceService, opsSvc *ops.Service) (*TGBot, error) {
|
func NewTGBot(db *gorm.DB, token string, opsSvc *ops.Service) (*TGBot, error) {
|
||||||
bot, err := tgbotapi.NewBotAPI(token)
|
bot, err := tgbotapi.NewBotAPI(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &TGBot{api: bot, finance: finance, db: db, opsSvc: opsSvc}, nil
|
return &TGBot{api: bot, db: db, opsSvc: opsSvc}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *TGBot) Start(ctx context.Context) {
|
func (b *TGBot) Start(ctx context.Context) {
|
||||||
@@ -91,71 +88,14 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var reply string
|
reply := ""
|
||||||
|
switch text {
|
||||||
switch {
|
case "/start":
|
||||||
case text == "/start":
|
reply = "🛠️ 欢迎使用 Ops-Assistant!\n\n模块化运维命令助手,支持:\n• /cpa - CPA 管理\n• /cf - Cloudflare 管理\n• /cfdav - cfdav 管理\n• /sub - 订阅管理\n• /mail - 邮箱管理\n\n/help - 使用说明"
|
||||||
reply = "🛠️ 欢迎使用 Ops-Assistant!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\n/today - 今日汇总\n/chart - 本月图表\n/help - 帮助"
|
case "/help":
|
||||||
|
reply = "📖 使用说明:\n\n直接发送 OPS 命令即可,例如:\n• /cpa status\n• /cf zones\n• /cf dns list <zone_id>\n• /cfdav users\n• /sub list\n\n模块入口默认关闭,可在 Web 后台「模块设置」中启用。"
|
||||||
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:
|
default:
|
||||||
amount, category, err := b.finance.AddTransaction(DefaultUserID, text)
|
reply = "❓ 无法识别,输入 /help 查看支持的命令"
|
||||||
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)
|
m := tgbotapi.NewMessage(chatID, reply)
|
||||||
@@ -163,75 +103,3 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
|
|||||||
log.Printf("发送消息失败 chat=%d: %v", chatID, err)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"ops-assistant/internal/module/cfdav"
|
"ops-assistant/internal/module/cfdav"
|
||||||
"ops-assistant/internal/module/cpa"
|
"ops-assistant/internal/module/cpa"
|
||||||
"ops-assistant/internal/module/mail"
|
"ops-assistant/internal/module/mail"
|
||||||
|
"ops-assistant/internal/module/rate"
|
||||||
"ops-assistant/internal/module/sub"
|
"ops-assistant/internal/module/sub"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -20,12 +21,14 @@ func BuildDefault(db *gorm.DB, dbPath, baseDir string) *Service {
|
|||||||
cpaModule := cpa.New(db, exec)
|
cpaModule := cpa.New(db, exec)
|
||||||
cfModule := cf.New(db, exec)
|
cfModule := cf.New(db, exec)
|
||||||
mailModule := mail.New(db, exec)
|
mailModule := mail.New(db, exec)
|
||||||
|
rateModule := rate.New()
|
||||||
cfdavModule := cfdav.New(db)
|
cfdavModule := cfdav.New(db)
|
||||||
subModule := sub.New(db)
|
subModule := sub.New(db)
|
||||||
|
|
||||||
r.RegisterModule("cpa", cpaModule.Handle)
|
r.RegisterModule("cpa", cpaModule.Handle)
|
||||||
r.RegisterModule("cf", cfModule.Handle)
|
r.RegisterModule("cf", cfModule.Handle)
|
||||||
r.RegisterModule("mail", mailModule.Handle)
|
r.RegisterModule("mail", mailModule.Handle)
|
||||||
|
r.RegisterModule("rate", rateModule.Handle)
|
||||||
r.RegisterModule("cfdav", cfdavModule.Handle)
|
r.RegisterModule("cfdav", cfdavModule.Handle)
|
||||||
r.RegisterModule("sub", subModule.Handle)
|
r.RegisterModule("sub", subModule.Handle)
|
||||||
return NewService(dbPath, baseDir, r)
|
return NewService(dbPath, baseDir, r)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func (s *Service) Handle(userID int64, text string) (bool, string) {
|
|||||||
if cmd.Module == "ops" && len(cmd.Args) > 0 && cmd.Args[0] == "modules" {
|
if cmd.Module == "ops" && len(cmd.Args) > 0 && cmd.Args[0] == "modules" {
|
||||||
return true, s.modulesStatusText()
|
return true, s.modulesStatusText()
|
||||||
}
|
}
|
||||||
if cmd.Module != "" && cmd.Module != "ops" && s.db != nil {
|
if cmd.Module != "" && cmd.Module != "ops" && cmd.Module != "rate" && s.db != nil {
|
||||||
if !coremodule.IsEnabled(s.db, cmd.Module) {
|
if !coremodule.IsEnabled(s.db, cmd.Module) {
|
||||||
return true, fmt.Sprintf("[ERR_FEATURE_DISABLED] 模块未启用: %s(开关: enable_module_%s)", cmd.Module, cmd.Module)
|
return true, fmt.Sprintf("[ERR_FEATURE_DISABLED] 模块未启用: %s(开关: enable_module_%s)", cmd.Module, cmd.Module)
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,7 @@ func (s *Service) helpText() string {
|
|||||||
"- /mail (邮箱状态)",
|
"- /mail (邮箱状态)",
|
||||||
"- /cfdav (CFDAV 用户管理)",
|
"- /cfdav (CFDAV 用户管理)",
|
||||||
"- /sub (订阅管理)",
|
"- /sub (订阅管理)",
|
||||||
|
"- /rate 138sgd to cny (实时汇率转换)",
|
||||||
"提示:发送 /cpa /cf /mail /cfdav /sub 获取各模块完整指令清单。",
|
"提示:发送 /cpa /cf /mail /cfdav /sub 获取各模块完整指令清单。",
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
|
|||||||
+11
-37
@@ -2,28 +2,24 @@ package feishu
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ops-assistant/internal/channel"
|
"ops-assistant/internal/channel"
|
||||||
"ops-assistant/internal/core/ops"
|
"ops-assistant/internal/core/ops"
|
||||||
"ops-assistant/internal/service"
|
|
||||||
"ops-assistant/models"
|
"ops-assistant/models"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
// DefaultUserID 统一用户ID
|
||||||
const DefaultUserID int64 = 1
|
const DefaultUserID int64 = 1
|
||||||
|
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
finance *service.FinanceService
|
|
||||||
opsSvc *ops.Service
|
opsSvc *ops.Service
|
||||||
appID string
|
appID string
|
||||||
appSecret string
|
appSecret string
|
||||||
@@ -31,10 +27,9 @@ type Bot struct {
|
|||||||
encryptKey string
|
encryptKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBot(db *gorm.DB, finance *service.FinanceService, opsSvc *ops.Service, appID, appSecret, verificationToken, encryptKey string) *Bot {
|
func NewBot(db *gorm.DB, opsSvc *ops.Service, appID, appSecret, verificationToken, encryptKey string) *Bot {
|
||||||
return &Bot{
|
return &Bot{
|
||||||
db: db,
|
db: db,
|
||||||
finance: finance,
|
|
||||||
opsSvc: opsSvc,
|
opsSvc: opsSvc,
|
||||||
appID: appID,
|
appID: appID,
|
||||||
appSecret: appSecret,
|
appSecret: appSecret,
|
||||||
@@ -56,29 +51,29 @@ func (b *Bot) RegisterRoutes(r *gin.Engine) {
|
|||||||
func (b *Bot) handleWebhook(c *gin.Context) {
|
func (b *Bot) handleWebhook(c *gin.Context) {
|
||||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
c.JSON(400, gin.H{"error": "bad request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一走 channel 包解析,便于后续扩展验签/解密
|
// 统一走 channel 包解析,便于后续扩展验签/解密
|
||||||
msg, verifyChallenge, err := channel.ParseFeishuInbound(body, b.verificationToken)
|
msg, verifyChallenge, err := channel.ParseFeishuInbound(body, b.verificationToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if verifyChallenge != "" {
|
if verifyChallenge != "" {
|
||||||
c.JSON(http.StatusOK, gin.H{"challenge": verifyChallenge})
|
c.JSON(200, gin.H{"challenge": verifyChallenge})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if msg == nil {
|
if msg == nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"code": 0})
|
c.JSON(200, gin.H{"code": 0})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 幂等去重
|
// 幂等去重
|
||||||
var existed models.MessageDedup
|
var existed models.MessageDedup
|
||||||
if err := b.db.Where("platform = ? AND event_id = ?", "feishu", msg.EventID).First(&existed).Error; err == nil {
|
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})
|
c.JSON(200, gin.H{"code": 0})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = b.db.Create(&models.MessageDedup{Platform: "feishu", EventID: msg.EventID, ProcessedAt: time.Now()}).Error
|
_ = b.db.Create(&models.MessageDedup{Platform: "feishu", EventID: msg.EventID, ProcessedAt: time.Now()}).Error
|
||||||
@@ -91,7 +86,7 @@ func (b *Bot) handleWebhook(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"code": 0})
|
c.JSON(200, gin.H{"code": 0})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) handleText(text string) string {
|
func (b *Bot) handleText(text string) string {
|
||||||
@@ -103,31 +98,10 @@ func (b *Bot) handleText(text string) string {
|
|||||||
}
|
}
|
||||||
switch trim {
|
switch trim {
|
||||||
case "帮助", "help", "/help", "菜单", "功能", "/start":
|
case "帮助", "help", "/help", "菜单", "功能", "/start":
|
||||||
return "🛠️ Ops-Assistant\n\n直接发送消费描述即可记账:\n• 午饭 25元\n• 打车 ¥30\n\n📋 命令:记录/查看、今日/今天、统计"
|
return "🛠️ Ops-Assistant\n\n模块化运维命令助手,支持:\n• /cpa - CPA 管理\n• /cf - Cloudflare 管理\n• /cfdav - cfdav 管理\n• /sub - 订阅管理\n• /mail - 邮箱管理"
|
||||||
case "查看", "记录", "列表", "最近":
|
default:
|
||||||
items, err := b.finance.GetTransactions(DefaultUserID, 10)
|
return "❓ 无法识别,请使用 OPS 命令(发送「帮助」查看说明)。"
|
||||||
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 {
|
func maskID(s string) string {
|
||||||
|
|||||||
+2
-5
@@ -11,7 +11,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ops-assistant/internal/core/ops"
|
"ops-assistant/internal/core/ops"
|
||||||
"ops-assistant/internal/service"
|
|
||||||
|
|
||||||
"ops-assistant/internal/core/ai"
|
"ops-assistant/internal/core/ai"
|
||||||
"ops-assistant/models"
|
"ops-assistant/models"
|
||||||
@@ -26,12 +25,11 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
// DefaultUserID 统一用户ID
|
||||||
const DefaultUserID int64 = 1
|
const DefaultUserID int64 = 1
|
||||||
|
|
||||||
type QQBot struct {
|
type QQBot struct {
|
||||||
api openapi.OpenAPI
|
api openapi.OpenAPI
|
||||||
finance *service.FinanceService
|
|
||||||
credentials *token.QQBotCredentials
|
credentials *token.QQBotCredentials
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
opsSvc *ops.Service
|
opsSvc *ops.Service
|
||||||
@@ -39,10 +37,9 @@ type QQBot struct {
|
|||||||
aiAutoReload time.Time
|
aiAutoReload time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService, opsSvc *ops.Service) *QQBot {
|
func NewQQBot(db *gorm.DB, appID string, secret string, opsSvc *ops.Service) *QQBot {
|
||||||
return &QQBot{
|
return &QQBot{
|
||||||
db: db,
|
db: db,
|
||||||
finance: finance,
|
|
||||||
opsSvc: opsSvc,
|
opsSvc: opsSvc,
|
||||||
credentials: &token.QQBotCredentials{
|
credentials: &token.QQBotCredentials{
|
||||||
AppID: appID,
|
AppID: appID,
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ import (
|
|||||||
"ops-assistant/internal/core/ecode"
|
"ops-assistant/internal/core/ecode"
|
||||||
"ops-assistant/internal/core/ops"
|
"ops-assistant/internal/core/ops"
|
||||||
"ops-assistant/internal/core/runbook"
|
"ops-assistant/internal/core/runbook"
|
||||||
"ops-assistant/internal/service"
|
|
||||||
"ops-assistant/models"
|
"ops-assistant/models"
|
||||||
"ops-assistant/version"
|
"ops-assistant/version"
|
||||||
|
|
||||||
@@ -40,7 +39,6 @@ type WebServer struct {
|
|||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
dbPath string
|
dbPath string
|
||||||
baseDir string
|
baseDir string
|
||||||
finance *service.FinanceService
|
|
||||||
port int
|
port int
|
||||||
username string
|
username string
|
||||||
password string
|
password string
|
||||||
@@ -161,12 +159,11 @@ var rolePermissions = map[string][]string{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebServer(db *gorm.DB, dbPath, baseDir string, finance *service.FinanceService, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer {
|
func NewWebServer(db *gorm.DB, dbPath, baseDir string, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer {
|
||||||
return &WebServer{
|
return &WebServer{
|
||||||
db: db,
|
db: db,
|
||||||
dbPath: dbPath,
|
dbPath: dbPath,
|
||||||
baseDir: baseDir,
|
baseDir: baseDir,
|
||||||
finance: finance,
|
|
||||||
port: port,
|
port: port,
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
|
|||||||
Reference in New Issue
Block a user