842782b3dd
Support a random daily run window (random_window_start/end) where the next auto-run time is derived from a stable per-job hash, avoiding thundering-herd starts while keeping one run per day. Honors the last auto-run on initial scheduling so restarts don't double-fire. Manual triggers can opt into dedupe (dedupe_manual_triggers): an advisory-locked path collapses concurrent requests onto an existing pending/running job instead of queueing duplicates. Scheduler error messages are localized to Chinese. Register the media_supply_cache_sync job (dry-run aware) and add the ops migration that seeds it.
332 lines
10 KiB
Go
332 lines
10 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
|
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
)
|
|
|
|
const (
|
|
ActionSchedulerJobUpdate = "scheduler_job.update"
|
|
ActionSchedulerJobEnable = "scheduler_job.enable"
|
|
ActionSchedulerJobDisable = "scheduler_job.disable"
|
|
ActionSchedulerJobRun = "scheduler_job.run"
|
|
ActionSchedulerJobDryRun = "scheduler_job.dry_run"
|
|
)
|
|
|
|
type SchedulerService struct {
|
|
repo *repository.SchedulerRepository
|
|
audits *AuditService
|
|
}
|
|
|
|
func NewSchedulerService(repo *repository.SchedulerRepository, audits *AuditService) *SchedulerService {
|
|
return &SchedulerService{repo: repo, audits: audits}
|
|
}
|
|
|
|
type SchedulerJobListInput struct {
|
|
Category string
|
|
Keyword string
|
|
Enabled *bool
|
|
Page int
|
|
Size int
|
|
}
|
|
|
|
type SchedulerJobListResult struct {
|
|
Items []domain.SchedulerJob `json:"items"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
Size int `json:"size"`
|
|
}
|
|
|
|
type SchedulerRunListResult struct {
|
|
Items []domain.SchedulerRun `json:"items"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
Size int `json:"size"`
|
|
}
|
|
|
|
type SchedulerJobUpdateInput struct {
|
|
Enabled *bool `json:"enabled"`
|
|
ScheduleType *string `json:"schedule_type"`
|
|
IntervalSeconds *int `json:"interval_seconds"`
|
|
CronExpr *string `json:"cron_expr"`
|
|
Timezone *string `json:"timezone"`
|
|
TimeoutSeconds *int `json:"timeout_seconds"`
|
|
BatchSize *int `json:"batch_size"`
|
|
MaxConcurrency *int `json:"max_concurrency"`
|
|
Config map[string]any `json:"config"`
|
|
}
|
|
|
|
type SchedulerTriggerInput struct {
|
|
Reason string `json:"reason"`
|
|
ConfigOverride map[string]any `json:"config_override"`
|
|
}
|
|
|
|
func (s *SchedulerService) ListJobs(ctx context.Context, in SchedulerJobListInput) (*SchedulerJobListResult, error) {
|
|
page := in.Page
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
size := in.Size
|
|
if size <= 0 || size > 200 {
|
|
size = 50
|
|
}
|
|
items, total, err := s.repo.ListJobs(ctx, repository.SchedulerJobFilter{
|
|
Category: strings.TrimSpace(in.Category),
|
|
Keyword: strings.TrimSpace(in.Keyword),
|
|
Enabled: in.Enabled,
|
|
Limit: size,
|
|
Offset: (page - 1) * size,
|
|
})
|
|
if err != nil {
|
|
return nil, response.ErrInternal(53001, "scheduler_jobs_query_failed", "调度任务列表读取失败")
|
|
}
|
|
return &SchedulerJobListResult{Items: items, Total: total, Page: page, Size: size}, nil
|
|
}
|
|
|
|
func (s *SchedulerService) GetJob(ctx context.Context, key string) (*domain.SchedulerJob, error) {
|
|
item, err := s.repo.GetJob(ctx, normalizeSchedulerJobKey(key))
|
|
if err != nil {
|
|
return nil, mapSchedulerJobError(err)
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (s *SchedulerService) UpdateJob(ctx context.Context, actor *Actor, key string, in SchedulerJobUpdateInput) (*domain.SchedulerJob, error) {
|
|
normalized, err := normalizeSchedulerJobUpdate(in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
updatedBy := actorID(actor)
|
|
item, err := s.repo.UpdateJob(ctx, normalizeSchedulerJobKey(key), repository.SchedulerJobUpdate{
|
|
Enabled: normalized.Enabled,
|
|
ScheduleType: normalized.ScheduleType,
|
|
IntervalSeconds: normalized.IntervalSeconds,
|
|
CronExpr: normalized.CronExpr,
|
|
Timezone: normalized.Timezone,
|
|
TimeoutSeconds: normalized.TimeoutSeconds,
|
|
BatchSize: normalized.BatchSize,
|
|
MaxConcurrency: normalized.MaxConcurrency,
|
|
Config: normalized.Config,
|
|
UpdatedBy: updatedBy,
|
|
})
|
|
if err != nil {
|
|
return nil, mapSchedulerJobError(err)
|
|
}
|
|
s.auditScheduler(ctx, actor, ActionSchedulerJobUpdate, item.JobKey, map[string]any{
|
|
"version": item.Version,
|
|
})
|
|
return item, nil
|
|
}
|
|
|
|
func (s *SchedulerService) SetEnabled(ctx context.Context, actor *Actor, key string, enabled bool) (*domain.SchedulerJob, error) {
|
|
item, err := s.repo.SetJobEnabled(ctx, normalizeSchedulerJobKey(key), enabled, actorID(actor))
|
|
if err != nil {
|
|
return nil, mapSchedulerJobError(err)
|
|
}
|
|
action := ActionSchedulerJobDisable
|
|
if enabled {
|
|
action = ActionSchedulerJobEnable
|
|
}
|
|
s.auditScheduler(ctx, actor, action, item.JobKey, map[string]any{"version": item.Version})
|
|
return item, nil
|
|
}
|
|
|
|
func (s *SchedulerService) Trigger(ctx context.Context, actor *Actor, key string, triggerType string, in SchedulerTriggerInput) (*domain.SchedulerTrigger, error) {
|
|
key = normalizeSchedulerJobKey(key)
|
|
job, err := s.repo.GetJobForRuntime(ctx, key)
|
|
if err != nil {
|
|
return nil, mapSchedulerJobError(err)
|
|
}
|
|
triggerType = strings.TrimSpace(triggerType)
|
|
if triggerType != domain.SchedulerTriggerManual && triggerType != domain.SchedulerTriggerDryRun {
|
|
return nil, response.ErrBadRequest(40083, "invalid_trigger_type", "trigger_type 不合法")
|
|
}
|
|
create := repository.SchedulerTriggerCreate{
|
|
JobKey: key,
|
|
TriggerType: triggerType,
|
|
RequestedBy: actorID(actor),
|
|
Reason: strings.TrimSpace(in.Reason),
|
|
ConfigOverride: sanitizeSchedulerConfig(in.ConfigOverride),
|
|
}
|
|
if triggerType == domain.SchedulerTriggerManual && boolFromSchedulerConfig(job.Config, "dedupe_manual_triggers") {
|
|
trigger, created, triggerErr := s.repo.CreateDedupedManualTrigger(ctx, create)
|
|
if triggerErr != nil {
|
|
return nil, response.ErrInternal(53004, "scheduler_trigger_create_failed", "创建调度触发失败")
|
|
}
|
|
if created {
|
|
s.auditScheduler(ctx, actor, ActionSchedulerJobRun, key, map[string]any{
|
|
"trigger_id": trigger.ID,
|
|
"reason": strings.TrimSpace(in.Reason),
|
|
})
|
|
}
|
|
return trigger, nil
|
|
}
|
|
trigger, err := s.repo.CreateTrigger(ctx, create)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(53004, "scheduler_trigger_create_failed", "创建调度触发失败")
|
|
}
|
|
action := ActionSchedulerJobRun
|
|
if triggerType == domain.SchedulerTriggerDryRun {
|
|
action = ActionSchedulerJobDryRun
|
|
}
|
|
s.auditScheduler(ctx, actor, action, key, map[string]any{
|
|
"trigger_id": trigger.ID,
|
|
"reason": strings.TrimSpace(in.Reason),
|
|
})
|
|
return trigger, nil
|
|
}
|
|
|
|
func (s *SchedulerService) ListRuns(ctx context.Context, key, status string, page, size int) (*SchedulerRunListResult, error) {
|
|
page = maxInt(page, 1)
|
|
if size <= 0 || size > 200 {
|
|
size = 50
|
|
}
|
|
items, total, err := s.repo.ListRuns(ctx, repository.SchedulerRunFilter{
|
|
JobKey: normalizeSchedulerJobKey(key),
|
|
Status: strings.TrimSpace(status),
|
|
Limit: size,
|
|
Offset: (page - 1) * size,
|
|
})
|
|
if err != nil {
|
|
return nil, response.ErrInternal(53005, "scheduler_runs_query_failed", "调度运行历史读取失败")
|
|
}
|
|
return &SchedulerRunListResult{Items: items, Total: total, Page: page, Size: size}, nil
|
|
}
|
|
|
|
func boolFromSchedulerConfig(config map[string]any, key string) bool {
|
|
value, ok := config[key]
|
|
if !ok || value == nil {
|
|
return false
|
|
}
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
case string:
|
|
return strings.EqualFold(strings.TrimSpace(typed), "true")
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *SchedulerService) ListInstances(ctx context.Context) ([]domain.SchedulerInstance, error) {
|
|
items, err := s.repo.ListInstances(ctx)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(53006, "scheduler_instances_query_failed", "调度实例读取失败")
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func normalizeSchedulerJobUpdate(in SchedulerJobUpdateInput) (SchedulerJobUpdateInput, error) {
|
|
out := in
|
|
if out.ScheduleType != nil {
|
|
value := strings.TrimSpace(*out.ScheduleType)
|
|
if value != "interval" && value != "cron" && value != "manual" {
|
|
return out, response.ErrBadRequest(40080, "invalid_schedule_type", "schedule_type 不合法")
|
|
}
|
|
out.ScheduleType = &value
|
|
}
|
|
if out.IntervalSeconds != nil && *out.IntervalSeconds <= 0 {
|
|
return out, response.ErrBadRequest(40081, "invalid_interval_seconds", "interval_seconds 必须大于 0")
|
|
}
|
|
if out.TimeoutSeconds != nil && *out.TimeoutSeconds <= 0 {
|
|
return out, response.ErrBadRequest(40082, "invalid_timeout_seconds", "timeout_seconds 必须大于 0")
|
|
}
|
|
if out.BatchSize != nil && *out.BatchSize <= 0 {
|
|
return out, response.ErrBadRequest(40084, "invalid_batch_size", "batch_size 必须大于 0")
|
|
}
|
|
if out.MaxConcurrency != nil && *out.MaxConcurrency <= 0 {
|
|
return out, response.ErrBadRequest(40085, "invalid_max_concurrency", "max_concurrency 必须大于 0")
|
|
}
|
|
if out.Timezone != nil {
|
|
value := strings.TrimSpace(*out.Timezone)
|
|
if value == "" {
|
|
value = "Asia/Shanghai"
|
|
}
|
|
if _, err := time.LoadLocation(value); err != nil {
|
|
return out, response.ErrBadRequest(40086, "invalid_timezone", "timezone 不合法")
|
|
}
|
|
out.Timezone = &value
|
|
}
|
|
if out.CronExpr != nil {
|
|
value := strings.TrimSpace(*out.CronExpr)
|
|
out.CronExpr = &value
|
|
}
|
|
out.Config = sanitizeSchedulerConfig(out.Config)
|
|
return out, nil
|
|
}
|
|
|
|
func sanitizeSchedulerConfig(config map[string]any) map[string]any {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]any, len(config))
|
|
for key, value := range config {
|
|
trimmed := strings.TrimSpace(key)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
out[trimmed] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeSchedulerJobKey(key string) string {
|
|
return strings.TrimSpace(key)
|
|
}
|
|
|
|
func mapSchedulerJobError(err error) error {
|
|
if errors.Is(err, repository.ErrSchedulerJobNotFound) {
|
|
return response.ErrNotFound(40480, "scheduler_job_not_found", "调度任务不存在")
|
|
}
|
|
return response.ErrInternal(53002, "scheduler_job_query_failed", "调度任务读取失败")
|
|
}
|
|
|
|
func actorID(actor *Actor) *int64 {
|
|
if actor == nil || actor.OperatorID <= 0 {
|
|
return nil
|
|
}
|
|
id := actor.OperatorID
|
|
return &id
|
|
}
|
|
|
|
func (s *SchedulerService) auditScheduler(ctx context.Context, actor *Actor, action, key string, metadata map[string]any) {
|
|
if actor == nil || s.audits == nil {
|
|
return
|
|
}
|
|
id := actor.OperatorID
|
|
event := domain.AuditEvent{
|
|
OperatorID: &id,
|
|
OperatorName: actor.DisplayName,
|
|
Action: action,
|
|
TargetType: "scheduler_job",
|
|
TargetID: key,
|
|
Metadata: metadata,
|
|
IP: actor.IP,
|
|
UserAgent: actor.UserAgent,
|
|
RequestID: actor.RequestID,
|
|
}
|
|
_ = s.audits.Append(ctx, event)
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func SchedulerJobConfigAsString(config map[string]any, key string) string {
|
|
value, ok := config[key]
|
|
if !ok || value == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprint(value)
|
|
}
|