Files
brother7 c2a5281534 修复:调整游客取号限流策略
问题:移动网络共享出口 IP 会造成游客取号被误限流。

实现:移除公开取号 IP 限制,改用项目总量与手机号 HMAC 限流,并支持 Retry-After 倒计时。
2026-07-31 22:56:33 +08:00

288 lines
9.8 KiB
Go

package httpapi
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"runtime/debug"
"strings"
"time"
"calllinesystem/server/internal/config"
"calllinesystem/server/internal/database"
"calllinesystem/server/internal/model"
"calllinesystem/server/internal/security"
"github.com/google/uuid"
"gorm.io/gorm"
)
type contextKey string
const (
principalKey contextKey = "principal"
requestIDKey contextKey = "request_id"
)
type Server struct {
db *gorm.DB
config config.Config
cipher *security.Cipher
logger *slog.Logger
hub eventPublisher
loginLimiter *loginLimiter
publicQueryLimiter *queryLimiter
publicTicketProjectLimiter *queryLimiter
publicTicketPhoneLimiter *queryLimiter
dummyPassword string
now func() time.Time
}
func New(db *gorm.DB, cfg config.Config, logger *slog.Logger) (*Server, error) {
return NewWithEventPublisher(db, cfg, logger, newEventHub())
}
func NewWithEventPublisher(db *gorm.DB, cfg config.Config, logger *slog.Logger, publisher eventPublisher) (*Server, error) {
fieldCipher, err := security.NewCipher(cfg.EncryptionKey, cfg.PhoneHMACKey)
if err != nil {
return nil, err
}
if publisher == nil {
return nil, fmt.Errorf("event publisher is required")
}
dummy, err := security.HashPassword("not-a-real-password-value")
if err != nil {
return nil, err
}
now := func() time.Time { return time.Now().UTC() }
return &Server{
db: db,
config: cfg,
cipher: fieldCipher,
logger: logger,
hub: publisher,
loginLimiter: newLoginLimiter(now),
publicQueryLimiter: newQueryLimiter(now, 120, time.Minute),
publicTicketProjectLimiter: newQueryLimiter(now, cfg.PublicTicketProjectLimit, cfg.PublicTicketRateWindow),
publicTicketPhoneLimiter: newQueryLimiter(now, cfg.PublicTicketPhoneLimit, cfg.PublicTicketRateWindow),
dummyPassword: dummy,
now: now,
}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /readyz", s.ready)
mux.HandleFunc("POST /api/staff/auth/login", s.loginFor(model.RoleStaff))
mux.Handle("POST /api/staff/auth/logout", s.requireStaff(s.logoutFor(model.RoleStaff)))
mux.Handle("GET /api/staff/auth/me", s.requireStaff(http.HandlerFunc(s.me)))
mux.HandleFunc("POST /api/admin/auth/login", s.loginFor(model.RoleAdmin))
mux.Handle("POST /api/admin/auth/logout", s.requireAdmin(s.logoutFor(model.RoleAdmin)))
mux.Handle("GET /api/admin/auth/me", s.requireAdmin(http.HandlerFunc(s.me)))
mux.Handle("GET /api/staff/projects", s.requireStaff(http.HandlerFunc(s.staffProjects)))
mux.Handle("GET /api/staff/projects/{id}/queue", s.requireStaff(http.HandlerFunc(s.queueSnapshot)))
mux.Handle("POST /api/staff/projects/{id}/tickets", s.requireStaff(http.HandlerFunc(s.createTicket)))
mux.Handle("POST /api/staff/projects/{id}/call-next", s.requireStaff(http.HandlerFunc(s.callNext)))
mux.HandleFunc("GET /api/public/status/{token}", s.publicStatus)
mux.HandleFunc("GET /api/public/projects", s.publicProjects)
mux.HandleFunc("POST /api/public/projects/{id}/tickets", s.publicCreateTicket)
mux.HandleFunc("POST /api/public/status/search", s.publicStatusByPhone)
mux.Handle("POST /api/internal/status/search", s.requireInternalNetwork(http.HandlerFunc(s.internalStatusByPhone)))
mux.HandleFunc("GET /api/display/overview", s.displayOverview)
mux.HandleFunc("GET /api/display/{token}/snapshot", s.displaySnapshot)
mux.Handle("GET /api/events", s.requireStaff(http.HandlerFunc(s.events)))
mux.Handle("GET /api/admin/overview", s.requireAdmin(http.HandlerFunc(s.adminOverview)))
mux.Handle("GET /api/admin/users", s.requireAdmin(http.HandlerFunc(s.adminUsers)))
mux.Handle("POST /api/admin/users", s.requireAdmin(http.HandlerFunc(s.createAdminUser)))
mux.Handle("PUT /api/admin/users/{id}", s.requireAdmin(http.HandlerFunc(s.updateAdminUser)))
mux.Handle("POST /api/admin/projects", s.requireAdmin(http.HandlerFunc(s.createProject)))
mux.Handle("PUT /api/admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.updateProject)))
mux.Handle("PUT /api/admin/projects/{id}/settings", s.requireAdmin(http.HandlerFunc(s.updateProjectSettings)))
mux.Handle("GET /api/admin/history/tickets", s.requireAdmin(http.HandlerFunc(s.adminHistoryTickets)))
mux.Handle("GET /api/admin/history/tickets/{id}", s.requireAdmin(http.HandlerFunc(s.adminHistoryTicket)))
mux.Handle("GET /api/admin/history/batches", s.requireAdmin(http.HandlerFunc(s.adminHistoryBatches)))
mux.Handle("GET /api/admin/history/batches/{id}", s.requireAdmin(http.HandlerFunc(s.adminHistoryBatch)))
mux.Handle("GET /api/admin/history/summary", s.requireAdmin(http.HandlerFunc(s.adminHistorySummary)))
mux.Handle("GET /api/admin/history/export.csv", s.requireAdmin(http.HandlerFunc(s.adminHistoryExport)))
return s.requestID(s.accessLog(s.recoverPanic(s.securityHeaders(mux))))
}
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "time": s.now()})
}
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
sqlDB, err := s.db.DB()
if err != nil {
writeError(w, err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready"})
return
}
if err := database.SchemaReady(ctx, sqlDB); err != nil {
s.logger.Warn("database schema is not ready", "error", err)
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ready"})
}
func (s *Server) requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := strings.TrimSpace(r.Header.Get("X-Request-ID"))
if len(requestID) < 8 || len(requestID) > 80 {
requestID = uuid.NewString()
}
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, requestID)))
})
}
type statusRecorder struct {
http.ResponseWriter
status int
wroteHeader bool
body *logBodyCapture
}
func (r *statusRecorder) WriteHeader(status int) {
if r.wroteHeader {
return
}
r.status = status
r.wroteHeader = true
r.ResponseWriter.WriteHeader(status)
}
func (r *statusRecorder) Write(value []byte) (int, error) {
if !r.wroteHeader {
r.WriteHeader(http.StatusOK)
}
n, err := r.ResponseWriter.Write(value)
if n > 0 && r.body != nil {
_, _ = r.body.Write(value[:n])
}
return n, err
}
func (r *statusRecorder) Unwrap() http.ResponseWriter {
return r.ResponseWriter
}
func (r *statusRecorder) responseStarted() bool {
return r.wroteHeader
}
type flushStatusRecorder struct {
*statusRecorder
}
func (r *flushStatusRecorder) Flush() {
if !r.wroteHeader {
r.WriteHeader(http.StatusOK)
}
r.ResponseWriter.(http.Flusher).Flush()
}
func (s *Server) accessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := s.now()
var requestBody *logBodyCapture
var responseBody *logBodyCapture
if shouldCaptureLogBody(r) {
requestBody = &logBodyCapture{}
responseBody = &logBodyCapture{}
if r.Body != nil {
r.Body = &logCaptureReadCloser{ReadCloser: r.Body, capture: requestBody}
}
}
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK, body: responseBody}
var responseWriter http.ResponseWriter = recorder
if _, ok := w.(http.Flusher); ok {
responseWriter = &flushStatusRecorder{statusRecorder: recorder}
}
next.ServeHTTP(responseWriter, r)
attributes := []any{
"request_id", requestID(r.Context()),
"method", r.Method,
"path", logPath(r),
"status", recorder.status,
"duration_ms", time.Since(started).Milliseconds(),
}
if isImportantLogRoute(r) {
if request := requestLogDetails(r, requestBody); request != nil {
attributes = append(attributes, "request", request)
}
if response := responseLogDetails(r, recorder); response != nil {
attributes = append(attributes, "response", response)
}
}
s.logger.Info("http request", attributes...)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
func (s *Server) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
responseStarted := false
if state, ok := w.(interface{ responseStarted() bool }); ok {
responseStarted = state.responseStarted()
}
s.logger.Error("panic recovered",
"request_id", requestID(r.Context()),
"error", recovered,
"response_started", responseStarted,
"stack", string(debug.Stack()),
)
if responseStarted {
return
}
writeError(w, fmt.Errorf("panic: %v", recovered))
}
}()
next.ServeHTTP(w, r)
})
}
func requestID(ctx context.Context) string {
value, _ := ctx.Value(requestIDKey).(string)
return value
}
func remoteIP(r *http.Request) *string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if net.ParseIP(host) == nil {
return nil
}
return &host
}
func boundedUserAgent(r *http.Request) string {
value := r.UserAgent()
if len(value) > 512 {
return value[:512]
}
return value
}