package qq import ( "bytes" "context" "encoding/json" "fmt" "log" "net/http" "strings" "time" "ops-assistant/models" "github.com/tencent-connect/botgo/dto" ) type subRemindItem struct { ID string `json:"id"` Name string `json:"name"` Category string `json:"category"` Account string `json:"account"` Amount float64 `json:"amount"` Currency string `json:"currency"` DueDate string `json:"due_date"` CycleUnit string `json:"cycle_unit"` CycleValue int `json:"cycle_value"` Remind []int `json:"remind_days"` } type subListResp struct { Success bool `json:"success"` Data []subRemindItem `json:"data"` Error string `json:"error"` } type subNotifySettings struct { APIBase string Token string NotifyQQUserID string NotifyGroupID string NotifyWeComWebhook string NotifyTime string } func (b *QQBot) StartSubScheduler(ctx context.Context, hour, minute int) { if b.db == nil { return } go func() { b.trySendTestOnce(ctx) for { next := nextRunTime(hour, minute) t := time.NewTimer(time.Until(next)) select { case <-ctx.Done(): t.Stop() return case <-t.C: b.runSubNotifyOnce(ctx) } } }() } func (b *QQBot) trySendTestOnce(ctx context.Context) { if b.db == nil { log.Printf("[sub_notify] test skip: db nil") return } var s models.AppSetting if err := b.db.Where("key = ?", "sub_notify_test_once").First(&s).Error; err != nil { log.Printf("[sub_notify] test skip: setting not found") return } if strings.TrimSpace(s.Value) != "1" { log.Printf("[sub_notify] test skip: flag=%s", strings.TrimSpace(s.Value)) return } cfg, err := b.loadSubNotifySettings() if err != nil { log.Printf("[sub_notify] test skip: load settings err=%v", err) return } if strings.TrimSpace(cfg.NotifyWeComWebhook) == "" && strings.TrimSpace(cfg.NotifyQQUserID) == "" && strings.TrimSpace(cfg.NotifyGroupID) == "" { log.Printf("[sub_notify] test skip: notify target empty") return } for i := 0; i < 10; i++ { if b.api != nil { break } select { case <-ctx.Done(): log.Printf("[sub_notify] test skip: ctx done") return case <-time.After(1 * time.Second): } } if b.api == nil { log.Printf("[sub_notify] test skip: api nil") return } msg := fmt.Sprintf("✅ 订阅提醒测试 %s", time.Now().Format("01-02 15:04")) if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" { if err := sendWeCom(cfg.NotifyWeComWebhook, msg); err == nil { s.Value = "0" _ = b.db.Save(&s).Error log.Printf("[sub_notify] test send ok wecom") } else { log.Printf("[sub_notify] test send failed wecom err=%v", err) } return } if strings.TrimSpace(cfg.NotifyGroupID) != "" { if err := b.sendGroup(cfg.NotifyGroupID, msg); err == nil { s.Value = "0" _ = b.db.Save(&s).Error log.Printf("[sub_notify] test send ok group=%s", cfg.NotifyGroupID) } else { log.Printf("[sub_notify] test send failed group=%s err=%v", cfg.NotifyGroupID, err) } return } if err := b.sendC2C(cfg.NotifyQQUserID, msg); err == nil { s.Value = "0" _ = b.db.Save(&s).Error log.Printf("[sub_notify] test send ok user=%s", cfg.NotifyQQUserID) } else { log.Printf("[sub_notify] test send failed user=%s err=%v", cfg.NotifyQQUserID, err) } } func nextRunTime(hour, minute int) time.Time { now := time.Now() loc := now.Location() next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc) if !next.After(now) { next = next.Add(24 * time.Hour) } return next } func (b *QQBot) runSubNotifyOnce(ctx context.Context) { cfg, err := b.loadSubNotifySettings() if err != nil { return } if strings.TrimSpace(cfg.NotifyWeComWebhook) == "" && strings.TrimSpace(cfg.NotifyQQUserID) == "" && strings.TrimSpace(cfg.NotifyGroupID) == "" { return } url := fmt.Sprintf("%s/api/subscriptions?user_id=1&channel=qq", strings.TrimRight(cfg.APIBase, "/")) body, status, err := doSubRequest("GET", url, nil, cfg.Token) if err != nil || status >= 400 { return } var out subListResp if err := json.Unmarshal([]byte(body), &out); err != nil || !out.Success { return } if len(out.Data) == 0 { return } now := time.Now() today := now.Format("2006-01-02") for _, it := range out.Data { if it.DueDate == "" { continue } due, err := time.ParseInLocation("2006-01-02", it.DueDate, now.Location()) if err != nil { continue } daysLeft := int(due.Sub(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())).Hours() / 24) if !shouldRemind(it.Remind, daysLeft) { continue } targetID := cfg.NotifyQQUserID if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" { targetID = "wecom" } else if strings.TrimSpace(cfg.NotifyGroupID) != "" { targetID = cfg.NotifyGroupID } if b.hasNotified("qqbot_official", targetID, it.ID, today, daysLeft) { continue } msg := formatSubReminder(it, daysLeft) if msg == "" { continue } if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" { _ = sendWeCom(cfg.NotifyWeComWebhook, msg) } else if strings.TrimSpace(cfg.NotifyGroupID) != "" { _ = b.sendGroup(cfg.NotifyGroupID, msg) } else { _ = b.sendC2C(cfg.NotifyQQUserID, msg) } b.markNotified("qqbot_official", targetID, it.ID, today, daysLeft) } } func (b *QQBot) loadSubNotifySettings() (*subNotifySettings, error) { if b.db == nil { return nil, fmt.Errorf("db 未初始化") } get := func(key string) string { var s models.AppSetting if err := b.db.Where("key = ?", key).First(&s).Error; err == nil { return strings.TrimSpace(s.Value) } return "" } cfg := &subNotifySettings{ APIBase: get("sub_api_base"), Token: get("sub_api_token"), NotifyQQUserID: get("sub_notify_qq_user_id"), NotifyGroupID: get("sub_notify_group_id"), NotifyWeComWebhook: get("sub_notify_wecom_webhook"), NotifyTime: get("sub_notify_time"), } if cfg.APIBase == "" { return nil, fmt.Errorf("sub_api_base 未配置") } if cfg.Token == "" { return nil, fmt.Errorf("sub_api_token 未配置") } return cfg, nil } func shouldRemind(remind []int, daysLeft int) bool { for _, d := range remind { if d == daysLeft { return true } } return false } func (b *QQBot) hasNotified(platform, userID, subID, date string, daysLeft int) bool { var row models.SubNotifyLog if err := b.db.Where("platform=? AND external_user_id=? AND sub_id=? AND notify_date=? AND days_left=?", platform, userID, subID, date, daysLeft).First(&row).Error; err == nil { return true } return false } func (b *QQBot) markNotified(platform, userID, subID, date string, daysLeft int) { _ = b.db.Create(&models.SubNotifyLog{Platform: platform, ExternalUserID: userID, SubID: subID, NotifyDate: date, DaysLeft: daysLeft, CreatedAt: time.Now()}).Error } func formatSubReminder(it subRemindItem, daysLeft int) string { prefix := "⏰" if daysLeft < 0 { prefix = "⚠️" } return fmt.Sprintf("%s 订阅提醒:%s | 分类:%s | 到期:%s | 剩余:%d天 | 金额:%.2f %s", prefix, it.Name, it.Category, it.DueDate, daysLeft, it.Amount, it.Currency) } func (b *QQBot) sendC2C(userID, msg string) error { if b.api == nil { log.Printf("[sub_notify] skip send: api nil") return nil } _, err := b.api.PostC2CMessage(context.Background(), userID, dto.MessageToCreate{Content: msg}) if err != nil { log.Printf("[sub_notify] send failed user=%s err=%v", userID, err) return err } log.Printf("[sub_notify] send ok user=%s", userID) return nil } func (b *QQBot) sendGroup(groupID, msg string) error { if b.api == nil { log.Printf("[sub_notify] skip send group: api nil") return nil } _, err := b.api.PostGroupMessage(context.Background(), groupID, dto.MessageToCreate{Content: msg}) if err != nil { log.Printf("[sub_notify] send failed group=%s err=%v", groupID, err) return err } log.Printf("[sub_notify] send ok group=%s", groupID) return nil } func sendWeCom(webhook, msg string) error { payload := map[string]any{ "msgtype": "text", "text": map[string]any{"content": msg}, } body, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPost, webhook, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { return fmt.Errorf("wecom status %d", resp.StatusCode) } return nil }