fix: harden real backend workflows and channel connections

This commit is contained in:
hectorzhao
2026-07-06 17:54:53 +08:00
parent 8cca361441
commit b5132d7f4e
47 changed files with 2530 additions and 314 deletions
+186
View File
@@ -0,0 +1,186 @@
package control
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
cmpp "github.com/bigwhite/gocmpp"
)
const defaultConnectTimeout = 5 * time.Second
type DialFunc func(context.Context, ConnectChannelCommand) error
type ConnectChannelCommand struct {
SchemaVersion string `json:"schemaVersion"`
MessageType string `json:"messageType"`
TraceID string `json:"traceId"`
ChannelID string `json:"channelId"`
ConnectionID string `json:"connectionId"`
Reason string `json:"reason"`
DesiredConnections int `json:"desiredConnections"`
Channel ChannelConfig `json:"channel"`
}
type ChannelConfig struct {
Code string `json:"code"`
Name string `json:"name"`
GatewayHost string `json:"gatewayHost"`
GatewayPort int `json:"gatewayPort"`
Account string `json:"account"`
PasswordCipher string `json:"passwordCipher"`
SrcID string `json:"srcId"`
CMPPVersion string `json:"cmppVersion"`
RateLimitPerSecond int `json:"rateLimitPerSecond"`
}
type ConnectionStateCallback struct {
ChannelID string `json:"channelId"`
ConnectionID string `json:"connectionId"`
Status string `json:"status"`
DesiredConnections int `json:"desiredConnections"`
CurrentConnections int `json:"currentConnections"`
LastConnectedAt string `json:"lastConnectedAt,omitempty"`
LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"`
LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"`
ReconnectCount int `json:"reconnectCount,omitempty"`
LastError string `json:"lastError,omitempty"`
}
type Server struct {
APIBaseURL string
HTTPClient *http.Client
Dial DialFunc
}
func Register(mux *http.ServeMux, server Server) {
if server.HTTPClient == nil {
server.HTTPClient = &http.Client{Timeout: 10 * time.Second}
}
if server.Dial == nil {
server.Dial = DialCMPP
}
mux.HandleFunc("/connections/connect", server.handleConnectChannel)
}
func (s Server) handleConnectChannel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var command ConnectChannelCommand
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
http.Error(w, fmt.Sprintf("invalid connect command: %v", err), http.StatusBadRequest)
return
}
if err := validateConnectChannelCommand(command); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
status := ConnectionStateCallback{
ChannelID: command.ChannelID,
ConnectionID: command.ConnectionID,
DesiredConnections: desiredConnections(command.DesiredConnections),
}
if err := s.Dial(r.Context(), command); err != nil {
status.Status = "failed"
status.CurrentConnections = 0
status.LastDisconnectedAt = time.Now().UTC().Format(time.RFC3339Nano)
status.LastError = err.Error()
} else {
now := time.Now().UTC().Format(time.RFC3339Nano)
status.Status = "connected"
status.CurrentConnections = status.DesiredConnections
status.LastConnectedAt = now
status.LastHeartbeatAt = now
}
if err := s.postConnectionState(r.Context(), status); err != nil {
http.Error(w, fmt.Sprintf("failed to callback api: %v", err), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(status)
}
func DialCMPP(ctx context.Context, command ConnectChannelCommand) error {
ctx, cancel := context.WithTimeout(ctx, defaultConnectTimeout)
defer cancel()
version := cmpp.V30
if strings.HasPrefix(command.Channel.CMPPVersion, "2") {
version = cmpp.V20
}
client := cmpp.NewClient(version)
defer client.Disconnect()
done := make(chan error, 1)
go func() {
addr := fmt.Sprintf("%s:%d", command.Channel.GatewayHost, command.Channel.GatewayPort)
done <- client.Connect(addr, command.Channel.Account, command.Channel.PasswordCipher, defaultConnectTimeout)
}()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-done:
return err
}
}
func (s Server) postConnectionState(ctx context.Context, state ConnectionStateCallback) error {
apiBaseURL := strings.TrimRight(s.APIBaseURL, "/")
if apiBaseURL == "" {
apiBaseURL = "http://127.0.0.1:3000/api"
}
payload, err := json.Marshal(state)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL+"/admin/gateway/connections", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("api returned %s", resp.Status)
}
return nil
}
func validateConnectChannelCommand(command ConnectChannelCommand) error {
if command.MessageType != "ConnectChannel" {
return fmt.Errorf("unsupported messageType %q", command.MessageType)
}
if command.ChannelID == "" || command.ConnectionID == "" {
return fmt.Errorf("channelId and connectionId are required")
}
if command.Channel.GatewayHost == "" || command.Channel.GatewayPort <= 0 {
return fmt.Errorf("gatewayHost and gatewayPort are required")
}
if command.Channel.Account == "" || command.Channel.PasswordCipher == "" {
return fmt.Errorf("account and passwordCipher are required")
}
return nil
}
func desiredConnections(value int) int {
if value > 0 {
return value
}
return 1
}
+122
View File
@@ -0,0 +1,122 @@
package control
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestConnectChannelCallbacksConnectedState(t *testing.T) {
var callback ConnectionStateCallback
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/admin/gateway/connections" {
t.Fatalf("unexpected callback path: %s", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
t.Fatalf("decode callback: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer api.Close()
handler := handlerWithDial(api.URL+"/api", func(context.Context, ConnectChannelCommand) error {
return nil
})
resp := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/connections/connect", strings.NewReader(validConnectCommand()))
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
}
if callback.Status != "connected" || callback.CurrentConnections != 2 || callback.DesiredConnections != 2 {
t.Fatalf("unexpected callback state: %+v", callback)
}
if callback.ChannelID != "channel-1" || callback.ConnectionID != "channel-1:primary" {
t.Fatalf("unexpected callback identity: %+v", callback)
}
if callback.LastConnectedAt == "" || callback.LastHeartbeatAt == "" {
t.Fatalf("expected connection timestamps: %+v", callback)
}
}
func TestConnectChannelCallbacksFailedState(t *testing.T) {
var callback ConnectionStateCallback
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
t.Fatalf("decode callback: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer api.Close()
handler := handlerWithDial(api.URL+"/api", func(context.Context, ConnectChannelCommand) error {
return errTestDial
})
resp := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/connections/connect", strings.NewReader(validConnectCommand()))
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
}
if callback.Status != "failed" || callback.CurrentConnections != 0 || callback.LastError == "" {
t.Fatalf("unexpected callback state: %+v", callback)
}
}
func TestConnectChannelRejectsInvalidCommand(t *testing.T) {
handler := handlerWithDial("", func(context.Context, ConnectChannelCommand) error {
return nil
})
resp := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/connections/connect", strings.NewReader(`{"messageType":"SubmitCommand"}`))
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusBadRequest {
t.Fatalf("unexpected response status: %d", resp.Code)
}
}
type testDialError struct{}
func (testDialError) Error() string {
return "dial failed"
}
var errTestDial testDialError
func handlerWithDial(apiBaseURL string, dial DialFunc) http.Handler {
mux := http.NewServeMux()
Register(mux, Server{APIBaseURL: apiBaseURL, Dial: dial})
return mux
}
func validConnectCommand() string {
return `{
"schemaVersion": "v1",
"messageType": "ConnectChannel",
"traceId": "trace-1",
"channelId": "channel-1",
"connectionId": "channel-1:primary",
"reason": "channel_created",
"desiredConnections": 2,
"channel": {
"code": "CMPP-A",
"name": "主通道",
"gatewayHost": "127.0.0.1",
"gatewayPort": 17890,
"account": "sp",
"passwordCipher": "secret",
"srcId": "10690000",
"cmppVersion": "3.0",
"rateLimitPerSecond": 100
}
}`
}