feat: xiaji-go v1.0.0 - 智能记账机器人
- Telegram Bot + QQ Bot (WebSocket) 双平台支持 - 150+ 预设分类关键词,jieba 智能分词 - Web 管理后台(记录查看/删除/CSV导出) - 金额精确存储(分/int64) - 版本信息嵌入(编译时注入) - Docker 支持 - 优雅关闭(context + signal)
This commit is contained in:
122
internal/bot/telegram.go
Normal file
122
internal/bot/telegram.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"xiaji-go/internal/service"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
type TGBot struct {
|
||||
api *tgbotapi.BotAPI
|
||||
finance *service.FinanceService
|
||||
}
|
||||
|
||||
func NewTGBot(token string, finance *service.FinanceService) (*TGBot, error) {
|
||||
bot, err := tgbotapi.NewBotAPI(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TGBot{api: bot, finance: finance}, 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
|
||||
}
|
||||
b.handleMessage(update.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
|
||||
text := msg.Text
|
||||
chatID := msg.Chat.ID
|
||||
userID := msg.From.ID
|
||||
|
||||
var reply string
|
||||
|
||||
switch {
|
||||
case text == "/start":
|
||||
reply = "🦞 欢迎使用虾记记账!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\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/start - 欢迎信息"
|
||||
|
||||
case text == "/today":
|
||||
today := time.Now().Format("2006-01-02")
|
||||
items, err := b.finance.GetTransactionsByDate(userID, 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 == "/list":
|
||||
items, err := b.finance.GetTransactions(userID, 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(userID, text)
|
||||
if err != nil {
|
||||
reply = "❌ 记账失败,请稍后重试"
|
||||
log.Printf("记账失败 user=%d: %v", userID, 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)
|
||||
}
|
||||
}
|
||||
212
internal/qq/qq.go
Normal file
212
internal/qq/qq.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"xiaji-go/internal/service"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type QQBot struct {
|
||||
api openapi.OpenAPI
|
||||
finance *service.FinanceService
|
||||
credentials *token.QQBotCredentials
|
||||
}
|
||||
|
||||
func NewQQBot(appID string, secret string, finance *service.FinanceService) *QQBot {
|
||||
return &QQBot{
|
||||
finance: finance,
|
||||
credentials: &token.QQBotCredentials{
|
||||
AppID: appID,
|
||||
AppSecret: secret,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// hashUserID 将 QQ 的字符串用户标识转为 int64
|
||||
func hashUserID(authorID string) int64 {
|
||||
h := fnv.New64a()
|
||||
h.Write([]byte(authorID))
|
||||
return int64(h.Sum64())
|
||||
}
|
||||
|
||||
func (b *QQBot) Start(ctx context.Context) {
|
||||
// 创建 token source 并启动自动刷新
|
||||
tokenSource := token.NewQQBotTokenSource(b.credentials)
|
||||
if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil {
|
||||
log.Printf("❌ QQ Bot Token 刷新失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化 OpenAPI
|
||||
b.api = botgo.NewOpenAPI(b.credentials.AppID, tokenSource).WithTimeout(5 * time.Second)
|
||||
|
||||
// 注册事件处理器
|
||||
_ = event.RegisterHandlers(
|
||||
b.groupATMessageHandler(),
|
||||
b.c2cMessageHandler(),
|
||||
b.channelATMessageHandler(),
|
||||
)
|
||||
|
||||
// 获取 WebSocket 接入信息
|
||||
wsInfo, err := b.api.WS(ctx, nil, "")
|
||||
if err != nil {
|
||||
log.Printf("❌ QQ Bot 获取 WS 信息失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置 intents: 群聊和C2C (1<<25) + 公域消息 (1<<30)
|
||||
intent := dto.Intent(1<<25 | 1<<30)
|
||||
|
||||
log.Printf("🚀 QQ Bot 已启动 (WebSocket, shards=%d)", wsInfo.Shards)
|
||||
|
||||
// 启动 session manager (阻塞)
|
||||
if err := botgo.NewSessionManager().Start(wsInfo, tokenSource, &intent); err != nil {
|
||||
log.Printf("❌ QQ Bot WebSocket 断开: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// isCommand 判断是否匹配命令关键词
|
||||
func isCommand(text string, keywords ...string) bool {
|
||||
for _, kw := range keywords {
|
||||
if text == kw {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// processAndReply 通用记账处理
|
||||
func (b *QQBot) processAndReply(userID string, content string) string {
|
||||
uid := hashUserID(userID)
|
||||
text := strings.TrimSpace(message.ETLInput(content))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
// 命令处理
|
||||
switch {
|
||||
case isCommand(text, "帮助", "help", "/help", "/start", "菜单", "功能"):
|
||||
return "🦞 虾记记账\n\n" +
|
||||
"直接发送消费描述即可记账:\n" +
|
||||
"• 午饭 25元\n" +
|
||||
"• 打车 ¥30\n" +
|
||||
"• 买咖啡15块\n\n" +
|
||||
"📋 命令列表:\n" +
|
||||
"• 记录/查看 — 最近10条\n" +
|
||||
"• 今日/今天 — 今日汇总\n" +
|
||||
"• 帮助 — 本帮助信息"
|
||||
|
||||
case isCommand(text, "查看", "记录", "列表", "list", "/list", "最近"):
|
||||
items, err := b.finance.GetTransactions(uid, 10)
|
||||
if err != nil {
|
||||
return "❌ 查询失败"
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "📭 暂无记录"
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📋 最近记录:\n\n")
|
||||
for _, item := range items {
|
||||
sb.WriteString(fmt.Sprintf("• [%s] %s:%.2f元\n", item.Date, item.Category, item.AmountYuan()))
|
||||
}
|
||||
return sb.String()
|
||||
|
||||
case isCommand(text, "今日", "今天", "today"):
|
||||
items, err := b.finance.GetTransactionsByDate(uid, today)
|
||||
if err != nil {
|
||||
return "❌ 查询失败"
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Sprintf("📭 %s 暂无消费记录", today)
|
||||
}
|
||||
var sb strings.Builder
|
||||
var total int64
|
||||
sb.WriteString(fmt.Sprintf("📊 今日(%s)消费:\n\n", today))
|
||||
for _, item := range items {
|
||||
sb.WriteString(fmt.Sprintf("• %s:%.2f元\n", item.Category, item.AmountYuan()))
|
||||
total += item.Amount
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", len(items), float64(total)/100.0))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
amount, category, err := b.finance.AddTransaction(uid, text)
|
||||
if err != nil {
|
||||
log.Printf("QQ记账失败 user=%s: %v", userID, err)
|
||||
return "❌ 记账失败,请稍后重试"
|
||||
}
|
||||
if amount == 0 {
|
||||
return "📍 没看到金额,这笔花了多少钱?"
|
||||
}
|
||||
|
||||
amountYuan := float64(amount) / 100.0
|
||||
return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, amountYuan, text)
|
||||
}
|
||||
|
||||
// channelATMessageHandler 频道@机器人消息
|
||||
func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler {
|
||||
return func(ev *dto.WSPayload, data *dto.WSATMessageData) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// groupATMessageHandler 群@机器人消息
|
||||
func (b *QQBot) groupATMessageHandler() event.GroupATMessageEventHandler {
|
||||
return func(ev *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// c2cMessageHandler C2C 私聊消息
|
||||
func (b *QQBot) c2cMessageHandler() event.C2CMessageEventHandler {
|
||||
return func(ev *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
116
internal/service/finance.go
Normal file
116
internal/service/finance.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"xiaji-go/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
|
||||
}
|
||||
116
internal/web/server.go
Normal file
116
internal/web/server.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"xiaji-go/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WebServer struct {
|
||||
db *gorm.DB
|
||||
port int
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func NewWebServer(db *gorm.DB, port int, username, password string) *WebServer {
|
||||
return &WebServer{db: db, port: port, username: username, password: password}
|
||||
}
|
||||
|
||||
func (s *WebServer) Start() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
r.LoadHTMLGlob("templates/*")
|
||||
|
||||
// 页面
|
||||
r.GET("/", s.handleIndex)
|
||||
r.GET("/api/records", s.handleRecords)
|
||||
r.POST("/delete/:id", s.handleDelete)
|
||||
r.GET("/export", s.handleExport)
|
||||
|
||||
// 健康检查
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
logAddr := fmt.Sprintf(":%d", s.port)
|
||||
fmt.Printf("🌐 Web后台运行在 http://127.0.0.1%s\n", logAddr)
|
||||
if err := r.Run(logAddr); err != nil {
|
||||
fmt.Printf("❌ Web服务启动失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebServer) handleIndex(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", nil)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleRecords(c *gin.Context) {
|
||||
var items []models.Transaction
|
||||
s.db.Where("is_deleted = ?", false).Order("id desc").Limit(50).Find(&items)
|
||||
|
||||
type txResponse struct {
|
||||
ID uint `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
Category string `json:"category"`
|
||||
Note string `json:"note"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
resp := make([]txResponse, len(items))
|
||||
for i, item := range items {
|
||||
resp[i] = txResponse{
|
||||
ID: item.ID,
|
||||
UserID: item.UserID,
|
||||
Amount: item.AmountYuan(),
|
||||
Category: item.Category,
|
||||
Note: item.Note,
|
||||
Date: item.Date,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleDelete(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
result := s.db.Model(&models.Transaction{}).Where("id = ? AND is_deleted = ?", id, false).Update("is_deleted", true)
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在或已删除"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleExport(c *gin.Context) {
|
||||
var items []models.Transaction
|
||||
s.db.Where("is_deleted = ?", false).Order("date asc, id asc").Find(&items)
|
||||
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename=transactions.csv")
|
||||
|
||||
// BOM for Excel
|
||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
c.Writer.WriteString("ID,日期,分类,金额(元),备注\n")
|
||||
|
||||
for _, item := range items {
|
||||
line := fmt.Sprintf("%d,%s,%s,%.2f,\"%s\"\n", item.ID, item.Date, item.Category, item.AmountYuan(), item.Note)
|
||||
c.Writer.WriteString(line)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user