From 8b2557b2bf04d081b2192af64739fda28155eb0d Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 10 Mar 2026 03:32:40 +0800 Subject: [PATCH] feat: channels/audit UI unify, apply flow hardening, bump v1.1.12 --- Makefile | 2 +- cmd/main.go | 60 +- config.yaml.example | 7 + config/config.go | 12 + docs/multi-platform-channel-deploy.md | 72 ++ internal/bot/telegram.go | 30 +- internal/channel/channel.go | 394 +++++++++++ internal/feishu/feishu.go | 130 ++++ internal/qq/qq.go | 60 +- internal/web/server.go | 937 ++++++++++++++++++++++++-- models/models.go | 132 +++- templates/audit.html | 135 ++++ templates/channels.html | 282 ++++++++ templates/index.html | 318 +++++---- templates/login.html | 2 +- 15 files changed, 2311 insertions(+), 262 deletions(-) create mode 100644 docs/multi-platform-channel-deploy.md create mode 100644 internal/channel/channel.go create mode 100644 internal/feishu/feishu.go create mode 100644 templates/audit.html create mode 100644 templates/channels.html diff --git a/Makefile b/Makefile index 27c753f..7bd5d4f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ APP_NAME := xiaji-go -VERSION := 1.1.0 +VERSION := 1.1.12 GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') LDFLAGS := -X xiaji-go/version.Version=$(VERSION) \ diff --git a/cmd/main.go b/cmd/main.go index d6b36e4..404c7c3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -11,18 +11,20 @@ import ( "xiaji-go/config" "xiaji-go/internal/bot" + "xiaji-go/internal/channel" + "xiaji-go/internal/feishu" "xiaji-go/internal/qq" "xiaji-go/internal/service" "xiaji-go/internal/web" "xiaji-go/models" "xiaji-go/version" + "github.com/gin-gonic/gin" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func main() { - // 版本信息 if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") { fmt.Println(version.Info()) return @@ -30,7 +32,6 @@ func main() { log.Printf("🦞 %s", version.Info()) - // 1. 加载配置 cfgPath := "config.yaml" if len(os.Args) > 1 { cfgPath = os.Args[1] @@ -41,28 +42,32 @@ func main() { log.Fatalf("无法加载配置: %v", err) } - // 2. 初始化数据库 db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{}) if err != nil { log.Fatalf("无法连接数据库: %v", err) } - // 3. 自动迁移表结构 if err := models.Migrate(db); err != nil { log.Fatalf("数据库迁移失败: %v", err) } - // 4. 初始化核心服务 + 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) defer finance.Close() - // 全局 context,用于优雅退出 ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // 5. 启动 Telegram Bot if cfg.Telegram.Enabled { - tgBot, err := bot.NewTGBot(cfg.Telegram.Token, finance) + tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, finance) if err != nil { log.Printf("⚠️ TG Bot 启动失败: %v", err) } else { @@ -70,29 +75,48 @@ func main() { } } - // 6. 启动 QQ Bot 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) } - // 7. 启动 Web 后台 - webServer := web.NewWebServer(db, cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password) - go webServer.Start() + engine := gin.New() + engine.Use(gin.Recovery()) + 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 已全面启动") sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig log.Println("⏳ 正在关闭服务...") - cancel() // 通知所有 goroutine 停止 - - // 等待一点时间让 goroutine 退出 + cancel() time.Sleep(2 * time.Second) - // 关闭数据库连接 sqlDB, err := db.DB() if err == nil { sqlDB.Close() diff --git a/config.yaml.example b/config.yaml.example index 5659ae3..f9c5129 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -17,3 +17,10 @@ qqbot: enabled: false appid: "YOUR_QQ_BOT_APPID" 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" diff --git a/config/config.go b/config/config.go index 42133ac..a1a20b9 100644 --- a/config/config.go +++ b/config/config.go @@ -24,6 +24,13 @@ type Config struct { AppID string `yaml:"appid"` Secret string `yaml:"secret"` } `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 { Username string `yaml:"username"` Password string `yaml:"password"` @@ -71,5 +78,10 @@ func (c *Config) Validate() error { 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 } diff --git a/docs/multi-platform-channel-deploy.md b/docs/multi-platform-channel-deploy.md new file mode 100644 index 0000000..eab019e --- /dev/null +++ b/docs/multi-platform-channel-deploy.md @@ -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:` + - qqbot_official: `qq::` + - feishu: `event_id`(回退 message_id) + +## 运行建议 +- 对公网暴露前请加 HTTPS(飞书回调必需) +- 建议将管理后台放在内网或反代鉴权后访问 +- 定期审计 `audit_logs` 里渠道配置修改记录 diff --git a/internal/bot/telegram.go b/internal/bot/telegram.go index 29205aa..d305e03 100644 --- a/internal/bot/telegram.go +++ b/internal/bot/telegram.go @@ -9,8 +9,10 @@ import ( xchart "xiaji-go/internal/chart" "xiaji-go/internal/service" + "xiaji-go/models" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "gorm.io/gorm" ) // DefaultUserID 统一用户ID,使所有平台共享同一份账本 @@ -19,14 +21,15 @@ const DefaultUserID int64 = 1 type TGBot struct { api *tgbotapi.BotAPI 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) if err != nil { 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) { @@ -49,11 +52,29 @@ func (b *TGBot) Start(ctx context.Context) { if update.Message == nil || update.Message.Text == "" { continue } + + eventID := fmt.Sprintf("tg:%d", update.UpdateID) + if b.isDuplicate(eventID) { + continue + } + log.Printf("📩 inbound platform=telegram event=%s chat=%d user=%d text=%q", eventID, update.Message.Chat.ID, update.Message.From.ID, strings.TrimSpace(update.Message.Text)) b.handleMessage(update.Message) } } } +func (b *TGBot) isDuplicate(eventID string) bool { + if b.db == nil || strings.TrimSpace(eventID) == "" { + return false + } + var existed models.MessageDedup + if err := b.db.Where("platform = ? AND event_id = ?", "telegram", eventID).First(&existed).Error; err == nil { + return true + } + _ = b.db.Create(&models.MessageDedup{Platform: "telegram", EventID: eventID, ProcessedAt: time.Now()}).Error + return false +} + func (b *TGBot) handleMessage(msg *tgbotapi.Message) { text := msg.Text chatID := msg.Chat.ID @@ -113,7 +134,6 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) { reply = "❓ 未知命令,输入 /help 查看帮助" default: - // 记账逻辑 amount, category, err := b.finance.AddTransaction(DefaultUserID, text) if err != nil { reply = "❌ 记账失败,请稍后重试" @@ -132,7 +152,6 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) { } } -// sendMonthlyChart 发送本月分类饼图 func (b *TGBot) sendMonthlyChart(chatID int64) { now := time.Now() dateFrom := now.Format("2006-01") + "-01" @@ -154,7 +173,6 @@ func (b *TGBot) sendMonthlyChart(chatID int64) { return } - // 计算总计文字 var total int64 var totalCount int for _, s := range stats { @@ -170,7 +188,6 @@ func (b *TGBot) sendMonthlyChart(chatID int64) { } } -// sendWeeklyChart 发送近7天每日消费柱状图 func (b *TGBot) sendWeeklyChart(chatID int64) { now := time.Now() dateFrom := now.AddDate(0, 0, -6).Format("2006-01-02") @@ -192,7 +209,6 @@ func (b *TGBot) sendWeeklyChart(chatID int64) { return } - // 总计 var total int64 var totalCount int for _, s := range stats { diff --git a/internal/channel/channel.go b/internal/channel/channel.go new file mode 100644 index 0000000..83435f5 --- /dev/null +++ b/internal/channel/channel.go @@ -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 +} diff --git a/internal/feishu/feishu.go b/internal/feishu/feishu.go new file mode 100644 index 0000000..68c8cdf --- /dev/null +++ b/internal/feishu/feishu.go @@ -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:] +} diff --git a/internal/qq/qq.go b/internal/qq/qq.go index 2c2278f..d4d1492 100644 --- a/internal/qq/qq.go +++ b/internal/qq/qq.go @@ -8,6 +8,7 @@ import ( "time" "xiaji-go/internal/service" + "xiaji-go/models" "github.com/tencent-connect/botgo" "github.com/tencent-connect/botgo/dto" @@ -15,6 +16,7 @@ import ( "github.com/tencent-connect/botgo/event" "github.com/tencent-connect/botgo/openapi" "github.com/tencent-connect/botgo/token" + "gorm.io/gorm" ) // DefaultUserID 统一用户ID,使所有平台共享同一份账本 @@ -24,10 +26,12 @@ type QQBot struct { api openapi.OpenAPI finance *service.FinanceService 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{ + db: db, finance: finance, credentials: &token.QQBotCredentials{ AppID: appID, @@ -37,42 +41,35 @@ func NewQQBot(appID string, secret string, finance *service.FinanceService) *QQB } func (b *QQBot) Start(ctx context.Context) { - // 创建 token source 并启动自动刷新 tokenSource := token.NewQQBotTokenSource(b.credentials) if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil { log.Printf("❌ QQ Bot Token 刷新失败: %v", err) return } - // 初始化 OpenAPI b.api = botgo.NewOpenAPI(b.credentials.AppID, tokenSource).WithTimeout(5 * time.Second) - // 注册事件处理器 _ = event.RegisterHandlers( b.groupATMessageHandler(), b.c2cMessageHandler(), b.channelATMessageHandler(), ) - // 获取 WebSocket 接入信息 wsInfo, err := b.api.WS(ctx, nil, "") if err != nil { log.Printf("❌ QQ Bot 获取 WS 信息失败: %v", err) return } - // 设置 intents: 群聊和C2C (1<<25) + 公域消息 (1<<30) intent := dto.Intent(1<<25 | 1<<30) log.Printf("🚀 QQ Bot 已启动 (WebSocket, shards=%d)", wsInfo.Shards) - // 启动 session manager (阻塞) if err := botgo.NewSessionManager().Start(wsInfo, tokenSource, &intent); err != nil { log.Printf("❌ QQ Bot WebSocket 断开: %v", err) } } -// isCommand 判断是否匹配命令关键词 func isCommand(text string, keywords ...string) bool { for _, kw := range keywords { if text == kw { @@ -82,7 +79,18 @@ func isCommand(text string, keywords ...string) bool { 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 { text := strings.TrimSpace(message.ETLInput(content)) if text == "" { @@ -91,7 +99,6 @@ func (b *QQBot) processAndReply(userID string, content string) string { today := time.Now().Format("2006-01-02") - // 命令处理 switch { case isCommand(text, "帮助", "help", "/help", "/start", "菜单", "功能"): return "🦞 虾记记账\n\n" + @@ -173,17 +180,18 @@ func (b *QQBot) processAndReply(userID string, content string) string { return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, amountYuan, text) } -// channelATMessageHandler 频道@机器人消息 func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler { return func(ev *dto.WSPayload, data *dto.WSATMessageData) error { + eventID := "qq:channel:" + strings.TrimSpace(data.ID) + if b.isDuplicate(eventID) { + return nil + } + log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.ChannelID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content))) reply := b.processAndReply(data.Author.ID, data.Content) if reply == "" { return nil } - _, err := b.api.PostMessage(context.Background(), data.ChannelID, &dto.MessageToCreate{ - MsgID: data.ID, - Content: reply, - }) + _, err := b.api.PostMessage(context.Background(), data.ChannelID, &dto.MessageToCreate{MsgID: data.ID, Content: reply}) if err != nil { log.Printf("QQ频道消息发送失败: %v", err) } @@ -191,17 +199,18 @@ func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler { } } -// groupATMessageHandler 群@机器人消息 func (b *QQBot) groupATMessageHandler() event.GroupATMessageEventHandler { return func(ev *dto.WSPayload, data *dto.WSGroupATMessageData) error { + eventID := "qq:group:" + strings.TrimSpace(data.ID) + if b.isDuplicate(eventID) { + return nil + } + log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.GroupID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content))) reply := b.processAndReply(data.Author.ID, data.Content) if reply == "" { return nil } - _, err := b.api.PostGroupMessage(context.Background(), data.GroupID, dto.MessageToCreate{ - MsgID: data.ID, - Content: reply, - }) + _, err := b.api.PostGroupMessage(context.Background(), data.GroupID, dto.MessageToCreate{MsgID: data.ID, Content: reply}) if err != nil { log.Printf("QQ群消息发送失败: %v", err) } @@ -209,17 +218,18 @@ func (b *QQBot) groupATMessageHandler() event.GroupATMessageEventHandler { } } -// c2cMessageHandler C2C 私聊消息 func (b *QQBot) c2cMessageHandler() event.C2CMessageEventHandler { return func(ev *dto.WSPayload, data *dto.WSC2CMessageData) error { + eventID := "qq:c2c:" + strings.TrimSpace(data.ID) + if b.isDuplicate(eventID) { + return nil + } + log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.Author.ID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content))) reply := b.processAndReply(data.Author.ID, data.Content) if reply == "" { return nil } - _, err := b.api.PostC2CMessage(context.Background(), data.Author.ID, dto.MessageToCreate{ - MsgID: data.ID, - Content: reply, - }) + _, err := b.api.PostC2CMessage(context.Background(), data.Author.ID, dto.MessageToCreate{MsgID: data.ID, Content: reply}) if err != nil { log.Printf("QQ私聊消息发送失败: %v", err) } diff --git a/internal/web/server.go b/internal/web/server.go index 9d82460..e8358fa 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1,15 +1,21 @@ package web import ( + "context" "crypto/hmac" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "net/http" "strconv" + "strings" "time" + "xiaji-go/internal/channel" + "xiaji-go/internal/service" "xiaji-go/models" + "xiaji-go/version" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -17,45 +23,157 @@ import ( type WebServer struct { db *gorm.DB + finance *service.FinanceService port int username string password string secretKey string + reloadFn func() (string, error) } -func NewWebServer(db *gorm.DB, port int, username, password string) *WebServer { +type CurrentUser struct { + Username string `json:"username"` + Role string `json:"role"` + UserID int64 `json:"user_id"` + Permissions map[string]bool `json:"-"` + PermList []string `json:"permissions"` + Flags map[string]bool `json:"flags"` + Caps map[string]bool `json:"effective_capabilities"` +} + +type flagPatchReq struct { + Enabled bool `json:"enabled"` + Reason string `json:"reason"` +} + +type channelConfigPatchReq struct { + Name *string `json:"name"` + Enabled *bool `json:"enabled"` + Config json.RawMessage `json:"config"` + Secrets json.RawMessage `json:"secrets"` +} + +var rolePermissions = map[string][]string{ + "owner": { + "records.read.self", "records.read.all", + "records.delete.self", "records.delete.all", + "records.export.self", "records.export.all", + "settings.flags.read", "settings.flags.write", + "channels.read", "channels.write", "channels.test", + "audit.read", + }, + "admin": { + "records.read.self", "records.delete.self", "records.export.self", + "settings.flags.read", "channels.read", "audit.read", + }, + "viewer": { + "records.read.self", + }, +} + +func NewWebServer(db *gorm.DB, finance *service.FinanceService, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer { return &WebServer{ db: db, + finance: finance, port: port, username: username, password: password, - secretKey: "xiaji-go-session-" + password, // 简单派生 + secretKey: "xiaji-go-session-" + sessionKey, + reloadFn: reloadFn, } } -// generateToken 生成登录 token func (s *WebServer) generateToken(username string) string { mac := hmac.New(sha256.New, []byte(s.secretKey)) mac.Write([]byte(username)) return hex.EncodeToString(mac.Sum(nil)) } -// validateToken 验证 token func (s *WebServer) validateToken(username, token string) bool { expected := s.generateToken(username) return hmac.Equal([]byte(expected), []byte(token)) } -// authRequired 登录认证中间件 +func (s *WebServer) buildCurrentUser(username string) *CurrentUser { + role := "viewer" + userID := int64(1) + if username == s.username { + role = "owner" + } + perms := map[string]bool{} + permList := make([]string, 0) + for _, p := range rolePermissions[role] { + perms[p] = true + permList = append(permList, p) + } + return &CurrentUser{Username: username, Role: role, UserID: userID, Permissions: perms, PermList: permList} +} + +func (s *WebServer) getFlagMap() map[string]bool { + res := map[string]bool{} + var flags []models.FeatureFlag + s.db.Find(&flags) + for _, f := range flags { + res[f.Key] = f.Enabled + } + return res +} + +func (s *WebServer) flagEnabled(key string) bool { + var ff models.FeatureFlag + if err := s.db.Where("key = ?", key).First(&ff).Error; err != nil { + return false + } + return ff.Enabled +} + +func (s *WebServer) hasPermission(u *CurrentUser, perm string) bool { + if u == nil { + return false + } + return u.Permissions[perm] +} + +func (s *WebServer) requirePerm(c *gin.Context, u *CurrentUser, perm, msg string) bool { + if s.hasPermission(u, perm) { + return true + } + deny(c, msg) + return false +} + +func (s *WebServer) renderPage(c *gin.Context, tpl string, u *CurrentUser, extra gin.H) { + data := gin.H{"version": "v" + version.Version} + if u != nil { + data["username"] = u.Username + } + for k, v := range extra { + data[k] = v + } + c.HTML(http.StatusOK, tpl, data) +} + +func deny(c *gin.Context, msg string) { + c.JSON(http.StatusForbidden, gin.H{"error": msg}) +} + +func currentUser(c *gin.Context) *CurrentUser { + if v, ok := c.Get("currentUser"); ok { + if u, ok2 := v.(*CurrentUser); ok2 { + return u + } + } + return nil +} + func (s *WebServer) authRequired() gin.HandlerFunc { return func(c *gin.Context) { username, _ := c.Cookie("xiaji_user") token, _ := c.Cookie("xiaji_token") if username == "" || token == "" || !s.validateToken(username, token) { - // 判断是 API 请求还是页面请求 path := c.Request.URL.Path - if len(path) >= 4 && path[:4] == "/api" || c.Request.Method == "POST" { + if strings.HasPrefix(path, "/api") || c.Request.Method == "POST" || c.Request.Method == "PATCH" { c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) } else { c.Redirect(http.StatusFound, "/login") @@ -63,34 +181,94 @@ func (s *WebServer) authRequired() gin.HandlerFunc { c.Abort() return } + + c.Set("currentUser", s.buildCurrentUser(username)) c.Next() } } +func (s *WebServer) writeAudit(actor int64, action, targetType, targetID, before, after, note string) { + _ = s.db.Create(&models.AuditLog{ + ActorID: actor, + Action: action, + TargetType: targetType, + TargetID: targetID, + BeforeJSON: before, + AfterJSON: after, + Note: note, + }).Error +} + +func (s *WebServer) writeAuditResult(actor int64, action, targetType, targetID, before, after, note, result string) { + finalNote := strings.TrimSpace(note) + if strings.TrimSpace(result) != "" { + if finalNote == "" { + finalNote = "result=" + result + } else { + finalNote = finalNote + " | result=" + result + } + } + s.writeAudit(actor, action, targetType, targetID, before, after, finalNote) +} + +func (s *WebServer) registerAPIV1Routes(auth *gin.RouterGroup) { + auth.GET("/api/v1/me", s.handleMe) + auth.GET("/api/v1/records", s.handleRecordsV1) + auth.POST("/api/v1/records/:id/delete", s.handleDeleteV1) + auth.GET("/api/v1/export", s.handleExportV1) + auth.GET("/api/v1/admin/settings/flags", s.handleFlagsList) + auth.PATCH("/api/v1/admin/settings/flags/:key", s.handleFlagPatch) + + auth.GET("/api/v1/admin/channels", s.handleChannelsList) + auth.PATCH("/api/v1/admin/channels/:platform", s.handleChannelPatch) + auth.POST("/api/v1/admin/channels/:platform/publish", s.handleChannelPublish) + auth.POST("/api/v1/admin/channels/reload", s.handleChannelReload) + auth.POST("/api/v1/admin/channels/disable-all", s.handleChannelDisableAll) + auth.POST("/api/v1/admin/channels/:platform/enable", s.handleChannelEnable) + auth.POST("/api/v1/admin/channels/:platform/disable", s.handleChannelDisable) + auth.POST("/api/v1/admin/channels/:platform/test", s.handleChannelTest) + auth.POST("/api/v1/admin/channels/:platform/apply", s.handleChannelApply) + auth.GET("/api/v1/admin/audit", s.handleAuditList) +} + +func (s *WebServer) registerLegacyCompatRoutes(auth *gin.RouterGroup) { + // 兼容老前端调用,统一复用 v1 handler(兼容层) + // + // 废弃计划(仅文档约束,当前不删): + // 1) 新功能与新页面只允许使用 /api/v1/* + // 2) 当确认无旧调用后,再移除以下旧路由映射 + // 3) 每次版本发布前,优先检查是否仍存在对旧路由的引用 + auth.GET("/api/records", s.handleRecordsV1) + auth.POST("/delete/:id", s.handleDeleteV1) + auth.GET("/export", s.handleExportV1) +} + +func (s *WebServer) RegisterRoutes(r *gin.Engine) { + r.LoadHTMLGlob("templates/*") + + r.GET("/login", s.handleLoginPage) + r.POST("/login", s.handleLogin) + r.GET("/logout", s.handleLogout) + r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + + auth := r.Group("/") + auth.Use(s.authRequired()) + { + auth.GET("/", s.handleIndex) + auth.GET("/channels", s.handleChannelsPage) + auth.GET("/audit", s.handleAuditPage) + + s.registerAPIV1Routes(auth) + s.registerLegacyCompatRoutes(auth) + } +} + func (s *WebServer) Start() { gin.SetMode(gin.ReleaseMode) - r := gin.Default() - r.LoadHTMLGlob("templates/*") - - // 公开路由(无需登录) - r.GET("/login", s.handleLoginPage) - r.POST("/login", s.handleLogin) - r.GET("/logout", s.handleLogout) - - // 健康检查(公开) - r.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) - - // 需要登录的路由 - auth := r.Group("/") - auth.Use(s.authRequired()) - { - auth.GET("/", s.handleIndex) - auth.GET("/api/records", s.handleRecords) - auth.POST("/delete/:id", s.handleDelete) - auth.GET("/export", s.handleExport) - } + r := gin.New() + r.Use(gin.Recovery()) + r.Use(gin.Logger()) + s.RegisterRoutes(r) logAddr := fmt.Sprintf(":%d", s.port) fmt.Printf("🌐 Web后台运行在 http://127.0.0.1%s\n", logAddr) @@ -100,14 +278,13 @@ func (s *WebServer) Start() { } func (s *WebServer) handleLoginPage(c *gin.Context) { - // 已登录则跳转首页 username, _ := c.Cookie("xiaji_user") token, _ := c.Cookie("xiaji_token") if username != "" && token != "" && s.validateToken(username, token) { c.Redirect(http.StatusFound, "/") return } - c.HTML(http.StatusOK, "login.html", gin.H{"error": ""}) + s.renderPage(c, "login.html", nil, gin.H{"error": ""}) } func (s *WebServer) handleLogin(c *gin.Context) { @@ -116,31 +293,111 @@ func (s *WebServer) handleLogin(c *gin.Context) { if username == s.username && password == s.password { token := s.generateToken(username) - maxAge := 7 * 24 * 3600 // 7天 - + maxAge := 7 * 24 * 3600 c.SetCookie("xiaji_user", username, maxAge, "/", "", false, true) c.SetCookie("xiaji_token", token, maxAge, "/", "", false, true) + u := s.buildCurrentUser(username) + s.writeAuditResult(u.UserID, "auth.login.success", "user", username, "", "", "", "success") c.Redirect(http.StatusFound, "/") return } - c.HTML(http.StatusOK, "login.html", gin.H{"error": "用户名或密码错误"}) + s.writeAuditResult(0, "auth.login.failed", "user", username, "", "", "用户名或密码错误", "failed") + s.renderPage(c, "login.html", nil, gin.H{"error": "用户名或密码错误"}) } func (s *WebServer) handleLogout(c *gin.Context) { + u := currentUser(c) + if u != nil { + s.writeAuditResult(u.UserID, "auth.logout", "user", u.Username, "", "", "", "success") + } c.SetCookie("xiaji_user", "", -1, "/", "", false, true) c.SetCookie("xiaji_token", "", -1, "/", "", false, true) c.Redirect(http.StatusFound, "/login") } func (s *WebServer) handleIndex(c *gin.Context) { - username, _ := c.Cookie("xiaji_user") - c.HTML(http.StatusOK, "index.html", gin.H{"username": username}) + u := currentUser(c) + s.renderPage(c, "index.html", u, nil) } -func (s *WebServer) handleRecords(c *gin.Context) { +func (s *WebServer) handleChannelsPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "channels.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "channels.html", u, nil) +} + +func (s *WebServer) handleAuditPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "audit.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "audit.html", u, nil) +} + +func (s *WebServer) handleMe(c *gin.Context) { + u := currentUser(c) + if u == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + return + } + + flags := s.getFlagMap() + caps := map[string]bool{ + "can_read_self": s.hasPermission(u, "records.read.self"), + "can_read_all": s.hasPermission(u, "records.read.all") && flags["allow_cross_user_read"], + "can_delete_self": s.hasPermission(u, "records.delete.self"), + "can_delete_all": s.hasPermission(u, "records.delete.all") && flags["allow_cross_user_delete"], + "can_export_self": s.hasPermission(u, "records.export.self"), + "can_export_all": s.hasPermission(u, "records.export.all") && flags["allow_export_all_users"], + "can_view_flags": s.hasPermission(u, "settings.flags.read"), + "can_edit_flags": s.hasPermission(u, "settings.flags.write"), + "can_view_channels": s.hasPermission(u, "channels.read"), + "can_edit_channels": s.hasPermission(u, "channels.write"), + "can_test_channels": s.hasPermission(u, "channels.test"), + "can_view_audit": s.hasPermission(u, "audit.read"), + } + + u.Flags = flags + u.Caps = caps + c.JSON(http.StatusOK, u) +} + +func (s *WebServer) handleRecordsV1(c *gin.Context) { + u := currentUser(c) + if !s.hasPermission(u, "records.read.self") { + s.writeAuditResult(u.UserID, "record.list.self", "transaction", "*", "", "", "无 records.read.self 权限", "denied") + deny(c, "无 records.read.self 权限") + return + } + + scope := c.DefaultQuery("scope", "self") + q := s.db.Model(&models.Transaction{}).Where("is_deleted = ?", false) + action := "record.list.self" + note := "" + + if scope == "all" { + action = "record.list.all" + if !s.hasPermission(u, "records.read.all") { + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.read.all 权限", "denied") + deny(c, "无 records.read.all 权限") + return + } + if !s.flagEnabled("allow_cross_user_read") { + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "策略开关 allow_cross_user_read 未开启", "denied") + deny(c, "策略开关 allow_cross_user_read 未开启") + return + } + } else { + q = q.Where("user_id = ?", u.UserID) + } + var items []models.Transaction - s.db.Where("is_deleted = ?", false).Order("id desc").Limit(50).Find(&items) + q.Order("id desc").Limit(100).Find(&items) type txResponse struct { ID uint `json:"id"` @@ -153,20 +410,15 @@ func (s *WebServer) handleRecords(c *gin.Context) { resp := make([]txResponse, len(items)) for i, item := range items { - resp[i] = txResponse{ - ID: item.ID, - UserID: item.UserID, - Amount: item.AmountYuan(), - Category: item.Category, - Note: item.Note, - Date: item.Date, - } + resp[i] = txResponse{ID: item.ID, UserID: item.UserID, Amount: item.AmountYuan(), Category: item.Category, Note: item.Note, Date: item.Date} } - + note = fmt.Sprintf("scope=%s,count=%d", scope, len(resp)) + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", note, "success") c.JSON(http.StatusOK, resp) } -func (s *WebServer) handleDelete(c *gin.Context) { +func (s *WebServer) handleDeleteV1(c *gin.Context) { + u := currentUser(c) idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { @@ -174,35 +426,598 @@ func (s *WebServer) handleDelete(c *gin.Context) { return } - result := s.db.Model(&models.Transaction{}).Where("id = ? AND is_deleted = ?", id, false).Update("is_deleted", true) - if result.Error != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) - return - } - if result.RowsAffected == 0 { + var tx models.Transaction + if err := s.db.Where("id = ? AND is_deleted = ?", id, false).First(&tx).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在或已删除"}) return } + action := "record.delete.self" + if tx.UserID == u.UserID { + if !s.hasPermission(u, "records.delete.self") { + s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "无 records.delete.self 权限", "denied") + deny(c, "无 records.delete.self 权限") + return + } + } else { + action = "record.delete.all" + if !s.hasPermission(u, "records.delete.all") { + s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "无 records.delete.all 权限", "denied") + deny(c, "无 records.delete.all 权限") + return + } + if !s.flagEnabled("allow_cross_user_delete") { + s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "策略开关 allow_cross_user_delete 未开启", "denied") + deny(c, "策略开关 allow_cross_user_delete 未开启") + return + } + } + + result := s.db.Model(&models.Transaction{}).Where("id = ? AND is_deleted = ?", id, false).Update("is_deleted", true) + if result.Error != nil { + s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", result.Error.Error(), "failed") + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) + return + } + s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", `{"is_deleted":true}`, "", "success") c.JSON(http.StatusOK, gin.H{"status": "success"}) } -func (s *WebServer) handleExport(c *gin.Context) { +func (s *WebServer) handleExportV1(c *gin.Context) { + u := currentUser(c) + scope := c.DefaultQuery("scope", "self") + action := "record.export.self" + + q := s.db.Model(&models.Transaction{}).Where("is_deleted = ?", false) + if scope == "all" { + action = "record.export.all" + if !s.hasPermission(u, "records.export.all") { + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.export.all 权限", "denied") + deny(c, "无 records.export.all 权限") + return + } + if !s.flagEnabled("allow_export_all_users") { + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "策略开关 allow_export_all_users 未开启", "denied") + deny(c, "策略开关 allow_export_all_users 未开启") + return + } + } else { + if !s.hasPermission(u, "records.export.self") { + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.export.self 权限", "denied") + deny(c, "无 records.export.self 权限") + return + } + q = q.Where("user_id = ?", u.UserID) + } + var items []models.Transaction - s.db.Where("is_deleted = ?", false).Order("date asc, id asc").Find(&items) + q.Order("date asc, id asc").Find(&items) now := time.Now().Format("20060102") filename := fmt.Sprintf("xiaji_%s.csv", now) - c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) - // BOM for Excel c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) - c.Writer.WriteString("ID,日期,分类,金额(元),备注\n") - + c.Writer.WriteString("ID,用户ID,日期,分类,金额(元),备注\n") for _, item := range items { - line := fmt.Sprintf("%d,%s,%s,%.2f,\"%s\"\n", item.ID, item.Date, item.Category, item.AmountYuan(), item.Note) + line := fmt.Sprintf("%d,%d,%s,%s,%.2f,\"%s\"\n", item.ID, item.UserID, item.Date, item.Category, item.AmountYuan(), item.Note) c.Writer.WriteString(line) } + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", fmt.Sprintf("scope=%s,count=%d", scope, len(items)), "success") +} + +func (s *WebServer) handleFlagsList(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + var flags []models.FeatureFlag + s.db.Order("key asc").Find(&flags) + c.JSON(http.StatusOK, flags) +} + +func (s *WebServer) handleFlagPatch(c *gin.Context) { + u := currentUser(c) + if !s.hasPermission(u, "settings.flags.write") { + s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", c.Param("key"), "", "", "无 settings.flags.write 权限", "denied") + deny(c, "无 settings.flags.write 权限") + return + } + + key := c.Param("key") + var req flagPatchReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误"}) + return + } + + var ff models.FeatureFlag + if err := s.db.Where("key = ?", key).First(&ff).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "开关不存在"}) + return + } + if ff.RequireReason && strings.TrimSpace(req.Reason) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "该开关修改必须提供 reason"}) + return + } + + before := fmt.Sprintf(`{"enabled":%v}`, ff.Enabled) + old := ff.Enabled + ff.Enabled = req.Enabled + ff.UpdatedBy = u.UserID + if err := s.db.Save(&ff).Error; err != nil { + s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", key, before, "", err.Error(), "failed") + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"}) + return + } + + after := fmt.Sprintf(`{"enabled":%v}`, ff.Enabled) + h := models.FeatureFlagHistory{FlagKey: key, OldValue: old, NewValue: req.Enabled, ChangedBy: u.UserID, Reason: req.Reason, RequestID: c.GetHeader("X-Request-ID")} + _ = s.db.Create(&h).Error + s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", key, before, after, req.Reason, "success") + + c.JSON(http.StatusOK, gin.H{"status": "success", "key": key, "old": old, "new": req.Enabled}) +} + +func sanitizeJSON(raw string) string { + if strings.TrimSpace(raw) == "" { + return "{}" + } + var m map[string]any + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return "{}" + } + for k := range m { + lk := strings.ToLower(k) + if strings.Contains(lk, "token") || strings.Contains(lk, "secret") || strings.Contains(lk, "key") || strings.Contains(lk, "password") { + m[k] = "***" + } + } + b, _ := json.Marshal(m) + return string(b) +} + +func isMaskedSecretsPayload(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return false + } + var walk func(any) bool + walk = func(x any) bool { + switch t := x.(type) { + case map[string]any: + if len(t) == 0 { + return false + } + allMasked := true + for _, vv := range t { + if !walk(vv) { + allMasked = false + break + } + } + return allMasked + case []any: + if len(t) == 0 { + return false + } + for _, vv := range t { + if !walk(vv) { + return false + } + } + return true + case string: + return strings.TrimSpace(t) == "***" + default: + return false + } + } + return walk(v) +} + +func (s *WebServer) handleChannelsList(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.read", "无 channels.read 权限") { + return + } + + var items []models.ChannelConfig + s.db.Order("platform asc").Find(&items) + + type out struct { + ID uint `json:"id"` + Platform string `json:"platform"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + ConfigJSON string `json:"config_json"` + DraftConfigJSON string `json:"draft_config_json"` + Secrets string `json:"secrets"` + DraftSecrets string `json:"draft_secrets"` + HasDraft bool `json:"has_draft"` + PublishedAt *time.Time `json:"published_at"` + LastCheck *time.Time `json:"last_check_at"` + UpdatedAt time.Time `json:"updated_at"` + } + + resp := make([]out, 0, len(items)) + for _, it := range items { + sec := channel.MaybeDecryptPublic(it.SecretJSON) + draftSec := channel.MaybeDecryptPublic(it.DraftSecretJSON) + resp = append(resp, out{ + ID: it.ID, + Platform: it.Platform, + Name: it.Name, + Enabled: it.Enabled, + Status: it.Status, + ConfigJSON: it.ConfigJSON, + DraftConfigJSON: it.DraftConfigJSON, + Secrets: sanitizeJSON(sec), + DraftSecrets: sanitizeJSON(draftSec), + HasDraft: strings.TrimSpace(it.DraftConfigJSON) != "" || strings.TrimSpace(it.DraftSecretJSON) != "", + PublishedAt: it.PublishedAt, + LastCheck: it.LastCheck, + UpdatedAt: it.UpdatedAt, + }) + } + c.JSON(http.StatusOK, resp) +} + +func (s *WebServer) handleChannelPatch(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { + return + } + + platform := c.Param("platform") + var req channelConfigPatchReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误"}) + return + } + + var row models.ChannelConfig + if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"}) + return + } + + before := fmt.Sprintf(`{"draft_config":%s,"draft_secrets":%s}`, + sanitizeJSON(row.DraftConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON))) + + if req.Name != nil { + row.Name = strings.TrimSpace(*req.Name) + } + if req.Enabled != nil { + row.Enabled = *req.Enabled + } + if len(req.Config) > 0 { + row.DraftConfigJSON = string(req.Config) + } + if len(req.Secrets) > 0 { + if isMaskedSecretsPayload(req.Secrets) { + // 前端脱敏占位符(***)不应覆盖真实密钥 + } else { + row.DraftSecretJSON = channel.EncryptSecretJSON(string(req.Secrets)) + } + } + + if err := s.db.Save(&row).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"}) + return + } + + after := fmt.Sprintf(`{"draft_config":%s,"draft_secrets":%s}`, + sanitizeJSON(row.DraftConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON))) + s.writeAudit(u.UserID, "channel_draft_update", "channel", row.Platform, before, after, "") + + c.JSON(http.StatusOK, gin.H{"status": "success", "mode": "draft"}) +} + +func (s *WebServer) handleChannelPublish(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { + return + } + + platform := c.Param("platform") + var row models.ChannelConfig + if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"}) + return + } + + before := fmt.Sprintf(`{"config":%s,"secrets":%s}`, + sanitizeJSON(row.ConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON))) + + if strings.TrimSpace(row.DraftConfigJSON) != "" { + row.ConfigJSON = row.DraftConfigJSON + } + if strings.TrimSpace(row.DraftSecretJSON) != "" { + row.SecretJSON = row.DraftSecretJSON + } + now := time.Now() + row.PublishedAt = &now + row.UpdatedBy = u.UserID + if err := s.db.Save(&row).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败"}) + return + } + + after := fmt.Sprintf(`{"config":%s,"secrets":%s}`, + sanitizeJSON(row.ConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON))) + s.writeAudit(u.UserID, "channel_publish", "channel", row.Platform, before, after, "") + + c.JSON(http.StatusOK, gin.H{"status": "success", "published_at": now}) +} + +func (s *WebServer) handleChannelReload(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { + return + } + + if s.reloadFn == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "reload 未配置"}) + return + } + + detail, err := s.reloadFn() + if err != nil { + s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", "failed: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", detail) + c.JSON(http.StatusOK, gin.H{"status": "success", "detail": detail}) +} + +func (s *WebServer) handleChannelEnable(c *gin.Context) { + s.handleChannelToggle(c, true) +} + +func (s *WebServer) handleChannelDisable(c *gin.Context) { + s.handleChannelToggle(c, false) +} + +func (s *WebServer) handleChannelDisableAll(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { + return + } + + res := s.db.Model(&models.ChannelConfig{}).Where("enabled = ?", true).Updates(map[string]any{ + "enabled": false, + "status": "disabled", + "updated_by": u.UserID, + }) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "批量关闭失败"}) + return + } + s.writeAudit(u.UserID, "channel_disable_all", "channel", "*", "", fmt.Sprintf(`{"affected":%d}`, res.RowsAffected), "") + c.JSON(http.StatusOK, gin.H{"status": "success", "affected": res.RowsAffected}) +} + +func (s *WebServer) handleChannelToggle(c *gin.Context, enable bool) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { + return + } + + platform := c.Param("platform") + var row models.ChannelConfig + if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"}) + return + } + + before := fmt.Sprintf(`{"enabled":%v}`, row.Enabled) + row.Enabled = enable + if !enable { + row.Status = "disabled" + } + row.UpdatedBy = u.UserID + if err := s.db.Save(&row).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"}) + return + } + after := fmt.Sprintf(`{"enabled":%v}`, row.Enabled) + action := "channel_disable" + if enable { + action = "channel_enable" + } + s.writeAudit(u.UserID, action, "channel", row.Platform, before, after, "") + c.JSON(http.StatusOK, gin.H{"status": "success", "enabled": row.Enabled, "platform": row.Platform}) +} + +func (s *WebServer) handleChannelTest(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.test", "无 channels.test 权限") { + return + } + + platform := c.Param("platform") + var row models.ChannelConfig + if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"}) + return + } + + if strings.TrimSpace(row.DraftConfigJSON) != "" { + row.ConfigJSON = row.DraftConfigJSON + } + if strings.TrimSpace(row.DraftSecretJSON) != "" { + row.SecretJSON = row.DraftSecretJSON + } + + now := time.Now() + status, detail := channel.TestChannelConnectivity(context.Background(), row) + row.LastCheck = &now + row.Status = status + if err := s.db.Save(&row).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "测试写入失败"}) + return + } + + s.writeAudit(u.UserID, "channel_test", "channel", row.Platform, "", fmt.Sprintf(`{"status":%q,"detail":%q}`, row.Status, detail), "manual test") + c.JSON(http.StatusOK, gin.H{"status": row.Status, "detail": detail, "platform": row.Platform, "checked_at": now}) +} + +func (s *WebServer) handleChannelApply(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { + return + } + + platform := c.Param("platform") + var req channelConfigPatchReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误", "stage": "patch", "committed": false}) + return + } + + var row models.ChannelConfig + if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在", "stage": "patch", "committed": false}) + return + } + + beforeEnabled := row.Enabled + beforeConfig := row.ConfigJSON + beforeDraftConfig := row.DraftConfigJSON + beforeSecret := channel.MaybeDecryptPublic(row.SecretJSON) + beforeDraftSecret := channel.MaybeDecryptPublic(row.DraftSecretJSON) + + if req.Name != nil { + row.Name = strings.TrimSpace(*req.Name) + } + if req.Enabled != nil { + row.Enabled = *req.Enabled + } + if len(req.Config) > 0 { + row.DraftConfigJSON = string(req.Config) + } + if len(req.Secrets) > 0 { + if isMaskedSecretsPayload(req.Secrets) { + // 前端脱敏占位符(***)不应覆盖真实密钥 + } else { + row.DraftSecretJSON = channel.EncryptSecretJSON(string(req.Secrets)) + } + } + if !row.Enabled { + row.Status = "disabled" + } + if strings.TrimSpace(row.DraftConfigJSON) != "" { + row.ConfigJSON = row.DraftConfigJSON + } + if strings.TrimSpace(row.DraftSecretJSON) != "" { + row.SecretJSON = row.DraftSecretJSON + } + publishAt := time.Now() + row.PublishedAt = &publishAt + row.UpdatedBy = u.UserID + + if err := s.db.Save(&row).Error; err != nil { + s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, "", "", "failed stage=publish: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存并发布失败", "stage": "publish", "committed": false}) + return + } + + before := fmt.Sprintf(`{"enabled":%v,"config":%s,"draft_config":%s,"secrets":%s,"draft_secrets":%s}`, + beforeEnabled, + sanitizeJSON(beforeConfig), + sanitizeJSON(beforeDraftConfig), + sanitizeJSON(beforeSecret), + sanitizeJSON(beforeDraftSecret), + ) + after := fmt.Sprintf(`{"enabled":%v,"config":%s,"draft_config":%s,"secrets":%s,"draft_secrets":%s}`, + row.Enabled, + sanitizeJSON(row.ConfigJSON), + sanitizeJSON(row.DraftConfigJSON), + sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON)), + sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON)), + ) + + if s.reloadFn == nil { + s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, "failed stage=reload: reload 未配置") + c.JSON(http.StatusInternalServerError, gin.H{"error": "reload 未配置", "stage": "reload", "committed": true}) + return + } + detail, err := s.reloadFn() + if err != nil { + s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, "failed stage=reload: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "stage": "reload", "committed": true}) + return + } + + note := fmt.Sprintf("apply(patch+publish+reload) detail=%s", detail) + s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, note) + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "platform": row.Platform, + "published_at": publishAt, + "detail": detail, + }) +} + +func (s *WebServer) handleAuditList(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") { + return + } + + action := strings.TrimSpace(c.Query("action")) + targetType := strings.TrimSpace(c.Query("target_type")) + result := strings.TrimSpace(c.Query("result")) + actorID := strings.TrimSpace(c.Query("actor_id")) + from := strings.TrimSpace(c.Query("from")) + to := strings.TrimSpace(c.Query("to")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100")) + if limit <= 0 || limit > 500 { + limit = 100 + } + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + if offset < 0 { + offset = 0 + } + + q := s.db.Model(&models.AuditLog{}) + if action != "" { + q = q.Where("action = ?", action) + } + if targetType != "" { + q = q.Where("target_type = ?", targetType) + } + if actorID != "" { + if aid, err := strconv.ParseInt(actorID, 10, 64); err == nil { + q = q.Where("actor_id = ?", aid) + } + } + if from != "" { + if t, err := time.Parse(time.RFC3339, from); err == nil { + q = q.Where("created_at >= ?", t) + } + } + if to != "" { + if t, err := time.Parse(time.RFC3339, to); err == nil { + q = q.Where("created_at <= ?", t) + } + } + if result != "" { + q = q.Where("note LIKE ?", "%result="+result+"%") + } + + var logs []models.AuditLog + if err := q.Order("id desc").Limit(limit).Offset(offset).Find(&logs).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"}) + return + } + c.JSON(http.StatusOK, logs) } diff --git a/models/models.go b/models/models.go index 3936448..9ddeb1e 100644 --- a/models/models.go +++ b/models/models.go @@ -7,15 +7,15 @@ import ( ) type Transaction struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID int64 `json:"user_id"` - Amount int64 `json:"amount"` // 金额,单位:分 - Category string `gorm:"size:50" json:"category"` - Note string `json:"note"` - Date string `gorm:"size:20;index" json:"date"` - IsDeleted bool `gorm:"default:false" json:"is_deleted"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + UserID int64 `json:"user_id"` + Amount int64 `json:"amount"` // 金额,单位:分 + Category string `gorm:"size:50" json:"category"` + Note string `json:"note"` + Date string `gorm:"size:20;index" json:"date"` + IsDeleted bool `gorm:"default:false" json:"is_deleted"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CategoryKeyword struct { @@ -24,14 +24,126 @@ type CategoryKeyword struct { 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 返回元为单位的金额(显示用) func (t *Transaction) AmountYuan() float64 { 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 自动迁移数据库表结构并初始化分类关键词 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 } diff --git a/templates/audit.html b/templates/audit.html new file mode 100644 index 0000000..7732b20 --- /dev/null +++ b/templates/audit.html @@ -0,0 +1,135 @@ + + + + + +🧾 审计日志 - 虾记 + + + +
+
+
🧾 审计日志
+
{{.version}}
+
+ +
+ +
+
+
操作类型
+
目标类型
+
结果
+
操作人ID
+
开始时间(RFC3339)
+
结束时间(RFC3339)
+
+
+ + +
+
+
+ + + + diff --git a/templates/channels.html b/templates/channels.html new file mode 100644 index 0000000..719bd03 --- /dev/null +++ b/templates/channels.html @@ -0,0 +1,282 @@ + + + + + +渠道配置 - 虾记 + + + +
+
🦞 渠道配置中心(草稿/发布) · {{.version}}
+ +
+
+
+ + +
+
默认推荐:直接点“保存并立即生效”。高级场景再用“保存草稿 / 发布草稿 / 热加载”。
+
+
+ + + + diff --git a/templates/index.html b/templates/index.html index 94dceda..1de064e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5,95 +5,91 @@ 🦞 虾记记账 -

🦞 虾记记账

-
Xiaji-Go 记账管理
- 退出 +
{{.version}}
+ 退出
-
-
0.00
-
今日支出
-
-
-
0.00
-
本月支出
-
-
-
0
-
总记录数
-
+
0.00
今日支出
+
0.00
本月支出
+
0
总记录数
- + +
- 📥 导出CSV + + + + 📥 导出CSV +
+ +
+
高级功能开关(高风险默认关闭)
+
@@ -103,95 +99,139 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-

确认删除?

- - + +
diff --git a/templates/login.html b/templates/login.html index 37d410b..4e917d9 100644 --- a/templates/login.html +++ b/templates/login.html @@ -103,7 +103,7 @@ body { {{if .error}}