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", "failed to list scheduler jobs") } 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) if _, err := s.repo.GetJobForRuntime(ctx, key); 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 不合法") } trigger, err := s.repo.CreateTrigger(ctx, repository.SchedulerTriggerCreate{ JobKey: key, TriggerType: triggerType, RequestedBy: actorID(actor), Reason: strings.TrimSpace(in.Reason), ConfigOverride: sanitizeSchedulerConfig(in.ConfigOverride), }) if err != nil { return nil, response.ErrInternal(53004, "scheduler_trigger_create_failed", "failed to create scheduler trigger") } 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", "failed to list scheduler runs") } return &SchedulerRunListResult{Items: items, Total: total, Page: page, Size: size}, nil } 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", "failed to list scheduler instances") } 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", "failed to load scheduler job") } 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) }