diff --git a/internal/service/bot_features_test.go b/internal/service/bot_features_test.go index b379bb7..5b2f487 100644 --- a/internal/service/bot_features_test.go +++ b/internal/service/bot_features_test.go @@ -317,6 +317,21 @@ func TestBotAdminCommandsManageDevicePolicy(t *testing.T) { if !found { t.Fatalf("cleanup rule not added; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules) } + + reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule add account_age_grace new_7d 7") + if err != nil { + t.Fatal(err) + } + cfg = loadBotConfig(ctx, repos) + found = false + for _, rule := range cfg.AccountCleanupRules { + if rule.ID == "new_7d" && rule.Type == "account_age_grace" && rule.MinCount == 7 { + found = true + } + } + if !found { + t.Fatalf("cleanup shorthand rule not added; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules) + } } func TestBotCleanupRulesCanBeDeletedUntilEmpty(t *testing.T) { @@ -347,6 +362,38 @@ func TestBotCleanupRulesCanBeDeletedUntilEmpty(t *testing.T) { } } +func TestBotCleanupRuleListInfersDaysAndHidesDuplicateNames(t *testing.T) { + ctx := context.Background() + repos, bot := newBotTestService(t) + admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} + if err := repos.User.Create(ctx, admin); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(ctx, SettingAccountCleanupRules, `[ + {"id":"login_7d","name":"login_7d","type":"recent_login","enabled":true,"window_days_min":1,"window_days_max":5,"min_count":1}, + {"id":"new_7d","name":"new_7d","type":"account_age_grace","enabled":true,"window_days_min":1,"window_days_max":1,"min_count":1} + ]`); err != nil { + t.Fatal(err) + } + channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} + msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} + + reply, err := bot.executeCommand(ctx, channel, msg, "/cleanup_rule list") + if err != nil { + t.Fatal(err) + } + for _, bad := range []string{"login_7d · login_7d", "new_7d · new_7d", "5 天内登录", "新号宽限 1 天"} { + if strings.Contains(reply.Text, bad) { + t.Fatalf("rule list still contains bad fragment %q: %s", bad, reply.Text) + } + } + for _, want := range []string{"login_7d", "7 天内登录", "new_7d", "新号宽限 7 天"} { + if !strings.Contains(reply.Text, want) { + t.Fatalf("rule list missing %q: %s", want, reply.Text) + } + } +} + func TestBotRegistrationCommandUsesOpenRegQuota(t *testing.T) { ctx := context.Background() repos, bot := newBotTestService(t) diff --git a/internal/service/bot_settings.go b/internal/service/bot_settings.go index 1657f97..114f7f1 100644 --- a/internal/service/bot_settings.go +++ b/internal/service/bot_settings.go @@ -3,12 +3,20 @@ package service import ( "context" "encoding/json" + "regexp" "strconv" "strings" "github.com/ShukeBta/MediaStationGo/internal/repository" ) +var ( + cleanupRuleWindowDaysRE = regexp.MustCompile(`(?i)(?:^|[_-])(\d+)[_-](\d+)\s*d(?:$|[_-])`) + cleanupRuleSingleDayRE = regexp.MustCompile(`(?i)(?:^|[_-])(\d+)\s*d(?:$|[_-])`) + cleanupRuleHoursRE = regexp.MustCompile(`(?i)(?:^|[_-])(\d+(?:\.\d+)?)\s*h(?:$|[_-])`) + cleanupRuleNumberRE = regexp.MustCompile(`(?i)(?:^|[_-])(\d+)(?:$|[_-])`) +) + // Bot / 设备管控相关的设置键。全部存储在 settings 表,由管理员通过 // Telegram Bot 命令调整。带安全默认值:所有"自动删号"策略默认关闭。 const ( @@ -187,6 +195,7 @@ func normalizeCleanupRules(rules []accountCleanupRule) []accountCleanupRule { if r.Name == "" { r.Name = r.ID } + inferCleanupRuleValuesFromID(&r) if r.WindowDaysMin < 1 { r.WindowDaysMin = 1 } @@ -206,3 +215,76 @@ func normalizeCleanupRules(rules []accountCleanupRule) []accountCleanupRule { } return out } + +func inferCleanupRuleValuesFromID(rule *accountCleanupRule) { + if rule == nil { + return + } + key := strings.ToLower(strings.TrimSpace(rule.ID + "_" + rule.Name)) + switch rule.Type { + case "watch_hours": + if minDays, maxDays := cleanupRuleWindowDays(key); minDays > 0 && maxDays > 0 { + rule.WindowDaysMin = minDays + rule.WindowDaysMax = maxDays + } else if days := cleanupRuleSingleDay(key); days > 0 { + rule.WindowDaysMin = days + rule.WindowDaysMax = days + } + if hours := cleanupRuleHours(key); hours > 0 { + rule.MinHours = hours + } + case "recent_login": + if days := cleanupRuleSingleDay(key); days > 0 { + rule.WindowDaysMax = days + } + case "account_age_grace": + if days := cleanupRuleSingleDay(key); days > 0 { + rule.MinCount = days + } + case "signin_streak": + if n := cleanupRuleTrailingNumber(key); n > 0 { + rule.MinCount = n + } + } +} + +func cleanupRuleWindowDays(value string) (int, int) { + if m := cleanupRuleWindowDaysRE.FindStringSubmatch(value); len(m) >= 3 { + minDays, _ := strconv.Atoi(m[1]) + maxDays, _ := strconv.Atoi(m[2]) + if maxDays < minDays { + maxDays = minDays + } + return minDays, maxDays + } + return 0, 0 +} + +func cleanupRuleSingleDay(value string) int { + if m := cleanupRuleSingleDayRE.FindStringSubmatch(value); len(m) >= 2 { + days, _ := strconv.Atoi(m[1]) + return days + } + return 0 +} + +func cleanupRuleHours(value string) float64 { + if m := cleanupRuleHoursRE.FindStringSubmatch(value); len(m) >= 2 { + hours, _ := strconv.ParseFloat(m[1], 64) + return hours + } + return 0 +} + +func cleanupRuleTrailingNumber(value string) int { + matches := cleanupRuleNumberRE.FindAllStringSubmatch(value, -1) + if len(matches) == 0 { + return 0 + } + last := matches[len(matches)-1] + if len(last) < 2 { + return 0 + } + n, _ := strconv.Atoi(last[1]) + return n +} diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go index cbd4a83..a2dc0bb 100644 --- a/internal/service/telegram_menu.go +++ b/internal/service/telegram_menu.go @@ -1309,30 +1309,32 @@ func parseCleanupRuleCommand(args []string) (accountCleanupRule, error) { rule := accountCleanupRule{ Type: strings.ToLower(strings.TrimSpace(args[0])), ID: strings.TrimSpace(args[1]), - Name: strings.TrimSpace(args[1]), Enabled: true, WindowDaysMin: 3, WindowDaysMax: 5, MinHours: 6, MinCount: 1, } - if len(args) > 2 { - rule.Name = strings.TrimSpace(args[2]) - } switch rule.Type { case "watch_hours": - if len(args) >= 6 { - rule.WindowDaysMin, _ = strconv.Atoi(args[3]) - rule.WindowDaysMax, _ = strconv.Atoi(args[4]) - rule.MinHours, _ = strconv.ParseFloat(args[5], 64) + name, values := cleanupRuleNameAndValues(args[2:], 3) + rule.Name = name + if len(values) >= 3 { + rule.WindowDaysMin, _ = strconv.Atoi(values[0]) + rule.WindowDaysMax, _ = strconv.Atoi(values[1]) + rule.MinHours, _ = strconv.ParseFloat(values[2], 64) } case "recent_login": - if len(args) >= 4 { - rule.WindowDaysMax, _ = strconv.Atoi(args[3]) + name, values := cleanupRuleNameAndValues(args[2:], 1) + rule.Name = name + if len(values) >= 1 { + rule.WindowDaysMax, _ = strconv.Atoi(values[0]) } case "signin_streak", "account_age_grace": - if len(args) >= 4 { - rule.MinCount, _ = strconv.Atoi(args[3]) + name, values := cleanupRuleNameAndValues(args[2:], 1) + rule.Name = name + if len(values) >= 1 { + rule.MinCount, _ = strconv.Atoi(values[0]) } default: return accountCleanupRule{}, fmt.Errorf("不支持的规则类型:%s", rule.Type) @@ -1344,6 +1346,25 @@ func parseCleanupRuleCommand(args []string) (accountCleanupRule, error) { return normalized[0], nil } +func cleanupRuleNameAndValues(args []string, numericCount int) (string, []string) { + if len(args) == 0 { + return "", nil + } + if len(args) >= numericCount && cleanupRuleValuesAreNumeric(args[:numericCount]) { + return "", args + } + return strings.TrimSpace(args[0]), args[1:] +} + +func cleanupRuleValuesAreNumeric(values []string) bool { + for _, value := range values { + if _, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err != nil { + return false + } + } + return true +} + func formatCleanupRules(rules []accountCleanupRule) string { if len(rules) == 0 { return "保号规则\n\n暂无规则。" @@ -1352,21 +1373,55 @@ func formatCleanupRules(rules []accountCleanupRule) string { sb.WriteString("保号规则\n") for i, r := range rules { state := map[bool]string{true: "启用", false: "停用"}[r.Enabled] - sb.WriteString(fmt.Sprintf("\n%d. %s · %s · %s · %s", i+1, r.ID, r.Name, cleanupRuleTypeLabel(r.Type), state)) - switch r.Type { - case "watch_hours": - sb.WriteString(fmt.Sprintf(" · %d~%d 天 %.1f 小时", r.WindowDaysMin, r.WindowDaysMax, r.MinHours)) - case "recent_login": - sb.WriteString(fmt.Sprintf(" · %d 天内登录", r.WindowDaysMax)) - case "signin_streak": - sb.WriteString(fmt.Sprintf(" · 连续签到 %d 天", r.MinCount)) - case "account_age_grace": - sb.WriteString(fmt.Sprintf(" · 新号宽限 %d 天", r.MinCount)) + detail := cleanupRuleDetail(r) + parts := []string{ + fmt.Sprintf("\n%d. %s", i+1, r.ID), } + if shouldShowCleanupRuleName(r, detail) { + parts = append(parts, r.Name) + } + parts = append(parts, cleanupRuleTypeLabel(r.Type), state) + if detail != "" { + parts = append(parts, detail) + } + sb.WriteString(strings.Join(parts, " · ")) } return sb.String() } +func shouldShowCleanupRuleName(r accountCleanupRule, detail string) bool { + name := strings.TrimSpace(r.Name) + if name == "" || strings.EqualFold(name, r.ID) { + return false + } + if detail != "" && strings.EqualFold(name, detail) { + return false + } + return true +} + +func cleanupRuleDetail(r accountCleanupRule) string { + switch r.Type { + case "watch_hours": + return fmt.Sprintf("%d~%d 天 %s 小时", r.WindowDaysMin, r.WindowDaysMax, formatRuleHours(r.MinHours)) + case "recent_login": + return fmt.Sprintf("%d 天内登录", r.WindowDaysMax) + case "signin_streak": + return fmt.Sprintf("连续签到 %d 天", r.MinCount) + case "account_age_grace": + return fmt.Sprintf("新号宽限 %d 天", r.MinCount) + default: + return "" + } +} + +func formatRuleHours(hours float64) string { + if hours == float64(int(hours)) { + return strconv.Itoa(int(hours)) + } + return fmt.Sprintf("%.1f", hours) +} + func cleanupRuleTypeLabel(t string) string { switch t { case "watch_hours":