Files
weichen 914d0aa39d feat(maintenance): wire sysadmin ui.* whitelist settings to public site status
- 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
2026-09-25 16:33:56 +00:00

217 lines
5.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}