- sysadmin.Service.GetSettingValue: whitelist read-through (DB > env > default) - maintenance.Service: SiteSettingsProvider injection; StatusView carries notice/readOnly; new CurrentSiteStatus + GET /api/v1/maintenance/site-status - composition root wires ui.maintenance_notice / ui.read_only_mode into maintenance site settings - MaintenanceBanner: persistent notice banner + read-only mode strip - chore(web): drop dead 'dirty' var in EnvSettingsSection (baseline TS6133 build blocker) - KB ops actions: deferred out-of-repo (shared infra container side); documented in task record
217 lines
5.4 KiB
Go
217 lines
5.4 KiB
Go
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)
|
||
}
|
||
}
|