package scheduler import ( "errors" "fmt" "strconv" "strings" "time" ) // Cron implements the standard five-field cron format: minute, hour, // day-of-month, month, day-of-week. Lists, ranges and steps are supported. type Cron struct { minute, hour, day, month, weekday field dayWildcard, weekdayWildcard bool } type field struct { min, max int values map[int]bool } func ParseCron(expression string) (Cron, error) { parts := strings.Fields(expression) if len(parts) != 5 { return Cron{}, errors.New("cron 表达式必须包含 5 段: 分 时 日 月 周") } definitions := [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 7}} fields := make([]field, 5) for i, part := range parts { parsed, err := parseField(part, definitions[i][0], definitions[i][1], i == 4) if err != nil { return Cron{}, fmt.Errorf("cron 第 %d 段无效: %w", i+1, err) } fields[i] = parsed } return Cron{minute: fields[0], hour: fields[1], day: fields[2], month: fields[3], weekday: fields[4], dayWildcard: parts[2] == "*", weekdayWildcard: parts[4] == "*"}, nil } func parseField(raw string, minimum, maximum int, weekday bool) (field, error) { result := field{min: minimum, max: maximum, values: map[int]bool{}} for _, item := range strings.Split(raw, ",") { item = strings.TrimSpace(item) if item == "" { return field{}, errors.New("存在空列表项") } base, stepText, hasStep := strings.Cut(item, "/") step := 1 if hasStep { var err error step, err = strconv.Atoi(stepText) if err != nil || step < 1 || step > maximum-minimum+1 { return field{}, errors.New("步长无效") } } start, end := minimum, maximum switch { case base == "*": case strings.Contains(base, "-"): left, right, _ := strings.Cut(base, "-") var err error start, err = cronNumber(left, minimum, maximum, weekday) if err != nil { return field{}, err } end, err = cronNumber(right, minimum, maximum, weekday) if err != nil || start > end { return field{}, errors.New("范围无效") } default: var err error start, err = cronNumber(base, minimum, maximum, weekday) if err != nil { return field{}, err } end = start if hasStep { end = maximum } } for value := start; value <= end; value += step { if weekday && value == 7 { value = 0 result.values[value] = true break } result.values[value] = true } } if len(result.values) == 0 { return field{}, errors.New("没有可用取值") } return result, nil } func cronNumber(raw string, minimum, maximum int, weekday bool) (int, error) { value, err := strconv.Atoi(raw) if err != nil || value < minimum || value > maximum { return 0, fmt.Errorf("%q 超出 %d-%d", raw, minimum, maximum) } if weekday && value == 7 { return 7, nil } return value, nil } func (c Cron) Matches(value time.Time) bool { dayMatch := c.day.values[value.Day()] weekdayMatch := c.weekday.values[int(value.Weekday())] calendarMatch := dayMatch && weekdayMatch // Vixie cron semantics: when both day fields are restricted, either may match. if !c.dayWildcard && !c.weekdayWildcard { calendarMatch = dayMatch || weekdayMatch } return c.minute.values[value.Minute()] && c.hour.values[value.Hour()] && c.month.values[int(value.Month())] && calendarMatch } func (c Cron) Next(after time.Time, location *time.Location) (time.Time, error) { if location == nil { location = time.UTC } candidate := after.UTC().Truncate(time.Minute).Add(time.Minute) deadline := candidate.AddDate(5, 0, 0) for candidate.Before(deadline) { if c.Matches(candidate.In(location)) { return candidate, nil } candidate = candidate.Add(time.Minute) } return time.Time{}, errors.New("未来 5 年内无匹配执行时间") }