新增通过手机号获取排队信息的内部接口

This commit is contained in:
2026-07-22 12:19:38 +08:00
parent de8e09f7c5
commit 32decdb15c
6 changed files with 114 additions and 1 deletions

View File

@@ -56,6 +56,12 @@ endpoint. It accepts `{ "phone": "..." }` and returns all current active
tickets associated with that phone. It is disabled when `APP_ENV=production`;
replace it with OTP or an external identity interface before formal launch.
`POST /api/internal/status/search` exposes the same active-ticket lookup to
trusted backend services in every environment. It accepts `{ "phone": "..." }`
and only allows direct peers on private or loopback networks. Do not publish
this path through the public Ingress; callers must use the internal service
address. Forwarded client-IP headers are intentionally ignored.
## Tests
```sh

View File

@@ -0,0 +1,34 @@
package httpapi
import (
"net"
"net/http"
)
func (s *Server) internalStatusByPhone(w http.ResponseWriter, r *http.Request) {
s.statusByPhone(w, r, false)
}
func (s *Server) requireInternalNetwork(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
address := remoteIP(r)
if address == nil {
writeInternalNetworkRequired(w)
return
}
ip := net.ParseIP(*address)
if ip == nil || (!ip.IsPrivate() && !ip.IsLoopback()) {
writeInternalNetworkRequired(w)
return
}
next.ServeHTTP(w, r)
})
}
func writeInternalNetworkRequired(w http.ResponseWriter) {
writeError(w, &apiError{
Status: http.StatusForbidden,
Code: "INTERNAL_NETWORK_REQUIRED",
Message: "该接口仅允许内部网络访问",
})
}

View File

@@ -0,0 +1,50 @@
package httpapi
import (
"io"
"log/slog"
"net/http/httptest"
"strings"
"testing"
"time"
"calllinesystem/server/internal/config"
)
func TestInternalPhoneLookupRejectsPublicNetwork(t *testing.T) {
server := internalRouteTestServer()
recorder := httptest.NewRecorder()
request := httptest.NewRequest("POST", "/api/internal/status/search", strings.NewReader(`{"phone":"13800138000"}`))
request.RemoteAddr = "203.0.113.8:42000"
request.Header.Set("X-Forwarded-For", "10.0.0.8")
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != 403 {
t.Fatalf("public network status = %d, want 403", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), `"code":"INTERNAL_NETWORK_REQUIRED"`) {
t.Fatalf("unexpected response: %s", recorder.Body.String())
}
}
func TestInternalPhoneLookupIsAvailableInProductionFromPrivateNetwork(t *testing.T) {
server := internalRouteTestServer()
recorder := httptest.NewRecorder()
request := httptest.NewRequest("POST", "/api/internal/status/search", strings.NewReader(`{"phone":"123"}`))
request.RemoteAddr = "10.23.4.5:42000"
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != 422 {
t.Fatalf("private network invalid phone status = %d, want 422", recorder.Code)
}
}
func internalRouteTestServer() *Server {
return &Server{
config: config.Config{Environment: "production"},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
now: func() time.Time { return time.Unix(100, 0).UTC() },
}
}

View File

@@ -85,7 +85,11 @@ func (s *Server) publicStatusByPhone(w http.ResponseWriter, r *http.Request) {
writeError(w, &apiError{Status: http.StatusNotFound, Code: "STATUS_NOT_FOUND", Message: "排队状态不存在或链接已失效"})
return
}
if s.publicQueryLimiter != nil {
s.statusByPhone(w, r, true)
}
func (s *Server) statusByPhone(w http.ResponseWriter, r *http.Request, limitByClientIP bool) {
if limitByClientIP && s.publicQueryLimiter != nil {
if allowed, retry := s.publicQueryLimiter.allow("ip:" + publicQueryClientKey(r)); !allowed {
writePublicQueryRateLimit(w, retry)
return

View File

@@ -88,6 +88,7 @@ func (s *Server) Handler() http.Handler {
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/{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)))