Files
weichen f3f23a3018 feat(errcode): 用稳定错误码记录 HTTP 与异步失败
失败响应此前不写日志,request_id 无法对照原因。注册表不覆盖处理器已经给出的 HTTP 状态。
2026-09-28 02:27:16 +00:00

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
}