diff --git a/docs/visitor-mini-program-integration.md b/docs/visitor-mini-program-integration.md index aae9c56..a538d9a 100644 --- a/docs/visitor-mini-program-integration.md +++ b/docs/visitor-mini-program-integration.md @@ -400,6 +400,24 @@ Content-Type: application/json 测试环境下该接口被调用过于频繁时会返回 HTTP `429`、错误码 `PUBLIC_QUERY_RATE_LIMITED`,并通过 `Retry-After` 告知等待秒数。 +### 5.5 内部系统按手机号查状态 + +供酒店业务服务等可信后端调用: + +```http +POST /api/internal/status/search +Content-Type: application/json +``` + +请求体仍为 `{ "phone": "13800138000" }`,响应为 `{ "tickets": [...] }`,只包含该手机号在运行或暂停项目中的 `WAITING`、`CALLED`、`ARRIVED` 活动号码,单个号码结构与 5.3 的状态响应一致。该接口在生产环境可用。 + +访问限制: + +- 调用方必须通过 ClusterIP、内网域名等内部服务地址直连; +- API 仅接受来源地址为私网或回环地址的连接,不信任 `X-Forwarded-For`; +- 不得在公网 Ingress、网关或负载均衡器上发布 `/api/internal/*` 路径; +- 如果公网 Ingress 与内部调用共用后端 Pod,仍须通过 Ingress 路由规则和 NetworkPolicy 阻断该路径,避免公网流量经私网代理地址绕过来源检查。 + ## 6. 号码状态与页面展示建议 | `status` | 建议展示 | 处理方式 | diff --git a/server/README.md b/server/README.md index ef2fa5e..259f925 100644 --- a/server/README.md +++ b/server/README.md @@ -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 diff --git a/server/internal/httpapi/internal.go b/server/internal/httpapi/internal.go new file mode 100644 index 0000000..64b6b35 --- /dev/null +++ b/server/internal/httpapi/internal.go @@ -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: "该接口仅允许内部网络访问", + }) +} diff --git a/server/internal/httpapi/internal_test.go b/server/internal/httpapi/internal_test.go new file mode 100644 index 0000000..d0283d7 --- /dev/null +++ b/server/internal/httpapi/internal_test.go @@ -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() }, + } +} diff --git a/server/internal/httpapi/public.go b/server/internal/httpapi/public.go index 6dffabb..22d63df 100644 --- a/server/internal/httpapi/public.go +++ b/server/internal/httpapi/public.go @@ -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 diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index ed5d2cf..eb3cd04 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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)))