249 lines
7.0 KiB
Go
249 lines
7.0 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/config"
|
|
"chat-agent-system/internal/handoff"
|
|
"chat-agent-system/internal/maintenance"
|
|
)
|
|
|
|
func (s *Server) handleMaintenanceStatus(w http.ResponseWriter, r *http.Request) {
|
|
if s.maintenance() == nil {
|
|
writeJSONResponse(w, http.StatusOK, maintenance.StatusView{Status: "none"})
|
|
return
|
|
}
|
|
view, err := s.maintenance().CurrentStatus(r.Context())
|
|
if err != nil {
|
|
writeDomainError(w, r, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, view)
|
|
}
|
|
|
|
// handleMaintenanceSiteStatus 只返回站点级开关(notice / readOnly),
|
|
// 供无维护窗口时的横幅与只读提示轮询。
|
|
func (s *Server) handleMaintenanceSiteStatus(w http.ResponseWriter, r *http.Request) {
|
|
if s.maintenance() == nil {
|
|
writeJSONResponse(w, http.StatusOK, maintenance.StatusView{Status: "none"})
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, s.maintenance().CurrentSiteStatus(r.Context()))
|
|
}
|
|
|
|
func (s *Server) handleAdminMaintenanceList(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
if s.maintenance() == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "maintenance service unavailable")
|
|
return
|
|
}
|
|
list, err := s.maintenance().ListRecent(r.Context(), 20)
|
|
if err != nil {
|
|
writeDomainError(w, r, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{"windows": list})
|
|
}
|
|
|
|
func (s *Server) handleAdminMaintenanceSchedule(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.requireSystemAdmin(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.maintenance() == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "maintenance service unavailable")
|
|
return
|
|
}
|
|
var req struct {
|
|
Start time.Time `json:"start"`
|
|
End time.Time `json:"end"`
|
|
Hours float64 `json:"hours"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
|
|
return
|
|
}
|
|
end := req.End
|
|
if end.IsZero() && req.Hours > 0 {
|
|
end = req.Start.Add(time.Duration(req.Hours * float64(time.Hour)))
|
|
}
|
|
wnd, err := s.maintenance().Schedule(r.Context(), maintenance.ScheduleInput{
|
|
Start: req.Start, End: end, Message: req.Message, CreatedBy: user.ID,
|
|
})
|
|
switch {
|
|
case errors.Is(err, maintenance.ErrInvalidWindow):
|
|
writeDomainError(w, r, http.StatusBadRequest, err)
|
|
case errors.Is(err, maintenance.ErrConflictOpen):
|
|
writeSafeError(w, r, http.StatusConflict, "已有未结束的维护窗口")
|
|
case err != nil:
|
|
writeDomainError(w, r, http.StatusInternalServerError, err)
|
|
default:
|
|
writeJSONResponse(w, http.StatusOK, wnd)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleAdminMaintenanceCancel(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
if s.maintenance() == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "maintenance service unavailable")
|
|
return
|
|
}
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
writeSafeError(w, r, http.StatusBadRequest, "无效的 id")
|
|
return
|
|
}
|
|
wnd, err := s.maintenance().Cancel(r.Context(), id)
|
|
switch {
|
|
case errors.Is(err, maintenance.ErrBadStatus):
|
|
writeSafeError(w, r, http.StatusConflict, "当前状态不可取消")
|
|
case err != nil:
|
|
writeDomainError(w, r, http.StatusInternalServerError, err)
|
|
default:
|
|
writeJSONResponse(w, http.StatusOK, wnd)
|
|
}
|
|
}
|
|
|
|
func (s *Server) maintenanceMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if s.maintenance() == nil {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
view, err := s.maintenance().CurrentStatus(r.Context())
|
|
if err != nil || !view.Active {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
path := r.URL.Path
|
|
if isMaintenanceExempt(r.Method, path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
// 管理员仍可访问管理台与维护 API
|
|
if user, err := s.authenticateUser(r); err == nil && user != nil {
|
|
if s.authzSvc() != nil {
|
|
if admin, aerr := s.authzSvc().IsSystemAdmin(r.Context(), user.ID, user.RoleID); aerr == nil && admin {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if r.Method == http.MethodGet && !strings.HasPrefix(path, "/api/") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Retry-After", "3600")
|
|
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{
|
|
"error": "MAINTENANCE",
|
|
"message": view.Message,
|
|
"status": view,
|
|
})
|
|
})
|
|
}
|
|
|
|
func isMaintenanceExempt(method, path string) bool {
|
|
if path == "/health" || path == "/api/v1/health" || path == "/ready" || path == "/api/v1/ready" {
|
|
return true
|
|
}
|
|
if path == "/api/v1/maintenance/status" {
|
|
return true
|
|
}
|
|
if method == http.MethodPost && (path == "/api/v1/auth/login" || path == "/api/v1/auth/register") {
|
|
return true
|
|
}
|
|
if method == http.MethodGet && path == "/api/v1/auth/public-config" {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(path, "/api/v1/admin/system") || strings.HasPrefix(path, "/api/v1/admin/maintenance") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) performMaintenanceHandoff(w maintenance.Window) error {
|
|
token, err := randomToken(24)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dataDir := config.DefaultDataDir()
|
|
if s.cfg != nil && s.cfg.DataDir != "" {
|
|
dataDir = s.cfg.DataDir
|
|
}
|
|
tokenPath := filepath.Join(dataDir, "maintenance-token")
|
|
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(tokenPath, []byte(token+"\n"), 0o600); err != nil {
|
|
return err
|
|
}
|
|
s.lastMaintToken = token
|
|
|
|
bin := envOr("MAINTPAGE_BIN", "./bin/maintpage")
|
|
if abs, err := filepath.Abs(bin); err == nil {
|
|
bin = abs
|
|
}
|
|
serverBin := envOr("AGENT_SERVER_BIN", "./bin/agent-server")
|
|
if abs, err := filepath.Abs(serverBin); err == nil {
|
|
serverBin = abs
|
|
}
|
|
addr := fmt.Sprintf("%s:%d", s.cfg.HTTPHost, s.cfg.HTTPPort)
|
|
extra := []string{
|
|
"MAINT_LISTEN_ADDR=" + addr,
|
|
"MAINT_MESSAGE=" + w.Message,
|
|
"MAINT_START=" + w.ScheduledStart.Format(time.RFC3339),
|
|
"MAINT_END=" + w.ScheduledEnd.Format(time.RFC3339),
|
|
fmt.Sprintf("MAINT_WINDOW_ID=%d", w.ID),
|
|
"MAINT_ADMIN_TOKEN=" + token,
|
|
"MAINT_SERVER_BIN=" + serverBin,
|
|
"DATA_DIR=" + dataDir,
|
|
}
|
|
if s.listener == nil {
|
|
return fmt.Errorf("maintenance: no listener to hand off")
|
|
}
|
|
cmd, err := handoff.PassToChild(s.listener, bin, nil, extra, 20*time.Second)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("maintenance: handed off to maintpage pid=%d window=%d\n", cmd.Process.Pid, w.ID)
|
|
// 触发进程退出:关闭 HTTP(会关本端 listener FD;子进程持有副本)
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
|
defer cancel()
|
|
_ = s.Shutdown(ctx)
|
|
os.Exit(0)
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func randomToken(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
func envOr(k, def string) string {
|
|
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|