Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27b038898d | |||
| 8b2557b2bf | |||
| 52b0d742a7 | |||
| bac7a7b708 | |||
| ebe8d92c75 | |||
| 0167b2ffbf |
@@ -39,7 +39,7 @@ COPY --from=builder /build/templates/ ./templates/
|
|||||||
COPY --from=builder /build/config.yaml.example ./config.yaml.example
|
COPY --from=builder /build/config.yaml.example ./config.yaml.example
|
||||||
|
|
||||||
# gojieba 词典文件
|
# gojieba 词典文件
|
||||||
COPY --from=builder /root/go/pkg/mod/github.com/yanyiwu/gojieba@v1.3.0/dict/ /app/dict/
|
COPY --from=builder /go/pkg/mod/github.com/yanyiwu/gojieba@v1.3.0/dict/ /app/dict/
|
||||||
|
|
||||||
# 数据目录
|
# 数据目录
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data"]
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -1,5 +1,5 @@
|
|||||||
APP_NAME := xiaji-go
|
APP_NAME := xiaji-go
|
||||||
VERSION := 1.0.0
|
VERSION := 1.1.12
|
||||||
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||||
LDFLAGS := -X xiaji-go/version.Version=$(VERSION) \
|
LDFLAGS := -X xiaji-go/version.Version=$(VERSION) \
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
一个支持 **Telegram Bot** 和 **QQ Bot** 的智能记账机器人,带 Web 管理后台。
|
一个支持 **Telegram Bot** 和 **QQ Bot** 的智能记账机器人,带 Web 管理后台。
|
||||||
|
|
||||||
|
> 当前稳定版本:`v1.1.12`
|
||||||
|
|
||||||
|
## 🆕 v1.1.12 更新
|
||||||
|
|
||||||
|
- 新增渠道管理页:`/channels`(移动端优先,统一 UI)
|
||||||
|
- 新增审计页面:`/audit`(中文化)
|
||||||
|
- 新增一键生效接口:`POST /api/v1/admin/channels/:platform/apply`
|
||||||
|
- 渠道配置安全增强:`***` 脱敏占位符不会覆盖真实 secrets
|
||||||
|
- apply 失败返回增强:失败时返回 `stage` 与 `committed` 字段
|
||||||
|
- 首页/登录/渠道/审计页面版本显示统一为 `vX.Y.Z`
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
- **多平台支持**:同时接入 Telegram Bot 和 QQ Bot(WebSocket 模式)
|
- **多平台支持**:同时接入 Telegram Bot 和 QQ Bot(WebSocket 模式)
|
||||||
|
|||||||
60
cmd/main.go
60
cmd/main.go
@@ -11,18 +11,20 @@ import (
|
|||||||
|
|
||||||
"xiaji-go/config"
|
"xiaji-go/config"
|
||||||
"xiaji-go/internal/bot"
|
"xiaji-go/internal/bot"
|
||||||
|
"xiaji-go/internal/channel"
|
||||||
|
"xiaji-go/internal/feishu"
|
||||||
"xiaji-go/internal/qq"
|
"xiaji-go/internal/qq"
|
||||||
"xiaji-go/internal/service"
|
"xiaji-go/internal/service"
|
||||||
"xiaji-go/internal/web"
|
"xiaji-go/internal/web"
|
||||||
"xiaji-go/models"
|
"xiaji-go/models"
|
||||||
"xiaji-go/version"
|
"xiaji-go/version"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// 版本信息
|
|
||||||
if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
|
if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
|
||||||
fmt.Println(version.Info())
|
fmt.Println(version.Info())
|
||||||
return
|
return
|
||||||
@@ -30,7 +32,6 @@ func main() {
|
|||||||
|
|
||||||
log.Printf("🦞 %s", version.Info())
|
log.Printf("🦞 %s", version.Info())
|
||||||
|
|
||||||
// 1. 加载配置
|
|
||||||
cfgPath := "config.yaml"
|
cfgPath := "config.yaml"
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
cfgPath = os.Args[1]
|
cfgPath = os.Args[1]
|
||||||
@@ -41,28 +42,32 @@ func main() {
|
|||||||
log.Fatalf("无法加载配置: %v", err)
|
log.Fatalf("无法加载配置: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 初始化数据库
|
|
||||||
db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("无法连接数据库: %v", err)
|
log.Fatalf("无法连接数据库: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 自动迁移表结构
|
|
||||||
if err := models.Migrate(db); err != nil {
|
if err := models.Migrate(db); err != nil {
|
||||||
log.Fatalf("数据库迁移失败: %v", err)
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 初始化核心服务
|
if err := channel.InitSecretCipher(cfg.Server.Key); err != nil {
|
||||||
|
log.Fatalf("初始化渠道密钥加密失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB 渠道配置覆盖 YAML 配置
|
||||||
|
if err := channel.ApplyChannelConfig(db, cfg); err != nil {
|
||||||
|
log.Printf("⚠️ 渠道配置加载失败,继续使用 YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
finance := service.NewFinanceService(db)
|
finance := service.NewFinanceService(db)
|
||||||
defer finance.Close()
|
defer finance.Close()
|
||||||
|
|
||||||
// 全局 context,用于优雅退出
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// 5. 启动 Telegram Bot
|
|
||||||
if cfg.Telegram.Enabled {
|
if cfg.Telegram.Enabled {
|
||||||
tgBot, err := bot.NewTGBot(cfg.Telegram.Token, finance)
|
tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, finance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ TG Bot 启动失败: %v", err)
|
log.Printf("⚠️ TG Bot 启动失败: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -70,29 +75,48 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 启动 QQ Bot
|
|
||||||
if cfg.QQBot.Enabled {
|
if cfg.QQBot.Enabled {
|
||||||
qqBot := qq.NewQQBot(cfg.QQBot.AppID, cfg.QQBot.Secret, finance)
|
qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, finance)
|
||||||
go qqBot.Start(ctx)
|
go qqBot.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 启动 Web 后台
|
engine := gin.New()
|
||||||
webServer := web.NewWebServer(db, cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password)
|
engine.Use(gin.Recovery())
|
||||||
go webServer.Start()
|
engine.Use(gin.Logger())
|
||||||
|
|
||||||
|
reloadFn := func() (string, error) {
|
||||||
|
if err := channel.ApplyChannelConfig(db, cfg); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
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, finance, cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password, cfg.Server.Key, reloadFn)
|
||||||
|
webServer.RegisterRoutes(engine)
|
||||||
|
|
||||||
|
if cfg.Feishu.Enabled {
|
||||||
|
fsBot := feishu.NewBot(db, finance, cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.VerificationToken, cfg.Feishu.EncryptKey)
|
||||||
|
fsBot.RegisterRoutes(engine)
|
||||||
|
go fsBot.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
logAddr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||||
|
log.Printf("🌐 Web后台运行在 http://127.0.0.1%s", logAddr)
|
||||||
|
if err := engine.Run(logAddr); err != nil {
|
||||||
|
log.Printf("❌ Web服务启动失败: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// 8. 优雅关闭
|
|
||||||
log.Println("🦞 Xiaji-Go 已全面启动")
|
log.Println("🦞 Xiaji-Go 已全面启动")
|
||||||
sig := make(chan os.Signal, 1)
|
sig := make(chan os.Signal, 1)
|
||||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-sig
|
<-sig
|
||||||
|
|
||||||
log.Println("⏳ 正在关闭服务...")
|
log.Println("⏳ 正在关闭服务...")
|
||||||
cancel() // 通知所有 goroutine 停止
|
cancel()
|
||||||
|
|
||||||
// 等待一点时间让 goroutine 退出
|
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
// 关闭数据库连接
|
|
||||||
sqlDB, err := db.DB()
|
sqlDB, err := db.DB()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
sqlDB.Close()
|
sqlDB.Close()
|
||||||
|
|||||||
@@ -17,3 +17,10 @@ qqbot:
|
|||||||
enabled: false
|
enabled: false
|
||||||
appid: "YOUR_QQ_BOT_APPID"
|
appid: "YOUR_QQ_BOT_APPID"
|
||||||
secret: "YOUR_QQ_BOT_SECRET"
|
secret: "YOUR_QQ_BOT_SECRET"
|
||||||
|
|
||||||
|
feishu:
|
||||||
|
enabled: false
|
||||||
|
app_id: "YOUR_FEISHU_APP_ID"
|
||||||
|
app_secret: "YOUR_FEISHU_APP_SECRET"
|
||||||
|
verification_token: "YOUR_FEISHU_VERIFICATION_TOKEN"
|
||||||
|
encrypt_key: "YOUR_FEISHU_ENCRYPT_KEY"
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ type Config struct {
|
|||||||
AppID string `yaml:"appid"`
|
AppID string `yaml:"appid"`
|
||||||
Secret string `yaml:"secret"`
|
Secret string `yaml:"secret"`
|
||||||
} `yaml:"qqbot"`
|
} `yaml:"qqbot"`
|
||||||
|
Feishu struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
AppID string `yaml:"app_id"`
|
||||||
|
AppSecret string `yaml:"app_secret"`
|
||||||
|
VerificationToken string `yaml:"verification_token"`
|
||||||
|
EncryptKey string `yaml:"encrypt_key"`
|
||||||
|
} `yaml:"feishu"`
|
||||||
Admin struct {
|
Admin struct {
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
@@ -71,5 +78,10 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("qqbot 已启用但 appid 或 secret 为空")
|
return fmt.Errorf("qqbot 已启用但 appid 或 secret 为空")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.Feishu.Enabled {
|
||||||
|
if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" {
|
||||||
|
return fmt.Errorf("feishu 已启用但 app_id 或 app_secret 为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
72
docs/multi-platform-channel-deploy.md
Normal file
72
docs/multi-platform-channel-deploy.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Xiaji-Go 多平台渠道配置与回调部署说明
|
||||||
|
|
||||||
|
## 已支持平台
|
||||||
|
- 官方 QQ Bot(qqbot_official)
|
||||||
|
- Telegram Bot(telegram)
|
||||||
|
- 飞书 Bot(feishu)
|
||||||
|
|
||||||
|
## 配置优先级
|
||||||
|
- 启动时:`数据库 channel_configs` > `config.yaml`
|
||||||
|
- 建议使用后台页面维护渠道配置:`/channels`
|
||||||
|
|
||||||
|
## 后台入口
|
||||||
|
- 渠道配置页:`/channels`
|
||||||
|
- 渠道 API:
|
||||||
|
- `GET /api/v1/admin/channels`
|
||||||
|
- `PATCH /api/v1/admin/channels/:platform`
|
||||||
|
- `POST /api/v1/admin/channels/:platform/test`
|
||||||
|
- 审计查询:`GET /api/v1/admin/audit`
|
||||||
|
|
||||||
|
## 回调地址
|
||||||
|
- 飞书 webhook: `POST /webhook/feishu`
|
||||||
|
|
||||||
|
### 飞书事件订阅配置
|
||||||
|
1. 在飞书开发者后台启用事件订阅
|
||||||
|
2. 请求网址填:`https://<你的域名>/webhook/feishu`
|
||||||
|
3. 订阅事件:`im.message.receive_v1`
|
||||||
|
4. 将 `verification_token`、`app_id`、`app_secret` 写入渠道 secrets JSON
|
||||||
|
|
||||||
|
## 渠道 secrets JSON 示例
|
||||||
|
|
||||||
|
### telegram
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "123456:ABCDEF"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### qqbot_official
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"appid": "102857798",
|
||||||
|
"secret": "xxxxxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### feishu
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app_id": "cli_xxx",
|
||||||
|
"app_secret": "xxx",
|
||||||
|
"verification_token": "xxx",
|
||||||
|
"encrypt_key": "optional"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 连接测试说明
|
||||||
|
- Telegram:调用 `getMe`
|
||||||
|
- QQ:调用 `getAppAccessToken`
|
||||||
|
- 飞书:调用 `tenant_access_token/internal`
|
||||||
|
|
||||||
|
测试成功会把渠道状态写成 `ok`,失败写成 `error`。
|
||||||
|
|
||||||
|
## 幂等去重
|
||||||
|
- 三平台入站统一落 `message_dedup`,避免重复处理:
|
||||||
|
- telegram: `tg:<update_id>`
|
||||||
|
- qqbot_official: `qq:<type>:<message_id>`
|
||||||
|
- feishu: `event_id`(回退 message_id)
|
||||||
|
|
||||||
|
## 运行建议
|
||||||
|
- 对公网暴露前请加 HTTPS(飞书回调必需)
|
||||||
|
- 建议将管理后台放在内网或反代鉴权后访问
|
||||||
|
- 定期审计 `audit_logs` 里渠道配置修改记录
|
||||||
13
go.mod
13
go.mod
@@ -22,6 +22,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
github.com/go-resty/resty/v2 v2.6.0 // indirect
|
github.com/go-resty/resty/v2 v2.6.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/gorilla/websocket v1.4.2 // indirect
|
github.com/gorilla/websocket v1.4.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
@@ -38,12 +39,14 @@ require (
|
|||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
github.com/wcharczuk/go-chart/v2 v2.1.2 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/crypto v0.16.0 // indirect
|
golang.org/x/crypto v0.23.0 // indirect
|
||||||
golang.org/x/net v0.19.0 // indirect
|
golang.org/x/image v0.18.0 // indirect
|
||||||
|
golang.org/x/net v0.25.0 // indirect
|
||||||
golang.org/x/oauth2 v0.23.0 // indirect
|
golang.org/x/oauth2 v0.23.0 // indirect
|
||||||
golang.org/x/sync v0.1.0 // indirect
|
golang.org/x/sync v0.7.0 // indirect
|
||||||
golang.org/x/sys v0.15.0 // indirect
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.16.0 // indirect
|
||||||
google.golang.org/protobuf v1.30.0 // indirect
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
36
go.sum
36
go.sum
@@ -36,6 +36,8 @@ github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGi
|
|||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
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/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.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
@@ -52,6 +54,7 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||||||
github.com/google/go-cmp v0.5.6/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 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
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/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 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||||
@@ -125,6 +128,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
|
||||||
|
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
|
||||||
github.com/yanyiwu/gojieba v1.3.0 h1:6VeaPOR+MawnImdeSvWNr7rP4tvUfnGlEKaoBnR33Ds=
|
github.com/yanyiwu/gojieba v1.3.0 h1:6VeaPOR+MawnImdeSvWNr7rP4tvUfnGlEKaoBnR33Ds=
|
||||||
github.com/yanyiwu/gojieba v1.3.0/go.mod h1:54wkP7sMJ6bklf7yPl6F+JG71dzVUU1WigZbR47nGdY=
|
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.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
@@ -136,11 +141,20 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
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-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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
|
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
|
||||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
|
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
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.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/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
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-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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@@ -152,8 +166,12 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT
|
|||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
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.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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
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/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
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-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -162,6 +180,10 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/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 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -181,26 +203,40 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.5.0/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
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/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
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.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.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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/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.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.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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
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/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
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-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.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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
@@ -7,22 +7,29 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
xchart "xiaji-go/internal/chart"
|
||||||
"xiaji-go/internal/service"
|
"xiaji-go/internal/service"
|
||||||
|
"xiaji-go/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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
||||||
|
const DefaultUserID int64 = 1
|
||||||
|
|
||||||
type TGBot struct {
|
type TGBot struct {
|
||||||
api *tgbotapi.BotAPI
|
api *tgbotapi.BotAPI
|
||||||
finance *service.FinanceService
|
finance *service.FinanceService
|
||||||
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTGBot(token string, finance *service.FinanceService) (*TGBot, error) {
|
func NewTGBot(db *gorm.DB, token string, finance *service.FinanceService) (*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}, nil
|
return &TGBot{api: bot, finance: finance, db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *TGBot) Start(ctx context.Context) {
|
func (b *TGBot) Start(ctx context.Context) {
|
||||||
@@ -45,28 +52,45 @@ func (b *TGBot) Start(ctx context.Context) {
|
|||||||
if update.Message == nil || update.Message.Text == "" {
|
if update.Message == nil || update.Message.Text == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eventID := fmt.Sprintf("tg:%d", update.UpdateID)
|
||||||
|
if b.isDuplicate(eventID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("📩 inbound platform=telegram event=%s chat=%d user=%d text=%q", eventID, update.Message.Chat.ID, update.Message.From.ID, strings.TrimSpace(update.Message.Text))
|
||||||
b.handleMessage(update.Message)
|
b.handleMessage(update.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *TGBot) isDuplicate(eventID string) bool {
|
||||||
|
if b.db == nil || strings.TrimSpace(eventID) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var existed models.MessageDedup
|
||||||
|
if err := b.db.Where("platform = ? AND event_id = ?", "telegram", eventID).First(&existed).Error; err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_ = b.db.Create(&models.MessageDedup{Platform: "telegram", EventID: eventID, ProcessedAt: time.Now()}).Error
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
|
func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
|
||||||
text := msg.Text
|
text := msg.Text
|
||||||
chatID := msg.Chat.ID
|
chatID := msg.Chat.ID
|
||||||
userID := msg.From.ID
|
|
||||||
|
|
||||||
var reply string
|
var reply string
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case text == "/start":
|
case text == "/start":
|
||||||
reply = "🦞 欢迎使用虾记记账!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\n/help - 帮助"
|
reply = "🦞 欢迎使用虾记记账!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\n/today - 今日汇总\n/chart - 本月图表\n/help - 帮助"
|
||||||
|
|
||||||
case text == "/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 - 欢迎信息"
|
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":
|
case text == "/today":
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
items, err := b.finance.GetTransactionsByDate(userID, today)
|
items, err := b.finance.GetTransactionsByDate(DefaultUserID, today)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reply = "❌ 查询失败"
|
reply = "❌ 查询失败"
|
||||||
} else if len(items) == 0 {
|
} else if len(items) == 0 {
|
||||||
@@ -83,8 +107,16 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
|
|||||||
reply = sb.String()
|
reply = sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case text == "/chart":
|
||||||
|
b.sendMonthlyChart(chatID)
|
||||||
|
return
|
||||||
|
|
||||||
|
case text == "/week":
|
||||||
|
b.sendWeeklyChart(chatID)
|
||||||
|
return
|
||||||
|
|
||||||
case text == "/list":
|
case text == "/list":
|
||||||
items, err := b.finance.GetTransactions(userID, 10)
|
items, err := b.finance.GetTransactions(DefaultUserID, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reply = "❌ 查询失败"
|
reply = "❌ 查询失败"
|
||||||
} else if len(items) == 0 {
|
} else if len(items) == 0 {
|
||||||
@@ -102,11 +134,10 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
|
|||||||
reply = "❓ 未知命令,输入 /help 查看帮助"
|
reply = "❓ 未知命令,输入 /help 查看帮助"
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 记账逻辑
|
amount, category, err := b.finance.AddTransaction(DefaultUserID, text)
|
||||||
amount, category, err := b.finance.AddTransaction(userID, text)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reply = "❌ 记账失败,请稍后重试"
|
reply = "❌ 记账失败,请稍后重试"
|
||||||
log.Printf("记账失败 user=%d: %v", userID, err)
|
log.Printf("记账失败: %v", err)
|
||||||
} else if amount == 0 {
|
} else if amount == 0 {
|
||||||
reply = "📍 没看到金额,这笔花了多少钱?"
|
reply = "📍 没看到金额,这笔花了多少钱?"
|
||||||
} else {
|
} else {
|
||||||
@@ -120,3 +151,75 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
394
internal/channel/channel.go
Normal file
394
internal/channel/channel.go
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
package channel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"xiaji-go/config"
|
||||||
|
"xiaji-go/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UnifiedMessage struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var secretCipher *cipherContext
|
||||||
|
|
||||||
|
type cipherContext struct {
|
||||||
|
aead cipher.AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitSecretCipher(key string) error {
|
||||||
|
k := deriveKey32(key)
|
||||||
|
block, err := aes.NewCipher(k)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
secretCipher = &cipherContext{aead: aead}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveKey32(s string) []byte {
|
||||||
|
b := []byte(s)
|
||||||
|
out := make([]byte, 32)
|
||||||
|
if len(b) >= 32 {
|
||||||
|
copy(out, b[:32])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
copy(out, b)
|
||||||
|
for i := len(b); i < 32; i++ {
|
||||||
|
out[i] = byte((i * 131) % 251)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptString(plain string) (string, error) {
|
||||||
|
if secretCipher == nil {
|
||||||
|
return plain, errors.New("cipher not initialized")
|
||||||
|
}
|
||||||
|
nonce := make([]byte, secretCipher.aead.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ciphertext := secretCipher.aead.Seal(nil, nonce, []byte(plain), nil)
|
||||||
|
buf := append(nonce, ciphertext...)
|
||||||
|
return "enc:v1:" + base64.StdEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptString(raw string) (string, error) {
|
||||||
|
if !strings.HasPrefix(raw, "enc:v1:") {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
if secretCipher == nil {
|
||||||
|
return "", errors.New("cipher not initialized")
|
||||||
|
}
|
||||||
|
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, "enc:v1:"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ns := secretCipher.aead.NonceSize()
|
||||||
|
if len(data) <= ns {
|
||||||
|
return "", errors.New("invalid ciphertext")
|
||||||
|
}
|
||||||
|
nonce := data[:ns]
|
||||||
|
ct := data[ns:]
|
||||||
|
pt, err := secretCipher.aead.Open(nil, nonce, ct, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(pt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func maybeDecrypt(raw string) string {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
pt, err := decryptString(raw)
|
||||||
|
if err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return pt
|
||||||
|
}
|
||||||
|
|
||||||
|
func MaybeDecryptPublic(raw string) string {
|
||||||
|
return maybeDecrypt(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncryptSecretJSON(raw string) string {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "enc:v1:") {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
if secretCipher == nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
enc, err := encryptString(raw)
|
||||||
|
if err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return enc
|
||||||
|
}
|
||||||
|
|
||||||
|
type telegramSecret struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type qqSecret struct {
|
||||||
|
AppID string `json:"appid"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type feishuSecret struct {
|
||||||
|
AppID string `json:"app_id"`
|
||||||
|
AppSecret string `json:"app_secret"`
|
||||||
|
VerificationToken string `json:"verification_token"`
|
||||||
|
EncryptKey string `json:"encrypt_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJSON(raw string, out any) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(raw), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyChannelConfig 从数据库渠道配置覆盖运行时配置(优先级:DB > YAML)
|
||||||
|
func ApplyChannelConfig(db *gorm.DB, cfg *config.Config) error {
|
||||||
|
var rows []models.ChannelConfig
|
||||||
|
if err := db.Find(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
switch row.Platform {
|
||||||
|
case "telegram":
|
||||||
|
sec := telegramSecret{}
|
||||||
|
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||||
|
cfg.Telegram.Enabled = row.Enabled
|
||||||
|
if strings.TrimSpace(sec.Token) != "" {
|
||||||
|
cfg.Telegram.Token = strings.TrimSpace(sec.Token)
|
||||||
|
}
|
||||||
|
case "qqbot_official":
|
||||||
|
sec := qqSecret{}
|
||||||
|
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||||
|
cfg.QQBot.Enabled = row.Enabled
|
||||||
|
if strings.TrimSpace(sec.AppID) != "" {
|
||||||
|
cfg.QQBot.AppID = strings.TrimSpace(sec.AppID)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sec.Secret) != "" {
|
||||||
|
cfg.QQBot.Secret = strings.TrimSpace(sec.Secret)
|
||||||
|
}
|
||||||
|
case "feishu":
|
||||||
|
sec := feishuSecret{}
|
||||||
|
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||||
|
cfg.Feishu.Enabled = row.Enabled
|
||||||
|
if strings.TrimSpace(sec.AppID) != "" {
|
||||||
|
cfg.Feishu.AppID = strings.TrimSpace(sec.AppID)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sec.AppSecret) != "" {
|
||||||
|
cfg.Feishu.AppSecret = strings.TrimSpace(sec.AppSecret)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sec.VerificationToken) != "" {
|
||||||
|
cfg.Feishu.VerificationToken = strings.TrimSpace(sec.VerificationToken)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sec.EncryptKey) != "" {
|
||||||
|
cfg.Feishu.EncryptKey = strings.TrimSpace(sec.EncryptKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpClient() *http.Client {
|
||||||
|
return &http.Client{Timeout: 8 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelConnectivity(ctx context.Context, row models.ChannelConfig) (status, detail string) {
|
||||||
|
if !row.Enabled {
|
||||||
|
return "disabled", "渠道未启用"
|
||||||
|
}
|
||||||
|
switch row.Platform {
|
||||||
|
case "telegram":
|
||||||
|
sec := telegramSecret{}
|
||||||
|
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||||
|
if strings.TrimSpace(sec.Token) == "" {
|
||||||
|
return "error", "telegram token 为空"
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/getMe", strings.TrimSpace(sec.Token))
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
resp, err := httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "error", err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode != 200 || !strings.Contains(string(body), `"ok":true`) {
|
||||||
|
return "error", fmt.Sprintf("telegram getMe失败: http=%d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return "ok", "telegram getMe 成功"
|
||||||
|
|
||||||
|
case "qqbot_official":
|
||||||
|
sec := qqSecret{}
|
||||||
|
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||||
|
if strings.TrimSpace(sec.AppID) == "" || strings.TrimSpace(sec.Secret) == "" {
|
||||||
|
return "error", "qq appid/secret 为空"
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]string{"appId": strings.TrimSpace(sec.AppID), "clientSecret": strings.TrimSpace(sec.Secret)})
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://bots.qq.com/app/getAppAccessToken", bytes.NewReader(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "error", err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode != 200 || !strings.Contains(string(body), "access_token") {
|
||||||
|
return "error", fmt.Sprintf("qq access token 获取失败: http=%d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return "ok", "qq access token 获取成功"
|
||||||
|
|
||||||
|
case "feishu":
|
||||||
|
sec := feishuSecret{}
|
||||||
|
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||||
|
if strings.TrimSpace(sec.AppID) == "" || strings.TrimSpace(sec.AppSecret) == "" {
|
||||||
|
return "error", "feishu app_id/app_secret 为空"
|
||||||
|
}
|
||||||
|
tk, err := GetFeishuTenantToken(ctx, strings.TrimSpace(sec.AppID), strings.TrimSpace(sec.AppSecret))
|
||||||
|
if err != nil || strings.TrimSpace(tk) == "" {
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("token 为空")
|
||||||
|
}
|
||||||
|
return "error", err.Error()
|
||||||
|
}
|
||||||
|
return "ok", "feishu tenant_access_token 获取成功"
|
||||||
|
default:
|
||||||
|
return "error", "未知平台"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseFeishuInbound(body []byte, verificationToken string) (*UnifiedMessage, string, error) {
|
||||||
|
// url_verification
|
||||||
|
var verifyReq struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Challenge string `json:"challenge"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &verifyReq); err == nil && verifyReq.Type == "url_verification" {
|
||||||
|
if strings.TrimSpace(verificationToken) != "" && verifyReq.Token != verificationToken {
|
||||||
|
return nil, "", fmt.Errorf("verification token mismatch")
|
||||||
|
}
|
||||||
|
return nil, verifyReq.Challenge, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var event struct {
|
||||||
|
Header struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
} `json:"header"`
|
||||||
|
Event struct {
|
||||||
|
Sender struct {
|
||||||
|
SenderID struct {
|
||||||
|
OpenID string `json:"open_id"`
|
||||||
|
} `json:"sender_id"`
|
||||||
|
} `json:"sender"`
|
||||||
|
Message struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"event"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &event); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Header.EventType != "im.message.receive_v1" {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
eventID := strings.TrimSpace(event.Header.EventID)
|
||||||
|
if eventID == "" {
|
||||||
|
eventID = strings.TrimSpace(event.Event.Message.MessageID)
|
||||||
|
}
|
||||||
|
if eventID == "" {
|
||||||
|
return nil, "", fmt.Errorf("missing event id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var content struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(event.Event.Message.Content), &content)
|
||||||
|
text := strings.TrimSpace(content.Text)
|
||||||
|
if text == "" {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UnifiedMessage{
|
||||||
|
Platform: "feishu",
|
||||||
|
EventID: eventID,
|
||||||
|
ChatID: strings.TrimSpace(event.Event.Message.ChatID),
|
||||||
|
UserID: strings.TrimSpace(event.Event.Sender.SenderID.OpenID),
|
||||||
|
Text: text,
|
||||||
|
}, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFeishuTenantToken(ctx context.Context, appID, appSecret string) (string, error) {
|
||||||
|
payload, _ := json.Marshal(map[string]string{"app_id": appID, "app_secret": appSecret})
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", bytes.NewReader(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("http=%d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
TenantAccessToken string `json:"tenant_access_token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if out.Code != 0 || strings.TrimSpace(out.TenantAccessToken) == "" {
|
||||||
|
if out.Msg == "" {
|
||||||
|
out.Msg = "获取token失败"
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf(out.Msg)
|
||||||
|
}
|
||||||
|
return out.TenantAccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendFeishuText(ctx context.Context, tenantToken, receiveID, text string) error {
|
||||||
|
contentBytes, _ := json.Marshal(map[string]string{"text": text})
|
||||||
|
payload, _ := json.Marshal(map[string]string{
|
||||||
|
"receive_id": receiveID,
|
||||||
|
"msg_type": "text",
|
||||||
|
"content": string(contentBytes),
|
||||||
|
})
|
||||||
|
url := "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id"
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tenantToken)
|
||||||
|
resp, err := httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("http=%d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), `"code":0`) {
|
||||||
|
return fmt.Errorf("feishu send failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
126
internal/chart/chart.go
Normal file
126
internal/chart/chart.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package chart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"xiaji-go/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
|
||||||
|
}
|
||||||
130
internal/feishu/feishu.go
Normal file
130
internal/feishu/feishu.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"xiaji-go/internal/channel"
|
||||||
|
"xiaji-go/internal/service"
|
||||||
|
"xiaji-go/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
||||||
|
const DefaultUserID int64 = 1
|
||||||
|
|
||||||
|
type Bot struct {
|
||||||
|
db *gorm.DB
|
||||||
|
finance *service.FinanceService
|
||||||
|
appID string
|
||||||
|
appSecret string
|
||||||
|
verificationToken string
|
||||||
|
encryptKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBot(db *gorm.DB, finance *service.FinanceService, appID, appSecret, verificationToken, encryptKey string) *Bot {
|
||||||
|
return &Bot{
|
||||||
|
db: db,
|
||||||
|
finance: finance,
|
||||||
|
appID: appID,
|
||||||
|
appSecret: appSecret,
|
||||||
|
verificationToken: verificationToken,
|
||||||
|
encryptKey: encryptKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) Start(ctx context.Context) {
|
||||||
|
log.Printf("🚀 Feishu Bot 已启用 app_id=%s", maskID(b.appID))
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Printf("⏳ Feishu Bot 已停止")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) RegisterRoutes(r *gin.Engine) {
|
||||||
|
r.POST("/webhook/feishu", b.handleWebhook)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleWebhook(c *gin.Context) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一走 channel 包解析,便于后续扩展验签/解密
|
||||||
|
msg, verifyChallenge, err := channel.ParseFeishuInbound(body, b.verificationToken)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if verifyChallenge != "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"challenge": verifyChallenge})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等去重
|
||||||
|
var existed models.MessageDedup
|
||||||
|
if err := b.db.Where("platform = ? AND event_id = ?", "feishu", msg.EventID).First(&existed).Error; err == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = b.db.Create(&models.MessageDedup{Platform: "feishu", EventID: msg.EventID, ProcessedAt: time.Now()}).Error
|
||||||
|
|
||||||
|
reply := b.handleText(msg.Text)
|
||||||
|
if reply != "" && msg.UserID != "" {
|
||||||
|
tk, err := channel.GetFeishuTenantToken(c.Request.Context(), b.appID, b.appSecret)
|
||||||
|
if err == nil {
|
||||||
|
_ = channel.SendFeishuText(c.Request.Context(), tk, msg.UserID, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleText(text string) string {
|
||||||
|
trim := strings.TrimSpace(text)
|
||||||
|
switch trim {
|
||||||
|
case "帮助", "help", "/help", "菜单", "功能", "/start":
|
||||||
|
return "🦞 虾记记账\n\n直接发送消费描述即可记账:\n• 午饭 25元\n• 打车 ¥30\n\n📋 命令:记录/查看、今日/今天、统计"
|
||||||
|
case "查看", "记录", "列表", "最近":
|
||||||
|
items, err := b.finance.GetTransactions(DefaultUserID, 10)
|
||||||
|
if err != nil {
|
||||||
|
return "❌ 查询失败"
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "📭 暂无记录"
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("📋 最近记录:\n\n")
|
||||||
|
for _, item := range items {
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %s %.2f元\n", item.Date, item.Category, item.AmountYuan()))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
amount, category, err := b.finance.AddTransaction(DefaultUserID, trim)
|
||||||
|
if err != nil {
|
||||||
|
return "❌ 记账失败,请稍后重试"
|
||||||
|
}
|
||||||
|
if amount == 0 {
|
||||||
|
return "📍 没看到金额,这笔花了多少钱?"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, float64(amount)/100.0, trim)
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskID(s string) string {
|
||||||
|
if len(s) <= 6 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
return s[:3] + "***" + s[len(s)-3:]
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@ package qq
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"xiaji-go/internal/service"
|
"xiaji-go/internal/service"
|
||||||
|
"xiaji-go/models"
|
||||||
|
|
||||||
"github.com/tencent-connect/botgo"
|
"github.com/tencent-connect/botgo"
|
||||||
"github.com/tencent-connect/botgo/dto"
|
"github.com/tencent-connect/botgo/dto"
|
||||||
@@ -16,16 +16,22 @@ import (
|
|||||||
"github.com/tencent-connect/botgo/event"
|
"github.com/tencent-connect/botgo/event"
|
||||||
"github.com/tencent-connect/botgo/openapi"
|
"github.com/tencent-connect/botgo/openapi"
|
||||||
"github.com/tencent-connect/botgo/token"
|
"github.com/tencent-connect/botgo/token"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
||||||
|
const DefaultUserID int64 = 1
|
||||||
|
|
||||||
type QQBot struct {
|
type QQBot struct {
|
||||||
api openapi.OpenAPI
|
api openapi.OpenAPI
|
||||||
finance *service.FinanceService
|
finance *service.FinanceService
|
||||||
credentials *token.QQBotCredentials
|
credentials *token.QQBotCredentials
|
||||||
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQQBot(appID string, secret string, finance *service.FinanceService) *QQBot {
|
func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService) *QQBot {
|
||||||
return &QQBot{
|
return &QQBot{
|
||||||
|
db: db,
|
||||||
finance: finance,
|
finance: finance,
|
||||||
credentials: &token.QQBotCredentials{
|
credentials: &token.QQBotCredentials{
|
||||||
AppID: appID,
|
AppID: appID,
|
||||||
@@ -34,50 +40,36 @@ func NewQQBot(appID string, secret string, finance *service.FinanceService) *QQB
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
func (b *QQBot) Start(ctx context.Context) {
|
||||||
// 创建 token source 并启动自动刷新
|
|
||||||
tokenSource := token.NewQQBotTokenSource(b.credentials)
|
tokenSource := token.NewQQBotTokenSource(b.credentials)
|
||||||
if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil {
|
if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil {
|
||||||
log.Printf("❌ QQ Bot Token 刷新失败: %v", err)
|
log.Printf("❌ QQ Bot Token 刷新失败: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化 OpenAPI
|
|
||||||
b.api = botgo.NewOpenAPI(b.credentials.AppID, tokenSource).WithTimeout(5 * time.Second)
|
b.api = botgo.NewOpenAPI(b.credentials.AppID, tokenSource).WithTimeout(5 * time.Second)
|
||||||
|
|
||||||
// 注册事件处理器
|
|
||||||
_ = event.RegisterHandlers(
|
_ = event.RegisterHandlers(
|
||||||
b.groupATMessageHandler(),
|
b.groupATMessageHandler(),
|
||||||
b.c2cMessageHandler(),
|
b.c2cMessageHandler(),
|
||||||
b.channelATMessageHandler(),
|
b.channelATMessageHandler(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 获取 WebSocket 接入信息
|
|
||||||
wsInfo, err := b.api.WS(ctx, nil, "")
|
wsInfo, err := b.api.WS(ctx, nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ QQ Bot 获取 WS 信息失败: %v", err)
|
log.Printf("❌ QQ Bot 获取 WS 信息失败: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置 intents: 群聊和C2C (1<<25) + 公域消息 (1<<30)
|
|
||||||
intent := dto.Intent(1<<25 | 1<<30)
|
intent := dto.Intent(1<<25 | 1<<30)
|
||||||
|
|
||||||
log.Printf("🚀 QQ Bot 已启动 (WebSocket, shards=%d)", wsInfo.Shards)
|
log.Printf("🚀 QQ Bot 已启动 (WebSocket, shards=%d)", wsInfo.Shards)
|
||||||
|
|
||||||
// 启动 session manager (阻塞)
|
|
||||||
if err := botgo.NewSessionManager().Start(wsInfo, tokenSource, &intent); err != nil {
|
if err := botgo.NewSessionManager().Start(wsInfo, tokenSource, &intent); err != nil {
|
||||||
log.Printf("❌ QQ Bot WebSocket 断开: %v", err)
|
log.Printf("❌ QQ Bot WebSocket 断开: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isCommand 判断是否匹配命令关键词
|
|
||||||
func isCommand(text string, keywords ...string) bool {
|
func isCommand(text string, keywords ...string) bool {
|
||||||
for _, kw := range keywords {
|
for _, kw := range keywords {
|
||||||
if text == kw {
|
if text == kw {
|
||||||
@@ -87,9 +79,19 @@ func isCommand(text string, keywords ...string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// processAndReply 通用记账处理
|
func (b *QQBot) isDuplicate(eventID string) bool {
|
||||||
|
if b.db == nil || strings.TrimSpace(eventID) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var existed models.MessageDedup
|
||||||
|
if err := b.db.Where("platform = ? AND event_id = ?", "qqbot_official", eventID).First(&existed).Error; err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_ = b.db.Create(&models.MessageDedup{Platform: "qqbot_official", EventID: eventID, ProcessedAt: time.Now()}).Error
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (b *QQBot) processAndReply(userID string, content string) string {
|
func (b *QQBot) processAndReply(userID string, content string) string {
|
||||||
uid := hashUserID(userID)
|
|
||||||
text := strings.TrimSpace(message.ETLInput(content))
|
text := strings.TrimSpace(message.ETLInput(content))
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -97,7 +99,6 @@ func (b *QQBot) processAndReply(userID string, content string) string {
|
|||||||
|
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
// 命令处理
|
|
||||||
switch {
|
switch {
|
||||||
case isCommand(text, "帮助", "help", "/help", "/start", "菜单", "功能"):
|
case isCommand(text, "帮助", "help", "/help", "/start", "菜单", "功能"):
|
||||||
return "🦞 虾记记账\n\n" +
|
return "🦞 虾记记账\n\n" +
|
||||||
@@ -108,10 +109,11 @@ func (b *QQBot) processAndReply(userID string, content string) string {
|
|||||||
"📋 命令列表:\n" +
|
"📋 命令列表:\n" +
|
||||||
"• 记录/查看 — 最近10条\n" +
|
"• 记录/查看 — 最近10条\n" +
|
||||||
"• 今日/今天 — 今日汇总\n" +
|
"• 今日/今天 — 今日汇总\n" +
|
||||||
|
"• 统计/报表 — 本月分类统计\n" +
|
||||||
"• 帮助 — 本帮助信息"
|
"• 帮助 — 本帮助信息"
|
||||||
|
|
||||||
case isCommand(text, "查看", "记录", "列表", "list", "/list", "最近"):
|
case isCommand(text, "查看", "记录", "列表", "list", "/list", "最近"):
|
||||||
items, err := b.finance.GetTransactions(uid, 10)
|
items, err := b.finance.GetTransactions(DefaultUserID, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "❌ 查询失败"
|
return "❌ 查询失败"
|
||||||
}
|
}
|
||||||
@@ -126,7 +128,7 @@ func (b *QQBot) processAndReply(userID string, content string) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
|
|
||||||
case isCommand(text, "今日", "今天", "today"):
|
case isCommand(text, "今日", "今天", "today"):
|
||||||
items, err := b.finance.GetTransactionsByDate(uid, today)
|
items, err := b.finance.GetTransactionsByDate(DefaultUserID, today)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "❌ 查询失败"
|
return "❌ 查询失败"
|
||||||
}
|
}
|
||||||
@@ -142,9 +144,30 @@ func (b *QQBot) processAndReply(userID string, content string) string {
|
|||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", len(items), float64(total)/100.0))
|
sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", len(items), float64(total)/100.0))
|
||||||
return sb.String()
|
return sb.String()
|
||||||
|
|
||||||
|
case isCommand(text, "统计", "报表", "图表", "chart", "/chart"):
|
||||||
|
now := time.Now()
|
||||||
|
dateFrom := now.Format("2006-01") + "-01"
|
||||||
|
dateTo := now.Format("2006-01-02")
|
||||||
|
stats, err := b.finance.GetCategoryStats(DefaultUserID, dateFrom, dateTo)
|
||||||
|
if err != nil || len(stats) == 0 {
|
||||||
|
return fmt.Sprintf("📭 %d年%d月暂无消费数据", now.Year(), now.Month())
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
var grandTotal int64
|
||||||
|
var grandCount int
|
||||||
|
sb.WriteString(fmt.Sprintf("📊 %d年%d月消费统计:\n\n", now.Year(), now.Month()))
|
||||||
|
for _, s := range stats {
|
||||||
|
yuan := float64(s.Total) / 100.0
|
||||||
|
sb.WriteString(fmt.Sprintf("• %s:%.2f元(%d笔)\n", s.Category, yuan, s.Count))
|
||||||
|
grandTotal += s.Total
|
||||||
|
grandCount += s.Count
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", grandCount, float64(grandTotal)/100.0))
|
||||||
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
amount, category, err := b.finance.AddTransaction(uid, text)
|
amount, category, err := b.finance.AddTransaction(DefaultUserID, text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("QQ记账失败 user=%s: %v", userID, err)
|
log.Printf("QQ记账失败 user=%s: %v", userID, err)
|
||||||
return "❌ 记账失败,请稍后重试"
|
return "❌ 记账失败,请稍后重试"
|
||||||
@@ -157,17 +180,18 @@ func (b *QQBot) processAndReply(userID string, content string) string {
|
|||||||
return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, amountYuan, text)
|
return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, amountYuan, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// channelATMessageHandler 频道@机器人消息
|
|
||||||
func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler {
|
func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler {
|
||||||
return func(ev *dto.WSPayload, data *dto.WSATMessageData) error {
|
return func(ev *dto.WSPayload, data *dto.WSATMessageData) error {
|
||||||
|
eventID := "qq:channel:" + strings.TrimSpace(data.ID)
|
||||||
|
if b.isDuplicate(eventID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.ChannelID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content)))
|
||||||
reply := b.processAndReply(data.Author.ID, data.Content)
|
reply := b.processAndReply(data.Author.ID, data.Content)
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
_, err := b.api.PostMessage(context.Background(), data.ChannelID, &dto.MessageToCreate{
|
_, err := b.api.PostMessage(context.Background(), data.ChannelID, &dto.MessageToCreate{MsgID: data.ID, Content: reply})
|
||||||
MsgID: data.ID,
|
|
||||||
Content: reply,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("QQ频道消息发送失败: %v", err)
|
log.Printf("QQ频道消息发送失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -175,17 +199,18 @@ func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// groupATMessageHandler 群@机器人消息
|
|
||||||
func (b *QQBot) groupATMessageHandler() event.GroupATMessageEventHandler {
|
func (b *QQBot) groupATMessageHandler() event.GroupATMessageEventHandler {
|
||||||
return func(ev *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
return func(ev *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
||||||
|
eventID := "qq:group:" + strings.TrimSpace(data.ID)
|
||||||
|
if b.isDuplicate(eventID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.GroupID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content)))
|
||||||
reply := b.processAndReply(data.Author.ID, data.Content)
|
reply := b.processAndReply(data.Author.ID, data.Content)
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
_, err := b.api.PostGroupMessage(context.Background(), data.GroupID, dto.MessageToCreate{
|
_, err := b.api.PostGroupMessage(context.Background(), data.GroupID, dto.MessageToCreate{MsgID: data.ID, Content: reply})
|
||||||
MsgID: data.ID,
|
|
||||||
Content: reply,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("QQ群消息发送失败: %v", err)
|
log.Printf("QQ群消息发送失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -193,17 +218,18 @@ func (b *QQBot) groupATMessageHandler() event.GroupATMessageEventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// c2cMessageHandler C2C 私聊消息
|
|
||||||
func (b *QQBot) c2cMessageHandler() event.C2CMessageEventHandler {
|
func (b *QQBot) c2cMessageHandler() event.C2CMessageEventHandler {
|
||||||
return func(ev *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
return func(ev *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
||||||
|
eventID := "qq:c2c:" + strings.TrimSpace(data.ID)
|
||||||
|
if b.isDuplicate(eventID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.Author.ID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content)))
|
||||||
reply := b.processAndReply(data.Author.ID, data.Content)
|
reply := b.processAndReply(data.Author.ID, data.Content)
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
_, err := b.api.PostC2CMessage(context.Background(), data.Author.ID, dto.MessageToCreate{
|
_, err := b.api.PostC2CMessage(context.Background(), data.Author.ID, dto.MessageToCreate{MsgID: data.ID, Content: reply})
|
||||||
MsgID: data.ID,
|
|
||||||
Content: reply,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("QQ私聊消息发送失败: %v", err)
|
log.Printf("QQ私聊消息发送失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,3 +114,41 @@ func (s *FinanceService) GetTransactionsByDate(userID int64, date string) ([]mod
|
|||||||
Order("id desc").Find(&items).Error
|
Order("id desc").Find(&items).Error
|
||||||
return items, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
114
models/models.go
114
models/models.go
@@ -24,14 +24,126 @@ type CategoryKeyword struct {
|
|||||||
Category string `gorm:"size:50"`
|
Category string `gorm:"size:50"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FeatureFlag 高风险能力开关(默认关闭)
|
||||||
|
type FeatureFlag struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Key string `gorm:"uniqueIndex;size:100" json:"key"`
|
||||||
|
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||||
|
RiskLevel string `gorm:"size:20" json:"risk_level"` // low|medium|high
|
||||||
|
Description string `gorm:"size:255" json:"description"`
|
||||||
|
RequireReason bool `gorm:"default:false" json:"require_reason"`
|
||||||
|
UpdatedBy int64 `json:"updated_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureFlagHistory 开关变更历史
|
||||||
|
type FeatureFlagHistory struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
FlagKey string `gorm:"index;size:100" json:"flag_key"`
|
||||||
|
OldValue bool `json:"old_value"`
|
||||||
|
NewValue bool `json:"new_value"`
|
||||||
|
ChangedBy int64 `json:"changed_by"`
|
||||||
|
Reason string `gorm:"size:255" json:"reason"`
|
||||||
|
RequestID string `gorm:"size:100" json:"request_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelConfig 渠道接入配置(平台适配层参数)
|
||||||
|
type ChannelConfig struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Platform string `gorm:"uniqueIndex;size:32" json:"platform"` // qqbot_official|telegram|feishu
|
||||||
|
Name string `gorm:"size:64" json:"name"`
|
||||||
|
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||||
|
Status string `gorm:"size:20;default:'disabled'" json:"status"` // ok|error|disabled
|
||||||
|
ConfigJSON string `gorm:"type:text" json:"config_json"` // 生效配置 JSON
|
||||||
|
SecretJSON string `gorm:"type:text" json:"-"` // 生效密钥 JSON(建议加密)
|
||||||
|
DraftConfigJSON string `gorm:"type:text" json:"draft_config_json"` // 草稿配置 JSON
|
||||||
|
DraftSecretJSON string `gorm:"type:text" json:"-"` // 草稿密钥 JSON(建议加密)
|
||||||
|
LastCheck *time.Time `json:"last_check_at"`
|
||||||
|
PublishedAt *time.Time `json:"published_at"`
|
||||||
|
UpdatedBy int64 `json:"updated_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditLog 通用审计日志
|
||||||
|
type AuditLog struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
ActorID int64 `gorm:"index" json:"actor_id"`
|
||||||
|
Action string `gorm:"size:64;index" json:"action"`
|
||||||
|
TargetType string `gorm:"size:64;index" json:"target_type"`
|
||||||
|
TargetID string `gorm:"size:128;index" json:"target_id"`
|
||||||
|
BeforeJSON string `gorm:"type:text" json:"before_json"`
|
||||||
|
AfterJSON string `gorm:"type:text" json:"after_json"`
|
||||||
|
Note string `gorm:"size:255" json:"note"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageDedup 入站事件幂等去重
|
||||||
|
type MessageDedup struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Platform string `gorm:"size:32;index:idx_platform_event,unique" json:"platform"`
|
||||||
|
EventID string `gorm:"size:128;index:idx_platform_event,unique" json:"event_id"`
|
||||||
|
ProcessedAt time.Time `json:"processed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// AmountYuan 返回元为单位的金额(显示用)
|
// AmountYuan 返回元为单位的金额(显示用)
|
||||||
func (t *Transaction) AmountYuan() float64 {
|
func (t *Transaction) AmountYuan() float64 {
|
||||||
return float64(t.Amount) / 100.0
|
return float64(t.Amount) / 100.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedDefaultFeatureFlags(db *gorm.DB) error {
|
||||||
|
defaults := []FeatureFlag{
|
||||||
|
{Key: "allow_cross_user_read", Enabled: false, RiskLevel: "high", Description: "允许读取非本人账本数据", RequireReason: true},
|
||||||
|
{Key: "allow_cross_user_delete", Enabled: false, RiskLevel: "high", Description: "允许删除非本人账本记录", RequireReason: true},
|
||||||
|
{Key: "allow_export_all_users", Enabled: false, RiskLevel: "high", Description: "允许导出全量用户账本数据", RequireReason: true},
|
||||||
|
{Key: "allow_manual_role_grant", Enabled: false, RiskLevel: "medium", Description: "允许人工授予角色", RequireReason: true},
|
||||||
|
{Key: "allow_bot_admin_commands", Enabled: false, RiskLevel: "medium", Description: "允许 Bot 侧执行管理命令", RequireReason: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ff := range defaults {
|
||||||
|
if err := db.Where("key = ?", ff.Key).FirstOrCreate(&ff).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedDefaultChannels(db *gorm.DB) error {
|
||||||
|
defaults := []ChannelConfig{
|
||||||
|
{Platform: "qqbot_official", Name: "QQ 官方 Bot", Enabled: false, Status: "disabled", ConfigJSON: "{}", SecretJSON: "{}", DraftConfigJSON: "{}", DraftSecretJSON: "{}"},
|
||||||
|
{Platform: "telegram", Name: "Telegram Bot", Enabled: false, Status: "disabled", ConfigJSON: "{}", SecretJSON: "{}", DraftConfigJSON: "{}", DraftSecretJSON: "{}"},
|
||||||
|
{Platform: "feishu", Name: "飞书 Bot", Enabled: false, Status: "disabled", ConfigJSON: "{}", SecretJSON: "{}", DraftConfigJSON: "{}", DraftSecretJSON: "{}"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ch := range defaults {
|
||||||
|
if err := db.Where("platform = ?", ch.Platform).FirstOrCreate(&ch).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate 自动迁移数据库表结构并初始化分类关键词
|
// Migrate 自动迁移数据库表结构并初始化分类关键词
|
||||||
func Migrate(db *gorm.DB) error {
|
func Migrate(db *gorm.DB) error {
|
||||||
if err := db.AutoMigrate(&Transaction{}, &CategoryKeyword{}); err != nil {
|
if err := db.AutoMigrate(
|
||||||
|
&Transaction{},
|
||||||
|
&CategoryKeyword{},
|
||||||
|
&FeatureFlag{},
|
||||||
|
&FeatureFlagHistory{},
|
||||||
|
&ChannelConfig{},
|
||||||
|
&AuditLog{},
|
||||||
|
&MessageDedup{},
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := seedDefaultFeatureFlags(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := seedDefaultChannels(db); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
135
templates/audit.html
Normal file
135
templates/audit.html
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>🧾 审计日志 - 虾记</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333;min-height:100vh}
|
||||||
|
.header{background:linear-gradient(135deg,#ff6b6b,#ee5a24);color:#fff;padding:20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||||
|
.header .title{font-weight:700}
|
||||||
|
.header .sub{font-size:12px;opacity:.9}
|
||||||
|
.header a{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:6px 10px;border-radius:8px;font-size:13px}
|
||||||
|
.header a:hover{background:rgba(255,255,255,.35)}
|
||||||
|
|
||||||
|
.wrap{max-width:600px;margin:0 auto;padding:15px}
|
||||||
|
.filters{background:#fff;border-radius:12px;padding:12px;box-shadow:0 1px 4px rgba(0,0,0,.05);margin-bottom:10px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
||||||
|
.filters input,.filters select{width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px;background:#fff}
|
||||||
|
small{color:#6b7280}
|
||||||
|
|
||||||
|
.actions{margin:0 0 10px;display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
button{border:none;border-radius:8px;padding:8px 12px;cursor:pointer;color:#fff;background:#ee5a24;font-size:13px}
|
||||||
|
button:hover{background:#d63031}
|
||||||
|
button.secondary{background:#6b7280}
|
||||||
|
button.secondary:hover{background:#4b5563}
|
||||||
|
|
||||||
|
.list{display:flex;flex-direction:column;gap:10px}
|
||||||
|
.log-card{background:#fff;border-radius:12px;padding:12px;box-shadow:0 1px 4px rgba(0,0,0,.05)}
|
||||||
|
.row{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}
|
||||||
|
.tag{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||||||
|
.tag.success{background:#dcfce7;color:#166534}
|
||||||
|
.tag.failed{background:#fee2e2;color:#991b1b}
|
||||||
|
.tag.denied{background:#fef3c7;color:#92400e}
|
||||||
|
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#4b5563;word-break:break-all}
|
||||||
|
.note{font-size:13px;color:#374151;margin-top:6px;white-space:pre-wrap;word-break:break-word}
|
||||||
|
.empty{text-align:center;padding:40px 10px;color:#999}
|
||||||
|
|
||||||
|
@media(max-width:640px){
|
||||||
|
.header{padding:14px 12px 10px;align-items:flex-start;flex-direction:column}
|
||||||
|
.header>div:last-child{display:flex;gap:8px}
|
||||||
|
.wrap{padding:12px}
|
||||||
|
.filters{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<div class="title">🧾 审计日志</div>
|
||||||
|
<div class="sub">{{.version}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/">返回首页</a>
|
||||||
|
<a href="/channels">渠道配置</a>
|
||||||
|
<a href="/logout">退出</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="filters">
|
||||||
|
<div><small>操作类型</small><input id="fAction" placeholder="如:record.delete.self"></div>
|
||||||
|
<div><small>目标类型</small><input id="fTarget" placeholder="如:transaction"></div>
|
||||||
|
<div><small>结果</small><select id="fResult"><option value="">全部</option><option value="success">成功</option><option value="denied">拒绝</option><option value="failed">失败</option></select></div>
|
||||||
|
<div><small>操作人ID</small><input id="fActor" placeholder="如:1"></div>
|
||||||
|
<div><small>开始时间(RFC3339)</small><input id="fFrom" placeholder="2026-03-09T00:00:00+08:00"></div>
|
||||||
|
<div><small>结束时间(RFC3339)</small><input id="fTo" placeholder="2026-03-10T00:00:00+08:00"></div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="loadAudit()">查询</button>
|
||||||
|
<button class="secondary" onclick="resetFilters()">重置</button>
|
||||||
|
</div>
|
||||||
|
<div id="list" class="list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||||
|
function qs(id){return document.getElementById(id).value.trim();}
|
||||||
|
|
||||||
|
const actionMap={
|
||||||
|
'auth.login.success':'登录成功','auth.login.failed':'登录失败','auth.logout':'退出登录',
|
||||||
|
'record.delete.self':'删除本人记录','record.delete.all':'删除全员记录','record.export':'导出记录',
|
||||||
|
'flag.update':'修改高级开关',
|
||||||
|
'channel_update_draft':'更新渠道草稿','channel_publish':'发布渠道草稿','channel_reload':'热加载渠道',
|
||||||
|
'channel_disable_all':'一键关闭全部渠道','channel_enable':'启用渠道','channel_disable':'停用渠道','channel_test':'测试渠道连接'
|
||||||
|
};
|
||||||
|
const targetMap={
|
||||||
|
'transaction':'记账记录','feature_flag':'高级开关','channel':'渠道','user':'用户','system':'系统'
|
||||||
|
};
|
||||||
|
|
||||||
|
function actionLabel(v){return actionMap[v]||v||'-';}
|
||||||
|
function targetLabel(v){return targetMap[v]||v||'-';}
|
||||||
|
function parseResult(note){
|
||||||
|
const m=String(note||'').match(/result=(success|failed|denied)/);
|
||||||
|
return m?m[1]:'';
|
||||||
|
}
|
||||||
|
function resultLabel(r){
|
||||||
|
if(r==='success') return '成功';
|
||||||
|
if(r==='failed') return '失败';
|
||||||
|
if(r==='denied') return '拒绝';
|
||||||
|
return '未标注';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters(){['fAction','fTarget','fResult','fActor','fFrom','fTo'].forEach(id=>document.getElementById(id).value='');loadAudit();}
|
||||||
|
|
||||||
|
async function loadAudit(){
|
||||||
|
const p=new URLSearchParams();
|
||||||
|
const m={action:qs('fAction'),target_type:qs('fTarget'),result:qs('fResult'),actor_id:qs('fActor'),from:qs('fFrom'),to:qs('fTo')};
|
||||||
|
Object.entries(m).forEach(([k,v])=>{if(v)p.set(k,v)});
|
||||||
|
p.set('limit','200');
|
||||||
|
|
||||||
|
const listEl=document.getElementById('list');
|
||||||
|
listEl.innerHTML='<div class="empty">加载中...</div>';
|
||||||
|
|
||||||
|
const r=await fetch('/api/v1/admin/audit?'+p.toString());
|
||||||
|
const data=await r.json().catch(()=>[]);
|
||||||
|
const list=Array.isArray(data)?data:[];
|
||||||
|
if(!list.length){listEl.innerHTML='<div class="empty">暂无审计记录</div>';return;}
|
||||||
|
|
||||||
|
listEl.innerHTML=list.map(it=>{
|
||||||
|
const result=parseResult(it.note);
|
||||||
|
const resultClass=result||'success';
|
||||||
|
const note=String(it.note||'').replace(/\s*\|\s*result=(success|failed|denied)\s*$/,'').trim();
|
||||||
|
return `<div class="log-card">
|
||||||
|
<div class="row"><strong>${actionLabel(it.action)}</strong><span class="tag ${resultClass}">${resultLabel(result)}</span></div>
|
||||||
|
<div class="mono" style="margin-top:4px;">#${it.id} · ${esc(it.created_at||'')}</div>
|
||||||
|
<div class="mono" style="margin-top:2px;">操作人: ${it.actor_id} · 目标: ${targetLabel(it.target_type)} (${esc(it.target_id||'')})</div>
|
||||||
|
<div class="note">${esc(note||'(无备注)')}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAudit();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
282
templates/channels.html
Normal file
282
templates/channels.html
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>渠道配置 - 虾记</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f0f2f5; color: #333; min-height: 100vh; }
|
||||||
|
.header { background: linear-gradient(135deg, #ff6b6b, #ee5a24); color: #fff; padding: 20px; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 10px rgba(0,0,0,.15); display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||||
|
.header a { color: #fff; text-decoration:none; background: rgba(255,255,255,.2); padding:6px 10px; border-radius:8px; font-size:13px; }
|
||||||
|
.header a:hover { background: rgba(255,255,255,.35); }
|
||||||
|
.wrap { max-width: 600px; margin: 0 auto; padding: 15px; }
|
||||||
|
.toolbar { display:flex; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
|
||||||
|
.tip { color:#6b7280; font-size:12px; margin-bottom:10px; }
|
||||||
|
.card { background:#fff; border-radius:12px; padding:14px; margin-bottom:10px; box-shadow:0 1px 4px rgba(0,0,0,.05); }
|
||||||
|
.row { display:flex; gap:8px; align-items:center; margin:8px 0; flex-wrap:wrap; }
|
||||||
|
.row label { width:110px; color:#4b5563; font-size:13px; }
|
||||||
|
.row input, .row textarea { flex:1; min-width:220px; padding:8px; border:1px solid #ddd; border-radius:8px; font-size:13px; }
|
||||||
|
.row textarea { min-height:74px; }
|
||||||
|
.btns { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; }
|
||||||
|
button { border:none; border-radius:8px; padding:8px 12px; cursor:pointer; color:#fff; font-size:13px; }
|
||||||
|
button:disabled { opacity:.65; cursor:not-allowed; }
|
||||||
|
.btn-apply, .btn-save, .btn-publish, .btn-test, .btn-reload, .btn-enable { background:#ee5a24; }
|
||||||
|
.btn-apply:hover, .btn-save:hover, .btn-publish:hover, .btn-test:hover, .btn-reload:hover, .btn-enable:hover { background:#d63031; }
|
||||||
|
.btn-disable { background:#9b2c2c; }
|
||||||
|
.btn-disable:hover { background:#7f1d1d; }
|
||||||
|
.badge { display:inline-block; font-size:12px; border-radius:999px; padding:2px 8px; }
|
||||||
|
.state { display:inline-block; font-size:12px; border-radius:999px; padding:2px 8px; margin-left:6px; }
|
||||||
|
.ok { background:#dcfce7; color:#166534; }
|
||||||
|
.error { background:#fee2e2; color:#991b1b; }
|
||||||
|
.disabled { background:#e5e7eb; color:#374151; }
|
||||||
|
small { color:#6b7280; }
|
||||||
|
|
||||||
|
@media(max-width:640px){
|
||||||
|
.header { padding: 14px 12px 10px; align-items:flex-start; flex-direction:column; }
|
||||||
|
.header > div:last-child { display:flex; gap:8px; }
|
||||||
|
.wrap { padding:12px; }
|
||||||
|
.toolbar button { flex: 1 1 calc(50% - 4px); }
|
||||||
|
.row label { width:100%; }
|
||||||
|
.row input, .row textarea { min-width:100%; }
|
||||||
|
.btns button { flex: 1 1 calc(50% - 4px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div>🦞 渠道配置中心(草稿/发布) · {{.version}}</div>
|
||||||
|
<div><a href="/">返回首页</a><a href="/logout">退出</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn-disable" onclick="disableAll()">一键全部关闭</button>
|
||||||
|
<button class="btn-reload" onclick="reloadRuntime()">热加载运行参数</button>
|
||||||
|
</div>
|
||||||
|
<div class="tip">默认推荐:直接点“保存并立即生效”。高级场景再用“保存草稿 / 发布草稿 / 热加载”。</div>
|
||||||
|
<div id="app"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
|
||||||
|
function renderError(msg) {
|
||||||
|
app.innerHTML = `<div class="card" style="border:1px solid #fecaca;background:#fef2f2;color:#991b1b;">${msg}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pretty(objStr) {
|
||||||
|
try { return JSON.stringify(JSON.parse(objStr || '{}'), null, 2); } catch { return '{}'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status) {
|
||||||
|
const s = (status || 'disabled');
|
||||||
|
const cls = s === 'ok' ? 'ok' : (s === 'error' ? 'error' : 'disabled');
|
||||||
|
return `<span class="badge ${cls}">${s}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeState(ch) {
|
||||||
|
if (!ch.enabled) return '<span class="state disabled">已关闭</span>';
|
||||||
|
if ((ch.status || '').toLowerCase() === 'ok') return '<span class="state ok">运行中</span>';
|
||||||
|
if ((ch.status || '').toLowerCase() === 'error') return '<span class="state error">配置异常</span>';
|
||||||
|
return '<span class="state disabled">待检测</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJSONSafe(text) {
|
||||||
|
try { return JSON.parse(text || '{}'); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchChannels() {
|
||||||
|
const r = await fetch('/api/v1/admin/channels');
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error('加载渠道失败: HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error('渠道返回格式异常');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(channels) {
|
||||||
|
app.innerHTML = channels.map(ch => {
|
||||||
|
const draftCfg = pretty(ch.draft_config_json || ch.config_json);
|
||||||
|
const draftSec = pretty(ch.draft_secrets || ch.secrets);
|
||||||
|
return `<div class="card" data-platform="${ch.platform}">
|
||||||
|
<h3>${ch.name || ch.platform} ${statusBadge(ch.status)} ${runtimeState(ch)} ${ch.has_draft ? '<span class="badge" style="background:#fef3c7;color:#92400e">draft</span>' : ''}</h3>
|
||||||
|
<small>平台:${ch.platform} | 发布:${ch.published_at || '-'} | 最近检测:${ch.last_check_at || '-'}</small>
|
||||||
|
<div class="row"><label>启用</label><input type="checkbox" class="enabled" ${ch.enabled ? 'checked' : ''}></div>
|
||||||
|
<div class="row"><label>显示名称</label><input class="name" value="${(ch.name||'').replace(/"/g,'"')}"></div>
|
||||||
|
<div class="row"><label>草稿 config(JSON)</label><textarea class="config">${draftCfg}</textarea></div>
|
||||||
|
<div class="row"><label>草稿 secrets(JSON)</label><textarea class="secrets">${draftSec}</textarea></div>
|
||||||
|
<div class="btns">
|
||||||
|
<button class="btn-apply" onclick="applyNow('${ch.platform}')">保存并立即生效</button>
|
||||||
|
<button class="btn-save" onclick="saveDraft('${ch.platform}')">保存草稿</button>
|
||||||
|
<button class="btn-publish" onclick="publishDraft('${ch.platform}')">发布草稿</button>
|
||||||
|
<button class="btn-test" onclick="testConn('${ch.platform}')">测试连接</button>
|
||||||
|
${ch.enabled ? `<button class="btn-disable" onclick="toggleChannel('${ch.platform}', false)">关闭通道</button>` : `<button class="btn-enable" onclick="toggleChannel('${ch.platform}', true)">开启通道</button>`}
|
||||||
|
</div>
|
||||||
|
<small class="msg"></small>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectChannelForm(platform) {
|
||||||
|
const card = document.querySelector(`[data-platform="${platform}"]`);
|
||||||
|
const name = card.querySelector('.name').value.trim();
|
||||||
|
const enabled = card.querySelector('.enabled').checked;
|
||||||
|
const configText = card.querySelector('.config').value;
|
||||||
|
const secretsText = card.querySelector('.secrets').value;
|
||||||
|
const msg = card.querySelector('.msg');
|
||||||
|
|
||||||
|
const config = parseJSONSafe(configText);
|
||||||
|
const secrets = parseJSONSafe(secretsText);
|
||||||
|
if (!config || !secrets) {
|
||||||
|
msg.textContent = 'JSON 格式错误,请检查 config/secrets';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { card, msg, payload: { name, enabled, config, secrets } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiJSON(url, options = {}) {
|
||||||
|
const r = await fetch(url, options);
|
||||||
|
const out = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(out.error || ('HTTP ' + r.status));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function msgOf(platform) {
|
||||||
|
return document.querySelector(`[data-platform="${platform}"] .msg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCardBusy(card, busy) {
|
||||||
|
if (!card) return;
|
||||||
|
card.querySelectorAll('button').forEach(btn => { btn.disabled = busy; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyNow(platform) {
|
||||||
|
const f = collectChannelForm(platform);
|
||||||
|
if (!f) return;
|
||||||
|
const { card, msg, payload } = f;
|
||||||
|
const applyBtn = card.querySelector('.btn-apply');
|
||||||
|
const oldText = applyBtn ? applyBtn.textContent : '';
|
||||||
|
|
||||||
|
setCardBusy(card, true);
|
||||||
|
if (applyBtn) applyBtn.textContent = '生效中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
msg.textContent = '保存并生效中...';
|
||||||
|
await apiJSON('/api/v1/admin/channels/' + platform + '/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
msg.textContent = '已生效';
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
msg.textContent = '失败:' + (e && e.message ? e.message : e);
|
||||||
|
} finally {
|
||||||
|
setCardBusy(card, false);
|
||||||
|
if (applyBtn) applyBtn.textContent = oldText || '保存并立即生效';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft(platform) {
|
||||||
|
const f = collectChannelForm(platform);
|
||||||
|
if (!f) return;
|
||||||
|
const { msg, payload } = f;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiJSON('/api/v1/admin/channels/' + platform, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
msg.textContent = '草稿已保存';
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
msg.textContent = '保存失败:' + (e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishDraft(platform) {
|
||||||
|
const msg = msgOf(platform);
|
||||||
|
try {
|
||||||
|
await apiJSON('/api/v1/admin/channels/' + platform + '/publish', { method: 'POST' });
|
||||||
|
msg.textContent = '发布成功,建议点“热加载运行参数”';
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
msg.textContent = '发布失败:' + (e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConn(platform) {
|
||||||
|
const msg = msgOf(platform);
|
||||||
|
try {
|
||||||
|
msg.textContent = '正在测试...';
|
||||||
|
const out = await apiJSON('/api/v1/admin/channels/' + platform + '/test', { method: 'POST' });
|
||||||
|
msg.textContent = `测试结果:${out.status} ${out.detail ? ' / ' + out.detail : ''}`;
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
msg.textContent = '测试失败:' + (e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleChannel(platform, enable) {
|
||||||
|
const msg = msgOf(platform);
|
||||||
|
try {
|
||||||
|
msg.textContent = enable ? '正在开启...' : '正在关闭...';
|
||||||
|
await apiJSON('/api/v1/admin/channels/' + platform + (enable ? '/enable' : '/disable'), { method: 'POST' });
|
||||||
|
msg.textContent = enable ? '已开启(请点热加载生效)' : '已关闭(请点热加载生效)';
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
msg.textContent = (enable ? '开启失败:' : '关闭失败:') + (e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadRuntime() {
|
||||||
|
try {
|
||||||
|
const out = await apiJSON('/api/v1/admin/channels/reload', { method: 'POST' });
|
||||||
|
alert('热加载成功:' + (out.detail || 'ok'));
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
alert('热加载失败:' + (e && e.message ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableAll() {
|
||||||
|
if (!confirm('确认要关闭所有通道吗?')) return;
|
||||||
|
try {
|
||||||
|
const out = await apiJSON('/api/v1/admin/channels/disable-all', { method: 'POST' });
|
||||||
|
alert('已关闭通道数:' + (out.affected || 0) + ',请点热加载生效。');
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
alert('批量关闭失败:' + (e && e.message ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
try {
|
||||||
|
const channels = await fetchChannels();
|
||||||
|
render(channels);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
renderError('页面加载失败:' + (e && e.message ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
renderError('前端脚本异常:' + (e && e.message ? e.message : 'unknown'));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
|
const msg = e && e.reason && e.reason.message ? e.reason.message : String(e.reason || 'unknown');
|
||||||
|
renderError('前端请求异常:' + msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
reload();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,91 +5,91 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>🦞 虾记记账</title>
|
<title>🦞 虾记记账</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f0f2f5; color: #333; min-height: 100vh; }
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333;min-height:100vh}
|
||||||
|
.header{background:linear-gradient(135deg,#ff6b6b,#ee5a24);color:#fff;padding:20px;text-align:center;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);position:relative}
|
||||||
|
.header h1{font-size:24px;margin-bottom:4px}
|
||||||
|
.header .subtitle{font-size:13px;opacity:.9}
|
||||||
|
.logout-btn{position:absolute;right:16px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,.2);color:#fff;text-decoration:none;padding:5px 14px;border-radius:6px;font-size:13px}
|
||||||
|
.logout-btn:hover{background:rgba(255,255,255,.35)}
|
||||||
|
|
||||||
.header { background: linear-gradient(135deg, #ff6b6b, #ee5a24); color: #fff; padding: 20px; text-align: center; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 10px rgba(0,0,0,.15); }
|
.stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;padding:15px;max-width:600px;margin:0 auto}
|
||||||
.header h1 { font-size: 24px; margin-bottom: 4px; }
|
.stat-card{background:#fff;border-radius:12px;padding:15px;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.06)}
|
||||||
.header .subtitle { font-size: 13px; opacity: .85; }
|
.stat-card .num{font-size:22px;font-weight:700;color:#ee5a24}
|
||||||
|
.stat-card .label{font-size:12px;color:#999;margin-top:4px}
|
||||||
|
|
||||||
.stats { display: flex; gap: 10px; padding: 15px; overflow-x: auto; }
|
.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:0 15px 10px;max-width:600px;margin:0 auto}
|
||||||
.stat-card { flex: 1; min-width: 120px; background: #fff; border-radius: 12px; padding: 15px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.06); }
|
.toolbar .spacer{flex:1}
|
||||||
.stat-card .num { font-size: 22px; font-weight: 700; color: #ee5a24; }
|
.filter-select{padding:8px 12px;border:1px solid #ddd;border-radius:8px;font-size:13px;background:#fff;min-width:110px}
|
||||||
.stat-card .label { font-size: 12px; color: #999; margin-top: 4px; }
|
.toolbar a{padding:8px 12px;background:#ee5a24;color:#fff;border-radius:8px;text-decoration:none;font-size:13px;white-space:nowrap}
|
||||||
|
.toolbar a:hover{background:#d63031}
|
||||||
|
|
||||||
.toolbar { display: flex; gap: 10px; padding: 0 15px 10px; align-items: center; }
|
#flagsPanel{display:none;max-width:600px;margin:0 auto 12px;background:#fff;border-radius:12px;padding:12px 14px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
|
||||||
.toolbar a { padding: 8px 16px; background: #ee5a24; color: #fff; border-radius: 8px; text-decoration: none; font-size: 13px; white-space: nowrap; }
|
#flagsList{display:flex;flex-direction:column;gap:8px}
|
||||||
.toolbar a:hover { background: #d63031; }
|
|
||||||
.toolbar .spacer { flex: 1; }
|
|
||||||
.filter-select { padding: 8px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 13px; background: #fff; }
|
|
||||||
|
|
||||||
.tx-list { padding: 0 15px 80px; }
|
.tx-list{padding:0 15px 80px;max-width:600px;margin:0 auto}
|
||||||
.tx-card { background: #fff; border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.05); display: flex; align-items: center; gap: 12px; transition: transform .15s; }
|
.tx-card{background:#fff;border-radius:12px;padding:14px 16px;margin-bottom:10px;box-shadow:0 1px 4px rgba(0,0,0,.05);display:flex;align-items:center;gap:12px}
|
||||||
.tx-card:active { transform: scale(.98); }
|
.tx-icon{width:42px;height:42px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
|
||||||
.tx-icon { width: 42px; height: 42px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
|
.tx-info{flex:1;min-width:0}
|
||||||
.tx-info { flex: 1; min-width: 0; }
|
.tx-category{font-weight:600;font-size:15px}
|
||||||
.tx-category { font-weight: 600; font-size: 15px; }
|
.tx-note{font-size:12px;color:#999;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.tx-note { font-size: 12px; color: #999; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.tx-right{text-align:right;flex-shrink:0}
|
||||||
.tx-right { text-align: right; flex-shrink: 0; }
|
.tx-amount{font-size:17px;font-weight:700;color:#e74c3c}
|
||||||
.tx-amount { font-size: 17px; font-weight: 700; color: #e74c3c; }
|
.tx-date{font-size:11px;color:#bbb;margin-top:2px}
|
||||||
.tx-date { font-size: 11px; color: #bbb; margin-top: 2px; }
|
.btn-del{background:none;border:1px solid #e74c3c;color:#e74c3c;border-radius:4px;padding:2px 8px;font-size:11px;cursor:pointer;margin-top:4px}
|
||||||
.tx-actions { margin-top: 4px; }
|
.btn-del:hover{background:#e74c3c;color:#fff}
|
||||||
.btn-del { background: none; border: 1px solid #e74c3c; color: #e74c3c; border-radius: 4px; padding: 2px 8px; font-size: 11px; cursor: pointer; }
|
|
||||||
.btn-del:hover { background: #e74c3c; color: #fff; }
|
|
||||||
|
|
||||||
.empty { text-align: center; padding: 60px 20px; color: #999; }
|
.empty{text-align:center;padding:60px 20px;color:#999}
|
||||||
.empty .icon { font-size: 48px; margin-bottom: 10px; }
|
.empty .icon{font-size:48px;margin-bottom:10px}
|
||||||
|
.cat-餐饮{background:#fff3e0}.cat-交通{background:#e3f2fd}.cat-购物{background:#fce4ec}.cat-娱乐{background:#f3e5f5}.cat-其他{background:#f5f5f5}
|
||||||
|
|
||||||
.cat-餐饮 { background: #fff3e0; }
|
.modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:200;align-items:center;justify-content:center}
|
||||||
.cat-交通 { background: #e3f2fd; }
|
.modal.show{display:flex}
|
||||||
.cat-购物 { background: #fce4ec; }
|
.modal-body{background:#fff;border-radius:16px;padding:24px;width:280px;text-align:center}
|
||||||
.cat-娱乐 { background: #f3e5f5; }
|
.modal-body .btns{display:flex;gap:10px;margin-top:16px}
|
||||||
.cat-其他 { background: #f5f5f5; }
|
.modal-body .btns button{flex:1;padding:10px;border:none;border-radius:8px;cursor:pointer}
|
||||||
|
|
||||||
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 200; align-items: center; justify-content: center; }
|
@media(max-width:640px){
|
||||||
.modal.show { display: flex; }
|
.header{padding:14px 12px 10px;text-align:left}
|
||||||
.modal-body { background: #fff; border-radius: 16px; padding: 24px; width: 280px; text-align: center; }
|
.header h1{font-size:20px;margin-right:90px}
|
||||||
.modal-body h3 { margin-bottom: 12px; }
|
.header .subtitle{font-size:12px;margin-right:90px}
|
||||||
.modal-body .btns { display: flex; gap: 10px; margin-top: 16px; }
|
.logout-btn{right:10px;top:14px;transform:none;padding:4px 10px;font-size:12px}
|
||||||
.modal-body .btns button { flex: 1; padding: 10px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; }
|
.stats{grid-template-columns:1fr;padding:12px}
|
||||||
.modal-body .btn-cancel { background: #f0f0f0; }
|
.stat-card{text-align:left;padding:12px}
|
||||||
.modal-body .btn-confirm { background: #e74c3c; color: #fff; }
|
.toolbar{padding:0 12px 10px}
|
||||||
|
.toolbar .spacer{display:none}
|
||||||
@media(min-width:600px) {
|
.toolbar a,.filter-select{flex:1 1 calc(50% - 4px);text-align:center}
|
||||||
.tx-list { max-width: 600px; margin: 0 auto; }
|
#flagsPanel{margin:0 12px 12px}
|
||||||
.stats { max-width: 600px; margin: 0 auto; }
|
.tx-list{padding:0 12px 72px}
|
||||||
.toolbar { max-width: 600px; margin: 0 auto; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>🦞 虾记记账</h1>
|
<h1>🦞 虾记记账</h1>
|
||||||
<div class="subtitle">Xiaji-Go 记账管理</div>
|
<div class="subtitle">{{.version}}</div>
|
||||||
|
<a href="/logout" class="logout-btn">退出</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card"><div class="num" id="todayTotal">0.00</div><div class="label">今日支出</div></div>
|
||||||
<div class="num" id="todayTotal">0.00</div>
|
<div class="stat-card"><div class="num" id="monthTotal">0.00</div><div class="label">本月支出</div></div>
|
||||||
<div class="label">今日支出</div>
|
<div class="stat-card"><div class="num" id="txCount">0</div><div class="label">总记录数</div></div>
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="num" id="monthTotal">0.00</div>
|
|
||||||
<div class="label">本月支出</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="num" id="txCount">0</div>
|
|
||||||
<div class="label">总记录数</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<select class="filter-select" id="catFilter" onchange="filterList()">
|
<select class="filter-select" id="catFilter" onchange="filterList()"><option value="">全部分类</option></select>
|
||||||
<option value="">全部分类</option>
|
<select class="filter-select" id="scopeFilter" onchange="loadData()" style="display:none;"><option value="self">仅本人</option><option value="all">全员</option></select>
|
||||||
</select>
|
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<a href="/export">📥 导出CSV</a>
|
<a href="#" id="btnFlags" style="display:none;">⚙️ 高级功能</a>
|
||||||
|
<a href="/audit" id="btnAudit" style="display:none;">🧾 审计日志</a>
|
||||||
|
<a href="/channels" id="btnChannels" style="display:none;">🔌 渠道配置</a>
|
||||||
|
<a href="#" id="btnExport">📥 导出CSV</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="flagsPanel">
|
||||||
|
<div style="font-weight:600;margin-bottom:8px;">高级功能开关(高风险默认关闭)</div>
|
||||||
|
<div id="flagsList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tx-list" id="txList"></div>
|
<div class="tx-list" id="txList"></div>
|
||||||
@@ -99,95 +99,139 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-
|
|||||||
<h3>确认删除?</h3>
|
<h3>确认删除?</h3>
|
||||||
<p id="delInfo" style="font-size:13px;color:#666;"></p>
|
<p id="delInfo" style="font-size:13px;color:#666;"></p>
|
||||||
<div class="btns">
|
<div class="btns">
|
||||||
<button class="btn-cancel" onclick="closeModal()">取消</button>
|
<button onclick="closeModal()">取消</button>
|
||||||
<button class="btn-confirm" onclick="confirmDelete()">删除</button>
|
<button style="background:#e74c3c;color:#fff" onclick="confirmDelete()">删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const catIcons = { '餐饮':'🍜','交通':'🚗','购物':'🛒','娱乐':'🎮','住房':'🏠','通讯':'📱','医疗':'💊','教育':'📚','其他':'📦' };
|
const catIcons={ '餐饮':'🍜','交通':'🚗','购物':'🛒','娱乐':'🎮','住房':'🏠','通讯':'📱','医疗':'💊','教育':'📚','其他':'📦' };
|
||||||
let allData = [];
|
let allData=[],deleteId=null,me=null,flags=[];
|
||||||
let deleteId = null;
|
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadMe(){ const r=await fetch('/api/v1/me'); if(!r.ok) throw new Error('读取用户信息失败'); me=await r.json(); }
|
||||||
try {
|
|
||||||
const r = await fetch('/api/records');
|
function initPermissionUI(){
|
||||||
allData = await r.json();
|
const caps=(me&&me.effective_capabilities)||{};
|
||||||
if (!Array.isArray(allData)) allData = [];
|
const scopeSel=document.getElementById('scopeFilter');
|
||||||
renderStats();
|
const btnChannels=document.getElementById('btnChannels');
|
||||||
renderList(allData);
|
const btnAudit=document.getElementById('btnAudit');
|
||||||
populateFilter();
|
const btnFlags=document.getElementById('btnFlags');
|
||||||
} catch(e) { console.error(e); }
|
|
||||||
|
scopeSel.style.display=(caps.can_read_all || (me.flags&&me.flags.allow_cross_user_read===false)) ? '' : 'none';
|
||||||
|
btnChannels.style.display=caps.can_view_channels ? '' : 'none';
|
||||||
|
btnAudit.style.display=caps.can_view_audit ? '' : 'none';
|
||||||
|
btnFlags.style.display=caps.can_view_flags ? '' : 'none';
|
||||||
|
|
||||||
|
btnFlags.onclick=async (e)=>{
|
||||||
|
e.preventDefault();
|
||||||
|
const p=document.getElementById('flagsPanel');
|
||||||
|
p.style.display=(p.style.display==='none'||!p.style.display)?'block':'none';
|
||||||
|
if(p.style.display==='block'){ await loadFlags(); renderFlags(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('btnExport').onclick=(e)=>{
|
||||||
|
e.preventDefault();
|
||||||
|
const scope=document.getElementById('scopeFilter').value||'self';
|
||||||
|
if(scope==='all'&&!caps.can_export_all){ alert('没有导出全员权限或开关未启用'); return; }
|
||||||
|
if(scope==='self'&&!caps.can_export_self){ alert('没有导出本人权限'); return; }
|
||||||
|
window.location.href='/api/v1/export?scope='+encodeURIComponent(scope);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStats() {
|
async function loadFlags(){
|
||||||
const today = new Date().toISOString().slice(0,10);
|
const caps=(me&&me.effective_capabilities)||{};
|
||||||
const month = today.slice(0,7);
|
if(!caps.can_view_flags) return;
|
||||||
let todaySum = 0, monthSum = 0;
|
const r=await fetch('/api/v1/admin/settings/flags');
|
||||||
allData.forEach(tx => {
|
const data=await r.json();
|
||||||
if (tx.date === today) todaySum += tx.amount;
|
flags=Array.isArray(data)?data:[];
|
||||||
if (tx.date && tx.date.startsWith(month)) monthSum += tx.amount;
|
|
||||||
});
|
|
||||||
document.getElementById('todayTotal').textContent = todaySum.toFixed(2);
|
|
||||||
document.getElementById('monthTotal').textContent = monthSum.toFixed(2);
|
|
||||||
document.getElementById('txCount').textContent = allData.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderList(data) {
|
function renderFlags(){
|
||||||
const el = document.getElementById('txList');
|
const list=document.getElementById('flagsList');
|
||||||
if (!data.length) {
|
const caps=(me&&me.effective_capabilities)||{};
|
||||||
el.innerHTML = '<div class="empty"><div class="icon">📭</div>暂无记录<br><small>通过 Telegram/QQ 发送消息记账</small></div>';
|
list.innerHTML=flags.map(f=>{
|
||||||
return;
|
const disabled=!caps.can_edit_flags?'disabled':'';
|
||||||
}
|
return `<label style="display:flex;align-items:center;justify-content:space-between;border:1px solid #eef2f7;border-radius:8px;padding:8px 10px;">
|
||||||
el.innerHTML = data.map(tx => {
|
<span><strong>${esc(f.key)}</strong><br><small>${esc(f.description||'')}(${esc(f.risk_level||'unknown')})</small></span>
|
||||||
const icon = catIcons[tx.category] || '📦';
|
<input type="checkbox" ${f.enabled?'checked':''} ${disabled} onchange="toggleFlag('${esc(f.key)}',this.checked)">
|
||||||
const catClass = 'cat-' + (tx.category || '其他');
|
</label>`;
|
||||||
return `<div class="tx-card">
|
|
||||||
<div class="tx-icon ${catClass}">${icon}</div>
|
|
||||||
<div class="tx-info">
|
|
||||||
<div class="tx-category">${tx.category || '其他'}</div>
|
|
||||||
<div class="tx-note" title="${esc(tx.note)}">${esc(tx.note)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="tx-right">
|
|
||||||
<div class="tx-amount">-${tx.amount.toFixed(2)}</div>
|
|
||||||
<div class="tx-date">${tx.date}</div>
|
|
||||||
<div class="tx-actions"><button class="btn-del" onclick="showDelete(${tx.id},'${esc(tx.note)}',${tx.amount})">删除</button></div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(s) { return String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
async function toggleFlag(key,enabled){
|
||||||
|
const caps=(me&&me.effective_capabilities)||{};
|
||||||
function populateFilter() {
|
if(!caps.can_edit_flags){ alert('无修改权限'); return; }
|
||||||
const cats = [...new Set(allData.map(t => t.category))].sort();
|
const reason=prompt('请输入修改原因(审计必填)')||'';
|
||||||
const sel = document.getElementById('catFilter');
|
const r=await fetch('/api/v1/admin/settings/flags/'+encodeURIComponent(key),{
|
||||||
const cur = sel.value;
|
method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled,reason})
|
||||||
sel.innerHTML = '<option value="">全部分类</option>' + cats.map(c => `<option value="${c}">${c}</option>`).join('');
|
});
|
||||||
sel.value = cur;
|
const out=await r.json().catch(()=>({}));
|
||||||
|
if(!r.ok){ alert(out.error||'修改失败'); }
|
||||||
|
await loadMe(); await loadFlags(); renderFlags();
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterList() {
|
async function loadData(){
|
||||||
const cat = document.getElementById('catFilter').value;
|
try{
|
||||||
renderList(cat ? allData.filter(t => t.category === cat) : allData);
|
const caps=(me&&me.effective_capabilities)||{};
|
||||||
|
const scopeEl=document.getElementById('scopeFilter');
|
||||||
|
let scope=scopeEl.value||'self';
|
||||||
|
if(scope==='all'&&!caps.can_read_all){ alert('没有查看全员数据权限或开关未启用'); scope='self'; scopeEl.value='self'; }
|
||||||
|
const r=await fetch('/api/v1/records?scope='+encodeURIComponent(scope));
|
||||||
|
allData=await r.json();
|
||||||
|
if(!Array.isArray(allData)) allData=[];
|
||||||
|
renderStats(); renderList(allData); populateFilter();
|
||||||
|
}catch(e){
|
||||||
|
console.error(e);
|
||||||
|
document.getElementById('txList').innerHTML='<div class="empty"><div class="icon">⚠️</div>数据加载失败</div>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDelete(id, note, amount) {
|
function renderStats(){
|
||||||
deleteId = id;
|
const today=new Date().toISOString().slice(0,10), month=today.slice(0,7);
|
||||||
document.getElementById('delInfo').textContent = `${note} (${amount.toFixed(2)}元)`;
|
let todaySum=0,monthSum=0;
|
||||||
document.getElementById('delModal').classList.add('show');
|
allData.forEach(tx=>{ const amt=Number(tx.amount||0); if(tx.date===today) todaySum+=amt; if(tx.date&&tx.date.startsWith(month)) monthSum+=amt; });
|
||||||
}
|
document.getElementById('todayTotal').textContent=todaySum.toFixed(2);
|
||||||
function closeModal() { document.getElementById('delModal').classList.remove('show'); deleteId = null; }
|
document.getElementById('monthTotal').textContent=monthSum.toFixed(2);
|
||||||
async function confirmDelete() {
|
document.getElementById('txCount').textContent=allData.length;
|
||||||
if (!deleteId) return;
|
|
||||||
await fetch('/delete/' + deleteId, { method: 'POST' });
|
|
||||||
closeModal();
|
|
||||||
loadData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData();
|
function renderList(data){
|
||||||
setInterval(loadData, 30000);
|
const el=document.getElementById('txList');
|
||||||
|
const caps=(me&&me.effective_capabilities)||{};
|
||||||
|
if(!data.length){ el.innerHTML='<div class="empty"><div class="icon">📭</div>暂无记录<br><small>通过 Telegram/QQ 发送消息记账</small></div>'; return; }
|
||||||
|
el.innerHTML=data.map(tx=>{
|
||||||
|
const icon=catIcons[tx.category]||'📦', cls='cat-'+(tx.category||'其他');
|
||||||
|
const amount=Number(tx.amount||0), note=esc(tx.note), canDelete=caps.can_delete_self||caps.can_delete_all;
|
||||||
|
const delBtn=canDelete ? `<button class="btn-del" onclick="showDelete(${tx.id},'${note.replace(/'/g,"'")}',${amount})">删除</button>` : `<button class="btn-del" style="opacity:.45;cursor:not-allowed;" onclick="alert('无删除权限')">删除</button>`;
|
||||||
|
return `<div class="tx-card"><div class="tx-icon ${cls}">${icon}</div><div class="tx-info"><div class="tx-category">${esc(tx.category||'其他')}</div><div class="tx-note" title="${note}">${note}</div></div><div class="tx-right"><div class="tx-amount">-${amount.toFixed(2)}</div><div class="tx-date">${esc(tx.date||'')}</div>${delBtn}</div></div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateFilter(){
|
||||||
|
const cats=[...new Set(allData.map(t=>t.category).filter(Boolean))].sort();
|
||||||
|
const sel=document.getElementById('catFilter'), cur=sel.value;
|
||||||
|
sel.innerHTML='<option value="">全部分类</option>'+cats.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join('');
|
||||||
|
sel.value=cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterList(){ const cat=document.getElementById('catFilter').value; renderList(cat?allData.filter(t=>t.category===cat):allData); }
|
||||||
|
function showDelete(id,note,amount){ deleteId=id; document.getElementById('delInfo').textContent=`${note} (${Number(amount).toFixed(2)}元)`; document.getElementById('delModal').classList.add('show'); }
|
||||||
|
function closeModal(){ document.getElementById('delModal').classList.remove('show'); deleteId=null; }
|
||||||
|
|
||||||
|
async function confirmDelete(){
|
||||||
|
if(!deleteId) return;
|
||||||
|
const r=await fetch('/api/v1/records/'+deleteId+'/delete',{method:'POST'});
|
||||||
|
const out=await r.json().catch(()=>({}));
|
||||||
|
if(!r.ok) alert(out.error||'删除失败');
|
||||||
|
closeModal(); loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function(){
|
||||||
|
try{ await loadMe(); initPermissionUI(); await loadData(); setInterval(loadData,30000); }
|
||||||
|
catch(e){ console.error(e); document.getElementById('txList').innerHTML='<div class="empty"><div class="icon">⚠️</div>页面初始化失败</div>'; }
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
126
templates/login.html
Normal file
126
templates/login.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>🦞 虾记 - 登录</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #f39c12 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,.2);
|
||||||
|
animation: slideUp .4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(30px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.login-logo .icon { font-size: 52px; }
|
||||||
|
.login-logo h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #333;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.login-logo .subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid #eee;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: border-color .2s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.form-group input:focus {
|
||||||
|
border-color: #ee5a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px;
|
||||||
|
background: linear-gradient(135deg, #ff6b6b, #ee5a24);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity .2s, transform .1s;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.btn-login:hover { opacity: .9; }
|
||||||
|
.btn-login:active { transform: scale(.98); }
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
background: #fff2f0;
|
||||||
|
color: #e74c3c;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #fde2e0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-logo">
|
||||||
|
<div class="icon">🦞</div>
|
||||||
|
<h1>虾记记账</h1>
|
||||||
|
<div class="subtitle">{{.version}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .error}}
|
||||||
|
<div class="error-msg">{{.error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input type="text" name="username" placeholder="请输入用户名" autocomplete="username" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-login">登 录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user