feat: add ops scheduler control center

This commit is contained in:
2026-05-20 10:46:49 +08:00
parent 98f73c5bea
commit e82ae56236
19 changed files with 2766 additions and 20 deletions
+301
View File
@@ -0,0 +1,301 @@
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)
}
+13 -13
View File
@@ -37,19 +37,19 @@ type SchedulerJob struct {
}
type SchedulerRun struct {
ID int64 `json:"id"`
JobKey string `json:"job_key"`
TriggerID *int64 `json:"trigger_id,omitempty"`
SchedulerInstanceID *string `json:"scheduler_instance_id,omitempty"`
TriggerType string `json:"trigger_type"`
Status string `json:"status"`
ConfigVersion int `json:"config_version"`
ConfigSnapshot map[string]any `json:"config_snapshot"`
Stats map[string]any `json:"stats"`
ErrorMessage *string `json:"error_message,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
DurationMilliseconds *int64 `json:"duration_ms,omitempty"`
ID int64 `json:"id"`
JobKey string `json:"job_key"`
TriggerID *int64 `json:"trigger_id,omitempty"`
SchedulerInstanceID *string `json:"scheduler_instance_id,omitempty"`
TriggerType string `json:"trigger_type"`
Status string `json:"status"`
ConfigVersion int `json:"config_version"`
ConfigSnapshot map[string]any `json:"config_snapshot"`
Stats map[string]any `json:"stats"`
ErrorMessage *string `json:"error_message,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
DurationMilliseconds *int64 `json:"duration_ms,omitempty"`
}
type SchedulerInstance struct {
+603
View File
@@ -0,0 +1,603 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/ops/domain"
)
var ErrSchedulerJobNotFound = errors.New("scheduler job not found")
type SchedulerRepository struct {
pool *pgxpool.Pool
}
func NewSchedulerRepository(pool *pgxpool.Pool) *SchedulerRepository {
return &SchedulerRepository{pool: pool}
}
type SchedulerJobFilter struct {
Category string
Keyword string
Enabled *bool
Limit int
Offset int
}
type SchedulerJobUpdate struct {
Enabled *bool
ScheduleType *string
IntervalSeconds *int
CronExpr *string
Timezone *string
TimeoutSeconds *int
BatchSize *int
MaxConcurrency *int
Config map[string]any
UpdatedBy *int64
}
type SchedulerRunFilter struct {
JobKey string
Status string
Limit int
Offset int
}
type SchedulerRunStart struct {
JobKey string
TriggerID *int64
SchedulerInstanceID string
TriggerType string
ConfigVersion int
ConfigSnapshot map[string]any
}
type SchedulerRunFinish struct {
RunID int64
Status string
Stats map[string]any
ErrorMessage *string
}
type SchedulerTriggerCreate struct {
JobKey string
TriggerType string
RequestedBy *int64
Reason string
ConfigOverride map[string]any
}
type SchedulerTriggerClaim struct {
ID int64
JobKey string
TriggerType string
ConfigOverride map[string]any
}
const schedulerJobSelect = `
SELECT
j.job_key,
j.display_name,
j.category,
j.description,
j.enabled,
j.schedule_type,
j.interval_seconds,
j.cron_expr,
j.timezone,
j.timeout_seconds,
j.batch_size,
j.max_concurrency,
j.config,
j.version,
j.updated_by,
j.updated_at,
j.created_at,
COALESCE(pending.pending_count, 0)
FROM ops.scheduler_jobs j
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS pending_count
FROM ops.scheduler_job_triggers t
WHERE t.job_key = j.job_key AND t.status = 'pending'
) pending ON true
`
func (r *SchedulerRepository) ListJobs(ctx context.Context, f SchedulerJobFilter) ([]domain.SchedulerJob, int64, error) {
args := []any{}
where := "1=1"
if f.Category != "" {
args = append(args, f.Category)
where += fmt.Sprintf(" AND j.category = $%d", len(args))
}
if f.Keyword != "" {
args = append(args, "%"+f.Keyword+"%")
where += fmt.Sprintf(" AND (j.job_key ILIKE $%d OR j.display_name ILIKE $%d OR COALESCE(j.description, '') ILIKE $%d)", len(args), len(args), len(args))
}
if f.Enabled != nil {
args = append(args, *f.Enabled)
where += fmt.Sprintf(" AND j.enabled = $%d", len(args))
}
var total int64
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM ops.scheduler_jobs j WHERE "+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
offset := f.Offset
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
limitArg := len(args) - 1
offsetArg := len(args)
rows, err := r.pool.Query(ctx, schedulerJobSelect+fmt.Sprintf(`
WHERE %s
ORDER BY j.category ASC, j.job_key ASC
LIMIT $%d OFFSET $%d`, where, limitArg, offsetArg), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]domain.SchedulerJob, 0, limit)
for rows.Next() {
item, scanErr := scanSchedulerJob(rows)
if scanErr != nil {
return nil, 0, scanErr
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if err := r.attachRunSnapshots(ctx, items); err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r *SchedulerRepository) GetJob(ctx context.Context, key string) (*domain.SchedulerJob, error) {
item, err := scanSchedulerJob(r.pool.QueryRow(ctx, schedulerJobSelect+`
WHERE j.job_key = $1`, key))
if err != nil {
return nil, err
}
items := []domain.SchedulerJob{*item}
if err := r.attachRunSnapshots(ctx, items); err != nil {
return nil, err
}
*item = items[0]
return item, nil
}
func (r *SchedulerRepository) GetJobForRuntime(ctx context.Context, key string) (*domain.SchedulerJob, error) {
return scanSchedulerJob(r.pool.QueryRow(ctx, schedulerJobSelect+`
WHERE j.job_key = $1`, key))
}
func (r *SchedulerRepository) UpdateJob(ctx context.Context, key string, in SchedulerJobUpdate) (*domain.SchedulerJob, error) {
configJSON, err := marshalSchedulerJSONMap(in.Config)
if err != nil {
return nil, err
}
return scanSchedulerJob(r.pool.QueryRow(ctx, `
UPDATE ops.scheduler_jobs
SET enabled = COALESCE($2, enabled),
schedule_type = COALESCE(NULLIF($3, ''), schedule_type),
interval_seconds = COALESCE($4, interval_seconds),
cron_expr = COALESCE($5, cron_expr),
timezone = COALESCE(NULLIF($6, ''), timezone),
timeout_seconds = COALESCE($7, timeout_seconds),
batch_size = COALESCE($8, batch_size),
max_concurrency = COALESCE($9, max_concurrency),
config = COALESCE($10::jsonb, config),
updated_by = COALESCE($11, updated_by),
version = version + 1,
updated_at = NOW()
WHERE job_key = $1
RETURNING job_key, display_name, category, description, enabled, schedule_type,
interval_seconds, cron_expr, timezone, timeout_seconds, batch_size,
max_concurrency, config, version, updated_by, updated_at, created_at, 0::int
`, key, in.Enabled, nullableStringPtr(in.ScheduleType), in.IntervalSeconds, in.CronExpr,
nullableStringPtr(in.Timezone), in.TimeoutSeconds, in.BatchSize, in.MaxConcurrency, configJSON, in.UpdatedBy))
}
func (r *SchedulerRepository) SetJobEnabled(ctx context.Context, key string, enabled bool, updatedBy *int64) (*domain.SchedulerJob, error) {
return r.UpdateJob(ctx, key, SchedulerJobUpdate{
Enabled: &enabled,
UpdatedBy: updatedBy,
})
}
func (r *SchedulerRepository) CreateTrigger(ctx context.Context, in SchedulerTriggerCreate) (*domain.SchedulerTrigger, error) {
configJSON, err := marshalSchedulerJSONMap(in.ConfigOverride)
if err != nil {
return nil, err
}
reason := nullableString(in.Reason)
return scanSchedulerTrigger(r.pool.QueryRow(ctx, `
INSERT INTO ops.scheduler_job_triggers (job_key, trigger_type, requested_by, reason, config_override)
VALUES ($1, $2, $3, $4, COALESCE($5::jsonb, '{}'::jsonb))
RETURNING id, job_key, trigger_type, status, requested_by, reason, config_override,
scheduler_instance_id, run_id, requested_at, claimed_at, finished_at, error_message
`, in.JobKey, in.TriggerType, in.RequestedBy, reason, configJSON))
}
func (r *SchedulerRepository) ClaimPendingTrigger(ctx context.Context, jobKey, instanceID string) (*SchedulerTriggerClaim, bool, error) {
var claim SchedulerTriggerClaim
var configRaw []byte
err := r.pool.QueryRow(ctx, `
UPDATE ops.scheduler_job_triggers t
SET status = 'claimed',
claimed_at = NOW(),
scheduler_instance_id = $2
WHERE t.id = (
SELECT id
FROM ops.scheduler_job_triggers
WHERE job_key = $1 AND status = 'pending'
ORDER BY requested_at ASC, id ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, job_key, trigger_type, config_override
`, jobKey, instanceID).Scan(&claim.ID, &claim.JobKey, &claim.TriggerType, &configRaw)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
return nil, false, err
}
claim.ConfigOverride = decodeSchedulerJSONMap(configRaw)
return &claim, true, nil
}
func (r *SchedulerRepository) FinishTrigger(ctx context.Context, triggerID int64, runID *int64, status string, errorMessage *string) error {
_, err := r.pool.Exec(ctx, `
UPDATE ops.scheduler_job_triggers
SET status = $3,
run_id = $2,
finished_at = NOW(),
error_message = $4
WHERE id = $1
`, triggerID, runID, status, errorMessage)
return err
}
func (r *SchedulerRepository) StartRun(ctx context.Context, in SchedulerRunStart) (*domain.SchedulerRun, error) {
configJSON, err := marshalSchedulerJSONMap(in.ConfigSnapshot)
if err != nil {
return nil, err
}
return scanSchedulerRun(r.pool.QueryRow(ctx, `
INSERT INTO ops.scheduler_job_runs (
job_key, trigger_id, scheduler_instance_id, trigger_type,
status, config_version, config_snapshot, started_at
)
VALUES ($1, $2, $3, $4, 'running', $5, COALESCE($6::jsonb, '{}'::jsonb), NOW())
RETURNING id, job_key, trigger_id, scheduler_instance_id, trigger_type, status,
config_version, config_snapshot, stats, error_message, started_at,
finished_at, duration_ms
`, in.JobKey, in.TriggerID, nullableString(in.SchedulerInstanceID), in.TriggerType, in.ConfigVersion, configJSON))
}
func (r *SchedulerRepository) FinishRun(ctx context.Context, in SchedulerRunFinish) (*domain.SchedulerRun, error) {
statsJSON, err := marshalSchedulerJSONMap(in.Stats)
if err != nil {
return nil, err
}
return scanSchedulerRun(r.pool.QueryRow(ctx, `
UPDATE ops.scheduler_job_runs
SET status = $2,
stats = COALESCE($3::jsonb, '{}'::jsonb),
error_message = $4,
finished_at = NOW(),
duration_ms = GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000)::bigint)
WHERE id = $1
RETURNING id, job_key, trigger_id, scheduler_instance_id, trigger_type, status,
config_version, config_snapshot, stats, error_message, started_at,
finished_at, duration_ms
`, in.RunID, in.Status, statsJSON, in.ErrorMessage))
}
func (r *SchedulerRepository) ListRuns(ctx context.Context, f SchedulerRunFilter) ([]domain.SchedulerRun, int64, error) {
args := []any{}
where := "1=1"
if f.JobKey != "" {
args = append(args, f.JobKey)
where += fmt.Sprintf(" AND job_key = $%d", len(args))
}
if f.Status != "" {
args = append(args, f.Status)
where += fmt.Sprintf(" AND status = $%d", len(args))
}
var total int64
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM ops.scheduler_job_runs WHERE "+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
offset := f.Offset
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
limitArg := len(args) - 1
offsetArg := len(args)
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
SELECT id, job_key, trigger_id, scheduler_instance_id, trigger_type, status,
config_version, config_snapshot, stats, error_message, started_at,
finished_at, duration_ms
FROM ops.scheduler_job_runs
WHERE %s
ORDER BY started_at DESC, id DESC
LIMIT $%d OFFSET $%d
`, where, limitArg, offsetArg), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]domain.SchedulerRun, 0, limit)
for rows.Next() {
item, scanErr := scanSchedulerRun(rows)
if scanErr != nil {
return nil, 0, scanErr
}
items = append(items, *item)
}
return items, total, rows.Err()
}
func (r *SchedulerRepository) UpsertInstance(ctx context.Context, instance domain.SchedulerInstance) error {
metadataJSON, err := marshalSchedulerJSONMap(instance.Metadata)
if err != nil {
return err
}
_, err = r.pool.Exec(ctx, `
INSERT INTO ops.scheduler_instances (instance_id, hostname, process, build_version, metadata, started_at, last_seen_at)
VALUES ($1, $2, $3, $4, COALESCE($5::jsonb, '{}'::jsonb), $6, NOW())
ON CONFLICT (instance_id) DO UPDATE
SET hostname = EXCLUDED.hostname,
process = EXCLUDED.process,
build_version = EXCLUDED.build_version,
metadata = EXCLUDED.metadata,
last_seen_at = NOW()
`, instance.InstanceID, instance.Hostname, instance.Process, instance.BuildVersion, metadataJSON, instance.StartedAt)
return err
}
func (r *SchedulerRepository) ListInstances(ctx context.Context) ([]domain.SchedulerInstance, error) {
rows, err := r.pool.Query(ctx, `
SELECT instance_id, hostname, process, build_version, started_at, last_seen_at, metadata
FROM ops.scheduler_instances
ORDER BY last_seen_at DESC
LIMIT 200
`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]domain.SchedulerInstance, 0, 16)
for rows.Next() {
item, scanErr := scanSchedulerInstance(rows)
if scanErr != nil {
return nil, scanErr
}
items = append(items, *item)
}
return items, rows.Err()
}
func (r *SchedulerRepository) attachRunSnapshots(ctx context.Context, items []domain.SchedulerJob) error {
if len(items) == 0 {
return nil
}
keys := make([]string, 0, len(items))
for _, item := range items {
keys = append(keys, item.JobKey)
}
rows, err := r.pool.Query(ctx, `
WITH ranked AS (
SELECT
id, job_key, trigger_id, scheduler_instance_id, trigger_type, status,
config_version, config_snapshot, stats, error_message, started_at,
finished_at, duration_ms,
ROW_NUMBER() OVER (PARTITION BY job_key ORDER BY started_at DESC, id DESC) AS rn
FROM ops.scheduler_job_runs
WHERE job_key = ANY($1)
)
SELECT id, job_key, trigger_id, scheduler_instance_id, trigger_type, status,
config_version, config_snapshot, stats, error_message, started_at,
finished_at, duration_ms
FROM ranked
WHERE rn = 1
`, keys)
if err != nil {
return err
}
defer rows.Close()
lastRuns := map[string]*domain.SchedulerRun{}
for rows.Next() {
run, scanErr := scanSchedulerRun(rows)
if scanErr != nil {
return scanErr
}
lastRuns[run.JobKey] = run
}
if err := rows.Err(); err != nil {
return err
}
runningRows, err := r.pool.Query(ctx, `
SELECT DISTINCT ON (job_key)
id, job_key, trigger_id, scheduler_instance_id, trigger_type, status,
config_version, config_snapshot, stats, error_message, started_at,
finished_at, duration_ms
FROM ops.scheduler_job_runs
WHERE job_key = ANY($1) AND status = 'running'
ORDER BY job_key, started_at DESC, id DESC
`, keys)
if err != nil {
return err
}
defer runningRows.Close()
runningRuns := map[string]*domain.SchedulerRun{}
for runningRows.Next() {
run, scanErr := scanSchedulerRun(runningRows)
if scanErr != nil {
return scanErr
}
runningRuns[run.JobKey] = run
}
if err := runningRows.Err(); err != nil {
return err
}
for i := range items {
items[i].LastRun = lastRuns[items[i].JobKey]
items[i].RunningRun = runningRuns[items[i].JobKey]
}
return nil
}
func scanSchedulerJob(row pgx.Row) (*domain.SchedulerJob, error) {
var item domain.SchedulerJob
var configRaw []byte
err := row.Scan(
&item.JobKey,
&item.DisplayName,
&item.Category,
&item.Description,
&item.Enabled,
&item.ScheduleType,
&item.IntervalSeconds,
&item.CronExpr,
&item.Timezone,
&item.TimeoutSeconds,
&item.BatchSize,
&item.MaxConcurrency,
&configRaw,
&item.Version,
&item.UpdatedBy,
&item.UpdatedAt,
&item.CreatedAt,
&item.PendingTriggers,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSchedulerJobNotFound
}
return nil, err
}
item.Config = decodeSchedulerJSONMap(configRaw)
return &item, nil
}
func scanSchedulerRun(row pgx.Row) (*domain.SchedulerRun, error) {
var item domain.SchedulerRun
var configRaw, statsRaw []byte
err := row.Scan(
&item.ID,
&item.JobKey,
&item.TriggerID,
&item.SchedulerInstanceID,
&item.TriggerType,
&item.Status,
&item.ConfigVersion,
&configRaw,
&statsRaw,
&item.ErrorMessage,
&item.StartedAt,
&item.FinishedAt,
&item.DurationMilliseconds,
)
if err != nil {
return nil, err
}
item.ConfigSnapshot = decodeSchedulerJSONMap(configRaw)
item.Stats = decodeSchedulerJSONMap(statsRaw)
return &item, nil
}
func scanSchedulerTrigger(row pgx.Row) (*domain.SchedulerTrigger, error) {
var item domain.SchedulerTrigger
var configRaw []byte
err := row.Scan(
&item.ID,
&item.JobKey,
&item.TriggerType,
&item.Status,
&item.RequestedBy,
&item.Reason,
&configRaw,
&item.SchedulerInstanceID,
&item.RunID,
&item.RequestedAt,
&item.ClaimedAt,
&item.FinishedAt,
&item.ErrorMessage,
)
if err != nil {
return nil, err
}
item.ConfigOverride = decodeSchedulerJSONMap(configRaw)
return &item, nil
}
func scanSchedulerInstance(row pgx.Row) (*domain.SchedulerInstance, error) {
var item domain.SchedulerInstance
var metadataRaw []byte
if err := row.Scan(
&item.InstanceID,
&item.Hostname,
&item.Process,
&item.BuildVersion,
&item.StartedAt,
&item.LastSeenAt,
&metadataRaw,
); err != nil {
return nil, err
}
item.Metadata = decodeSchedulerJSONMap(metadataRaw)
return &item, nil
}
func marshalSchedulerJSONMap(value map[string]any) ([]byte, error) {
if value == nil {
return nil, nil
}
return json.Marshal(value)
}
func decodeSchedulerJSONMap(raw []byte) map[string]any {
if len(raw) == 0 {
return map[string]any{}
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil || out == nil {
return map[string]any{}
}
return out
}
func nullableStringPtr(value *string) *string {
if value == nil {
return nil
}
trimmed := *value
return &trimmed
}
+11
View File
@@ -26,6 +26,7 @@ type Deps struct {
Audits *app.AuditService
SiteDomains *app.SiteDomainMappingService
Compliance *app.ComplianceService
Scheduler *app.SchedulerService
}
func (d Deps) ServerAllowedOrigins() []string {
@@ -115,6 +116,16 @@ func RegisterRoutes(d Deps) {
authed.POST("/jobs/:source/:id/retry", retryJobHandler(d.Jobs))
authed.POST("/jobs/:source/:id/cancel", cancelJobHandler(d.Jobs))
authed.GET("/scheduler/jobs", listSchedulerJobsHandler(d.Scheduler))
authed.GET("/scheduler/jobs/:key", getSchedulerJobHandler(d.Scheduler))
authed.PATCH("/scheduler/jobs/:key", updateSchedulerJobHandler(d.Scheduler))
authed.POST("/scheduler/jobs/:key/enable", enableSchedulerJobHandler(d.Scheduler, true))
authed.POST("/scheduler/jobs/:key/disable", enableSchedulerJobHandler(d.Scheduler, false))
authed.POST("/scheduler/jobs/:key/run", triggerSchedulerJobHandler(d.Scheduler, "manual"))
authed.POST("/scheduler/jobs/:key/dry-run", triggerSchedulerJobHandler(d.Scheduler, "dry_run"))
authed.GET("/scheduler/jobs/:key/runs", listSchedulerRunsHandler(d.Scheduler))
authed.GET("/scheduler/instances", listSchedulerInstancesHandler(d.Scheduler))
authed.GET("/site-domain-mappings", listSiteDomainMappingsHandler(d.SiteDomains))
authed.POST("/site-domain-mappings", createSiteDomainMappingHandler(d.SiteDomains))
authed.PATCH("/site-domain-mappings/:id", updateSiteDomainMappingHandler(d.SiteDomains))
@@ -0,0 +1,121 @@
package transport
import (
"errors"
"io"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/ops/app"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
func listSchedulerJobsHandler(svc *app.SchedulerService) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "50"))
var enabled *bool
if raw := strings.TrimSpace(c.Query("enabled")); raw != "" {
value, err := strconv.ParseBool(raw)
if err != nil {
response.Error(c, response.ErrBadRequest(40087, "invalid_enabled", "enabled 必须是布尔值"))
return
}
enabled = &value
}
result, err := svc.ListJobs(c.Request.Context(), app.SchedulerJobListInput{
Category: strings.TrimSpace(c.Query("category")),
Keyword: strings.TrimSpace(c.Query("keyword")),
Enabled: enabled,
Page: page,
Size: size,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func getSchedulerJobHandler(svc *app.SchedulerService) gin.HandlerFunc {
return func(c *gin.Context) {
item, err := svc.GetJob(c.Request.Context(), c.Param("key"))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
}
func updateSchedulerJobHandler(svc *app.SchedulerService) gin.HandlerFunc {
return func(c *gin.Context) {
var req app.SchedulerJobUpdateInput
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40088, "invalid_payload", err.Error()))
return
}
item, err := svc.UpdateJob(c.Request.Context(), actorFromGin(c), c.Param("key"), req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
}
func enableSchedulerJobHandler(svc *app.SchedulerService, enabled bool) gin.HandlerFunc {
return func(c *gin.Context) {
item, err := svc.SetEnabled(c.Request.Context(), actorFromGin(c), c.Param("key"), enabled)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
}
func triggerSchedulerJobHandler(svc *app.SchedulerService, triggerType string) gin.HandlerFunc {
return func(c *gin.Context) {
var req app.SchedulerTriggerInput
if c.Request.Body != nil {
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.Error(c, response.ErrBadRequest(40089, "invalid_payload", err.Error()))
return
}
}
item, err := svc.Trigger(c.Request.Context(), actorFromGin(c), c.Param("key"), triggerType, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
}
func listSchedulerRunsHandler(svc *app.SchedulerService) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "50"))
result, err := svc.ListRuns(c.Request.Context(), c.Param("key"), strings.TrimSpace(c.Query("status")), page, size)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func listSchedulerInstancesHandler(svc *app.SchedulerService) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.ListInstances(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"items": items})
}
}