Files
root c28c4f1419
Frontend CI / Frontend (push) Successful in 3m37s
Backend CI / Backend (push) Failing after 6m47s
feat(schedule): support KOL subscription targets and random time windows
Extend schedule tasks to dispatch KOL subscription prompts in addition
to prompt rules, add daily/weekly scheduling with fixed or random time
windows, and track dispatch outcomes (last run, status, consecutive
failures).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:51:45 +08:00

178 lines
4.5 KiB
Go

package schedule
import (
"crypto/sha256"
"encoding/binary"
"fmt"
"strings"
"time"
)
const (
TimeModeFixed = "fixed"
TimeModeRandomWindow = "random_window"
maxRandomRunSearchDays = 370
)
type RandomPolicy struct {
Days []time.Weekday
TimeMode string
WindowStart time.Duration
WindowEnd time.Duration
Seed int64
}
func ParseDayKey(value string) (time.Weekday, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "sun", "sunday", "0", "7":
return time.Sunday, true
case "mon", "monday", "1":
return time.Monday, true
case "tue", "tuesday", "2":
return time.Tuesday, true
case "wed", "wednesday", "3":
return time.Wednesday, true
case "thu", "thursday", "4":
return time.Thursday, true
case "fri", "friday", "5":
return time.Friday, true
case "sat", "saturday", "6":
return time.Saturday, true
default:
return time.Sunday, false
}
}
func DayKey(day time.Weekday) string {
switch day {
case time.Sunday:
return "sun"
case time.Monday:
return "mon"
case time.Tuesday:
return "tue"
case time.Wednesday:
return "wed"
case time.Thursday:
return "thu"
case time.Friday:
return "fri"
case time.Saturday:
return "sat"
default:
return ""
}
}
func ParseClockDuration(value string) (time.Duration, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, fmt.Errorf("time value is required")
}
parsed, err := time.Parse("15:04:05", trimmed)
if err != nil {
parsed, err = time.Parse("15:04", trimmed)
}
if err != nil {
return 0, fmt.Errorf("parse time %q: %w", trimmed, err)
}
return time.Duration(parsed.Hour())*time.Hour +
time.Duration(parsed.Minute())*time.Minute +
time.Duration(parsed.Second())*time.Second, nil
}
func FormatClockDuration(value time.Duration) string {
if value < 0 {
value = 0
}
value = value.Truncate(time.Second)
hour := int(value / time.Hour)
value -= time.Duration(hour) * time.Hour
minute := int(value / time.Minute)
value -= time.Duration(minute) * time.Minute
second := int(value / time.Second)
return fmt.Sprintf("%02d:%02d:%02d", hour, minute, second)
}
func ComputeNextRandomRun(policy RandomPolicy, startAt, endAt *time.Time, now time.Time) (*time.Time, error) {
return computeNextRandomRun(policy, startAt, endAt, now, maxRandomRunSearchDays)
}
func computeNextRandomRun(policy RandomPolicy, startAt, endAt *time.Time, now time.Time, maxSearchDays int) (*time.Time, error) {
days := normalizePolicyDays(policy.Days)
if len(days) == 0 {
return nil, fmt.Errorf("schedule days are required")
}
if strings.TrimSpace(policy.TimeMode) == "" {
policy.TimeMode = TimeModeRandomWindow
}
if policy.TimeMode != TimeModeRandomWindow {
return nil, fmt.Errorf("unsupported random policy time mode: %s", policy.TimeMode)
}
if policy.WindowEnd <= policy.WindowStart {
return nil, fmt.Errorf("random window end must be after start")
}
notBefore := now.In(time.Local)
if startAt != nil {
start := startAt.In(time.Local)
if start.After(notBefore) {
notBefore = start
}
}
allowed := make(map[time.Weekday]struct{}, len(days))
for _, day := range days {
allowed[day] = struct{}{}
}
baseDate := time.Date(notBefore.Year(), notBefore.Month(), notBefore.Day(), 0, 0, 0, 0, notBefore.Location())
for offset := 0; offset <= maxSearchDays; offset++ {
dayStart := baseDate.AddDate(0, 0, offset)
if _, ok := allowed[dayStart.Weekday()]; !ok {
continue
}
runAt := dayStart.Add(policy.WindowStart + stableWindowOffset(policy.Seed, dayStart, policy.WindowEnd-policy.WindowStart))
if runAt.Before(notBefore) || runAt.Equal(notBefore) {
continue
}
if endAt != nil && runAt.After(endAt.In(time.Local)) {
return nil, nil
}
return &runAt, nil
}
return nil, nil
}
func normalizePolicyDays(days []time.Weekday) []time.Weekday {
seen := make(map[time.Weekday]struct{}, len(days))
result := make([]time.Weekday, 0, len(days))
for _, day := range days {
if day < time.Sunday || day > time.Saturday {
continue
}
if _, ok := seen[day]; ok {
continue
}
seen[day] = struct{}{}
result = append(result, day)
}
return result
}
func stableWindowOffset(seed int64, day time.Time, width time.Duration) time.Duration {
widthSeconds := int64(width / time.Second)
if widthSeconds <= 0 {
return 0
}
var buf [16]byte
binary.BigEndian.PutUint64(buf[:8], uint64(seed))
binary.BigEndian.PutUint64(buf[8:], uint64(day.Year()*10000+int(day.Month())*100+day.Day()))
sum := sha256.Sum256(buf[:])
value := binary.BigEndian.Uint64(sum[:8])
return time.Duration(int64(value%uint64(widthSeconds))) * time.Second
}