From 0c1a4f06f7a47118181e3cb2f2f9eb5d58d61111 Mon Sep 17 00:00:00 2001 From: openclaw Date: Sun, 15 Feb 2026 06:40:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20xiaji-go=20v1.0.0=20-=20=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E8=AE=B0=E8=B4=A6=E6=9C=BA=E5=99=A8=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Telegram Bot + QQ Bot (WebSocket) 双平台支持 - 150+ 预设分类关键词,jieba 智能分词 - Web 管理后台(记录查看/删除/CSV导出) - 金额精确存储(分/int64) - 版本信息嵌入(编译时注入) - Docker 支持 - 优雅关闭(context + signal) --- .dockerignore | 7 ++ .gitignore | 22 ++++ Dockerfile | 52 ++++++++ Makefile | 51 ++++++++ README.md | 175 ++++++++++++++++++++++++++ cmd/main.go | 102 ++++++++++++++++ config.yaml.example | 19 +++ config/config.go | 75 ++++++++++++ docker-compose.yml | 14 +++ go.mod | 49 ++++++++ go.sum | 236 ++++++++++++++++++++++++++++++++++++ internal/bot/telegram.go | 122 +++++++++++++++++++ internal/qq/qq.go | 212 ++++++++++++++++++++++++++++++++ internal/service/finance.go | 116 ++++++++++++++++++ internal/web/server.go | 116 ++++++++++++++++++ models/models.go | 136 +++++++++++++++++++++ templates/index.html | 193 +++++++++++++++++++++++++++++ version/version.go | 22 ++++ 18 files changed, 1719 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/main.go create mode 100644 config.yaml.example create mode 100644 config/config.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/bot/telegram.go create mode 100644 internal/qq/qq.go create mode 100644 internal/service/finance.go create mode 100644 internal/web/server.go create mode 100644 models/models.go create mode 100644 templates/index.html create mode 100644 version/version.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a453ead --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +*.db +config.yaml +xiaji-go +.git +.gitignore +README.md +docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f96c69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# 二进制文件 +xiaji-go +xiaji-go-linux-amd64 + +# 数据库 +*.db + +# 配置文件(含敏感信息) +config.yaml + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Go +vendor/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3aeb55a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# ============ 构建阶段 ============ +FROM golang:1.22-bookworm AS builder + +ARG VERSION=dev +ARG GIT_COMMIT=unknown +ARG BUILD_TIME=unknown + +WORKDIR /build + +# 安装 gojieba 依赖 +RUN apt-get update && apt-get install -y gcc g++ cmake && rm -rf /var/lib/apt/lists/* + +# 先复制依赖文件,利用缓存 +COPY go.mod go.sum ./ +RUN go mod download + +# 复制源码 +COPY . . + +# 编译 +RUN CGO_ENABLED=1 go build \ + -ldflags "-X xiaji-go/version.Version=${VERSION} \ + -X xiaji-go/version.GitCommit=${GIT_COMMIT} \ + -X xiaji-go/version.BuildTime=${BUILD_TIME}" \ + -o xiaji-go cmd/main.go + +# ============ 运行阶段 ============ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y ca-certificates tzdata && rm -rf /var/lib/apt/lists/* + +ENV TZ=Asia/Shanghai + +WORKDIR /app + +# 从构建阶段复制 +COPY --from=builder /build/xiaji-go . +COPY --from=builder /build/templates/ ./templates/ +COPY --from=builder /build/config.yaml.example ./config.yaml.example + +# gojieba 词典文件 +COPY --from=builder /root/go/pkg/mod/github.com/yanyiwu/gojieba@v1.3.0/dict/ /app/dict/ + +# 数据目录 +VOLUME ["/app/data"] + +EXPOSE 9521 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:9521/health || exit 1 + +ENTRYPOINT ["./xiaji-go"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..37a0934 --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +APP_NAME := xiaji-go +VERSION := 1.0.0 +GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') +LDFLAGS := -X xiaji-go/version.Version=$(VERSION) \ + -X xiaji-go/version.GitCommit=$(GIT_COMMIT) \ + -X xiaji-go/version.BuildTime=$(BUILD_TIME) + +DOCKER_IMAGE := ouaone/xiaji-go +DOCKER_TAG := $(VERSION) + +.PHONY: build clean run docker docker-push release help + +## build: 编译二进制文件 +build: + go build -ldflags "$(LDFLAGS)" -o $(APP_NAME) cmd/main.go + +## build-linux: 交叉编译 Linux amd64 +build-linux: + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(APP_NAME)-linux-amd64 cmd/main.go + +## clean: 清理编译产物 +clean: + rm -f $(APP_NAME) $(APP_NAME)-linux-amd64 + +## run: 编译并运行 +run: build + ./$(APP_NAME) + +## docker: 构建 Docker 镜像 +docker: + docker build -t $(DOCKER_IMAGE):$(DOCKER_TAG) -t $(DOCKER_IMAGE):latest \ + --build-arg VERSION=$(VERSION) \ + --build-arg GIT_COMMIT=$(GIT_COMMIT) \ + --build-arg BUILD_TIME=$(BUILD_TIME) . + +## docker-push: 推送 Docker 镜像 +docker-push: + docker push $(DOCKER_IMAGE):$(DOCKER_TAG) + docker push $(DOCKER_IMAGE):latest + +## version: 显示版本信息 +version: + @echo "$(APP_NAME) v$(VERSION) ($(GIT_COMMIT))" + +## help: 显示帮助 +help: + @echo "$(APP_NAME) v$(VERSION)" + @echo "" + @echo "可用目标:" + @grep -E '^## ' Makefile | sed 's/^## / /' diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d2328e --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +# 🦞 虾记 Xiaji-Go + +一个支持 **Telegram Bot** 和 **QQ Bot** 的智能记账机器人,带 Web 管理后台。 + +## ✨ 功能特性 + +- **多平台支持**:同时接入 Telegram Bot 和 QQ Bot(WebSocket 模式) +- **智能记账**:发送自然语言自动识别金额和消费分类(基于 jieba 分词) +- **150+ 预设分类关键词**:餐饮、交通、购物、饮品、水果、住房、医疗、娱乐等 +- **Web 管理后台**:响应式设计,支持记录查看、删除、CSV 导出 +- **金额精确**:使用分(int64)存储,避免浮点精度问题 +- **优雅关闭**:支持 SIGTERM/SIGINT 信号,干净退出 + +## 📦 快速开始 + +### 二进制运行 + +```bash +# 1. 复制配置文件 +cp config.yaml.example config.yaml + +# 2. 编辑配置(填入 Bot Token 等信息) +vi config.yaml + +# 3. 运行 +./xiaji-go +``` + +### Docker 运行 + +```bash +# 1. 准备配置文件 +cp config.yaml.example config.yaml +vi config.yaml + +# 2. 使用 docker-compose +docker-compose up -d + +# 或直接 docker run +docker run -d \ + --name xiaji-go \ + -p 9521:9521 \ + -v $(pwd)/config.yaml:/app/config.yaml:ro \ + -v $(pwd)/data:/app/data \ + ouaone/xiaji-go:latest +``` + +### 从源码编译 + +```bash +# 需要 Go 1.22+、GCC(gojieba 依赖 CGO) +make build + +# 交叉编译 Linux +make build-linux +``` + +## ⚙️ 配置说明 + +```yaml +server: + port: 9521 # Web 后台端口 + key: "your-secret-key" # 会话密钥 + +database: + path: "./xiaji.db" # SQLite 数据库路径 + +admin: + username: "admin" # Web 后台用户名 + password: "your_password" # Web 后台密码 + +telegram: + enabled: true # 是否启用 Telegram Bot + token: "YOUR_BOT_TOKEN" # BotFather 获取的 Token + +qqbot: + enabled: false # 是否启用 QQ Bot + appid: "YOUR_APPID" # QQ 开放平台 AppID + secret: "YOUR_SECRET" # QQ 开放平台 AppSecret +``` + +## 💬 使用方式 + +### Telegram Bot 命令 + +| 命令 | 说明 | +|------|------| +| `/start` | 欢迎信息 | +| `/help` | 使用帮助 | +| `/list` | 最近 10 条记录 | +| `/today` | 今日消费汇总 | +| 直接发消息 | 自动记账(如"午饭 25元") | + +### QQ Bot 关键词 + +| 关键词 | 说明 | +|--------|------| +| `帮助` / `菜单` / `功能` | 显示帮助 | +| `查看` / `记录` / `列表` | 最近 10 条记录 | +| `今日` / `今天` | 今日消费汇总 | +| 直接发消息 | 自动记账 | + +### 记账格式 + +支持多种自然语言格式: + +``` +午饭 25元 +打车 ¥30 +买咖啡15块 +车费10元 +超市买水果38.5 +``` + +### 消费分类 + +系统预设了 150+ 个关键词,自动匹配以下分类: + +| 分类 | 示例关键词 | +|------|-----------| +| 🍜 餐饮 | 早饭、午饭、晚饭、外卖、火锅、面条 | +| 🚗 交通 | 打车、车费、地铁、公交、加油 | +| 🛒 购物 | 买、淘宝、超市、衣服、手机 | +| ☕ 饮品 | 咖啡、奶茶、星巴克、可乐 | +| 🍎 水果 | 水果、苹果、香蕉、西瓜 | +| 🍪 零食 | 零食、面包、蛋糕、甜品 | +| 🏠 住房 | 房租、水电、物业、宽带 | +| 📱 通讯 | 话费、流量、充值 | +| 💊 医疗 | 看病、药、医院、挂号 | +| 🎮 娱乐 | 电影、游戏、健身、旅游 | +| 📚 教育 | 书、课、培训、学费 | +| 🚬 烟酒 | 烟、白酒、红酒 | + +## 🌐 Web 管理后台 + +访问 `http://localhost:9521`: + +- 📊 今日支出 / 本月支出 / 总记录数统计 +- 📋 最近 50 条记录列表 +- 🔍 按分类筛选 +- 🗑️ 删除记录 +- 📥 CSV 导出(Excel 兼容 BOM 编码) + +## 🏗️ 项目结构 + +``` +xiaji-go/ +├── cmd/main.go # 入口文件 +├── config/config.go # 配置加载与验证 +├── version/version.go # 版本信息 +├── models/models.go # 数据模型与分类关键词 +├── internal/ +│ ├── bot/telegram.go # Telegram Bot +│ ├── qq/qq.go # QQ Bot (WebSocket) +│ ├── service/finance.go # 记账核心服务(jieba 分词) +│ └── web/server.go # Web 管理后台 +├── templates/index.html # Web 前端页面 +├── config.yaml.example # 配置示例 +├── Dockerfile # Docker 镜像 +├── docker-compose.yml # Docker Compose +└── Makefile # 构建脚本 +``` + +## 📋 技术栈 + +- **语言**: Go 1.22 +- **Web 框架**: Gin +- **数据库**: SQLite (GORM) +- **分词**: gojieba (结巴分词 Go 版) +- **Telegram SDK**: go-telegram-bot-api v5 +- **QQ SDK**: tencent-connect/botgo v0.2.1 (WebSocket 模式) + +## 📄 License + +MIT diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..d6b36e4 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + "xiaji-go/config" + "xiaji-go/internal/bot" + "xiaji-go/internal/qq" + "xiaji-go/internal/service" + "xiaji-go/internal/web" + "xiaji-go/models" + "xiaji-go/version" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func main() { + // 版本信息 + if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") { + fmt.Println(version.Info()) + return + } + + log.Printf("🦞 %s", version.Info()) + + // 1. 加载配置 + cfgPath := "config.yaml" + if len(os.Args) > 1 { + cfgPath = os.Args[1] + } + + cfg, err := config.LoadConfig(cfgPath) + if err != nil { + log.Fatalf("无法加载配置: %v", err) + } + + // 2. 初始化数据库 + db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{}) + if err != nil { + log.Fatalf("无法连接数据库: %v", err) + } + + // 3. 自动迁移表结构 + if err := models.Migrate(db); err != nil { + log.Fatalf("数据库迁移失败: %v", err) + } + + // 4. 初始化核心服务 + finance := service.NewFinanceService(db) + defer finance.Close() + + // 全局 context,用于优雅退出 + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 5. 启动 Telegram Bot + if cfg.Telegram.Enabled { + tgBot, err := bot.NewTGBot(cfg.Telegram.Token, finance) + if err != nil { + log.Printf("⚠️ TG Bot 启动失败: %v", err) + } else { + go tgBot.Start(ctx) + } + } + + // 6. 启动 QQ Bot + if cfg.QQBot.Enabled { + qqBot := qq.NewQQBot(cfg.QQBot.AppID, cfg.QQBot.Secret, finance) + go qqBot.Start(ctx) + } + + // 7. 启动 Web 后台 + webServer := web.NewWebServer(db, cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password) + go webServer.Start() + + // 8. 优雅关闭 + log.Println("🦞 Xiaji-Go 已全面启动") + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + + log.Println("⏳ 正在关闭服务...") + cancel() // 通知所有 goroutine 停止 + + // 等待一点时间让 goroutine 退出 + time.Sleep(2 * time.Second) + + // 关闭数据库连接 + sqlDB, err := db.DB() + if err == nil { + sqlDB.Close() + } + + log.Println("👋 Xiaji-Go 已关闭") +} diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..5659ae3 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,19 @@ +server: + port: 9521 + key: "your-secret-key-change-me" + +database: + path: "./xiaji.db" + +admin: + username: "admin" + password: "your_password" + +telegram: + enabled: true + token: "YOUR_TELEGRAM_BOT_TOKEN" + +qqbot: + enabled: false + appid: "YOUR_QQ_BOT_APPID" + secret: "YOUR_QQ_BOT_SECRET" diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..42133ac --- /dev/null +++ b/config/config.go @@ -0,0 +1,75 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server struct { + Port int `yaml:"port"` + Key string `yaml:"key"` + } `yaml:"server"` + Database struct { + Path string `yaml:"path"` + } `yaml:"database"` + Telegram struct { + Enabled bool `yaml:"enabled"` + Token string `yaml:"token"` + } `yaml:"telegram"` + QQBot struct { + Enabled bool `yaml:"enabled"` + AppID string `yaml:"appid"` + Secret string `yaml:"secret"` + } `yaml:"qqbot"` + Admin struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + } `yaml:"admin"` +} + +func LoadConfig(path string) (*Config, error) { + cfg := &Config{} + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("打开配置文件失败: %w", err) + } + defer file.Close() + + d := yaml.NewDecoder(file) + if err := d.Decode(&cfg); err != nil { + return nil, fmt.Errorf("解析配置文件失败: %w", err) + } + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("配置验证失败: %w", err) + } + + return cfg, nil +} + +func (c *Config) Validate() error { + if c.Database.Path == "" { + return fmt.Errorf("database.path 不能为空") + } + if c.Server.Port <= 0 || c.Server.Port > 65535 { + return fmt.Errorf("server.port 必须在 1-65535 之间,当前值: %d", c.Server.Port) + } + if c.Server.Key == "" { + return fmt.Errorf("server.key 不能为空(用于会话签名)") + } + if c.Admin.Username == "" || c.Admin.Password == "" { + return fmt.Errorf("admin.username 和 admin.password 不能为空") + } + if c.Telegram.Enabled && c.Telegram.Token == "" { + return fmt.Errorf("telegram 已启用但 token 为空") + } + if c.QQBot.Enabled { + if c.QQBot.AppID == "" || c.QQBot.Secret == "" { + return fmt.Errorf("qqbot 已启用但 appid 或 secret 为空") + } + } + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1a2fddd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.8' + +services: + xiaji-go: + image: ouaone/xiaji-go:latest + container_name: xiaji-go + restart: unless-stopped + ports: + - "9521:9521" + volumes: + - ./config.yaml:/app/config.yaml:ro + - ./data:/app/data + environment: + - TZ=Asia/Shanghai diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b3646cd --- /dev/null +++ b/go.mod @@ -0,0 +1,49 @@ +module xiaji-go + +go 1.22 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/tencent-connect/botgo v0.2.1 + github.com/yanyiwu/gojieba v1.3.0 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/sqlite v1.5.4 + gorm.io/gorm v1.25.5 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/go-resty/resty/v2 v2.6.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-sqlite3 v1.14.17 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/tidwall/gjson v1.9.3 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.16.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/oauth2 v0.23.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8967b8f --- /dev/null +++ b/go.sum @@ -0,0 +1,236 @@ +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= +github.com/go-resty/resty/v2 v2.6.0 h1:joIR5PNLM2EFqqESUjCMGXrWmXNHEU9CEiK813oKYS4= +github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk= +github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI= +github.com/tidwall/gjson v1.9.3 h1:hqzS9wAHMO+KVBBkLxYdkEeeFHuqr95GfClRLKlgK0E= +github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yanyiwu/gojieba v1.3.0 h1:6VeaPOR+MawnImdeSvWNr7rP4tvUfnGlEKaoBnR33Ds= +github.com/yanyiwu/gojieba v1.3.0/go.mod h1:54wkP7sMJ6bklf7yPl6F+JG71dzVUU1WigZbR47nGdY= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0= +gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/bot/telegram.go b/internal/bot/telegram.go new file mode 100644 index 0000000..0b7df0a --- /dev/null +++ b/internal/bot/telegram.go @@ -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) + } +} diff --git a/internal/qq/qq.go b/internal/qq/qq.go new file mode 100644 index 0000000..bc3feba --- /dev/null +++ b/internal/qq/qq.go @@ -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 + } +} diff --git a/internal/service/finance.go b/internal/service/finance.go new file mode 100644 index 0000000..67356f3 --- /dev/null +++ b/internal/service/finance.go @@ -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 +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..57f5ffe --- /dev/null +++ b/internal/web/server.go @@ -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) + } +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..3936448 --- /dev/null +++ b/models/models.go @@ -0,0 +1,136 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type Transaction struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID int64 `json:"user_id"` + Amount int64 `json:"amount"` // 金额,单位:分 + Category string `gorm:"size:50" json:"category"` + Note string `json:"note"` + Date string `gorm:"size:20;index" json:"date"` + IsDeleted bool `gorm:"default:false" json:"is_deleted"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CategoryKeyword struct { + ID uint `gorm:"primaryKey"` + Keyword string `gorm:"uniqueIndex;size:50"` + Category string `gorm:"size:50"` +} + +// AmountYuan 返回元为单位的金额(显示用) +func (t *Transaction) AmountYuan() float64 { + return float64(t.Amount) / 100.0 +} + +// Migrate 自动迁移数据库表结构并初始化分类关键词 +func Migrate(db *gorm.DB) error { + if err := db.AutoMigrate(&Transaction{}, &CategoryKeyword{}); err != nil { + return err + } + + // 检查是否已有关键词数据 + var count int64 + db.Model(&CategoryKeyword{}).Count(&count) + if count > 0 { + return nil + } + + // 预设分类关键词 + keywords := []CategoryKeyword{ + // 餐饮 + {Keyword: "早餐", Category: "餐饮"}, {Keyword: "午餐", Category: "餐饮"}, {Keyword: "晚餐", Category: "餐饮"}, + {Keyword: "早饭", Category: "餐饮"}, {Keyword: "午饭", Category: "餐饮"}, {Keyword: "晚饭", Category: "餐饮"}, + {Keyword: "吃饭", Category: "餐饮"}, {Keyword: "吃", Category: "餐饮"}, {Keyword: "饭", Category: "餐饮"}, + {Keyword: "面", Category: "餐饮"}, {Keyword: "粉", Category: "餐饮"}, {Keyword: "粥", Category: "餐饮"}, + {Keyword: "火锅", Category: "餐饮"}, {Keyword: "烧烤", Category: "餐饮"}, {Keyword: "烤肉", Category: "餐饮"}, + {Keyword: "外卖", Category: "餐饮"}, {Keyword: "点餐", Category: "餐饮"}, {Keyword: "宵夜", Category: "餐饮"}, + {Keyword: "夜宵", Category: "餐饮"}, {Keyword: "小吃", Category: "餐饮"}, {Keyword: "快餐", Category: "餐饮"}, + {Keyword: "饺子", Category: "餐饮"}, {Keyword: "面条", Category: "餐饮"}, {Keyword: "米饭", Category: "餐饮"}, + {Keyword: "菜", Category: "餐饮"}, {Keyword: "肉", Category: "餐饮"}, {Keyword: "鱼", Category: "餐饮"}, + {Keyword: "鸡", Category: "餐饮"}, {Keyword: "蛋", Category: "餐饮"}, {Keyword: "汤", Category: "餐饮"}, + {Keyword: "麻辣烫", Category: "餐饮"}, {Keyword: "炒饭", Category: "餐饮"}, {Keyword: "盖饭", Category: "餐饮"}, + {Keyword: "包子", Category: "餐饮"}, {Keyword: "馒头", Category: "餐饮"}, {Keyword: "饼", Category: "餐饮"}, + {Keyword: "食堂", Category: "餐饮"}, {Keyword: "餐厅", Category: "餐饮"}, {Keyword: "饭店", Category: "餐饮"}, + {Keyword: "美团", Category: "餐饮"}, {Keyword: "饿了么", Category: "餐饮"}, + + // 交通 + {Keyword: "打车", Category: "交通"}, {Keyword: "车费", Category: "交通"}, {Keyword: "出租车", Category: "交通"}, + {Keyword: "滴滴", Category: "交通"}, {Keyword: "公交", Category: "交通"}, {Keyword: "地铁", Category: "交通"}, + {Keyword: "高铁", Category: "交通"}, {Keyword: "火车", Category: "交通"}, {Keyword: "飞机", Category: "交通"}, + {Keyword: "机票", Category: "交通"}, {Keyword: "车票", Category: "交通"}, {Keyword: "船票", Category: "交通"}, + {Keyword: "加油", Category: "交通"}, {Keyword: "油费", Category: "交通"}, {Keyword: "停车", Category: "交通"}, + {Keyword: "停车费", Category: "交通"}, {Keyword: "过路费", Category: "交通"}, {Keyword: "高速", Category: "交通"}, + {Keyword: "骑车", Category: "交通"}, {Keyword: "单车", Category: "交通"}, {Keyword: "共享", Category: "交通"}, + {Keyword: "顺风车", Category: "交通"}, {Keyword: "快车", Category: "交通"}, {Keyword: "专车", Category: "交通"}, + {Keyword: "拼车", Category: "交通"}, {Keyword: "出行", Category: "交通"}, {Keyword: "通勤", Category: "交通"}, + + // 购物 + {Keyword: "买", Category: "购物"}, {Keyword: "购物", Category: "购物"}, {Keyword: "淘宝", Category: "购物"}, + {Keyword: "京东", Category: "购物"}, {Keyword: "拼多多", Category: "购物"}, {Keyword: "网购", Category: "购物"}, + {Keyword: "超市", Category: "购物"}, {Keyword: "商场", Category: "购物"}, {Keyword: "衣服", Category: "购物"}, + {Keyword: "鞋", Category: "购物"}, {Keyword: "裤子", Category: "购物"}, {Keyword: "裙子", Category: "购物"}, + {Keyword: "包", Category: "购物"}, {Keyword: "手机", Category: "购物"}, {Keyword: "电脑", Category: "购物"}, + {Keyword: "日用品", Category: "购物"}, {Keyword: "生活用品", Category: "购物"}, + + // 饮品 + {Keyword: "咖啡", Category: "饮品"}, {Keyword: "奶茶", Category: "饮品"}, {Keyword: "茶", Category: "饮品"}, + {Keyword: "饮料", Category: "饮品"}, {Keyword: "水", Category: "饮品"}, {Keyword: "果汁", Category: "饮品"}, + {Keyword: "星巴克", Category: "饮品"}, {Keyword: "瑞幸", Category: "饮品"}, {Keyword: "喜茶", Category: "饮品"}, + {Keyword: "蜜雪", Category: "饮品"}, {Keyword: "可乐", Category: "饮品"}, {Keyword: "啤酒", Category: "饮品"}, + {Keyword: "酒", Category: "饮品"}, {Keyword: "牛奶", Category: "饮品"}, + + // 水果 + {Keyword: "水果", Category: "水果"}, {Keyword: "苹果", Category: "水果"}, {Keyword: "香蕉", Category: "水果"}, + {Keyword: "橘子", Category: "水果"}, {Keyword: "橙子", Category: "水果"}, {Keyword: "葡萄", Category: "水果"}, + {Keyword: "西瓜", Category: "水果"}, {Keyword: "草莓", Category: "水果"}, {Keyword: "芒果", Category: "水果"}, + + // 零食 + {Keyword: "零食", Category: "零食"}, {Keyword: "薯片", Category: "零食"}, {Keyword: "糖", Category: "零食"}, + {Keyword: "巧克力", Category: "零食"}, {Keyword: "饼干", Category: "零食"}, {Keyword: "面包", Category: "零食"}, + {Keyword: "蛋糕", Category: "零食"}, {Keyword: "甜品", Category: "零食"}, {Keyword: "甜点", Category: "零食"}, + + // 住房 + {Keyword: "房租", Category: "住房"}, {Keyword: "租房", Category: "住房"}, {Keyword: "水电", Category: "住房"}, + {Keyword: "电费", Category: "住房"}, {Keyword: "水费", Category: "住房"}, {Keyword: "燃气", Category: "住房"}, + {Keyword: "物业", Category: "住房"}, {Keyword: "宽带", Category: "住房"}, {Keyword: "网费", Category: "住房"}, + + // 通讯 + {Keyword: "话费", Category: "通讯"}, {Keyword: "流量", Category: "通讯"}, {Keyword: "充值", Category: "通讯"}, + {Keyword: "手机费", Category: "通讯"}, + + // 医疗 + {Keyword: "看病", Category: "医疗"}, {Keyword: "药", Category: "医疗"}, {Keyword: "医院", Category: "医疗"}, + {Keyword: "挂号", Category: "医疗"}, {Keyword: "体检", Category: "医疗"}, {Keyword: "医疗", Category: "医疗"}, + {Keyword: "门诊", Category: "医疗"}, {Keyword: "牙", Category: "医疗"}, + + // 娱乐 + {Keyword: "电影", Category: "娱乐"}, {Keyword: "游戏", Category: "娱乐"}, {Keyword: "KTV", Category: "娱乐"}, + {Keyword: "唱歌", Category: "娱乐"}, {Keyword: "旅游", Category: "娱乐"}, {Keyword: "景点", Category: "娱乐"}, + {Keyword: "门票", Category: "娱乐"}, {Keyword: "健身", Category: "娱乐"}, {Keyword: "运动", Category: "娱乐"}, + {Keyword: "会员", Category: "娱乐"}, {Keyword: "VIP", Category: "娱乐"}, + + // 教育 + {Keyword: "书", Category: "教育"}, {Keyword: "课", Category: "教育"}, {Keyword: "培训", Category: "教育"}, + {Keyword: "学费", Category: "教育"}, {Keyword: "考试", Category: "教育"}, {Keyword: "学习", Category: "教育"}, + + // 烟酒 + {Keyword: "烟", Category: "烟酒"}, {Keyword: "香烟", Category: "烟酒"}, {Keyword: "白酒", Category: "烟酒"}, + {Keyword: "红酒", Category: "烟酒"}, + + // 红包/转账 + {Keyword: "红包", Category: "红包"}, {Keyword: "转账", Category: "转账"}, {Keyword: "借", Category: "转账"}, + {Keyword: "还钱", Category: "转账"}, + + // 宠物 + {Keyword: "猫粮", Category: "宠物"}, {Keyword: "狗粮", Category: "宠物"}, {Keyword: "宠物", Category: "宠物"}, + } + + return db.CreateInBatches(keywords, 50).Error +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..09f6f8f --- /dev/null +++ b/templates/index.html @@ -0,0 +1,193 @@ + + + + + +🦞 虾记记账 + + + + +
+

🦞 虾记记账

+
Xiaji-Go 记账管理
+
+ +
+
+
0.00
+
今日支出
+
+
+
0.00
+
本月支出
+
+
+
0
+
总记录数
+
+
+ +
+ +
+ 📥 导出CSV +
+ +
+ + + + + + diff --git a/version/version.go b/version/version.go new file mode 100644 index 0000000..dfa0ad0 --- /dev/null +++ b/version/version.go @@ -0,0 +1,22 @@ +package version + +import ( + "fmt" + "runtime" +) + +var ( + Version = "dev" + GitCommit = "unknown" + BuildTime = "unknown" + GoVersion = runtime.Version() +) + +func Info() string { + return fmt.Sprintf("Xiaji-Go %s (commit: %s, built: %s, %s %s/%s)", + Version, GitCommit, BuildTime, GoVersion, runtime.GOOS, runtime.GOARCH) +} + +func Short() string { + return fmt.Sprintf("Xiaji-Go %s", Version) +}