feat: complete cmpp gateway delivery recovery workflows

This commit is contained in:
hectorzhao
2026-07-08 16:30:06 +08:00
parent cc628d0214
commit 8144f08652
60 changed files with 8901 additions and 94 deletions
+150 -3
View File
@@ -9,6 +9,10 @@ import (
"strings"
"time"
"cmpp-platform/gateway/internal/inbound"
"cmpp-platform/gateway/internal/queue"
"cmpp-platform/gateway/internal/upstream"
cmpp "github.com/bigwhite/gocmpp"
)
@@ -53,9 +57,17 @@ type ConnectionStateCallback struct {
}
type Server struct {
APIBaseURL string
HTTPClient *http.Client
Dial DialFunc
APIBaseURL string
HTTPClient *http.Client
Dial DialFunc
Upstream *upstream.Manager
RecoveryCandidates func(context.Context) ([]inbound.DownstreamPresence, error)
RecoveryStatuses func(context.Context) ([]inbound.DownstreamRecoveryStatus, error)
}
type DownstreamRecoveryOverview struct {
Candidates []inbound.DownstreamPresence `json:"candidates"`
Statuses []inbound.DownstreamRecoveryStatus `json:"statuses"`
}
func Register(mux *http.ServeMux, server Server) {
@@ -65,7 +77,16 @@ func Register(mux *http.ServeMux, server Server) {
if server.Dial == nil {
server.Dial = DialCMPP
}
if server.Upstream == nil {
server.Upstream = &upstream.Manager{APIBaseURL: server.APIBaseURL, HTTPClient: server.HTTPClient}
}
mux.HandleFunc("/connections/connect", server.handleConnectChannel)
mux.HandleFunc("/upstream/submit", server.handleUpstreamSubmit)
mux.HandleFunc("/downstream/receipt", server.handleDownstreamReceipt)
mux.HandleFunc("/downstream/uplink", server.handleDownstreamUplink)
mux.HandleFunc("/downstream/recovery-candidates", server.handleDownstreamRecoveryCandidates)
mux.HandleFunc("/downstream/recovery-statuses", server.handleDownstreamRecoveryStatuses)
mux.HandleFunc("/downstream/recovery-overview", server.handleDownstreamRecoveryOverview)
}
func (s Server) handleConnectChannel(w http.ResponseWriter, r *http.Request) {
@@ -111,6 +132,132 @@ func (s Server) handleConnectChannel(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(status)
}
func (s Server) handleUpstreamSubmit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var command queue.SubmitCommand
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
http.Error(w, fmt.Sprintf("invalid submit command: %v", err), http.StatusBadRequest)
return
}
result, err := s.Upstream.Submit(r.Context(), command)
if err != nil {
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(result)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
}
func (s Server) handleDownstreamReceipt(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var event inbound.DownstreamReceipt
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
http.Error(w, fmt.Sprintf("invalid downstream receipt: %v", err), http.StatusBadRequest)
return
}
delivered, err := inbound.PushReceipt(event)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"delivered": delivered})
}
func (s Server) handleDownstreamUplink(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var event inbound.DownstreamUplink
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
http.Error(w, fmt.Sprintf("invalid downstream uplink: %v", err), http.StatusBadRequest)
return
}
delivered, err := inbound.PushUplink(event)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"delivered": delivered})
}
func (s Server) handleDownstreamRecoveryCandidates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.RecoveryCandidates == nil {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]inbound.DownstreamPresence{})
return
}
candidates, err := s.RecoveryCandidates(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to load recovery candidates: %v", err), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(candidates)
}
func (s Server) handleDownstreamRecoveryStatuses(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.RecoveryStatuses == nil {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]inbound.DownstreamRecoveryStatus{})
return
}
statuses, err := s.RecoveryStatuses(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to load recovery statuses: %v", err), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(statuses)
}
func (s Server) handleDownstreamRecoveryOverview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
candidates := []inbound.DownstreamPresence{}
if s.RecoveryCandidates != nil {
result, err := s.RecoveryCandidates(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to load recovery candidates: %v", err), http.StatusBadGateway)
return
}
candidates = result
}
statuses := []inbound.DownstreamRecoveryStatus{}
if s.RecoveryStatuses != nil {
result, err := s.RecoveryStatuses(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to load recovery statuses: %v", err), http.StatusBadGateway)
return
}
statuses = result
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(DownstreamRecoveryOverview{
Candidates: candidates,
Statuses: statuses,
})
}
func DialCMPP(ctx context.Context, command ConnectChannelCommand) error {
ctx, cancel := context.WithTimeout(ctx, defaultConnectTimeout)
defer cancel()
+101 -1
View File
@@ -7,6 +7,9 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"cmpp-platform/gateway/internal/inbound"
)
func TestConnectChannelCallbacksConnectedState(t *testing.T) {
@@ -84,6 +87,99 @@ func TestConnectChannelRejectsInvalidCommand(t *testing.T) {
}
}
func TestRecoveryCandidatesEndpointReturnsView(t *testing.T) {
handler := handlerWithServer(Server{
RecoveryCandidates: func(context.Context) ([]inbound.DownstreamPresence, error) {
return []inbound.DownstreamPresence{{
Account: "100001",
GatewayInstanceID: "gateway-a",
State: "connected",
UpdatedAt: time.Now().UTC(),
}}, nil
},
})
resp := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/downstream/recovery-candidates", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
}
var payload []inbound.DownstreamPresence
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload) != 1 || payload[0].Account != "100001" {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestRecoveryStatusesEndpointReturnsView(t *testing.T) {
handler := handlerWithServer(Server{
RecoveryStatuses: func(context.Context) ([]inbound.DownstreamRecoveryStatus, error) {
return []inbound.DownstreamRecoveryStatus{{
Account: "100001",
GatewayInstanceID: "gateway-a",
State: "waiting_connection",
AttemptCount: 2,
}}, nil
},
})
resp := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/downstream/recovery-statuses", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
}
var payload []inbound.DownstreamRecoveryStatus
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload) != 1 || payload[0].State != "waiting_connection" || payload[0].AttemptCount != 2 {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestRecoveryOverviewEndpointReturnsCombinedView(t *testing.T) {
handler := handlerWithServer(Server{
RecoveryCandidates: func(context.Context) ([]inbound.DownstreamPresence, error) {
return []inbound.DownstreamPresence{{
Account: "100001",
GatewayInstanceID: "gateway-a",
State: "connected",
}}, nil
},
RecoveryStatuses: func(context.Context) ([]inbound.DownstreamRecoveryStatus, error) {
return []inbound.DownstreamRecoveryStatus{{
Account: "100001",
GatewayInstanceID: "gateway-a",
State: "success",
}}, nil
},
})
resp := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/downstream/recovery-overview", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
}
var payload DownstreamRecoveryOverview
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Candidates) != 1 || payload.Candidates[0].Account != "100001" {
t.Fatalf("unexpected candidate payload: %+v", payload.Candidates)
}
if len(payload.Statuses) != 1 || payload.Statuses[0].State != "success" {
t.Fatalf("unexpected status payload: %+v", payload.Statuses)
}
}
type testDialError struct{}
func (testDialError) Error() string {
@@ -93,8 +189,12 @@ func (testDialError) Error() string {
var errTestDial testDialError
func handlerWithDial(apiBaseURL string, dial DialFunc) http.Handler {
return handlerWithServer(Server{APIBaseURL: apiBaseURL, Dial: dial})
}
func handlerWithServer(server Server) http.Handler {
mux := http.NewServeMux()
Register(mux, Server{APIBaseURL: apiBaseURL, Dial: dial})
Register(mux, server)
return mux
}