package maintenance import ( "context" "errors" "fmt" "sync" "time" ) // HandoffFunc 到点时调用:负责 spawn 公告进程并移交 listener。 type HandoffFunc func(ctx context.Context, w Window) error // SiteSettingsProvider 提供站点级开关(ui.maintenance_notice / ui.read_only_mode // 的运行时生效值)。由装配根从 sysadmin 设置解析注入;nil 表示未接线。 type SiteSettingsProvider func(ctx context.Context) (notice string, readOnly bool) // Service 维护窗口调度。 type Service struct { store Store now func() time.Time handoff HandoffFunc site SiteSettingsProvider mu sync.Mutex timer *time.Timer stopCh chan struct{} } func NewService(store Store) *Service { return &Service{ store: store, now: time.Now, stopCh: make(chan struct{}), } } // SetSiteSettings 注入站点开关提供方(装配根调用)。 func (s *Service) SetSiteSettings(fn SiteSettingsProvider) { s.site = fn } func (s *Service) SetHandoff(fn HandoffFunc) { s.mu.Lock() defer s.mu.Unlock() s.handoff = fn } // StatusView 公开状态(登录页/横幅)。 type StatusView struct { Active bool `json:"active"` Scheduled bool `json:"scheduled"` Message string `json:"message"` ScheduledStart *time.Time `json:"scheduledStart,omitempty"` ScheduledEnd *time.Time `json:"scheduledEnd,omitempty"` ExpectedHours float64 `json:"expectedHours,omitempty"` WindowID int64 `json:"windowId,omitempty"` Status string `json:"status"` // 站点级开关(sysadmin 白名单 ui.* 生效值)。 Notice string `json:"notice,omitempty"` // ui.maintenance_notice 非空时横幅常驻展示 ReadOnly bool `json:"readOnly,omitempty"` // ui.read_only_mode 为 true 时前端进入只读提示 } func (s *Service) CurrentStatus(ctx context.Context) (StatusView, error) { siteNotice, siteReadOnly := "", false if s.site != nil { siteNotice, siteReadOnly = s.site(ctx) } w, err := s.store.GetOpen(ctx) if err != nil { if errors.Is(err, ErrNotFound) { view := StatusView{Status: "none", Notice: siteNotice, ReadOnly: siteReadOnly} return view, nil } return StatusView{}, err } hours := w.ScheduledEnd.Sub(w.ScheduledStart).Hours() view := StatusView{ Message: w.Message, ScheduledStart: &w.ScheduledStart, ScheduledEnd: &w.ScheduledEnd, ExpectedHours: hours, WindowID: w.ID, Status: w.Status, Notice: siteNotice, ReadOnly: siteReadOnly, } switch w.Status { case StatusActive: view.Active = true case StatusScheduled: view.Scheduled = true } return view, nil } // CurrentSiteStatus 返回站点级开关(不依赖维护窗口);site provider 未接线时为零值。 func (s *Service) CurrentSiteStatus(ctx context.Context) StatusView { view := StatusView{Status: "none"} if s == nil || s.site == nil { return view } notice, readOnly := s.site(ctx) view.Notice = notice view.ReadOnly = readOnly if notice != "" { // notice 自身驱动横幅(非维护窗口),状态标记为 site_notice 以便前端区分。 view.Status = "site_notice" } return view } type ScheduleInput struct { Start time.Time End time.Time Message string CreatedBy int64 } func (s *Service) Schedule(ctx context.Context, in ScheduleInput) (Window, error) { if in.End.Before(in.Start) || !in.End.After(in.Start) { return Window{}, ErrInvalidWindow } if in.Start.Before(s.now().Add(-time.Minute)) { return Window{}, fmt.Errorf("%w: start must be in the future", ErrInvalidWindow) } createdBy := in.CreatedBy w, err := s.store.Insert(ctx, Window{ ScheduledStart: in.Start.UTC(), ScheduledEnd: in.End.UTC(), Message: in.Message, CreatedBy: &createdBy, }) if err != nil { return Window{}, err } s.Arm(ctx) return w, nil } func (s *Service) Cancel(ctx context.Context, id int64) (Window, error) { w, err := s.store.UpdateStatus(ctx, id, StatusScheduled, StatusCancelled) if err != nil { return Window{}, err } s.Arm(ctx) return w, nil } func (s *Service) MarkActive(ctx context.Context, id int64) (Window, error) { return s.store.UpdateStatus(ctx, id, StatusScheduled, StatusActive) } func (s *Service) MarkEnded(ctx context.Context, id int64) (Window, error) { return s.store.UpdateStatus(ctx, id, StatusActive, StatusEnded) } func (s *Service) ListRecent(ctx context.Context, limit int) ([]Window, error) { return s.store.ListRecent(ctx, limit) } // Arm 根据最近 scheduled 窗口重设 timer。 func (s *Service) Arm(ctx context.Context) { s.mu.Lock() defer s.mu.Unlock() if s.timer != nil { s.timer.Stop() s.timer = nil } w, err := s.store.GetOpen(ctx) if err != nil || w == nil || w.Status != StatusScheduled { return } delay := time.Until(w.ScheduledStart) if delay < 0 { delay = 0 } id := w.ID s.timer = time.AfterFunc(delay, func() { s.fire(id) }) } func (s *Service) fire(id int64) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() w, err := s.MarkActive(ctx, id) if err != nil { return } s.mu.Lock() fn := s.handoff s.mu.Unlock() if fn != nil { _ = fn(ctx, w) } } // Start 启动时恢复 timer;Stop 停止。 func (s *Service) Start(ctx context.Context) { s.Arm(ctx) } func (s *Service) Stop() { s.mu.Lock() defer s.mu.Unlock() if s.timer != nil { s.timer.Stop() s.timer = nil } select { case <-s.stopCh: default: close(s.stopCh) } }