新增通过手机号获取排队信息的内部接口
This commit is contained in:
@@ -400,6 +400,24 @@ Content-Type: application/json
|
|||||||
|
|
||||||
测试环境下该接口被调用过于频繁时会返回 HTTP `429`、错误码 `PUBLIC_QUERY_RATE_LIMITED`,并通过 `Retry-After` 告知等待秒数。
|
测试环境下该接口被调用过于频繁时会返回 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. 号码状态与页面展示建议
|
## 6. 号码状态与页面展示建议
|
||||||
|
|
||||||
| `status` | 建议展示 | 处理方式 |
|
| `status` | 建议展示 | 处理方式 |
|
||||||
|
|||||||
@@ -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`;
|
tickets associated with that phone. It is disabled when `APP_ENV=production`;
|
||||||
replace it with OTP or an external identity interface before formal launch.
|
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
|
## Tests
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
34
server/internal/httpapi/internal.go
Normal file
34
server/internal/httpapi/internal.go
Normal 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: "该接口仅允许内部网络访问",
|
||||||
|
})
|
||||||
|
}
|
||||||
50
server/internal/httpapi/internal_test.go
Normal file
50
server/internal/httpapi/internal_test.go
Normal 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() },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "排队状态不存在或链接已失效"})
|
writeError(w, &apiError{Status: http.StatusNotFound, Code: "STATUS_NOT_FOUND", Message: "排队状态不存在或链接已失效"})
|
||||||
return
|
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 {
|
if allowed, retry := s.publicQueryLimiter.allow("ip:" + publicQueryClientKey(r)); !allowed {
|
||||||
writePublicQueryRateLimit(w, retry)
|
writePublicQueryRateLimit(w, retry)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/public/projects", s.publicProjects)
|
mux.HandleFunc("GET /api/public/projects", s.publicProjects)
|
||||||
mux.HandleFunc("POST /api/public/projects/{id}/tickets", s.publicCreateTicket)
|
mux.HandleFunc("POST /api/public/projects/{id}/tickets", s.publicCreateTicket)
|
||||||
mux.HandleFunc("POST /api/public/status/search", s.publicStatusByPhone)
|
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.HandleFunc("GET /api/display/{token}/snapshot", s.displaySnapshot)
|
||||||
mux.Handle("GET /api/events", s.requireStaff(http.HandlerFunc(s.events)))
|
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/overview", s.requireAdmin(http.HandlerFunc(s.adminOverview)))
|
||||||
|
|||||||
Reference in New Issue
Block a user