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,11 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"ops-assistant/internal/core/ai"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func buildAIClient(db *gorm.DB) *ai.Client {
|
||||
return ai.LoadClient(db)
|
||||
}
|
||||
+324
-83
@@ -4,11 +4,17 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"xiaji-go/internal/service"
|
||||
"xiaji-go/models"
|
||||
"ops-assistant/internal/core/ops"
|
||||
"ops-assistant/internal/service"
|
||||
|
||||
"ops-assistant/internal/core/ai"
|
||||
"ops-assistant/models"
|
||||
|
||||
"github.com/tencent-connect/botgo"
|
||||
"github.com/tencent-connect/botgo/dto"
|
||||
@@ -16,6 +22,7 @@ import (
|
||||
"github.com/tencent-connect/botgo/event"
|
||||
"github.com/tencent-connect/botgo/openapi"
|
||||
"github.com/tencent-connect/botgo/token"
|
||||
"github.com/tidwall/gjson"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -23,20 +30,26 @@ import (
|
||||
const DefaultUserID int64 = 1
|
||||
|
||||
type QQBot struct {
|
||||
api openapi.OpenAPI
|
||||
finance *service.FinanceService
|
||||
credentials *token.QQBotCredentials
|
||||
db *gorm.DB
|
||||
api openapi.OpenAPI
|
||||
finance *service.FinanceService
|
||||
credentials *token.QQBotCredentials
|
||||
db *gorm.DB
|
||||
opsSvc *ops.Service
|
||||
aiClient *ai.Client
|
||||
aiAutoReload time.Time
|
||||
}
|
||||
|
||||
func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService) *QQBot {
|
||||
func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService, opsSvc *ops.Service) *QQBot {
|
||||
return &QQBot{
|
||||
db: db,
|
||||
finance: finance,
|
||||
opsSvc: opsSvc,
|
||||
credentials: &token.QQBotCredentials{
|
||||
AppID: appID,
|
||||
AppSecret: secret,
|
||||
},
|
||||
aiClient: buildAIClient(db),
|
||||
aiAutoReload: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,87 +110,315 @@ func (b *QQBot) processAndReply(userID string, content string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
switch {
|
||||
case isCommand(text, "帮助", "help", "/help", "/start", "菜单", "功能"):
|
||||
return "🦞 虾记记账\n\n" +
|
||||
"直接发送消费描述即可记账:\n" +
|
||||
"• 午饭 25元\n" +
|
||||
"• 打车 ¥30\n" +
|
||||
"• 买咖啡15块\n\n" +
|
||||
"📋 命令列表:\n" +
|
||||
"• 记录/查看 — 最近10条\n" +
|
||||
"• 今日/今天 — 今日汇总\n" +
|
||||
"• 统计/报表 — 本月分类统计\n" +
|
||||
"• 帮助 — 本帮助信息"
|
||||
|
||||
case isCommand(text, "查看", "记录", "列表", "list", "/list", "最近"):
|
||||
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()
|
||||
|
||||
case isCommand(text, "今日", "今天", "today"):
|
||||
items, err := b.finance.GetTransactionsByDate(DefaultUserID, today)
|
||||
if err != nil {
|
||||
return "❌ 查询失败"
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Sprintf("📭 %s 暂无消费记录", today)
|
||||
}
|
||||
var sb strings.Builder
|
||||
var total int64
|
||||
sb.WriteString(fmt.Sprintf("📊 今日(%s)消费:\n\n", today))
|
||||
for _, item := range items {
|
||||
sb.WriteString(fmt.Sprintf("• %s:%.2f元\n", item.Category, item.AmountYuan()))
|
||||
total += item.Amount
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", len(items), float64(total)/100.0))
|
||||
return sb.String()
|
||||
|
||||
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()
|
||||
// sub 引导式新增流程(优先级高于模块命令)
|
||||
if reply, handled := b.handleSubFlow(userID, text); handled {
|
||||
return reply
|
||||
}
|
||||
|
||||
amount, category, err := b.finance.AddTransaction(DefaultUserID, text)
|
||||
// 先交给 opsSvc 处理命令
|
||||
if b.opsSvc != nil {
|
||||
if handled, out := b.opsSvc.Handle(DefaultUserID, text); handled {
|
||||
if strings.HasPrefix(text, "/cpa ") || text == "/cpa" || strings.HasPrefix(text, "/cf ") || strings.HasPrefix(text, "/mail ") || strings.HasPrefix(text, "/cfdav ") || text == "/cfdav" {
|
||||
jobID := parseJobID(out)
|
||||
if jobID > 0 {
|
||||
go b.waitAndPushJobResult(userID, jobID, text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// 规则兜底:常见自然语映射到标准命令
|
||||
if b.opsSvc != nil {
|
||||
norm := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(text), " ", ""), " ", "")
|
||||
if (strings.Contains(norm, "域名") || strings.Contains(norm, "站点")) && !strings.Contains(norm, "解析") && !strings.Contains(strings.ToLower(norm), "dns") {
|
||||
cmd := "/cf zones"
|
||||
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
|
||||
jobID := parseJobID(out)
|
||||
if jobID > 0 {
|
||||
go b.waitAndPushJobResult(userID, jobID, cmd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
switch norm {
|
||||
case "cpa状态", "CPA状态", "cpaStatus", "cpastatus":
|
||||
cmd := "/cpa status"
|
||||
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
|
||||
jobID := parseJobID(out)
|
||||
if jobID > 0 {
|
||||
go b.waitAndPushJobResult(userID, jobID, cmd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
case "功能", "菜单", "帮助", "help", "Help", "HELP", "你能做什么", "你会什么":
|
||||
cmd := "/help"
|
||||
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
|
||||
return out
|
||||
}
|
||||
case "cf状态", "cf配置", "cf配置状态", "cloudflare状态", "cloudflare配置":
|
||||
cmd := "/cf status"
|
||||
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
|
||||
jobID := parseJobID(out)
|
||||
if jobID > 0 {
|
||||
go b.waitAndPushJobResult(userID, jobID, cmd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
case "cf域名", "cf账号域名", "cfzones", "cf zones", "cloudflare域名", "cloudflare站点", "站点列表", "域名列表", "我的域名", "域名清单":
|
||||
cmd := "/cf zones"
|
||||
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
|
||||
jobID := parseJobID(out)
|
||||
if jobID > 0 {
|
||||
go b.waitAndPushJobResult(userID, jobID, cmd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
case "cf解析", "cf记录", "解析记录", "dns记录", "dns列表", "列解析", "列记录":
|
||||
return "❌ 缺少 zone_id,请用:/cf dns list <zone_id>"
|
||||
case "cfworkers", "cf workers", "workers列表", "workers list", "列workers":
|
||||
cmd := "/cf workers list"
|
||||
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
|
||||
jobID := parseJobID(out)
|
||||
if jobID > 0 {
|
||||
go b.waitAndPushJobResult(userID, jobID, cmd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非命令:尝试 AI 翻译 -> 标准命令
|
||||
if time.Since(b.aiAutoReload) > 3*time.Second {
|
||||
b.aiClient = buildAIClient(b.db)
|
||||
b.aiAutoReload = time.Now()
|
||||
}
|
||||
if b.aiClient != nil {
|
||||
if cmd, err := b.aiClient.Suggest(text); err == nil {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd != "" && cmd != "FAIL" && strings.HasPrefix(cmd, "/") {
|
||||
// 仅翻译成命令,交给模块处理(不回译文)
|
||||
if b.opsSvc != nil {
|
||||
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
|
||||
if strings.HasPrefix(cmd, "/cpa ") || cmd == "/cpa" || strings.HasPrefix(cmd, "/cf ") || strings.HasPrefix(cmd, "/mail ") {
|
||||
jobID := parseJobID(out)
|
||||
if jobID > 0 {
|
||||
go b.waitAndPushJobResult(userID, jobID, cmd)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return "❌ 无法识别,请使用标准命令"
|
||||
}
|
||||
return "❌ 无法识别,请使用标准命令"
|
||||
} else {
|
||||
if strings.Contains(err.Error(), "rate limited") {
|
||||
return "⚠️ AI 服务繁忙,请稍后再试或使用标准命令"
|
||||
}
|
||||
return "❌ 无法识别,请使用标准命令"
|
||||
}
|
||||
}
|
||||
// 仍然无法处理
|
||||
return "❌ 无法识别,请使用标准命令"
|
||||
}
|
||||
|
||||
func parseJobID(out string) uint {
|
||||
re := regexp.MustCompile(`job=(\d+)`)
|
||||
m := re.FindStringSubmatch(out)
|
||||
if len(m) < 2 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return uint(n)
|
||||
}
|
||||
|
||||
func (b *QQBot) waitAndPushJobResult(userID string, jobID uint, cmd string) {
|
||||
if b.db == nil {
|
||||
return
|
||||
}
|
||||
var job models.OpsJob
|
||||
for i := 0; i < 15; i++ { // 最多等 ~30s
|
||||
time.Sleep(2 * time.Second)
|
||||
if err := b.db.First(&job, jobID).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
if job.Status == "pending" || job.Status == "running" {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if job.ID == 0 || job.Status == "pending" || job.Status == "running" {
|
||||
return
|
||||
}
|
||||
msg := formatJobResult(b.db, jobID, cmd)
|
||||
if strings.TrimSpace(msg) == "" {
|
||||
return
|
||||
}
|
||||
_, err := b.api.PostC2CMessage(context.Background(), userID, dto.MessageToCreate{Content: msg})
|
||||
if err != nil {
|
||||
log.Printf("QQ记账失败 user=%s: %v", userID, err)
|
||||
return "❌ 记账失败,请稍后重试"
|
||||
}
|
||||
if amount == 0 {
|
||||
return "📍 没看到金额,这笔花了多少钱?"
|
||||
log.Printf("QQ 推送任务结果失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
amountYuan := float64(amount) / 100.0
|
||||
return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, amountYuan, text)
|
||||
func formatJobResult(db *gorm.DB, jobID uint, cmd string) string {
|
||||
var job models.OpsJob
|
||||
if err := db.First(&job, jobID).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
if job.Runbook == "cpa_status" {
|
||||
return formatCPAStatusResult(db, jobID, job.Status)
|
||||
}
|
||||
if job.Runbook == "cpa_usage_backup" {
|
||||
return formatCPAUsageBackupResult(db, jobID)
|
||||
}
|
||||
if job.Runbook == "cf_zones" {
|
||||
return formatCFZonesResult(db, jobID, cmd)
|
||||
}
|
||||
if job.Runbook == "cf_workers_list" {
|
||||
return formatCFWorkersResult(db, jobID, cmd)
|
||||
}
|
||||
return fmt.Sprintf("📦 %s 结果:%s (job=%d)", strings.TrimSpace(cmd), job.Status, jobID)
|
||||
}
|
||||
|
||||
func formatCFZonesResult(db *gorm.DB, jobID uint, cmd string) string {
|
||||
var steps []models.OpsJobStep
|
||||
_ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error
|
||||
var raw string
|
||||
for _, st := range steps {
|
||||
if st.StepID == "list_zones" {
|
||||
raw = strings.TrimSpace(st.StdoutTail)
|
||||
break
|
||||
}
|
||||
}
|
||||
if raw == "" {
|
||||
return fmt.Sprintf("📦 %s 结果:success (job=%d)", strings.TrimSpace(cmd), jobID)
|
||||
}
|
||||
arr := gjson.Get(raw, "zones").Array()
|
||||
if len(arr) == 0 {
|
||||
arr = gjson.Get(raw, "result").Array()
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return fmt.Sprintf("📦 %s 结果:success (job=%d)\n(no zones)", strings.TrimSpace(cmd), jobID)
|
||||
}
|
||||
lines := make([]string, 0, len(arr)+2)
|
||||
lines = append(lines, fmt.Sprintf("✅ %s 完成 (job=%d)", strings.TrimSpace(cmd), jobID))
|
||||
limit := len(arr)
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
name := arr[i].Get("name").String()
|
||||
id := arr[i].Get("id").String()
|
||||
if name == "" && id == "" {
|
||||
continue
|
||||
}
|
||||
if id != "" {
|
||||
lines = append(lines, fmt.Sprintf("- %s (%s)", name, id))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- %s", name))
|
||||
}
|
||||
}
|
||||
if len(arr) > limit {
|
||||
lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(arr), limit))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatCFWorkersResult(db *gorm.DB, jobID uint, cmd string) string {
|
||||
var steps []models.OpsJobStep
|
||||
_ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error
|
||||
var raw string
|
||||
for _, st := range steps {
|
||||
if st.StepID == "list_workers" {
|
||||
raw = strings.TrimSpace(st.StdoutTail)
|
||||
break
|
||||
}
|
||||
}
|
||||
if raw == "" {
|
||||
return fmt.Sprintf("📦 %s 结果:success (job=%d)", strings.TrimSpace(cmd), jobID)
|
||||
}
|
||||
arr := gjson.Get(raw, "workers").Array()
|
||||
if len(arr) == 0 {
|
||||
arr = gjson.Get(raw, "result").Array()
|
||||
}
|
||||
lines := make([]string, 0, len(arr)+2)
|
||||
lines = append(lines, fmt.Sprintf("✅ %s 完成 (job=%d)", strings.TrimSpace(cmd), jobID))
|
||||
if len(arr) == 0 {
|
||||
lines = append(lines, "(no workers)")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
limit := len(arr)
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
name := arr[i].Get("id").String()
|
||||
if name == "" {
|
||||
name = arr[i].Get("name").String()
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s", name))
|
||||
}
|
||||
if len(arr) > limit {
|
||||
lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(arr), limit))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatCPAUsageBackupResult(db *gorm.DB, jobID uint) string {
|
||||
var steps []models.OpsJobStep
|
||||
_ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error
|
||||
var raw string
|
||||
for _, st := range steps {
|
||||
if st.StepID == "export_and_package" {
|
||||
raw = strings.TrimSpace(st.StdoutTail)
|
||||
break
|
||||
}
|
||||
}
|
||||
if raw == "" {
|
||||
return fmt.Sprintf("✅ /cpa usage backup 执行成功(job=%d)", jobID)
|
||||
}
|
||||
backup := ""
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
if strings.HasPrefix(line, "backup=") {
|
||||
backup = strings.TrimSpace(strings.TrimPrefix(line, "backup="))
|
||||
break
|
||||
}
|
||||
}
|
||||
if backup == "" {
|
||||
return fmt.Sprintf("✅ /cpa usage backup 执行成功(job=%d)", jobID)
|
||||
}
|
||||
file := path.Base(backup)
|
||||
return fmt.Sprintf("✅ /cpa usage backup 执行成功(job=%d)\n📦 备份文件:%s\n📁 路径:%s", jobID, file, path.Dir(backup)+"/")
|
||||
}
|
||||
|
||||
func formatCPAStatusResult(db *gorm.DB, jobID uint, status string) string {
|
||||
var steps []models.OpsJobStep
|
||||
_ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error
|
||||
var svc, usage string
|
||||
for _, st := range steps {
|
||||
if st.StepID == "service_status" {
|
||||
svc = strings.TrimSpace(st.StdoutTail)
|
||||
}
|
||||
if st.StepID == "usage_snapshot" {
|
||||
usage = strings.TrimSpace(st.StdoutTail)
|
||||
}
|
||||
}
|
||||
tr := gjson.Get(usage, "usage.total_requests").String()
|
||||
tt := gjson.Get(usage, "usage.total_tokens").String()
|
||||
if tr == "" {
|
||||
tr = "-"
|
||||
}
|
||||
if tt == "" {
|
||||
tt = "-"
|
||||
}
|
||||
return fmt.Sprintf("✅ /cpa status 完成 (job=%d)\nservice=%s\nrequests=%s\ntokens=%s", jobID, svc, tr, tt)
|
||||
}
|
||||
|
||||
func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ops-assistant/models"
|
||||
)
|
||||
|
||||
type subSessionPayload struct {
|
||||
Step string `json:"step"`
|
||||
Temp map[string]string `json:"temp"`
|
||||
}
|
||||
|
||||
type subAPISettings struct {
|
||||
APIBase string
|
||||
Token string
|
||||
}
|
||||
|
||||
const (
|
||||
subStepName = "WAIT_NAME"
|
||||
subStepCategory = "WAIT_CATEGORY"
|
||||
subStepAcct = "WAIT_ACCOUNT"
|
||||
subStepAmount = "WAIT_AMOUNT"
|
||||
subStepCurrency = "WAIT_CURRENCY"
|
||||
subStepDate = "WAIT_DATE"
|
||||
subStepCycleUnit = "WAIT_CYCLE_UNIT"
|
||||
subStepCycleValue = "WAIT_CYCLE_VALUE"
|
||||
)
|
||||
|
||||
var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
|
||||
func (b *QQBot) handleSubFlow(userID string, text string) (string, bool) {
|
||||
raw := strings.TrimSpace(text)
|
||||
if !strings.HasPrefix(raw, "/sub") {
|
||||
if raw == "/cancel" {
|
||||
if b.hasSubSession(userID) {
|
||||
b.clearSubSession(userID)
|
||||
return "✅ 已取消订阅新增流程", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if !b.hasSubSession(userID) {
|
||||
return "", false
|
||||
}
|
||||
} else {
|
||||
if raw == "/sub" || strings.HasPrefix(raw, "/sub help") {
|
||||
if b.opsSvc != nil {
|
||||
if handled, out := b.opsSvc.Handle(DefaultUserID, raw); handled {
|
||||
return out, true
|
||||
}
|
||||
}
|
||||
return "订阅模块\n- /sub add\n- /sub list\n- /sub report\n- /sub report category\n- /sub report cycle\n- /sub report due <7|30|overdue>\n- /sub del <id>\n- /sub cancel(或 /cancel)取消新增流程", true
|
||||
}
|
||||
if raw == "/sub cancel" {
|
||||
if b.hasSubSession(userID) {
|
||||
b.clearSubSession(userID)
|
||||
return "✅ 已取消订阅新增流程", true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
if raw == "/sub add" || strings.HasPrefix(raw, "/sub add ") {
|
||||
b.setSubSession(userID, &subSessionPayload{Step: subStepName, Temp: map[string]string{}})
|
||||
return "1️⃣ 输入订阅名称(例如:Netflix)", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
sess := b.getSubSession(userID)
|
||||
if sess == nil {
|
||||
return "", false
|
||||
}
|
||||
msg := raw
|
||||
if msg == "" {
|
||||
return "", true
|
||||
}
|
||||
if sess.Temp == nil {
|
||||
sess.Temp = map[string]string{}
|
||||
}
|
||||
|
||||
switch sess.Step {
|
||||
case subStepName:
|
||||
sess.Temp["name"] = msg
|
||||
sess.Step = subStepCategory
|
||||
b.setSubSession(userID, sess)
|
||||
return "2️⃣ 输入分类(例如:通信/视频/游戏/工具/其他,可 /skip=未分类)", true
|
||||
case subStepCategory:
|
||||
if msg == "/skip" {
|
||||
sess.Temp["category"] = "未分类"
|
||||
} else {
|
||||
sess.Temp["category"] = msg
|
||||
}
|
||||
if strings.TrimSpace(sess.Temp["category"]) == "" {
|
||||
return "❌ 分类不能为空,请重新输入(可 /skip)", true
|
||||
}
|
||||
sess.Step = subStepAcct
|
||||
b.setSubSession(userID, sess)
|
||||
return "3️⃣ 输入账号/备注(可选,回复 /skip 跳过)", true
|
||||
case subStepAcct:
|
||||
if msg == "/skip" {
|
||||
sess.Temp["account"] = ""
|
||||
} else {
|
||||
sess.Temp["account"] = msg
|
||||
}
|
||||
sess.Step = subStepAmount
|
||||
b.setSubSession(userID, sess)
|
||||
return "4️⃣ 输入金额(数字,例如 9.99)", true
|
||||
case subStepAmount:
|
||||
amt, err := strconv.ParseFloat(msg, 64)
|
||||
if err != nil || amt < 0 {
|
||||
return "❌ 金额格式错误,请输入数字(例如 9.99)", true
|
||||
}
|
||||
sess.Temp["amount"] = fmt.Sprintf("%.2f", amt)
|
||||
sess.Step = subStepCurrency
|
||||
b.setSubSession(userID, sess)
|
||||
return "5️⃣ 输入币种(CNY/USD/EUR/GBP/HKD/JPY/MYR/SGD,或 /skip 默认 CNY)", true
|
||||
case subStepCurrency:
|
||||
cur := strings.ToUpper(msg)
|
||||
if msg == "/skip" {
|
||||
cur = "CNY"
|
||||
}
|
||||
if !isAllowedCurrency(cur) {
|
||||
return "❌ 币种不支持,请输入 CNY/USD/EUR/GBP/HKD/JPY/MYR/SGD", true
|
||||
}
|
||||
sess.Temp["currency"] = cur
|
||||
sess.Step = subStepDate
|
||||
b.setSubSession(userID, sess)
|
||||
return "6️⃣ 输入到期日(YYYY-MM-DD)", true
|
||||
case subStepDate:
|
||||
if !dateRe.MatchString(msg) {
|
||||
return "❌ 日期格式错误,请用 YYYY-MM-DD", true
|
||||
}
|
||||
sess.Temp["due_date"] = msg
|
||||
sess.Step = subStepCycleUnit
|
||||
b.setSubSession(userID, sess)
|
||||
return "7️⃣ 输入周期单位(day/month/year)", true
|
||||
case subStepCycleUnit:
|
||||
unit := strings.ToLower(msg)
|
||||
if !isAllowedCycleUnit(unit) {
|
||||
return "❌ 周期单位不支持,请输入 day/month/year", true
|
||||
}
|
||||
sess.Temp["cycle_unit"] = unit
|
||||
sess.Step = subStepCycleValue
|
||||
b.setSubSession(userID, sess)
|
||||
return "8️⃣ 输入周期数量(数字)", true
|
||||
case subStepCycleValue:
|
||||
cv, err := strconv.Atoi(msg)
|
||||
if err != nil || cv <= 0 {
|
||||
return "❌ 周期数量必须是正整数", true
|
||||
}
|
||||
sess.Temp["cycle_value"] = strconv.Itoa(cv)
|
||||
b.clearSubSession(userID)
|
||||
return b.subCreate(sess)
|
||||
default:
|
||||
b.clearSubSession(userID)
|
||||
return "❌ 会话已重置,请重新发送 /sub add", true
|
||||
}
|
||||
}
|
||||
|
||||
func (b *QQBot) subCreate(sess *subSessionPayload) (string, bool) {
|
||||
cfg, err := b.loadSubSettings()
|
||||
if err != nil {
|
||||
return "❌ 未配置 sub_api_base/sub_api_token", true
|
||||
}
|
||||
payload := map[string]any{
|
||||
"user_id": "1",
|
||||
"channel": "qq",
|
||||
"name": sess.Temp["name"],
|
||||
"category": sess.Temp["category"],
|
||||
"account": sess.Temp["account"],
|
||||
"amount": toFloat(sess.Temp["amount"]),
|
||||
"currency": sess.Temp["currency"],
|
||||
"due_date": sess.Temp["due_date"],
|
||||
"cycle_unit": sess.Temp["cycle_unit"],
|
||||
"cycle_value": toInt(sess.Temp["cycle_value"]),
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions"
|
||||
resp, status, err := doSubRequest(http.MethodPost, url, body, cfg.Token)
|
||||
if err != nil {
|
||||
return "❌ 创建失败: " + err.Error(), true
|
||||
}
|
||||
if status >= 400 {
|
||||
return fmt.Sprintf("❌ 创建失败: http %d %s", status, strings.TrimSpace(resp)), true
|
||||
}
|
||||
return "✅ 添加成功", true
|
||||
}
|
||||
|
||||
func (b *QQBot) loadSubSettings() (*subAPISettings, error) {
|
||||
if b.db == nil {
|
||||
return nil, fmt.Errorf("db nil")
|
||||
}
|
||||
var base models.AppSetting
|
||||
if err := b.db.Where("key = ?", "sub_api_base").First(&base).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var token models.AppSetting
|
||||
if err := b.db.Where("key = ?", "sub_api_token").First(&token).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiBase := strings.TrimSpace(base.Value)
|
||||
apiToken := strings.TrimSpace(token.Value)
|
||||
if apiBase == "" || apiToken == "" {
|
||||
return nil, fmt.Errorf("empty settings")
|
||||
}
|
||||
return &subAPISettings{APIBase: apiBase, Token: apiToken}, nil
|
||||
}
|
||||
|
||||
func (b *QQBot) hasSubSession(userID string) bool {
|
||||
if b.db == nil {
|
||||
return false
|
||||
}
|
||||
var st models.SubSession
|
||||
if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&st).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(st.Step) != ""
|
||||
}
|
||||
|
||||
func (b *QQBot) getSubSession(userID string) *subSessionPayload {
|
||||
if b.db == nil {
|
||||
return nil
|
||||
}
|
||||
var st models.SubSession
|
||||
if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&st).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
var payload subSessionPayload
|
||||
if err := json.Unmarshal([]byte(st.TempJSON), &payload.Temp); err != nil {
|
||||
payload.Temp = map[string]string{}
|
||||
}
|
||||
payload.Step = st.Step
|
||||
return &payload
|
||||
}
|
||||
|
||||
func (b *QQBot) setSubSession(userID string, sess *subSessionPayload) {
|
||||
if b.db == nil {
|
||||
return
|
||||
}
|
||||
tmp, _ := json.Marshal(sess.Temp)
|
||||
var row models.SubSession
|
||||
if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&row).Error; err == nil {
|
||||
row.Step = sess.Step
|
||||
row.TempJSON = string(tmp)
|
||||
_ = b.db.Save(&row).Error
|
||||
return
|
||||
}
|
||||
_ = b.db.Create(&models.SubSession{
|
||||
UserID: userID,
|
||||
Platform: "qqbot_official",
|
||||
Step: sess.Step,
|
||||
TempJSON: string(tmp),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (b *QQBot) clearSubSession(userID string) {
|
||||
if b.db == nil {
|
||||
return
|
||||
}
|
||||
_ = b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").Delete(&models.SubSession{}).Error
|
||||
}
|
||||
|
||||
func doSubRequest(method, url string, body []byte, token string) (string, int, error) {
|
||||
req, err := http.NewRequest(method, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return string(data), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func toInt(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.Atoi(s)
|
||||
return v
|
||||
}
|
||||
|
||||
func toFloat(s string) float64 {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.ParseFloat(s, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
func isAllowedCurrency(c string) bool {
|
||||
switch strings.ToUpper(c) {
|
||||
case "CNY", "USD", "EUR", "GBP", "HKD", "JPY", "MYR", "SGD":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedCycleUnit(u string) bool {
|
||||
switch strings.ToLower(u) {
|
||||
case "day", "month", "year":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user