feat: 模块化命令框架 + runbook 严格断言与查询结果回传
- core/module: 命令模板、门控(feature flag/confirm/dry-run)、ecode 错误标签、ResultFormatter - runbook executor: http.get/http.post/assert.json(expect_success 严格断言)、目标主机管理、任务落库、最终 stdout 直接回传 - cf 模块: /cf dns list 支持域名自动解析 zone_id;dns list/zones/workers list 格式化回传;API success:false 判真失败 - 全部 CF/CPA runbook 接入严格断言,杜绝假阳性 success - 新增 cpa/cfdav/mail/sub 模块与 runbooks;QQ 订阅到期提醒调度 - Web 后台: 模块设置(CF/CPA/cfdav/AI)、OPS 任务、渠道管理、审计页
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user